Merge pull request #34129 from openedx/dkaplan1/APER-3146_investigate-fix-exception-handling-in-program-cert-revocation
feat: fix exception handling in program cert revocation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Helper functions for working with Credentials."""
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
@@ -42,7 +43,7 @@ def get_credentials_api_client(user):
|
||||
Arguments:
|
||||
user (User): The user to authenticate as when requesting credentials.
|
||||
"""
|
||||
scopes = ['email', 'profile', 'user_id']
|
||||
scopes = ["email", "profile", "user_id"]
|
||||
jwt = create_jwt_for_user(user, scopes=scopes)
|
||||
|
||||
client = requests.Session()
|
||||
@@ -65,7 +66,12 @@ def get_credentials_api_base_url(org=None):
|
||||
return url
|
||||
|
||||
|
||||
def get_credentials(user, program_uuid=None, credential_type=None):
|
||||
def get_credentials(
|
||||
user: User,
|
||||
program_uuid: str = None,
|
||||
credential_type: str = None,
|
||||
raise_on_error: bool = False,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Given a user, get credentials earned from the credentials service.
|
||||
|
||||
@@ -75,6 +81,7 @@ def get_credentials(user, program_uuid=None, credential_type=None):
|
||||
Keyword Arguments:
|
||||
program_uuid (str): UUID of the program whose credential to retrieve.
|
||||
credential_type (str): Which type of credentials to return (course-run or program)
|
||||
raise_on_error (bool): Reraise errors back to the caller, instead if returning empty results.
|
||||
|
||||
Returns:
|
||||
list of dict, representing credentials returned by the Credentials
|
||||
@@ -82,31 +89,38 @@ def get_credentials(user, program_uuid=None, credential_type=None):
|
||||
"""
|
||||
credential_configuration = CredentialsApiConfig.current()
|
||||
|
||||
querystring = {'username': user.username, 'status': 'awarded', 'only_visible': 'True'}
|
||||
querystring = {
|
||||
"username": user.username,
|
||||
"status": "awarded",
|
||||
"only_visible": "True",
|
||||
}
|
||||
|
||||
if program_uuid:
|
||||
querystring['program_uuid'] = program_uuid
|
||||
querystring["program_uuid"] = program_uuid
|
||||
|
||||
if credential_type:
|
||||
querystring['type'] = credential_type
|
||||
querystring["type"] = credential_type
|
||||
|
||||
# Bypass caching for staff users, who may be generating credentials and
|
||||
# want to see them displayed immediately.
|
||||
use_cache = credential_configuration.is_cache_enabled and not user.is_staff
|
||||
cache_key = f'{credential_configuration.CACHE_KEY}.{user.username}' if use_cache else None
|
||||
cache_key = (
|
||||
f"{credential_configuration.CACHE_KEY}.{user.username}" if use_cache else None
|
||||
)
|
||||
if cache_key and program_uuid:
|
||||
cache_key = f'{cache_key}.{program_uuid}'
|
||||
cache_key = f"{cache_key}.{program_uuid}"
|
||||
|
||||
api_client = get_credentials_api_client(user)
|
||||
base_api_url = get_credentials_api_base_url()
|
||||
|
||||
return get_api_data(
|
||||
credential_configuration,
|
||||
'credentials',
|
||||
"credentials",
|
||||
api_client=api_client,
|
||||
base_api_url=base_api_url,
|
||||
querystring=querystring,
|
||||
cache_key=cache_key
|
||||
cache_key=cache_key,
|
||||
raise_on_error=raise_on_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,11 +136,13 @@ def get_courses_completion_status(username, course_run_ids):
|
||||
"""
|
||||
credential_configuration = CredentialsApiConfig.current()
|
||||
if not credential_configuration.enabled:
|
||||
log.warning('%s configuration is disabled.', credential_configuration.API_NAME)
|
||||
log.warning("%s configuration is disabled.", credential_configuration.API_NAME)
|
||||
return [], False
|
||||
|
||||
completion_status_url = (f'{settings.CREDENTIALS_INTERNAL_SERVICE_URL}/api'
|
||||
'/credentials/v1/learner_cert_status/')
|
||||
completion_status_url = (
|
||||
f"{settings.CREDENTIALS_INTERNAL_SERVICE_URL}/api"
|
||||
"/credentials/v1/learner_cert_status/"
|
||||
)
|
||||
try:
|
||||
api_client = get_credentials_api_client(
|
||||
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME)
|
||||
@@ -134,30 +150,36 @@ def get_courses_completion_status(username, course_run_ids):
|
||||
api_response = api_client.post(
|
||||
completion_status_url,
|
||||
json={
|
||||
'username': username,
|
||||
'course_runs': course_run_ids,
|
||||
}
|
||||
"username": username,
|
||||
"course_runs": course_run_ids,
|
||||
},
|
||||
)
|
||||
api_response.raise_for_status()
|
||||
course_completion_response = api_response.json()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
log.exception("An unexpected error occurred while reqeusting course completion statuses "
|
||||
"for user [%s] for course_run_ids [%s] with exc [%s]:",
|
||||
username,
|
||||
course_run_ids,
|
||||
exc
|
||||
)
|
||||
log.exception(
|
||||
"An unexpected error occurred while reqeusting course completion statuses "
|
||||
"for user [%s] for course_run_ids [%s] with exc [%s]:",
|
||||
username,
|
||||
course_run_ids,
|
||||
exc,
|
||||
)
|
||||
return [], True
|
||||
log.info("Course completion status response for user [%s] for course_run_ids [%s] is [%s]",
|
||||
username,
|
||||
course_run_ids,
|
||||
course_completion_response)
|
||||
log.info(
|
||||
"Course completion status response for user [%s] for course_run_ids [%s] is [%s]",
|
||||
username,
|
||||
course_run_ids,
|
||||
course_completion_response,
|
||||
)
|
||||
# Yes, This is course_credentials_data. The key is named status but
|
||||
# it contains all the courses data from credentials.
|
||||
course_credentials_data = course_completion_response.get('status', [])
|
||||
course_credentials_data = course_completion_response.get("status", [])
|
||||
if course_credentials_data is not None:
|
||||
filtered_records = [course_data['course_run']['key'] for course_data in course_credentials_data if
|
||||
course_data['course_run']['key'] in course_run_ids and
|
||||
course_data['status'] == settings.CREDENTIALS_COURSE_COMPLETION_STATE]
|
||||
filtered_records = [
|
||||
course_data["course_run"]["key"]
|
||||
for course_data in course_credentials_data
|
||||
if course_data["course_run"]["key"] in course_run_ids
|
||||
and course_data["status"] == settings.CREDENTIALS_COURSE_COMPLETION_STATE
|
||||
]
|
||||
return filtered_records, False
|
||||
return [], False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
This file contains celery tasks for programs-related functionality.
|
||||
"""
|
||||
from typing import Dict, List
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from celery import shared_task
|
||||
@@ -13,7 +14,6 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from requests.exceptions import HTTPError
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from lms.djangoapps.certificates.api import available_date_for_certificate
|
||||
@@ -27,6 +27,7 @@ from openedx.core.djangoapps.credentials.utils import (
|
||||
)
|
||||
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -37,9 +38,9 @@ LOGGER = get_task_logger(__name__)
|
||||
# unwanted behavior: infinite retries.
|
||||
MAX_RETRIES = 11
|
||||
|
||||
PROGRAM_CERTIFICATE = 'program'
|
||||
COURSE_CERTIFICATE = 'course-run'
|
||||
DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
|
||||
PROGRAM_CERTIFICATE = "program"
|
||||
COURSE_CERTIFICATE = "course-run"
|
||||
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
||||
|
||||
|
||||
def get_completed_programs(site, student):
|
||||
@@ -77,22 +78,28 @@ def get_inverted_programs(student):
|
||||
return inverted_programs
|
||||
|
||||
|
||||
def get_certified_programs(student):
|
||||
def get_certified_programs(student: User, raise_on_error: bool = False) -> List[str]:
|
||||
"""
|
||||
Find the UUIDs of all the programs for which the student has already been awarded
|
||||
a certificate.
|
||||
|
||||
Args:
|
||||
student:
|
||||
User object representing the student
|
||||
student: User object representing the student
|
||||
|
||||
Keyword Arguments:
|
||||
raise_on_error (bool): Reraise errors back to the caller, instead of returning empty results.
|
||||
|
||||
Returns:
|
||||
str[]: UUIDs of the programs for which the student has been awarded a certificate
|
||||
|
||||
"""
|
||||
certified_programs = []
|
||||
for credential in get_credentials(student, credential_type='program'):
|
||||
certified_programs.append(credential['credential']['program_uuid'])
|
||||
for credential in get_credentials(
|
||||
student,
|
||||
credential_type=PROGRAM_CERTIFICATE,
|
||||
raise_on_error=raise_on_error,
|
||||
):
|
||||
certified_programs.append(credential["credential"]["program_uuid"])
|
||||
return certified_programs
|
||||
|
||||
|
||||
@@ -118,19 +125,11 @@ def award_program_certificate(client, user, program_uuid, visible_date):
|
||||
response = client.post(
|
||||
api_url,
|
||||
json={
|
||||
'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)
|
||||
}
|
||||
]
|
||||
}
|
||||
"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()
|
||||
|
||||
@@ -161,24 +160,21 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
def _retry_with_custom_exception(username, reason, countdown):
|
||||
exception = MaxRetriesExceededError(
|
||||
f"Failed to award program certificate for user {username}. Reason: {reason}"
|
||||
)
|
||||
return self.retry(
|
||||
exc=exception,
|
||||
countdown=countdown,
|
||||
max_retries=MAX_RETRIES
|
||||
)
|
||||
return self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
LOGGER.info(f"Running task award_program_certificates for username {username}")
|
||||
programs_without_certificates = configuration_helpers.get_value('programs_without_certificates', [])
|
||||
programs_without_certificates = configuration_helpers.get_value("programs_without_certificates", [])
|
||||
if programs_without_certificates:
|
||||
if str(programs_without_certificates[0]).lower() == "all":
|
||||
# this check will prevent unnecessary logging for partners without program certificates
|
||||
return
|
||||
|
||||
countdown = 2 ** self.request.retries
|
||||
countdown = 2**self.request.retries
|
||||
# If the credentials config model is disabled for this
|
||||
# feature, it may indicate a condition where processing of such tasks
|
||||
# has been temporarily disabled. Since this is a recoverable situation,
|
||||
@@ -247,7 +243,7 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
|
||||
except HTTPError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
LOGGER.exception(
|
||||
f"Certificate for program {program_uuid} could not be found. " +
|
||||
f"Certificate for program {program_uuid} could not be found. "
|
||||
f"Unable to award certificate to user {username}. The program might not be configured."
|
||||
)
|
||||
elif exc.response.status_code == 429:
|
||||
@@ -261,7 +257,7 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
|
||||
raise _retry_with_custom_exception(
|
||||
username=username,
|
||||
reason=error_msg,
|
||||
countdown=rate_limit_countdown
|
||||
countdown=rate_limit_countdown,
|
||||
) from exc
|
||||
else:
|
||||
LOGGER.exception(
|
||||
@@ -308,11 +304,11 @@ def post_course_certificate_configuration(client, cert_config, certificate_avail
|
||||
response = client.post(
|
||||
credentials_api_url,
|
||||
json={
|
||||
"course_id": cert_config['course_id'],
|
||||
"certificate_type": cert_config['mode'],
|
||||
"course_id": cert_config["course_id"],
|
||||
"certificate_type": cert_config["mode"],
|
||||
"certificate_available_date": certificate_available_date,
|
||||
"is_active": True
|
||||
}
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Sometimes helpful error context is swallowed when calling `raise_for_status()`. We try to print out any additional
|
||||
@@ -338,21 +334,16 @@ def post_course_certificate(client, username, certificate, visible_date, date_ov
|
||||
response = client.post(
|
||||
api_url,
|
||||
json={
|
||||
'username': username,
|
||||
'status': 'awarded' if certificate.is_valid() else 'revoked', # Only need the two options at this time
|
||||
'credential': {
|
||||
'course_run_key': str(certificate.course_id),
|
||||
'mode': certificate.mode,
|
||||
'type': COURSE_CERTIFICATE,
|
||||
"username": username,
|
||||
"status": "awarded" if certificate.is_valid() else "revoked", # Only need the two options at this time
|
||||
"credential": {
|
||||
"course_run_key": str(certificate.course_id),
|
||||
"mode": certificate.mode,
|
||||
"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)
|
||||
}
|
||||
]
|
||||
}
|
||||
"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()
|
||||
|
||||
@@ -361,9 +352,7 @@ def post_course_certificate(client, username, certificate, visible_date, date_ov
|
||||
@shared_task(bind=True, ignore_result=True)
|
||||
@set_code_owner_attribute
|
||||
def update_credentials_course_certificate_configuration_available_date(
|
||||
self,
|
||||
course_key,
|
||||
certificate_available_date=None
|
||||
self, course_key, certificate_available_date=None
|
||||
):
|
||||
"""
|
||||
This task will update the CourseCertificate configuration's available date
|
||||
@@ -386,22 +375,20 @@ def update_credentials_course_certificate_configuration_available_date(
|
||||
# There should only ever be one certificate relevant mode per course run
|
||||
modes = [mode.slug for mode in course_modes if mode.slug in CourseMode.CERTIFICATE_RELEVANT_MODES]
|
||||
if len(modes) != 1:
|
||||
LOGGER.exception(
|
||||
f'Either course {course_key} has no certificate mode or multiple modes. Task failed.'
|
||||
)
|
||||
LOGGER.exception(f"Either course {course_key} has no certificate mode or multiple modes. Task failed.")
|
||||
return
|
||||
|
||||
credentials_client = get_credentials_api_client(
|
||||
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
|
||||
)
|
||||
cert_config = {
|
||||
'course_id': course_key,
|
||||
'mode': modes[0],
|
||||
"course_id": course_key,
|
||||
"mode": modes[0],
|
||||
}
|
||||
post_course_certificate_configuration(
|
||||
client=credentials_client,
|
||||
cert_config=cert_config,
|
||||
certificate_available_date=certificate_available_date
|
||||
certificate_available_date=certificate_available_date,
|
||||
)
|
||||
|
||||
|
||||
@@ -421,19 +408,16 @@ def award_course_certificate(self, username, course_run_key):
|
||||
username (str): The user to award the Credentials course cert to
|
||||
course_run_key (str): The course run key to award the certificate for
|
||||
"""
|
||||
|
||||
def _retry_with_custom_exception(username, course_run_key, reason, countdown):
|
||||
exception = MaxRetriesExceededError(
|
||||
f"Failed to award course certificate for user {username} for course {course_run_key}. Reason: {reason}"
|
||||
)
|
||||
return self.retry(
|
||||
exc=exception,
|
||||
countdown=countdown,
|
||||
max_retries=MAX_RETRIES
|
||||
)
|
||||
return self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
LOGGER.info(f"Running task award_course_certificate for username {username}")
|
||||
|
||||
countdown = 2 ** self.request.retries
|
||||
countdown = 2**self.request.retries
|
||||
|
||||
# If the credentials config model is disabled for this
|
||||
# feature, it may indicate a condition where processing of such tasks
|
||||
@@ -448,9 +432,8 @@ def award_course_certificate(self, username, course_run_key):
|
||||
username=username,
|
||||
course_run_key=course_run_key,
|
||||
reason=error_msg,
|
||||
countdown=countdown
|
||||
countdown=countdown,
|
||||
)
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_run_key)
|
||||
try:
|
||||
@@ -463,7 +446,7 @@ def award_course_certificate(self, username, course_run_key):
|
||||
try:
|
||||
certificate = GeneratedCertificate.eligible_certificates.get(
|
||||
user=user.id,
|
||||
course_id=course_key
|
||||
course_id=course_key,
|
||||
)
|
||||
except GeneratedCertificate.DoesNotExist:
|
||||
LOGGER.exception(
|
||||
@@ -505,7 +488,12 @@ def award_course_certificate(self, username, course_run_key):
|
||||
date_override = None
|
||||
|
||||
post_course_certificate(
|
||||
credentials_client, username, certificate, visible_date, date_override, org=course_key.org
|
||||
credentials_client,
|
||||
username,
|
||||
certificate,
|
||||
visible_date,
|
||||
date_override,
|
||||
org=course_key.org,
|
||||
)
|
||||
|
||||
LOGGER.info(f"Awarded certificate for course {course_key} to user {username}")
|
||||
@@ -516,11 +504,11 @@ def award_course_certificate(self, username, course_run_key):
|
||||
username=username,
|
||||
course_run_key=course_run_key,
|
||||
reason=error_msg,
|
||||
countdown=countdown
|
||||
countdown=countdown,
|
||||
) from exc
|
||||
|
||||
|
||||
def get_revokable_program_uuids(course_specific_programs, student):
|
||||
def get_revokable_program_uuids(course_specific_programs: List[Dict], student: User) -> List[str]:
|
||||
"""
|
||||
Get program uuids for which certificate to be revoked.
|
||||
|
||||
@@ -532,14 +520,19 @@ def get_revokable_program_uuids(course_specific_programs, student):
|
||||
student (User): Representing the student whose programs to check for.
|
||||
|
||||
Returns:
|
||||
list if program UUIDs for which certificates to be revoked
|
||||
list of program UUIDs for which certificates to be revoked
|
||||
|
||||
Raises:
|
||||
HttpError, if the API call generated by get_certified_programs fails
|
||||
"""
|
||||
program_uuids_to_revoke = []
|
||||
existing_program_uuids = get_certified_programs(student)
|
||||
# Get any programs where the user has already been rewarded a certificate
|
||||
# Failed API calls with get_certified_programs should raise exceptions,
|
||||
# because an empty response would dangerously imply a false negative.
|
||||
existing_program_uuids = get_certified_programs(student, raise_on_error=True)
|
||||
for program in course_specific_programs:
|
||||
if program['uuid'] in existing_program_uuids:
|
||||
program_uuids_to_revoke.append(program['uuid'])
|
||||
if program["uuid"] in existing_program_uuids:
|
||||
program_uuids_to_revoke.append(program["uuid"])
|
||||
|
||||
return program_uuids_to_revoke
|
||||
|
||||
@@ -561,13 +554,10 @@ def revoke_program_certificate(client, username, program_uuid):
|
||||
response = client.post(
|
||||
api_url,
|
||||
json={
|
||||
'username': username,
|
||||
'status': 'revoked',
|
||||
'credential': {
|
||||
'type': PROGRAM_CERTIFICATE,
|
||||
'program_uuid': program_uuid
|
||||
}
|
||||
}
|
||||
"username": username,
|
||||
"status": "revoked",
|
||||
"credential": {"type": PROGRAM_CERTIFICATE, "program_uuid": program_uuid},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -594,17 +584,14 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
def _retry_with_custom_exception(username, course_key, reason, countdown):
|
||||
exception = MaxRetriesExceededError(
|
||||
f"Failed to revoke program certificate for user {username} for course {course_key}. Reason: {reason}"
|
||||
)
|
||||
return self.retry(
|
||||
exc=exception,
|
||||
countdown=countdown,
|
||||
max_retries=MAX_RETRIES
|
||||
)
|
||||
return self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
countdown = 2 ** self.request.retries
|
||||
countdown = 2**self.request.retries
|
||||
# If the credentials config model is disabled for this
|
||||
# feature, it may indicate a condition where processing of such tasks
|
||||
# has been temporarily disabled. Since this is a recoverable situation,
|
||||
@@ -619,13 +606,16 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
|
||||
username=username,
|
||||
course_key=course_key,
|
||||
reason=error_msg,
|
||||
countdown=countdown
|
||||
countdown=countdown,
|
||||
)
|
||||
|
||||
try:
|
||||
student = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
LOGGER.exception(f"Task revoke_program_certificates was called with invalid username {username}", username)
|
||||
LOGGER.exception(
|
||||
f"Task revoke_program_certificates was called with invalid username {username}",
|
||||
username,
|
||||
)
|
||||
# Don't retry for this case - just conclude the task.
|
||||
return
|
||||
|
||||
@@ -644,15 +634,14 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
|
||||
program_uuids_to_revoke = get_revokable_program_uuids(course_specific_programs, student)
|
||||
except Exception as exc:
|
||||
error_msg = (
|
||||
f"Failed to determine program certificates to be revoked for user {username} "
|
||||
f"with course {course_key}"
|
||||
f"Failed to determine program certificates to be revoked for user {username} " f"with course {course_key}"
|
||||
)
|
||||
LOGGER.exception(error_msg)
|
||||
raise _retry_with_custom_exception(
|
||||
username=username,
|
||||
course_key=course_key,
|
||||
reason=error_msg,
|
||||
countdown=countdown
|
||||
countdown=countdown,
|
||||
) from exc
|
||||
|
||||
if program_uuids_to_revoke:
|
||||
@@ -689,12 +678,10 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
|
||||
username,
|
||||
course_key,
|
||||
reason=error_msg,
|
||||
countdown=rate_limit_countdown
|
||||
countdown=rate_limit_countdown,
|
||||
) from exc
|
||||
else:
|
||||
LOGGER.exception(
|
||||
f"Unable to revoke certificate for user {username} for program {program_uuid}."
|
||||
)
|
||||
LOGGER.exception(f"Unable to revoke certificate for user {username} for program {program_uuid}.")
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# keep trying to revoke other certs, but retry the whole task to fix any missing entries
|
||||
LOGGER.warning(f"Failed to revoke certificate for program {program_uuid} of user {username}.")
|
||||
@@ -710,12 +697,7 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
|
||||
f"Failed to revoke certificate for user {username} "
|
||||
f"for programs {failed_program_certificate_revoke_attempts}"
|
||||
)
|
||||
raise _retry_with_custom_exception(
|
||||
username,
|
||||
course_key,
|
||||
reason=error_msg,
|
||||
countdown=countdown
|
||||
)
|
||||
raise _retry_with_custom_exception(username, course_key, reason=error_msg, countdown=countdown)
|
||||
|
||||
else:
|
||||
LOGGER.info(f"There is no program certificates for user {username} to revoke")
|
||||
@@ -738,7 +720,7 @@ def update_certificate_visible_date_on_course_update(self, course_key):
|
||||
Arguments:
|
||||
course_key(str): The course identifier
|
||||
"""
|
||||
countdown = 2 ** self.request.retries
|
||||
countdown = 2**self.request.retries
|
||||
|
||||
# If the CredentialsApiConfig configuration model is disabled for this feature, it may indicate a condition where
|
||||
# processing of such tasks has been temporarily disabled. Since this is a recoverable situation, mark this task for
|
||||
@@ -757,12 +739,9 @@ def update_certificate_visible_date_on_course_update(self, course_key):
|
||||
|
||||
# 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)
|
||||
)
|
||||
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 "
|
||||
@@ -786,7 +765,7 @@ def update_certificate_available_date_on_course_update(self, course_key):
|
||||
Args:
|
||||
course_key(str): The course identifier
|
||||
"""
|
||||
countdown = 2 ** self.request.retries
|
||||
countdown = 2**self.request.retries
|
||||
|
||||
# If the CredentialsApiConfig configuration model is disabled for this feature, it may indicate a condition where
|
||||
# processing of such tasks has been temporarily disabled. Since this is a recoverable situation, mark this task for
|
||||
@@ -808,17 +787,16 @@ def update_certificate_available_date_on_course_update(self, course_key):
|
||||
# - The course-run is instructor-paced, AND
|
||||
# - The `certificates_display_behavior` is set to "end_with_date",
|
||||
if (
|
||||
course_overview and
|
||||
course_overview.self_paced is False and
|
||||
course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
course_overview
|
||||
and course_overview.self_paced is False
|
||||
and course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
):
|
||||
LOGGER.info(
|
||||
f"Queueing task to update the `certificate_available_date` of course-run {course_key} to "
|
||||
f"[{course_overview.certificate_available_date}] in the Credentials service"
|
||||
)
|
||||
update_credentials_course_certificate_configuration_available_date.delay(
|
||||
str(course_key),
|
||||
str(course_overview.certificate_available_date)
|
||||
str(course_key), str(course_overview.certificate_available_date)
|
||||
)
|
||||
# OR,
|
||||
# - The course-run is self-paced, AND
|
||||
@@ -828,11 +806,7 @@ def update_certificate_available_date_on_course_update(self, course_key):
|
||||
# associated with a `certificate_available_date`. This ends up causing learners' certificate to be incorrectly
|
||||
# hidden. This is due to the Credentials IDA not understanding the concept of course pacing. Thus, we need a way
|
||||
# to remove this value from self-paced courses in Credentials.
|
||||
elif (
|
||||
course_overview and
|
||||
course_overview.self_paced is True and
|
||||
course_overview.certificate_available_date is None
|
||||
):
|
||||
elif course_overview and course_overview.self_paced is True and course_overview.certificate_available_date is None:
|
||||
LOGGER.info(
|
||||
"Queueing task to remove the `certificate_available_date` in the Credentials service for course-run "
|
||||
f"{course_key}"
|
||||
|
||||
@@ -19,23 +19,31 @@ from django.test import TestCase, override_settings
|
||||
from edx_rest_api_client.auth import SuppliedJwtAuth
|
||||
from requests.exceptions import HTTPError
|
||||
from testfixtures import LogCapture
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.certificates.tests.factories import CertificateDateOverrideFactory, GeneratedCertificateFactory
|
||||
from lms.djangoapps.certificates.tests.factories import (
|
||||
CertificateDateOverrideFactory,
|
||||
GeneratedCertificateFactory,
|
||||
)
|
||||
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
|
||||
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.credentials.tests.mixins import CredentialsApiConfigMixin
|
||||
from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory
|
||||
from openedx.core.djangoapps.programs import tasks
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import (
|
||||
SiteConfigurationFactory,
|
||||
SiteFactory,
|
||||
)
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CREDENTIALS_INTERNAL_SERVICE_URL = 'https://credentials.example.com'
|
||||
TASKS_MODULE = 'openedx.core.djangoapps.programs.tasks'
|
||||
CREDENTIALS_INTERNAL_SERVICE_URL = "https://credentials.example.com"
|
||||
TASKS_MODULE = "openedx.core.djangoapps.programs.tasks"
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@@ -49,32 +57,32 @@ class GetAwardedCertificateProgramsTestCase(TestCase):
|
||||
Helper to make dummy results from the credentials API
|
||||
"""
|
||||
result = {
|
||||
'id': 1,
|
||||
'username': 'dummy-username',
|
||||
'credential': {
|
||||
'credential_id': None,
|
||||
'program_uuid': None,
|
||||
"id": 1,
|
||||
"username": "dummy-username",
|
||||
"credential": {
|
||||
"credential_id": None,
|
||||
"program_uuid": None,
|
||||
},
|
||||
'status': 'dummy-status',
|
||||
'uuid': 'dummy-uuid',
|
||||
'certificate_url': 'http://credentials.edx.org/credentials/dummy-uuid/'
|
||||
"status": "dummy-status",
|
||||
"uuid": "dummy-uuid",
|
||||
"certificate_url": "http://credentials.edx.org/credentials/dummy-uuid/",
|
||||
}
|
||||
result.update(**kwargs)
|
||||
return result
|
||||
|
||||
@mock.patch(TASKS_MODULE + '.get_credentials')
|
||||
@mock.patch(TASKS_MODULE + ".get_credentials")
|
||||
def test_get_certified_programs(self, mock_get_credentials):
|
||||
"""
|
||||
Ensure the API is called and results handled correctly.
|
||||
"""
|
||||
student = UserFactory(username='test-username')
|
||||
student = UserFactory(username="test-username")
|
||||
mock_get_credentials.return_value = [
|
||||
self.make_credential_result(status='awarded', credential={'program_uuid': 1}),
|
||||
self.make_credential_result(status="awarded", credential={"program_uuid": 1}),
|
||||
]
|
||||
|
||||
result = tasks.get_certified_programs(student)
|
||||
assert mock_get_credentials.call_args[0] == (student,)
|
||||
assert mock_get_credentials.call_args[1] == {'credential_type': 'program'}
|
||||
assert mock_get_credentials.call_args[1].get("credential_type", None) == "program"
|
||||
assert result == [1]
|
||||
|
||||
|
||||
@@ -83,49 +91,50 @@ class AwardProgramCertificateTestCase(TestCase):
|
||||
"""
|
||||
Test the award_program_certificate function
|
||||
"""
|
||||
|
||||
@httpretty.activate
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
|
||||
@mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url")
|
||||
def test_award_program_certificate(self, mock_get_api_base_url):
|
||||
"""
|
||||
Ensure the correct API call gets made
|
||||
"""
|
||||
mock_get_api_base_url.return_value = 'http://test-server/'
|
||||
student = UserFactory(username='test-username', email='test-email@email.com')
|
||||
mock_get_api_base_url.return_value = "http://test-server/"
|
||||
student = UserFactory(username="test-username", email="test-email@email.com")
|
||||
|
||||
test_client = requests.Session()
|
||||
test_client.auth = SuppliedJwtAuth('test-token')
|
||||
test_client.auth = SuppliedJwtAuth("test-token")
|
||||
|
||||
httpretty.register_uri(
|
||||
httpretty.POST,
|
||||
'http://test-server/credentials/',
|
||||
"http://test-server/credentials/",
|
||||
)
|
||||
|
||||
tasks.award_program_certificate(test_client, student, 123, datetime(2010, 5, 30))
|
||||
|
||||
expected_body = {
|
||||
'username': student.username,
|
||||
'lms_user_id': student.id,
|
||||
'credential': {
|
||||
'program_uuid': 123,
|
||||
'type': tasks.PROGRAM_CERTIFICATE,
|
||||
"username": student.username,
|
||||
"lms_user_id": student.id,
|
||||
"credential": {
|
||||
"program_uuid": 123,
|
||||
"type": tasks.PROGRAM_CERTIFICATE,
|
||||
},
|
||||
'attributes': [
|
||||
"attributes": [
|
||||
{
|
||||
'name': 'visible_date',
|
||||
'value': '2010-05-30T00:00:00Z',
|
||||
"name": "visible_date",
|
||||
"value": "2010-05-30T00:00:00Z",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
last_request_body = httpretty.last_request().body.decode('utf-8')
|
||||
last_request_body = httpretty.last_request().body.decode("utf-8")
|
||||
assert json.loads(last_request_body) == expected_body
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
@mock.patch(TASKS_MODULE + '.award_program_certificate')
|
||||
@mock.patch(TASKS_MODULE + '.get_certified_programs')
|
||||
@mock.patch(TASKS_MODULE + '.get_completed_programs')
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
|
||||
@mock.patch(TASKS_MODULE + ".award_program_certificate")
|
||||
@mock.patch(TASKS_MODULE + ".get_certified_programs")
|
||||
@mock.patch(TASKS_MODULE + ".get_completed_programs")
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME="test-service-username")
|
||||
class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the 'award_program_certificates' celery task.
|
||||
@@ -134,11 +143,11 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.create_credentials_config()
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
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')
|
||||
ApplicationFactory.create(name="credentials")
|
||||
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
|
||||
|
||||
def test_completion_check(
|
||||
@@ -184,13 +193,13 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
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')
|
||||
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_current_site_configuration")
|
||||
def test_awarding_certs_with_skip_program_certificate(
|
||||
self,
|
||||
mocked_get_current_site_configuration,
|
||||
mock_get_completed_programs,
|
||||
mock_get_certified_programs,
|
||||
mock_award_program_certificate,
|
||||
self,
|
||||
mocked_get_current_site_configuration,
|
||||
mock_get_completed_programs,
|
||||
mock_get_certified_programs,
|
||||
mock_award_program_certificate,
|
||||
):
|
||||
"""
|
||||
Checks that the Credentials API is used to award certificates for
|
||||
@@ -204,9 +213,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
mock_get_certified_programs.return_value = [1]
|
||||
|
||||
# programs to be skipped
|
||||
self.site_configuration.site_values = {
|
||||
"programs_without_certificates": [2]
|
||||
}
|
||||
self.site_configuration.site_values = {"programs_without_certificates": [2]}
|
||||
self.site_configuration.save()
|
||||
mocked_get_current_site_configuration.return_value = self.site_configuration
|
||||
|
||||
@@ -222,21 +229,16 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
# program uuids are same as mock dates
|
||||
|
||||
@ddt.data(
|
||||
('credentials', 'enable_learner_issuance'),
|
||||
("credentials", "enable_learner_issuance"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_retry_if_config_disabled(
|
||||
self,
|
||||
disabled_config_type,
|
||||
disabled_config_attribute,
|
||||
*mock_helpers
|
||||
):
|
||||
def test_retry_if_config_disabled(self, disabled_config_type, disabled_config_attribute, *mock_helpers):
|
||||
"""
|
||||
Checks that the task is aborted if any relevant api configs are
|
||||
disabled.
|
||||
"""
|
||||
getattr(self, f'create_{disabled_config_type}_config')(**{disabled_config_attribute: False})
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.warning') as mock_warning:
|
||||
getattr(self, f"create_{disabled_config_type}_config")(**{disabled_config_attribute: False})
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.warning") as mock_warning:
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
assert mock_warning.called
|
||||
@@ -248,8 +250,8 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
Checks that the task will be aborted and not retried if the username
|
||||
passed was not found, and that an exception is logged.
|
||||
"""
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
tasks.award_program_certificates.delay('nonexistent-username').get()
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.exception") as mock_exception:
|
||||
tasks.award_program_certificates.delay("nonexistent-username").get()
|
||||
assert mock_exception.called
|
||||
for mock_helper in mock_helpers:
|
||||
assert not mock_helper.called
|
||||
@@ -270,13 +272,13 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
assert not mock_get_certified_programs.called
|
||||
assert not mock_award_program_certificate.called
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.site_configuration.helpers.get_value')
|
||||
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value")
|
||||
def test_programs_without_certificates(
|
||||
self,
|
||||
mock_get_value,
|
||||
mock_get_completed_programs,
|
||||
mock_get_certified_programs,
|
||||
mock_award_program_certificate
|
||||
mock_award_program_certificate,
|
||||
):
|
||||
"""
|
||||
Checks that the task will be aborted without further action if there exists a list
|
||||
@@ -289,22 +291,22 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
assert not mock_get_certified_programs.called
|
||||
assert not mock_award_program_certificate.called
|
||||
|
||||
@mock.patch(TASKS_MODULE + '.get_credentials_api_client')
|
||||
@mock.patch(TASKS_MODULE + ".get_credentials_api_client")
|
||||
def test_failure_to_create_api_client_retries(
|
||||
self,
|
||||
mock_get_api_client,
|
||||
mock_get_completed_programs,
|
||||
mock_get_certified_programs,
|
||||
mock_award_program_certificate
|
||||
mock_award_program_certificate,
|
||||
):
|
||||
"""
|
||||
Checks that we log an exception and retry if the API client isn't creating.
|
||||
"""
|
||||
mock_get_api_client.side_effect = Exception('boom')
|
||||
mock_get_api_client.side_effect = Exception("boom")
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_get_certified_programs.return_value = [2]
|
||||
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.exception") as mock_exception:
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
|
||||
@@ -347,33 +349,30 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
"""
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_get_certified_programs.side_effect = [[], [2]]
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect([Exception("boom"), None])
|
||||
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.info') as mock_info, \
|
||||
mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_warning:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.info") as mock_info, mock.patch(
|
||||
TASKS_MODULE + ".LOGGER.exception"
|
||||
) as mock_warning:
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
|
||||
assert mock_award_program_certificate.call_count == 3
|
||||
mock_warning.assert_called_once_with(
|
||||
'Failed to award certificate for program {uuid} to user {username}.'.format(
|
||||
uuid=1,
|
||||
username=self.student.username)
|
||||
"Failed to award certificate for program {uuid} to user {username}.".format(
|
||||
uuid=1, username=self.student.username
|
||||
)
|
||||
)
|
||||
mock_info.assert_any_call(f"Awarded certificate for program {1} to user {self.student.username}")
|
||||
mock_info.assert_any_call(f"Awarded certificate for program {2} to user {self.student.username}")
|
||||
|
||||
def test_retry_on_programs_api_errors(
|
||||
self,
|
||||
mock_get_completed_programs,
|
||||
*_mock_helpers
|
||||
):
|
||||
def test_retry_on_programs_api_errors(self, mock_get_completed_programs, *_mock_helpers):
|
||||
"""
|
||||
Ensures that any otherwise-unhandled errors that arise while trying
|
||||
to get completed programs (e.g. network issues or other
|
||||
transient API errors) will cause the task to be failed and queued for
|
||||
retry.
|
||||
"""
|
||||
mock_get_completed_programs.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
mock_get_completed_programs.side_effect = self._make_side_effect([Exception("boom"), None])
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
assert mock_get_completed_programs.call_count == 3
|
||||
|
||||
@@ -391,7 +390,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
"""
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_get_certified_programs.return_value = [1]
|
||||
mock_get_certified_programs.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
mock_get_certified_programs.side_effect = self._make_side_effect([Exception("boom"), None])
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
assert mock_get_certified_programs.call_count == 2
|
||||
assert mock_award_program_certificate.call_count == 1
|
||||
@@ -408,9 +407,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
exception = HTTPError()
|
||||
exception.response = mock.Mock(status_code=429)
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect([exception, None])
|
||||
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
|
||||
@@ -428,9 +425,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
exception = HTTPError()
|
||||
exception.response = mock.Mock(status_code=404)
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect([exception, None])
|
||||
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
|
||||
@@ -448,9 +443,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
exception = HTTPError()
|
||||
exception.response = mock.Mock(status_code=418)
|
||||
mock_get_completed_programs.return_value = {1: 1, 2: 2}
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
mock_award_program_certificate.side_effect = self._make_side_effect([exception, None])
|
||||
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
|
||||
@@ -464,30 +457,30 @@ class PostCourseCertificateTestCase(TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self): # lint-amnesty, pylint: disable=super-method-not-called
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
self.student = UserFactory.create(username="test-student")
|
||||
self.course = CourseOverviewFactory.create(
|
||||
self_paced=True # Any option to allow the certificate to be viewable for the course
|
||||
)
|
||||
self.certificate = GeneratedCertificateFactory(
|
||||
user=self.student,
|
||||
mode='verified',
|
||||
mode="verified",
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
status="downloadable",
|
||||
)
|
||||
|
||||
@httpretty.activate
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
|
||||
@mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url")
|
||||
def test_post_course_certificate(self, mock_get_api_base_url):
|
||||
"""
|
||||
Ensure the correct API call gets made
|
||||
"""
|
||||
mock_get_api_base_url.return_value = 'http://test-server/'
|
||||
mock_get_api_base_url.return_value = "http://test-server/"
|
||||
test_client = requests.Session()
|
||||
test_client.auth = SuppliedJwtAuth('test-token')
|
||||
test_client.auth = SuppliedJwtAuth("test-token")
|
||||
|
||||
httpretty.register_uri(
|
||||
httpretty.POST,
|
||||
'http://test-server/credentials/',
|
||||
"http://test-server/credentials/",
|
||||
)
|
||||
|
||||
visible_date = datetime.now()
|
||||
@@ -495,28 +488,33 @@ class PostCourseCertificateTestCase(TestCase):
|
||||
tasks.post_course_certificate(test_client, self.student.username, self.certificate, visible_date)
|
||||
|
||||
expected_body = {
|
||||
'username': self.student.username,
|
||||
'status': 'awarded',
|
||||
'credential': {
|
||||
'course_run_key': str(self.certificate.course_id),
|
||||
'mode': self.certificate.mode,
|
||||
'type': tasks.COURSE_CERTIFICATE,
|
||||
"username": self.student.username,
|
||||
"status": "awarded",
|
||||
"credential": {
|
||||
"course_run_key": str(self.certificate.course_id),
|
||||
"mode": self.certificate.mode,
|
||||
"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
|
||||
}]
|
||||
"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')
|
||||
last_request_body = httpretty.last_request().body.decode("utf-8")
|
||||
assert json.loads(last_request_body) == expected_body
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
@mock.patch("lms.djangoapps.certificates.api.auto_certificate_generation_enabled", mock.Mock(return_value=True))
|
||||
@mock.patch(TASKS_MODULE + '.post_course_certificate')
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
|
||||
@mock.patch(
|
||||
"lms.djangoapps.certificates.api.auto_certificate_generation_enabled",
|
||||
mock.Mock(return_value=True),
|
||||
)
|
||||
@mock.patch(TASKS_MODULE + ".post_course_certificate")
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME="test-service-username")
|
||||
class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Test the award_course_certificate celery task
|
||||
@@ -529,21 +527,21 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
self.course = CourseOverviewFactory.create(
|
||||
self_paced=True, # Any option to allow the certificate to be viewable for the course
|
||||
certificate_available_date=self.available_date,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
)
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
self.student = UserFactory.create(username="test-student")
|
||||
# Instantiate the Certificate first so that the config doesn't execute issuance
|
||||
self.certificate = GeneratedCertificateFactory.create(
|
||||
user=self.student,
|
||||
mode='verified',
|
||||
mode="verified",
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
status="downloadable",
|
||||
)
|
||||
|
||||
self.create_credentials_config()
|
||||
self.site = SiteFactory()
|
||||
|
||||
ApplicationFactory.create(name='credentials')
|
||||
ApplicationFactory.create(name="credentials")
|
||||
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
|
||||
|
||||
def _add_certificate_date_override(self):
|
||||
@@ -552,12 +550,12 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
self.certificate.date_override = CertificateDateOverrideFactory.create(
|
||||
generated_certificate=self.certificate,
|
||||
overridden_by=UserFactory.create(username='test-admin'),
|
||||
overridden_by=UserFactory.create(username="test-admin"),
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
'verified',
|
||||
'no-id-professional',
|
||||
"verified",
|
||||
"no-id-professional",
|
||||
)
|
||||
def test_award_course_certificates(self, mode, mock_post_course_certificate):
|
||||
"""
|
||||
@@ -600,7 +598,7 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
Test that the post method is never called if the config is disabled
|
||||
"""
|
||||
self.create_credentials_config(enabled=False)
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.warning') as mock_warning:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.warning") as mock_warning:
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.award_course_certificate.delay(self.student.username, str(self.course.id)).get()
|
||||
assert mock_warning.called
|
||||
@@ -610,9 +608,9 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Test that the post method is never called if the user isn't found by username
|
||||
"""
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.exception") as mock_exception:
|
||||
# Use a random username here since this user won't be found in the DB
|
||||
tasks.award_course_certificate.delay('random_username', str(self.course.id)).get()
|
||||
tasks.award_course_certificate.delay("random_username", str(self.course.id)).get()
|
||||
assert mock_exception.called
|
||||
assert not mock_post_course_certificate.called
|
||||
|
||||
@@ -621,7 +619,7 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
Test that the post method is never called if the certificate doesn't exist for the user and course
|
||||
"""
|
||||
self.certificate.delete()
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.exception") as mock_exception:
|
||||
tasks.award_course_certificate.delay(self.student.username, str(self.course.id)).get()
|
||||
assert mock_exception.called
|
||||
assert not mock_post_course_certificate.called
|
||||
@@ -631,7 +629,7 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
Test that the post method is never called if the CourseOverview isn't found
|
||||
"""
|
||||
self.course.delete()
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.exception") as mock_exception:
|
||||
# Use the certificate course id here since the course will be deleted
|
||||
tasks.award_course_certificate.delay(self.student.username, str(self.certificate.course_id)).get()
|
||||
assert mock_exception.called
|
||||
@@ -643,7 +641,7 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
# Temporarily disable the config so the signal isn't handled from .save
|
||||
self.create_credentials_config(enabled=False)
|
||||
self.certificate.mode = 'audit'
|
||||
self.certificate.mode = "audit"
|
||||
self.certificate.save()
|
||||
self.create_credentials_config()
|
||||
|
||||
@@ -658,41 +656,41 @@ class RevokeProgramCertificateTestCase(TestCase):
|
||||
"""
|
||||
|
||||
@httpretty.activate
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
|
||||
@mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url")
|
||||
def test_revoke_program_certificate(self, mock_get_api_base_url):
|
||||
"""
|
||||
Ensure the correct API call gets made
|
||||
"""
|
||||
mock_get_api_base_url.return_value = 'http://test-server/'
|
||||
test_username = 'test-username'
|
||||
mock_get_api_base_url.return_value = "http://test-server/"
|
||||
test_username = "test-username"
|
||||
test_client = requests.Session()
|
||||
test_client.auth = SuppliedJwtAuth('test-token')
|
||||
test_client.auth = SuppliedJwtAuth("test-token")
|
||||
|
||||
httpretty.register_uri(
|
||||
httpretty.POST,
|
||||
'http://test-server/credentials/',
|
||||
"http://test-server/credentials/",
|
||||
)
|
||||
|
||||
tasks.revoke_program_certificate(test_client, test_username, 123)
|
||||
|
||||
expected_body = {
|
||||
'username': test_username,
|
||||
'status': 'revoked',
|
||||
'credential': {
|
||||
'program_uuid': 123,
|
||||
'type': tasks.PROGRAM_CERTIFICATE,
|
||||
}
|
||||
"username": test_username,
|
||||
"status": "revoked",
|
||||
"credential": {
|
||||
"program_uuid": 123,
|
||||
"type": tasks.PROGRAM_CERTIFICATE,
|
||||
},
|
||||
}
|
||||
last_request_body = httpretty.last_request().body.decode('utf-8')
|
||||
last_request_body = httpretty.last_request().body.decode("utf-8")
|
||||
assert json.loads(last_request_body) == expected_body
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
@mock.patch(TASKS_MODULE + '.revoke_program_certificate')
|
||||
@mock.patch(TASKS_MODULE + '.get_certified_programs')
|
||||
@mock.patch(TASKS_MODULE + '.get_inverted_programs')
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
|
||||
@mock.patch(TASKS_MODULE + ".revoke_program_certificate")
|
||||
@mock.patch(TASKS_MODULE + ".get_certified_programs")
|
||||
@mock.patch(TASKS_MODULE + ".get_inverted_programs")
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME="test-service-username")
|
||||
class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Tests for the 'revoke_program_certificates' celery task.
|
||||
@@ -701,29 +699,24 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
self.course_key = 'course-v1:testX+test101+2T2020'
|
||||
self.student = UserFactory.create(username="test-student")
|
||||
self.course_key = "course-v1:testX+test101+2T2020"
|
||||
self.site = SiteFactory()
|
||||
self.site_configuration = SiteConfigurationFactory(site=self.site)
|
||||
ApplicationFactory.create(name='credentials')
|
||||
ApplicationFactory.create(name="credentials")
|
||||
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
|
||||
self.create_credentials_config()
|
||||
|
||||
self.inverted_programs = {self.course_key: [{'uuid': 1}, {'uuid': 2}]}
|
||||
self.inverted_programs = {self.course_key: [{"uuid": 1}, {"uuid": 2}]}
|
||||
|
||||
def _make_side_effect(self, side_effects):
|
||||
def _make_side_effect(self, side_effects, *args, **kwargs):
|
||||
"""
|
||||
DRY helper. Returns a side effect function for use with mocks that
|
||||
will be called multiple times, permitting Exceptions to be raised
|
||||
(or not) in a specified order.
|
||||
|
||||
See Also:
|
||||
http://www.voidspace.org.uk/python/mock/examples.html#multiple-calls-with-different-effects
|
||||
http://www.voidspace.org.uk/python/mock/mock.html#mock.Mock.side_effect
|
||||
|
||||
"""
|
||||
|
||||
def side_effect(*_a):
|
||||
def side_effect(*args, **kwargs):
|
||||
if side_effects:
|
||||
exc = side_effects.pop(0)
|
||||
if exc:
|
||||
@@ -756,9 +749,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
the proper programs.
|
||||
"""
|
||||
expected_program_uuid = 1
|
||||
mock_get_inverted_programs.return_value = {
|
||||
self.course_key: [{'uuid': expected_program_uuid}]
|
||||
}
|
||||
mock_get_inverted_programs.return_value = {self.course_key: [{"uuid": expected_program_uuid}]}
|
||||
mock_get_certified_programs.return_value = [expected_program_uuid]
|
||||
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
@@ -768,21 +759,16 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
assert call_args[2] == expected_program_uuid
|
||||
|
||||
@ddt.data(
|
||||
('credentials', 'enable_learner_issuance'),
|
||||
("credentials", "enable_learner_issuance"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_retry_if_config_disabled(
|
||||
self,
|
||||
disabled_config_type,
|
||||
disabled_config_attribute,
|
||||
*mock_helpers
|
||||
):
|
||||
def test_retry_if_config_disabled(self, disabled_config_type, disabled_config_attribute, *mock_helpers):
|
||||
"""
|
||||
Checks that the task is aborted if any relevant api configs are
|
||||
disabled.
|
||||
"""
|
||||
getattr(self, f'create_{disabled_config_type}_config')(**{disabled_config_attribute: False})
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.warning') as mock_warning:
|
||||
getattr(self, f"create_{disabled_config_type}_config")(**{disabled_config_attribute: False})
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.warning") as mock_warning:
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
assert mock_warning.called
|
||||
@@ -794,8 +780,8 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
Checks that the task will be aborted and not retried if the username
|
||||
passed was not found, and that an exception is logged.
|
||||
"""
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
tasks.revoke_program_certificates.delay('nonexistent-username', self.course_key).get()
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.exception") as mock_exception:
|
||||
tasks.revoke_program_certificates.delay("nonexistent-username", self.course_key).get()
|
||||
assert mock_exception.called
|
||||
for mock_helper in mock_helpers:
|
||||
assert not mock_helper.called
|
||||
@@ -830,17 +816,18 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
"""
|
||||
mock_get_inverted_programs.return_value = self.inverted_programs
|
||||
mock_get_certified_programs.side_effect = [[1], [1, 2]]
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect([Exception("boom"), None])
|
||||
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.info') as mock_info, \
|
||||
mock.patch(TASKS_MODULE + '.LOGGER.warning') as mock_warning:
|
||||
with mock.patch(TASKS_MODULE + ".LOGGER.info") as mock_info, mock.patch(
|
||||
TASKS_MODULE + ".LOGGER.warning"
|
||||
) as mock_warning:
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
|
||||
assert mock_revoke_program_certificate.call_count == 3
|
||||
mock_warning.assert_called_once_with(
|
||||
'Failed to revoke certificate for program {uuid} of user {username}.'.format(
|
||||
uuid=1,
|
||||
username=self.student.username)
|
||||
"Failed to revoke certificate for program {uuid} of user {username}.".format(
|
||||
uuid=1, username=self.student.username
|
||||
)
|
||||
)
|
||||
mock_info.assert_any_call(f"Revoked certificate for program {1} for user {self.student.username}")
|
||||
mock_info.assert_any_call(f"Revoked certificate for program {2} for user {self.student.username}")
|
||||
@@ -859,7 +846,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
"""
|
||||
mock_get_inverted_programs.return_value = self.inverted_programs
|
||||
mock_get_certified_programs.return_value = [1]
|
||||
mock_get_certified_programs.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
mock_get_certified_programs.side_effect = self._make_side_effect([Exception("boom"), None])
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
assert mock_get_certified_programs.call_count == 2
|
||||
assert mock_revoke_program_certificate.call_count == 1
|
||||
@@ -877,9 +864,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
exception.response = mock.Mock(status_code=429)
|
||||
mock_get_inverted_programs.return_value = self.inverted_programs
|
||||
mock_get_certified_programs.return_value = [1, 2]
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect([exception, None])
|
||||
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
|
||||
@@ -898,9 +883,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
exception.response = mock.Mock(status_code=404)
|
||||
mock_get_inverted_programs.return_value = self.inverted_programs
|
||||
mock_get_certified_programs.return_value = [1, 2]
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect([exception, None])
|
||||
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
|
||||
@@ -919,9 +902,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
exception.response = mock.Mock(status_code=418)
|
||||
mock_get_inverted_programs.return_value = self.inverted_programs
|
||||
mock_get_certified_programs.return_value = [1, 2]
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect(
|
||||
[exception, None]
|
||||
)
|
||||
mock_revoke_program_certificate.side_effect = self._make_side_effect([exception, None])
|
||||
|
||||
tasks.revoke_program_certificates.delay(self.student.username, self.course_key).get()
|
||||
|
||||
@@ -939,10 +920,8 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
mock_get_inverted_programs.return_value = self.inverted_programs
|
||||
mock_get_certified_programs.return_value = [1, 2]
|
||||
|
||||
with mock.patch(
|
||||
TASKS_MODULE + ".get_credentials_api_client"
|
||||
) as mock_get_api_client, mock.patch(
|
||||
TASKS_MODULE + '.LOGGER.exception'
|
||||
with mock.patch(TASKS_MODULE + ".get_credentials_api_client") as mock_get_api_client, mock.patch(
|
||||
TASKS_MODULE + ".LOGGER.exception"
|
||||
) as mock_exception:
|
||||
mock_get_api_client.side_effect = Exception("boom")
|
||||
with pytest.raises(MaxRetriesExceededError):
|
||||
@@ -953,36 +932,35 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME="test-service-username")
|
||||
class UpdateCredentialsCourseCertificateConfigurationAvailableDateTestCase(TestCase):
|
||||
"""
|
||||
Tests for the update_credentials_course_certificate_configuration_available_date function
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course = CourseOverviewFactory.create(
|
||||
certificate_available_date=datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
certificate_available_date=datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
)
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug='verified')
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug='audit')
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug="verified")
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug="audit")
|
||||
self.available_date = self.course.certificate_available_date
|
||||
self.course_id = self.course.id
|
||||
self.credentials_worker = UserFactory(username='test-service-username')
|
||||
self.credentials_worker = UserFactory(username="test-service-username")
|
||||
|
||||
def test_update_course_cert_available_date(self):
|
||||
with mock.patch(TASKS_MODULE + '.post_course_certificate_configuration') as update_posted:
|
||||
with mock.patch(TASKS_MODULE + ".post_course_certificate_configuration") as update_posted:
|
||||
tasks.update_credentials_course_certificate_configuration_available_date(
|
||||
self.course_id,
|
||||
self.available_date
|
||||
self.course_id, self.available_date
|
||||
)
|
||||
update_posted.assert_called_once()
|
||||
|
||||
def test_course_with_two_paid_modes(self):
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug='professional')
|
||||
with mock.patch(TASKS_MODULE + '.post_course_certificate_configuration') as update_posted:
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug="professional")
|
||||
with mock.patch(TASKS_MODULE + ".post_course_certificate_configuration") as update_posted:
|
||||
tasks.update_credentials_course_certificate_configuration_available_date(
|
||||
self.course_id,
|
||||
self.available_date
|
||||
self.course_id, self.available_date
|
||||
)
|
||||
update_posted.assert_not_called()
|
||||
|
||||
@@ -992,39 +970,40 @@ class PostCourseCertificateConfigurationTestCase(TestCase):
|
||||
"""
|
||||
Test the post_course_certificate_configuration function
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.certificate = {
|
||||
'mode': 'verified',
|
||||
'course_id': 'testCourse',
|
||||
"mode": "verified",
|
||||
"course_id": "testCourse",
|
||||
}
|
||||
|
||||
@httpretty.activate
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
|
||||
@mock.patch("openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url")
|
||||
def test_post_course_certificate_configuration(self, mock_get_api_base_url):
|
||||
"""
|
||||
Ensure the correct API call gets made
|
||||
"""
|
||||
mock_get_api_base_url.return_value = 'http://test-server/'
|
||||
mock_get_api_base_url.return_value = "http://test-server/"
|
||||
test_client = requests.Session()
|
||||
test_client.auth = SuppliedJwtAuth('test-token')
|
||||
test_client.auth = SuppliedJwtAuth("test-token")
|
||||
|
||||
httpretty.register_uri(
|
||||
httpretty.POST,
|
||||
'http://test-server/course_certificates/',
|
||||
"http://test-server/course_certificates/",
|
||||
)
|
||||
|
||||
available_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
available_date = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
tasks.post_course_certificate_configuration(test_client, self.certificate, available_date)
|
||||
|
||||
expected_body = {
|
||||
"course_id": 'testCourse',
|
||||
"certificate_type": 'verified',
|
||||
"course_id": "testCourse",
|
||||
"certificate_type": "verified",
|
||||
"certificate_available_date": available_date,
|
||||
"is_active": True
|
||||
"is_active": True,
|
||||
}
|
||||
last_request_body = httpretty.last_request().body.decode('utf-8')
|
||||
last_request_body = httpretty.last_request().body.decode("utf-8")
|
||||
assert json.loads(last_request_body) == expected_body
|
||||
|
||||
|
||||
@@ -1033,33 +1012,34 @@ class UpdateCertificateVisibleDatesOnCourseUpdateTestCase(CredentialsApiConfigMi
|
||||
"""
|
||||
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')
|
||||
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',
|
||||
mode="verified",
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
status="downloadable",
|
||||
)
|
||||
self.certificate_student2 = GeneratedCertificateFactory.create(
|
||||
user=self.student2,
|
||||
mode='verified',
|
||||
mode="verified",
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
status="downloadable",
|
||||
)
|
||||
self.certificate_student3 = GeneratedCertificateFactory.create(
|
||||
user=self.student3,
|
||||
mode='verified',
|
||||
mode="verified",
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
status="downloadable",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
@@ -1100,6 +1080,7 @@ class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigM
|
||||
"""
|
||||
Tests for the `update_certificate_available_date_on_course_update` task.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.credentials_api_config = self.create_credentials_config(enabled=False)
|
||||
@@ -1132,10 +1113,7 @@ class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigM
|
||||
self.credentials_api_config.enabled = True
|
||||
self.credentials_api_config.enable_learner_issuance = True
|
||||
|
||||
course = CourseOverviewFactory.create(
|
||||
self_paced=True,
|
||||
certificate_available_date=None
|
||||
)
|
||||
course = CourseOverviewFactory.create(self_paced=True, certificate_available_date=None)
|
||||
|
||||
with mock.patch(
|
||||
f"{TASKS_MODULE}.update_credentials_course_certificate_configuration_available_date.delay"
|
||||
@@ -1160,7 +1138,7 @@ class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigM
|
||||
course = CourseOverviewFactory.create(
|
||||
self_paced=False,
|
||||
certificate_available_date=available_date,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
@@ -1183,7 +1161,7 @@ class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigM
|
||||
course = CourseOverviewFactory.create(
|
||||
self_paced=False,
|
||||
certificate_available_date=available_date,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.EARLY_NO_INFO,
|
||||
)
|
||||
|
||||
expected_message = (
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from openedx.core.lib.cache_utils import zpickle, zunpickle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from requests import session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,9 +23,20 @@ def get_fields(fields, response):
|
||||
return results
|
||||
|
||||
|
||||
def get_api_data(api_config, resource, api_client, base_api_url, resource_id=None,
|
||||
querystring=None, cache_key=None, many=True,
|
||||
traverse_pagination=True, fields=None, long_term_cache=False):
|
||||
def get_api_data(
|
||||
api_config: Any,
|
||||
resource: str,
|
||||
api_client: "session",
|
||||
base_api_url: str,
|
||||
resource_id: Optional[Union[int, str]] = None,
|
||||
querystring: Optional[Dict[Any, Any]] = None,
|
||||
cache_key: Optional[str] = None,
|
||||
many: bool = True,
|
||||
traverse_pagination: bool = True,
|
||||
fields: Optional[List[Any]] = None,
|
||||
long_term_cache: bool = False,
|
||||
raise_on_error: bool = False,
|
||||
) -> Union[List[Any], Dict[Any, Any]]:
|
||||
"""
|
||||
GET data from an edX REST API endpoint using the API client.
|
||||
|
||||
@@ -43,21 +58,26 @@ def get_api_data(api_config, resource, api_client, base_api_url, resource_id=Non
|
||||
many (bool): Whether the resource requested is a collection of objects, or a single object.
|
||||
If false, an empty dict will be returned in cases of failure rather than the default empty list.
|
||||
traverse_pagination (bool): Whether to traverse pagination or return paginated response..
|
||||
fields (list): Return only specific fields from the response
|
||||
long_term_cache (bool): Whether to use the long term cache ttl or the standard cache ttl
|
||||
raise_on_error (bool): Reraise errors back to the caller, instead if returning empty results.
|
||||
|
||||
Returns:
|
||||
Data returned by the API. When hitting a list endpoint, extracts "results" (list of dict)
|
||||
returned by DRF-powered APIs.
|
||||
returned by DRF-powered APIs. By default, returns an empty result if the called API
|
||||
returns an error.
|
||||
"""
|
||||
no_data = [] if many else {}
|
||||
|
||||
if not api_config.enabled:
|
||||
log.warning('%s configuration is disabled.', api_config.API_NAME)
|
||||
log.warning("%s configuration is disabled.", api_config.API_NAME)
|
||||
return no_data
|
||||
|
||||
if cache_key:
|
||||
cache_key = f'{cache_key}.{resource_id}' if resource_id is not None else cache_key
|
||||
cache_key += '.zpickled'
|
||||
cache_key = (
|
||||
f"{cache_key}.{resource_id}" if resource_id is not None else cache_key
|
||||
)
|
||||
cache_key += ".zpickled"
|
||||
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
@@ -77,19 +97,23 @@ def get_api_data(api_config, resource, api_client, base_api_url, resource_id=Non
|
||||
querystring = querystring if querystring else {}
|
||||
api_url = urljoin(
|
||||
f"{base_api_url}/",
|
||||
f"{resource}/{str(resource_id) + '/' if resource_id is not None else ''}"
|
||||
f"{resource}/{str(resource_id) + '/' if resource_id is not None else ''}",
|
||||
)
|
||||
response = api_client.get(api_url, params=querystring)
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
|
||||
if resource_id is None and traverse_pagination:
|
||||
results = _traverse_pagination(response, api_client, api_url, querystring, no_data)
|
||||
results = _traverse_pagination(
|
||||
response, api_client, api_url, querystring, no_data
|
||||
)
|
||||
else:
|
||||
results = response
|
||||
|
||||
except: # pylint: disable=bare-except
|
||||
log.exception('Failed to retrieve data from the %s API.', api_config.API_NAME)
|
||||
log.exception("Failed to retrieve data from the %s API.", api_config.API_NAME)
|
||||
if raise_on_error:
|
||||
raise
|
||||
return no_data
|
||||
|
||||
if cache_key:
|
||||
@@ -111,17 +135,17 @@ def _traverse_pagination(response, api_client, api_url, querystring, no_data):
|
||||
|
||||
Extracts and concatenates "results" (list of dict) returned by DRF-powered APIs.
|
||||
"""
|
||||
results = response.get('results', no_data)
|
||||
results = response.get("results", no_data)
|
||||
|
||||
page = 1
|
||||
next_page = response.get('next')
|
||||
next_page = response.get("next")
|
||||
while next_page:
|
||||
page += 1
|
||||
querystring['page'] = page
|
||||
querystring["page"] = page
|
||||
response = api_client.get(api_url, params=querystring)
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
results += response.get('results', no_data)
|
||||
next_page = response.get('next')
|
||||
results += response.get("results", no_data)
|
||||
next_page = response.get("next")
|
||||
|
||||
return results
|
||||
|
||||
@@ -16,29 +16,36 @@ from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfi
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from openedx.core.lib.edx_api_utils import get_api_data
|
||||
|
||||
UTILITY_MODULE = 'openedx.core.lib.edx_api_utils'
|
||||
TEST_API_URL = 'http://www-internal.example.com/api'
|
||||
UTILITY_MODULE = "openedx.core.lib.edx_api_utils"
|
||||
TEST_API_URL = "http://www-internal.example.com/api"
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@httpretty.activate
|
||||
class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, CacheIsolationTestCase):
|
||||
class TestGetEdxApiData(
|
||||
CatalogIntegrationMixin, CredentialsApiConfigMixin, CacheIsolationTestCase
|
||||
):
|
||||
"""
|
||||
Tests for edX API data retrieval utility.
|
||||
"""
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
ENABLED_CACHES = ["default"]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user = UserFactory()
|
||||
self.base_api_url = CatalogIntegration.current().get_internal_api_url().strip('/')
|
||||
self.base_api_url = (
|
||||
CatalogIntegration.current().get_internal_api_url().strip("/")
|
||||
)
|
||||
|
||||
httpretty.httpretty.reset()
|
||||
cache.clear()
|
||||
|
||||
def _mock_catalog_api(self, responses, url=None):
|
||||
assert httpretty.is_enabled(), 'httpretty must be enabled to mock Catalog API calls.'
|
||||
assert (
|
||||
httpretty.is_enabled()
|
||||
), "httpretty must be enabled to mock Catalog API calls."
|
||||
|
||||
url = url if url else urljoin(f"{self.base_api_url}/", "programs/")
|
||||
|
||||
@@ -57,19 +64,22 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
catalog_integration = self.create_catalog_integration()
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
expected_collection = ['some', 'test', 'data']
|
||||
expected_collection = ["some", "test", "data"]
|
||||
data = {
|
||||
'next': None,
|
||||
'results': expected_collection,
|
||||
"next": None,
|
||||
"results": expected_collection,
|
||||
}
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(data), content_type='application/json')]
|
||||
[httpretty.Response(body=json.dumps(data), content_type="application/json")]
|
||||
)
|
||||
|
||||
with mock.patch('requests.Session') as mock_init:
|
||||
with mock.patch("requests.Session") as mock_init:
|
||||
actual_collection = get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
)
|
||||
|
||||
# Verify that the helper function didn't initialize its own client.
|
||||
@@ -86,25 +96,30 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
catalog_integration = self.create_catalog_integration()
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
expected_collection = ['some', 'test', 'data']
|
||||
expected_collection = ["some", "test", "data"]
|
||||
url = urljoin(f"{self.base_api_url}/", "/programs/?page={}")
|
||||
|
||||
responses = []
|
||||
for page, record in enumerate(expected_collection, start=1):
|
||||
data = {
|
||||
'next': url.format(page + 1) if page < len(expected_collection) else None,
|
||||
'results': [record],
|
||||
"next": url.format(page + 1)
|
||||
if page < len(expected_collection)
|
||||
else None,
|
||||
"results": [record],
|
||||
}
|
||||
|
||||
body = json.dumps(data)
|
||||
responses.append(
|
||||
httpretty.Response(body=body, content_type='application/json')
|
||||
httpretty.Response(body=body, content_type="application/json")
|
||||
)
|
||||
|
||||
self._mock_catalog_api(responses)
|
||||
|
||||
actual_collection = get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
)
|
||||
assert actual_collection == expected_collection
|
||||
|
||||
@@ -120,22 +135,31 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
url = urljoin(f"{self.base_api_url}/", "/programs/?page={}")
|
||||
responses = [
|
||||
{
|
||||
'next': url.format(2),
|
||||
'results': ['some'],
|
||||
"next": url.format(2),
|
||||
"results": ["some"],
|
||||
},
|
||||
{
|
||||
'next': url.format(None),
|
||||
'results': ['test'],
|
||||
"next": url.format(None),
|
||||
"results": ["test"],
|
||||
},
|
||||
]
|
||||
expected_response = responses[0]
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(body), content_type='application/json') for body in responses]
|
||||
[
|
||||
httpretty.Response(
|
||||
body=json.dumps(body), content_type="application/json"
|
||||
)
|
||||
for body in responses
|
||||
]
|
||||
)
|
||||
|
||||
actual_collection = get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, traverse_pagination=False
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
traverse_pagination=False,
|
||||
)
|
||||
assert actual_collection == expected_response
|
||||
self._assert_num_requests(1)
|
||||
@@ -150,15 +174,23 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
resource_id = 1
|
||||
url = urljoin(f"{self.base_api_url}/", f"programs/{resource_id}/")
|
||||
|
||||
expected_resource = {'key': 'value'}
|
||||
expected_resource = {"key": "value"}
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(expected_resource), content_type='application/json')],
|
||||
url=url
|
||||
[
|
||||
httpretty.Response(
|
||||
body=json.dumps(expected_resource), content_type="application/json"
|
||||
)
|
||||
],
|
||||
url=url,
|
||||
)
|
||||
|
||||
actual_resource = get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, resource_id=resource_id
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
assert actual_resource == expected_resource
|
||||
|
||||
@@ -180,15 +212,23 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
resource_id = 0
|
||||
url = urljoin(f"{self.base_api_url}/", f"programs/{resource_id}/")
|
||||
|
||||
expected_resource = {'key': 'value', 'results': []}
|
||||
expected_resource = {"key": "value", "results": []}
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(expected_resource), content_type='application/json')],
|
||||
url=url
|
||||
[
|
||||
httpretty.Response(
|
||||
body=json.dumps(expected_resource), content_type="application/json"
|
||||
)
|
||||
],
|
||||
url=url,
|
||||
)
|
||||
|
||||
actual_resource = get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, resource_id=resource_id
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
assert actual_resource == expected_resource
|
||||
|
||||
@@ -201,17 +241,21 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
catalog_integration = self.create_catalog_integration(cache_ttl=5)
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
response = {'lang': 'en', 'weeks_to_complete': '5'}
|
||||
response = {"lang": "en", "weeks_to_complete": "5"}
|
||||
|
||||
resource_id = 'course-v1:testX+testABC+1T2019'
|
||||
resource_id = "course-v1:testX+testABC+1T2019"
|
||||
url = urljoin(f"{self.base_api_url}/", f"course_runs/{resource_id}/")
|
||||
|
||||
expected_resource_for_lang = {'lang': 'en'}
|
||||
expected_resource_for_weeks_to_complete = {'weeks_to_complete': '5'}
|
||||
expected_resource_for_lang = {"lang": "en"}
|
||||
expected_resource_for_weeks_to_complete = {"weeks_to_complete": "5"}
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(response), content_type='application/json')],
|
||||
url=url
|
||||
[
|
||||
httpretty.Response(
|
||||
body=json.dumps(response), content_type="application/json"
|
||||
)
|
||||
],
|
||||
url=url,
|
||||
)
|
||||
|
||||
cache_key = CatalogIntegration.current().CACHE_KEY
|
||||
@@ -219,24 +263,24 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
# get response and set the cache.
|
||||
actual_resource_for_lang = get_api_data(
|
||||
catalog_integration,
|
||||
'course_runs',
|
||||
"course_runs",
|
||||
resource_id=resource_id,
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
cache_key=cache_key,
|
||||
fields=['lang']
|
||||
fields=["lang"],
|
||||
)
|
||||
assert actual_resource_for_lang == expected_resource_for_lang
|
||||
|
||||
# Hit the cache
|
||||
actual_resource = get_api_data(
|
||||
catalog_integration,
|
||||
'course_runs',
|
||||
"course_runs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=resource_id,
|
||||
cache_key=cache_key,
|
||||
fields=['weeks_to_complete']
|
||||
fields=["weeks_to_complete"],
|
||||
)
|
||||
|
||||
assert actual_resource == expected_resource_for_weeks_to_complete
|
||||
@@ -251,73 +295,94 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
catalog_integration = self.create_catalog_integration(cache_ttl=5)
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
expected_collection = ['some', 'test', 'data']
|
||||
expected_collection = ["some", "test", "data"]
|
||||
data = {
|
||||
'next': None,
|
||||
'results': expected_collection,
|
||||
"next": None,
|
||||
"results": expected_collection,
|
||||
}
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(data), content_type='application/json')],
|
||||
[
|
||||
httpretty.Response(
|
||||
body=json.dumps(data), content_type="application/json"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
resource_id = 1
|
||||
url = urljoin(f"{self.base_api_url}/", f"programs/{resource_id}/")
|
||||
|
||||
expected_resource = {'key': 'value'}
|
||||
expected_resource = {"key": "value"}
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body=json.dumps(expected_resource), content_type='application/json')],
|
||||
url=url
|
||||
[
|
||||
httpretty.Response(
|
||||
body=json.dumps(expected_resource), content_type="application/json"
|
||||
)
|
||||
],
|
||||
url=url,
|
||||
)
|
||||
|
||||
cache_key = CatalogIntegration.current().CACHE_KEY
|
||||
|
||||
# Warm up the cache.
|
||||
get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, cache_key=cache_key
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
cache_key=cache_key,
|
||||
)
|
||||
get_api_data(
|
||||
catalog_integration,
|
||||
'programs',
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=resource_id,
|
||||
cache_key=cache_key
|
||||
cache_key=cache_key,
|
||||
)
|
||||
|
||||
# Hit the cache.
|
||||
actual_collection = get_api_data(
|
||||
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, cache_key=cache_key
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
cache_key=cache_key,
|
||||
)
|
||||
assert actual_collection == expected_collection
|
||||
|
||||
actual_resource = get_api_data(
|
||||
catalog_integration,
|
||||
'programs',
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=resource_id,
|
||||
cache_key=cache_key
|
||||
cache_key=cache_key,
|
||||
)
|
||||
assert actual_resource == expected_resource
|
||||
|
||||
# Verify that only two requests were made, not four.
|
||||
self._assert_num_requests(2)
|
||||
|
||||
@mock.patch(UTILITY_MODULE + '.log.warning')
|
||||
@mock.patch(UTILITY_MODULE + ".log.warning")
|
||||
def test_api_config_disabled(self, mock_warning):
|
||||
"""
|
||||
Verify that no data is retrieved if the provided config model is disabled.
|
||||
"""
|
||||
catalog_integration = self.create_catalog_integration(enabled=False)
|
||||
|
||||
actual = get_api_data(catalog_integration, 'programs', api_client=None, base_api_url=self.base_api_url)
|
||||
actual = get_api_data(
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=None,
|
||||
base_api_url=self.base_api_url,
|
||||
)
|
||||
|
||||
assert mock_warning.called
|
||||
assert actual == []
|
||||
|
||||
@mock.patch(UTILITY_MODULE + '.log.exception')
|
||||
@mock.patch(UTILITY_MODULE + ".log.exception")
|
||||
def test_data_retrieval_failure(self, mock_exception):
|
||||
"""
|
||||
Verify that an exception is logged when data can't be retrieved.
|
||||
@@ -326,15 +391,24 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body='clunk', content_type='application/json', status_code=500)]
|
||||
[
|
||||
httpretty.Response(
|
||||
body="clunk", content_type="application/json", status_code=500
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
actual = get_api_data(catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url)
|
||||
actual = get_api_data(
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
)
|
||||
|
||||
assert mock_exception.called
|
||||
assert actual == []
|
||||
|
||||
@mock.patch(UTILITY_MODULE + '.log.warning')
|
||||
@mock.patch(UTILITY_MODULE + ".log.warning")
|
||||
def test_api_config_disabled_with_id_and_not_collection(self, mock_warning):
|
||||
"""
|
||||
Verify that no data is retrieved if the provided config model is disabled.
|
||||
@@ -343,17 +417,17 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
|
||||
actual = get_api_data(
|
||||
catalog_integration,
|
||||
'programs',
|
||||
"programs",
|
||||
api_client=None,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=100,
|
||||
many=False
|
||||
many=False,
|
||||
)
|
||||
|
||||
assert mock_warning.called
|
||||
assert actual == {}
|
||||
|
||||
@mock.patch(UTILITY_MODULE + '.log.exception')
|
||||
@mock.patch(UTILITY_MODULE + ".log.exception")
|
||||
def test_data_retrieval_failure_with_id(self, mock_exception):
|
||||
"""
|
||||
Verify that an exception is logged when data can't be retrieved.
|
||||
@@ -362,16 +436,52 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
self._mock_catalog_api(
|
||||
[httpretty.Response(body='clunk', content_type='application/json', status_code=500)]
|
||||
[
|
||||
httpretty.Response(
|
||||
body="clunk", content_type="application/json", status_code=500
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
actual = get_api_data(
|
||||
catalog_integration,
|
||||
'programs',
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=100,
|
||||
many=False
|
||||
many=False,
|
||||
)
|
||||
assert mock_exception.called
|
||||
assert actual == {}
|
||||
|
||||
def test_data_retrieval_raise_on_error(self):
|
||||
"""
|
||||
Verify that when raise_on_error is set and the API call gives an error response,
|
||||
we raise HttpError.
|
||||
"""
|
||||
catalog_integration = self.create_catalog_integration()
|
||||
api = get_catalog_api_client(self.user)
|
||||
|
||||
url = urljoin(f"{self.base_api_url}/", "/programs/100")
|
||||
|
||||
self._mock_catalog_api(
|
||||
[
|
||||
httpretty.Response(
|
||||
body="clunk",
|
||||
content_type="application/json",
|
||||
status_code=500,
|
||||
url=url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
get_api_data(
|
||||
catalog_integration,
|
||||
"programs",
|
||||
api_client=api,
|
||||
base_api_url=self.base_api_url,
|
||||
resource_id=100,
|
||||
many=False,
|
||||
raise_on_error=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user