diff --git a/eox_nelp/edxapp_wrapper/backends/certificates_m_v1.py b/eox_nelp/edxapp_wrapper/backends/certificates_m_v1.py index e856e44d..07a97b4f 100644 --- a/eox_nelp/edxapp_wrapper/backends/certificates_m_v1.py +++ b/eox_nelp/edxapp_wrapper/backends/certificates_m_v1.py @@ -28,3 +28,23 @@ def get_generate_course_certificate_method(): generate_course_certificate method. """ return certificates.generation.generate_course_certificate + + +def get_certificates_utils(): + """Allow to get the certificates utils module. + https://github.com/nelc/edx-platform/tree/open-release/redwood.nelp/lms/djangoapps/certificates/utils.py + + Returns: + certificates.utils module. + """ + return certificates.utils + + +def get_certificates_models(): + """Allow to get the certificates models module. + https://github.com/nelc/edx-platform/tree/open-release/redwood.nelp/lms/djangoapps/certificates/models.py + + Returns: + certificates.models module. + """ + return certificates.models diff --git a/eox_nelp/edxapp_wrapper/certificates.py b/eox_nelp/edxapp_wrapper/certificates.py index 891ff6d8..ab39b5b2 100644 --- a/eox_nelp/edxapp_wrapper/certificates.py +++ b/eox_nelp/edxapp_wrapper/certificates.py @@ -13,3 +13,5 @@ GeneratedCertificateAdmin = backend.get_generated_certificates_admin() generate_course_certificate = backend.get_generate_course_certificate_method() +utils = backend.get_certificates_utils() +models = backend.get_certificates_models() diff --git a/eox_nelp/edxapp_wrapper/test_backends/certificates_m_v1.py b/eox_nelp/edxapp_wrapper/test_backends/certificates_m_v1.py index 3d53b724..d213ecf4 100644 --- a/eox_nelp/edxapp_wrapper/test_backends/certificates_m_v1.py +++ b/eox_nelp/edxapp_wrapper/test_backends/certificates_m_v1.py @@ -56,3 +56,19 @@ def get_generate_course_certificate_method(): Mock instance. """ return Mock() + + +def get_certificates_utils(): + """Return utils mock module + Returns: + Mock instance. + """ + return Mock() + + +def get_certificates_models(): + """Return models mock module + Returns: + Mock instance. + """ + return Mock() diff --git a/eox_nelp/programs/api/v1/serializers.py b/eox_nelp/programs/api/v1/serializers.py index e9720aa1..49d8d13f 100644 --- a/eox_nelp/programs/api/v1/serializers.py +++ b/eox_nelp/programs/api/v1/serializers.py @@ -64,3 +64,16 @@ class ProgramLookupSerializer(serializers.Serializer, ProgramValidationMixin): training_location = serializers.CharField(read_only=True, default="FutureX") trainer_type = serializers.IntegerField(read_only=True, default=10) unit = serializers.CharField(read_only=True, default="hour") + + certificate_url = serializers.SerializerMethodField() + completion_date = serializers.CharField(required=True, allow_null=True) + completion_date_hijri = serializers.CharField(required=True, allow_null=True) + + def get_certificate_url(self, program): + """Generate certificate_url based on user and course code. + Args: + program: Program data dictionary. + """ + if program.get("certificate_path"): + return self.context["request"].build_absolute_uri(program["certificate_path"]) + return None diff --git a/eox_nelp/programs/api/v1/tests/test_utils.py b/eox_nelp/programs/api/v1/tests/test_utils.py index 607b6709..9ca9496a 100644 --- a/eox_nelp/programs/api/v1/tests/test_utils.py +++ b/eox_nelp/programs/api/v1/tests/test_utils.py @@ -79,10 +79,14 @@ class GetProgramLookupRepresentationTestCase(unittest.TestCase): """Test cases for get_program_lookup_representation.""" @patch("eox_nelp.programs.api.v1.utils.get_program_metadata") - @patch("eox_nelp.programs.api.v1.utils.convert_to_isoformat") - @patch("eox_nelp.programs.api.v1.utils.hms_to_int") - @patch("eox_nelp.programs.api.v1.utils.Gregorian") - def test_get_program_lookup_representation(self, mock_gregorian, mock_hms_to_int, mock_convert, mock_get_metadata): + @patch("eox_nelp.programs.api.v1.utils.get_user_lms_certificate_path") + @patch("eox_nelp.programs.api.v1.utils.get_user_generated_certificate") + def test_get_program_lookup_representation( + self, + mock_get_user_generated_certificate, + mock_get_user_lms_certificate_path, + mock_get_metadata, + ): """ Test generating program lookup representation. Expected behavior: @@ -94,9 +98,10 @@ def test_get_program_lookup_representation(self, mock_gregorian, mock_hms_to_int "mandatory": "01", "program_approve": "00", } - mock_convert.side_effect = lambda x: x - mock_hms_to_int.return_value = 5 - mock_gregorian.fromisoformat.return_value.to_hijri.return_value.isoformat.return_value = "1440-01-01" + mock_get_user_generated_certificate.return_value = MagicMock( + modified_date=MagicMock(strftime=MagicMock(return_value="2024-06-01T00:00:00Z")) + ) + mock_get_user_lms_certificate_path.return_value = "/certificates/abc-123" course_api_data = { "course_id": "course-v1:edx+test+2024", "name": "Test Course", @@ -104,14 +109,15 @@ def test_get_program_lookup_representation(self, mock_gregorian, mock_hms_to_int "end": "2020-12-31T00:00:00Z", "effort": "5:00", } + user = MagicMock(id=123) expected_data = { "program_name": "Test Course", "program_code": "CODE", "training_location": "FutureX", - "date_start": "2020-01-01T00:00:00Z", - "date_start_hijri": "1440-01-01", - "date_end": "2020-12-31T00:00:00Z", - "date_end_hijri": "1440-01-01", + "date_start": "2020-01-01", + "date_start_hijri": "1441-05-06", + "date_end": "2020-12-31", + "date_end_hijri": "1442-05-16", "trainer_type": 10, "type_of_activity": None, "type_of_activity_id": 1, @@ -120,8 +126,12 @@ def test_get_program_lookup_representation(self, mock_gregorian, mock_hms_to_int "mandatory": "01", "program_approve": "00", "code": "course-v1:edx+test+2024", + "certificate_path": "/certificates/abc-123", + "completion_date": "2024-06-01", + "completion_date_hijri": "1445-11-24", } - result = utils.get_program_lookup_representation(course_api_data) + + result = utils.get_program_lookup_representation(user, course_api_data) self.assertDictEqual(result, expected_data) @@ -203,3 +213,25 @@ def test_hms_to_int_minutes_out_of_range(self): """ self.assertEqual(utils.hms_to_int("2:99"), 2) self.assertEqual(utils.hms_to_int("2:-5"), 2) + + class GetUserLmsCertificatePathTestCase(unittest.TestCase): + """Test cases for get_user_lms_certificate_path.""" + + @patch("eox_nelp.programs.api.v1.utils.certificates_utils._certificate_html_url") + @patch("eox_nelp.programs.api.v1.utils.certificates_utils.certificate_status_for_student") + def test_returns_certificate_path(self, mock_cert_status, mock_cert_url): + """ + Test that the function returns the correct certificate path. + Expected behavior: + - Returns the LMS certificate path for the user and course. + - Calls certificate_status_for_student and _certificate_html_url with correct parameters. + """ + generated_certificate = MagicMock() + mock_cert_status.return_value = {"uuid": "abc-123"} + mock_cert_url.return_value = "/certificates/abc-123" + + result = utils.get_user_lms_certificate_path(generated_certificate) + + mock_cert_status.assert_called_once_with(generated_certificate) + mock_cert_url.assert_called_once_with(uuid="abc-123") + self.assertEqual(result, "/certificates/abc-123") diff --git a/eox_nelp/programs/api/v1/tests/test_views.py b/eox_nelp/programs/api/v1/tests/test_views.py index 0d732a4d..12375a96 100644 --- a/eox_nelp/programs/api/v1/tests/test_views.py +++ b/eox_nelp/programs/api/v1/tests/test_views.py @@ -405,11 +405,108 @@ def test_get_programs_list_authenticated_without_national_id(self): @patch("eox_nelp.programs.api.v1.views.CourseDetailSerializer") @patch("eox_nelp.programs.api.v1.utils.get_program_metadata") + @patch("eox_nelp.programs.api.v1.utils.get_user_lms_certificate_path") + @patch("eox_nelp.programs.api.v1.utils.get_user_generated_certificate") + @patch("eox_nelp.programs.api.v1.views.CourseEnrollment.is_enrolled") + @patch("eox_nelp.programs.api.v1.views.courses") + def test_get_programs_filtered_by_certificated( + self, + mock_courses, + mock_is_enrolled, + mock_get_user_generated_certificate, + mock_get_user_lms_certificate_path, + mock_get_program_metadata, + mock_course_serializer, + ): # pylint: disable=too-many-arguments, too-many-positional-arguments + """ + Test GET returns program list for authenticated user filtered by certificated only. + Expected behavior: + - Response without filter Status code 200. + - Response without filter matches expected data. + - Response with certificated_only=true Status code 404. + - Response with certificated_only=true returns error as no certificated programs found. """ + user_by_national_id_instance, _ = User.objects.get_or_create(username="user0", password="pass0") + national_id = "1222888555" + ExtraInfo.objects.get_or_create( # pylint: disable=no-member + user=user_by_national_id_instance, + arabic_name="مسؤل", + national_id=national_id, + ) + mock_courses.get_courses.return_value = [MagicMock(id="course-v1:edx+special+2024")] + serializer_side_effect = [] + mock_is_enrolled.return_value = True + for course_data in COURSE_API_SERIALIZER_DATA: + mock_serializer_instance = MagicMock() + mock_serializer_instance.data = course_data + serializer_side_effect.append(mock_serializer_instance) + mock_course_serializer.side_effect = serializer_side_effect + mock_get_user_lms_certificate_path.return_value = "" + mock_get_program_metadata.return_value = { + "trainer_type": 10, + "type_of_activity": 165, + "mandatory": "01", + "program_approve": "00", + "program_code": "eltesst", + "program_name": "small-graded", + } + mock_get_user_generated_certificate.return_value = MagicMock( + modified_date=MagicMock(strftime=MagicMock(return_value="2024-06-01T00:00:00Z")) + ) + expected_data = { + "results": [ + { + "program_name": "testigngg", + "program_code": "eltesst", + "type_of_activity": "برنامج الاستثمار الأمثل (برامج قصيرة)", + "type_of_activity_id": 165, + "mandatory": "01", + "program_approve": "00", + "code": "course-v1:edx+cd101+2020323", + "date_start": "2030-01-01", + "date_end": None, + "date_start_hijri": "1451-08-26", + "date_end_hijri": None, + "duration": 1, + "training_location": "FutureX", + "trainer_type": 10, + "unit": "hour", + "certificate_url": None, + "completion_date": "2024-06-01", + "completion_date_hijri": "1445-11-24", + } + ], + "pagination": {"next": None, "previous": None, "count": 1, "num_pages": 1}, + } + + response = self.client.get(self.url, {"national_id": national_id}) + response_filtered = self.client.get(self.url, {"national_id": national_id, "certificated_only": "true"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data, expected_data) + self.assertDictEqual( + response_filtered.data, + { + "error": "NO_PROGRAM_FOR_NATIONAL_ID", + "message": "No program found for the provided National ID 1222888555." + }, + ) + self.assertEqual(response_filtered.status_code, status.HTTP_404_NOT_FOUND) + + @patch("eox_nelp.programs.api.v1.views.CourseDetailSerializer") + @patch("eox_nelp.programs.api.v1.utils.get_program_metadata") + @patch("eox_nelp.programs.api.v1.utils.get_user_lms_certificate_path") + @patch("eox_nelp.programs.api.v1.utils.get_user_generated_certificate") @patch("eox_nelp.programs.api.v1.views.CourseEnrollment.is_enrolled") @patch("eox_nelp.programs.api.v1.views.courses") def test_get_programs_list_with_national_id( - self, mock_courses, mock_is_enrolled, mock_get_program_metadata, mock_course_serializer, - ): + self, + mock_courses, + mock_is_enrolled, + mock_get_user_generated_certificate, + mock_get_user_lms_certificate_path, + mock_get_program_metadata, + mock_course_serializer, + ): # pylint: disable=too-many-arguments, too-many-positional-arguments """ Test GET returns program list for user with given national_id. Expected behavior: @@ -441,6 +538,10 @@ def test_get_programs_list_with_national_id( "program_approve": "01", "program_code": "nationalidtest", } + mock_get_user_lms_certificate_path.return_value = "/certificates/def-456" + mock_get_user_generated_certificate.return_value = MagicMock( + modified_date=MagicMock(strftime=MagicMock(return_value="2024-06-01T00:00:00Z")) + ) expected_data = [ { "program_name": "testigngg", @@ -458,6 +559,9 @@ def test_get_programs_list_with_national_id( "training_location": "FutureX", "trainer_type": 10, "unit": "hour", + "certificate_url": "http://testserver/certificates/def-456", + "completion_date": "2024-06-01", + "completion_date_hijri": "1445-11-24", } ] @@ -499,9 +603,16 @@ def test_get_programs_list_unauthenticated_national_id(self): @patch("eox_nelp.programs.api.v1.views.courses") @patch("eox_nelp.programs.api.v1.views.CourseDetailSerializer") @patch("eox_nelp.programs.api.v1.utils.get_program_metadata") + @patch("eox_nelp.programs.api.v1.utils.get_user_lms_certificate_path") + @patch("eox_nelp.programs.api.v1.utils.get_user_generated_certificate") def test_get_programs_list_missing_data( - self, mock_get_program_metadata, mock_course_serializer, mock_courses - ): + self, + mock_get_user_generated_certificate, + mock_get_user_lms_certificate_path, + mock_get_program_metadata, + mock_course_serializer, + mock_courses, + ): # pylint: disable=too-many-arguments, too-many-positional-arguments """ Test GET returns error if ProgramLookupSerializer is invalid. Expected behavior: @@ -525,6 +636,8 @@ def test_get_programs_list_missing_data( serializer_side_effect.append(mock_serializer_instance) mock_course_serializer.side_effect = serializer_side_effect mock_get_program_metadata.return_value = {} + mock_get_user_lms_certificate_path.return_value = "" + mock_get_user_generated_certificate.return_value = None expected_data = [ { "program_name": "testigngg", @@ -542,6 +655,9 @@ def test_get_programs_list_missing_data( "training_location": "FutureX", "trainer_type": 10, "unit": "hour", + "certificate_url": None, + "completion_date": None, + "completion_date_hijri": None, }, { "program_name": "small-graded", @@ -559,6 +675,9 @@ def test_get_programs_list_missing_data( "training_location": "FutureX", "trainer_type": 10, "unit": "hour", + "certificate_url": None, + "completion_date": None, + "completion_date_hijri": None, }, ] @@ -592,13 +711,21 @@ def test_get_programs_list_not_found_national_id(self, mock_super_get): @patch("eox_nelp.programs.api.v1.views.CourseDetailSerializer") @patch("eox_nelp.programs.api.v1.utils.get_program_metadata") + @patch("eox_nelp.programs.api.v1.utils.get_user_lms_certificate_path") + @patch("eox_nelp.programs.api.v1.utils.get_user_generated_certificate") @patch("eox_nelp.programs.api.v1.views.CourseEnrollment.is_enrolled") @patch("eox_nelp.programs.api.v1.views.courses") def test_get_programs_list_no_courses_enrolled( - self, mock_courses, mock_is_enrolled, _, mock_course_serializer, - ): - """ - Test GET returns program list for user with given national_id. + self, + mock_courses, + mock_is_enrolled, + mock_get_user_generated_certificate, + mock_get_user_lms_certificate_path, + mock_get_program_metadata, + mock_course_serializer, + ): # pylint: disable=too-many-arguments, too-many-positional-arguments + """ + Test GET not returns program list for user with given national_id. Expected behavior: - Status code 400. - The expected data matches with the response. @@ -621,6 +748,11 @@ def test_get_programs_list_no_courses_enrolled( course = MagicMock(id="course-v1:edx+special+2024") mock_courses.get_courses.return_value = [course] mock_is_enrolled.return_value = False + mock_get_program_metadata.return_value = {} + mock_get_user_lms_certificate_path.return_value = "" + mock_get_user_generated_certificate.return_value = MagicMock( + modified_date=MagicMock(strftime=MagicMock(return_value="2024-06-01T00:00:00Z")) + ) response = self.client.get(self.url, {"national_id": national_id}) diff --git a/eox_nelp/programs/api/v1/utils.py b/eox_nelp/programs/api/v1/utils.py index 89106aa6..b44f34e3 100644 --- a/eox_nelp/programs/api/v1/utils.py +++ b/eox_nelp/programs/api/v1/utils.py @@ -10,6 +10,8 @@ from hijridate import Gregorian from opaque_keys.edx.keys import CourseKey +from eox_nelp.edxapp_wrapper.certificates import models as certificates_models +from eox_nelp.edxapp_wrapper.certificates import utils as certificates_utils from eox_nelp.edxapp_wrapper.modulestore import modulestore from eox_nelp.programs.api.v1.constants import TYPES_OF_ACTIVITY_MAPPING @@ -52,7 +54,7 @@ def update_program_metadata(course_id, program_data, user): store.update_item(course_block, user.id) -def get_program_lookup_representation(course_api_data): +def get_program_lookup_representation(user, course_api_data): """ Generate a lookup representation for program metadata. @@ -65,6 +67,10 @@ def get_program_lookup_representation(course_api_data): program_metadata = get_program_metadata(course_api_data["course_id"]) date_start_iso = convert_to_isoformat(course_api_data.get("start")) date_end_iso = convert_to_isoformat(course_api_data.get("end")) + generated_certificate = get_user_generated_certificate(user, course_api_data["course_id"]) + completion_date = convert_to_isoformat( + generated_certificate.modified_date.strftime("%Y-%m-%dT%H:%M:%S%z") + ) if generated_certificate else None program_lookup_representation = { "program_name": course_api_data.get("name"), "program_code": program_metadata.get("program_code"), @@ -81,6 +87,11 @@ def get_program_lookup_representation(course_api_data): "mandatory": program_metadata.get("mandatory"), "program_approve": program_metadata.get("program_approve"), "code": course_api_data["course_id"], + "certificate_path": get_user_lms_certificate_path(generated_certificate) if generated_certificate else None, + "completion_date": completion_date, + "completion_date_hijri": Gregorian.fromisoformat( + completion_date + ).to_hijri().isoformat() if completion_date else None, } return program_lookup_representation @@ -133,3 +144,34 @@ def hms_to_int(time_str): except ValueError as e: logger.warning("Error converting time string: %s", e) return None + + +def get_user_lms_certificate_path(generated_certificate): + """ + Retrieve the path of a certificate using a generated certificate object. + + Args: + generated_certificate: GeneratedCertificate object + """ + cert_status = certificates_utils.certificate_status(generated_certificate) + lms_certificate_path = certificates_utils._certificate_html_url( # pylint: disable=protected-access + uuid=cert_status["uuid"], + ) + return lms_certificate_path + + +def get_user_generated_certificate(user, course_id): + """ + Retrieve the GeneratedCertificate object for a user and course. + + Args: + user: User object + course_id: Course identifier + + """ + GeneratedCertificate = certificates_models.GeneratedCertificate # pylint: disable=invalid-name + try: + generated_certificate = GeneratedCertificate.objects.get(user=user, course_id=course_id) + except GeneratedCertificate.DoesNotExist: + generated_certificate = None + return generated_certificate diff --git a/eox_nelp/programs/api/v1/views.py b/eox_nelp/programs/api/v1/views.py index b24bfb97..73da9b50 100644 --- a/eox_nelp/programs/api/v1/views.py +++ b/eox_nelp/programs/api/v1/views.py @@ -204,7 +204,7 @@ def get_queryset(self): program_queryset = [] for course in visible_courses_queryset: course_data = CourseDetailSerializer(course, context={'request': self.request}).data - program_lookup = get_program_lookup_representation(course_data) + program_lookup = get_program_lookup_representation(self.request.user_by_national_id, course_data) if CourseEnrollment.is_enrolled(self.request.user_by_national_id, program_lookup["code"]): program_queryset.append(program_lookup) @@ -212,8 +212,16 @@ def get_queryset(self): def filter_queryset(self, queryset): """ - Filter the queryset... + Filter the queryset using query parameters. + Filters: + - certificated_only: If true, only include programs with a certificate path. + Args: + queryset: Initial queryset Returns: QuerySet: filtered queryset """ + if self.request.query_params.get("certificated_only") == "true": + queryset = [ + program_data for program_data in queryset if program_data.get("certificate_path") + ] return queryset