diff --git a/cms/djangoapps/contentstore/docs/diagrams/visible_date_and_certificate_available_date.dsl b/cms/djangoapps/contentstore/docs/diagrams/certificate_available_date.dsl similarity index 86% rename from cms/djangoapps/contentstore/docs/diagrams/visible_date_and_certificate_available_date.dsl rename to cms/djangoapps/contentstore/docs/diagrams/certificate_available_date.dsl index e55b7ad8dd..bc082e349f 100644 --- a/cms/djangoapps/contentstore/docs/diagrams/visible_date_and_certificate_available_date.dsl +++ b/cms/djangoapps/contentstore/docs/diagrams/certificate_available_date.dsl @@ -1,6 +1,6 @@ /* - * This is a high level diagram visualizing how the `CERTIFICATE_AVAILBLE_DATE` and "visible date" attribute updates - * are updated internally and transmit to the Credentials IDA. + * This is a high level diagram visualizing how the `CERTIFICATE_AVAILBLE_DATE` update is + * updated internally and transmitted to the Credentials IDA. * * It is written using Structurizr DSL (https://structurizr.org/). */ @@ -33,9 +33,7 @@ workspace { co_app -> modulestore "Retrieves course details from Mongo" co_app -> monolith_db "Updates CourseOverview record" co_app -> programs_app "Emits COURSE_CERT_DATE_CHANGED signal" - programs_app -> celery "Enqueue UPDATE_CERTIFICATE_VISIBLE_DATE task" programs_app -> celery "Enqueue UPDATE_CERTIFICATE_AVAILABLE_DATE task" - celery -> credentials "REST requests to update `visible_date` attributes" celery -> credentials "REST request to update `certificate_available_date` setting" } diff --git a/cms/djangoapps/contentstore/docs/diagrams/rendered/certificate_available_date.png b/cms/djangoapps/contentstore/docs/diagrams/rendered/certificate_available_date.png new file mode 100644 index 0000000000..988d9e5dc3 Binary files /dev/null and b/cms/djangoapps/contentstore/docs/diagrams/rendered/certificate_available_date.png differ diff --git a/cms/djangoapps/contentstore/docs/diagrams/rendered/visible_date_and_certificate_available_date.png b/cms/djangoapps/contentstore/docs/diagrams/rendered/visible_date_and_certificate_available_date.png deleted file mode 100644 index d56eda232e..0000000000 Binary files a/cms/djangoapps/contentstore/docs/diagrams/rendered/visible_date_and_certificate_available_date.png and /dev/null differ diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index b7fe8aaca6..4439eeb5f2 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -849,7 +849,7 @@ def _course_uses_available_date(course): ) -def available_date_for_certificate(course, certificate): +def available_date_for_certificate(course, certificate) -> datetime: """ Returns the available date to use with a certificate diff --git a/lms/envs/production.py b/lms/envs/production.py index 9ce9fd6c9b..a1acd692f4 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -1063,8 +1063,6 @@ EXPLICIT_QUEUES = { 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, 'openedx.core.djangoapps.programs.tasks.revoke_program_certificates': { 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, - 'openedx.core.djangoapps.programs.tasks.update_certificate_visible_date_on_course_update': { - 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, 'openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update': { 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, 'openedx.core.djangoapps.programs.tasks.award_course_certificate': { diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index cc5c202fd4..3a241a5c51 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -4,6 +4,7 @@ import copy import datetime import logging import uuid +from typing import TYPE_CHECKING, Any, List, Union import pycountry import requests @@ -31,6 +32,9 @@ from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.lib.edx_api_utils import get_api_data +if TYPE_CHECKING: + from django.contrib.sites.models import Site + logger = logging.getLogger(__name__) missing_details_msg_tpl = "Failed to get details for program {uuid} from the cache." @@ -98,7 +102,14 @@ def check_catalog_integration_and_get_user(error_message_field): # pylint: disable=redefined-outer-name -def get_programs(site=None, uuid=None, uuids=None, course=None, catalog_course_uuid=None, organization=None): +def get_programs( + site: "Site" = None, + uuid: str = None, + uuids: List[str] = None, + course: str = None, + catalog_course_uuid: str = None, + organization: str = None, +) -> Union[str, List[str]]: """Read programs from the cache. The cache is populated by a management command, cache_programs. @@ -112,7 +123,7 @@ def get_programs(site=None, uuid=None, uuids=None, course=None, catalog_course_u organization (string): short name for specific organization to read from the cache. Returns: - list of dict, representing programs. + list of str, representing programs. dict, if a specific program is requested. """ if len([arg for arg in (site, uuid, uuids, course, catalog_course_uuid, organization) if arg is not None]) != 1: @@ -194,7 +205,7 @@ def get_programs_by_type_slug(site, program_type_slug): return get_programs_by_uuids(uuids) -def get_programs_by_uuids(uuids): +def get_programs_by_uuids(uuids: List[Any]) -> List[str]: """ Gets a list of programs for the provided uuids """ diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index 33f4fc33e9..1b7b1b7486 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -37,7 +37,7 @@ def get_credentials_records_url(program_uuid=None): return base_url -def get_credentials_api_client(user): +def get_credentials_api_client(user) -> requests.Session: """ Returns an authenticated Credentials API client. diff --git a/openedx/core/djangoapps/programs/docs/decisions/0001-sync-certificate-available-dates.rst b/openedx/core/djangoapps/programs/docs/decisions/0001-sync-certificate-available-dates.rst index dfe9fcb492..c9bd660b31 100644 --- a/openedx/core/djangoapps/programs/docs/decisions/0001-sync-certificate-available-dates.rst +++ b/openedx/core/djangoapps/programs/docs/decisions/0001-sync-certificate-available-dates.rst @@ -4,7 +4,9 @@ Sync certificate_available_date and visible_date for certificates Status ------ -Review +Superseded by Credentials ADR `0001 Certificate Available Date`_. + +.. _0001 Certificate Available Date: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0005-restricted-application-for-SSO.rst Context ------- diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py index 097aac2683..0a85cbf84e 100644 --- a/openedx/core/djangoapps/programs/signals.py +++ b/openedx/core/djangoapps/programs/signals.py @@ -2,7 +2,6 @@ This module contains signals / handlers related to programs. """ - import logging from django.dispatch import receiver @@ -14,7 +13,7 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_DATE_CHANGE, - COURSE_CERT_REVOKED + COURSE_CERT_REVOKED, ) LOGGER = logging.getLogger(__name__) @@ -39,11 +38,10 @@ def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs) if not is_credentials_enabled(): return - LOGGER.debug( - f"Handling COURSE_CERT_AWARDED: user={user}, course_key={course_key}, mode={mode}, status={status}" - ) + LOGGER.debug(f"Handling COURSE_CERT_AWARDED: user={user}, course_key={course_key}, mode={mode}, status={status}") # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded from openedx.core.djangoapps.programs.tasks import award_program_certificates + award_program_certificates.delay(user.username) @@ -68,7 +66,7 @@ def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs) Returns: None """ - verbose = kwargs.get('verbose', False) + verbose = kwargs.get("verbose", False) if verbose: LOGGER.info( f"Starting handle_course_cert_changed with params: sender [{sender}], user [{user}], course_key " @@ -87,6 +85,7 @@ def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs) LOGGER.debug(f"Handling COURSE_CERT_CHANGED: user={user}, course_key={course_key}, mode={mode}, status={status}") # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded from openedx.core.djangoapps.programs.tasks import award_course_certificate + award_course_certificate.delay(user.username, str(course_key)) @@ -112,16 +111,16 @@ def handle_course_cert_revoked(sender, user, course_key, mode, status, **kwargs) LOGGER.info(f"Handling COURSE_CERT_REVOKED: user={user}, course_key={course_key}, mode={mode}, status={status}") # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded from openedx.core.djangoapps.programs.tasks import revoke_program_certificates + revoke_program_certificates.delay(user.username, str(course_key)) -@receiver(COURSE_CERT_DATE_CHANGE, dispatch_uid='course_certificate_date_change_handler') +@receiver(COURSE_CERT_DATE_CHANGE, dispatch_uid="course_certificate_date_change_handler") def handle_course_cert_date_change(sender, course_key, **kwargs): # pylint: disable=unused-argument """ When a course run's configuration has been updated, and the system has detected an update related to the display behavior or availability date of the certificates issued in that course, we should enqueue celery tasks responsible for: - - updating the `visible_date` attribute of any previously awarded certificates the Credentials IDA manages - updating the certificate available date of the course run's course certificate configuration in Credentials Args: @@ -137,6 +136,7 @@ def handle_course_cert_date_change(sender, course_key, **kwargs): # pylint: dis LOGGER.info(f"Handling COURSE_CERT_DATE_CHANGE for course {course_key}") # import here, because signal is registered at startup, but items in tasks are not yet loaded from openedx.core.djangoapps.programs.tasks import update_certificate_available_date_on_course_update + update_certificate_available_date_on_course_update.delay(str(course_key)) @@ -161,4 +161,5 @@ def handle_course_pacing_change(sender, updated_course_overview, **kwargs): # p LOGGER.info(f"Handling COURSE_PACING_CHANGED for course {course_id}") # import here, because signal is registered at startup, but items in tasks are not yet loaded from openedx.core.djangoapps.programs.tasks import update_certificate_available_date_on_course_update + update_certificate_available_date_on_course_update.delay(course_id) diff --git a/openedx/core/djangoapps/programs/tasks.py b/openedx/core/djangoapps/programs/tasks.py index 3ea5638578..9338045cf8 100644 --- a/openedx/core/djangoapps/programs/tasks.py +++ b/openedx/core/djangoapps/programs/tasks.py @@ -3,7 +3,7 @@ This file contains celery tasks and utility functions responsible for syncing co between the monolith and the Credentials IDA. """ -from typing import Dict, List +from typing import TYPE_CHECKING, Dict, List, Optional from urllib.parse import urljoin from celery import shared_task @@ -19,7 +19,6 @@ from opaque_keys.edx.keys import CourseKey from requests.exceptions import HTTPError from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.certificates.api import available_date_for_certificate from lms.djangoapps.certificates.models import GeneratedCertificate from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from openedx.core.djangoapps.credentials.api import is_credentials_enabled @@ -32,6 +31,12 @@ from openedx.core.djangoapps.programs.utils import ProgramProgressMeter from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from xmodule.data import CertificatesDisplayBehaviors +if TYPE_CHECKING: + from datetime import datetime + + from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user + from requests import Session + User = get_user_model() LOGGER = get_task_logger(__name__) @@ -44,7 +49,7 @@ COURSE_CERTIFICATE = "course-run" DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" -def get_completed_programs(site, student): +def get_completed_programs(site: Site, student: "UserType") -> Dict: """ Given a set of completed courses, determine which programs are completed. @@ -53,14 +58,14 @@ def get_completed_programs(site, student): student (User): Representing the student whose completed programs to check for. Returns: - dict of {program_UUIDs: visible_dates} + Dict of program_UUIDs:availability dates """ meter = ProgramProgressMeter(site, student) return meter.completed_programs_with_available_dates -def get_inverted_programs(student): +def get_inverted_programs(student: "UserType"): """ Get programs keyed by course run ID. @@ -79,7 +84,7 @@ def get_inverted_programs(student): return inverted_programs -def get_certified_programs(student: User, raise_on_error: bool = False) -> List[str]: +def get_certified_programs(student: "UserType", raise_on_error: bool = False) -> List[str]: """ Find the UUIDs of all the programs for which the student has already been awarded a certificate. @@ -104,7 +109,7 @@ def get_certified_programs(student: User, raise_on_error: bool = False) -> List[ return certified_programs -def get_revokable_program_uuids(course_specific_programs: List[Dict], student: User) -> List[str]: +def get_revokable_program_uuids(course_specific_programs: List[Dict], student: "UserType") -> List[str]: """ Get program uuids for which certificate to be revoked. @@ -133,7 +138,7 @@ def get_revokable_program_uuids(course_specific_programs: List[Dict], student: U return program_uuids_to_revoke -def award_program_certificate(client, user, program_uuid, visible_date): +def award_program_certificate(client: "Session", user: "UserType", program_uuid: "str") -> None: """ Issue a new certificate of completion to the given student for the given program. @@ -144,8 +149,6 @@ def award_program_certificate(client, user, program_uuid, visible_date): The student's user data program_uuid: uuid of the completed program - visible_date: - when the program credential should be visible to user Returns: None @@ -158,7 +161,6 @@ def award_program_certificate(client, user, program_uuid, visible_date): "username": user.username, "lms_user_id": user.id, "credential": {"type": PROGRAM_CERTIFICATE, "program_uuid": program_uuid}, - "attributes": [{"name": "visible_date", "value": visible_date.strftime(DATE_FORMAT)}], }, ) response.raise_for_status() @@ -190,7 +192,13 @@ def revoke_program_certificate(client, username, program_uuid): response.raise_for_status() -def post_course_certificate(client, username, certificate, visible_date, date_override=None, org=None): +def post_course_certificate( + client: "Session", + username: str, + certificate: GeneratedCertificate, + date_override: Optional["datetime"] = None, + org: Optional[str] = None, +): """ POST a certificate that has been updated to Credentials """ @@ -208,7 +216,6 @@ def post_course_certificate(client, username, certificate, visible_date, date_ov "type": COURSE_CERTIFICATE, }, "date_override": {"date": date_override.strftime(DATE_FORMAT)} if date_override else None, - "attributes": [{"name": "visible_date", "value": visible_date.strftime(DATE_FORMAT)}], }, ) response.raise_for_status() @@ -350,13 +357,8 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable failed_program_certificate_award_attempts = [] for program_uuid in new_program_uuids: - visible_date = completed_programs[program_uuid] try: - LOGGER.info( - f"Visible date for program certificate awarded to user {student} in program {program_uuid} is " - f"{visible_date}" - ) - award_program_certificate(credentials_client, student, program_uuid, visible_date) + award_program_certificate(credentials_client, student, program_uuid) LOGGER.info(f"Awarded program certificate to user {student} in program {program_uuid}") except HTTPError as exc: if exc.response.status_code == 404: @@ -421,9 +423,7 @@ def update_credentials_course_certificate_configuration_available_date( ): """ This task will update the CourseCertificate configuration's available date - in Credentials. This is different from the "visible_date" attribute. This - date will always either be the available date that is set in Studio for a - given course, or it will be None. + in Credentials. Arguments: course_run_key (str): The course run key to award the certificate for @@ -551,21 +551,15 @@ def award_course_certificate(self, username, course_run_key): ) return - visible_date = available_date_for_certificate(course_overview, certificate) - LOGGER.info( - f"Task award_course_certificate will award a course certificate to user {user.id} in course run " - f"{course_key} with a visible date of {visible_date}" - ) - # If the certificate has an associated CertificateDateOverride, send it along try: - date_override = certificate.date_override.date + date_override = certificate.date_override.date # type: Optional["datetime"] LOGGER.info( f"Task award_course_certificate will award a course certificate to user {user.id} in course run " f"{course_key} with an override date of {date_override}" ) except ObjectDoesNotExist: - date_override = None + date_override = None # type: Optional["datetime"] try: credentials_client = get_credentials_api_client( @@ -575,7 +569,6 @@ def award_course_certificate(self, username, course_run_key): credentials_client, username, certificate, - visible_date, date_override, org=course_key.org, ) @@ -722,57 +715,6 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py LOGGER.info(f"Successfully completed the task revoke_program_certificates for user {student}") -@shared_task( - bind=True, - ignore_result=True, - autoretry_for=(Exception,), - max_retries=10, - retry_backoff=30, - retry_backoff_max=600, - retry_jitter=True, -) -@set_code_owner_attribute -def update_certificate_visible_date_on_course_update(self, course_key): - """ - This task is designed to be called whenever a course-run's `certificate_available_date` is updated. - - When executed, this task will first get a list of all learners within the course-run that have earned a certificate. - Next, we will enqueue an additional `award_course_certificate` task for each learner in this list. These subtasks - will be responsible for updating the `visible_date` attribute on each certificate the Credentials IDA knows about. - - If this function is moved, make sure to update its entry in EXPLICIT_QUEUES in the settings files so it runs in the - correct queue. - - Arguments: - course_key(str): The course identifier - """ - # If the CredentialsApiConfig configuration model is disabled for this feature, it may indicate a condition where - # processing of such tasks has been temporarily disabled. This is a recoverable situation, so let celery retry. - if not is_credentials_enabled(): - error_msg = ( - "Cannot execute the `update_certificate_visible_date_on_course_update` task. Issuing user credentials " - "through the Credentials IDA is disabled." - ) - LOGGER.warning(error_msg) - raise MaxRetriesExceededError( - f"Failed to update the `visible_date` attribute for certificates in course {course_key}. Reason: " - f"{error_msg}" - ) - - # Retrieve a list of all usernames of learners who have a certificate record in this course-run. The - # Credentials IDA REST API still requires a username as the main identifier for the learner. - users_with_certificates_in_course = GeneratedCertificate.eligible_available_certificates.filter( - course_id=course_key - ).values_list("user__username", flat=True) - - LOGGER.info( - f"Resending course certificates for learners in course {course_key} to the Credentials service. Queueing " - f"{len(users_with_certificates_in_course)} `award_course_certificate` tasks." - ) - for user in users_with_certificates_in_course: - award_course_certificate.delay(user, str(course_key)) - - @shared_task( bind=True, ignore_result=True, @@ -799,7 +741,7 @@ def update_certificate_available_date_on_course_update(self, course_key): # retry instead of failing it. if not is_credentials_enabled(): error_msg = ( - "Cannot execute the `update_certificate_visible_date_on_course_update` task. Issuing user credentials " + "Cannot execute the `update_certificate_available_date_on_course_update` task. Issuing user credentials " "through the Credentials IDA is disabled." ) LOGGER.warning(error_msg) diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py index 5d104af2af..fae9b1dff2 100644 --- a/openedx/core/djangoapps/programs/tests/test_signals.py +++ b/openedx/core/djangoapps/programs/tests/test_signals.py @@ -1,17 +1,15 @@ """ This module contains tests for programs-related signals and signal handlers. """ -import datetime +import datetime from unittest import mock from django.test import TestCase from opaque_keys.edx.keys import CourseKey from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.content.course_overviews.tests.factories import ( - CourseOverviewFactory, -) +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.programs.signals import ( handle_course_cert_awarded, handle_course_cert_changed, @@ -28,15 +26,15 @@ from openedx.core.djangoapps.signals.signals import ( from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from openedx.core.djangolib.testing.utils import skip_unless_lms -TEST_USERNAME = 'test-user' -TEST_COURSE_KEY = CourseKey.from_string('course-v1:edX+test_course+1') +TEST_USERNAME = "test-user" +TEST_COURSE_KEY = CourseKey.from_string("course-v1:edX+test_course+1") # The credentials app isn't installed for the CMS. @skip_unless_lms -@mock.patch('openedx.core.djangoapps.programs.tasks.award_program_certificates.delay') +@mock.patch("openedx.core.djangoapps.programs.tasks.award_program_certificates.delay") @mock.patch( - 'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled', + "openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled", new_callable=mock.PropertyMock, return_value=False, ) @@ -54,8 +52,8 @@ class CertAwardedReceiverTest(TestCase): sender=self.__class__, user=UserFactory.create(username=TEST_USERNAME), course_key=TEST_COURSE_KEY, - mode='test-mode', - status='test-status', + mode="test-mode", + status="test-status", ) def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument @@ -95,9 +93,9 @@ class CertAwardedReceiverTest(TestCase): # The credentials app isn't installed for the CMS. @skip_unless_lms -@mock.patch('openedx.core.djangoapps.programs.tasks.award_course_certificate.delay') +@mock.patch("openedx.core.djangoapps.programs.tasks.award_course_certificate.delay") @mock.patch( - 'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled', + "openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled", new_callable=mock.PropertyMock, return_value=False, ) @@ -119,8 +117,8 @@ class CertChangedReceiverTest(TestCase): sender=self.__class__, user=self.user, course_key=TEST_COURSE_KEY, - mode='test-mode', - status='test-status', + mode="test-mode", + status="test-status", ) def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument @@ -160,9 +158,7 @@ class CertChangedReceiverTest(TestCase): def test_records_enabled(self, mock_is_learner_issuance_enabled, mock_task): mock_is_learner_issuance_enabled.return_value = True - site_config = SiteConfigurationFactory.create( - site_values={'course_org_filter': ['edX']} - ) + site_config = SiteConfigurationFactory.create(site_values={"course_org_filter": ["edX"]}) # Correctly sent handle_course_cert_changed(**self.signal_kwargs) @@ -170,7 +166,7 @@ class CertChangedReceiverTest(TestCase): mock_task.reset_mock() # Correctly not sent - site_config.site_values['ENABLE_LEARNER_RECORDS'] = False + site_config.site_values["ENABLE_LEARNER_RECORDS"] = False site_config.save() handle_course_cert_changed(**self.signal_kwargs) assert not mock_task.called @@ -178,9 +174,9 @@ class CertChangedReceiverTest(TestCase): # The credentials app isn't installed for the CMS. @skip_unless_lms -@mock.patch('openedx.core.djangoapps.programs.tasks.revoke_program_certificates.delay') +@mock.patch("openedx.core.djangoapps.programs.tasks.revoke_program_certificates.delay") @mock.patch( - 'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled', + "openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled", new_callable=mock.PropertyMock, return_value=False, ) @@ -198,8 +194,8 @@ class CertRevokedReceiverTest(TestCase): sender=self.__class__, user=UserFactory.create(username=TEST_USERNAME), course_key=TEST_COURSE_KEY, - mode='test-mode', - status='test-status', + mode="test-mode", + status="test-status", ) def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument @@ -238,10 +234,9 @@ class CertRevokedReceiverTest(TestCase): @skip_unless_lms -@mock.patch('openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update.delay') -@mock.patch('openedx.core.djangoapps.programs.tasks.update_certificate_visible_date_on_course_update.delay') +@mock.patch("openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update.delay") @mock.patch( - 'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled', + "openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled", new_callable=mock.PropertyMock, return_value=False, ) @@ -255,37 +250,26 @@ class CourseCertAvailableDateChangedReceiverTest(TestCase): """ DRY helper. """ - return { - 'sender': self.__class__, - 'course_key': TEST_COURSE_KEY, - 'available_date': datetime.datetime.now() - } + return {"sender": self.__class__, "course_key": TEST_COURSE_KEY, "available_date": datetime.datetime.now()} - def test_signal_received( - self, - mock_is_learner_issuance_enabled, - mock_visible_date_task, - mock_cad_task - ): + def test_signal_received(self, mock_is_learner_issuance_enabled, mock_cad_task): """ Ensures the receiver function is invoked when COURSE_CERT_DATE_CHANGE is sent. """ COURSE_CERT_DATE_CHANGE.send(**self.signal_kwargs) assert mock_is_learner_issuance_enabled.call_count == 1 - assert mock_visible_date_task.call_count == 0 assert mock_cad_task.call_count == 0 - def test_programs_disabled(self, mock_is_learner_issuance_enabled, mock_visible_date_task, mock_cad_task): + def test_programs_disabled(self, mock_is_learner_issuance_enabled, mock_cad_task): """ Ensures that the receiver function does not queue any celery tasks if the system is not configured to use the Credentials service. """ handle_course_cert_date_change(**self.signal_kwargs) assert mock_is_learner_issuance_enabled.call_count == 1 - assert mock_visible_date_task.call_count == 0 assert mock_cad_task.call_count == 0 - def test_programs_enabled(self, mock_is_learner_issuance_enabled, mock_visible_date_task, mock_cad_task): + def test_programs_enabled(self, mock_is_learner_issuance_enabled, mock_cad_task): """ Ensures that the receiver function enqueues the expected celery tasks when the system is configured to use the Credentials IDA. @@ -294,14 +278,12 @@ class CourseCertAvailableDateChangedReceiverTest(TestCase): handle_course_cert_date_change(**self.signal_kwargs) assert mock_is_learner_issuance_enabled.call_count == 1 - assert mock_visible_date_task.call_count == 0 assert mock_cad_task.call_count == 1 @skip_unless_lms -@mock.patch('openedx.core.djangoapps.programs.tasks.update_certificate_visible_date_on_course_update.delay') -@mock.patch('openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update.delay') -@mock.patch('openedx.core.djangoapps.programs.signals.is_credentials_enabled') +@mock.patch("openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update.delay") +@mock.patch("openedx.core.djangoapps.programs.signals.is_credentials_enabled") class CoursePacingChangedReceiverTest(TestCase): """ Tests for the `handle_course_pacing_change` signal handler function. @@ -319,15 +301,14 @@ class CoursePacingChangedReceiverTest(TestCase): DRY helper. """ return { - 'sender': self.__class__, - 'updated_course_overview': self.course_overview, + "sender": self.__class__, + "updated_course_overview": self.course_overview, } def test_handle_course_pacing_change_credentials_disabled( self, mock_is_creds_enabled, mock_cad_task, - mock_visible_date_task, ): """ Test the verifies the behavior of the `handle_course_pacing_change` signal receiver when use of the Credentials @@ -337,14 +318,12 @@ class CoursePacingChangedReceiverTest(TestCase): handle_course_pacing_change(**self.signal_kwargs) assert mock_is_creds_enabled.call_count == 1 - assert mock_visible_date_task.call_count == 0 assert mock_cad_task.call_count == 0 def test_handle_course_pacing_change_credentials_enabled( self, mock_is_creds_enabled, mock_cad_task, - mock_visible_date_task ): """ Test that verifies the behavior of the `handle_course_pacing_change` signal receiver when use of the Credentials @@ -355,5 +334,4 @@ class CoursePacingChangedReceiverTest(TestCase): handle_course_pacing_change(**self.signal_kwargs) assert mock_is_creds_enabled.call_count == 1 - assert mock_visible_date_task.call_count == 0 assert mock_cad_task.call_count == 1 diff --git a/openedx/core/djangoapps/programs/tests/test_tasks.py b/openedx/core/djangoapps/programs/tests/test_tasks.py index 0c37323499..d30cc4e868 100644 --- a/openedx/core/djangoapps/programs/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tests/test_tasks.py @@ -100,7 +100,7 @@ class AwardProgramCertificateTestCase(TestCase): "http://test-server/credentials/", ) - tasks.award_program_certificate(test_client, student, 123, datetime(2010, 5, 30)) + tasks.award_program_certificate(test_client, student, 123) expected_body = { "username": student.username, @@ -109,17 +109,41 @@ class AwardProgramCertificateTestCase(TestCase): "program_uuid": 123, "type": tasks.PROGRAM_CERTIFICATE, }, - "attributes": [ - { - "name": "visible_date", - "value": "2010-05-30T00:00:00Z", - } - ], } last_request_body = httpretty.last_request().body.decode("utf-8") assert json.loads(last_request_body) == expected_body +@skip_unless_lms +@ddt.ddt +@override_settings(CREDENTIALS_SERVICE_USERNAME="test-service-username") +class AwardProgramCertificatesUtilitiesTestCase(CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase): + """ + Tests for the utility methods for the 'award_program_certificates' celery task. + """ + + def setUp(self): + super().setUp() + self.create_credentials_config() + self.student = UserFactory.create(username="test-student") + self.site = SiteFactory() + self.site_configuration = SiteConfigurationFactory(site=self.site) + self.catalog_integration = self.create_catalog_integration() + ApplicationFactory.create(name="credentials") + UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME) + + def test_get_completed_programs(self): + """get_completed_programs returns result of ProgramProgressMeter.completed_programs_with_available_dates""" + expected = {1: 1, 2: 2, 3: 3} + with mock.patch( + TASKS_MODULE + ".ProgramProgressMeter.completed_programs_with_available_dates", + new_callable=mock.PropertyMock, + ) as mock_completed_programs_with_available_dates: + mock_completed_programs_with_available_dates.return_value = expected + completed_programs = tasks.get_completed_programs(self.site, self.student) + assert expected == completed_programs + + @skip_unless_lms @ddt.ddt @mock.patch(TASKS_MODULE + ".award_program_certificate") @@ -180,10 +204,6 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo actual_program_uuids = [call[0][2] for call in mock_award_program_certificate.call_args_list] assert actual_program_uuids == expected_awarded_program_uuids - actual_visible_dates = [call[0][3] for call in mock_award_program_certificate.call_args_list] - assert actual_visible_dates == expected_awarded_program_uuids - # program uuids are same as mock dates - @mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_current_site_configuration") def test_awarding_certs_with_skip_program_certificate( self, @@ -215,9 +235,6 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo tasks.award_program_certificates.delay(self.student.username).get() actual_program_uuids = [call[0][2] for call in mock_award_program_certificate.call_args_list] assert actual_program_uuids == expected_awarded_program_uuids - actual_visible_dates = [call[0][3] for call in mock_award_program_certificate.call_args_list] - assert actual_visible_dates == expected_awarded_program_uuids - # program uuids are same as mock dates @ddt.data( ("credentials", "enable_learner_issuance"), @@ -471,9 +488,7 @@ class PostCourseCertificateTestCase(TestCase): "http://test-server/credentials/", ) - visible_date = datetime.now() - - tasks.post_course_certificate(test_client, self.student.username, self.certificate, visible_date) + tasks.post_course_certificate(test_client, self.student.username, self.certificate) expected_body = { "username": self.student.username, @@ -484,12 +499,6 @@ class PostCourseCertificateTestCase(TestCase): "type": tasks.COURSE_CERTIFICATE, }, "date_override": None, - "attributes": [ - { - "name": "visible_date", - "value": visible_date.strftime("%Y-%m-%dT%H:%M:%SZ"), # text representation of date - } - ], } last_request_body = httpretty.last_request().body.decode("utf-8") assert json.loads(last_request_body) == expected_body @@ -555,7 +564,6 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase): call_args, _ = mock_post_course_certificate.call_args assert call_args[1] == self.student.username assert call_args[2] == self.certificate - assert call_args[3] == self.certificate.modified_date def test_award_course_certificates_available_date(self, mock_post_course_certificate): """ @@ -567,7 +575,6 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase): call_args, _ = mock_post_course_certificate.call_args assert call_args[1] == self.student.username assert call_args[2] == self.certificate - assert call_args[3] == self.available_date def test_award_course_certificates_override_date(self, mock_post_course_certificate): """ @@ -578,8 +585,7 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase): call_args, _ = mock_post_course_certificate.call_args assert call_args[1] == self.student.username assert call_args[2] == self.certificate - assert call_args[3] == self.certificate.modified_date - assert call_args[4] == self.certificate.date_override.date + assert call_args[3] == self.certificate.date_override.date def test_award_course_cert_not_called_if_disabled(self, mock_post_course_certificate): """ @@ -1009,76 +1015,6 @@ class PostCourseCertificateConfigurationTestCase(TestCase): assert json.loads(last_request_body) == expected_body -@skip_unless_lms -class UpdateCertificateVisibleDatesOnCourseUpdateTestCase(CredentialsApiConfigMixin, TestCase): - """ - Tests for the `update_certificate_visible_date_on_course_update` task. - """ - - def setUp(self): - super().setUp() - self.credentials_api_config = self.create_credentials_config(enabled=False) - # setup course - self.course = CourseOverviewFactory.create() - # setup users - self.student1 = UserFactory.create(username="test-student1") - self.student2 = UserFactory.create(username="test-student2") - self.student3 = UserFactory.create(username="test-student3") - # award certificates to users in course we created - self.certificate_student1 = GeneratedCertificateFactory.create( - user=self.student1, - mode="verified", - course_id=self.course.id, - status="downloadable", - ) - self.certificate_student2 = GeneratedCertificateFactory.create( - user=self.student2, - mode="verified", - course_id=self.course.id, - status="downloadable", - ) - self.certificate_student3 = GeneratedCertificateFactory.create( - user=self.student3, - mode="verified", - course_id=self.course.id, - status="downloadable", - ) - - def tearDown(self): - super().tearDown() - self.credentials_api_config = self.create_credentials_config(enabled=False) - - def test_update_visible_dates_but_credentials_config_disabled(self): - """ - This test verifies the behavior of the `update_certificate_visible_date_on_course_update` task when the - CredentialsApiConfig is disabled. - - If the system is configured to _not_ use the Credentials IDA, we should expect this task to eventually throw an - exception when the max number of retries has reached. - """ - with pytest.raises(MaxRetriesExceededError): - # pylint: disable=no-value-for-parameter - tasks.update_certificate_visible_date_on_course_update(self.course.id) - - def test_update_visible_dates(self): - """ - Happy path test. - - This test verifies the behavior of the `update_certificate_visible_date_on_course_update` task. This test - verifies attempts by the system to queue a number of `award_course_certificate` tasks to ensure the - `visible_date` attribute is updated on all eligible course certificates. - """ - # enable the CredentialsApiConfig to issue certificates using the Credentials service - self.credentials_api_config.enabled = True - self.credentials_api_config.enable_learner_issuance = True - - with mock.patch(f"{TASKS_MODULE}.award_course_certificate.delay") as award_course_cert: - # pylint: disable=no-value-for-parameter - tasks.update_certificate_visible_date_on_course_update(self.course.id) - - assert award_course_cert.call_count == 3 - - @skip_unless_lms class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigMixin, TestCase): """