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:
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 446 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
-------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user