**Previously** When a course administrator changed the `certificates_display_behavior` (presumably to `end_with_date`) AND set the `certificate_available_date` in Studio, the `certificate_available_date` was not syncing to Credentials. This was because we chose to send the `certificate_available_date` only if the course is self-paced and the `certificate_display_behavior` is set to `end_with_date`. [See PR #28275](https://github.com/openedx/edx-platform/pull/28275). However, we were checking those two conditions by looking at the relevant `CourseOverview`, which was not yet truly saved to reflect the updated display behavior at the time of the check due to atomic requests. [Read more about atomic requests and transactions here](https://docs.djangoproject.com/en/4.0/topics/db/transactions/#tying-transactions-to-http-requests-1); we have `ATOMIC_REQUESTS` set to `TRUE` in our codebase. Because the `certificate_display_behavior` was not (yet) `end_with_date`, the post to Credentials was not being fired. **Solution** To fix, this commit sends the `COURSE_CERT_DATE_CHANGE` signal `on_commit` instead, which waits until the transaction has completed and the update to the `CourseOverview` has been truly applied to the database. [Read more about `on_commit` here](https://docs.djangoproject.com/en/4.0/topics/db/transactions/#django.db.transaction.on_commit). Now, when the relevant `CourseOverview` is read, it will have the updated `certificate_display_behavior`. See the [Django docs for how to test on_commit callbacks here](https://docs.djangoproject.com/en/3.2/topics/testing/tools/#django.test.TestCase.captureOnCommitCallbacks); this seems to be our first time using the built-in method. This commit also cleans up some previous code that was meant to get around the problem caused by atomic requests, that is now unneccessary with this fix. It essentially reverses the work done in [PR #26991](https://github.com/openedx/edx-platform/pull/26991): we no longer need to explicitly pass the `certificate_available_date` since we can trust the `CourseOverview` to be properly updated. **Rejected Solutions** A. Simply publish the `COURSE_CERT_DATE_CHANGE` signal `on_commit`; no other changes. Rejected because: This would fix the problem, but leaves a lot of unnecessary code and some puzzling inconsistencies. I prefer the solution above because we are cleaning up behind ourselves. B. Pass the new `certificate_display_behavior` along with the `certificate_available_date`; read those direclty instead of checking the (not-yet-properly-updated) `CourseOverview`. Rejected because: The pattern of passing the new `certificate_available_date` down through all these methods was put in place to get around the atomic requests problem. I believe `on_commit` to be a better solution to getting around that problem. I’d like to move away from passing data down through several functions / methods. C. Start the celery task `on_commit` (rather than send the signal `on_commit`). Rejected because: The signal receiver basically only starts the celery task, and I find the break to be a bit more readable when sending the signal. No need to split hairs here. D. Remove the check for pacing and display behavior; send the updated `certificate_available_date` every time there is a change, no matter what the current display behavior is. Rejected because: We intentionally added this check in [PR #28275](https://github.com/openedx/edx-platform/pull/28275) because the task was not behaving as expected without it (specifically around self-paced courses). I assume this is still necessary. **Relevant Prior Work** The following PRs--in order--show how this section (and other relevant sections) of the code have been changed over time: 1. [Move cert date signals to avoid race conditions #26841](https://github.com/openedx/edx-platform/pull/26841) 2. [feat: Pass date in cert date update signal #26991](https://github.com/openedx/edx-platform/pull/26991) 3. [Fix certificate available date sync #28275](https://github.com/openedx/edx-platform/pull/28275) 4. [fix: Correct an issue where cert available date was not sent to Crede… #28524](https://github.com/openedx/edx-platform/pull/28524) MICROBA-1818
211 lines
7.5 KiB
Python
211 lines
7.5 KiB
Python
"""
|
|
This module contains signals / handlers related to programs.
|
|
"""
|
|
|
|
|
|
import logging
|
|
|
|
from django.dispatch import receiver
|
|
|
|
from openedx.core.djangoapps.credentials.helpers import is_learner_records_enabled_for_org
|
|
from openedx.core.djangoapps.signals.signals import (
|
|
COURSE_CERT_AWARDED,
|
|
COURSE_CERT_CHANGED,
|
|
COURSE_CERT_DATE_CHANGE,
|
|
COURSE_CERT_REVOKED
|
|
)
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
@receiver(COURSE_CERT_AWARDED)
|
|
def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument
|
|
"""
|
|
If programs is enabled and a learner is awarded a course certificate,
|
|
schedule a celery task to process any programs certificates for which
|
|
the learner may now be eligible.
|
|
|
|
Args:
|
|
sender:
|
|
class of the object instance that sent this signal
|
|
user:
|
|
django.contrib.auth.User - the user to whom a cert was awarded
|
|
course_key:
|
|
refers to the course run for which the cert was awarded
|
|
mode:
|
|
mode / certificate type, e.g. "verified"
|
|
status:
|
|
either "downloadable" or "generating"
|
|
|
|
Returns:
|
|
None
|
|
|
|
"""
|
|
# Import here instead of top of file since this module gets imported before
|
|
# the credentials app is loaded, resulting in a Django deprecation warning.
|
|
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
|
|
|
# Avoid scheduling new tasks if certification is disabled.
|
|
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
|
return
|
|
|
|
# schedule background task to process
|
|
LOGGER.debug(
|
|
'handling COURSE_CERT_AWARDED: username=%s, course_key=%s, mode=%s, status=%s',
|
|
user,
|
|
course_key,
|
|
mode,
|
|
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)
|
|
|
|
|
|
@receiver(COURSE_CERT_CHANGED)
|
|
def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs):
|
|
"""
|
|
If a learner is awarded a course certificate,
|
|
schedule a celery task to process that course certificate
|
|
|
|
Args:
|
|
sender:
|
|
class of the object instance that sent this signal
|
|
user:
|
|
django.contrib.auth.User - the user to whom a cert was awarded
|
|
course_key:
|
|
refers to the course run for which the cert was awarded
|
|
mode:
|
|
mode / certificate type, e.g. "verified"
|
|
status:
|
|
"downloadable"
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
# Import here instead of top of file since this module gets imported before
|
|
# the credentials app is loaded, resulting in a Django deprecation warning.
|
|
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
|
|
|
verbose = kwargs.get('verbose', False)
|
|
if verbose:
|
|
msg = "Starting handle_course_cert_changed with params: "\
|
|
"sender [{sender}], "\
|
|
"user [{username}], "\
|
|
"course_key [{course_key}], "\
|
|
"mode [{mode}], "\
|
|
"status [{status}], "\
|
|
"kwargs [{kw}]"\
|
|
.format(
|
|
sender=sender,
|
|
username=getattr(user, 'username', None),
|
|
course_key=str(course_key),
|
|
mode=mode,
|
|
status=status,
|
|
kw=kwargs
|
|
)
|
|
|
|
LOGGER.info(msg)
|
|
|
|
# Avoid scheduling new tasks if certification is disabled.
|
|
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
|
if verbose:
|
|
LOGGER.info("Skipping send cert: is_learner_issuance_enabled False")
|
|
return
|
|
|
|
# Avoid scheduling new tasks if learner records are disabled for this site (right now, course certs are only
|
|
# used for learner records -- when that changes, we can remove this bit and always send course certs).
|
|
if not is_learner_records_enabled_for_org(course_key.org):
|
|
if verbose:
|
|
LOGGER.info(
|
|
"Skipping send cert: ENABLE_LEARNER_RECORDS False for org [{org}]".format(
|
|
org=course_key.org
|
|
)
|
|
)
|
|
return
|
|
|
|
# schedule background task to process
|
|
LOGGER.debug(
|
|
'handling COURSE_CERT_CHANGED: username=%s, course_key=%s, mode=%s, status=%s',
|
|
user,
|
|
course_key,
|
|
mode,
|
|
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))
|
|
|
|
|
|
@receiver(COURSE_CERT_REVOKED)
|
|
def handle_course_cert_revoked(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument
|
|
"""
|
|
If programs is enabled and a learner's course certificate is revoked,
|
|
schedule a celery task to revoke any related program certificates.
|
|
|
|
Args:
|
|
sender:
|
|
class of the object instance that sent this signal
|
|
user:
|
|
django.contrib.auth.User - the user for which a cert was revoked
|
|
course_key:
|
|
refers to the course run for which the cert was revoked
|
|
mode:
|
|
mode / certificate type, e.g. "verified"
|
|
status:
|
|
revoked
|
|
|
|
Returns:
|
|
None
|
|
|
|
"""
|
|
# Import here instead of top of file since this module gets imported before
|
|
# the credentials app is loaded, resulting in a Django deprecation warning.
|
|
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
|
|
|
# Avoid scheduling new tasks if certification is disabled.
|
|
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
|
return
|
|
|
|
# schedule background task to process
|
|
LOGGER.info(
|
|
f"handling COURSE_CERT_REVOKED: user={user.id}, 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')
|
|
def handle_course_cert_date_change(sender, course_key, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
If course is updated and the certificate_available_date is changed,
|
|
schedule a celery task to update visible_date for all certificates
|
|
within course.
|
|
|
|
Args:
|
|
course_key (CourseLocator): refers to the course whose certificate_available_date was updated.
|
|
|
|
Returns:
|
|
None
|
|
|
|
"""
|
|
|
|
# Import here instead of top of file since this module gets imported before
|
|
# the credentials app is loaded, resulting in a Django deprecation warning.
|
|
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
|
|
|
# Avoid scheduling new tasks if certification is disabled.
|
|
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
|
return
|
|
|
|
# schedule background task to process
|
|
LOGGER.info(
|
|
'handling COURSE_CERT_DATE_CHANGE for course %s',
|
|
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_visible_date_on_course_update
|
|
update_certificate_visible_date_on_course_update.delay(str(course_key))
|