import base64 import logging import re from django.core import validators from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.db import transaction from django.db.models import Q from django.db.models.functions import Lower from django.http.response import Http404 from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters as rest_framework_filters from rest_framework import status, viewsets from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS, IsAuthenticated from rest_framework.response import Response from course_discovery.apps.api import filters, serializers from course_discovery.apps.api.cache import CompressedCacheResponseMixin from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.permissions import IsCourseEditorOrReadOnly from course_discovery.apps.api.serializers import CourseEntitlementSerializer, MetadataWithType from course_discovery.apps.api.utils import get_query_param, reviewable_data_has_changed from course_discovery.apps.api.v1.exceptions import EditableAndQUnsupported from course_discovery.apps.api.v1.views.course_runs import CourseRunViewSet from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.constants import COURSE_ID_REGEX, COURSE_UUID_REGEX from course_discovery.apps.course_metadata.models import ( Course, CourseEditor, CourseEntitlement, CourseRun, CourseType, CourseUrlSlug, Organization, Program, Seat, Video ) from course_discovery.apps.course_metadata.utils import ( create_missing_entitlement, ensure_draft_world, validate_course_number ) from course_discovery.apps.publisher.utils import is_publisher_user logger = logging.getLogger(__name__) def writable_request_wrapper(method): def inner(*args, **kwargs): try: with transaction.atomic(): return method(*args, **kwargs) except ValidationError as exc: return Response(exc.message if hasattr(exc, 'message') else str(exc), status=status.HTTP_400_BAD_REQUEST) except (PermissionDenied, Http404): raise # just pass these along except Exception as e: # pylint: disable=broad-except content = e.content.decode('utf8') if hasattr(e, 'content') else str(e) msg = _('Failed to set data: {}').format(content) logger.exception(msg) return Response(msg, status=status.HTTP_400_BAD_REQUEST) return inner # pylint: disable=useless-super-delegation class CourseViewSet(CompressedCacheResponseMixin, viewsets.ModelViewSet): """ Course resource. """ filter_backends = (DjangoFilterBackend, rest_framework_filters.OrderingFilter) filterset_class = filters.CourseFilter lookup_field = 'key' lookup_value_regex = COURSE_ID_REGEX + '|' + COURSE_UUID_REGEX permission_classes = (IsAuthenticated, IsCourseEditorOrReadOnly,) serializer_class = serializers.CourseWithProgramsSerializer metadata_class = MetadataWithType metadata_related_choices_whitelist = ('mode', 'level_type', 'subjects',) course_key_regex = re.compile(COURSE_ID_REGEX) course_uuid_regex = re.compile(COURSE_UUID_REGEX) # Explicitly support PageNumberPagination and LimitOffsetPagination. Future # versions of this API should only support the system default, PageNumberPagination. pagination_class = ProxiedPagination def get_object(self): queryset = self.filter_queryset(self.get_queryset()) key = self.kwargs['key'] if self.course_key_regex.match(key): filter_key = 'key' elif self.course_uuid_regex.match(key): filter_key = 'uuid' filter_kwargs = {filter_key: key} obj = get_object_or_404(queryset, **filter_kwargs) # May raise a permission denied self.check_object_permissions(self.request, obj) return obj def get_queryset(self): partner = self.request.site.partner q = self.request.query_params.get('q') # We don't want to create an additional elasticsearch index right now for draft courses, so we # try to implement a basic search behavior with this pubq parameter here against key and name. pub_q = self.request.query_params.get('pubq') edit_method = self.request.method not in SAFE_METHODS edit_mode = get_query_param(self.request, 'editable') or edit_method if edit_mode and q: raise EditableAndQUnsupported() if edit_mode and (not self.request.user.is_staff and not is_publisher_user(self.request.user)): raise PermissionDenied if edit_mode: # Start with either draft versions or real versions of the courses queryset = Course.objects.filter_drafts() queryset = CourseEditor.editable_courses(self.request.user, queryset, check_editors=edit_method) else: queryset = self.queryset if q: queryset = Course.search(q, queryset=queryset) queryset = self.get_serializer_class().prefetch_queryset(queryset=queryset, partner=partner) else: if edit_mode: course_runs = CourseRun.objects.filter_drafts(course__partner=partner) else: course_runs = CourseRun.objects.filter(course__partner=partner) if not get_query_param(self.request, 'include_hidden_course_runs'): course_runs = course_runs.exclude(hidden=True) if get_query_param(self.request, 'marketable_course_runs_only'): course_runs = course_runs.marketable().active() if get_query_param(self.request, 'marketable_enrollable_course_runs_with_archived'): course_runs = course_runs.marketable().enrollable() if get_query_param(self.request, 'published_course_runs_only'): course_runs = course_runs.filter(status=CourseRunStatus.Published) if get_query_param(self.request, 'include_deleted_programs'): programs = Program.objects.all() else: programs = Program.objects.exclude(status=ProgramStatus.Deleted) queryset = self.get_serializer_class().prefetch_queryset( queryset=queryset, course_runs=course_runs, partner=partner, programs=programs, ) if pub_q and edit_mode: return queryset.filter(Q(key__icontains=pub_q) | Q(title__icontains=pub_q)).order_by(Lower('key')) return queryset.order_by(Lower('key')) def get_serializer_context(self): context = super().get_serializer_context() query_params = ['exclude_utm', 'include_deleted_programs'] for query_param in query_params: context[query_param] = get_query_param(self.request, query_param) return context def get_course_key(self, data): return '{org}+{number}'.format(org=data['org'], number=data['number']) @writable_request_wrapper def create(self, request, *args, **kwargs): """ Create a Course, Course Entitlement, and Entitlement. """ course_run_creation_fields = request.data.pop('course_run', None) course_creation_fields = { 'title': request.data.get('title'), 'number': request.data.get('number'), 'org': request.data.get('org'), 'type': request.data.get('type'), } url_slug = request.data.get('url_slug', '') missing_values = [k for k, v in course_creation_fields.items() if v is None] error_message = '' if missing_values: error_message += ''.join([_('Missing value for: [{name}]. ').format(name=name) for name in missing_values]) if not Organization.objects.filter(key=course_creation_fields['org']).exists(): error_message += _('Organization [{org}] does not exist. ').format(org=course_creation_fields['org']) if not CourseType.objects.filter(uuid=course_creation_fields['type']).exists(): error_message += _('Course Type [{course_type}] does not exist. ').format( course_type=course_creation_fields['type']) if error_message: return Response((_('Incorrect data sent. ') + error_message).strip(), status=status.HTTP_400_BAD_REQUEST) partner = request.site.partner course_creation_fields['partner'] = partner.id course_creation_fields['key'] = self.get_course_key(course_creation_fields) validate_course_number(course_creation_fields['number']) serializer = self.get_serializer(data=course_creation_fields) serializer.is_valid(raise_exception=True) # Confirm that this course doesn't already exist in an official non-draft form if Course.objects.filter(partner=partner, key=course_creation_fields['key']).exists(): raise Exception(_('A course with key [{key}] already exists.').format(key=course_creation_fields['key'])) # if a manually entered url_slug, ensure it's not already taken (auto-generated are guaranteed uniqueness) if url_slug: validators.validate_slug(url_slug) if CourseUrlSlug.objects.filter(url_slug=url_slug, partner=partner).exists(): raise Exception(_('Course creation was unsuccessful. The course URL slug ‘[{url_slug}]’ is already in ' 'use. Please update this field and try again.').format(url_slug=url_slug)) course = serializer.save(draft=True) course.set_active_url_slug(url_slug) organization = Organization.objects.get(key=course_creation_fields['org']) course.authoring_organizations.add(organization) entitlement_types = course.type.entitlement_types.all() prices = request.data.get('prices', {}) for entitlement_type in entitlement_types: CourseEntitlement.objects.create( course=course, mode=entitlement_type, partner=partner, price=prices.get(entitlement_type.slug, 0), draft=True, ) CourseEditor.objects.create( user=request.user, course=course, ) # We want to create the course run here so it is captured as part of the atomic transaction. # Note: We have to send the request object as well because it is used for its metadata # (like request.user and is set as part of the serializer context) if course_run_creation_fields: course_run_creation_fields.update({'course': course.key, 'prices': prices}) run_response = CourseRunViewSet().create_run_helper(course_run_creation_fields, request) if run_response.status_code != 201: raise Exception(str(run_response.data)) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def update_entitlement(self, course, entitlement_type, price, partial=False): """ Finds and updates an existing entitlement from the incoming data, with verification. Will create an entitlement if we're switching from Audit. Returns a tuple of (CourseEntitlement, bool) where the second value is whether the entitlement changed. """ entitlement = CourseEntitlement.everything.filter(course=course, draft=True).first() existing_slug = entitlement.mode.slug if entitlement else Seat.AUDIT # We want to allow upgrading an entitlement from Audit -> Verified, but allow no other # entitlement type changes. We use the official version existing as an indicator for # ecom products having already been created. entitlement_type_switch_whitelist = {Seat.AUDIT: Seat.VERIFIED} if (course.official_version and existing_slug != entitlement_type.slug and entitlement_type_switch_whitelist.get(existing_slug) != entitlement_type.slug): raise ValidationError(_('Switching entitlement types after being reviewed is not supported. Please reach ' 'out to your project coordinator for additional help if necessary.')) if entitlement: data = {'mode': entitlement_type.slug, 'price': price} serializer = CourseEntitlementSerializer(entitlement, data=data, partial=partial) serializer.is_valid(raise_exception=True) return serializer.save(), entitlement.price != float(price) else: return (CourseEntitlement.objects.create( course=course, mode=entitlement_type, partner=course.partner, price=price, draft=True, ), True) def log_request_subjects_and_prices(self, data, course): # pragma: no cover req_subjects = ', '.join(data.get('subjects', [])) current_subjects = ', '.join(list(map(lambda s: s.slug, course.subjects.all()))) prices = data.get('prices', {}) logger.info( 'UPDATE to course uuid - {uuid}, req subjects - [{req_subjects}], request prices - {prices}, ' 'current subjects - [{current_subjects}]'.format(uuid=data.get('uuid'), req_subjects=req_subjects, prices=prices, current_subjects=current_subjects) ) @writable_request_wrapper def update_course(self, data, partial=False): # pylint: disable=too-many-statements """ Updates an existing course from incoming data. """ changed = False # Sending draft=False means the course data is live and updates should be pushed out immediately draft = data.pop('draft', True) image_data = data.pop('image', None) video_data = data.pop('video', None) url_slug = data.pop('url_slug', '') # Get and validate object serializer course = self.get_object() course = ensure_draft_world(course) # always work on drafts serializer = self.get_serializer(course, data=data, partial=partial) serializer.is_valid(raise_exception=True) # TEMPORARY - log incoming request (subject and prices) for all course updates, see Jira DISCO-1593 self.log_request_subjects_and_prices(data, course) # First, update course entitlements if data.get('type') or data.get('prices'): entitlements = [] prices = data.get('prices', {}) course_type = CourseType.objects.get(uuid=data.get('type')) if data.get('type') else course.type entitlement_types = course_type.entitlement_types.all() for entitlement_type in entitlement_types: price = prices.get(entitlement_type.slug) if price is None: continue entitlement, did_change = self.update_entitlement(course, entitlement_type, price, partial=partial) entitlements.append(entitlement) changed = changed or did_change # Deleting entitlements here since they would be orphaned otherwise. # One example of how this situation can happen is if a course team is switching between # "Verified and Audit" and "Audit Only" before actually publishing their course run. course.entitlements.exclude(mode__in=entitlement_types).delete() course.entitlements.set(entitlements) # Save video if a new video source is provided if (video_data and video_data.get('src') and (not course.video or video_data.get('src') != course.video.src)): video, __ = Video.objects.get_or_create(src=video_data['src']) course.video = video # Save image and convert to the correct format if image_data and isinstance(image_data, str) and image_data.startswith('data:image'): # base64 encoded image - decode file_format, imgstr = image_data.split(';base64,') # format ~= data:image/X;base64,/xxxyyyzzz/ ext = file_format.split('/')[-1] # guess file extension image_data = ContentFile(base64.b64decode(imgstr), name='tmp.{extension}'.format(extension=ext)) course.image.save(image_data.name, image_data) # If price didnt change, check the other fields on the course # (besides image and video, they are popped off above) changed = changed or reviewable_data_has_changed(course, serializer.validated_data.items()) if url_slug: validators.validate_slug(url_slug) all_course_historical_slugs_excluding_present = CourseUrlSlug.objects.filter( url_slug=url_slug, partner=course.partner).exclude(course__uuid=course.uuid) if all_course_historical_slugs_excluding_present.exists(): raise Exception( _('Course edit was unsuccessful. The course URL slug ‘[{url_slug}]’ is already in use. ' 'Please update this field and try again.').format(url_slug=url_slug)) # Then the course itself course = serializer.save() if url_slug: course.set_active_url_slug(url_slug) if not draft: for course_run in course.active_course_runs: if course_run.status == CourseRunStatus.Published: # This will also update the course course_run.update_or_create_official_version() # Revert any Reviewed course runs back to Unpublished if changed: for course_run in course.course_runs.filter(status=CourseRunStatus.Reviewed): course_run.status = CourseRunStatus.Unpublished course_run.save() course_run.official_version.status = CourseRunStatus.Unpublished course_run.official_version.save() # hack to get the correctly-updated url slug into the response return_dict = {'url_slug': course.active_url_slug} return_dict.update(serializer.data) return Response(return_dict) def update(self, request, *_args, **_kwargs): """ Update details for a course. """ return self.update_course(request.data, partial=False) def partial_update(self, request, *_args, **_kwargs): """ Partially update details for a course. """ return self.update_course(request.data, partial=True) def destroy(self, _request, *_args, **_kwargs): """ Delete a course. """ # Not supported return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) def list(self, request, *args, **kwargs): """ List all courses. --- parameters: - name: exclude_utm description: Exclude UTM parameters from marketing URLs. required: false type: integer paramType: query multiple: false - name: include_deleted_programs description: Will include deleted programs in the associated programs array required: false type: integer paramType: query multiple: false - name: keys description: Filter by keys (comma-separated list) required: false type: string paramType: query multiple: false - name: include_hidden_course_runs description: Include course runs that are hidden in the response. required: false type: integer paramType: query mulitple: false - name: marketable_course_runs_only description: Restrict returned course runs to those that are published, have seats, and are enrollable or will be enrollable in the future required: false type: integer paramType: query mulitple: false - name: marketable_enrollable_course_runs_with_archived description: Restrict returned course runs to those that are published, have seats, and can be enrolled in now. Includes archived courses. required: false type: integer paramType: query mulitple: false - name: published_course_runs_only description: Filter course runs by published ones only required: false type: integer paramType: query mulitple: false - name: q description: Elasticsearch querystring query. This filter takes precedence over other filters. required: false type: string paramType: query multiple: false """ return super(CourseViewSet, self).list(request, *args, **kwargs) def retrieve(self, request, *args, **kwargs): """ Retrieve details for a course. """ # Check if we can convert from run-level seat pricing to course-level entitlements. # # Yes, creating an object is kind of an odd thing to do on a GET endpoint - but it's a one time migration # to entitlements and subsequent calls will not make further objects. # This was deemed simpler than faking that an entitlement exists in the response and making the object when # a client calls PATCH. course = self.get_object() if get_query_param(request, 'editable') and not course.entitlements.exists(): create_missing_entitlement(course) return super(CourseViewSet, self).retrieve(request, *args, **kwargs)