From e7570c52e68cef8f8bcce05bfcf77b74b2aa5042 Mon Sep 17 00:00:00 2001 From: stephensanchez <steve@edx.org> Date: Wed, 22 Oct 2014 18:08:37 +0000 Subject: [PATCH] adding all the tests for data and api. Updating tests for views Last chunk of tests and pep8 cleanup Code Review cleanup. Additional CR comments Changing serialization of suggested prices. --- common/djangoapps/enrollment/api.py | 317 +++++++++++++----- common/djangoapps/enrollment/data.py | 67 +++- common/djangoapps/enrollment/serializers.py | 42 ++- .../enrollment/tests/fake_data_api.py | 96 ++++++ .../djangoapps/enrollment/tests/test_api.py | 151 +++++++++ .../djangoapps/enrollment/tests/test_data.py | 172 ++++++++++ .../djangoapps/enrollment/tests/test_views.py | 152 +++++++++ common/djangoapps/enrollment/views.py | 54 ++- 8 files changed, 950 insertions(+), 101 deletions(-) create mode 100644 common/djangoapps/enrollment/tests/fake_data_api.py create mode 100644 common/djangoapps/enrollment/tests/test_api.py create mode 100644 common/djangoapps/enrollment/tests/test_data.py create mode 100644 common/djangoapps/enrollment/tests/test_views.py diff --git a/common/djangoapps/enrollment/api.py b/common/djangoapps/enrollment/api.py index 0c65f4de66e..ea76e032a88 100644 --- a/common/djangoapps/enrollment/api.py +++ b/common/djangoapps/enrollment/api.py @@ -3,7 +3,11 @@ Enrollment API for creating, updating, and deleting enrollments. Also provides a course level, such as available course modes. """ -from enrollment import data +from django.utils import importlib +import logging +from django.conf import settings + +log = logging.getLogger(__name__) class CourseEnrollmentError(Exception): @@ -12,8 +16,25 @@ class CourseEnrollmentError(Exception): Describes any error that may occur when reading or updating enrollment information for a student or a course. """ + def __init__(self, msg, data=None): + super(Exception, self).__init__(msg) + # Corresponding information to help resolve the error. + self.data = data + + +class CourseModeNotFoundError(CourseEnrollmentError): + pass + + +class EnrollmentNotFoundError(CourseEnrollmentError): + pass + + +class EnrollmentApiLoadError(CourseEnrollmentError): pass +DEFAULT_DATA_API = 'enrollment.data' + def get_enrollments(student_id): """ Retrieves all the courses a student is enrolled in. @@ -31,36 +52,58 @@ def get_enrollments(student_id): >>> get_enrollments("Bob") [ { - course_id: "edX/DemoX/2014T2", - is_active: True, - mode: "honor", - student: "Bob", - course_modes: [ - "audit", - "honor" - ], - enrollment_start: 2014-04-07, - enrollment_end: 2014-06-07, - invite_only: False + "created": "2014-10-20T20:18:00Z", + "mode": "honor", + "is_active": True, + "student": "Bob", + "course": { + "course_id": "edX/DemoX/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } + ], + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": False + } }, { - course_id: "edX/edX-Insider/2014T2", - is_active: True, - mode: "honor", - student: "Bob", - course_modes: [ - "audit", - "honor", - "verified" - ], - enrollment_start: 2014-05-01, - enrollment_end: 2014-06-01, - invite_only: True - }, + "created": "2014-10-25T20:18:00Z", + "mode": "verified", + "is_active": True, + "student": "Bob", + "course": { + "course_id": "edX/edX-Insider/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } + ], + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": True + } + } ] """ - return data.get_course_enrollments(student_id) + enrollments = _data_api().get_course_enrollments(student_id) + for enrollment in enrollments: + enrollment['student'] = student_id + return enrollments def get_enrollment(student_id, course_id): @@ -76,23 +119,35 @@ def get_enrollment(student_id, course_id): A serializable dictionary of the course enrollment. Example: - >>> add_enrollment("Bob", "edX/DemoX/2014T2") + >>> get_enrollment("Bob", "edX/DemoX/2014T2") { - course_id: "edX/DemoX/2014T2", - is_active: True, - mode: "honor", - student: "Bob", - course_modes: [ - "audit", - "honor" - ], - enrollment_start: 2014-04-07, - enrollment_end: 2014-06-07, - invite_only: False + "created": "2014-10-20T20:18:00Z", + "mode": "honor", + "is_active": True, + "student": "Bob", + "course": { + "course_id": "edX/DemoX/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } + ], + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": False + } } """ - return data.get_course_enrollment(student_id, course_id) + enrollment = _data_api().get_course_enrollment(student_id, course_id) + enrollment['student'] = student_id + return enrollment def add_enrollment(student_id, course_id, mode='honor', is_active=True): @@ -114,20 +169,33 @@ def add_enrollment(student_id, course_id, mode='honor', is_active=True): Example: >>> add_enrollment("Bob", "edX/DemoX/2014T2", mode="audit") { - course_id: "edX/DemoX/2014T2", - is_active: True, - mode: "audit", - student: "Bob", - course_modes: [ - "audit", - "honor" - ], - enrollment_start: 2014-04-07, - enrollment_end: 2014-06-07, - invite_only: False + "created": "2014-10-20T20:18:00Z", + "mode": "honor", + "is_active": True, + "student": "Bob", + "course": { + "course_id": "edX/DemoX/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } + ], + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": False + } } """ - return data.update_course_enrollment(student_id, course_id, mode=mode, is_active=is_active) + _validate_course_mode(course_id, mode) + enrollment = _data_api().update_course_enrollment(student_id, course_id, mode=mode, is_active=is_active) + enrollment['student'] = student_id + return enrollment def deactivate_enrollment(student_id, course_id): @@ -146,20 +214,38 @@ def deactivate_enrollment(student_id, course_id): Example: >>> deactivate_enrollment("Bob", "edX/DemoX/2014T2") { - course_id: "edX/DemoX/2014T2", - mode: "honor", - is_active: False, - student: "Bob", - course_modes: [ - "audit", - "honor" - ], - enrollment_start: 2014-04-07, - enrollment_end: 2014-06-07, - invite_only: False + "created": "2014-10-20T20:18:00Z", + "mode": "honor", + "is_active": False, + "student": "Bob", + "course": { + "course_id": "edX/DemoX/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } + ], + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": False + } } """ - return data.update_course_enrollment(student_id, course_id, is_active=False) + # Check to see if there is an enrollment. We do not want to create a deactivated enrollment. + if not _data_api().get_course_enrollment(student_id, course_id): + raise EnrollmentNotFoundError( + u"No enrollment was found for student {student} in course {course}" + .format(student=student_id, course=course_id) + ) + enrollment = _data_api().update_course_enrollment(student_id, course_id, is_active=False) + enrollment['student'] = student_id + return enrollment def update_enrollment(student_id, course_id, mode): @@ -178,21 +264,34 @@ def update_enrollment(student_id, course_id, mode): Example: >>> update_enrollment("Bob", "edX/DemoX/2014T2", "honor") { - course_id: "edX/DemoX/2014T2", - mode: "honor", - is_active: True, - student: "Bob", - course_modes: [ - "audit", - "honor" - ], - enrollment_start: 2014-04-07, - enrollment_end: 2014-06-07, - invite_only: False + "created": "2014-10-20T20:18:00Z", + "mode": "honor", + "is_active": True, + "student": "Bob", + "course": { + "course_id": "edX/DemoX/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } + ], + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": False + } } """ - return data.update_course_enrollment(student_id, course_id, mode) + _validate_course_mode(course_id, mode) + enrollment = _data_api().update_course_enrollment(student_id, course_id, mode) + enrollment['student'] = student_id + return enrollment def get_course_enrollment_details(course_id): @@ -209,15 +308,67 @@ def get_course_enrollment_details(course_id): Example: >>> get_course_enrollment_details("edX/DemoX/2014T2") { - course_id: "edX/DemoX/2014T2", - course_modes: [ - "audit", - "honor" + "course_id": "edX/DemoX/2014T2", + "enrollment_end": 2014-12-20T20:18:00Z, + "course_modes": [ + { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": null, + "description": null + } ], - enrollment_start: 2014-04-01, - enrollment_end: 2014-06-01, - invite_only: False + "enrollment_start": 2014-10-15T20:18:00Z, + "invite_only": False } """ - pass + return _data_api().get_course_enrollment_info(course_id) + + +def _validate_course_mode(course_id, mode): + """Checks to see if the specified course mode is valid for the course. + + If the requested course mode is not available for the course, raise an error with corresponding + course enrollment information. + + 'honor' is special cased. If there are no course modes configured, and the specified mode is + 'honor', return true, allowing the enrollment to be 'honor' even if the mode is not explicitly + set for the course. + + Args: + course_id (str): The course to check against for available course modes. + mode (str): The slug for the course mode specified in the enrollment. + + Returns: + None + + Raises: + CourseModeNotFound: raised if the course mode is not found. + """ + course_enrollment_info = _data_api().get_course_enrollment_info(course_id) + course_modes = course_enrollment_info["course_modes"] + if mode not in (mode['slug'] for mode in course_modes): + msg = u"Specified course mode unavailable for course {course_id}".format(course_id=course_id) + log.warn(msg) + error = CourseModeNotFoundError(msg, course_enrollment_info) + raise error + + +def _data_api(): + """Returns a Data API. + This relies on Django settings to find the appropriate data API. + + """ + # We retrieve the settings in-line here (rather than using the + # top-level constant), so that @override_settings will work + # in the test suite. + api_path = getattr(settings, "ENROLLMENT_DATA_API", DEFAULT_DATA_API) + + try: + return importlib.import_module(api_path) + except (ImportError, ValueError): + raise EnrollmentApiLoadError(api_path) diff --git a/common/djangoapps/enrollment/data.py b/common/djangoapps/enrollment/data.py index b6686f6731a..f72c5fcb016 100644 --- a/common/djangoapps/enrollment/data.py +++ b/common/djangoapps/enrollment/data.py @@ -3,13 +3,29 @@ Data Aggregation Layer of the Enrollment API. Collects all enrollment specific d source to be used throughout the API. """ +import logging from django.contrib.auth.models import User from opaque_keys.edx.keys import CourseKey -from enrollment.serializers import CourseEnrollmentSerializer -from student.models import CourseEnrollment +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError +from enrollment.serializers import CourseEnrollmentSerializer, CourseField +from student.models import CourseEnrollment, NonExistentCourseError + +log = logging.getLogger(__name__) def get_course_enrollments(student_id): + """Retrieve a list representing all aggregated data for a student's course enrollments. + + Construct a representation of all course enrollment data for a specific student.. + + Args: + student_id (str): The name of the student to retrieve course enrollment information for. + + Returns: + A serializable list of dictionaries of all aggregated enrollment data for a student. + + """ qset = CourseEnrollment.objects.filter( user__username=student_id, is_active=True ).order_by('created') @@ -17,6 +33,18 @@ def get_course_enrollments(student_id): def get_course_enrollment(student_id, course_id): + """Retrieve an object representing all aggregated data for a student's course enrollment. + + Get the course enrollment information for a specific student and course. + + Args: + student_id (str): The name of the student to retrieve course enrollment information for. + course_id (str): The course to retrieve course enrollment information for. + + Returns: + A serializable dictionary representing the course enrollment. + + """ course_key = CourseKey.from_string(course_id) try: enrollment = CourseEnrollment.objects.get( @@ -28,6 +56,20 @@ def get_course_enrollment(student_id, course_id): def update_course_enrollment(student_id, course_id, mode=None, is_active=None): + """Modify a course enrollment for a student. + + Allows updates to a specific course enrollment. + + Args: + student_id (str): The name of the student to retrieve course enrollment information for. + course_id (str): The course to retrieve course enrollment information for. + mode (str): (Optional) The mode for the new enrollment. + is_active (boolean): (Optional) Determines if the enrollment is active. + + Returns: + A serializable dictionary representing the modified course enrollment. + + """ course_key = CourseKey.from_string(course_id) student = User.objects.get(username=student_id) if not CourseEnrollment.is_enrolled(student, course_key): @@ -41,8 +83,23 @@ def update_course_enrollment(student_id, course_id, mode=None, is_active=None): def get_course_enrollment_info(course_id): - pass + """Returns all course enrollment information for the given course. + Based on the course id, return all related course information.. -def get_course_enrollments_info(student_id): - pass + Args: + course_id (str): The course to retrieve enrollment information for. + + Returns: + A serializable dictionary representing the course's enrollment information. + + """ + course_key = CourseKey.from_string(course_id) + course = modulestore().get_course(course_key) + if course is None: + log.warning( + u"Requested enrollment information for unknown course {course}" + .format(course=course_id) + ) + raise NonExistentCourseError + return CourseField().to_native(course) diff --git a/common/djangoapps/enrollment/serializers.py b/common/djangoapps/enrollment/serializers.py index 324c03b0582..d01deb0023d 100644 --- a/common/djangoapps/enrollment/serializers.py +++ b/common/djangoapps/enrollment/serializers.py @@ -3,12 +3,36 @@ Serializers for all Course Enrollment related return objects. """ from rest_framework import serializers +from rest_framework.fields import Field from student.models import CourseEnrollment from course_modes.models import CourseMode +class StringListField(serializers.CharField): + """Custom Serializer for turning a comma delimited string into a list. + + This field is designed to take a string such as "1,2,3" and turn it into an actual list + [1,2,3] + + """ + def field_to_native(self, obj, field_name): + """ + Serialize the object's class name. + """ + if not obj.suggested_prices: + return [] + + items = obj.suggested_prices.split(',') + return [int(item) for item in items] + + class CourseField(serializers.RelatedField): - """Custom field to wrap a CourseDescriptor object. Read-only.""" + """Read-Only representation of course enrollment information. + + Aggregates course information from the CourseDescriptor as well as the Course Modes configured + for enrolling in the course. + + """ def to_native(self, course): course_id = unicode(course.id) @@ -24,8 +48,10 @@ class CourseField(serializers.RelatedField): class CourseEnrollmentSerializer(serializers.ModelSerializer): - """ - Serializes CourseEnrollment models + """Serializes CourseEnrollment models + + Aggregates all data from the Course Enrollment table, and pulls in the serialization for + the Course Descriptor and course modes, to give a complete representation of course enrollment. """ course = CourseField() @@ -37,11 +63,17 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): class ModeSerializer(serializers.Serializer): - """Serializes a course's 'Mode' tuples""" + """Serializes a course's 'Mode' tuples + + Returns a serialized representation of the modes available for course enrollment. The course + modes models are designed to return a tuple instead of the model object itself. This serializer + does not handle the model object itself, but the tuple. + + """ slug = serializers.CharField(max_length=100) name = serializers.CharField(max_length=255) min_price = serializers.IntegerField() - suggested_prices = serializers.CharField(max_length=255) + suggested_prices = StringListField(max_length=255) currency = serializers.CharField(max_length=8) expiration_datetime = serializers.DateTimeField() description = serializers.CharField() diff --git a/common/djangoapps/enrollment/tests/fake_data_api.py b/common/djangoapps/enrollment/tests/fake_data_api.py new file mode 100644 index 00000000000..d1374c3fefb --- /dev/null +++ b/common/djangoapps/enrollment/tests/fake_data_api.py @@ -0,0 +1,96 @@ +""" +A Fake Data API for testing purposes. +""" +import copy +import datetime + + +_DEFAULT_FAKE_MODE = { + "slug": "honor", + "name": "Honor Code Certificate", + "min_price": 0, + "suggested_prices": "", + "currency": "usd", + "expiration_datetime": None, + "description": None +} + +_ENROLLMENTS = [] + +_COURSES = [] + + +def get_course_enrollments(student_id): + """Stubbed out Enrollment data request.""" + return _ENROLLMENTS + + +def get_course_enrollment(student_id, course_id): + """Stubbed out Enrollment data request.""" + return _get_fake_enrollment(student_id, course_id) + + +def update_course_enrollment(student_id, course_id, mode=None, is_active=None): + """Stubbed out Enrollment data request.""" + enrollment = _get_fake_enrollment(student_id, course_id) + if not enrollment: + enrollment = add_enrollment(student_id, course_id) + if mode is not None: + enrollment['mode'] = mode + if is_active is not None: + enrollment['is_active'] = is_active + return enrollment + + +def get_course_enrollment_info(course_id): + """Stubbed out Enrollment data request.""" + return _get_fake_course_info(course_id) + + +def _get_fake_enrollment(student_id, course_id): + for enrollment in _ENROLLMENTS: + if student_id == enrollment['student'] and course_id == enrollment['course']['course_id']: + return enrollment + + +def _get_fake_course_info(course_id): + for course in _COURSES: + if course_id == course['course_id']: + return course + + +def add_enrollment(student_id, course_id, is_active=True, mode='honor'): + enrollment = { + "created": datetime.datetime.now(), + "mode": mode, + "is_active": is_active, + "course": _get_fake_course_info(course_id), + "student": student_id + } + _ENROLLMENTS.append(enrollment) + return enrollment + + +def add_course(course_id, enrollment_start=None, enrollment_end=None, invite_only=False, course_modes=None): + course_info = { + "course_id": course_id, + "enrollment_end": enrollment_end, + "course_modes": [], + "enrollment_start": enrollment_start, + "invite_only": invite_only, + } + if not course_modes: + course_info['course_modes'].append(_DEFAULT_FAKE_MODE) + else: + for mode in course_modes: + new_mode = copy.deepcopy(_DEFAULT_FAKE_MODE) + new_mode['slug'] = mode + course_info['course_modes'].append(new_mode) + _COURSES.append(course_info) + + +def reset(): + global _COURSES + _COURSES = [] + global _ENROLLMENTS + _ENROLLMENTS = [] diff --git a/common/djangoapps/enrollment/tests/test_api.py b/common/djangoapps/enrollment/tests/test_api.py new file mode 100644 index 00000000000..fe996bd6dc2 --- /dev/null +++ b/common/djangoapps/enrollment/tests/test_api.py @@ -0,0 +1,151 @@ +""" +Tests for student enrollment. +""" +import ddt +from nose.tools import raises +import unittest +from django.test import TestCase +from django.test.utils import override_settings +from django.conf import settings +from enrollment import api +from enrollment.tests import fake_data_api + + +@ddt.ddt +@override_settings(ENROLLMENT_DATA_API="enrollment.tests.fake_data_api") +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class EnrollmentTest(TestCase): + """ + Test student enrollment, especially with different course modes. + """ + USERNAME = "Bob" + COURSE_ID = "some/great/course" + + def setUp(self): + fake_data_api.reset() + + @ddt.data( + # Default (no course modes in the database) + # Expect automatically being enrolled as "honor". + ([], 'honor'), + + # Audit / Verified / Honor + # We should always go to the "choose your course" page. + # We should also be enrolled as "honor" by default. + (['honor', 'verified', 'audit'], 'honor'), + + # Check for professional ed happy path. + (['professional'], 'professional') + ) + @ddt.unpack + def test_enroll(self, course_modes, mode): + # Add a fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=course_modes) + # Enroll in the course and verify the URL we get sent to + result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode=mode) + self.assertIsNotNone(result) + self.assertEquals(result['student'], self.USERNAME) + self.assertEquals(result['course']['course_id'], self.COURSE_ID) + self.assertEquals(result['mode'], mode) + + get_result = api.get_enrollment(self.USERNAME, self.COURSE_ID) + self.assertEquals(result, get_result) + + @raises(api.CourseModeNotFoundError) + def test_prof_ed_enroll(self): + # Add a fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=['professional']) + # Enroll in the course and verify the URL we get sent to + api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='verified') + + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as "honor". + ([], 'honor'), + + # Audit / Verified / Honor + # We should always go to the "choose your course" page. + # We should also be enrolled as "honor" by default. + (['honor', 'verified', 'audit'], 'honor'), + + # Check for professional ed happy path. + (['professional'], 'professional') + ) + @ddt.unpack + def test_unenroll(self, course_modes, mode): + # Add a fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=course_modes) + # Enroll in the course and verify the URL we get sent to + result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode=mode) + self.assertIsNotNone(result) + self.assertEquals(result['student'], self.USERNAME) + self.assertEquals(result['course']['course_id'], self.COURSE_ID) + self.assertEquals(result['mode'], mode) + self.assertTrue(result['is_active']) + + result = api.deactivate_enrollment(self.USERNAME, self.COURSE_ID) + self.assertIsNotNone(result) + self.assertEquals(result['student'], self.USERNAME) + self.assertEquals(result['course']['course_id'], self.COURSE_ID) + self.assertEquals(result['mode'], mode) + self.assertFalse(result['is_active']) + + @raises(api.EnrollmentNotFoundError) + def test_unenroll_not_enrolled_in_course(self): + # Add a fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=['honor']) + api.deactivate_enrollment(self.USERNAME, self.COURSE_ID) + + @ddt.data( + # Simple test of honor and verified. + ([ + {'course_id': 'the/first/course', 'course_modes': [], 'mode': 'honor'}, + {'course_id': 'the/second/course', 'course_modes': ['honor', 'verified'], 'mode': 'verified'} + ]), + + # No enrollments + ([]), + + # One Enrollment + ([ + {'course_id': 'the/third/course', 'course_modes': ['honor', 'verified', 'audit'], 'mode': 'audit'} + ]), + ) + def test_get_all_enrollments(self, enrollments): + for enrollment in enrollments: + fake_data_api.add_course(enrollment['course_id'], course_modes=enrollment['course_modes']) + api.add_enrollment(self.USERNAME, enrollment['course_id'], enrollment['mode']) + result = api.get_enrollments(self.USERNAME) + self.assertEqual(len(enrollments), len(result)) + for result_enrollment in result: + self.assertIn( + result_enrollment['course']['course_id'], + [enrollment['course_id'] for enrollment in enrollments] + ) + + def test_update_enrollment(self): + # Add a fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit']) + # Enroll in the course and verify the URL we get sent to + result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='audit') + get_result = api.get_enrollment(self.USERNAME, self.COURSE_ID) + self.assertEquals(result, get_result) + + result = api.update_enrollment(self.USERNAME, self.COURSE_ID, mode='honor') + self.assertEquals('honor', result['mode']) + + result = api.update_enrollment(self.USERNAME, self.COURSE_ID, mode='verified') + self.assertEquals('verified', result['mode']) + + def test_get_course_details(self): + # Add a fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit']) + result = api.get_course_enrollment_details(self.COURSE_ID) + self.assertEquals(result['course_id'], self.COURSE_ID) + self.assertEquals(3, len(result['course_modes'])) + + @override_settings(ENROLLMENT_DATA_API='foo.bar.biz.baz') + @raises(api.EnrollmentApiLoadError) + def test_data_api_config_error(self): + # Enroll in the course and verify the URL we get sent to + api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='audit') diff --git a/common/djangoapps/enrollment/tests/test_data.py b/common/djangoapps/enrollment/tests/test_data.py new file mode 100644 index 00000000000..9655ecb00da --- /dev/null +++ b/common/djangoapps/enrollment/tests/test_data.py @@ -0,0 +1,172 @@ +""" +Test the Data Aggregation Layer for Course Enrollments. + +""" +import ddt +from nose.tools import raises +import unittest + +from django.test.utils import override_settings +from django.conf import settings +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, mixed_store_config +) +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, CourseModeFactory +from student.models import CourseEnrollment, NonExistentCourseError +from enrollment import data + +# Since we don't need any XML course fixtures, use a modulestore configuration +# that disables the XML modulestore. +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + + +@ddt.ddt +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class EnrollmentDataTest(ModuleStoreTestCase): + """ + Test course enrollment data aggregation. + + """ + USERNAME = "Bob" + EMAIL = "bob@example.com" + PASSWORD = "edx" + + def setUp(self): + """ Create a course and user, then log in. """ + super(EnrollmentDataTest, self).setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as "honor". + ([], 'honor'), + + # Audit / Verified / Honor + # We should always go to the "choose your course" page. + # We should also be enrolled as "honor" by default. + (['honor', 'verified', 'audit'], 'honor'), + ) + @ddt.unpack + def test_enroll(self, course_modes, enrollment_mode): + # Create the course modes (if any) required for this test case + self._create_course_modes(course_modes) + + enrollment = data.update_course_enrollment( + self.user.username, + unicode(self.course.id), + mode=enrollment_mode, + is_active=True + ) + + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, enrollment_mode) + + # Confirm the returned enrollment and the data match up. + self.assertEqual(course_mode, enrollment['mode']) + self.assertEqual(is_active, enrollment['is_active']) + + def test_unenroll(self): + # Enroll the student in the course + CourseEnrollment.enroll(self.user, self.course.id, mode="honor") + + enrollment = data.update_course_enrollment( + self.user.username, + unicode(self.course.id), + is_active=False + ) + + # Determine that the returned enrollment is inactive. + self.assertFalse(enrollment['is_active']) + + # Expect that we're no longer enrolled + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) + + @ddt.data( + # No course modes, no course enrollments. + ([]), + + # Audit / Verified / Honor course modes, with three course enrollments. + (['honor', 'verified', 'audit']), + ) + def test_get_course_info(self, course_modes): + self._create_course_modes(course_modes, course=self.course) + result_course = data.get_course_enrollment_info(unicode(self.course.id)) + result_slugs = [mode['slug'] for mode in result_course['course_modes']] + for course_mode in course_modes: + self.assertIn(course_mode, result_slugs) + + @ddt.data( + # No course modes, no course enrollments. + ([], []), + + # Audit / Verified / Honor course modes, with three course enrollments. + (['honor', 'verified', 'audit'], ['1', '2', '3']), + ) + @ddt.unpack + def test_get_course_enrollments(self, course_modes, course_numbers): + # Create all the courses + created_courses = [] + for course_number in course_numbers: + created_courses.append(CourseFactory.create(number=course_number)) + + created_enrollments = [] + for course in created_courses: + self._create_course_modes(course_modes, course=course) + # Create the original enrollment. + created_enrollments.append(data.update_course_enrollment( + self.user.username, + unicode(course.id), + )) + + # Compare the created enrollments with the results + # from the get enrollments request. + results = data.get_course_enrollments(self.user.username) + self.assertEqual(results, created_enrollments) + + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as "honor". + ([], 'honor'), + + # Audit / Verified / Honor + # We should always go to the "choose your course" page. + # We should also be enrolled as "honor" by default. + (['honor', 'verified', 'audit'], 'verified'), + ) + @ddt.unpack + def test_get_course_enrollment(self, course_modes, enrollment_mode): + self._create_course_modes(course_modes) + + # Try to get an enrollment before it exists. + result = data.get_course_enrollment(self.user.username, unicode(self.course.id)) + self.assertIsNone(result) + + # Create the original enrollment. + enrollment = data.update_course_enrollment( + self.user.username, + unicode(self.course.id), + mode=enrollment_mode, + is_active=True + ) + # Get the enrollment and compare it to the original. + result = data.get_course_enrollment(self.user.username, unicode(self.course.id)) + self.assertEqual(enrollment, result) + + @raises(NonExistentCourseError) + def test_non_existent_course(self): + data.get_course_enrollment_info("this/is/bananas") + + def _create_course_modes(self, course_modes, course=None): + course_id = course.id if course else self.course.id + for mode_slug in course_modes: + CourseModeFactory.create( + course_id=course_id, + mode_slug=mode_slug, + mode_display_name=mode_slug, + ) diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py new file mode 100644 index 00000000000..5ed71806376 --- /dev/null +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -0,0 +1,152 @@ +""" +Tests for student enrollment. +""" +import ddt +import json +import unittest + +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from rest_framework.test import APITestCase +from rest_framework import status +from django.conf import settings +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, mixed_store_config +) +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, CourseModeFactory +from student.models import CourseEnrollment + +# Since we don't need any XML course fixtures, use a modulestore configuration +# that disables the XML modulestore. +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + + +@ddt.ddt +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class EnrollmentTest(ModuleStoreTestCase, APITestCase): + """ + Test student enrollment, especially with different course modes. + """ + USERNAME = "Bob" + EMAIL = "bob@example.com" + PASSWORD = "edx" + + def setUp(self): + """ Create a course and user, then log in. """ + super(EnrollmentTest, self).setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as "honor". + ([], 'honor'), + + # Audit / Verified / Honor + # We should always go to the "choose your course" page. + # We should also be enrolled as "honor" by default. + (['honor', 'verified', 'audit'], 'honor'), + ) + @ddt.unpack + def test_enroll(self, course_modes, enrollment_mode): + # Create the course modes (if any) required for this test case + for mode_slug in course_modes: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode_slug, + mode_display_name=mode_slug, + ) + + # Enroll in the course and verify the URL we get sent to + self._create_enrollment() + + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id)) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, enrollment_mode) + + def test_enroll_prof_ed(self): + # Create the prod ed mode. + CourseModeFactory.create( + course_id=self.course.id, + mode_slug='professional', + mode_display_name='Professional Education', + ) + + # Enroll in the course, this will fail if the mode is not explicitly professional. + resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))})) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + # While the enrollment wrong is invalid, the response content should have + # all the valid enrollment modes. + data = json.loads(resp.content) + self.assertEqual(unicode(self.course.id), data['course_id']) + self.assertEqual(1, len(data['course_modes'])) + self.assertEqual('professional', data['course_modes'][0]['slug']) + + def test_unenroll(self): + # Create a course mode. + CourseModeFactory.create( + course_id=self.course.id, + mode_slug='honor', + mode_display_name='Honor', + ) + + # Create an enrollment + resp = self._create_enrollment() + + # Deactivate the enrollment in the course and verify the URL we get sent to + resp = self.client.post(reverse( + 'courseenrollment', + kwargs={'course_id': (unicode(self.course.id))} + ), {'deactivate': True}) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = json.loads(resp.content) + self.assertEqual(unicode(self.course.id), data['course']['course_id']) + self.assertEqual('honor', data['mode']) + self.assertFalse(data['is_active']) + + def test_user_not_authenticated(self): + # Log out, so we're no longer authenticated + self.client.logout() + + # Try to enroll, this should fail. + resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))})) + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_unenroll_not_enrolled_in_course(self): + # Deactivate the enrollment in the course and verify the URL we get sent to + resp = self.client.post(reverse( + 'courseenrollment', + kwargs={'course_id': (unicode(self.course.id))} + ), {'deactivate': True}) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + def test_invalid_enrollment_mode(self): + # Request an enrollment with verified mode, which does not exist for this course. + resp = self.client.post(reverse( + 'courseenrollment', + kwargs={'course_id': (unicode(self.course.id))}), + {'mode': 'verified'} + ) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + data = json.loads(resp.content) + self.assertEqual(unicode(self.course.id), data['course_id']) + self.assertEqual('honor', data['course_modes'][0]['slug']) + + def test_with_invalid_course_id(self): + # Create an enrollment + resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': 'entirely/fake/course'})) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + def _create_enrollment(self): + resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))})) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = json.loads(resp.content) + self.assertEqual(unicode(self.course.id), data['course']['course_id']) + self.assertEqual('honor', data['mode']) + self.assertTrue(data['is_active']) + return resp diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 76a2b34e903..9546403c270 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -3,12 +3,14 @@ The Enrollment API Views should be simple, lean HTTP endpoints for API access. T consist primarily of authentication, request validation, and serialization. """ +from rest_framework import status from rest_framework.authentication import OAuth2Authentication, SessionAuthentication from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import UserRateThrottle from enrollment import api +from student.models import NonExistentCourseError class EnrollmentUserThrottle(UserRateThrottle): @@ -20,6 +22,17 @@ class EnrollmentUserThrottle(UserRateThrottle): @permission_classes((IsAuthenticated,)) @throttle_classes([EnrollmentUserThrottle]) def list_student_enrollments(request): + """List out all the enrollments for the current student + + Returns a JSON response with all the course enrollments for the current student. + + Args: + request (Request): The GET request for course enrollment listings. + + Returns: + A JSON serialized representation of the student's course enrollments. + + """ return Response(api.get_enrollments(request.user.username)) @@ -28,11 +41,36 @@ def list_student_enrollments(request): @permission_classes((IsAuthenticated,)) @throttle_classes([EnrollmentUserThrottle]) def get_course_enrollment(request, course_id=None): - if 'mode' in request.DATA: - return Response(api.update_enrollment(request.user.username, course_id, request.DATA['mode'])) - elif 'deactivate' in request.DATA: - return Response(api.deactivate_enrollment(request.user.username, course_id)) - elif course_id and request.method == 'POST': - return Response(api.add_enrollment(request.user.username, course_id)) - else: - return Response(api.get_enrollment(request.user.username, course_id)) + """Create, read, or update enrollment information for a student. + + HTTP Endpoint for all CRUD operations for a student course enrollment. Allows creation, reading, and + updates of the current enrollment for a particular course. + + Args: + request (Request): To get current course enrollment information, a GET request will return + information for the current user and the specified course. A POST request will create a + new course enrollment for the current user. If 'mode' or 'deactivate' are found in the + POST parameters, the mode can be modified, or the enrollment can be deactivated. + course_id (str): URI element specifying the course location. Enrollment information will be + returned, created, or updated for this particular course. + + Return: + A JSON serialized representation of the course enrollment. If this is a new or modified enrollment, + the returned enrollment will reflect all changes. + + """ + try: + if 'mode' in request.DATA: + return Response(api.update_enrollment(request.user.username, course_id, request.DATA['mode'])) + elif 'deactivate' in request.DATA: + return Response(api.deactivate_enrollment(request.user.username, course_id)) + elif course_id and request.method == 'POST': + return Response(api.add_enrollment(request.user.username, course_id)) + else: + return Response(api.get_enrollment(request.user.username, course_id)) + except api.CourseModeNotFoundError as error: + return Response(status=status.HTTP_400_BAD_REQUEST, data=error.data) + except NonExistentCourseError: + return Response(status=status.HTTP_400_BAD_REQUEST) + except api.EnrollmentNotFoundError: + return Response(status=status.HTTP_400_BAD_REQUEST) -- GitLab