From 44a6962a9c9e8183ed2d97373986f933616534d9 Mon Sep 17 00:00:00 2001 From: Jenni Whitman Date: Tue, 31 Oct 2023 13:06:56 -0400 Subject: [PATCH] refactor v1 structure + add financial page --- cms/serializers.py | 24 + cms/serializers_test.py | 103 +++- courses/forms.py | 2 +- courses/serializers/v1/__init__.py | 641 -------------------- courses/serializers/v1/base.py | 174 ++++++ courses/serializers/v1/base_test.py | 30 + courses/serializers/v1/courses.py | 146 +++++ courses/serializers/v1/courses_test.py | 236 ++++++++ courses/serializers/v1/departments.py | 25 + courses/serializers/v1/departments_test.py | 4 + courses/serializers/v1/programs.py | 310 ++++++++++ courses/serializers/v1/programs_test.py | 379 ++++++++++++ courses/serializers/v1/serializers_test.py | 644 --------------------- courses/views/v1/__init__.py | 5 +- courses/views/v1/views_test.py | 2 +- flexiblepricing/serializers.py | 3 +- 16 files changed, 1436 insertions(+), 1292 deletions(-) create mode 100644 courses/serializers/v1/base.py create mode 100644 courses/serializers/v1/base_test.py create mode 100644 courses/serializers/v1/courses.py create mode 100644 courses/serializers/v1/courses_test.py create mode 100644 courses/serializers/v1/departments.py create mode 100644 courses/serializers/v1/departments_test.py create mode 100644 courses/serializers/v1/programs.py create mode 100644 courses/serializers/v1/programs_test.py diff --git a/cms/serializers.py b/cms/serializers.py index ee1c83bc7f..3839f78d50 100644 --- a/cms/serializers.py +++ b/cms/serializers.py @@ -140,6 +140,7 @@ class ProgramPageSerializer(serializers.ModelSerializer): feature_image_src = serializers.SerializerMethodField() page_url = serializers.SerializerMethodField() price = serializers.SerializerMethodField() + financial_assistance_form_url = serializers.SerializerMethodField() def get_feature_image_src(self, instance): """Serializes the source of the feature_image""" @@ -155,11 +156,34 @@ def get_page_url(self, instance): def get_price(self, instance): return instance.price[0].value["text"] if len(instance.price) > 0 else None + def get_financial_assistance_form_url(self, instance): + """ + Returns URL of the Financial Assistance Form. + """ + financial_assistance_page = FlexiblePricingRequestForm.objects.filter( + selected_program_id=instance.program.id + ).live().first() + if (financial_assistance_page is None) and (instance.get_children() is not None): + financial_assistance_page = ( + instance.get_children().type(FlexiblePricingRequestForm).live().first() + ) + if (financial_assistance_page is None) & (len(instance.program.related_programs) > 0): + financial_assistance_page = FlexiblePricingRequestForm.objects.filter( + selected_program__in=instance.program.related_programs + ).first() + return ( + f"{instance.get_url()}{financial_assistance_page.slug}/" + if financial_assistance_page + else "" + ) + + class Meta: model = models.ProgramPage fields = [ "feature_image_src", "page_url", + "financial_assistance_form_url", "description", "live", "length", diff --git a/cms/serializers_test.py b/cms/serializers_test.py index acd8d2cee2..719be3dca9 100644 --- a/cms/serializers_test.py +++ b/cms/serializers_test.py @@ -11,7 +11,7 @@ ProgramPageFactory, ) from cms.models import FlexiblePricingRequestForm -from cms.serializers import CoursePageSerializer +from cms.serializers import CoursePageSerializer, ProgramPageSerializer from courses.factories import CourseFactory, ProgramFactory from main.test_utils import assert_drf_json_equal @@ -225,3 +225,104 @@ def test_serialize_course_page_with_flex_price_form_as_child_no_program( "effort": course_page.effort, }, ) + + +def test_serialize_program_page(mocker, fully_configured_wagtail, staff_user, mock_context): + fake_image_src = "http://example.com/my.img" + patched_get_wagtail_src = mocker.patch( + "cms.serializers.get_wagtail_img_src", return_value=fake_image_src + ) + + program = ProgramFactory(page=None) + program_page = ProgramPageFactory(program=program) + financial_assistance_form = FlexiblePricingFormFactory( + selected_program_id=program.id, parent=program_page + ) + rf = RequestFactory() + request = rf.get("/") + request.user = staff_user + + data = ProgramPageSerializer( + instance=program_page, context=program_page.get_context(request) + ).data + assert_drf_json_equal( + data, + { + "feature_image_src": fake_image_src, + "page_url": program_page.url, + "financial_assistance_form_url": f"{program_page.get_url()}{financial_assistance_form.slug}/", + "description": bleach.clean(program_page.description, tags=[], strip=True), + "live": True, + "length": program_page.length, + "effort": program_page.effort, + "price": None, + } + ) + + +def test_serialize_program_page__with_related_financial_form(mocker, fully_configured_wagtail, staff_user, + mock_context): + fake_image_src = "http://example.com/my.img" + patched_get_wagtail_src = mocker.patch( + "cms.serializers.get_wagtail_img_src", return_value=fake_image_src + ) + + program = ProgramFactory(page=None) + program_page = ProgramPageFactory(program=program) + other_program = ProgramFactory(page=None) + other_program_page = ProgramPageFactory(program=other_program) + financial_assistance_form = FlexiblePricingFormFactory( + selected_program_id=other_program.id, parent=other_program_page + ) + program.add_related_program(other_program) + rf = RequestFactory() + request = rf.get("/") + request.user = staff_user + + data = ProgramPageSerializer( + instance=program_page, context=program_page.get_context(request) + ).data + assert_drf_json_equal( + data, + { + "feature_image_src": fake_image_src, + "page_url": program_page.url, + "financial_assistance_form_url": f"{program_page.get_url()}{financial_assistance_form.slug}/", + "description": bleach.clean(program_page.description, tags=[], strip=True), + "live": True, + "length": program_page.length, + "effort": program_page.effort, + "price": None, + } + ) + + +def test_serialize_program_page__no_financial_form(mocker, fully_configured_wagtail, staff_user, + mock_context): + fake_image_src = "http://example.com/my.img" + patched_get_wagtail_src = mocker.patch( + "cms.serializers.get_wagtail_img_src", return_value=fake_image_src + ) + + program = ProgramFactory(page=None) + program_page = ProgramPageFactory(program=program) + rf = RequestFactory() + request = rf.get("/") + request.user = staff_user + + data = ProgramPageSerializer( + instance=program_page, context=program_page.get_context(request) + ).data + assert_drf_json_equal( + data, + { + "feature_image_src": fake_image_src, + "page_url": program_page.url, + "financial_assistance_form_url": "", + "description": bleach.clean(program_page.description, tags=[], strip=True), + "live": True, + "length": program_page.length, + "effort": program_page.effort, + "price": None, + } + ) diff --git a/courses/forms.py b/courses/forms.py index 5e27671797..d7ce35c766 100644 --- a/courses/forms.py +++ b/courses/forms.py @@ -9,7 +9,7 @@ ProgramRequirement, ProgramRequirementNodeType, ) -from courses.serializers.v1 import ProgramRequirementTreeSerializer +from courses.serializers.v1.programs import ProgramRequirementTreeSerializer from courses.widgets import ProgramRequirementsInput diff --git a/courses/serializers/v1/__init__.py b/courses/serializers/v1/__init__.py index 0a57987953..e69de29bb2 100644 --- a/courses/serializers/v1/__init__.py +++ b/courses/serializers/v1/__init__.py @@ -1,641 +0,0 @@ -""" -Course model serializers -""" -import logging - -from django.contrib.auth.models import AnonymousUser -from rest_framework import serializers -from rest_framework.exceptions import ValidationError -from django.db.models import CharField, Q - -from cms.serializers import CoursePageSerializer, ProgramPageSerializer -from courses import models -from courses.api import create_run_enrollments -from courses.constants import CONTENT_TYPE_MODEL_COURSE, CONTENT_TYPE_MODEL_PROGRAM -from courses.serializers import get_thumbnail_url, BaseProgramRequirementTreeSerializer -from ecommerce.serializers import ProductFlexibilePriceSerializer -from flexiblepricing.api import is_courseware_flexible_price_approved -from main import features -from main.serializers import StrictFieldsSerializer -from mitol.common.utils.datetime import now_in_utc -from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE -from users.models import User - -logger = logging.getLogger(__name__) - - -class BaseCourseSerializer(serializers.ModelSerializer): - """Basic course model serializer""" - - type = serializers.SerializerMethodField(read_only=True) - - def to_representation(self, instance): - data = super().to_representation(instance) - if not self.context.get("include_page_fields") or not hasattr(instance, "page"): - return data - return {**data, **CoursePageSerializer(instance=instance.page).data} - - @staticmethod - def get_type(obj): - return CONTENT_TYPE_MODEL_COURSE - - class Meta: - model = models.Course - fields = [ - "id", - "title", - "readable_id", - "type", - ] - - -class ProductRelatedField(serializers.RelatedField): - """serializer for the Product generic field""" - - def to_representation(self, instance): - serializer = ProductFlexibilePriceSerializer( - instance=instance, context=self.context - ) - return serializer.data - - -class BaseCourseRunSerializer(serializers.ModelSerializer): - """Minimal CourseRun model serializer""" - - class Meta: - model = models.CourseRun - fields = [ - "title", - "start_date", - "end_date", - "enrollment_start", - "enrollment_end", - "expiration_date", - "courseware_url", - "courseware_id", - "certificate_available_date", - "upgrade_deadline", - "is_upgradable", - "is_self_paced", - "run_tag", - "id", - "live", - "course_number", - ] - - -class CourseRunSerializer(BaseCourseRunSerializer): - """CourseRun model serializer""" - - products = ProductRelatedField(many=True, read_only=True) - approved_flexible_price_exists = serializers.SerializerMethodField() - - class Meta: - model = models.CourseRun - fields = BaseCourseRunSerializer.Meta.fields + [ - "products", - "approved_flexible_price_exists", - ] - - def to_representation(self, instance): - data = super().to_representation(instance) - if self.context and self.context.get("include_enrolled_flag"): - return { - **data, - **{ - "is_enrolled": getattr(instance, "user_enrollments", 0) > 0, - "is_verified": getattr(instance, "verified_enrollments", 0) > 0, - }, - } - return data - - def get_approved_flexible_price_exists(self, instance): - # Get the User object if it exists. - user = self.context["request"].user if "request" in self.context else None - - # Check for an approved flexible price record if the - # user exists and has an ID (not an Anonymous user). - # Otherwise return False. - flexible_price_exists = ( - is_courseware_flexible_price_approved( - instance, self.context["request"].user - ) - if user and user.id - else False - ) - return flexible_price_exists - - -class DepartmentSerializer(serializers.ModelSerializer): - """Department model serializer""" - - class Meta: - model = models.Department - fields = ["name"] - - -class DepartmentWithCountSerializer(DepartmentSerializer): - """CourseRun model serializer that includes the number of courses and programs associated with each departments""" - - courses = serializers.IntegerField() - programs = serializers.IntegerField() - - class Meta: - model = models.Department - fields = DepartmentSerializer.Meta.fields + [ - "courses", - "programs", - ] - - -class CourseSerializer(BaseCourseSerializer): - """Course model serializer""" - - departments = DepartmentSerializer(many=True, read_only=True) - next_run_id = serializers.SerializerMethodField() - page = CoursePageSerializer(read_only=True) - programs = serializers.SerializerMethodField() - - def get_next_run_id(self, instance): - """Get next run id""" - run = instance.first_unexpired_run - return run.id if run is not None else None - - def get_programs(self, instance): - if self.context.get("all_runs", False): - from courses.serializers.v1 import BaseProgramSerializer - - return BaseProgramSerializer(instance.programs, many=True).data - - return None - - class Meta: - model = models.Course - fields = [ - "id", - "title", - "readable_id", - "next_run_id", - "departments", - "page", - "programs", - ] - - -class CourseWithCourseRunsSerializer(CourseSerializer): - """Course model serializer - also serializes child course runs""" - - courseruns = CourseRunSerializer(many=True, read_only=True) - - class Meta: - model = models.Course - fields = CourseSerializer.Meta.fields + [ - "courseruns", - ] - - -class CourseRunWithCourseSerializer(CourseRunSerializer): - """ - CourseRun model serializer - also serializes the parent Course. - """ - - course = CourseSerializer(read_only=True, context={"include_page_fields": True}) - - class Meta: - model = models.CourseRun - fields = CourseRunSerializer.Meta.fields + [ - "course", - ] - - -class BaseProgramSerializer(serializers.ModelSerializer): - """Basic program model serializer""" - - type = serializers.SerializerMethodField(read_only=True) - - @staticmethod - def get_type(obj): - return CONTENT_TYPE_MODEL_PROGRAM - - class Meta: - model = models.Program - fields = ["title", "readable_id", "id", "type"] - - -class ProgramSerializer(serializers.ModelSerializer): - """Program model serializer""" - - courses = serializers.SerializerMethodField() - requirements = serializers.SerializerMethodField() - req_tree = serializers.SerializerMethodField() - page = serializers.SerializerMethodField() - departments = DepartmentSerializer(many=True, read_only=True) - - def get_courses(self, instance): - """Serializer for courses""" - return CourseWithCourseRunsSerializer( - [course[0] for course in instance.courses if course[0].live], - many=True, - context={"include_page_fields": True}, - ).data - - def get_requirements(self, instance): - return { - "required": [course.id for course in instance.required_courses], - "electives": [course.id for course in instance.elective_courses], - } - - def get_req_tree(self, instance): - req_root = instance.get_requirements_root() - - if req_root is None: - return [] - - return ProgramRequirementTreeSerializer(instance=req_root).data - - def get_page(self, instance): - if hasattr(instance, "page"): - return ProgramPageSerializer(instance.page).data - else: - return {"feature_image_src": get_thumbnail_url(None)} - - class Meta: - model = models.Program - fields = [ - "title", - "readable_id", - "id", - "courses", - "requirements", - "req_tree", - "page", - "program_type", - "departments", - "live", - ] - - -class FullProgramSerializer(ProgramSerializer): - """Adds more data to the ProgramSerializer.""" - - start_date = serializers.SerializerMethodField() - end_date = serializers.SerializerMethodField() - enrollment_start = serializers.SerializerMethodField() - - def get_start_date(self, instance): - """ - start_date is the starting date for the earliest live course run for all courses in a program - - Returns: - datetime: The starting date - """ - courses_in_program = [course[0] for course in instance.courses] - return ( - models.CourseRun.objects.filter(course__in=courses_in_program, live=True) - .order_by("start_date") - .values_list("start_date", flat=True) - .first() - ) - - def get_end_date(self, instance): - """ - end_date is the end date for the latest live course run for all courses in a program. - - Returns: - datetime: The ending date - """ - courses_in_program = [course[0] for course in instance.courses] - return ( - models.CourseRun.objects.filter(course__in=courses_in_program, live=True) - .order_by("end_date") - .values_list("end_date", flat=True) - .last() - ) - - def get_enrollment_start(self, instance): - """ - enrollment_start is first date where enrollment starts for any live course run - """ - courses_in_program = [course[0] for course in instance.courses] - return ( - models.CourseRun.objects.filter(course__in=courses_in_program, live=True) - .order_by("enrollment_start") - .values_list("enrollment_start", flat=True) - .first() - ) - - class Meta(ProgramSerializer.Meta): - fields = ProgramSerializer.Meta.fields + [ - "title", - "readable_id", - "id", - "courses", - "num_courses", - "requirements", - "req_tree", - ] - - -class CourseRunCertificateSerializer(serializers.ModelSerializer): - """CourseRunCertificate model serializer""" - - class Meta: - model = models.CourseRunCertificate - fields = ["uuid", "link"] - - -class CourseRunGradeSerializer(serializers.ModelSerializer): - """CourseRunGrade serializer""" - - class Meta: - model = models.CourseRunGrade - fields = ["grade", "letter_grade", "passed", "set_by_admin", "grade_percent"] - - -class BaseCourseRunEnrollmentSerializer(serializers.ModelSerializer): - certificate = serializers.SerializerMethodField(read_only=True) - enrollment_mode = serializers.ChoiceField( - (EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE), read_only=True - ) - approved_flexible_price_exists = serializers.SerializerMethodField() - grades = serializers.SerializerMethodField(read_only=True) - - def get_certificate(self, enrollment): - """ - Resolve a certificate for this enrollment if it exists - """ - # When create method is called it returns list object of enrollments - if isinstance(enrollment, list): - enrollment = enrollment[0] if enrollment else None - - # No need to include a certificate if there is no corresponding wagtail page - # to support the render - try: - if ( - not enrollment - or not enrollment.run.course.page - or not enrollment.run.course.page.certificate_page - ): - return None - except models.Course.page.RelatedObjectDoesNotExist: - return None - - # Using IDs because we don't need the actual record and this avoids redundant queries - user_id = enrollment.user_id - course_run_id = enrollment.run_id - try: - return CourseRunCertificateSerializer( - models.CourseRunCertificate.objects.get( - user_id=user_id, course_run_id=course_run_id - ) - ).data - except models.CourseRunCertificate.DoesNotExist: - return None - - def get_approved_flexible_price_exists(self, instance): - instance_run = instance[0].run if isinstance(instance, list) else instance.run - instance_user = ( - instance[0].user if isinstance(instance, list) else instance.user - ) - flexible_price_exists = is_courseware_flexible_price_approved( - instance_run, instance_user - ) - return flexible_price_exists - - def get_grades(self, instance): - instance_run = instance[0].run if isinstance(instance, list) else instance.run - instance_user = ( - instance[0].user if isinstance(instance, list) else instance.user - ) - - return CourseRunGradeSerializer( - instance=models.CourseRunGrade.objects.filter( - user=instance_user, course_run=instance_run - ).all(), - many=True, - ).data - - class Meta: - model = models.CourseRunEnrollment - fields = [ - "run", - "id", - "edx_emails_subscription", - "certificate", - "enrollment_mode", - "approved_flexible_price_exists", - "grades", - ] - - -class CourseRunEnrollmentSerializer(BaseCourseRunEnrollmentSerializer): - """CourseRunEnrollment model serializer""" - - run = CourseRunWithCourseSerializer(read_only=True) - run_id = serializers.IntegerField(write_only=True) - certificate = serializers.SerializerMethodField(read_only=True) - enrollment_mode = serializers.ChoiceField( - (EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE), read_only=True - ) - approved_flexible_price_exists = serializers.SerializerMethodField() - grades = serializers.SerializerMethodField(read_only=True) - - def create(self, validated_data): - user = self.context["user"] - run_id = validated_data["run_id"] - try: - run = models.CourseRun.objects.get(id=run_id) - except models.CourseRun.DoesNotExist: - raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) - successful_enrollments, edx_request_success = create_run_enrollments( - user, - [run], - keep_failed_enrollments=features.is_enabled(features.IGNORE_EDX_FAILURES), - ) - return successful_enrollments - - class Meta(BaseCourseRunEnrollmentSerializer.Meta): - fields = BaseCourseRunEnrollmentSerializer.Meta.fields + [ - "run_id", - ] - - -class ProgramCertificateSerializer(serializers.ModelSerializer): - """ProgramCertificate model serializer""" - - class Meta: - model = models.ProgramCertificate - fields = ["uuid", "link"] - - -class UserProgramEnrollmentDetailSerializer(serializers.Serializer): - program = ProgramSerializer() - enrollments = CourseRunEnrollmentSerializer(many=True) - certificate = serializers.SerializerMethodField(read_only=True) - - def get_certificate(self, user_program_enrollment): - """ - Resolve a certificate for this enrollment if it exists - """ - certificate = user_program_enrollment.get("certificate") - return ProgramCertificateSerializer(certificate).data if certificate else None - - -class ProgramRequirementDataSerializer(StrictFieldsSerializer): - """Serializer for ProgramRequirement data""" - - node_type = serializers.ChoiceField( - choices=( - models.ProgramRequirementNodeType.OPERATOR, - models.ProgramRequirementNodeType.COURSE, - ) - ) - course = serializers.CharField(source="course_id", allow_null=True, default=None) - program = serializers.CharField(source="program_id", required=False) - title = serializers.CharField(allow_null=True, default=None) - operator = serializers.CharField(allow_null=True, default=None) - operator_value = serializers.CharField(allow_null=True, default=None) - elective_flag = serializers.BooleanField(allow_null=True, default=False) - - -class ProgramRequirementSerializer(StrictFieldsSerializer): - """Serializer for a ProgramRequirement""" - - id = serializers.IntegerField(required=False, allow_null=True, default=None) - data = ProgramRequirementDataSerializer() - - def get_fields(self): - """Override because 'children' is a recursive structure""" - fields = super().get_fields() - fields["children"] = ProgramRequirementSerializer(many=True, default=[]) - return fields - - -class ProgramRequirementTreeSerializer(BaseProgramRequirementTreeSerializer): - - child = ProgramRequirementSerializer() - - -class PartnerSchoolSerializer(serializers.ModelSerializer): - class Meta: - model = models.PartnerSchool - fields = "__all__" - - -class LearnerProgramRecordShareSerializer(serializers.ModelSerializer): - class Meta: - model = models.LearnerProgramRecordShare - fields = "__all__" - - -class LearnerRecordSerializer(serializers.BaseSerializer): - """ - Gathers the various data needed to display the learner's program record. - Pass the program you want the record for and attach the learner via context - object. - """ - - def to_representation(self, instance): - """ - Returns formatted data. - - Args: - - instance (Program): The program to retrieve data for. - """ - user = None - - if "request" in self.context: - if not isinstance(self.context["request"].user, AnonymousUser): - user = self.context["request"].user - - if "user" in self.context and isinstance(self.context["user"], User): - user = self.context["user"] - - if user is None: - raise ValidationError("Valid user object not found") - - courses = [] - for course, requirement_type in instance.courses: - fmt_course = { - "title": course.title, - "id": course.id, - "readable_id": course.readable_id, - "reqtype": requirement_type, - "grade": None, - "certificate": None, - } - runs_ids = models.CourseRunCertificate.objects.filter( - user=user, course_run__course=course, is_revoked=False - ).values_list("course_run__id", flat=True) - - if not runs_ids: - # if there are no certificates then show verified enrollment grades that either - # certificate available date has passed or course has ended if no certificate available date - runs_ids = models.CourseRunEnrollment.objects.filter( - Q(user=user) - & Q(run__course=course) - & Q(enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE) - & Q(change_status=None) - & ( - Q(run__certificate_available_date__lt=now_in_utc()) - | ( - Q(run__certificate_available_date=None) - & Q(run__end_date__lt=now_in_utc()) - ) - ) - ).values_list("run__id", flat=True) - - grade = ( - models.CourseRunGrade.objects.filter(user=user, course_run__in=runs_ids) - .order_by("-grade") - .first() - ) - - if grade is not None: - grade.grade = round(grade.grade, 2) - fmt_course["grade"] = CourseRunGradeSerializer(grade).data - - certificate = ( - models.CourseRunCertificate.objects.filter( - user=user, course_run__course=course, is_revoked=False - ) - .order_by("-created_on") - .first() - ) - - if certificate is not None: - fmt_course["certificate"] = CourseRunCertificateSerializer( - certificate - ).data - - courses.append(fmt_course) - - shares = models.LearnerProgramRecordShare.objects.filter( - user=user, program=instance, is_active=True - ).all() - - output = { - "user": { - "name": user.name, - "email": user.email, - "username": user.username, - }, - "program": { - "title": instance.title, - "readable_id": instance.readable_id, - "courses": courses, - "requirements": ProgramRequirementTreeSerializer( - instance.requirements_root - ).data, - }, - "sharing": LearnerProgramRecordShareSerializer(shares, many=True).data - if "anonymous_pull" not in self.context - else [], - "partner_schools": PartnerSchoolSerializer( - models.PartnerSchool.objects.all(), many=True - ).data - if "anonymous_pull" not in self.context - else [], - } - - return output diff --git a/courses/serializers/v1/base.py b/courses/serializers/v1/base.py new file mode 100644 index 0000000000..93dc6a8018 --- /dev/null +++ b/courses/serializers/v1/base.py @@ -0,0 +1,174 @@ +from rest_framework import serializers + +from cms.serializers import CoursePageSerializer +from courses import models +from courses.constants import CONTENT_TYPE_MODEL_COURSE, CONTENT_TYPE_MODEL_PROGRAM +from ecommerce.serializers import ProductFlexibilePriceSerializer +from flexiblepricing.api import is_courseware_flexible_price_approved +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE + + +class BaseCourseSerializer(serializers.ModelSerializer): + """Basic course model serializer""" + + type = serializers.SerializerMethodField(read_only=True) + + def to_representation(self, instance): + data = super().to_representation(instance) + if not self.context.get("include_page_fields") or not hasattr(instance, "page"): + return data + return {**data, **CoursePageSerializer(instance=instance.page).data} + + @staticmethod + def get_type(obj): + return CONTENT_TYPE_MODEL_COURSE + + class Meta: + model = models.Course + fields = [ + "id", + "title", + "readable_id", + "type", + ] + + +class BaseCourseRunSerializer(serializers.ModelSerializer): + """Minimal CourseRun model serializer""" + + class Meta: + model = models.CourseRun + fields = [ + "title", + "start_date", + "end_date", + "enrollment_start", + "enrollment_end", + "expiration_date", + "courseware_url", + "courseware_id", + "certificate_available_date", + "upgrade_deadline", + "is_upgradable", + "is_self_paced", + "run_tag", + "id", + "live", + "course_number", + ] + + +class BaseProgramSerializer(serializers.ModelSerializer): + """Basic program model serializer""" + + type = serializers.SerializerMethodField(read_only=True) + + @staticmethod + def get_type(obj): + return CONTENT_TYPE_MODEL_PROGRAM + + class Meta: + model = models.Program + fields = ["title", "readable_id", "id", "type"] + + +class BaseCourseRunEnrollmentSerializer(serializers.ModelSerializer): + certificate = serializers.SerializerMethodField(read_only=True) + enrollment_mode = serializers.ChoiceField( + (EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE), read_only=True + ) + approved_flexible_price_exists = serializers.SerializerMethodField() + grades = serializers.SerializerMethodField(read_only=True) + + def get_certificate(self, enrollment): + """ + Resolve a certificate for this enrollment if it exists + """ + # When create method is called it returns list object of enrollments + if isinstance(enrollment, list): + enrollment = enrollment[0] if enrollment else None + + # No need to include a certificate if there is no corresponding wagtail page + # to support the render + try: + if ( + not enrollment + or not enrollment.run.course.page + or not enrollment.run.course.page.certificate_page + ): + return None + except models.Course.page.RelatedObjectDoesNotExist: + return None + + # Using IDs because we don't need the actual record and this avoids redundant queries + user_id = enrollment.user_id + course_run_id = enrollment.run_id + try: + return CourseRunCertificateSerializer( + models.CourseRunCertificate.objects.get( + user_id=user_id, course_run_id=course_run_id + ) + ).data + except models.CourseRunCertificate.DoesNotExist: + return None + + def get_approved_flexible_price_exists(self, instance): + instance_run = instance[0].run if isinstance(instance, list) else instance.run + instance_user = ( + instance[0].user if isinstance(instance, list) else instance.user + ) + flexible_price_exists = is_courseware_flexible_price_approved( + instance_run, instance_user + ) + return flexible_price_exists + + def get_grades(self, instance): + instance_run = instance[0].run if isinstance(instance, list) else instance.run + instance_user = ( + instance[0].user if isinstance(instance, list) else instance.user + ) + + return CourseRunGradeSerializer( + instance=models.CourseRunGrade.objects.filter( + user=instance_user, course_run=instance_run + ).all(), + many=True, + ).data + + class Meta: + model = models.CourseRunEnrollment + fields = [ + "run", + "id", + "edx_emails_subscription", + "certificate", + "enrollment_mode", + "approved_flexible_price_exists", + "grades", + ] + + +class ProductRelatedField(serializers.RelatedField): + """serializer for the Product generic field""" + + def to_representation(self, instance): + serializer = ProductFlexibilePriceSerializer( + instance=instance, context=self.context + ) + return serializer.data + + +class CourseRunCertificateSerializer(serializers.ModelSerializer): + """CourseRunCertificate model serializer""" + + class Meta: + model = models.CourseRunCertificate + fields = ["uuid", "link"] + + +class CourseRunGradeSerializer(serializers.ModelSerializer): + """CourseRunGrade serializer""" + + class Meta: + model = models.CourseRunGrade + fields = ["grade", "letter_grade", "passed", "set_by_admin", "grade_percent"] diff --git a/courses/serializers/v1/base_test.py b/courses/serializers/v1/base_test.py new file mode 100644 index 0000000000..0972eb4b17 --- /dev/null +++ b/courses/serializers/v1/base_test.py @@ -0,0 +1,30 @@ +import pytest + +from courses.factories import ProgramFactory, CourseFactory +from courses.serializers.v1.base import BaseProgramSerializer, BaseCourseSerializer + +pytestmark = [pytest.mark.django_db] + + +def test_base_program_serializer(): + """Test BaseProgramSerializer serialization""" + program = ProgramFactory.create() + data = BaseProgramSerializer(program).data + assert data == { + "title": program.title, + "readable_id": program.readable_id, + "id": program.id, + "type": "program", + } + + +def test_base_course_serializer(): + """Test CourseRun serialization""" + course = CourseFactory.create() + data = BaseCourseSerializer(course).data + assert data == { + "title": course.title, + "readable_id": course.readable_id, + "id": course.id, + "type": "course", + } diff --git a/courses/serializers/v1/courses.py b/courses/serializers/v1/courses.py new file mode 100644 index 0000000000..296ab738ca --- /dev/null +++ b/courses/serializers/v1/courses.py @@ -0,0 +1,146 @@ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from cms.serializers import CoursePageSerializer +from courses import models +from courses.api import create_run_enrollments +from courses.serializers.v1.base import BaseCourseSerializer, BaseCourseRunEnrollmentSerializer, BaseCourseRunSerializer, ProductRelatedField +from courses.serializers.v1.departments import DepartmentSerializer +from flexiblepricing.api import is_courseware_flexible_price_approved +from main import features +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE + + +class CourseSerializer(BaseCourseSerializer): + """Course model serializer""" + + departments = DepartmentSerializer(many=True, read_only=True) + next_run_id = serializers.SerializerMethodField() + page = CoursePageSerializer(read_only=True) + programs = serializers.SerializerMethodField() + + def get_next_run_id(self, instance): + """Get next run id""" + run = instance.first_unexpired_run + return run.id if run is not None else None + + def get_programs(self, instance): + if self.context.get("all_runs", False): + from courses.serializers.v1.base import BaseProgramSerializer + + return BaseProgramSerializer(instance.programs, many=True).data + + return None + + class Meta: + model = models.Course + fields = [ + "id", + "title", + "readable_id", + "next_run_id", + "departments", + "page", + "programs", + ] + + +class CourseRunSerializer(BaseCourseRunSerializer): + """CourseRun model serializer""" + + products = ProductRelatedField(many=True, read_only=True) + approved_flexible_price_exists = serializers.SerializerMethodField() + + class Meta: + model = models.CourseRun + fields = BaseCourseRunSerializer.Meta.fields + [ + "products", + "approved_flexible_price_exists", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + if self.context and self.context.get("include_enrolled_flag"): + return { + **data, + **{ + "is_enrolled": getattr(instance, "user_enrollments", 0) > 0, + "is_verified": getattr(instance, "verified_enrollments", 0) > 0, + }, + } + return data + + def get_approved_flexible_price_exists(self, instance): + # Get the User object if it exists. + user = self.context["request"].user if "request" in self.context else None + + # Check for an approved flexible price record if the + # user exists and has an ID (not an Anonymous user). + # Otherwise return False. + flexible_price_exists = ( + is_courseware_flexible_price_approved( + instance, self.context["request"].user + ) + if user and user.id + else False + ) + return flexible_price_exists + + +class CourseWithCourseRunsSerializer(CourseSerializer): + """Course model serializer - also serializes child course runs""" + + courseruns = CourseRunSerializer(many=True, read_only=True) + + class Meta: + model = models.Course + fields = CourseSerializer.Meta.fields + [ + "courseruns", + ] + + +class CourseRunWithCourseSerializer(CourseRunSerializer): + """ + CourseRun model serializer - also serializes the parent Course. + """ + + course = CourseSerializer(read_only=True, context={"include_page_fields": True}) + + class Meta: + model = models.CourseRun + fields = CourseRunSerializer.Meta.fields + [ + "course", + ] + + +class CourseRunEnrollmentSerializer(BaseCourseRunEnrollmentSerializer): + """CourseRunEnrollment model serializer""" + + run = CourseRunWithCourseSerializer(read_only=True) + run_id = serializers.IntegerField(write_only=True) + certificate = serializers.SerializerMethodField(read_only=True) + enrollment_mode = serializers.ChoiceField( + (EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE), read_only=True + ) + approved_flexible_price_exists = serializers.SerializerMethodField() + grades = serializers.SerializerMethodField(read_only=True) + + def create(self, validated_data): + user = self.context["user"] + run_id = validated_data["run_id"] + try: + run = models.CourseRun.objects.get(id=run_id) + except models.CourseRun.DoesNotExist: + raise ValidationError({"run_id": f"Invalid course run id: {run_id}"}) + successful_enrollments, edx_request_success = create_run_enrollments( + user, + [run], + keep_failed_enrollments=features.is_enabled(features.IGNORE_EDX_FAILURES), + ) + return successful_enrollments + + class Meta(BaseCourseRunEnrollmentSerializer.Meta): + fields = BaseCourseRunEnrollmentSerializer.Meta.fields + [ + "run_id", + ] + diff --git a/courses/serializers/v1/courses_test.py b/courses/serializers/v1/courses_test.py new file mode 100644 index 0000000000..03461a8ef7 --- /dev/null +++ b/courses/serializers/v1/courses_test.py @@ -0,0 +1,236 @@ +import bleach +import pytest +from django.contrib.auth.models import AnonymousUser + +from cms.factories import FlexiblePricingFormFactory, CoursePageFactory +from cms.serializers import CoursePageSerializer +from courses.factories import CourseRunFactory, CourseRunEnrollmentFactory, CourseRunGradeFactory +from courses.models import Department +from courses.serializers.v1.base import BaseCourseSerializer, CourseRunGradeSerializer +from courses.serializers.v1.courses import CourseRunSerializer, CourseWithCourseRunsSerializer, CourseSerializer, \ + CourseRunWithCourseSerializer, CourseRunEnrollmentSerializer +from courses.serializers.v1.programs import ProgramSerializer +from ecommerce.serializers import BaseProductSerializer +from flexiblepricing.constants import FlexiblePriceStatus +from flexiblepricing.factories import FlexiblePriceFactory +from main import features +from main.test_utils import assert_drf_json_equal, drf_datetime + +pytestmark = [pytest.mark.django_db] + + +@pytest.mark.parametrize("is_anonymous", [True, False]) +@pytest.mark.parametrize("all_runs", [True, False]) +def test_serialize_course(mocker, mock_context, is_anonymous, all_runs, settings): + """Test Course serialization""" + settings.FEATURES[features.ENABLE_NEW_DESIGN] = True + if is_anonymous: + mock_context["request"].user = AnonymousUser() + if all_runs: + mock_context["all_runs"] = True + user = mock_context["request"].user + courseRun1 = CourseRunFactory.create() + courseRun2 = CourseRunFactory.create(course=courseRun1.course) + course = courseRun1.course + department = "a course departments" + course.departments.set([Department.objects.create(name=department)]) + + CourseRunEnrollmentFactory.create( + run=courseRun1, **({} if is_anonymous else {"user": user}) + ) + + data = CourseWithCourseRunsSerializer(instance=course, context=mock_context).data + + assert_drf_json_equal( + data, + { + "title": course.title, + "readable_id": course.readable_id, + "id": course.id, + "courseruns": [ + CourseRunSerializer(courseRun1).data, + CourseRunSerializer(courseRun2).data, + ], + "next_run_id": course.first_unexpired_run.id, + "departments": [{"name": department}], + "page": CoursePageSerializer(course.page).data, + "programs": ProgramSerializer(course.programs, many=True).data + if all_runs + else None, + }, + ) + + +@pytest.mark.parametrize("financial_assistance_available", [True, False]) +def test_serialize_course_with_page_fields( + mocker, mock_context, financial_assistance_available +): + """ + Tests course serialization with Page fields and Financial Assistance form. + """ + fake_image_src = "http://example.com/my.img" + patched_get_wagtail_src = mocker.patch( + "cms.serializers.get_wagtail_img_src", return_value=fake_image_src + ) + if financial_assistance_available: + financial_assistance_form = FlexiblePricingFormFactory() + course_page = financial_assistance_form.get_parent() + course_page.product.program = None + expected_financial_assistance_url = ( + f"{course_page.get_url()}{financial_assistance_form.slug}/" + ) + else: + course_page = CoursePageFactory.create() + course_page.product.program = None + expected_financial_assistance_url = "" + course = course_page.course + data = BaseCourseSerializer( + instance=course, context={**mock_context, "include_page_fields": True} + ).data + assert_drf_json_equal( + data, + { + "title": course.title, + "readable_id": course.readable_id, + "id": course.id, + "type": "course", + "feature_image_src": fake_image_src, + "page_url": None, + "financial_assistance_form_url": expected_financial_assistance_url, + "instructors": [], + "current_price": None, + "description": bleach.clean(course_page.description, tags=[], strip=True), + "live": True, + "effort": course_page.effort, + "length": course_page.length, + }, + ) + patched_get_wagtail_src.assert_called_once_with(course_page.feature_image) + + +def test_serialize_course_run(): + """Test CourseRun serialization""" + course_run = CourseRunFactory.create(course__page=None) + course_run.refresh_from_db() + + data = CourseRunSerializer(course_run).data + assert_drf_json_equal( + data, + { + "title": course_run.title, + "courseware_id": course_run.courseware_id, + "run_tag": course_run.run_tag, + "courseware_url": course_run.courseware_url, + "start_date": drf_datetime(course_run.start_date), + "end_date": drf_datetime(course_run.end_date), + "enrollment_start": drf_datetime(course_run.enrollment_start), + "enrollment_end": drf_datetime(course_run.enrollment_end), + "expiration_date": drf_datetime(course_run.expiration_date), + "upgrade_deadline": drf_datetime(course_run.upgrade_deadline), + "is_upgradable": course_run.is_upgradable, + "id": course_run.id, + "products": [], + "approved_flexible_price_exists": False, + "live": True, + "is_self_paced": course_run.is_self_paced, + "certificate_available_date": drf_datetime( + course_run.certificate_available_date + ), + "course_number": course_run.course_number, + }, + ) + + +def test_serialize_course_run_with_course(): + """Test CoursePageDepartmentsSerializer serialization""" + course_run = CourseRunFactory.create(course__page=None) + data = CourseRunWithCourseSerializer(course_run).data + + assert data == { + "course": CourseSerializer(course_run.course).data, + "course_number": course_run.course_number, + "title": course_run.title, + "courseware_id": course_run.courseware_id, + "courseware_url": course_run.courseware_url, + "start_date": drf_datetime(course_run.start_date), + "end_date": drf_datetime(course_run.end_date), + "enrollment_start": drf_datetime(course_run.enrollment_start), + "enrollment_end": drf_datetime(course_run.enrollment_end), + "expiration_date": drf_datetime(course_run.expiration_date), + "upgrade_deadline": drf_datetime(course_run.upgrade_deadline), + "certificate_available_date": drf_datetime( + course_run.certificate_available_date + ), + "is_upgradable": course_run.is_upgradable, + "is_self_paced": False, + "id": course_run.id, + "products": BaseProductSerializer(course_run.products, many=True).data, + "approved_flexible_price_exists": False, + "live": True, + "run_tag": course_run.run_tag, + } + + +@pytest.mark.parametrize("receipts_enabled", [True, False]) +def test_serialize_course_run_enrollments(settings, receipts_enabled): + """Test that CourseRunEnrollmentSerializer has correct data""" + settings.ENABLE_ORDER_RECEIPTS = receipts_enabled + course_run_enrollment = CourseRunEnrollmentFactory.create() + serialized_data = CourseRunEnrollmentSerializer(course_run_enrollment).data + assert serialized_data == { + "run": CourseRunWithCourseSerializer(course_run_enrollment.run).data, + "id": course_run_enrollment.id, + "edx_emails_subscription": True, + "enrollment_mode": "audit", + "certificate": None, + "approved_flexible_price_exists": False, + "grades": [], + } + + +@pytest.mark.parametrize("approved_flexible_price_exists", [True, False]) +def test_serialize_course_run_enrollments_with_flexible_pricing( + approved_flexible_price_exists, +): + """Test that CourseRunEnrollmentSerializer has correct data""" + course_run_enrollment = CourseRunEnrollmentFactory.create() + if approved_flexible_price_exists: + status = FlexiblePriceStatus.APPROVED + else: + status = FlexiblePriceStatus.PENDING_MANUAL_APPROVAL + + FlexiblePriceFactory.create( + user=course_run_enrollment.user, + courseware_object=course_run_enrollment.run.course, + status=status, + ) + serialized_data = CourseRunEnrollmentSerializer(course_run_enrollment).data + assert serialized_data == { + "run": CourseRunWithCourseSerializer(course_run_enrollment.run).data, + "id": course_run_enrollment.id, + "edx_emails_subscription": True, + "enrollment_mode": "audit", + "approved_flexible_price_exists": approved_flexible_price_exists, + "certificate": None, + "grades": [], + } + + +def test_serialize_course_run_enrollments_with_grades(): + """Test that CourseRunEnrollmentSerializer has correct data""" + course_run_enrollment = CourseRunEnrollmentFactory.create() + + grade = CourseRunGradeFactory.create( + course_run=course_run_enrollment.run, user=course_run_enrollment.user + ) + + serialized_data = CourseRunEnrollmentSerializer(course_run_enrollment).data + assert serialized_data == { + "run": CourseRunWithCourseSerializer(course_run_enrollment.run).data, + "id": course_run_enrollment.id, + "edx_emails_subscription": True, + "enrollment_mode": "audit", + "approved_flexible_price_exists": False, + "certificate": None, + "grades": CourseRunGradeSerializer([grade], many=True).data, + } diff --git a/courses/serializers/v1/departments.py b/courses/serializers/v1/departments.py new file mode 100644 index 0000000000..e0c2f1514a --- /dev/null +++ b/courses/serializers/v1/departments.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from courses import models + + +class DepartmentSerializer(serializers.ModelSerializer): + """Department model serializer""" + + class Meta: + model = models.Department + fields = ["name"] + + +class DepartmentWithCountSerializer(DepartmentSerializer): + """CourseRun model serializer that includes the number of courses and programs associated with each departments""" + + courses = serializers.IntegerField() + programs = serializers.IntegerField() + + class Meta: + model = models.Department + fields = DepartmentSerializer.Meta.fields + [ + "courses", + "programs", + ] diff --git a/courses/serializers/v1/departments_test.py b/courses/serializers/v1/departments_test.py new file mode 100644 index 0000000000..27c5f0483d --- /dev/null +++ b/courses/serializers/v1/departments_test.py @@ -0,0 +1,4 @@ +import pytest + + +pytestmark = [pytest.mark.django_db] diff --git a/courses/serializers/v1/programs.py b/courses/serializers/v1/programs.py new file mode 100644 index 0000000000..8ebd1b8fcb --- /dev/null +++ b/courses/serializers/v1/programs.py @@ -0,0 +1,310 @@ +from django.contrib.auth.models import AnonymousUser +from django.db.models import Q +from mitol.common.utils import now_in_utc +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from cms.serializers import ProgramPageSerializer +from courses import models +from courses.serializers import get_thumbnail_url, BaseProgramRequirementTreeSerializer +from courses.serializers.v1.departments import DepartmentSerializer +from courses.serializers.v1.courses import CourseWithCourseRunsSerializer, CourseRunEnrollmentSerializer +from courses.serializers.v1.base import CourseRunCertificateSerializer, CourseRunGradeSerializer +from main.serializers import StrictFieldsSerializer +from openedx.constants import EDX_ENROLLMENT_VERIFIED_MODE +from users.models import User + + +class ProgramSerializer(serializers.ModelSerializer): + """Program model serializer""" + + courses = serializers.SerializerMethodField() + requirements = serializers.SerializerMethodField() + req_tree = serializers.SerializerMethodField() + page = serializers.SerializerMethodField() + departments = DepartmentSerializer(many=True, read_only=True) + + def get_courses(self, instance): + """Serializer for courses""" + return CourseWithCourseRunsSerializer( + [course[0] for course in instance.courses if course[0].live], + many=True, + context={"include_page_fields": True}, + ).data + + def get_requirements(self, instance): + return { + "required": [course.id for course in instance.required_courses], + "electives": [course.id for course in instance.elective_courses], + } + + def get_req_tree(self, instance): + req_root = instance.get_requirements_root() + + if req_root is None: + return [] + + return ProgramRequirementTreeSerializer(instance=req_root).data + + def get_page(self, instance): + if hasattr(instance, "page"): + return ProgramPageSerializer(instance.page).data + else: + return {"feature_image_src": get_thumbnail_url(None)} + + class Meta: + model = models.Program + fields = [ + "title", + "readable_id", + "id", + "courses", + "requirements", + "req_tree", + "page", + "program_type", + "departments", + "live", + ] + + +class FullProgramSerializer(ProgramSerializer): + """Adds more data to the ProgramSerializer.""" + + start_date = serializers.SerializerMethodField() + end_date = serializers.SerializerMethodField() + enrollment_start = serializers.SerializerMethodField() + + def get_start_date(self, instance): + """ + start_date is the starting date for the earliest live course run for all courses in a program + + Returns: + datetime: The starting date + """ + courses_in_program = [course[0] for course in instance.courses] + return ( + models.CourseRun.objects.filter(course__in=courses_in_program, live=True) + .order_by("start_date") + .values_list("start_date", flat=True) + .first() + ) + + def get_end_date(self, instance): + """ + end_date is the end date for the latest live course run for all courses in a program. + + Returns: + datetime: The ending date + """ + courses_in_program = [course[0] for course in instance.courses] + return ( + models.CourseRun.objects.filter(course__in=courses_in_program, live=True) + .order_by("end_date") + .values_list("end_date", flat=True) + .last() + ) + + def get_enrollment_start(self, instance): + """ + enrollment_start is first date where enrollment starts for any live course run + """ + courses_in_program = [course[0] for course in instance.courses] + return ( + models.CourseRun.objects.filter(course__in=courses_in_program, live=True) + .order_by("enrollment_start") + .values_list("enrollment_start", flat=True) + .first() + ) + + class Meta(ProgramSerializer.Meta): + fields = ProgramSerializer.Meta.fields + [ + "title", + "readable_id", + "id", + "courses", + "num_courses", + "requirements", + "req_tree", + ] + + +class ProgramCertificateSerializer(serializers.ModelSerializer): + """ProgramCertificate model serializer""" + + class Meta: + model = models.ProgramCertificate + fields = ["uuid", "link"] + + +class UserProgramEnrollmentDetailSerializer(serializers.Serializer): + program = ProgramSerializer() + enrollments = CourseRunEnrollmentSerializer(many=True) + certificate = serializers.SerializerMethodField(read_only=True) + + def get_certificate(self, user_program_enrollment): + """ + Resolve a certificate for this enrollment if it exists + """ + certificate = user_program_enrollment.get("certificate") + return ProgramCertificateSerializer(certificate).data if certificate else None + + +class ProgramRequirementDataSerializer(StrictFieldsSerializer): + """Serializer for ProgramRequirement data""" + + node_type = serializers.ChoiceField( + choices=( + models.ProgramRequirementNodeType.OPERATOR, + models.ProgramRequirementNodeType.COURSE, + ) + ) + course = serializers.CharField(source="course_id", allow_null=True, default=None) + program = serializers.CharField(source="program_id", required=False) + title = serializers.CharField(allow_null=True, default=None) + operator = serializers.CharField(allow_null=True, default=None) + operator_value = serializers.CharField(allow_null=True, default=None) + elective_flag = serializers.BooleanField(allow_null=True, default=False) + + +class ProgramRequirementSerializer(StrictFieldsSerializer): + """Serializer for a ProgramRequirement""" + + id = serializers.IntegerField(required=False, allow_null=True, default=None) + data = ProgramRequirementDataSerializer() + + def get_fields(self): + """Override because 'children' is a recursive structure""" + fields = super().get_fields() + fields["children"] = ProgramRequirementSerializer(many=True, default=[]) + return fields + + +class ProgramRequirementTreeSerializer(BaseProgramRequirementTreeSerializer): + + child = ProgramRequirementSerializer() + + +class PartnerSchoolSerializer(serializers.ModelSerializer): + class Meta: + model = models.PartnerSchool + fields = "__all__" + + +class LearnerProgramRecordShareSerializer(serializers.ModelSerializer): + class Meta: + model = models.LearnerProgramRecordShare + fields = "__all__" + + +class LearnerRecordSerializer(serializers.BaseSerializer): + """ + Gathers the various data needed to display the learner's program record. + Pass the program you want the record for and attach the learner via context + object. + """ + + def to_representation(self, instance): + """ + Returns formatted data. + + Args: + - instance (Program): The program to retrieve data for. + """ + user = None + + if "request" in self.context: + if not isinstance(self.context["request"].user, AnonymousUser): + user = self.context["request"].user + + if "user" in self.context and isinstance(self.context["user"], User): + user = self.context["user"] + + if user is None: + raise ValidationError("Valid user object not found") + + courses = [] + for course, requirement_type in instance.courses: + fmt_course = { + "title": course.title, + "id": course.id, + "readable_id": course.readable_id, + "reqtype": requirement_type, + "grade": None, + "certificate": None, + } + runs_ids = models.CourseRunCertificate.objects.filter( + user=user, course_run__course=course, is_revoked=False + ).values_list("course_run__id", flat=True) + + if not runs_ids: + # if there are no certificates then show verified enrollment grades that either + # certificate available date has passed or course has ended if no certificate available date + runs_ids = models.CourseRunEnrollment.objects.filter( + Q(user=user) + & Q(run__course=course) + & Q(enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE) + & Q(change_status=None) + & ( + Q(run__certificate_available_date__lt=now_in_utc()) + | ( + Q(run__certificate_available_date=None) + & Q(run__end_date__lt=now_in_utc()) + ) + ) + ).values_list("run__id", flat=True) + + grade = ( + models.CourseRunGrade.objects.filter(user=user, course_run__in=runs_ids) + .order_by("-grade") + .first() + ) + + if grade is not None: + grade.grade = round(grade.grade, 2) + fmt_course["grade"] = CourseRunGradeSerializer(grade).data + + certificate = ( + models.CourseRunCertificate.objects.filter( + user=user, course_run__course=course, is_revoked=False + ) + .order_by("-created_on") + .first() + ) + + if certificate is not None: + fmt_course["certificate"] = CourseRunCertificateSerializer( + certificate + ).data + + courses.append(fmt_course) + + shares = models.LearnerProgramRecordShare.objects.filter( + user=user, program=instance, is_active=True + ).all() + + output = { + "user": { + "name": user.name, + "email": user.email, + "username": user.username, + }, + "program": { + "title": instance.title, + "readable_id": instance.readable_id, + "courses": courses, + "requirements": ProgramRequirementTreeSerializer( + instance.requirements_root + ).data, + }, + "sharing": LearnerProgramRecordShareSerializer(shares, many=True).data + if "anonymous_pull" not in self.context + else [], + "partner_schools": PartnerSchoolSerializer( + models.PartnerSchool.objects.all(), many=True + ).data + if "anonymous_pull" not in self.context + else [], + } + + return output diff --git a/courses/serializers/v1/programs_test.py b/courses/serializers/v1/programs_test.py new file mode 100644 index 0000000000..a6c8467d8c --- /dev/null +++ b/courses/serializers/v1/programs_test.py @@ -0,0 +1,379 @@ +import pytest +from datetime import timedelta +from decimal import Decimal + +from django.utils.timezone import now +from mitol.common.utils import now_in_utc + +from cms.serializers import ProgramPageSerializer +from courses.factories import CourseRunFactory, ProgramFactory, CourseFactory, CourseRunEnrollmentFactory, \ + CourseRunGradeFactory, program_with_empty_requirements +from courses.models import Department, ProgramRequirementNodeType, ProgramRequirement +from courses.serializers.v1.courses import CourseWithCourseRunsSerializer +from courses.serializers.v1.programs import ProgramSerializer, LearnerRecordSerializer, ProgramRequirementSerializer, ProgramRequirementTreeSerializer +from main.test_utils import assert_drf_json_equal +from openedx.constants import EDX_ENROLLMENT_VERIFIED_MODE, EDX_ENROLLMENT_AUDIT_MODE + +pytestmark = [pytest.mark.django_db] + +@pytest.mark.parametrize( + "remove_tree", + [True, False], +) +def test_serialize_program(mock_context, remove_tree, program_with_empty_requirements): + """Test Program serialization""" + run1 = CourseRunFactory.create( + course__page=None, + start_date=now() + timedelta(hours=1), + ) + course1 = run1.course + run2 = CourseRunFactory.create( + course__page=None, + start_date=now() + timedelta(hours=2), + ) + course2 = run2.course + runs = ( + [run1, run2] + + [ + CourseRunFactory.create( + course=course1, start_date=now() + timedelta(hours=3) + ) + for _ in range(2) + ] + + [ + CourseRunFactory.create( + course=course2, start_date=now() + timedelta(hours=3) + ) + for _ in range(2) + ] + ) + departments = [ + Department.objects.create(name=f"department{num}") for num in range(3) + ] + course1.departments.set([departments[0], departments[1]]) + course2.departments.set([departments[1], departments[2]]) + + formatted_reqs = {"required": [], "electives": []} + + if not remove_tree: + program_with_empty_requirements.add_requirement(course1) + program_with_empty_requirements.add_requirement(course2) + formatted_reqs["required"] = [ + course.id for course in program_with_empty_requirements.required_courses + ] + formatted_reqs["electives"] = [ + course.id for course in program_with_empty_requirements.elective_courses + ] + + data = ProgramSerializer( + instance=program_with_empty_requirements, context=mock_context + ).data + + assert_drf_json_equal( + data, + { + "title": program_with_empty_requirements.title, + "readable_id": program_with_empty_requirements.readable_id, + "id": program_with_empty_requirements.id, + "courses": [ + CourseWithCourseRunsSerializer( + instance=course, context={**mock_context} + ).data + for course in [course1, course2] + ] + if not remove_tree + else [], + "requirements": formatted_reqs, + "req_tree": ProgramRequirementTreeSerializer( + program_with_empty_requirements.requirements_root + ).data, + "page": ProgramPageSerializer(program_with_empty_requirements.page).data, + "program_type": "Series", + "departments": [], + "live": True, + }, + ) + + +def test_program_requirement_tree_serializer_valid(): + """Verify that the ProgramRequirementTreeSerializer validates data""" + program = ProgramFactory.create() + course1, course2, course3 = CourseFactory.create_batch(3) + root = program.requirements_root + + serializer = ProgramRequirementTreeSerializer( + instance=root, + data=[ + { + "data": { + "node_type": "operator", + "title": "Required Courses", + "operator": "all_of", + }, + "children": [ + {"id": None, "data": {"node_type": "course", "course": course1.id}} + ], + }, + { + "data": { + "node_type": "operator", + "title": "Elective Courses", + "operator": "min_number_of", + "operator_value": "1", + }, + "children": [], + }, + ], + context={"program": program}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + +def test_program_requirement_deletion(): + """Verify that saving the requirements for one program doesn't affect other programs""" + + courses = CourseFactory.create_batch(3) + + program1 = ProgramFactory.create() + program2 = ProgramFactory.create() + root1 = program1.requirements_root + root2 = program2.requirements_root + + for root in [root1, root2]: + program = root.program + # build the same basic tree structure for both + required = root.add_child( + program=program, + node_type=ProgramRequirementNodeType.OPERATOR, + title="Required", + operator=ProgramRequirement.Operator.ALL_OF, + ) + for course in courses: + required.add_child( + program=program, + node_type=ProgramRequirementNodeType.COURSE, + course=course, + ) + + expected = list(ProgramRequirement.get_tree(parent=root2)) + + # this will delete everything under this tree + serializer = ProgramRequirementTreeSerializer(instance=root1, data=[]) + serializer.is_valid(raise_exception=True) + serializer.save() + + assert list(ProgramRequirement.get_tree(parent=root1)) == [ + root1 + ] # just the one root node + assert list(ProgramRequirement.get_tree(parent=root2)) == expected + + +@pytest.mark.parametrize( + "enrollment_mode", [EDX_ENROLLMENT_VERIFIED_MODE, EDX_ENROLLMENT_AUDIT_MODE] +) +def test_learner_record_serializer( + mock_context, program_with_empty_requirements, enrollment_mode +): + """Verify that saving the requirements for one program doesn't affect other programs""" + + program = program_with_empty_requirements + courses = CourseFactory.create_batch(3) + + user = mock_context["request"].user + + course_runs = [] + grades = [] + grade_multiplier_to_test_ordering = 1 + for course in courses: + program.add_requirement(course) + course_run = CourseRunFactory.create(course=course) + course_run_enrollment = CourseRunEnrollmentFactory.create( + run=course_run, + user=user, + enrollment_mode=enrollment_mode, + ) + course_runs.append(course_run) + + grades.append( + CourseRunGradeFactory.create( + course_run=course_run, + user=user, + grade=(0.313133 * grade_multiplier_to_test_ordering), + ) + ) + grade_multiplier_to_test_ordering += 1 + + serialized_data = LearnerRecordSerializer( + instance=program, context=mock_context + ).data + program_requirements_payload = [ + { + "children": [ + { + "children": [ + { + "data": { + "course": courses[0].id, + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "title": "", + "elective_flag": False, + }, + "id": program.get_requirements_root() + .get_children() + .first() + .get_children() + .filter(course=courses[0].id) + .first() + .id, + }, + { + "data": { + "course": courses[1].id, + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "title": "", + "elective_flag": False, + }, + "id": program.get_requirements_root() + .get_children() + .first() + .get_children() + .filter(course=courses[1].id) + .first() + .id, + }, + { + "data": { + "course": courses[2].id, + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "title": "", + "elective_flag": False, + }, + "id": program.get_requirements_root() + .get_children() + .first() + .get_children() + .filter(course=courses[2].id) + .first() + .id, + }, + ], + "data": { + "course": None, + "node_type": "operator", + "operator": ProgramRequirement.Operator.ALL_OF.value, + "operator_value": None, + "program": program.id, + "title": "Required Courses", + "elective_flag": False, + }, + "id": program.get_requirements_root().get_children().first().id, + }, + { + "data": { + "course": None, + "node_type": "operator", + "operator": ProgramRequirement.Operator.MIN_NUMBER_OF.value, + "operator_value": "1", + "program": program.id, + "title": "Elective Courses", + "elective_flag": True, + }, + "id": program.get_requirements_root().get_children().last().id, + }, + ], + "data": { + "course": None, + "node_type": "program_root", + "operator": None, + "operator_value": None, + "program": program.id, + "title": "", + "elective_flag": False, + }, + "id": program.requirements_root.id, + } + ] + user_info_payload = { + "email": user.email, + "name": user.name, + "username": user.username, + } + course_0_payload = { + "certificate": None, + "grade": { + "grade": round(grades[0].grade, 2), + "grade_percent": Decimal(grades[0].grade_percent), + "letter_grade": grades[0].letter_grade, + "passed": grades[0].passed, + "set_by_admin": grades[0].set_by_admin, + }, + "id": courses[0].id, + "readable_id": courses[0].readable_id, + "reqtype": "Required Courses", + "title": courses[0].title, + } + if enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE: + course_0_payload["grade"] = None + if course_runs[0].certificate_available_date >= now_in_utc() or ( + not course_runs[0].certificate_available_date + and course_runs[0].end_date >= now_in_utc() + ): + course_0_payload["grade"] = None + assert user_info_payload == serialized_data["user"] + assert program_requirements_payload == serialized_data["program"]["requirements"] + assert course_0_payload == serialized_data["program"]["courses"][0] + + +def test_program_serializer_returns_default_image(): + """If the program has no page, we should still get a featured_image_url.""" + + program = ProgramFactory.create(page=None) + + assert "feature_image_src" in ProgramSerializer(program).data["page"] + + +@pytest.mark.parametrize( + "data", + [ + { + "id": None, + "data": { + "node_type": ProgramRequirementNodeType.COURSE, + }, + "children": [], + }, + { + "id": 1, + "data": { + "node_type": ProgramRequirementNodeType.COURSE, + }, + "children": [], + }, + { + "id": 1, + "data": { + "node_type": ProgramRequirementNodeType.COURSE, + }, + }, + { + "data": { + "node_type": ProgramRequirementNodeType.COURSE, + }, + "children": [], + }, + ], +) +def test_program_requirement_serializer_valid(data): + """Verify that the ProgramRequirementSerializer validates data""" + serializer = ProgramRequirementSerializer(data=data) + serializer.is_valid(raise_exception=True) diff --git a/courses/serializers/v1/serializers_test.py b/courses/serializers/v1/serializers_test.py index 9f14cddfe6..eed54ed3d4 100644 --- a/courses/serializers/v1/serializers_test.py +++ b/courses/serializers/v1/serializers_test.py @@ -2,653 +2,9 @@ Tests for course serializers """ # pylint: disable=unused-argument, redefined-outer-name -from datetime import timedelta -from decimal import Decimal -import bleach import pytest -from django.contrib.auth.models import AnonymousUser -from django.utils.timezone import now - -from cms.factories import CoursePageFactory, FlexiblePricingFormFactory -from cms.serializers import ProgramPageSerializer, CoursePageSerializer -from courses.factories import ( - CourseFactory, - CourseRunEnrollmentFactory, - CourseRunFactory, - CourseRunGradeFactory, - ProgramFactory, - program_with_empty_requirements, -) -from courses.models import ( - Department, - ProgramRequirement, - ProgramRequirementNodeType, -) -from courses.serializers.v1 import ( - CourseSerializer, - BaseCourseSerializer, - BaseProgramSerializer, - CourseRunWithCourseSerializer, - CourseRunEnrollmentSerializer, - CourseRunGradeSerializer, - CourseRunSerializer, - CourseWithCourseRunsSerializer, - LearnerRecordSerializer, - ProgramRequirementSerializer, - ProgramRequirementTreeSerializer, - ProgramSerializer, -) -from ecommerce.serializers import BaseProductSerializer -from flexiblepricing.constants import FlexiblePriceStatus -from flexiblepricing.factories import FlexiblePriceFactory -from main.test_utils import assert_drf_json_equal, drf_datetime -from main import features -from mitol.common.utils.datetime import now_in_utc -from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE pytestmark = [pytest.mark.django_db] -def test_base_program_serializer(): - """Test BaseProgramSerializer serialization""" - program = ProgramFactory.create() - data = BaseProgramSerializer(program).data - assert data == { - "title": program.title, - "readable_id": program.readable_id, - "id": program.id, - "type": "program", - } - - -@pytest.mark.parametrize( - "remove_tree", - [True, False], -) -def test_serialize_program(mock_context, remove_tree, program_with_empty_requirements): - """Test Program serialization""" - run1 = CourseRunFactory.create( - course__page=None, - start_date=now() + timedelta(hours=1), - ) - course1 = run1.course - run2 = CourseRunFactory.create( - course__page=None, - start_date=now() + timedelta(hours=2), - ) - course2 = run2.course - runs = ( - [run1, run2] - + [ - CourseRunFactory.create( - course=course1, start_date=now() + timedelta(hours=3) - ) - for _ in range(2) - ] - + [ - CourseRunFactory.create( - course=course2, start_date=now() + timedelta(hours=3) - ) - for _ in range(2) - ] - ) - departments = [ - Department.objects.create(name=f"department{num}") for num in range(3) - ] - course1.departments.set([departments[0], departments[1]]) - course2.departments.set([departments[1], departments[2]]) - - formatted_reqs = {"required": [], "electives": []} - - if not remove_tree: - program_with_empty_requirements.add_requirement(course1) - program_with_empty_requirements.add_requirement(course2) - formatted_reqs["required"] = [ - course.id for course in program_with_empty_requirements.required_courses - ] - formatted_reqs["electives"] = [ - course.id for course in program_with_empty_requirements.elective_courses - ] - - data = ProgramSerializer( - instance=program_with_empty_requirements, context=mock_context - ).data - - assert_drf_json_equal( - data, - { - "title": program_with_empty_requirements.title, - "readable_id": program_with_empty_requirements.readable_id, - "id": program_with_empty_requirements.id, - "courses": [ - CourseWithCourseRunsSerializer( - instance=course, context={**mock_context} - ).data - for course in [course1, course2] - ] - if not remove_tree - else [], - "requirements": formatted_reqs, - "req_tree": ProgramRequirementTreeSerializer( - program_with_empty_requirements.requirements_root - ).data, - "page": ProgramPageSerializer(program_with_empty_requirements.page).data, - "program_type": "Series", - "departments": [], - "live": True, - }, - ) - - -def test_base_course_serializer(): - """Test CourseRun serialization""" - course = CourseFactory.create() - data = BaseCourseSerializer(course).data - assert data == { - "title": course.title, - "readable_id": course.readable_id, - "id": course.id, - "type": "course", - } - - -@pytest.mark.parametrize("is_anonymous", [True, False]) -@pytest.mark.parametrize("all_runs", [True, False]) -def test_serialize_course(mocker, mock_context, is_anonymous, all_runs, settings): - """Test Course serialization""" - settings.FEATURES[features.ENABLE_NEW_DESIGN] = True - if is_anonymous: - mock_context["request"].user = AnonymousUser() - if all_runs: - mock_context["all_runs"] = True - user = mock_context["request"].user - courseRun1 = CourseRunFactory.create() - courseRun2 = CourseRunFactory.create(course=courseRun1.course) - course = courseRun1.course - department = "a course departments" - course.departments.set([Department.objects.create(name=department)]) - - CourseRunEnrollmentFactory.create( - run=courseRun1, **({} if is_anonymous else {"user": user}) - ) - - data = CourseWithCourseRunsSerializer(instance=course, context=mock_context).data - - assert_drf_json_equal( - data, - { - "title": course.title, - "readable_id": course.readable_id, - "id": course.id, - "courseruns": [ - CourseRunSerializer(courseRun1).data, - CourseRunSerializer(courseRun2).data, - ], - "next_run_id": course.first_unexpired_run.id, - "departments": [{"name": department}], - "page": CoursePageSerializer(course.page).data, - "programs": ProgramSerializer(course.programs, many=True).data - if all_runs - else None, - }, - ) - - -@pytest.mark.parametrize("financial_assistance_available", [True, False]) -def test_serialize_course_with_page_fields( - mocker, mock_context, financial_assistance_available -): - """ - Tests course serialization with Page fields and Financial Assistance form. - """ - fake_image_src = "http://example.com/my.img" - patched_get_wagtail_src = mocker.patch( - "cms.serializers.get_wagtail_img_src", return_value=fake_image_src - ) - if financial_assistance_available: - financial_assistance_form = FlexiblePricingFormFactory() - course_page = financial_assistance_form.get_parent() - course_page.product.program = None - expected_financial_assistance_url = ( - f"{course_page.get_url()}{financial_assistance_form.slug}/" - ) - else: - course_page = CoursePageFactory.create() - course_page.product.program = None - expected_financial_assistance_url = "" - course = course_page.course - data = BaseCourseSerializer( - instance=course, context={**mock_context, "include_page_fields": True} - ).data - assert_drf_json_equal( - data, - { - "title": course.title, - "readable_id": course.readable_id, - "id": course.id, - "type": "course", - "feature_image_src": fake_image_src, - "page_url": None, - "financial_assistance_form_url": expected_financial_assistance_url, - "instructors": [], - "current_price": None, - "description": bleach.clean(course_page.description, tags=[], strip=True), - "live": True, - "effort": course_page.effort, - "length": course_page.length, - }, - ) - patched_get_wagtail_src.assert_called_once_with(course_page.feature_image) - - -def test_serialize_course_run(): - """Test CourseRun serialization""" - course_run = CourseRunFactory.create(course__page=None) - course_run.refresh_from_db() - - data = CourseRunSerializer(course_run).data - assert_drf_json_equal( - data, - { - "title": course_run.title, - "courseware_id": course_run.courseware_id, - "run_tag": course_run.run_tag, - "courseware_url": course_run.courseware_url, - "start_date": drf_datetime(course_run.start_date), - "end_date": drf_datetime(course_run.end_date), - "enrollment_start": drf_datetime(course_run.enrollment_start), - "enrollment_end": drf_datetime(course_run.enrollment_end), - "expiration_date": drf_datetime(course_run.expiration_date), - "upgrade_deadline": drf_datetime(course_run.upgrade_deadline), - "is_upgradable": course_run.is_upgradable, - "id": course_run.id, - "products": [], - "approved_flexible_price_exists": False, - "live": True, - "is_self_paced": course_run.is_self_paced, - "certificate_available_date": drf_datetime( - course_run.certificate_available_date - ), - "course_number": course_run.course_number, - }, - ) - - -def test_serialize_course_run_with_course(): - """Test CoursePageDepartmentsSerializer serialization""" - course_run = CourseRunFactory.create(course__page=None) - data = CourseRunWithCourseSerializer(course_run).data - - assert data == { - "course": CourseSerializer(course_run.course).data, - "course_number": course_run.course_number, - "title": course_run.title, - "courseware_id": course_run.courseware_id, - "courseware_url": course_run.courseware_url, - "start_date": drf_datetime(course_run.start_date), - "end_date": drf_datetime(course_run.end_date), - "enrollment_start": drf_datetime(course_run.enrollment_start), - "enrollment_end": drf_datetime(course_run.enrollment_end), - "expiration_date": drf_datetime(course_run.expiration_date), - "upgrade_deadline": drf_datetime(course_run.upgrade_deadline), - "certificate_available_date": drf_datetime( - course_run.certificate_available_date - ), - "is_upgradable": course_run.is_upgradable, - "is_self_paced": False, - "id": course_run.id, - "products": BaseProductSerializer(course_run.products, many=True).data, - "approved_flexible_price_exists": False, - "live": True, - "run_tag": course_run.run_tag, - } - - -@pytest.mark.parametrize("receipts_enabled", [True, False]) -def test_serialize_course_run_enrollments(settings, receipts_enabled): - """Test that CourseRunEnrollmentSerializer has correct data""" - settings.ENABLE_ORDER_RECEIPTS = receipts_enabled - course_run_enrollment = CourseRunEnrollmentFactory.create() - serialized_data = CourseRunEnrollmentSerializer(course_run_enrollment).data - assert serialized_data == { - "run": CourseRunWithCourseSerializer(course_run_enrollment.run).data, - "id": course_run_enrollment.id, - "edx_emails_subscription": True, - "enrollment_mode": "audit", - "certificate": None, - "approved_flexible_price_exists": False, - "grades": [], - } - - -@pytest.mark.parametrize("approved_flexible_price_exists", [True, False]) -def test_serialize_course_run_enrollments_with_flexible_pricing( - approved_flexible_price_exists, -): - """Test that CourseRunEnrollmentSerializer has correct data""" - course_run_enrollment = CourseRunEnrollmentFactory.create() - if approved_flexible_price_exists: - status = FlexiblePriceStatus.APPROVED - else: - status = FlexiblePriceStatus.PENDING_MANUAL_APPROVAL - - FlexiblePriceFactory.create( - user=course_run_enrollment.user, - courseware_object=course_run_enrollment.run.course, - status=status, - ) - serialized_data = CourseRunEnrollmentSerializer(course_run_enrollment).data - assert serialized_data == { - "run": CourseRunWithCourseSerializer(course_run_enrollment.run).data, - "id": course_run_enrollment.id, - "edx_emails_subscription": True, - "enrollment_mode": "audit", - "approved_flexible_price_exists": approved_flexible_price_exists, - "certificate": None, - "grades": [], - } - - -def test_serialize_course_run_enrollments_with_grades(): - """Test that CourseRunEnrollmentSerializer has correct data""" - course_run_enrollment = CourseRunEnrollmentFactory.create() - - grade = CourseRunGradeFactory.create( - course_run=course_run_enrollment.run, user=course_run_enrollment.user - ) - - serialized_data = CourseRunEnrollmentSerializer(course_run_enrollment).data - assert serialized_data == { - "run": CourseRunWithCourseSerializer(course_run_enrollment.run).data, - "id": course_run_enrollment.id, - "edx_emails_subscription": True, - "enrollment_mode": "audit", - "approved_flexible_price_exists": False, - "certificate": None, - "grades": CourseRunGradeSerializer([grade], many=True).data, - } - - -@pytest.mark.parametrize( - "data", - [ - { - "id": None, - "data": { - "node_type": ProgramRequirementNodeType.COURSE, - }, - "children": [], - }, - { - "id": 1, - "data": { - "node_type": ProgramRequirementNodeType.COURSE, - }, - "children": [], - }, - { - "id": 1, - "data": { - "node_type": ProgramRequirementNodeType.COURSE, - }, - }, - { - "data": { - "node_type": ProgramRequirementNodeType.COURSE, - }, - "children": [], - }, - ], -) -def test_program_requirement_serializer_valid(data): - """Verify that the ProgramRequirementSerializer validates data""" - serializer = ProgramRequirementSerializer(data=data) - serializer.is_valid(raise_exception=True) - - -def test_program_requirement_tree_serializer_valid(): - """Verify that the ProgramRequirementTreeSerializer validates data""" - program = ProgramFactory.create() - course1, course2, course3 = CourseFactory.create_batch(3) - root = program.requirements_root - - serializer = ProgramRequirementTreeSerializer( - instance=root, - data=[ - { - "data": { - "node_type": "operator", - "title": "Required Courses", - "operator": "all_of", - }, - "children": [ - {"id": None, "data": {"node_type": "course", "course": course1.id}} - ], - }, - { - "data": { - "node_type": "operator", - "title": "Elective Courses", - "operator": "min_number_of", - "operator_value": "1", - }, - "children": [], - }, - ], - context={"program": program}, - ) - serializer.is_valid(raise_exception=True) - serializer.save() - - -def test_program_requirement_deletion(): - """Verify that saving the requirements for one program doesn't affect other programs""" - - courses = CourseFactory.create_batch(3) - - program1 = ProgramFactory.create() - program2 = ProgramFactory.create() - root1 = program1.requirements_root - root2 = program2.requirements_root - - for root in [root1, root2]: - program = root.program - # build the same basic tree structure for both - required = root.add_child( - program=program, - node_type=ProgramRequirementNodeType.OPERATOR, - title="Required", - operator=ProgramRequirement.Operator.ALL_OF, - ) - for course in courses: - required.add_child( - program=program, - node_type=ProgramRequirementNodeType.COURSE, - course=course, - ) - - expected = list(ProgramRequirement.get_tree(parent=root2)) - - # this will delete everything under this tree - serializer = ProgramRequirementTreeSerializer(instance=root1, data=[]) - serializer.is_valid(raise_exception=True) - serializer.save() - - assert list(ProgramRequirement.get_tree(parent=root1)) == [ - root1 - ] # just the one root node - assert list(ProgramRequirement.get_tree(parent=root2)) == expected - - -@pytest.mark.parametrize( - "enrollment_mode", [EDX_ENROLLMENT_VERIFIED_MODE, EDX_ENROLLMENT_AUDIT_MODE] -) -def test_learner_record_serializer( - mock_context, program_with_empty_requirements, enrollment_mode -): - """Verify that saving the requirements for one program doesn't affect other programs""" - - program = program_with_empty_requirements - courses = CourseFactory.create_batch(3) - - user = mock_context["request"].user - - course_runs = [] - grades = [] - grade_multiplier_to_test_ordering = 1 - for course in courses: - program.add_requirement(course) - course_run = CourseRunFactory.create(course=course) - course_run_enrollment = CourseRunEnrollmentFactory.create( - run=course_run, - user=user, - enrollment_mode=enrollment_mode, - ) - course_runs.append(course_run) - - grades.append( - CourseRunGradeFactory.create( - course_run=course_run, - user=user, - grade=(0.313133 * grade_multiplier_to_test_ordering), - ) - ) - grade_multiplier_to_test_ordering += 1 - - serialized_data = LearnerRecordSerializer( - instance=program, context=mock_context - ).data - program_requirements_payload = [ - { - "children": [ - { - "children": [ - { - "data": { - "course": courses[0].id, - "node_type": "course", - "operator": None, - "operator_value": None, - "program": program.id, - "title": "", - "elective_flag": False, - }, - "id": program.get_requirements_root() - .get_children() - .first() - .get_children() - .filter(course=courses[0].id) - .first() - .id, - }, - { - "data": { - "course": courses[1].id, - "node_type": "course", - "operator": None, - "operator_value": None, - "program": program.id, - "title": "", - "elective_flag": False, - }, - "id": program.get_requirements_root() - .get_children() - .first() - .get_children() - .filter(course=courses[1].id) - .first() - .id, - }, - { - "data": { - "course": courses[2].id, - "node_type": "course", - "operator": None, - "operator_value": None, - "program": program.id, - "title": "", - "elective_flag": False, - }, - "id": program.get_requirements_root() - .get_children() - .first() - .get_children() - .filter(course=courses[2].id) - .first() - .id, - }, - ], - "data": { - "course": None, - "node_type": "operator", - "operator": ProgramRequirement.Operator.ALL_OF.value, - "operator_value": None, - "program": program.id, - "title": "Required Courses", - "elective_flag": False, - }, - "id": program.get_requirements_root().get_children().first().id, - }, - { - "data": { - "course": None, - "node_type": "operator", - "operator": ProgramRequirement.Operator.MIN_NUMBER_OF.value, - "operator_value": "1", - "program": program.id, - "title": "Elective Courses", - "elective_flag": True, - }, - "id": program.get_requirements_root().get_children().last().id, - }, - ], - "data": { - "course": None, - "node_type": "program_root", - "operator": None, - "operator_value": None, - "program": program.id, - "title": "", - "elective_flag": False, - }, - "id": program.requirements_root.id, - } - ] - user_info_payload = { - "email": user.email, - "name": user.name, - "username": user.username, - } - course_0_payload = { - "certificate": None, - "grade": { - "grade": round(grades[0].grade, 2), - "grade_percent": Decimal(grades[0].grade_percent), - "letter_grade": grades[0].letter_grade, - "passed": grades[0].passed, - "set_by_admin": grades[0].set_by_admin, - }, - "id": courses[0].id, - "readable_id": courses[0].readable_id, - "reqtype": "Required Courses", - "title": courses[0].title, - } - if enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE: - course_0_payload["grade"] = None - if course_runs[0].certificate_available_date >= now_in_utc() or ( - not course_runs[0].certificate_available_date - and course_runs[0].end_date >= now_in_utc() - ): - course_0_payload["grade"] = None - assert user_info_payload == serialized_data["user"] - assert program_requirements_payload == serialized_data["program"]["requirements"] - assert course_0_payload == serialized_data["program"]["courses"][0] - - -def test_program_serializer_returns_default_image(): - """If the program has no page, we should still get a featured_image_url.""" - - program = ProgramFactory.create(page=None) - - assert "feature_image_src" in ProgramSerializer(program).data["page"] diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 9659e5c475..07e036dacb 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -42,12 +42,11 @@ CourseRunEnrollmentSerializer, CourseRunWithCourseSerializer, CourseWithCourseRunsSerializer, - DepartmentWithCountSerializer, - LearnerRecordSerializer, PartnerSchoolSerializer, ProgramSerializer, - UserProgramEnrollmentDetailSerializer, ) +from courses.serializers.v1.programs import UserProgramEnrollmentDetailSerializer, LearnerRecordSerializer +from courses.serializers.v1.departments import DepartmentWithCountSerializer from courses.tasks import send_partner_school_email from courses.utils import get_program_certificate_by_enrollment from ecommerce.models import FulfilledOrder, Order, PendingOrder, Product diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 8d49c6d45a..1709446e68 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -27,11 +27,11 @@ ) from courses.serializers.v1 import ( CourseRunEnrollmentSerializer, - CourseRunSerializer, CourseWithCourseRunsSerializer, ProgramSerializer, CourseRunWithCourseSerializer, ) +from courses.serializers.v1.courses import CourseRunSerializer from courses.views.test_utils import ( num_queries_from_course, num_queries_from_programs, diff --git a/flexiblepricing/serializers.py b/flexiblepricing/serializers.py index 7b110152f0..c2b3c98dd9 100644 --- a/flexiblepricing/serializers.py +++ b/flexiblepricing/serializers.py @@ -2,7 +2,8 @@ from rest_framework import serializers from courses.models import Course, Program -from courses.serializers.v1 import BaseCourseSerializer, BaseProgramSerializer +from courses.serializers.v1 import BaseCourseSerializer +from courses.serializers.v1.base import BaseProgramSerializer from ecommerce.models import Discount from ecommerce.serializers import DiscountSerializer from flexiblepricing import models