diff --git a/courses/serializers/v1/programs.py b/courses/serializers/v1/programs.py index 290213937e..b9c434e89c 100644 --- a/courses/serializers/v1/programs.py +++ b/courses/serializers/v1/programs.py @@ -69,7 +69,7 @@ class ProgramRequirementTreeSerializer(BaseProgramRequirementTreeSerializer): class PartnerSchoolSerializer(serializers.ModelSerializer): class Meta: model = models.PartnerSchool - fields = "__all__" + fields = ["id", "name"] class LearnerProgramRecordShareSerializer(serializers.ModelSerializer): diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py index 1834d51b94..91b622ad01 100644 --- a/courses/views/v1/__init__.py +++ b/courses/views/v1/__init__.py @@ -633,9 +633,22 @@ class PartnerSchoolViewSet(viewsets.ReadOnlyModelViewSet): queryset = PartnerSchool.objects.all() +def get_enrolled_program_or_404(user, program_id: int) -> Program: + """Return a program only if the user has an active enrollment for it.""" + + enrollment = get_object_or_404( + ProgramEnrollment.objects.select_related("program"), + user=user, + program_id=program_id, + ) + return enrollment.program + + class GetLearnerRecordView(APIView): """View to get learner record by program ID""" + permission_classes = [IsAuthenticated] + @extend_schema( operation_id="learner_record_retrieve_by_id", description="Get learner record using program ID", @@ -645,7 +658,7 @@ def get(self, request, pk): """ Retrieve the learner record for a specific program. """ - program = Program.objects.get(pk=pk) + program = get_enrolled_program_or_404(request.user, pk) serializer = LearnerRecordSerializer(program, context={"request": request}) return Response(serializer.data) @@ -662,7 +675,7 @@ def post(self, request, pk): Sets up a sharing link for the learner's record. Returns back the entire learner record. """ - program = Program.objects.get(pk=pk) + program = get_enrolled_program_or_404(request.user, pk) school = None @@ -672,7 +685,7 @@ def post(self, request, pk): ): try: school = PartnerSchool.objects.get(pk=request.data["partnerSchool"]) - except: # noqa: E722 + except PartnerSchool.DoesNotExist: return Response("Partner school not found.", status.HTTP_404_NOT_FOUND) (ps_share, _) = LearnerProgramRecordShare.objects.filter( @@ -707,7 +720,7 @@ def post(self, request, pk: int): anonymous ones; shares sent to partner schools are always allowed once they are sent. """ - program = Program.objects.get(pk=pk) + program = get_enrolled_program_or_404(request.user, pk) LearnerProgramRecordShare.objects.filter( user=request.user, partner_school=None, program=program diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py index 1b4c502465..f65497ac98 100644 --- a/courses/views/v1/views_test.py +++ b/courses/views/v1/views_test.py @@ -31,6 +31,8 @@ CourseRunCertificateFactory, CourseRunEnrollmentFactory, CourseRunFactory, + LearnerProgramRecordShareFactory, + PartnerSchoolFactory, ProgramCertificateFactory, ProgramEnrollmentFactory, ProgramFactory, @@ -39,6 +41,7 @@ Course, CourseRun, CourseRunEnrollment, + LearnerProgramRecordShare, PaidProgram, Program, ProgramEnrollment, @@ -1188,3 +1191,170 @@ def test_destroy_program_enrollment_paid_fails(user_drf_client, user): enrollment.refresh_from_db() assert enrollment.active is True + + +def test_get_learner_record_requires_program_enrollment(user_drf_client): + """The learner record endpoint should return 404 without a program enrollment.""" + program = ProgramFactory.create() + + resp = user_drf_client.get(reverse("get-learner-record", kwargs={"pk": program.id})) + + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_learner_record(user_drf_client, user): + """The learner record endpoint should return data for an enrolled user.""" + enrollment = ProgramEnrollmentFactory.create(user=user) + partner_school = PartnerSchoolFactory.create() + + resp = user_drf_client.get( + reverse("get-learner-record", kwargs={"pk": enrollment.program.id}) + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["program"]["title"] == enrollment.program.title + assert resp.json()["partner_schools"] == [ + {"id": partner_school.id, "name": partner_school.name} + ] + + +def test_share_learner_record_requires_program_enrollment(user_drf_client): + """The learner record share endpoint should return 404 without a program enrollment.""" + program = ProgramFactory.create() + + resp = user_drf_client.post( + reverse("learner-record-share", kwargs={"pk": program.id}), + data={"partnerSchool": None}, + ) + + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_share_learner_record(user_drf_client, user, mocker): + """The learner record share endpoint should create an active anonymous share.""" + enrollment = ProgramEnrollmentFactory.create(user=user) + partner_school = PartnerSchoolFactory.create() + patched_send_email = mocker.patch( + "courses.views.v1.send_partner_school_email.delay" + ) + + resp = user_drf_client.post( + reverse("learner-record-share", kwargs={"pk": enrollment.program.id}), + data={"partnerSchool": None}, + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["partner_schools"] == [ + {"id": partner_school.id, "name": partner_school.name} + ] + assert ( + LearnerProgramRecordShare.objects.filter( + user=user, + program=enrollment.program, + partner_school=None, + is_active=True, + ).count() + == 1 + ) + patched_send_email.assert_not_called() + + +def test_share_learner_record_partner_school(user_drf_client, user, mocker): + """The learner record share endpoint should create an active partner school share.""" + enrollment = ProgramEnrollmentFactory.create(user=user) + partner_school = PartnerSchoolFactory.create() + patched_send_email = mocker.patch( + "courses.views.v1.send_partner_school_email.delay" + ) + + resp = user_drf_client.post( + reverse("learner-record-share", kwargs={"pk": enrollment.program.id}), + data={"partnerSchool": partner_school.id}, + ) + + assert resp.status_code == status.HTTP_200_OK + assert all("email" not in school for school in resp.json()["partner_schools"]) + share = LearnerProgramRecordShare.objects.get( + user=user, + program=enrollment.program, + partner_school=partner_school, + ) + assert share.is_active is True + patched_send_email.assert_called_once_with(share.share_uuid) + + +def test_revoke_learner_record_share_requires_program_enrollment(user_drf_client): + """The learner record revoke endpoint should return 404 without a program enrollment.""" + program = ProgramFactory.create() + + resp = user_drf_client.post( + reverse("revoke-learner-record-share", kwargs={"pk": program.id}) + ) + + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_revoke_learner_record_share(user_drf_client, user): + """The learner record revoke endpoint should disable only anonymous shares.""" + enrollment = ProgramEnrollmentFactory.create(user=user) + anonymous_share = LearnerProgramRecordShareFactory.create( + user=user, + program=enrollment.program, + partner_school=None, + is_active=True, + ) + partner_school_share = LearnerProgramRecordShareFactory.create( + user=user, + program=enrollment.program, + is_active=True, + ) + + resp = user_drf_client.post( + reverse("revoke-learner-record-share", kwargs={"pk": enrollment.program.id}) + ) + + assert resp.status_code == status.HTTP_200_OK + anonymous_share.refresh_from_db() + partner_school_share.refresh_from_db() + assert anonymous_share.is_active is False + assert partner_school_share.is_active is True + + +def test_get_shared_learner_record(user): + """The shared learner record endpoint should expose active shared records.""" + enrollment = ProgramEnrollmentFactory.create(user=user) + share = LearnerProgramRecordShareFactory.create( + user=user, + program=enrollment.program, + partner_school=None, + is_active=True, + ) + client = APIClient() + + resp = client.get( + reverse("shared_learner_record_from_uuid", kwargs={"uuid": share.share_uuid}) + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["program"]["title"] == enrollment.program.title + assert resp.json()["sharing"] == [] + assert resp.json()["partner_schools"] == [] + + +def test_get_shared_learner_record_inactive_share_returns_404(user): + """The shared learner record endpoint should return 404 for inactive links.""" + enrollment = ProgramEnrollmentFactory.create(user=user) + share = LearnerProgramRecordShareFactory.create( + user=user, + program=enrollment.program, + partner_school=None, + is_active=False, + ) + client = APIClient() + + resp = client.get( + reverse("shared_learner_record_from_uuid", kwargs={"uuid": share.share_uuid}) + ) + + assert resp.status_code == status.HTTP_404_NOT_FOUND + assert resp.json() == [] diff --git a/frontend/public/src/factories/course.js b/frontend/public/src/factories/course.js index 50e0ef8ada..7e16ef5a7f 100644 --- a/frontend/public/src/factories/course.js +++ b/frontend/public/src/factories/course.js @@ -383,9 +383,8 @@ export const makeLearnerRecordProgram = (): LearnerRecordProgram => ({ }) export const makePartnerSchool = (): PartnerSchool => ({ - id: genPartnerSchoolId.next().value, - name: casual.company_name, - email: casual.email + id: genPartnerSchoolId.next().value, + name: casual.company_name }) export const makeLearnerRecordShare = ( diff --git a/frontend/public/src/flow/courseTypes.js b/frontend/public/src/flow/courseTypes.js index cf8c9a6b21..376ce40a11 100644 --- a/frontend/public/src/flow/courseTypes.js +++ b/frontend/public/src/flow/courseTypes.js @@ -91,8 +91,7 @@ export type ProgramEnrollment = { export type PartnerSchool = { id: number, - name: string, - email: string + name: string } export type RequirementNode = { diff --git a/openapi/specs/oasdiff-err-ignore.txt b/openapi/specs/oasdiff-err-ignore.txt index 094ae627e2..26f4d0b3a1 100644 --- a/openapi/specs/oasdiff-err-ignore.txt +++ b/openapi/specs/oasdiff-err-ignore.txt @@ -19,3 +19,15 @@ GET /api/v2/program_enrollments/{id}/ the response property `program/page` becam GET /api/v2/programs/ the response property `results/items/page` became nullable for the status `200` GET /api/v2/programs/{id}/ the response property `page` became nullable for the status `200` POST /api/v2/verified_program_enrollments/{courserun_id}/ the response property `run/allOf[#/components/schemas/V2CourseRunWithCourse]/course/allOf[#/components/schemas/V2Course]/page` became nullable for the status `201` +GET /api/records/program/{id}/ removed the required property `partner_schools/items/created_on` from the response with the `200` status +GET /api/records/program/{id}/ removed the required property `partner_schools/items/email` from the response with the `200` status +GET /api/records/program/{id}/ removed the required property `partner_schools/items/updated_on` from the response with the `200` status +POST /api/records/program/{id}/revoke/ removed the required property `partner_schools/items/created_on` from the response with the `200` status +POST /api/records/program/{id}/revoke/ removed the required property `partner_schools/items/email` from the response with the `200` status +POST /api/records/program/{id}/revoke/ removed the required property `partner_schools/items/updated_on` from the response with the `200` status +POST /api/records/program/{id}/share/ removed the required property `partner_schools/items/created_on` from the response with the `200` status +POST /api/records/program/{id}/share/ removed the required property `partner_schools/items/email` from the response with the `200` status +POST /api/records/program/{id}/share/ removed the required property `partner_schools/items/updated_on` from the response with the `200` status +GET /api/records/shared/{uuid}/ removed the required property `partner_schools/items/created_on` from the response with the `200` status +GET /api/records/shared/{uuid}/ removed the required property `partner_schools/items/email` from the response with the `200` status +GET /api/records/shared/{uuid}/ removed the required property `partner_schools/items/updated_on` from the response with the `200` status diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index ab3f42c3c1..aff38c8f04 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -6673,27 +6673,12 @@ components: id: type: integer readOnly: true - created_on: - type: string - format: date-time - readOnly: true - updated_on: - type: string - format: date-time - readOnly: true name: type: string maxLength: 255 - email: - type: string - is_active: - type: boolean required: - - created_on - - email - id - name - - updated_on PartnerSchoolRequest: type: object properties: @@ -6701,13 +6686,7 @@ components: type: string minLength: 1 maxLength: 255 - email: - type: string - minLength: 1 - is_active: - type: boolean required: - - email - name PatchedChangeEmailRequestUpdateRequest: type: object diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 9db539ddbd..e88416d001 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -6673,27 +6673,12 @@ components: id: type: integer readOnly: true - created_on: - type: string - format: date-time - readOnly: true - updated_on: - type: string - format: date-time - readOnly: true name: type: string maxLength: 255 - email: - type: string - is_active: - type: boolean required: - - created_on - - email - id - name - - updated_on PartnerSchoolRequest: type: object properties: @@ -6701,13 +6686,7 @@ components: type: string minLength: 1 maxLength: 255 - email: - type: string - minLength: 1 - is_active: - type: boolean required: - - email - name PatchedChangeEmailRequestUpdateRequest: type: object diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 5e8c65db10..feaf283c75 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -6673,27 +6673,12 @@ components: id: type: integer readOnly: true - created_on: - type: string - format: date-time - readOnly: true - updated_on: - type: string - format: date-time - readOnly: true name: type: string maxLength: 255 - email: - type: string - is_active: - type: boolean required: - - created_on - - email - id - name - - updated_on PartnerSchoolRequest: type: object properties: @@ -6701,13 +6686,7 @@ components: type: string minLength: 1 maxLength: 255 - email: - type: string - minLength: 1 - is_active: - type: boolean required: - - email - name PatchedChangeEmailRequestUpdateRequest: type: object