feat: removing visible_date-to-creds updates per-cert (#35113)

* feat: removing visible_date-to-creds updates per-cert

The credentials IDA now relies on  the course certificate configuration
and (if present) `certificate_available_date` for displayability. We no
longer need to send `visible_date` updates for every awarded certificate
when a course  overview changes.
This commit is contained in:
Deborah Kaplan
2024-07-17 08:43:12 -04:00
committed by GitHub
parent 3589d964cb
commit 58de0964ca
12 changed files with 116 additions and 250 deletions

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@@ -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

View File

@@ -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': {

View File

@@ -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
"""

View File

@@ -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.

View File

@@ -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
-------

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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):
"""