diff --git a/common/djangoapps/student/models/course_enrollment.py b/common/djangoapps/student/models/course_enrollment.py index 0673495f0e..831915ae8f 100644 --- a/common/djangoapps/student/models/course_enrollment.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -179,13 +179,10 @@ class CourseEnrollmentQuerySet(models.QuerySet): def get_user_course_ids_with_certificates(self, username): """ - Gets user's course ids with certificates. + Retrieve the list of course IDs for which the given user has earned certificates. """ - from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel - course_ids_with_certificates = GeneratedCertificate.objects.filter( - user__username=username - ).values_list('course_id', flat=True) - return course_ids_with_certificates + from lms.djangoapps.certificates.api import get_course_ids_from_certs_for_user + return get_course_ids_from_certs_for_user(username) class CourseEnrollmentManager(models.Manager): diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index 02b254ffad..023b815053 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -24,7 +24,7 @@ from common.djangoapps.student.api import is_user_enrolled_in_course from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.branding import api as branding_api from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION as _AUTO_CERTIFICATE_GENERATION -from lms.djangoapps.certificates.data import CertificateStatuses +from lms.djangoapps.certificates.data import CertificateStatuses, GeneratedCertificateData from lms.djangoapps.certificates.generation_handler import generate_certificate_task as _generate_certificate_task from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist as _is_on_certificate_allowlist from lms.djangoapps.certificates.models import ( @@ -32,6 +32,7 @@ from lms.djangoapps.certificates.models import ( CertificateDateOverride, CertificateGenerationConfiguration, CertificateGenerationCourseSetting, + CertificateGenerationHistory, CertificateInvalidation, CertificateTemplate, CertificateTemplateAsset, @@ -129,19 +130,16 @@ def get_certificate_for_user(username, course_key, format_results=True): Arguments: username (unicode): The identifier of the user. course_key (CourseKey): A Course Key. + format_results (boolean): Default True. If False, return the GeneratedCertificate object + instead of the serialized dict representation. Returns: - A dict containing information about the certificate or, optionally, + A dict containing a serialized representation of the certificate or, optionally, the GeneratedCertificate object itself. + If there is no GeneratedCertificate object for the user and course combination, returns None. """ - try: - cert = GeneratedCertificate.eligible_certificates.get(user__username=username, course_id=course_key) - except GeneratedCertificate.DoesNotExist: - return None - if format_results: - return _format_certificate_for_user(username, cert) - else: - return cert + cert = GeneratedCertificate.eligible_certificates.filter(user__username=username, course_id=course_key).first() + return _format_certificate_for_user(username, cert) if cert and format_results else cert def get_certificate_for_user_id(user, course_id): @@ -728,16 +726,44 @@ def create_certificate_invalidation_entry(certificate, user_requesting_invalidat return certificate_invalidation -def get_certificate_invalidation_entry(certificate): +def get_certificate_invalidation_entry(generated_certificate, invalidated_by=None, notes=None, active=None): """ - Retrieves and returns an certificate invalidation entry for a given certificate id. + Retrieve a certificate invalidation entry for a specified certificate. + + Optionally filter the invalidation entry by who invalidated it, notes, and whether the invalidation is active. + + Args: + generated_certificate (GeneratedCertificate): The certificate to find the invalidation entry for. + invalidated_by (User, optional): User who invalidated the certificate. Defaults to None. + notes (str, optional): Notes associated with the invalidation. Defaults to None. + active (bool, optional): Whether the invalidation entry is currently active. Defaults to None. + + Returns: + CertificateInvalidationEntry or None: The matching invalidation entry if found, else None. """ - log.info(f"Attempting to retrieve certificate invalidation entry for certificate with id {certificate.id}.") - try: - certificate_invalidation_entry = CertificateInvalidation.objects.get(generated_certificate=certificate) - except ObjectDoesNotExist: - log.warning(f"No certificate invalidation found linked to certificate with id {certificate.id}.") - return None + log.info( + f"Attempting to retrieve certificate invalidation entry " + f"for certificate with id {generated_certificate.id}." + ) + + cert_filter_args = { + "generated_certificate": generated_certificate + } + + if invalidated_by is not None: + cert_filter_args["invalidated_by"] = invalidated_by + + if notes is not None: + cert_filter_args["notes"] = notes + + if active is not None: + cert_filter_args["active"] = active + + certificate_invalidation_entry = CertificateInvalidation.objects.filter(**cert_filter_args).first() + + # If object does not exist, add a warning to the logs + if certificate_invalidation_entry is None: + log.warning(f"No certificate invalidation found linked to certificate with id {generated_certificate.id}.") return certificate_invalidation_entry @@ -958,3 +984,281 @@ def clear_pii_from_certificate_records_for_user(user): None """ GeneratedCertificate.objects.filter(user=user).update(name="") + + +def get_cert_history_for_course_id(course_id): + """ + Retrieve all certificate generation history records for the specified course. + + Args: + course_id (CourseLocator | CourseKey): The unique identifier for the course. + + Returns: + QuerySet[CertificateGenerationHistory]: A queryset of all certificate generation history records + associated with the specified course. + """ + return CertificateGenerationHistory.objects.filter(course_id=course_id) + + +def is_certificate_generation_enabled(): + """ + Checks if certificate generation is currently enabled. + + This function queries the `CertificateGenerationConfiguration` model to retrieve the + current configuration and returns whether certificate generation is enabled or not. + + Returns: + bool: True if certificate generation is enabled, False otherwise. + """ + return CertificateGenerationConfiguration.current().enabled + + +def set_certificate_generation_config(enabled=True): + """ + Configures the certificate generation settings. + + Args: + enabled (bool): If True, enables certificate generation; if False, disables it. Default is True. + + Returns: + CertificateGenerationConfiguration: The created or updated certificate generation configuration object. + """ + return CertificateGenerationConfiguration.objects.create(enabled=enabled) + + +def get_certificate_generation_history(course_id=None, generated_by=None, instructor_task=None, is_regeneration=None): + """ + Retrieves a queryset of `CertificateGenerationHistory` records, filtered by the provided criteria. + + Args: + course_id (Optional[CourseKey]): The unique ID of the course (if filtering by course). + generated_by (Optional[User]): The user who generated the certificate (if filtering by user). + instructor_task (Optional[bool]): Whether the certificate generation was triggered by an instructor task. + is_regeneration (Optional[bool]): Whether the certificate was regenerated. + + Returns: + QuerySet[CertificateGenerationHistory]: A queryset of filtered `CertificateGenerationHistory` records. + """ + cert_filter_args = {} + + # Only add the filter if the corresponding argument is provided + if course_id is not None: + cert_filter_args["course_id"] = course_id + + if generated_by is not None: + cert_filter_args["generated_by"] = generated_by + + if instructor_task is not None: + cert_filter_args["instructor_task"] = instructor_task + + if is_regeneration is not None: + cert_filter_args["is_regeneration"] = is_regeneration + + res = CertificateGenerationHistory.objects.filter(**cert_filter_args) + + return res + + +def create_or_update_certificate_generation_history(course_id, generated_by, instructor_task, is_regeneration): + """ + Creates a new certificate generation history record or updates an existing one. + + Args: + course_id (CourseKey): The unique identifier for the course run. + generated_by (User): The user (typically an instructor or admin) who initiated the certificate generation. + instructor_task (str): A descriptor or task identifier for the instructor-related task. + is_regeneration (bool): A flag indicating whether the certificate is being + regenerated (True) or newly generated (False). + + Returns: + CertificateGenerationHistory: The created or updated CertificateGenerationHistory instance. + """ + cert_history, created = CertificateGenerationHistory.objects.update_or_create( + course_id=course_id, + generated_by=generated_by, + instructor_task=instructor_task, + is_regeneration=is_regeneration + ) + + return cert_history + + +def create_or_update_eligible_certificate_for_user(user, course_id, status): + """ + Create or update an eligible GeneratedCertificate for a user in a specific course. + + Args: + user (User): The user for whom the certificate is being created or updated. + course_id (CourseKey): The unique identifier for the course the certificate applies to. + status (str): The status of the certificate (e.g., "downloadable", "issued"). + + Returns: + tuple: A tuple containing: + - `GeneratedCertificate`: The created or updated certificate. + - `bool`: A boolean indicating whether the certificate was created (True) or updated (False). + """ + + cert, created = GeneratedCertificate.eligible_certificates.update_or_create( + user=user, + course_id=course_id, + status=status + ) + + return cert, created + + +def get_cert_invalidations_for_course(course_key): + """ + Retrieves all certificate invalidations associated with the specified course. + + Args: + course_key (CourseKey): The unique identifier for the course run. + + Returns: + QuerySet[CertificateInvalidation]: A queryset containing all invalidations for the specified course. + """ + return CertificateInvalidation.get_certificate_invalidations(course_key) + + +def get_certificates_for_course_and_users(course_id, users): + """ + Retrieves all GeneratedCertificate records for a specific course and a list of users. + + Args: + course_id (CourseKey): The unique identifier for the course run. + users (Iterable[User]): A list or queryset of User instances to filter certificates by. + + Returns: + QuerySet[GeneratedCertificate]: A queryset containing the matching certificate records. + """ + return GeneratedCertificate.objects.filter(course_id=course_id, user__in=users) + + +def get_course_ids_from_certs_for_user(username): + """ + Retrieves a list of course IDs for which the given user has generated certificates. + + Args: + username (str): The username of the user whose course IDs are being retrieved. + + Returns: + list: A list of course IDs for which the user has generated certificates. + If no certificates are found, an empty list is returned. + """ + course_ids_with_certificates = GeneratedCertificate.objects.filter( + user__username=username + ).values_list('course_id', flat=True) + + return list(course_ids_with_certificates) + + +def get_generated_certificate(user, course_id): + """ + Retrieves the GeneratedCertificateData for the given user and course. + + Args: + user (User): The user for whom the certificate is being fetched. + course_id (CourseKey): The unique identifier for the course. + + Returns: + Optional[GeneratedCertificateData]: A `GeneratedCertificateData` object if found, otherwise None. + """ + try: + cert = GeneratedCertificate.objects.get(user=user, course_id=course_id) + return GeneratedCertificateData( + user=cert.user, + course_id=cert.course_id + ) + except GeneratedCertificate.DoesNotExist: + return None + + +def create_generated_certificate(cert_args): + """ + Creates a new `GeneratedCertificate` object using the provided arguments. + + Args: + cert_args (dict): A dictionary containing the certificate's attributes, such as + "user", "course_id", "status", etc. + + Returns: + GeneratedCertificate: The newly created `GeneratedCertificate` object. + + Raises: + ValueError: If any required fields ("user", "course_id") are missing from `cert_args`. + """ + + required_fields = {"user", "course_id"} + + # Check if all required fields are present + if not all(field in cert_args for field in required_fields): + raise ValueError(f"Missing required fields: {required_fields - cert_args.keys()}") + + # Create and return the GeneratedCertificate object + return GeneratedCertificate.objects.create(**cert_args) + + +def get_eligible_certificate(user, course_id): + """ + Retrieves the eligible GeneratedCertificate for a given user and course. + + Args: + user (User): The user object for whom the certificate is being retrieved. + course_id (CourseKey): The unique identifier for the course run. + + Returns: + GeneratedCertificate or None: The eligible certificate instance if one exists; otherwise, None. + """ + try: + return GeneratedCertificate.eligible_certificates.get( + user=user.id, + course_id=course_id + ) + except GeneratedCertificate.DoesNotExist: + return None + + +def get_eligible_and_available_certificates(user): + """ + Retrieves all eligible and available certificates for the specified user. + + Args: + user (User): The user whose eligible and available certificates are being retrieved. + + Returns: + QuerySet[GeneratedCertificate]: A queryset containing all eligible and available certificates + for the specified user. + """ + return GeneratedCertificate.eligible_available_certificates.filter(user=user) + + +def get_certificates_by_course_and_status(course_id, status): + """ + Retrieves all eligible certificates for a specific course and status. + + Args: + course_id (CourseKey): The unique identifier for the course run. + status (str): The status of the certificates to filter by (e.g., "downloadable", "issued"). + + Returns: + QuerySet[GeneratedCertificate]: A queryset containing the eligible certificates + matching the provided course ID and status. + """ + + return GeneratedCertificate.eligible_certificates.filter( + course_id=course_id, + status=status + ) + + +def get_unique_certificate_statuses(course_key): + """ + Retrieves the unique certificate statuses for the specified course. + + Args: + course_key (CourseKey): The unique identifier for the course run. + + Returns: + QuerySet[str]: A queryset containing the unique certificate statuses for the specified course. + """ + return GeneratedCertificate.get_unique_statuses(course_key=course_key) diff --git a/lms/djangoapps/certificates/data.py b/lms/djangoapps/certificates/data.py index e69fc7dcb9..dba44ab186 100644 --- a/lms/djangoapps/certificates/data.py +++ b/lms/djangoapps/certificates/data.py @@ -4,6 +4,12 @@ Certificates Data This provides Data models to represent Certificates data. """ +from dataclasses import dataclass +from opaque_keys.edx.keys import CourseKey +from django.contrib.auth import get_user_model + +User = get_user_model() + class CertificateStatuses: """ @@ -81,3 +87,51 @@ class CertificateStatuses: bool: True if the status is refundable. """ return status not in cls.NON_REFUNDABLE_STATUSES + + +@dataclass +class GeneratedCertificateData: + """ + A data representation of a generated course certificate. + + This class encapsulates the essential fields related to a user's generated + certificate, including course information, user identity, certificate status, + grade, and download metadata. + + Attributes: + user (User): The user who earned the certificate. + course_id (CourseKey): Identifier for the course associated with the certificate. + verify_uuid (str): UUID used to verify the certificate. + grade (str): The grade achieved in the course. + key (str): Internal key identifier for the certificate. + distinction (bool): Whether the certificate was issued with distinction. + status (str): Current status of the certificate (e.g., 'downloadable', 'unavailable'). + mode (str): Enrollment mode at the time of certificate issuance (e.g., 'honor', 'verified'). + name (str): Full name as it appears on the certificate. + created_date (str): Timestamp for when the certificate was created. + modified_date (str): Timestamp for when the certificate was last modified. + download_uuid (str): UUID used for generating the download URL. + download_url (str): Direct URL to download the certificate. + error_reason (str): Reason for any certificate generation failure, if applicable. + + Methods: + validate_mode(): Validates that the mode is within the supported set of enrollment modes. + """ + user: User + course_id: CourseKey + verify_uuid: str = "" + grade: str = "" + key: str = "" + distinction: bool = False + status: str = "unavailable" + mode: str = "honor" + name: str = "" + created_date: str = None + modified_date: str = None + download_uuid: str = "" + download_url: str = "" + error_reason: str = "" + + def validate_mode(self): + if self.mode not in self.MODES: + raise ValueError(f"Invalid mode: {self.mode}") diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index ca12c98966..827e4017c6 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -46,6 +46,7 @@ from lms.djangoapps.certificates.api import ( get_certificate_invalidation_entry, get_certificate_url, get_certificates_for_user, + get_course_ids_from_certs_for_user, get_certificates_for_user_by_course_keys, has_self_generated_certificates_enabled, is_certificate_invalidated, @@ -1275,3 +1276,46 @@ class CertificatesLearnerRetirementFunctionality(ModuleStoreTestCase): cert_course2 = GeneratedCertificate.objects.get(user=self.user, course_id=self.course2.id) assert cert_course1.name == "" assert cert_course2.name == "" + + +class GetCourseIdsForUsernameTests(TestCase): + """ + Test suite for the `get_course_ids_from_certs_for_user` function. + """ + + def setUp(self): + """ + Set up a user and two course certificates for testing. + + Creates a test user using a factory and generates two course certificates + associated with distinct course keys. + """ + self.user = UserFactory() + self.course_key_1 = CourseKey.from_string("course-v1:some+fake+course1") + self.course_key_2 = CourseKey.from_string("course-v1:some+fake+course2") + + GeneratedCertificate.objects.create(user=self.user, course_id=self.course_key_1) + GeneratedCertificate.objects.create(user=self.user, course_id=self.course_key_2) + + def test_returns_correct_course_ids(self): + """ + Test that the function returns all course IDs for which the user has certificates. + + Verifies that both course keys created in setUp are returned when the + user's username is passed to the function. + """ + course_ids = get_course_ids_from_certs_for_user(self.user) + + self.assertIn(self.course_key_1, course_ids) + self.assertIn(self.course_key_2, course_ids) + self.assertEqual(len(course_ids), 2) + + def test_returns_empty_for_unknown_user(self): + """ + Test that the function returns an empty list if the user has no certificates. + + Uses a non-existent username to ensure that the function does not raise + errors and returns an empty list as expected. + """ + course_ids = get_course_ids_from_certs_for_user("nonexistentuser") + self.assertEqual(course_ids, []) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 918b83d960..47b55e1090 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -57,11 +57,7 @@ from common.djangoapps.util.tests.test_date_utils import fake_pgettext, fake_uge from common.djangoapps.util.url import reload_django_url_config from common.djangoapps.util.views import ensure_valid_course_key from lms.djangoapps.certificates import api as certs_api -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - CertificateGenerationCourseSetting, - CertificateStatuses -) +from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import ( CertificateAllowlistFactory, CertificateInvalidationFactory, @@ -320,6 +316,7 @@ class CoursewareIndexTestCase(BaseViewsTestCase): """ Tests for the courseware index view, used for instructor previews. """ + def setUp(self): super().setUp() self._create_global_staff_user() # this view needs staff permission @@ -1135,7 +1132,7 @@ class ProgressPageTests(ProgressPageBaseTests): self.assertNotContains(resp, 'Request Certificate') # Enable the feature, but do not enable it for this course - CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_certificate_generation_config(enabled=True) resp = self._get_progress_page() self.assertNotContains(resp, 'Request Certificate') @@ -1160,7 +1157,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) # Enable the feature, but do not enable it for this course - CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_certificate_generation_config(enabled=True) # Enable certificate generation for this course certs_api.set_cert_generation_enabled(self.course.id, True) @@ -1191,7 +1188,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) # Enable the feature, but do not enable it for this course - CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_certificate_generation_config(enabled=True) # Enable certificate generation for this course certs_api.set_cert_generation_enabled(self.course.id, True) @@ -1281,7 +1278,7 @@ class ProgressPageTests(ProgressPageBaseTests): @ddt.unpack def test_show_certificate_request_button(self, course_mode, user_verified): """Verify that the Request Certificate is not displayed in audit mode.""" - CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_certificate_generation_config(enabled=True) certs_api.set_cert_generation_enabled(self.course.id, True) CourseEnrollment.enroll(self.user, self.course.id, mode=course_mode) with patch( @@ -1547,10 +1544,8 @@ class ProgressPageTests(ProgressPageBaseTests): Verify if the learner is not ID Verified, and the certs are not yet generated, but the learner is eligible, the get_cert_data would return cert status Unverified """ - CertificateGenerationConfiguration(enabled=True).save() - CertificateGenerationCourseSetting( - course_key=self.course.id, self_generation_enabled=True - ).save() + certs_api.set_certificate_generation_config(enabled=True) + certs_api.set_cert_generation_enabled(self.course.id, True) with patch.dict(settings.FEATURES, ENABLE_CERTIFICATES_IDV_REQUIREMENT=enable_cert_idv_requirement): with patch( 'lms.djangoapps.certificates.api.certificate_downloadable_status', @@ -1589,7 +1584,7 @@ class ProgressPageTests(ProgressPageBaseTests): status=CertificateStatuses.downloadable, mode=mode ) - CertificateGenerationConfiguration(enabled=True).save() + certs_api.set_certificate_generation_config(enabled=True) certs_api.set_cert_generation_enabled(self.course.id, True) return generated_certificate @@ -2249,6 +2244,7 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf This class overrides the get_response method, which is used by the tests defined in RenderXBlockTestMixin. """ + def setUp(self): reload_django_url_config() super().setUp() @@ -2520,6 +2516,7 @@ class TestBasePublicVideoXBlock(ModuleStoreTestCase): """ Tests for public video xblock. """ + def setup_course(self, enable_waffle=True): """ Helper method to create the course. @@ -2563,6 +2560,7 @@ class TestRenderPublicVideoXBlock(TestBasePublicVideoXBlock): """ Tests for the courseware.render_public_video_xblock endpoint. """ + def get_response(self, usage_key, is_embed): """ Overridable method to get the response from the endpoint that is being tested. @@ -2614,6 +2612,7 @@ class TestRenderXBlockSelfPaced(TestRenderXBlock): # lint-amnesty, pylint: disa Test rendering XBlocks for a self-paced course. Relies on the query count assertions in the tests defined by RenderXBlockMixin. """ + def setUp(self): # lint-amnesty, pylint: disable=useless-super-delegation super().setUp() @@ -2627,6 +2626,7 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCa """ Ensure that the Enterprise Data Consent redirects are in place only when consent is required. """ + def setUp(self): super().setUp() self.user = UserFactory.create() @@ -2727,6 +2727,7 @@ class DatesTabTestCase(TestCase): """ Ensure that the legacy dates view redirects appropriately (it no longer exists). """ + def test_legacy_redirect(self): """ Verify that the legacy dates page redirects to the MFE correctly. @@ -2771,6 +2772,7 @@ class ContentOptimizationTestCase(ModuleStoreTestCase): """ Test our ability to make browser optimizations based on XBlock content. """ + def setUp(self): super().setUp() self.math_html_usage_keys = [] diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py index 42dd9139ee..2b9f732912 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py @@ -35,7 +35,10 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, U from common.djangoapps.student.tests.factories import InstructorFactory from common.djangoapps.student.tests.factories import StaffFactory from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.api import ( + get_certificate_for_user_id, + create_or_update_eligible_certificate_for_user +) from lms.djangoapps.grades.config.waffle import BULK_MANAGEMENT, WRITABLE_GRADEBOOK from lms.djangoapps.grades.constants import GradeOverrideFeatureEnum from lms.djangoapps.grades.course_data import CourseData @@ -1819,11 +1822,12 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase): Test that when we update a user's grade to failing, their certificate is marked notpassing """ with override_waffle_flag(self.waffle_flag, active=True): - GeneratedCertificate.eligible_certificates.create( - user=self.student, - course_id=self.course.id, - status=CertificateStatuses.downloadable, - ) + cert_args = { + "user": self.student, + "course_id": self.course.id, + "status": CertificateStatuses.downloadable, + } + create_or_update_eligible_certificate_for_user(**cert_args) self.login_staff() post_data = [ { @@ -1853,7 +1857,7 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase): content_type='application/json', ) assert status.HTTP_202_ACCEPTED == resp.status_code - cert = GeneratedCertificate.certificate_for_student(self.student, self.course.id) + cert = get_certificate_for_user_id(self.student, self.course.id) assert cert.status == CertificateStatuses.notpassing diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index d58a70940d..ce6bdbd983 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -21,11 +21,6 @@ from common.djangoapps.student.tests.factories import InstructorFactory from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.certificates import api as certs_api from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - CertificateInvalidation, - GeneratedCertificate -) from lms.djangoapps.certificates.tests.factories import ( CertificateAllowlistFactory, CertificateInvalidationFactory, @@ -64,7 +59,7 @@ class CertificateTaskViewTests(SharedModuleStoreTestCase): cache.clear() # Enable the certificate generation feature - CertificateGenerationConfiguration.objects.create(enabled=True) + certs_api.set_certificate_generation_config(enabled=True) def _login_as(self, role): """ @@ -182,7 +177,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): cache.clear() # Enable the certificate generation feature - CertificateGenerationConfiguration.objects.create(enabled=True) + certs_api.set_certificate_generation_config(enabled=True) def test_visible_only_to_global_staff(self): # Instructors don't see the certificates section @@ -195,7 +190,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): def test_visible_only_when_feature_flag_enabled(self): # Disable the feature flag - CertificateGenerationConfiguration.objects.create(enabled=False) + certs_api.set_certificate_generation_config(enabled=False) cache.clear() # Now even global staff can't see the certificates section @@ -357,7 +352,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() - CertificateGenerationConfiguration.objects.create(enabled=True) + certs_api.set_certificate_generation_config(enabled=True) @ddt.data('enable_certificate_generation') def test_allow_only_global_staff(self, url_name): @@ -463,8 +458,8 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): # Assert success message assert res_json['message'] ==\ - 'Certificate regeneration task has been started.' \ - ' You can view the status of the generation task in the "Pending Tasks" section.' + 'Certificate regeneration task has been started.' \ + ' You can view the status of the generation task in the "Pending Tasks" section.' def test_certificate_regeneration_error(self): """ @@ -493,7 +488,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): # Assert Error Message assert res_json['message'] ==\ - 'Please select certificate statuses from the list only.' + 'Please select certificate statuses from the list only.' # Access the url passing 'certificate_statuses' that are not present in db url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) @@ -550,7 +545,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() - CertificateGenerationConfiguration.objects.create(enabled=True) + certs_api.set_certificate_generation_config(enabled=True) self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_certificate_exception_added_successfully(self): @@ -778,7 +773,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase): assert not res_json['success'] # Assert Error Message assert res_json['message'] ==\ - 'The record is not in the correct format. Please add a valid username or email address.' + 'The record is not in the correct format. Please add a valid username or email address.' def test_remove_certificate_exception_non_existing_error(self): """ @@ -869,7 +864,7 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() - CertificateGenerationConfiguration.objects.create(enabled=True) + certs_api.set_certificate_generation_config(enabled=True) self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_generate_certificate_exceptions_all_students(self): @@ -1035,7 +1030,7 @@ class TestCertificatesInstructorApiBulkAllowlist(SharedModuleStoreTestCase): data = json.loads(response.content.decode('utf-8')) assert len(data['general_errors']) != 0 assert data['general_errors'][0] ==\ - 'Make sure that the file you upload is in CSV format with no extraneous characters or rows.' + 'Make sure that the file you upload is in CSV format with no extraneous characters or rows.' def test_bad_file_upload_type(self): """ @@ -1206,20 +1201,20 @@ class CertificateInvalidationViewTests(SharedModuleStoreTestCase): # Verify that CertificateInvalidation record has been created in the database i.e. no DoesNotExist error try: - CertificateInvalidation.objects.get( - generated_certificate=self.generated_certificate, - invalidated_by=self.global_staff, - notes=self.notes, - active=True, - ) + cert_filter_args = { + "generated_certificate": self.generated_certificate, + "invalidated_by": self.global_staff, + "notes": self.notes, + "active": True + } + certs_api.get_certificate_invalidation_entry(**cert_filter_args) + except ObjectDoesNotExist: self.fail("The certificate is not invalidated.") - # Validate generated certificate was invalidated - generated_certificate = GeneratedCertificate.eligible_certificates.get( - user=self.enrolled_user_1, - course_id=self.course.id, - ) + # Check if the generated certificate was invalidated + generated_certificate = certs_api.get_certificate_for_user(self.enrolled_user_1, self.course.id, False) + assert not generated_certificate.is_valid() def test_missing_username_and_email_error(self): @@ -1345,12 +1340,18 @@ class CertificateInvalidationViewTests(SharedModuleStoreTestCase): assert response.status_code == 204 # Verify that certificate invalidation successfully removed from database + with pytest.raises(ObjectDoesNotExist): - CertificateInvalidation.objects.get( - generated_certificate=self.generated_certificate, - invalidated_by=self.global_staff, - active=True, - ) + certs_filter_args = { + "generated_certificate": self.generated_certificate, + "invalidated_by": self.global_staff, + "active": True + } + + cert_invalidation_entry = certs_api.get_certificate_invalidation_entry(**certs_filter_args) + + if cert_invalidation_entry is None: + raise ObjectDoesNotExist def test_remove_certificate_invalidation_error(self): """ diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index f3b2dc9bc7..2cc86c4246 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -39,12 +39,6 @@ from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled from lms.djangoapps.bulk_email.models_api import is_bulk_email_disabled_for_course from lms.djangoapps.certificates import api as certs_api from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - CertificateGenerationHistory, - CertificateInvalidation, - GeneratedCertificate -) from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.block_render import get_block_by_usage_id from lms.djangoapps.courseware.courses import get_studio_url @@ -213,7 +207,7 @@ def instructor_dashboard_2(request, course_id): # lint-amnesty, pylint: disable # This is used to generate example certificates # and enable self-generated certificates for a course. # Note: This is hidden for all CCXs - certs_enabled = CertificateGenerationConfiguration.current().enabled and not hasattr(course_key, 'ccx') + certs_enabled = certs_api.is_certificate_generation_enabled() and not hasattr(course_key, 'ccx') certs_instructor_enabled = settings.FEATURES.get('ENABLE_CERTIFICATES_INSTRUCTOR_MANAGE', False) if certs_enabled and (access['admin'] or (access['instructor'] and certs_instructor_enabled)): @@ -250,7 +244,7 @@ def instructor_dashboard_2(request, course_id): # lint-amnesty, pylint: disable kwargs={'course_id': str(course_key)} ) - certificate_invalidations = CertificateInvalidation.get_certificate_invalidations(course_key) + certificate_invalidations = certs_api.get_cert_invalidations_for_course(course_key) context = { 'course': course, @@ -375,7 +369,7 @@ def _section_certificates(course): instructor_generation_enabled = settings.FEATURES.get('CERTIFICATES_INSTRUCTOR_GENERATION', False) certificate_statuses_with_count = { certificate['status']: certificate['count'] - for certificate in GeneratedCertificate.get_unique_statuses(course_key=course.id) + for certificate in certs_api.get_unique_certificate_statuses(course.id) } return { @@ -391,7 +385,7 @@ def _section_certificates(course): 'certificate_statuses_with_count': certificate_statuses_with_count, 'status': CertificateStatuses, 'certificate_generation_history': - CertificateGenerationHistory.objects.filter(course_id=course.id).order_by("-created"), + certs_api.get_cert_history_for_course_id(course_id=course.id).order_by("-created"), 'urls': { 'enable_certificate_generation': reverse( 'certificate_task', diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 41bae91531..9b83acdf61 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ from rest_framework import serializers -from lms.djangoapps.certificates.models import CertificateStatuses +from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.instructor.access import ROLES from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 7dcceab5b4..5f9dfc8a32 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -20,7 +20,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey import xmodule.graders as xmgraders from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.api import get_certificates_by_course_and_status from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.grades.api import context as grades_context from lms.djangoapps.program_enrollments.api import fetch_program_enrollments_by_students @@ -71,10 +71,13 @@ def issued_certificates(course_key, features): report_run_date = datetime.date.today().strftime("%B %d, %Y") certificate_features = [x for x in CERTIFICATE_FEATURES if x in features] - generated_certificates = list(GeneratedCertificate.eligible_certificates.filter( - course_id=course_key, - status=CertificateStatuses.downloadable - ).values(*certificate_features).annotate(total_issued_certificate=Count('mode'))) + generated_certificates = list( + get_certificates_by_course_and_status( + course_id=course_key, + status=CertificateStatuses.downloadable + ).values( + *certificate_features).annotate(total_issued_certificate=Count('mode')) + ) # Report run date for data in generated_certificates: diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index a9642ea70f..9f97e5f901 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -20,6 +20,7 @@ from lms.djangoapps.instructor_analytics.basic import ( # lint-amnesty, pylint: PROGRAM_ENROLLMENT_FEATURES, STUDENT_FEATURES, StudentModule, + issued_certificates, enrolled_students_features, get_available_features, get_proctored_exam_results, @@ -29,6 +30,7 @@ from lms.djangoapps.instructor_analytics.basic import ( # lint-amnesty, pylint: list_problem_responses ) from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory +from lms.djangoapps.certificates.api import create_generated_certificate from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed @@ -132,6 +134,40 @@ class TestAnalyticsBasic(ModuleStoreTestCase): assert list(userreport.keys()) == ['username'] assert userreport['username'] in [user.username for user in self.users] + def test_issued_certificates_basic(self): + """ + Test that the `issued_certificates` function returns the correct aggregated data + for a single downloadable certificate. + + This test: + - Creates a downloadable certificate for a user. + - Verifies that the function returns a list with a single item. + - Confirms that the returned certificate contains the expected course ID. + - Ensures that the total count of issued certificates is correct. + - Verifies that the 'report_run_date' field is present in the result. + + The test ensures that the `issued_certificates` function behaves as expected + for a single downloadable certificate scenario. + """ + cert_args = { + "user": UserFactory(), + "course_id": self.course_key, + "mode": "honor", + "status": "downloadable", + "grade": "Pass" + } + create_generated_certificate(cert_args) + features = ['course_id', 'mode', 'status', 'grade'] + results = issued_certificates(self.course_key, features) + + assert isinstance(results, list) + assert len(results) == 1 + + cert = results[0] + assert cert['course_id'] == str(self.course_key) + assert cert['total_issued_certificate'] == 1 + assert 'report_run_date' in cert + def test_enrolled_students_features_keys(self): query_features = ('username', 'name', 'email', 'city', 'country',) for user in self.users: diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 6474efc1d3..ecf7deaee2 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -16,7 +16,7 @@ from celery.states import READY_STATES from common.djangoapps.util import milestones_helpers from lms.djangoapps.bulk_email.api import get_course_email -from lms.djangoapps.certificates.models import CertificateGenerationHistory +from lms.djangoapps.certificates.api import create_or_update_certificate_generation_history from lms.djangoapps.instructor_task.api_helper import ( QueueConnectionError, check_arguments_for_overriding, @@ -540,12 +540,14 @@ def generate_certificates_for_students(request, course_key, student_set=None, sp task_key = "" instructor_task = submit_task(request, task_type, task_class, course_key, task_input, task_key) - CertificateGenerationHistory.objects.create( - course_id=course_key, - generated_by=request.user, - instructor_task=instructor_task, - is_regeneration=False - ) + cert_filter_args = { + "course_id": course_key, + "generated_by": request.user, + "instructor_task": instructor_task, + "is_regeneration": False + } + + create_or_update_certificate_generation_history(**cert_filter_args) return instructor_task @@ -567,12 +569,14 @@ def regenerate_certificates(request, course_key, statuses_to_regenerate): instructor_task = submit_task(request, task_type, task_class, course_key, task_input, task_key) - CertificateGenerationHistory.objects.create( - course_id=course_key, - generated_by=request.user, - instructor_task=instructor_task, - is_regeneration=True - ) + cert_filter_args = { + "course_id": course_key, + "generated_by": request.user, + "instructor_task": instructor_task, + "is_regeneration": True + } + + create_or_update_certificate_generation_history(**cert_filter_args) return instructor_task diff --git a/lms/djangoapps/instructor_task/tasks_helper/grades.py b/lms/djangoapps/instructor_task/tasks_helper/grades.py index 5358af3708..27596a3738 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/grades.py +++ b/lms/djangoapps/instructor_task/tasks_helper/grades.py @@ -23,7 +23,7 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import BulkRoleCache from lms.djangoapps.certificates import api as certs_api -from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.api import get_certificates_for_course_and_users from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.grades.api import CourseGradeFactory @@ -242,7 +242,7 @@ class _CertificateBulkContext: self.certificates_by_user = { certificate.user.id: certificate for certificate in - GeneratedCertificate.objects.filter(course_id=context.course_id, user__in=users) + get_certificates_for_course_and_users(course_id=context.course_id, users=users) } @@ -280,6 +280,7 @@ class InMemoryReportMixin: """ Mixin for a file report that will generate file in memory and then upload to report store """ + def _generate(self): """ Internal method for generating a grade report for the given context. @@ -341,6 +342,7 @@ class TemporaryFileReportMixin: """ Mixin for a file report that will write rows iteratively to a TempFile """ + def _generate(self): """ Generate a CSV containing all students' problem grades within a given `course_id`. @@ -415,6 +417,7 @@ class GradeReportBase: """ Base class for grade reports (ProblemGradeReport and CourseGradeReport). """ + def __init__(self, context): self.context = context diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 4b84fbff7f..beeb9e1c42 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -19,7 +19,7 @@ from common.test.utils import normalize_repr from lms.djangoapps.bulk_email.api import create_course_email from lms.djangoapps.bulk_email.data import BulkEmailTargetChoices from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import CertificateGenerationHistory +from lms.djangoapps.certificates.api import get_certificate_generation_history from lms.djangoapps.instructor_task.api import ( SpecificStudentIdMissingError, generate_anonymous_ids, @@ -417,12 +417,13 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa self.create_task_request(self.instructor), self.course.id ) - certificate_generation_history = CertificateGenerationHistory.objects.filter( - course_id=self.course.id, - generated_by=self.instructor, - instructor_task=instructor_task, - is_regeneration=False - ) + cert_args = { + "course_id": self.course.id, + "generated_by": self.instructor, + "instructor_task": instructor_task, + "is_regeneration": False + } + certificate_generation_history = get_certificate_generation_history(**cert_args) # Validate that record was added to CertificateGenerationHistory assert certificate_generation_history.exists() @@ -432,12 +433,13 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa self.course.id, [CertificateStatuses.downloadable, CertificateStatuses.generating] ) - certificate_generation_history = CertificateGenerationHistory.objects.filter( - course_id=self.course.id, - generated_by=self.instructor, - instructor_task=instructor_task, - is_regeneration=True - ) + cert_args = { + "course_id": self.course.id, + "generated_by": self.instructor, + "instructor_task": instructor_task, + "is_regeneration": True + } + certificate_generation_history = get_certificate_generation_history(**cert_args) # Validate that record was added to CertificateGenerationHistory assert certificate_generation_history.exists() diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 4144dd9568..e876fc4e9c 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -31,7 +31,6 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.grades.course_data import CourseData @@ -116,6 +115,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase): """ Tests that CSV grade report generation works. """ + def setUp(self): super().setUp() self.course = CourseFactory.create() @@ -810,7 +810,7 @@ class TestProblemResponsesReport(TestReportMixin, InstructorTaskModuleTestCase): 'user_id': self.instructor.id } with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'), \ - freeze_time('2020-01-01'): + freeze_time('2020-01-01'): with patch('lms.djangoapps.instructor_task.tasks_helper.grades' '.ProblemResponses._build_student_data') as mock_build_student_data: mock_build_student_data.return_value = ( @@ -832,6 +832,7 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase): """ Test that the problem CSV generation works. """ + def setUp(self): super().setUp() self.initialize_course() @@ -1138,6 +1139,7 @@ class TestProblemReportCohortedContent(TestReportMixin, ContentGroupTestCase, In """ Test the problem report on a course that has cohorted content. """ + def setUp(self): super().setUp() # construct cohorted problems to work on. @@ -1238,6 +1240,7 @@ class TestCourseSurveyReport(TestReportMixin, InstructorTaskCourseTestCase): """ Tests that Course Survey report generation works. """ + def setUp(self): super().setUp() self.course = CourseFactory.create() @@ -1333,6 +1336,7 @@ class TestStudentReport(TestReportMixin, InstructorTaskCourseTestCase): """ Tests that CSV student profile report generation works. """ + def setUp(self): super().setUp() self.course = CourseFactory.create() @@ -1475,6 +1479,7 @@ class TestListMayEnroll(TestReportMixin, InstructorTaskCourseTestCase): students who may enroll in a given course (but have not signed up for it yet) works. """ + def _create_enrollment(self, email): """ Factory method for creating CourseEnrollmentAllowed objects. @@ -1521,6 +1526,7 @@ class TestListMayEnroll(TestReportMixin, InstructorTaskCourseTestCase): class MockDefaultStorage: """Mock django's DefaultStorage""" + def __init__(self): pass @@ -1534,6 +1540,7 @@ class TestCohortStudents(TestReportMixin, InstructorTaskCourseTestCase): """ Tests that bulk student cohorting works. """ + def setUp(self): super().setUp() @@ -1797,6 +1804,7 @@ class TestGradeReport(TestReportMixin, InstructorTaskModuleTestCase): """ Test that grade report has correct grade values. """ + def setUp(self): super().setUp() self.create_course() @@ -1981,8 +1989,8 @@ class TestGradeReport(TestReportMixin, InstructorTaskModuleTestCase): if create_non_zero_grade: self.submit_student_answer(self.student.username, 'Problem1', ['Option 1']) with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'), \ - patch('lms.djangoapps.grades.course_data.get_course_blocks') as mock_course_blocks, \ - patch('lms.djangoapps.grades.subsection_grade.get_score') as mock_get_score: + patch('lms.djangoapps.grades.course_data.get_course_blocks') as mock_course_blocks, \ + patch('lms.djangoapps.grades.subsection_grade.get_score') as mock_get_score: CourseGradeReport.generate(None, None, self.course.id, {}, 'graded') assert not mock_course_blocks.called assert not mock_get_score.called @@ -1994,6 +2002,7 @@ class TestGradeReportEnrollmentAndCertificateInfo(TestReportMixin, InstructorTas """ Test that grade report has correct user enrollment, verification, and certificate information. """ + def setUp(self): super().setUp() @@ -2147,7 +2156,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Allowlist 5 students @@ -2307,7 +2316,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Grant error certs to 3 students @@ -2316,7 +2325,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.error, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Grant a deleted cert to the 6th student @@ -2325,7 +2334,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.deleted, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Allowlist 7 students @@ -2367,7 +2376,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2377,7 +2386,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.error, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2387,7 +2396,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.deleted, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2397,7 +2406,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.generating, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2438,7 +2447,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2448,7 +2457,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.error, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2458,7 +2467,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.unavailable, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2468,7 +2477,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.generating, - mode=GeneratedCertificate.CourseMode.VERIFIED, + mode=CourseMode.VERIFIED, grade=default_grade ) @@ -2513,7 +2522,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Grant error certs to 3 students @@ -2522,7 +2531,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.error, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Grant a deleted cert to the 6th student @@ -2531,7 +2540,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.deleted, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Grant a notpassing cert to the 7th student @@ -2540,7 +2549,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): user=student, course_id=self.course.id, status=CertificateStatuses.notpassing, - mode=GeneratedCertificate.CourseMode.VERIFIED + mode=CourseMode.VERIFIED ) # Allowlist 7 students diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index a5940d8a13..3ad5430c77 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -28,8 +28,7 @@ from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW from common.djangoapps.course_modes.models import CourseMode, get_course_prices from common.djangoapps.util.views import expose_header from lms.djangoapps.edxnotes.helpers import is_feature_enabled -from lms.djangoapps.certificates.api import get_certificate_url -from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.api import get_certificate_url, get_eligible_certificate from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.course_goals.models import UserActivity @@ -279,12 +278,12 @@ class CoursewareMeta: linkedin_config = LinkedInAddToProfileConfiguration.current() if linkedin_config.is_enabled(): - try: - user_certificate = GeneratedCertificate.eligible_certificates.get( - user=self.effective_user, course_id=self.course_key - ) - except GeneratedCertificate.DoesNotExist: + + user_certificate = get_eligible_certificate(user=self.effective_user, course_id=self.course_key) + + if user_certificate is None: return + cert_url = self.request.build_absolute_uri( get_certificate_url(course_id=self.course_key, uuid=user_certificate.verify_uuid) ) diff --git a/openedx/core/djangoapps/credentials/tasks/v1/tasks.py b/openedx/core/djangoapps/credentials/tasks/v1/tasks.py index 8c010f1c5b..603ab88e92 100644 --- a/openedx/core/djangoapps/credentials/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/credentials/tasks/v1/tasks.py @@ -16,9 +16,11 @@ from MySQLdb import OperationalError from opaque_keys.edx.keys import CourseKey from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.certificates.api import get_recently_modified_certificates +from lms.djangoapps.certificates.api import ( + get_generated_certificate, + get_recently_modified_certificates +) from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.grades.api import CourseGradeFactory, get_recently_modified_grades from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -352,16 +354,15 @@ def send_grade_if_interesting( # If we don't have mode and/or status, retrieve them from the learner's certificate record if mode is None or status is None: - try: - cert = GeneratedCertificate.objects.get(user=user, course_id=course_run_key) # pylint: disable=no-member - mode = cert.mode - status = cert.status - except GeneratedCertificate.DoesNotExist: - # we only care about grades for which there is a certificate record + cert = get_generated_certificate(user=user, course_id=course_run_key) + if cert is None: if verbose: logger.warning(f"{warning_base} no certificate record in the specified course run") return + mode = cert.mode + status = cert.status + # Don't worry about the certificate record being in a passing or awarded status. Having a certificate record in any # status is good enough to record a verified attempt at a course. The Credentials IDA keeps track of how many times # a learner has made an attempt at a course run of a course, so it wants to know about all the learner's efforts. diff --git a/openedx/core/djangoapps/credentials/tests/test_tasks.py b/openedx/core/djangoapps/credentials/tests/test_tasks.py index 1b09e38ce1..68aea792be 100644 --- a/openedx/core/djangoapps/credentials/tests/test_tasks.py +++ b/openedx/core/djangoapps/credentials/tests/test_tasks.py @@ -19,7 +19,6 @@ from testfixtures import LogCapture from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.certificates.api import get_recently_modified_certificates from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.grades.models import PersistentCourseGrade from lms.djangoapps.grades.models_api import get_recently_modified_grades from lms.djangoapps.grades.tests.utils import mock_passing_grade @@ -189,7 +188,7 @@ class TestHandleNotifyCredentialsTask(TestCase): grade2 = PersistentCourseGrade.objects.create(user_id=self.user.id, course_id='course-v1:edX+Test+22', percent_grade=1) - total_certificates = GeneratedCertificate.objects.filter(**cert_filter_args).order_by('modified_date') # pylint: disable=no-member + total_certificates = get_recently_modified_certificates(**cert_filter_args) total_grades = PersistentCourseGrade.objects.all() self.options['auto'] = True diff --git a/openedx/core/djangoapps/programs/tasks.py b/openedx/core/djangoapps/programs/tasks.py index d3b4b867e4..2829cf2075 100644 --- a/openedx/core/djangoapps/programs/tasks.py +++ b/openedx/core/djangoapps/programs/tasks.py @@ -19,7 +19,8 @@ from opaque_keys.edx.keys import CourseKey from requests.exceptions import HTTPError from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.data import GeneratedCertificateData +from lms.djangoapps.certificates.api import get_eligible_certificate from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from openedx.core.djangoapps.credentials.api import is_credentials_enabled from openedx.core.djangoapps.credentials.utils import ( @@ -195,7 +196,7 @@ def revoke_program_certificate(client, username, program_uuid): def post_course_certificate( client: "Session", username: str, - certificate: GeneratedCertificate, + certificate: GeneratedCertificateData, date_override: Optional["datetime"] = None, org: Optional[str] = None, ): @@ -517,12 +518,9 @@ def award_course_certificate(self, username, course_run_key): return # Get the cert for the course key and username if it's both passing and available in professional/verified - try: - certificate = GeneratedCertificate.eligible_certificates.get( - user=user.id, - course_id=course_key, - ) - except GeneratedCertificate.DoesNotExist: + certificate = get_eligible_certificate(user=user, course_id=course_key) + + if certificate is None: LOGGER.warning( f"Task award_course_certificate was called for user {user.id} in course run {course_key} but this learner " "has not earned a course certificate in this course run" diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 49fb054b9b..b385d8284c 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -25,7 +25,6 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.date_utils import strftime_localized from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.commerce.utils import EcommerceService, get_program_price_info from openedx.core.djangoapps.catalog.api import get_programs_by_type from openedx.core.djangoapps.catalog.constants import PathwayType @@ -341,7 +340,7 @@ class ProgramProgressMeter: Returns a dict of {uuid_string: available_datetime} """ # Query for all user certs up front, for performance reasons (rather than querying per course run). - user_certificates = GeneratedCertificate.eligible_available_certificates.filter(user=self.user) + user_certificates = certificate_api.get_eligible_and_available_certificates(user=self.user) certificates_by_run = {cert.course_id: cert for cert in user_certificates} completed = {}