""" Views for the Entitlements v1 API. """ import logging from django.db import IntegrityError, transaction from django.db.models import Q from django.http import HttpResponseBadRequest from django_filters.rest_framework import DjangoFilterBackend from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.paginators import DefaultPagination from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status, viewsets from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long CourseEntitlement, CourseEntitlementPolicy, CourseEntitlementSupportDetail ) from common.djangoapps.entitlements.rest_api.v1.filters import CourseEntitlementFilter from common.djangoapps.entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable from common.djangoapps.student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in log = logging.getLogger(__name__) class EntitlementsPagination(DefaultPagination): """ Paginator for entitlements API. """ page_size = 50 max_page_size = 100 @transaction.atomic def _unenroll_entitlement(course_entitlement, course_run_key): """ Internal method to handle the details of Unenrolling a User in a Course Run. """ CourseEnrollment.unenroll(course_entitlement.user, course_run_key, skip_refund=True) @transaction.atomic def _process_revoke_and_unenroll_entitlement(course_entitlement, is_refund=False): """ Process the revoke of the Course Entitlement and refund if needed Arguments: course_entitlement: Course Entitlement Object is_refund (bool): True if a refund should be processed Exceptions: IntegrityError if there is an issue that should reverse the database changes """ if course_entitlement.expired_at is None: course_entitlement.expire_entitlement() log.info( 'Set expired_at to [%s] for course entitlement [%s]', course_entitlement.expired_at, course_entitlement.uuid ) if course_entitlement.enrollment_course_run is not None: course_id = course_entitlement.enrollment_course_run.course_id _unenroll_entitlement(course_entitlement, course_id) log.info( 'Unenrolled user [%s] from course run [%s] as part of revocation of course entitlement [%s]', course_entitlement.user.username, course_id, course_entitlement.uuid ) if is_refund: course_entitlement.refund() def set_entitlement_policy(entitlement, site): """ Assign the appropriate CourseEntitlementPolicy to the given CourseEntitlement based on its mode and site. Arguments: entitlement: Course Entitlement object site: string representation of a Site object Notes: Site-specific, mode-agnostic policies take precedence over mode-specific, site-agnostic policies. If no appropriate CourseEntitlementPolicy is found, the default CourseEntitlementPolicy is assigned. """ policy_mode = entitlement.mode if CourseMode.is_professional_slug(policy_mode): policy_mode = CourseMode.PROFESSIONAL filter_query = (Q(site=site) | Q(site__isnull=True)) & (Q(mode=policy_mode) | Q(mode__isnull=True)) policy = CourseEntitlementPolicy.objects.filter(filter_query).order_by('-site', '-mode').first() entitlement.policy = policy if policy else None entitlement.save() class EntitlementViewSet(viewsets.ModelViewSet): """ ViewSet for the Entitlements API. """ ENTITLEMENT_UUID4_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,) permission_classes = (permissions.IsAuthenticated, IsAdminOrSupportOrAuthenticatedReadOnly,) lookup_value_regex = ENTITLEMENT_UUID4_REGEX lookup_field = 'uuid' serializer_class = CourseEntitlementSerializer filter_backends = (DjangoFilterBackend,) filterset_class = CourseEntitlementFilter pagination_class = EntitlementsPagination def get_queryset(self): user = self.request.user if self.request.method in permissions.SAFE_METHODS: if (user.is_staff and (self.request.query_params.get('user', None) is not None or self.kwargs.get('uuid', None) is not None)): # Return the full query set so that the Filters class can be used to apply, # - The UUID Filter # - The User Filter to the GET request return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run') # Non Staff Users will only be able to retrieve their own entitlements return CourseEntitlement.objects.filter(user=user).select_related('user').select_related( 'enrollment_course_run' ) # All other methods require the full Query set and the Permissions class already restricts access to them # to Admin users return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run') def get_upgradeable_enrollments_for_entitlement(self, entitlement): """ Retrieve all the CourseEnrollments that are upgradeable for a given CourseEntitlement Arguments: entitlement: CourseEntitlement that we are requesting the CourseEnrollments for. Returns: list: List of upgradeable CourseEnrollments """ # find all course_runs within the course course_runs = get_course_runs_for_course(entitlement.course_uuid) # check if the user has enrollments for any of the course_runs upgradeable_enrollments = [] for course_run in course_runs: course_run_id = CourseKey.from_string(course_run.get('key')) enrollment = CourseEnrollment.get_enrollment(entitlement.user, course_run_id) if (enrollment and enrollment.is_active and is_course_run_entitlement_fulfillable(course_run_id, entitlement)): upgradeable_enrollments.append(enrollment) return upgradeable_enrollments def create(self, request, *args, **kwargs): support_details = request.data.pop('support_details', []) email_opt_in = request.data.pop('email_opt_in', False) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) entitlement = serializer.instance set_entitlement_policy(entitlement, request.site) # The owners for a course are the organizations that own the course. By taking owner.key, # we are able to pass in the organization key for email_opt_in owners = get_owners_for_course(entitlement.course_uuid) for owner in owners: update_email_opt_in(entitlement.user, owner['key'], email_opt_in) if support_details: for support_detail in support_details: support_detail['entitlement'] = entitlement support_detail['support_user'] = request.user CourseEntitlementSupportDetail.objects.create(**support_detail) else: user = entitlement.user upgradeable_enrollments = self.get_upgradeable_enrollments_for_entitlement(entitlement) # if there is only one upgradeable enrollment, update the mode to the paid entitlement.mode # if there is any ambiguity about which enrollment to upgrade # (i.e. multiple upgradeable enrollments or no available upgradeable enrollment), don't alter # the enrollment if len(upgradeable_enrollments) == 1: enrollment = upgradeable_enrollments[0] log.info( 'Upgrading enrollment [%s] from %s to %s while adding entitlement for user [%s] for course [%s]', enrollment, enrollment.mode, serializer.data.get('mode'), user.username, serializer.data.get('course_uuid') ) enrollment.update_enrollment(mode=entitlement.mode) entitlement.set_enrollment(enrollment) else: log.info( 'No enrollment upgraded while adding entitlement for user [%s] for course [%s] ', user.username, serializer.data.get('course_uuid') ) headers = self.get_success_headers(serializer.data) # Note, the entitlement is re-serialized before getting added to the Response, # so that the 'modified' date reflects changes that occur when upgrading enrollment. return Response( CourseEntitlementSerializer(entitlement).data, status=status.HTTP_201_CREATED, headers=headers ) def retrieve(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument """ Override the retrieve method to expire a record that is past the policy and is requested via the API before returning that record. """ entitlement = self.get_object() entitlement.update_expired_at() serializer = self.get_serializer(entitlement) return Response(serializer.data) def list(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused-argument """ Override the list method to expire records that are past the policy and requested via the API before returning those records. """ queryset = self.filter_queryset(self.get_queryset()) user = self.request.user if not user.is_staff: with transaction.atomic(): for entitlement in queryset: entitlement.update_expired_at() page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) def perform_destroy(self, instance): """ This method is an override and is called by the destroy method, which is called when a DELETE operation occurs This method will revoke the User's entitlement and unenroll the user if they are enrolled in a Course Run It is assumed the user has already been refunded. """ log.info( 'Entitlement Revoke requested for Course Entitlement[%s]', instance.uuid ) # This is not called with is_refund=True here because it is assumed the user has already been refunded. _process_revoke_and_unenroll_entitlement(instance) def partial_update(self, request, *args, **kwargs): entitlement_uuid = kwargs.get('uuid', None) try: entitlement = CourseEntitlement.objects.get(uuid=entitlement_uuid) except CourseEntitlement.DoesNotExist: return HttpResponseBadRequest( 'Could not find entitlement {entitlement_uuid} to update'.format( entitlement_uuid=entitlement_uuid ) ) support_details = request.data.pop('support_details', []) # If a patch request does not explicitly update an entitlement's refundability status, we want to ensure that # changes made to other attributes of the entitlement do not implicitly change its ability to be refunded. if request.data.get('refund_locked') is None: request.data['refund_locked'] = not entitlement.is_entitlement_refundable() for support_detail in support_details: support_detail['entitlement'] = entitlement support_detail['support_user'] = request.user unenrolled_run_id = support_detail.get('unenrolled_run', None) if unenrolled_run_id: try: unenrolled_run_course_key = CourseKey.from_string(unenrolled_run_id) _unenroll_entitlement(entitlement, unenrolled_run_course_key) support_detail['unenrolled_run'] = CourseOverview.objects.get(id=unenrolled_run_course_key) except (InvalidKeyError, CourseOverview.DoesNotExist) as error: return HttpResponseBadRequest( 'Error raised while trying to unenroll user {user} from course run {course_id}: {error}' .format(user=entitlement.user.username, course_id=unenrolled_run_id, error=error) ) CourseEntitlementSupportDetail.objects.create(**support_detail) return super().partial_update(request, *args, **kwargs) # lint-amnesty, pylint: disable=no-member, super-with-arguments class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): """ Endpoint in the Entitlement API to handle the Enrollment of a User's Entitlement. This API will handle - Enroll - Unenroll - Switch Enrollment """ authentication_classes = (JwtAuthentication, SessionAuthentication,) # TODO: ARCH-91 # This view is excluded from Swagger doc generation because it # does not specify a serializer class. exclude_from_schema = True permission_classes = (permissions.IsAuthenticated,) queryset = CourseEntitlement.objects.all() def _verify_course_run_for_entitlement(self, entitlement, course_run_id): """ Verifies that a Course run is a child of the Course assigned to the entitlement. """ course_runs = get_course_runs_for_course(entitlement.course_uuid) for run in course_runs: if course_run_id == run.get('key', ''): return True return False @transaction.atomic def _enroll_entitlement(self, entitlement, course_run_key, user): """ Internal method to handle the details of enrolling a User in a Course Run. Returns a response object is there is an error or exception, None otherwise """ try: unexpired_paid_modes = [mode.slug for mode in CourseMode.paid_modes_for_course(course_run_key)] can_upgrade = unexpired_paid_modes and entitlement.mode in unexpired_paid_modes enrollment = CourseEnrollment.enroll( user=user, course_key=course_run_key, mode=entitlement.mode, check_access=True, can_upgrade=can_upgrade ) except AlreadyEnrolledError: enrollment = CourseEnrollment.get_enrollment(user, course_run_key) if enrollment.mode == entitlement.mode: entitlement.set_enrollment(enrollment) elif enrollment.mode not in unexpired_paid_modes: enrollment.update_enrollment(mode=entitlement.mode) entitlement.set_enrollment(enrollment) # Else the User is already enrolled in another paid Mode and we should # not do anything else related to Entitlements. except CourseEnrollmentException: message = ( 'Course Entitlement Enroll for {username} failed for course: {course_id}, ' 'mode: {mode}, and entitlement: {entitlement}' ).format( username=user.username, course_id=course_run_key, mode=entitlement.mode, entitlement=entitlement.uuid ) return Response( status=status.HTTP_400_BAD_REQUEST, data={'message': message} ) entitlement.set_enrollment(enrollment) return None def create(self, request, uuid): """ On POST this method will be called and will handle enrolling a user in the provided course_run_id from the data. This is called on a specific entitlement UUID so the course_run_id has to correspond to the Course that is assigned to the Entitlement. When this API is called for a user who is already enrolled in a run that User will be unenrolled from their current run and enrolled in the new run if it is available. """ course_run_id = request.data.get('course_run_id', None) if not course_run_id: return Response( status=status.HTTP_400_BAD_REQUEST, data='The Course Run ID was not provided.' ) # Verify that the user has an Entitlement for the provided Entitlement UUID. try: entitlement = CourseEntitlement.objects.get(uuid=uuid, user=request.user, expired_at=None) except CourseEntitlement.DoesNotExist: return Response( status=status.HTTP_400_BAD_REQUEST, data='The Entitlement for this UUID does not exist or is Expired.' ) # Verify the course run ID is of the same Course as the Course entitlement. course_run_valid = self._verify_course_run_for_entitlement(entitlement, course_run_id) if not course_run_valid: return Response( status=status.HTTP_400_BAD_REQUEST, data={ 'message': 'The Course Run ID is not a match for this Course Entitlement.' } ) try: course_run_key = CourseKey.from_string(course_run_id) except InvalidKeyError: return Response( status=status.HTTP_400_BAD_REQUEST, data={ 'message': f'Invalid {course_run_id}' } ) # Verify that the run is fullfillable if not is_course_run_entitlement_fulfillable(course_run_key, entitlement): return Response( status=status.HTTP_400_BAD_REQUEST, data={ 'message': 'The User is unable to enroll in Course Run {course_id}, it is not available.'.format( course_id=course_run_id ) } ) # Determine if this is a Switch session or a simple enroll and handle both. if entitlement.enrollment_course_run is None: response = self._enroll_entitlement( entitlement=entitlement, course_run_key=course_run_key, user=request.user ) if response: return response elif entitlement.enrollment_course_run.course_id != course_run_id: _unenroll_entitlement( course_entitlement=entitlement, course_run_key=entitlement.enrollment_course_run.course_id ) response = self._enroll_entitlement( entitlement=entitlement, course_run_key=course_run_key, user=request.user ) if response: return response return Response( status=status.HTTP_201_CREATED, data={ 'course_run_id': course_run_id, } ) def destroy(self, request, uuid): """ On DELETE call to this API we will unenroll the course enrollment for the provided uuid If is_refund parameter is provided then unenroll the user, set Entitlement expiration, and issue a refund """ is_refund = request.query_params.get('is_refund', 'false') == 'true' # Retrieve the entitlement for the UUID belongs to the current user. try: entitlement = CourseEntitlement.objects.get(uuid=uuid, user=request.user, expired_at=None) except CourseEntitlement.DoesNotExist: return Response( status=status.HTTP_400_BAD_REQUEST, data='The Entitlement for this UUID does not exist or is Expired.' ) if is_refund and entitlement.is_entitlement_refundable(): # Revoke the Course Entitlement and issue Refund log.info( 'Entitlement Refund requested for Course Entitlement[%s]', entitlement.uuid ) try: _process_revoke_and_unenroll_entitlement(course_entitlement=entitlement, is_refund=True) except IntegrityError: # This state is reached when there was a failure in revoke and refund process resulting # in a reversion of DB changes return Response( status=status.HTTP_500_INTERNAL_SERVER_ERROR, data={ 'message': 'Entitlement revoke and refund failed due to refund internal process failure' }) elif not is_refund: if entitlement.enrollment_course_run is not None: _unenroll_entitlement( course_entitlement=entitlement, course_run_key=entitlement.enrollment_course_run.course_id ) else: log.info( 'Entitlement Refund failed for Course Entitlement [%s]. Entitlement is not refundable', entitlement.uuid ) return Response( status=status.HTTP_400_BAD_REQUEST, data={ 'message': 'Entitlement refund failed, Entitlement is not refundable' }) return Response(status=status.HTTP_204_NO_CONTENT)