From a1e5af0aa94bba2861f93310ffbe133657c81245 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 10 Oct 2025 17:08:04 -0400 Subject: [PATCH 1/3] improve openapi for req_tree --- courses/serializers/v2/programs.py | 16 +++++++++------- openapi/specs/v0.yaml | 16 +++++++++++++--- openapi/specs/v1.yaml | 16 +++++++++++++--- openapi/specs/v2.yaml | 16 +++++++++++++--- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/courses/serializers/v2/programs.py b/courses/serializers/v2/programs.py index b1d376b94b..1a25fe61b8 100644 --- a/courses/serializers/v2/programs.py +++ b/courses/serializers/v2/programs.py @@ -23,13 +23,15 @@ class ProgramRequirementDataSerializer(StrictFieldsSerializer): """Serializer for ProgramRequirement data""" node_type = serializers.ChoiceField( - choices=( - ProgramRequirementNodeType.OPERATOR, - ProgramRequirementNodeType.COURSE, - ) + choices=[x.value for x in ProgramRequirementNodeType] + ) + course = serializers.IntegerField(source="course_id", allow_null=True, default=None) + program = serializers.IntegerField( + source="program_id", allow_null=True, default=None + ) + required_program = serializers.IntegerField( + source="required_program_id", allow_null=True, default=None ) - 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) @@ -40,7 +42,7 @@ class ProgramRequirementDataSerializer(StrictFieldsSerializer): class ProgramRequirementSerializer(StrictFieldsSerializer): """Serializer for a ProgramRequirement""" - id = serializers.IntegerField(required=False, allow_null=True, default=None) + id = serializers.IntegerField() data = ProgramRequirementDataSerializer() def get_fields(self): diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 756ee79630..0e6fbf5136 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4867,7 +4867,6 @@ components: properties: id: type: integer - nullable: true data: $ref: '#/components/schemas/V2ProgramRequirementData' children: @@ -4877,6 +4876,7 @@ components: default: [] required: - data + - id V2ProgramRequirementData: type: object description: Serializer for ProgramRequirement data @@ -4884,10 +4884,14 @@ components: node_type: $ref: '#/components/schemas/V2ProgramRequirementDataNodeTypeEnum' course: - type: string + type: integer nullable: true program: - type: string + type: integer + nullable: true + required_program: + type: integer + nullable: true title: type: string nullable: true @@ -4905,15 +4909,21 @@ components: - node_type V2ProgramRequirementDataNodeTypeEnum: enum: + - program_root - operator - course + - program type: string description: |- + * `program_root` - program_root * `operator` - operator * `course` - course + * `program` - program x-enum-descriptions: + - program_root - operator - course + - program YearsExperienceEnum: enum: - 2 diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 8812be584c..a3637839a5 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4867,7 +4867,6 @@ components: properties: id: type: integer - nullable: true data: $ref: '#/components/schemas/V2ProgramRequirementData' children: @@ -4877,6 +4876,7 @@ components: default: [] required: - data + - id V2ProgramRequirementData: type: object description: Serializer for ProgramRequirement data @@ -4884,10 +4884,14 @@ components: node_type: $ref: '#/components/schemas/V2ProgramRequirementDataNodeTypeEnum' course: - type: string + type: integer nullable: true program: - type: string + type: integer + nullable: true + required_program: + type: integer + nullable: true title: type: string nullable: true @@ -4905,15 +4909,21 @@ components: - node_type V2ProgramRequirementDataNodeTypeEnum: enum: + - program_root - operator - course + - program type: string description: |- + * `program_root` - program_root * `operator` - operator * `course` - course + * `program` - program x-enum-descriptions: + - program_root - operator - course + - program YearsExperienceEnum: enum: - 2 diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 037505f2d1..05996f2a93 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -4867,7 +4867,6 @@ components: properties: id: type: integer - nullable: true data: $ref: '#/components/schemas/V2ProgramRequirementData' children: @@ -4877,6 +4876,7 @@ components: default: [] required: - data + - id V2ProgramRequirementData: type: object description: Serializer for ProgramRequirement data @@ -4884,10 +4884,14 @@ components: node_type: $ref: '#/components/schemas/V2ProgramRequirementDataNodeTypeEnum' course: - type: string + type: integer nullable: true program: - type: string + type: integer + nullable: true + required_program: + type: integer + nullable: true title: type: string nullable: true @@ -4905,15 +4909,21 @@ components: - node_type V2ProgramRequirementDataNodeTypeEnum: enum: + - program_root - operator - course + - program type: string description: |- + * `program_root` - program_root * `operator` - operator * `course` - course + * `program` - program x-enum-descriptions: + - program_root - operator - course + - program YearsExperienceEnum: enum: - 2 From fc54035c8ea441bc3167069bce2d49a6f166f062 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 15 Oct 2025 11:26:02 -0400 Subject: [PATCH 2/3] return root children from v2 req_tree --- courses/serializers/v2/programs.py | 10 ++- courses/serializers/v2/programs_test.py | 113 ++++++++++++++++++++++++ openapi/specs/v0.yaml | 2 +- openapi/specs/v1.yaml | 2 +- openapi/specs/v2.yaml | 2 +- 5 files changed, 125 insertions(+), 4 deletions(-) diff --git a/courses/serializers/v2/programs.py b/courses/serializers/v2/programs.py index 1a25fe61b8..581527d76f 100644 --- a/courses/serializers/v2/programs.py +++ b/courses/serializers/v2/programs.py @@ -42,7 +42,7 @@ class ProgramRequirementDataSerializer(StrictFieldsSerializer): class ProgramRequirementSerializer(StrictFieldsSerializer): """Serializer for a ProgramRequirement""" - id = serializers.IntegerField() + id = serializers.IntegerField(required=False, allow_null=True, default=None) data = ProgramRequirementDataSerializer() def get_fields(self): @@ -81,6 +81,14 @@ class Meta: class ProgramRequirementTreeSerializer(BaseProgramRequirementTreeSerializer): child = ProgramRequirementSerializer() + @property + def data(self): + """Return children of root node directly, or empty array if no children""" + # BaseProgramRequirementTreeSerializer overrides the data property + # to bypass to_implementation, so we do also. + full_data = super().data + return full_data[0].get("children", []) if full_data else [] + @extend_schema_serializer( component_name="V2ProgramSerializer", diff --git a/courses/serializers/v2/programs_test.py b/courses/serializers/v2/programs_test.py index 66036ef0c3..2324fae1b3 100644 --- a/courses/serializers/v2/programs_test.py +++ b/courses/serializers/v2/programs_test.py @@ -1,4 +1,5 @@ from datetime import timedelta +from unittest.mock import ANY import pytest from django.utils.timezone import now @@ -6,6 +7,7 @@ from cms.factories import CoursePageFactory from cms.serializers import ProgramPageSerializer from courses.factories import ( # noqa: F401 + CourseFactory, CourseRunFactory, ProgramCollectionFactory, ProgramFactory, @@ -144,3 +146,114 @@ def test_serialize_program( "max_price": program_with_empty_requirements.page.max_price, }, ) + + +def test_program_requirement_tree_serializer_save(): + """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": [ + {"id": None, "data": {"node_type": "course", "course": course2.id}}, + {"id": None, "data": {"node_type": "course", "course": course3.id}}, + ], + }, + ], + context={"program": program}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + root.refresh_from_db() + assert ProgramRequirementTreeSerializer(instance=root).data == [ + { + "data": { + "node_type": "operator", + "operator": "all_of", + "operator_value": None, + "program": program.id, + "course": None, + "required_program": None, + "title": "Required Courses", + "elective_flag": False, + }, + "id": ANY, + "children": [ + { + "data": { + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "course": course1.id, + "required_program": None, + "title": None, + "elective_flag": False, + }, + "id": ANY, + } + ], + }, + { + "data": { + "node_type": "operator", + "operator": "min_number_of", + "operator_value": "1", + "program": program.id, + "course": None, + "required_program": None, + "title": "Elective Courses", + "elective_flag": False, + }, + "id": ANY, + "children": [ + { + "data": { + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "course": course2.id, + "required_program": None, + "title": None, + "elective_flag": False, + }, + "id": ANY, + }, + { + "data": { + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "course": course3.id, + "required_program": None, + "title": None, + "elective_flag": False, + }, + "id": ANY, + }, + ], + }, + ] diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 0e6fbf5136..f584285d9a 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4867,6 +4867,7 @@ components: properties: id: type: integer + nullable: true data: $ref: '#/components/schemas/V2ProgramRequirementData' children: @@ -4876,7 +4877,6 @@ components: default: [] required: - data - - id V2ProgramRequirementData: type: object description: Serializer for ProgramRequirement data diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index a3637839a5..142df23cca 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4867,6 +4867,7 @@ components: properties: id: type: integer + nullable: true data: $ref: '#/components/schemas/V2ProgramRequirementData' children: @@ -4876,7 +4877,6 @@ components: default: [] required: - data - - id V2ProgramRequirementData: type: object description: Serializer for ProgramRequirement data diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 05996f2a93..d3e1182d2e 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -4867,6 +4867,7 @@ components: properties: id: type: integer + nullable: true data: $ref: '#/components/schemas/V2ProgramRequirementData' children: @@ -4876,7 +4877,6 @@ components: default: [] required: - data - - id V2ProgramRequirementData: type: object description: Serializer for ProgramRequirement data From 8b6a10659aa5171426771ffc941f973cfc083b75 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 15 Oct 2025 11:31:09 -0400 Subject: [PATCH 3/3] return root children from v2 req_tree --- courses/serializers/v1/programs_test.py | 63 ++++++++++++++++++++++++- courses/serializers/v2/programs.py | 6 ++- openapi/specs/v0.yaml | 9 ++-- openapi/specs/v1.yaml | 9 ++-- openapi/specs/v2.yaml | 9 ++-- 5 files changed, 76 insertions(+), 20 deletions(-) diff --git a/courses/serializers/v1/programs_test.py b/courses/serializers/v1/programs_test.py index f516ca12ba..a6d116f81f 100644 --- a/courses/serializers/v1/programs_test.py +++ b/courses/serializers/v1/programs_test.py @@ -1,5 +1,6 @@ from datetime import timedelta from decimal import Decimal +from unittest.mock import ANY import pytest from django.utils.timezone import now @@ -108,7 +109,7 @@ def test_serialize_program(mock_context, remove_tree, program_with_empty_require ) -def test_program_requirement_tree_serializer_valid(): +def test_program_requirement_tree_serializer_save(): """Verify that the ProgramRequirementTreeSerializer validates data""" program = ProgramFactory.create() course1, course2, course3 = CourseFactory.create_batch(3) @@ -142,6 +143,66 @@ def test_program_requirement_tree_serializer_valid(): serializer.is_valid(raise_exception=True) serializer.save() + root.refresh_from_db() + assert ProgramRequirementTreeSerializer(instance=root).data == [ + { + "data": { + "node_type": "program_root", + "operator": None, + "operator_value": None, + "program": program.id, + "course": None, + "required_program": None, + "title": "", + "elective_flag": False, + }, + "id": ANY, + "children": [ + { + "data": { + "node_type": "operator", + "operator": "all_of", + "operator_value": None, + "program": program.id, + "course": None, + "required_program": None, + "title": "Required Courses", + "elective_flag": False, + }, + "id": ANY, + "children": [ + { + "data": { + "node_type": "course", + "operator": None, + "operator_value": None, + "program": program.id, + "course": course1.id, + "required_program": None, + "title": None, + "elective_flag": False, + }, + "id": ANY, + } + ], + }, + { + "data": { + "node_type": "operator", + "operator": "min_number_of", + "operator_value": "1", + "program": program.id, + "course": None, + "required_program": None, + "title": "Elective Courses", + "elective_flag": False, + }, + "id": ANY, + }, + ], + } + ] + def test_program_requirement_deletion(): """Verify that saving the requirements for one program doesn't affect other programs""" diff --git a/courses/serializers/v2/programs.py b/courses/serializers/v2/programs.py index 581527d76f..fba49ab996 100644 --- a/courses/serializers/v2/programs.py +++ b/courses/serializers/v2/programs.py @@ -23,7 +23,11 @@ class ProgramRequirementDataSerializer(StrictFieldsSerializer): """Serializer for ProgramRequirement data""" node_type = serializers.ChoiceField( - choices=[x.value for x in ProgramRequirementNodeType] + choices=( + ProgramRequirementNodeType.COURSE, + ProgramRequirementNodeType.PROGRAM, + ProgramRequirementNodeType.OPERATOR, + ) ) course = serializers.IntegerField(source="course_id", allow_null=True, default=None) program = serializers.IntegerField( diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index f584285d9a..79bba4c18c 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -4909,21 +4909,18 @@ components: - node_type V2ProgramRequirementDataNodeTypeEnum: enum: - - program_root - - operator - course - program + - operator type: string description: |- - * `program_root` - program_root - * `operator` - operator * `course` - course * `program` - program + * `operator` - operator x-enum-descriptions: - - program_root - - operator - course - program + - operator YearsExperienceEnum: enum: - 2 diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 142df23cca..74da736ec6 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -4909,21 +4909,18 @@ components: - node_type V2ProgramRequirementDataNodeTypeEnum: enum: - - program_root - - operator - course - program + - operator type: string description: |- - * `program_root` - program_root - * `operator` - operator * `course` - course * `program` - program + * `operator` - operator x-enum-descriptions: - - program_root - - operator - course - program + - operator YearsExperienceEnum: enum: - 2 diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index d3e1182d2e..181b69c01d 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -4909,21 +4909,18 @@ components: - node_type V2ProgramRequirementDataNodeTypeEnum: enum: - - program_root - - operator - course - program + - operator type: string description: |- - * `program_root` - program_root - * `operator` - operator * `course` - course * `program` - program + * `operator` - operator x-enum-descriptions: - - program_root - - operator - course - program + - operator YearsExperienceEnum: enum: - 2