diff --git a/common/djangoapps/entitlements/admin.py b/common/djangoapps/entitlements/admin.py index 16c9000b2f..133582ac73 100644 --- a/common/djangoapps/entitlements/admin.py +++ b/common/djangoapps/entitlements/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import CourseEntitlement diff --git a/common/djangoapps/entitlements/api/__init__.py b/common/djangoapps/entitlements/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/entitlements/api/urls.py b/common/djangoapps/entitlements/api/urls.py new file mode 100644 index 0000000000..3fa0c71d65 --- /dev/null +++ b/common/djangoapps/entitlements/api/urls.py @@ -0,0 +1,5 @@ +from django.conf.urls import include, url + +urlpatterns = [ + url(r'^v1/', include('entitlements.api.v1.urls', namespace='v1')), +] diff --git a/common/djangoapps/entitlements/api/v1/__init__.py b/common/djangoapps/entitlements/api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/entitlements/api/v1/filters.py b/common/djangoapps/entitlements/api/v1/filters.py new file mode 100644 index 0000000000..bb8938a32d --- /dev/null +++ b/common/djangoapps/entitlements/api/v1/filters.py @@ -0,0 +1,41 @@ +from django_filters import rest_framework as filters + +from entitlements.models import CourseEntitlement + + +class CharListFilter(filters.CharFilter): + """ Filters a field via a comma-delimited list of values. """ + + def filter(self, qs, value): # pylint: disable=method-hidden + if value not in (None, ''): + value = value.split(',') + + return super(CharListFilter, self).filter(qs, value) + + +class UUIDListFilter(CharListFilter): + """ Filters a field via a comma-delimited list of UUIDs. """ + + def __init__(self, name='uuid', label=None, widget=None, method=None, lookup_expr='in', required=False, + distinct=False, exclude=False, **kwargs): + super(UUIDListFilter, self).__init__( + name=name, + label=label, + widget=widget, + method=method, + lookup_expr=lookup_expr, + required=required, + distinct=distinct, + exclude=exclude, + **kwargs + ) + + +class CourseEntitlementFilter(filters.FilterSet): + + uuid = UUIDListFilter() + course_uuid = UUIDListFilter() + + class Meta: + model = CourseEntitlement + fields = ('uuid',) diff --git a/common/djangoapps/entitlements/api/v1/serializers.py b/common/djangoapps/entitlements/api/v1/serializers.py new file mode 100644 index 0000000000..be3fc0f597 --- /dev/null +++ b/common/djangoapps/entitlements/api/v1/serializers.py @@ -0,0 +1,21 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from entitlements.models import CourseEntitlement + + +class CourseEntitlementSerializer(serializers.ModelSerializer): + user = serializers.SlugRelatedField(slug_field='username', queryset=get_user_model().objects.all()) + + class Meta: + model = CourseEntitlement + fields = ( + 'user', + 'uuid', + 'course_uuid', + 'expired_at', + 'created', + 'modified', + 'mode', + 'order_number' + ) diff --git a/common/djangoapps/entitlements/api/v1/tests/__init__.py b/common/djangoapps/entitlements/api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/entitlements/api/v1/tests/test_serializers.py b/common/djangoapps/entitlements/api/v1/tests/test_serializers.py new file mode 100644 index 0000000000..93a14c1842 --- /dev/null +++ b/common/djangoapps/entitlements/api/v1/tests/test_serializers.py @@ -0,0 +1,32 @@ +import unittest + +from django.conf import settings +from django.test import RequestFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from entitlements.api.v1.serializers import CourseEntitlementSerializer +from entitlements.tests.factories import CourseEntitlementFactory + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class EntitlementsSerializerTests(ModuleStoreTestCase): + def setUp(self): + super(EntitlementsSerializerTests, self).setUp() + + def test_data(self): + entitlement = CourseEntitlementFactory() + request = RequestFactory().get('') + serializer = CourseEntitlementSerializer(entitlement, context={'request': request}) + + expected = { + 'user': entitlement.user.username, + 'uuid': str(entitlement.uuid), + 'expired_at': entitlement.expired_at, + 'course_uuid': str(entitlement.course_uuid), + 'mode': entitlement.mode, + 'order_number': entitlement.order_number, + 'created': entitlement.created.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + 'modified': entitlement.modified.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + } + + assert serializer.data == expected diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py new file mode 100644 index 0000000000..259881c35b --- /dev/null +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -0,0 +1,139 @@ +import json +import unittest +import uuid + +from django.conf import settings +from django.core.urlresolvers import reverse +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from entitlements.tests.factories import CourseEntitlementFactory +from entitlements.models import CourseEntitlement +from entitlements.api.v1.serializers import CourseEntitlementSerializer +from student.tests.factories import CourseEnrollmentFactory, UserFactory, TEST_PASSWORD + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class EntitlementViewSetTest(ModuleStoreTestCase): + ENTITLEMENTS_DETAILS_PATH = 'entitlements_api:v1:entitlements-detail' + + def setUp(self): + super(EntitlementViewSetTest, self).setUp() + self.user = UserFactory(is_staff=True) + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.course = CourseFactory() + self.entitlements_list_url = reverse('entitlements_api:v1:entitlements-list') + + def _get_data_set(self, user, course_uuid): + """ + Get a basic data set for an entitlement + """ + return { + "user": user.username, + "mode": "verified", + "course_uuid": course_uuid, + "order_number": "EDX-1001" + } + + def test_auth_required(self): + self.client.logout() + response = self.client.get(self.entitlements_list_url) + assert response.status_code == 401 + + def test_staff_user_required(self): + not_staff_user = UserFactory() + self.client.login(username=not_staff_user.username, password=UserFactory._DEFAULT_PASSWORD) + response = self.client.get(self.entitlements_list_url) + assert response.status_code == 403 + + def test_add_entitlement_with_missing_data(self): + entitlement_data_missing_parts = self._get_data_set(self.user, str(uuid.uuid4())) + entitlement_data_missing_parts.pop('mode') + entitlement_data_missing_parts.pop('course_uuid') + + response = self.client.post( + self.entitlements_list_url, + data=json.dumps(entitlement_data_missing_parts), + content_type='application/json', + ) + assert response.status_code == 400 + + def test_add_entitlement(self): + course_uuid = uuid.uuid4() + entitlement_data = self._get_data_set(self.user, str(course_uuid)) + + response = self.client.post( + self.entitlements_list_url, + data=json.dumps(entitlement_data), + content_type='application/json', + ) + assert response.status_code == 201 + results = response.data + + course_entitlement = CourseEntitlement.objects.get( + user=self.user, + course_uuid=course_uuid + ) + assert results == CourseEntitlementSerializer(course_entitlement).data + + def test_get_entitlements(self): + entitlements = CourseEntitlementFactory.create_batch(2) + + response = self.client.get( + self.entitlements_list_url, + content_type='application/json', + ) + assert response.status_code == 200 + + results = response.data.get('results', []) + assert results == CourseEntitlementSerializer(entitlements, many=True).data + + def test_get_entitlement_by_uuid(self): + entitlement = CourseEntitlementFactory() + CourseEntitlementFactory.create_batch(2) + + CourseEntitlementFactory() + url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(entitlement.uuid)]) + + response = self.client.get( + url, + content_type='application/json', + ) + assert response.status_code == 200 + + results = response.data + assert results == CourseEntitlementSerializer(entitlement).data + + def test_delete_and_revoke_entitlement(self): + course_entitlement = CourseEntitlementFactory() + url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)]) + + response = self.client.delete( + url, + content_type='application/json', + ) + assert response.status_code == 204 + course_entitlement.refresh_from_db() + assert course_entitlement.expired_at is not None + + def test_revoke_unenroll_entitlement(self): + course_entitlement = CourseEntitlementFactory() + url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)]) + + enrollment = CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + course_entitlement.refresh_from_db() + course_entitlement.enrollment_course_run = enrollment + course_entitlement.save() + + assert course_entitlement.enrollment_course_run is not None + + response = self.client.delete( + url, + content_type='application/json', + ) + assert response.status_code == 204 + + course_entitlement.refresh_from_db() + assert course_entitlement.expired_at is not None + assert course_entitlement.enrollment_course_run is None diff --git a/common/djangoapps/entitlements/api/v1/urls.py b/common/djangoapps/entitlements/api/v1/urls.py new file mode 100644 index 0000000000..a8a81e0de9 --- /dev/null +++ b/common/djangoapps/entitlements/api/v1/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url, include +from rest_framework.routers import DefaultRouter + +from .views import EntitlementViewSet + +router = DefaultRouter() +router.register(r'entitlements', EntitlementViewSet, base_name='entitlements') + +urlpatterns = [ + url(r'', include(router.urls)), +] diff --git a/common/djangoapps/entitlements/api/v1/views.py b/common/djangoapps/entitlements/api/v1/views.py new file mode 100644 index 0000000000..c03c443f30 --- /dev/null +++ b/common/djangoapps/entitlements/api/v1/views.py @@ -0,0 +1,53 @@ +import logging + +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from edx_rest_framework_extensions.authentication import JwtAuthentication +from rest_framework import permissions, viewsets +from rest_framework.authentication import SessionAuthentication + +from entitlements.api.v1.filters import CourseEntitlementFilter +from entitlements.models import CourseEntitlement +from entitlements.api.v1.serializers import CourseEntitlementSerializer +from student.models import CourseEnrollment + +log = logging.getLogger(__name__) + + +class EntitlementViewSet(viewsets.ModelViewSet): + authentication_classes = (JwtAuthentication, SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated, permissions.IsAdminUser,) + queryset = CourseEntitlement.objects.all().select_related('user') + lookup_value_regex = '[0-9a-f-]+' + lookup_field = 'uuid' + serializer_class = CourseEntitlementSerializer + filter_backends = (DjangoFilterBackend,) + filter_class = CourseEntitlementFilter + + def perform_destroy(self, instance): + """ + This method is an override and is called by the DELETE method + """ + save_model = False + if instance.expired_at is None: + instance.expired_at = timezone.now() + log.info('Set expired_at to [%s] for course entitlement [%s]', instance.expired_at, instance.uuid) + save_model = True + + if instance.enrollment_course_run is not None: + CourseEnrollment.unenroll( + user=instance.user, + course_id=instance.enrollment_course_run.course_id, + skip_refund=True + ) + enrollment = instance.enrollment_course_run + instance.enrollment_course_run = None + save_model = True + log.info( + 'Unenrolled user [%s] from course run [%s] as part of revocation of course entitlement [%s]', + instance.user.username, + enrollment.course_id, + instance.uuid + ) + if save_model: + instance.save() diff --git a/common/djangoapps/entitlements/migrations/0002_auto_20171102_0719.py b/common/djangoapps/entitlements/migrations/0002_auto_20171102_0719.py new file mode 100644 index 0000000000..2ab05153fb --- /dev/null +++ b/common/djangoapps/entitlements/migrations/0002_auto_20171102_0719.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Migration to remove default Mode and to move comments to Help Text + """ + + dependencies = [ + ('entitlements', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='courseentitlement', + name='course_uuid', + field=models.UUIDField(help_text=b'UUID for the Course, not the Course Run'), + ), + migrations.AlterField( + model_name='courseentitlement', + name='enrollment_course_run', + field=models.ForeignKey(to='student.CourseEnrollment', help_text=b'The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.', null=True), + ), + migrations.AlterField( + model_name='courseentitlement', + name='expired_at', + field=models.DateTimeField(help_text=b'The date that an entitlement expired, if NULL the entitlement has not expired.', null=True), + ), + migrations.AlterField( + model_name='courseentitlement', + name='mode', + field=models.CharField(help_text=b'The mode of the Course that will be applied on enroll.', max_length=100), + ), + ] diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index d38d15fd80..09b03e5e6c 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -1,9 +1,8 @@ import uuid as uuid_tools +from django.conf import settings from django.db import models from model_utils.models import TimeStampedModel -from django.contrib.auth.models import User -from course_modes.models import CourseMode class CourseEntitlement(TimeStampedModel): @@ -11,87 +10,17 @@ class CourseEntitlement(TimeStampedModel): Represents a Student's Entitlement to a Course Run for a given Course. """ - user = models.ForeignKey(User) + user = models.ForeignKey(settings.AUTH_USER_MODEL) uuid = models.UUIDField(default=uuid_tools.uuid4, editable=False) - course_uuid = models.UUIDField() - - # The date that an the entitlement expired - # if NULL the entitlement has not expired - expired_at = models.DateTimeField(null=True) - - # The mode of the Course that will be applied - mode = models.CharField(default=CourseMode.DEFAULT_MODE_SLUG, max_length=100) - - # The ID of the course enrollment for this Entitlement - # if NULL the entitlement is not in use - enrollment_course_run = models.ForeignKey('student.CourseEnrollment', null=True) + course_uuid = models.UUIDField(help_text='UUID for the Course, not the Course Run') + expired_at = models.DateTimeField( + null=True, + help_text='The date that an entitlement expired, if NULL the entitlement has not expired.' + ) + mode = models.CharField(max_length=100, help_text='The mode of the Course that will be applied on enroll.') + enrollment_course_run = models.ForeignKey( + 'student.CourseEnrollment', + null=True, + help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.' + ) order_number = models.CharField(max_length=128, null=True) - - @classmethod - def entitlements_for_user(cls, user): - """ - Retrieve all the Entitlements for a User - - Arguments: - user: A Django User object identifying the current user - - Returns: - All of the Entitlements for the User - """ - return cls.objects.filter(user=user) - - @classmethod - def get_user_course_entitlement(cls, user, course_uuid): - """ - Retrieve The entitlement for the given parent course id if it exists for the User - - Arguments: - user: A Django User object identifying the current user - course_uuid(string): The parent course uuid - - Returns: - The single entitlement for the requested parent course id - """ - return cls.objects.filter(user=user, course_uuid=course_uuid).first() - - @classmethod - def update_or_create_new_entitlement(cls, user, course_uuid, entitlement_data): - """ - Updates or creates a new Course Entitlement - - Arguments: - user: A Django User object identifying the current user - course_uuid(string): The parent course uuid - entitlement_data(dict): The dictionary containing all the data for the entitlement - e.g. entitlement_data = { - 'user': user, - 'course_uuid': course_uuid - 'enroll_end_date': '2017-09-14 11:47:58.000000', - 'mode': 'verified', - } - - Returns: - stored_entitlement: The new or updated CourseEntitlement object - is_created (bool): Boolean representing whether or not the Entitlement was created or updated - """ - stored_entitlement, is_created = cls.objects.update_or_create( - user=user, - course_uuid=course_uuid, - defaults=entitlement_data - ) - return stored_entitlement, is_created - - @classmethod - def update_entitlement_enrollment(cls, user, course_uuid, course_run_enrollment): - """ - Sets the enrollment course for a given entitlement - - Arguments: - user: A Django User object identifying the current user - course_uuid(string): The parent course uuid - course_run_enrollment (CourseEnrollment): The CourseEnrollment object to store, None to clear the Enrollment - """ - return cls.objects.filter( - user=user, - course_uuid=course_uuid - ).update(enrollment_course_run_id=course_run_enrollment) diff --git a/common/djangoapps/entitlements/tests/__init__.py b/common/djangoapps/entitlements/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/entitlements/tests/factories.py b/common/djangoapps/entitlements/tests/factories.py new file mode 100644 index 0000000000..f22073136e --- /dev/null +++ b/common/djangoapps/entitlements/tests/factories.py @@ -0,0 +1,18 @@ +import string +import uuid + +import factory +from factory.fuzzy import FuzzyChoice, FuzzyText + +from entitlements.models import CourseEntitlement +from student.tests.factories import UserFactory + + +class CourseEntitlementFactory(factory.django.DjangoModelFactory): + class Meta(object): + model = CourseEntitlement + + course_uuid = uuid.uuid4() + mode = FuzzyChoice(['verified', 'profesional']) + user = factory.SubFactory(UserFactory) + order_number = FuzzyText(prefix='TEXTX', chars=string.digits) diff --git a/common/djangoapps/entitlements/tests/test_data.py b/common/djangoapps/entitlements/tests/test_data.py deleted file mode 100644 index 3f1c1fde20..0000000000 --- a/common/djangoapps/entitlements/tests/test_data.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Test the Data Aggregation Layer for Course Entitlements. -""" -import unittest -import uuid - -import ddt -from django.conf import settings - -from entitlements.models import CourseEntitlement -from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory -from student.tests.factories import CourseEnrollmentFactory - - -@ddt.ddt -@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class EntitlementDataTest(ModuleStoreTestCase): - """ - Test course entitlement data aggregation. - """ - USERNAME = "Bob" - EMAIL = "bob@example.com" - PASSWORD = "edx" - - def setUp(self): - """Create a course and user, then log in. """ - super(EntitlementDataTest, self).setUp() - self.course = CourseFactory.create() - self.course_uuid = uuid.uuid4() - self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - - def _add_entitlement_for_user(self, course, user, parent_uuid): - entitlement_data = { - 'user': user, - 'course_uuid': parent_uuid, - 'mode': 'verified', - } - stored_entitlement, is_created = CourseEntitlement.update_or_create_new_entitlement( - user, - parent_uuid, - entitlement_data - ) - return stored_entitlement, is_created - - def test_get_entitlement_info(self): - stored_entitlement, is_created = self._add_entitlement_for_user(self.course, self.user, self.course_uuid) - self.assertTrue(is_created) - - # Get the Entitlement and verify the data - entitlement = CourseEntitlement.get_user_course_entitlement(self.user, self.course_uuid) - self.assertEqual(entitlement.course_uuid, self.course_uuid) - self.assertEqual(entitlement.mode, 'verified') - self.assertIsNone(entitlement.enrollment_course_run) - - def test_get_course_entitlements(self): - course2 = CourseFactory.create() - - stored_entitlement, is_created = self._add_entitlement_for_user(self.course, self.user, self.course_uuid) - self.assertTrue(is_created) - - course2_uuid = uuid.uuid4() - stored_entitlement2, is_created2 = self._add_entitlement_for_user(course2, self.user, course2_uuid) - self.assertTrue(is_created2) - - # Get the Entitlement and verify the data - entitlement_list = CourseEntitlement.entitlements_for_user(self.user) - - self.assertEqual(2, len(entitlement_list)) - self.assertEqual(self.course_uuid, entitlement_list[0].course_uuid) - self.assertEqual(course2_uuid, entitlement_list[1].course_uuid) - - def test_set_enrollment(self): - stored_entitlement, is_created = self._add_entitlement_for_user(self.course, self.user, self.course_uuid) - self.assertTrue(is_created) - - # Entitlement set not enroll the user in the Course run - enrollment = CourseEnrollmentFactory( - user=self.user, - course_id=self.course.id, - mode="verified", - ) - CourseEntitlement.update_entitlement_enrollment(self.user, self.course_uuid, enrollment) - - entitlement = CourseEntitlement.get_user_course_entitlement(self.user, self.course_uuid) - self.assertIsNotNone(entitlement.enrollment_course_run) - - def test_remove_enrollment(self): - stored_entitlement, is_created = self._add_entitlement_for_user(self.course, self.user, self.course_uuid) - self.assertTrue(is_created) - - # Entitlement set not enroll the user in the Course run - enrollment = CourseEnrollmentFactory( - user=self.user, - course_id=self.course.id, - mode="verified", - ) - CourseEntitlement.update_entitlement_enrollment(self.user, self.course_uuid, enrollment) - - entitlement = CourseEntitlement.get_user_course_entitlement(self.user, self.course_uuid) - self.assertIsNotNone(entitlement.enrollment_course_run) - - CourseEntitlement.update_entitlement_enrollment(self.user, self.course_uuid, None) - entitlement = CourseEntitlement.get_user_course_entitlement(self.user, self.course_uuid) - self.assertIsNone(entitlement.enrollment_course_run) diff --git a/lms/urls.py b/lms/urls.py index 1c44ecabfc..e501ef68a3 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -88,6 +88,9 @@ urlpatterns = [ # Enrollment API RESTful endpoints url(r'^api/enrollment/v1/', include('enrollment.urls')), + # Entitlement API RESTful endpoints + url(r'^api/entitlements/', include('entitlements.api.urls', namespace='entitlements_api')), + # Courseware search endpoints url(r'^search/', include('search.urls')),