diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index ec790a4315c1..b7fe8aaca6b6 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -7,10 +7,8 @@ certificates models or any other certificates modules. """ - import logging from datetime import datetime -from pytz import UTC from django.conf import settings from django.contrib.auth import get_user_model @@ -18,18 +16,18 @@ from django.db.models import Q from eventtracking import tracker from opaque_keys.edx.django.models import CourseKeyField +from opaque_keys.edx.keys import CourseKey from organizations.api import get_course_organization_id +from pytz import UTC from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.api import is_user_enrolled_in_course from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.branding import api as branding_api -from lms.djangoapps.certificates.generation_handler import ( - generate_certificate_task as _generate_certificate_task, - is_on_certificate_allowlist as _is_on_certificate_allowlist -) from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION as _AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.data import CertificateStatuses +from lms.djangoapps.certificates.generation_handler import generate_certificate_task as _generate_certificate_task +from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist as _is_on_certificate_allowlist from lms.djangoapps.certificates.models import ( CertificateAllowlist, CertificateDateOverride, @@ -41,16 +39,13 @@ ExampleCertificateSet, GeneratedCertificate, ) -from lms.djangoapps.certificates.utils import ( - get_certificate_url as _get_certificate_url, - has_html_certificates_enabled as _has_html_certificates_enabled, - should_certificate_be_visible as _should_certificate_be_visible, - certificate_status as _certificate_status, - certificate_status_for_student as _certificate_status_for_student, -) +from lms.djangoapps.certificates.utils import certificate_status as _certificate_status +from lms.djangoapps.certificates.utils import certificate_status_for_student as _certificate_status_for_student +from lms.djangoapps.certificates.utils import get_certificate_url as _get_certificate_url +from lms.djangoapps.certificates.utils import has_html_certificates_enabled as _has_html_certificates_enabled +from lms.djangoapps.certificates.utils import should_certificate_be_visible as _should_certificate_be_visible from lms.djangoapps.instructor import access from lms.djangoapps.utils import _get_key -from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -83,8 +78,8 @@ def _format_certificate_for_user(username, cert): "is_passing": CertificateStatuses.is_passing_status(cert.status), "is_pdf_certificate": bool(cert.download_url), "download_url": ( - cert.download_url or get_certificate_url(cert.user.id, cert.course_id, uuid=cert.verify_uuid, - user_certificate=cert) + cert.download_url + or get_certificate_url(cert.user.id, cert.course_id, uuid=cert.verify_uuid, user_certificate=cert) if cert.status == CertificateStatuses.downloadable else None ), @@ -139,10 +134,7 @@ def get_certificate_for_user(username, course_key, format_results=True): the GeneratedCertificate object itself. """ try: - cert = GeneratedCertificate.eligible_certificates.get( - user__username=username, - course_id=course_key - ) + cert = GeneratedCertificate.eligible_certificates.get(user__username=username, course_id=course_key) except GeneratedCertificate.DoesNotExist: return None @@ -178,13 +170,8 @@ def get_certificates_for_user_by_course_keys(user, course_keys): Course keys for courses for which the user does not have a certificate will be omitted. """ - certs = GeneratedCertificate.eligible_certificates.filter( - user=user, course_id__in=course_keys - ) - return { - cert.course_id: _format_certificate_for_user(user.username, cert) - for cert in certs - } + certs = GeneratedCertificate.eligible_certificates.filter(user=user, course_id__in=course_keys) + return {cert.course_id: _format_certificate_for_user(user.username, cert) for cert in certs} def get_recently_modified_certificates(course_keys=None, start_date=None, end_date=None, user_ids=None): @@ -195,30 +182,28 @@ def get_recently_modified_certificates(course_keys=None, start_date=None, end_da cert_filter_args = {} if course_keys: - cert_filter_args['course_id__in'] = course_keys + cert_filter_args["course_id__in"] = course_keys if start_date: - cert_filter_args['modified_date__gte'] = start_date + cert_filter_args["modified_date__gte"] = start_date if end_date: - cert_filter_args['modified_date__lte'] = end_date + cert_filter_args["modified_date__lte"] = end_date if user_ids: - cert_filter_args['user__id__in'] = user_ids + cert_filter_args["user__id__in"] = user_ids # Include certificates with a CertificateDateOverride modified within the # given time range. if start_date or end_date: certs_with_modified_overrides = get_certs_with_modified_overrides(course_keys, start_date, end_date, user_ids) - return GeneratedCertificate.objects.filter( - **cert_filter_args - ).union( - certs_with_modified_overrides - ).order_by( - 'modified_date' + return ( + GeneratedCertificate.objects.filter(**cert_filter_args) + .union(certs_with_modified_overrides) + .order_by("modified_date") ) - return GeneratedCertificate.objects.filter(**cert_filter_args).order_by('modified_date') + return GeneratedCertificate.objects.filter(**cert_filter_args).order_by("modified_date") def get_certs_with_modified_overrides(course_keys=None, start_date=None, end_date=None, user_ids=None): @@ -229,9 +214,9 @@ def get_certs_with_modified_overrides(course_keys=None, start_date=None, end_dat """ override_filter_args = {} if start_date: - override_filter_args['history_date__gte'] = start_date + override_filter_args["history_date__gte"] = start_date if end_date: - override_filter_args['history_date__lte'] = end_date + override_filter_args["history_date__lte"] = end_date # Get the HistoricalCertificateDateOverrides that have entries within the # given date range. We check the history table to catch deleted overrides @@ -239,16 +224,16 @@ def get_certs_with_modified_overrides(course_keys=None, start_date=None, end_dat overrides = CertificateDateOverride.history.filter(**override_filter_args) # Get the associated GeneratedCertificate ids. - override_cert_ids = overrides.values_list('generated_certificate', flat=True) + override_cert_ids = overrides.values_list("generated_certificate", flat=True) # Build the args for the GeneratedCertificate query. First, filter by all # certs identified in override_cert_ids; then by the other arguments passed, # if present. - cert_filter_args = {'pk__in': override_cert_ids} + cert_filter_args = {"pk__in": override_cert_ids} if course_keys: - cert_filter_args['course_id__in'] = course_keys + cert_filter_args["course_id__in"] = course_keys if user_ids: - cert_filter_args['user__id__in'] = user_ids + cert_filter_args["user__id__in"] = user_ids return GeneratedCertificate.objects.filter(**cert_filter_args) @@ -287,14 +272,17 @@ def certificate_downloadable_status(student, course_key): # If the certificate status is an error user should view that status is "generating". # On the back-end, need to monitor those errors and re-submit the task. + # pylint: disable=simplifiable-if-expression response_data = { - 'is_downloadable': False, - 'is_generating': True if current_status['status'] in [CertificateStatuses.generating, # pylint: disable=simplifiable-if-expression - CertificateStatuses.error] else False, - 'is_unverified': True if current_status['status'] == CertificateStatuses.unverified else False, # pylint: disable=simplifiable-if-expression - 'download_url': None, - 'uuid': None, + "is_downloadable": False, + "is_generating": ( + True if current_status["status"] in [CertificateStatuses.generating, CertificateStatuses.error] else False + ), + "is_unverified": (True if current_status["status"] == CertificateStatuses.unverified else False), + "download_url": None, + "uuid": None, } + # pylint: enable=simplifiable-if-expression course_overview = get_course_overview_or_none(course_key) @@ -306,28 +294,28 @@ def certificate_downloadable_status(student, course_key): display_behavior_is_valid = True if ( - not certificates_viewable_for_course(course_overview) and - CertificateStatuses.is_passing_status(current_status['status']) and - display_behavior_is_valid and - course_overview.certificate_available_date + not certificates_viewable_for_course(course_overview) + and CertificateStatuses.is_passing_status(current_status["status"]) + and display_behavior_is_valid + and course_overview.certificate_available_date ): - response_data['earned_but_not_available'] = True - response_data['certificate_available_date'] = course_overview.certificate_available_date + response_data["earned_but_not_available"] = True + response_data["certificate_available_date"] = course_overview.certificate_available_date may_view_certificate = _should_certificate_be_visible( course_overview.certificates_display_behavior, course_overview.certificates_show_before_end, course_overview.has_ended(), course_overview.certificate_available_date, - course_overview.self_paced + course_overview.self_paced, ) - if current_status['status'] == CertificateStatuses.downloadable and may_view_certificate: - response_data['is_downloadable'] = True - response_data['download_url'] = current_status['download_url'] or get_certificate_url( - student.id, course_key, current_status['uuid'] + if current_status["status"] == CertificateStatuses.downloadable and may_view_certificate: + response_data["is_downloadable"] = True + response_data["download_url"] = current_status["download_url"] or get_certificate_url( + student.id, course_key, current_status["uuid"] ) - response_data['is_pdf_certificate'] = bool(current_status['download_url']) - response_data['uuid'] = current_status['uuid'] + response_data["is_pdf_certificate"] = bool(current_status["download_url"]) + response_data["uuid"] = current_status["uuid"] return response_data @@ -356,11 +344,14 @@ def set_cert_generation_enabled(course_key, is_enabled): """ CertificateGenerationCourseSetting.set_self_generation_enabled_for_course(course_key, is_enabled) - cert_event_type = 'enabled' if is_enabled else 'disabled' - event_name = '.'.join(['edx', 'certificate', 'generation', cert_event_type]) - tracker.emit(event_name, { - 'course_id': str(course_key), - }) + cert_event_type = "enabled" if is_enabled else "disabled" + event_name = ".".join(["edx", "certificate", "generation", cert_event_type]) + tracker.emit( + event_name, + { + "course_id": str(course_key), + }, + ) if is_enabled: log.info("Enabled self-generated certificates for course '%s'.", str(course_key)) else: @@ -407,8 +398,8 @@ def has_self_generated_certificates_enabled(course_key): """ return ( - CertificateGenerationConfiguration.current().enabled and - CertificateGenerationCourseSetting.is_self_generation_enabled_for_course(course_key) + CertificateGenerationConfiguration.current().enabled + and CertificateGenerationCourseSetting.is_self_generation_enabled_for_course(course_key) ) @@ -458,10 +449,10 @@ def get_active_web_certificate(course, is_preview_mode=None): """ Retrieves the active web certificate configuration for the specified course """ - certificates = getattr(course, 'certificates', {}) - configurations = certificates.get('certificates', []) + certificates = getattr(course, "certificates", {}) + configurations = certificates.get("certificates", []) for config in configurations: - if config.get('is_active') or is_preview_mode: + if config.get("is_active") or is_preview_mode: return config return None @@ -478,32 +469,19 @@ def get_certificate_template(course_key, mode, language): active_templates = CertificateTemplate.objects.filter(is_active=True) if org_id and mode: # get template by org, mode, and key - org_mode_and_key_templates = active_templates.filter( - organization_id=org_id, - mode=mode, - course_key=course_key - ) + org_mode_and_key_templates = active_templates.filter(organization_id=org_id, mode=mode, course_key=course_key) template = _get_language_specific_template_or_default(language, org_mode_and_key_templates) # since no template matched that course_key, only consider templates with empty course_key empty_course_key_templates = active_templates.filter(course_key=CourseKeyField.Empty) if not template and org_id and mode: # get template by org and mode - org_and_mode_templates = empty_course_key_templates.filter( - organization_id=org_id, - mode=mode - ) + org_and_mode_templates = empty_course_key_templates.filter(organization_id=org_id, mode=mode) template = _get_language_specific_template_or_default(language, org_and_mode_templates) if not template and org_id: # get template by only org - org_templates = empty_course_key_templates.filter( - organization_id=org_id, - mode=None - ) + org_templates = empty_course_key_templates.filter(organization_id=org_id, mode=None) template = _get_language_specific_template_or_default(language, org_templates) if not template and mode: # get template by only mode - mode_templates = empty_course_key_templates.filter( - organization_id=None, - mode=mode - ) + mode_templates = empty_course_key_templates.filter(organization_id=None, mode=mode) template = _get_language_specific_template_or_default(language, mode_templates) return template if template else None @@ -515,10 +493,10 @@ def _get_language_specific_template_or_default(language, templates): """ two_letter_language = _get_two_letter_language_code(language) - language_or_default_templates = list(templates.filter(Q(language=two_letter_language) - | Q(language=None) | Q(language=''))) - language_specific_template = _get_language_specific_template(two_letter_language, - language_or_default_templates) + language_or_default_templates = list( + templates.filter(Q(language=two_letter_language) | Q(language=None) | Q(language="")) + ) + language_specific_template = _get_language_specific_template(two_letter_language, language_or_default_templates) if language_specific_template: return language_specific_template else: @@ -537,7 +515,7 @@ def _get_all_languages_or_default_template(templates): Returns the first template that isn't language specific """ for template in templates: - if template.language == '': + if template.language == "": return template return templates[0] if templates else None @@ -550,8 +528,8 @@ def _get_two_letter_language_code(language_code): """ if language_code is None: return None - elif language_code == '': - return '' + elif language_code == "": + return "" else: return language_code[:2] @@ -560,7 +538,7 @@ def get_asset_url_by_slug(asset_slug): """ Returns certificate template asset url for given asset_slug. """ - asset_url = '' + asset_url = "" try: template_asset = CertificateTemplateAsset.objects.get(asset_slug=asset_slug) asset_url = template_asset.asset.url @@ -592,17 +570,17 @@ def get_certificate_footer_context(): # get Terms of Service and Honor Code page url terms_of_service_and_honor_code = branding_api.get_tos_and_honor_code_url() if terms_of_service_and_honor_code != branding_api.EMPTY_URL: - data.update({'company_tos_url': terms_of_service_and_honor_code}) + data.update({"company_tos_url": terms_of_service_and_honor_code}) # get Privacy Policy page url privacy_policy = branding_api.get_privacy_url() if privacy_policy != branding_api.EMPTY_URL: - data.update({'company_privacy_url': privacy_policy}) + data.update({"company_privacy_url": privacy_policy}) # get About page url about = branding_api.get_about_url() if about != branding_api.EMPTY_URL: - data.update({'company_about_url': about}) + data.update({"company_about_url": about}) return data @@ -631,7 +609,7 @@ def certificates_viewable_for_course(course): course.certificates_show_before_end, course.has_ended(), course.certificate_available_date, - course.self_paced + course.self_paced, ) @@ -650,9 +628,9 @@ def create_or_update_certificate_allowlist_entry(user, course_key, notes, enable user=user, course_id=course_key, defaults={ - 'allowlist': enabled, - 'notes': notes, - } + "allowlist": enabled, + "notes": notes, + }, ) log.info(f"Updated the allowlist of course {course_key} with student {user.id} and enabled={enabled}") @@ -675,7 +653,7 @@ def remove_allowlist_entry(user, course_key): certificate = get_certificate_for_user(user.username, course_key, False) if certificate: log.info(f"Invalidating certificate for student {user.id} in course {course_key} before allowlist removal.") - certificate.invalidate(source='allowlist_removal') + certificate.invalidate(source="allowlist_removal") log.info(f"Removing student {user.id} from the allowlist in course {course_key}.") allowlist_entry.delete() @@ -735,10 +713,10 @@ def create_certificate_invalidation_entry(certificate, user_requesting_invalidat certificate_invalidation, __ = CertificateInvalidation.objects.update_or_create( generated_certificate=certificate, defaults={ - 'active': True, - 'invalidated_by': user_requesting_invalidation, - 'notes': notes, - } + "active": True, + "invalidated_by": user_requesting_invalidation, + "notes": notes, + }, ) return certificate_invalidation @@ -772,10 +750,7 @@ def get_enrolled_allowlisted_users(course_key): - are allowlisted in this course run """ users = CourseEnrollment.objects.users_enrolled_in(course_key) - return users.filter( - certificateallowlist__course_id=course_key, - certificateallowlist__allowlist=True - ) + return users.filter(certificateallowlist__course_id=course_key, certificateallowlist__allowlist=True) def get_enrolled_allowlisted_not_passing_users(course_key): @@ -787,8 +762,7 @@ def get_enrolled_allowlisted_not_passing_users(course_key): """ users = get_enrolled_allowlisted_users(course_key) return users.exclude( - generatedcertificate__course_id=course_key, - generatedcertificate__status__in=CertificateStatuses.PASSED_STATUSES + generatedcertificate__course_id=course_key, generatedcertificate__status__in=CertificateStatuses.PASSED_STATUSES ) @@ -796,10 +770,10 @@ def certificate_info_for_user(user, course_id, grade, user_is_allowlisted, user_ """ Returns the certificate info for a user for grade report. """ - certificate_is_delivered = 'N' - certificate_type = 'N/A' + certificate_is_delivered = "N" + certificate_type = "N/A" status = _certificate_status(user_certificate) - certificate_generated = status['status'] == CertificateStatuses.downloadable + certificate_generated = status["status"] == CertificateStatuses.downloadable course_overview = get_course_overview_or_none(course_id) if not course_overview: return None @@ -808,18 +782,17 @@ def certificate_info_for_user(user, course_id, grade, user_is_allowlisted, user_ course_overview.certificates_show_before_end, course_overview.has_ended(), course_overview.certificate_available_date, - course_overview.self_paced + course_overview.self_paced, ) enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_id) mode_is_verified = enrollment_mode in CourseMode.VERIFIED_MODES user_is_verified = grade is not None and mode_is_verified - eligible_for_certificate = 'Y' if (user_is_allowlisted or user_is_verified or certificate_generated) \ - else 'N' + eligible_for_certificate = "Y" if (user_is_allowlisted or user_is_verified or certificate_generated) else "N" if certificate_generated and can_have_certificate: - certificate_is_delivered = 'Y' - certificate_type = status['mode'] + certificate_is_delivered = "Y" + certificate_type = status["mode"] return [eligible_for_certificate, certificate_is_delivered, certificate_type] @@ -854,11 +827,11 @@ def can_show_certificate_message(course, student, course_grade, certificates_ena has_passed_or_is_allowlisted = _has_passed_or_is_allowlisted(course, student, course_grade) return ( - (auto_cert_gen_enabled or certificates_enabled_for_course) and - has_active_enrollment and - certificates_are_viewable and - has_passed_or_is_allowlisted and - (not is_beta_tester) + (auto_cert_gen_enabled or certificates_enabled_for_course) + and has_active_enrollment + and certificates_are_viewable + and has_passed_or_is_allowlisted + and (not is_beta_tester) ) @@ -935,20 +908,15 @@ def invalidate_certificate(user_id, course_key_or_id, source): """ course_key = _get_key(course_key_or_id, CourseKey) if _is_on_certificate_allowlist(user_id, course_key): - log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.') + log.info(f"User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.") return False try: - generated_certificate = GeneratedCertificate.objects.get( - user=user_id, - course_id=course_key - ) + generated_certificate = GeneratedCertificate.objects.get(user=user_id, course_id=course_key) generated_certificate.invalidate(source=source) except ObjectDoesNotExist: log.warning( - 'Invalidation failed because a certificate for user %d in course %s does not exist.', - user_id, - course_key + "Invalidation failed because a certificate for user %d in course %s does not exist.", user_id, course_key ) return False diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index c766d4250bd9..21d04a7e3da6 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -1,6 +1,5 @@ """Tests for the certificates Python API. """ - import uuid from contextlib import contextmanager from datetime import datetime, timedelta @@ -20,17 +19,10 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from testfixtures import LogCapture -from xmodule.data import CertificatesDisplayBehaviors -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.tests.factories import ( - CourseEnrollmentFactory, - GlobalStaffFactory, - UserFactory -) +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, GlobalStaffFactory, UserFactory from common.djangoapps.util.testing import EventTestMixin from lms.djangoapps.certificates.api import ( auto_certificate_generation_enabled, @@ -38,8 +30,8 @@ can_be_added_to_allowlist, can_show_certificate_available_date_field, can_show_certificate_message, - certificate_status_for_student, certificate_downloadable_status, + certificate_status_for_student, clear_pii_from_certificate_records_for_user, create_certificate_invalidation_entry, create_or_update_certificate_allowlist_entry, @@ -69,41 +61,45 @@ ) from lms.djangoapps.certificates.tests.factories import ( CertificateAllowlistFactory, + CertificateInvalidationFactory, GeneratedCertificateFactory, - CertificateInvalidationFactory ) from lms.djangoapps.certificates.tests.test_generation_handler import ID_VERIFIED_METHOD, PASSING_GRADE_METHOD from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration +from xmodule.data import CertificatesDisplayBehaviors +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory -CAN_GENERATE_METHOD = 'lms.djangoapps.certificates.generation_handler._can_generate_regular_certificate' -BETA_TESTER_METHOD = 'lms.djangoapps.certificates.api.access.is_beta_tester' -CERTS_VIEWABLE_METHOD = 'lms.djangoapps.certificates.api.certificates_viewable_for_course' -PASSED_OR_ALLOWLISTED_METHOD = 'lms.djangoapps.certificates.api._has_passed_or_is_allowlisted' +CAN_GENERATE_METHOD = "lms.djangoapps.certificates.generation_handler._can_generate_regular_certificate" +BETA_TESTER_METHOD = "lms.djangoapps.certificates.api.access.is_beta_tester" +CERTS_VIEWABLE_METHOD = "lms.djangoapps.certificates.api.certificates_viewable_for_course" +PASSED_OR_ALLOWLISTED_METHOD = "lms.djangoapps.certificates.api._has_passed_or_is_allowlisted" FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() -FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True +FEATURES_WITH_CERTS_ENABLED["CERTIFICATES_HTML_VIEW"] = True class WebCertificateTestMixin: """ Mixin with helpers for testing Web Certificates. """ + def _setup_course_certificate(self): """ Creates certificate configuration for course """ certificates = [ { - 'id': 1, - 'name': 'Test Certificate Name', - 'description': 'Test Certificate Description', - 'course_title': 'tes_course_title', - 'signatories': [], - 'version': 1, - 'is_active': True + "id": 1, + "name": "Test Certificate Name", + "description": "Test Certificate Description", + "course_title": "tes_course_title", + "signatories": [], + "version": 1, + "is_active": True, } ] - self.course.certificates = {'certificates': certificates} + self.course.certificates = {"certificates": certificates} self.course.cert_html_view_enabled = True self.course.save() self.store.update_item(self.course, self.user.id) @@ -111,8 +107,9 @@ def _setup_course_certificate(self): @ddt.ddt class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTestCase): - """Tests for the `certificate_downloadable_status` helper function. """ - ENABLED_SIGNALS = ['course_published'] + """Tests for the `certificate_downloadable_status` helper function.""" + + ENABLED_SIGNALS = ["course_published"] def setUp(self): super().setUp() @@ -120,19 +117,16 @@ def setUp(self): self.student = UserFactory() self.student_no_cert = UserFactory() self.course = CourseFactory.create( - org='edx', - number='verified', - display_name='Verified Course', + org="edx", + number="verified", + display_name="Verified Course", end=datetime.now(pytz.UTC), self_paced=False, - certificate_available_date=datetime.now(pytz.UTC) - timedelta(days=2) + certificate_available_date=datetime.now(pytz.UTC) - timedelta(days=2), ) GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=CertificateStatuses.downloadable, - mode='verified' + user=self.student, course_id=self.course.id, status=CertificateStatuses.downloadable, mode="verified" ) self.request_factory = RequestFactory() @@ -140,41 +134,38 @@ def setUp(self): def test_cert_status_with_generating(self): cert_user = UserFactory() GeneratedCertificateFactory.create( - user=cert_user, - course_id=self.course.id, - status=CertificateStatuses.generating, - mode='verified' + user=cert_user, course_id=self.course.id, status=CertificateStatuses.generating, mode="verified" ) - assert certificate_downloadable_status(cert_user, self.course.id) ==\ - {'is_downloadable': False, - 'is_generating': True, - 'is_unverified': False, - 'download_url': None, - 'uuid': None} + assert certificate_downloadable_status(cert_user, self.course.id) == { + "is_downloadable": False, + "is_generating": True, + "is_unverified": False, + "download_url": None, + "uuid": None, + } def test_cert_status_with_error(self): cert_user = UserFactory() GeneratedCertificateFactory.create( - user=cert_user, - course_id=self.course.id, - status=CertificateStatuses.error, - mode='verified' + user=cert_user, course_id=self.course.id, status=CertificateStatuses.error, mode="verified" ) - assert certificate_downloadable_status(cert_user, self.course.id) ==\ - {'is_downloadable': False, - 'is_generating': True, - 'is_unverified': False, - 'download_url': None, - 'uuid': None} + assert certificate_downloadable_status(cert_user, self.course.id) == { + "is_downloadable": False, + "is_generating": True, + "is_unverified": False, + "download_url": None, + "uuid": None, + } def test_without_cert(self): - assert certificate_downloadable_status(self.student_no_cert, self.course.id) ==\ - {'is_downloadable': False, - 'is_generating': False, - 'is_unverified': False, - 'download_url': None, - 'uuid': None} + assert certificate_downloadable_status(self.student_no_cert, self.course.id) == { + "is_downloadable": False, + "is_generating": False, + "is_unverified": False, + "download_url": None, + "uuid": None, + } def verify_downloadable_pdf_cert(self): """ @@ -186,44 +177,46 @@ def verify_downloadable_pdf_cert(self): user=cert_user, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode='verified', - download_url='www.google.com', + mode="verified", + download_url="www.google.com", ) - assert certificate_downloadable_status(cert_user, self.course.id) ==\ - {'is_downloadable': True, - 'is_generating': False, - 'is_unverified': False, - 'download_url': 'www.google.com', - 'is_pdf_certificate': True, - 'uuid': cert.verify_uuid} + assert certificate_downloadable_status(cert_user, self.course.id) == { + "is_downloadable": True, + "is_generating": False, + "is_unverified": False, + "download_url": "www.google.com", + "is_pdf_certificate": True, + "uuid": cert.verify_uuid, + } - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_pdf_cert_with_html_enabled(self): self.verify_downloadable_pdf_cert() def test_pdf_cert_with_html_disabled(self): self.verify_downloadable_pdf_cert() - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_with_downloadable_web_cert(self): cert_status = certificate_status_for_student(self.student, self.course.id) - assert certificate_downloadable_status(self.student, self.course.id) ==\ - {'is_downloadable': True, - 'is_generating': False, - 'is_unverified': False, - 'download_url': f'/certificates/{cert_status["uuid"]}', - 'is_pdf_certificate': False, - 'uuid': cert_status['uuid']} + assert certificate_downloadable_status(self.student, self.course.id) == { + "is_downloadable": True, + "is_generating": False, + "is_unverified": False, + "download_url": f'/certificates/{cert_status["uuid"]}', + "is_pdf_certificate": False, + "uuid": cert_status["uuid"], + } @ddt.data( (False, timedelta(days=2), False, True), (False, -timedelta(days=2), True, None), - (True, timedelta(days=2), True, None) + (True, timedelta(days=2), True, None), ) @ddt.unpack - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) - @patch.dict(settings.FEATURES, {'ENABLE_V2_CERT_DISPLAY_SETTINGS': False}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) + @patch.dict(settings.FEATURES, {"ENABLE_V2_CERT_DISPLAY_SETTINGS": False}) def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadable_status, earned_but_not_available): """ Test 'downloadable status' @@ -236,8 +229,8 @@ def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadabl self._setup_course_certificate() downloadable_status = certificate_downloadable_status(self.student, self.course.id) - assert downloadable_status['is_downloadable'] == cert_downloadable_status - assert downloadable_status.get('earned_but_not_available') == earned_but_not_available + assert downloadable_status["is_downloadable"] == cert_downloadable_status + assert downloadable_status.get("earned_but_not_available") == earned_but_not_available @ddt.data( (True, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None), @@ -249,15 +242,15 @@ def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadabl (False, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, False, True), ) @ddt.unpack - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) - @patch.dict(settings.FEATURES, {'ENABLE_V2_CERT_DISPLAY_SETTINGS': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) + @patch.dict(settings.FEATURES, {"ENABLE_V2_CERT_DISPLAY_SETTINGS": True}) def test_cert_api_return_v2( self, self_paced, cert_avail_delta, certificates_display_behavior, cert_downloadable_status, - earned_but_not_available + earned_but_not_available, ): """ Test 'downloadable status' @@ -271,36 +264,26 @@ def test_cert_api_return_v2( self._setup_course_certificate() downloadable_status = certificate_downloadable_status(self.student, self.course.id) - assert downloadable_status['is_downloadable'] == cert_downloadable_status - assert downloadable_status.get('earned_but_not_available') == earned_but_not_available + assert downloadable_status["is_downloadable"] == cert_downloadable_status + assert downloadable_status.get("earned_but_not_available") == earned_but_not_available @ddt.ddt class CertificateIsInvalid(WebCertificateTestMixin, ModuleStoreTestCase): - """Tests for the `is_certificate_invalid` helper function. """ + """Tests for the `is_certificate_invalid` helper function.""" def setUp(self): super().setUp() self.student = UserFactory() - self.course = CourseFactory.create( - org='edx', - number='verified', - display_name='Verified Course' - ) - self.course_overview = CourseOverviewFactory.create( - id=self.course.id - ) + self.course = CourseFactory.create(org="edx", number="verified", display_name="Verified Course") + self.course_overview = CourseOverviewFactory.create(id=self.course.id) self.global_staff = GlobalStaffFactory() self.request_factory = RequestFactory() def test_method_with_no_certificate(self): - """ Test the case when there is no certificate for a user for a specific course. """ - course = CourseFactory.create( - org='edx', - number='honor', - display_name='Course 1' - ) + """Test the case when there is no certificate for a user for a specific course.""" + course = CourseFactory.create(org="edx", number="honor", display_name="Course 1") # Also check query count for 'is_certificate_invalid' method. with self.assertNumQueries(1): assert not is_certificate_invalidated(self.student, course.id) @@ -315,8 +298,8 @@ def test_method_with_no_certificate(self): CertificateStatuses.unavailable, ) def test_method_with_invalidated_cert(self, status): - """ Verify that if certificate is marked as invalid than method will return - True. """ + """Verify that if certificate is marked as invalid than method will return + True.""" generated_cert = self._generate_cert(status) self._invalidate_certificate(generated_cert, True) assert is_certificate_invalidated(self.student, self.course.id) @@ -331,8 +314,8 @@ def test_method_with_invalidated_cert(self, status): CertificateStatuses.unavailable, ) def test_method_with_inactive_invalidated_cert(self, status): - """ Verify that if certificate is valid but it's invalidated status is - false than method will return false. """ + """Verify that if certificate is valid but it's invalidated status is + false than method will return false.""" generated_cert = self._generate_cert(status) self._invalidate_certificate(generated_cert, False) assert not is_certificate_invalidated(self.student, self.course.id) @@ -347,42 +330,36 @@ def test_method_with_inactive_invalidated_cert(self, status): CertificateStatuses.unavailable, ) def test_method_with_all_statues(self, status): - """ Verify method return True if certificate has valid status but it is - marked as invalid in CertificateInvalidation table. """ + """Verify method return True if certificate has valid status but it is + marked as invalid in CertificateInvalidation table.""" certificate = self._generate_cert(status) CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=True + generated_certificate=certificate, invalidated_by=self.global_staff, active=True ) # Also check query count for 'is_certificate_invalid' method. with self.assertNumQueries(2): assert is_certificate_invalidated(self.student, self.course.id) def _invalidate_certificate(self, certificate, active): - """ Dry method to mark certificate as invalid. """ + """Dry method to mark certificate as invalid.""" CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=active + generated_certificate=certificate, invalidated_by=self.global_staff, active=active ) # Invalidate user certificate certificate.invalidate() assert not certificate.is_valid() def _generate_cert(self, status): - """ Dry method to generate certificate. """ + """Dry method to generate certificate.""" return GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=status, - mode='verified' + user=self.student, course_id=self.course.id, status=status, mode="verified" ) class CertificateGetTests(SharedModuleStoreTestCase): - """Tests for the `test_get_certificate_for_user` helper function. """ + """Tests for the `test_get_certificate_for_user` helper function.""" + now = timezone.now() @classmethod @@ -394,31 +371,25 @@ def setUpClass(cls): cls.student = UserFactory() cls.student_no_cert = UserFactory() cls.uuid = uuid.uuid4().hex - cls.nonexistent_course_id = CourseKey.from_string('course-v1:some+fake+course') + cls.nonexistent_course_id = CourseKey.from_string("course-v1:some+fake+course") cls.web_cert_course = CourseFactory.create( - org='edx', - number='verified_1', - display_name='Verified Course 1', - cert_html_view_enabled=True + org="edx", number="verified_1", display_name="Verified Course 1", cert_html_view_enabled=True ) cls.pdf_cert_course = CourseFactory.create( - org='edx', - number='verified_2', - display_name='Verified Course 2', - cert_html_view_enabled=False + org="edx", number="verified_2", display_name="Verified Course 2", cert_html_view_enabled=False ) cls.no_cert_course = CourseFactory.create( - org='edx', - number='verified_3', - display_name='Verified Course 3', + org="edx", + number="verified_3", + display_name="Verified Course 3", ) # certificate for the first course GeneratedCertificateFactory.create( user=cls.student, course_id=cls.web_cert_course.id, status=CertificateStatuses.downloadable, - mode='verified', - download_url='www.google.com', + mode="verified", + download_url="www.google.com", grade="0.88", verify_uuid=cls.uuid, ) @@ -427,16 +398,14 @@ def setUpClass(cls): user=cls.student, course_id=cls.pdf_cert_course.id, status=CertificateStatuses.downloadable, - mode='honor', - download_url='www.gmail.com', + mode="honor", + download_url="www.gmail.com", grade="0.99", verify_uuid=cls.uuid, ) # certificate for a course that will be deleted GeneratedCertificateFactory.create( - user=cls.student, - course_id=cls.nonexistent_course_id, - status=CertificateStatuses.downloadable + user=cls.student, course_id=cls.nonexistent_course_id, status=CertificateStatuses.downloadable ) @classmethod @@ -450,14 +419,14 @@ def test_get_certificate_for_user(self): """ cert = get_certificate_for_user(self.student.username, self.web_cert_course.id) - assert cert['username'] == self.student.username - assert cert['course_key'] == self.web_cert_course.id - assert cert['created'] == self.now - assert cert['type'] == CourseMode.VERIFIED - assert cert['status'] == CertificateStatuses.downloadable - assert cert['grade'] == '0.88' - assert cert['is_passing'] is True - assert cert['download_url'] == 'www.google.com' + assert cert["username"] == self.student.username + assert cert["course_key"] == self.web_cert_course.id + assert cert["created"] == self.now + assert cert["type"] == CourseMode.VERIFIED + assert cert["status"] == CertificateStatuses.downloadable + assert cert["grade"] == "0.88" + assert cert["is_passing"] is True + assert cert["download_url"] == "www.google.com" def test_get_certificate_for_user_id(self): """ @@ -469,7 +438,7 @@ def test_get_certificate_for_user_id(self): assert cert.course_id == self.web_cert_course.id assert cert.mode == CourseMode.VERIFIED assert cert.status == CertificateStatuses.downloadable - assert cert.grade == '0.88' + assert cert.grade == "0.88" def test_get_certificates_for_user(self): """ @@ -477,22 +446,22 @@ def test_get_certificates_for_user(self): """ certs = get_certificates_for_user(self.student.username) assert len(certs) == 2 - assert certs[0]['username'] == self.student.username - assert certs[1]['username'] == self.student.username - assert certs[0]['course_key'] == self.web_cert_course.id - assert certs[1]['course_key'] == self.pdf_cert_course.id - assert certs[0]['created'] == self.now - assert certs[1]['created'] == self.now - assert certs[0]['type'] == CourseMode.VERIFIED - assert certs[1]['type'] == CourseMode.HONOR - assert certs[0]['status'] == CertificateStatuses.downloadable - assert certs[1]['status'] == CertificateStatuses.downloadable - assert certs[0]['is_passing'] is True - assert certs[1]['is_passing'] is True - assert certs[0]['grade'] == '0.88' - assert certs[1]['grade'] == '0.99' - assert certs[0]['download_url'] == 'www.google.com' - assert certs[1]['download_url'] == 'www.gmail.com' + assert certs[0]["username"] == self.student.username + assert certs[1]["username"] == self.student.username + assert certs[0]["course_key"] == self.web_cert_course.id + assert certs[1]["course_key"] == self.pdf_cert_course.id + assert certs[0]["created"] == self.now + assert certs[1]["created"] == self.now + assert certs[0]["type"] == CourseMode.VERIFIED + assert certs[1]["type"] == CourseMode.HONOR + assert certs[0]["status"] == CertificateStatuses.downloadable + assert certs[1]["status"] == CertificateStatuses.downloadable + assert certs[0]["is_passing"] is True + assert certs[1]["is_passing"] is True + assert certs[0]["grade"] == "0.88" + assert certs[1]["grade"] == "0.99" + assert certs[0]["download_url"] == "www.google.com" + assert certs[1]["download_url"] == "www.gmail.com" def test_get_certificates_for_user_by_course_keys(self): """ @@ -505,9 +474,9 @@ def test_get_certificates_for_user_by_course_keys(self): ) assert set(certs.keys()) == {self.web_cert_course.id} cert = certs[self.web_cert_course.id] - assert cert['username'] == self.student.username - assert cert['course_key'] == self.web_cert_course.id - assert cert['download_url'] == 'www.google.com' + assert cert["username"] == self.student.username + assert cert["course_key"] == self.web_cert_course.id + assert cert["download_url"] == "www.google.com" def test_no_certificate_for_user(self): """ @@ -521,45 +490,27 @@ def test_no_certificates_for_user(self): """ assert not get_certificates_for_user(self.student_no_cert.username) - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_get_web_certificate_url(self): """ Test the get_certificate_url with a web cert course """ - expected_url = reverse( - 'certificates:render_cert_by_uuid', - kwargs=dict(certificate_uuid=self.uuid) - ) - cert_url = get_certificate_url( - user_id=self.student.id, - course_id=self.web_cert_course.id, - uuid=self.uuid - ) + expected_url = reverse("certificates:render_cert_by_uuid", kwargs=dict(certificate_uuid=self.uuid)) + cert_url = get_certificate_url(user_id=self.student.id, course_id=self.web_cert_course.id, uuid=self.uuid) assert expected_url == cert_url - expected_url = reverse( - 'certificates:render_cert_by_uuid', - kwargs=dict(certificate_uuid=self.uuid) - ) + expected_url = reverse("certificates:render_cert_by_uuid", kwargs=dict(certificate_uuid=self.uuid)) - cert_url = get_certificate_url( - user_id=self.student.id, - course_id=self.web_cert_course.id, - uuid=self.uuid - ) + cert_url = get_certificate_url(user_id=self.student.id, course_id=self.web_cert_course.id, uuid=self.uuid) assert expected_url == cert_url - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_get_pdf_certificate_url(self): """ Test the get_certificate_url with a pdf cert course """ - cert_url = get_certificate_url( - user_id=self.student.id, - course_id=self.pdf_cert_course.id, - uuid=self.uuid - ) - assert 'www.gmail.com' == cert_url + cert_url = get_certificate_url(user_id=self.student.id, course_id=self.pdf_cert_course.id, uuid=self.uuid) + assert "www.gmail.com" == cert_url def test_get_certificate_with_deleted_course(self): """ @@ -570,7 +521,7 @@ def test_get_certificate_with_deleted_course(self): @ddt.ddt class GenerateUserCertificatesTest(ModuleStoreTestCase): - """Tests for generating certificates for students. """ + """Tests for generating certificates for students.""" def setUp(self): super().setUp() @@ -585,15 +536,15 @@ def setUp(self): mode=CourseMode.VERIFIED, ) - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': False}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": False}) def test_cert_url_empty_with_invalid_certificate(self): """ Test certificate url is empty if html view is not enabled and certificate is not yet generated """ url = get_certificate_url(self.user.id, self.course_run_key) - assert url == '' + assert url == "" - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_generation(self): """ Test that a cert is successfully generated @@ -609,7 +560,7 @@ def test_generation(self): assert cert.status == CertificateStatuses.downloadable assert cert.mode == CourseMode.VERIFIED - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) @ddt.data(True, False) def test_generation_unverified(self, enable_idv_requirement): """ @@ -630,16 +581,13 @@ def test_generation_unverified(self, enable_idv_requirement): else: assert cert.status == CertificateStatuses.downloadable - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_generation_notpassing(self): """ Test that a cert is successfully generated with a status of notpassing """ GeneratedCertificateFactory( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode=CourseMode.AUDIT + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode=CourseMode.AUDIT ) with mock.patch(PASSING_GRADE_METHOD, return_value=False): @@ -653,12 +601,12 @@ def test_generation_notpassing(self): @ddt.ddt class CertificateGenerationEnabledTest(EventTestMixin, TestCase): - """Test enabling/disabling self-generated certificates for a course. """ + """Test enabling/disabling self-generated certificates for a course.""" - COURSE_KEY = CourseLocator(org='test', course='test', run='test') + COURSE_KEY = CourseLocator(org="test", course="test", run="test") def setUp(self): # pylint: disable=arguments-differ - super().setUp('lms.djangoapps.certificates.api.tracker') + super().setUp("lms.djangoapps.certificates.api.tracker") # Since model-based configuration is cached, we need # to clear the cache before each test. @@ -670,7 +618,7 @@ def setUp(self): # pylint: disable=arguments-differ (False, True, False), (True, None, False), (True, False, False), - (True, True, True) + (True, True, True), ) @ddt.unpack def test_cert_generation_enabled(self, is_feature_enabled, is_course_enabled, expect_enabled): @@ -679,8 +627,8 @@ def test_cert_generation_enabled(self, is_feature_enabled, is_course_enabled, ex if is_course_enabled is not None: set_cert_generation_enabled(self.COURSE_KEY, is_course_enabled) - cert_event_type = 'enabled' if is_course_enabled else 'disabled' - event_name = '.'.join(['edx', 'certificate', 'generation', cert_event_type]) + cert_event_type = "enabled" if is_course_enabled else "disabled" + event_name = ".".join(["edx", "certificate", "generation", cert_event_type]) self.assert_event_emitted( event_name, course_id=str(self.COURSE_KEY), @@ -709,27 +657,27 @@ def test_setting_is_course_specific(self): self._assert_enabled_for_course(self.COURSE_KEY, True) # Should be disabled for another course - other_course = CourseLocator(org='other', course='other', run='other') + other_course = CourseLocator(org="other", course="other", run="other") self._assert_enabled_for_course(other_course, False) def _assert_enabled_for_course(self, course_key, expect_enabled): - """Check that self-generated certificates are enabled or disabled for the course. """ + """Check that self-generated certificates are enabled or disabled for the course.""" actual_enabled = has_self_generated_certificates_enabled(course_key) assert expect_enabled == actual_enabled @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) class CertificatesBrandingTest(ModuleStoreTestCase): - """Test certificates branding. """ + """Test certificates branding.""" - COURSE_KEY = CourseLocator(org='test', course='test', run='test') + COURSE_KEY = CourseLocator(org="test", course="test", run="test") configuration = { - 'logo_image_url': 'test_site/images/header-logo.png', - 'SITE_NAME': 'test_site.localhost', - 'urls': { - 'ABOUT': 'test-site/about', - 'PRIVACY': 'test-site/privacy', - 'TOS_AND_HONOR': 'test-site/tos-and-honor', + "logo_image_url": "test_site/images/header-logo.png", + "SITE_NAME": "test_site.localhost", + "urls": { + "ABOUT": "test-site/about", + "PRIVACY": "test-site/privacy", + "TOS_AND_HONOR": "test-site/tos-and-honor", }, } @@ -744,13 +692,10 @@ def test_certificate_header_data(self): data = get_certificate_header_context(is_secure=True) # Make sure there are not unexpected keys in dict returned by 'get_certificate_header_context' - self.assertCountEqual( - list(data.keys()), - ['logo_src', 'logo_url'] - ) - assert self.configuration['logo_image_url'] in data['logo_src'] + self.assertCountEqual(list(data.keys()), ["logo_src", "logo_url"]) + assert self.configuration["logo_image_url"] in data["logo_src"] - assert self.configuration['SITE_NAME'] in data['logo_url'] + assert self.configuration["SITE_NAME"] in data["logo_url"] @with_site_configuration(configuration=configuration) def test_certificate_footer_data(self): @@ -763,19 +708,17 @@ def test_certificate_footer_data(self): data = get_certificate_footer_context() # Make sure there are not unexpected keys in dict returned by 'get_certificate_footer_context' - self.assertCountEqual( - list(data.keys()), - ['company_about_url', 'company_privacy_url', 'company_tos_url'] - ) - assert self.configuration['urls']['ABOUT'] in data['company_about_url'] - assert self.configuration['urls']['PRIVACY'] in data['company_privacy_url'] - assert self.configuration['urls']['TOS_AND_HONOR'] in data['company_tos_url'] + self.assertCountEqual(list(data.keys()), ["company_about_url", "company_privacy_url", "company_tos_url"]) + assert self.configuration["urls"]["ABOUT"] in data["company_about_url"] + assert self.configuration["urls"]["PRIVACY"] in data["company_privacy_url"] + assert self.configuration["urls"]["TOS_AND_HONOR"] in data["company_tos_url"] class CertificateAllowlistTests(ModuleStoreTestCase): """ Tests for allowlist functionality. """ + def setUp(self): super().setUp() @@ -823,10 +766,7 @@ def test_remove_allowlist_entry_with_certificate(self): """ CertificateAllowlistFactory.create(course_id=self.course_run_key, user=self.user) GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.downloadable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.downloadable, mode="verified" ) assert is_on_allowlist(self.user, self.course_run_key) @@ -862,7 +802,7 @@ def test_get_allowlist_entry_dne(self): """ expected_messages = [ f"Attempting to retrieve an allowlist entry for student {self.user.id} in course {self.course_run_key}.", - f"No allowlist entry found for student {self.user.id} in course {self.course_run_key}." + f"No allowlist entry found for student {self.user.id} in course {self.course_run_key}.", ] with LogCapture() as log: @@ -919,15 +859,10 @@ def test_can_be_added_to_allowlist_certificate_invalidated(self): invalidation list. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=True + generated_certificate=certificate, invalidated_by=self.global_staff, active=True ) assert not can_be_added_to_allowlist(self.user, self.course_run_key) @@ -1005,7 +940,7 @@ def test_add_and_update(self): Test add and update of the allowlist """ u1 = UserFactory() - notes = 'blah' + notes = "blah" # Check before adding user entry = get_allowlist_entry(u1, self.course_run_key) @@ -1017,7 +952,7 @@ def test_add_and_update(self): assert entry.notes == notes # Update user - new_notes = 'really useful info' + new_notes = "really useful info" create_or_update_certificate_allowlist_entry(u1, self.course_run_key, new_notes) entry = get_allowlist_entry(u1, self.course_run_key) assert entry.notes == new_notes @@ -1027,7 +962,7 @@ def test_remove(self): Test removal from the allowlist """ u1 = UserFactory() - notes = 'I had a thought....' + notes = "I had a thought...." # Add user create_or_update_certificate_allowlist_entry(u1, self.course_run_key, notes) @@ -1044,6 +979,7 @@ class CertificateInvalidationTests(ModuleStoreTestCase): """ Tests for the certificate invalidation functionality. """ + def setUp(self): super().setUp() @@ -1065,10 +1001,7 @@ def test_create_certificate_invalidation_entry(self): invalidation entries. This is functionality the Instructor Dashboard django app relies on. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) result = create_certificate_invalidation_entry(certificate, self.global_staff, "Test!") @@ -1082,16 +1015,11 @@ def test_get_certificate_invalidation_entry(self): Test to verify that we can retrieve a certificate invalidation entry for a learner. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) invalidation = CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=True + generated_certificate=certificate, invalidated_by=self.global_staff, active=True ) retrieved_invalidation = get_certificate_invalidation_entry(certificate) @@ -1105,10 +1033,7 @@ def test_get_certificate_invalidation_entry_dne(self): Test to verify behavior when a certificate invalidation entry does not exist. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) expected_messages = [ @@ -1130,6 +1055,7 @@ class MockGeneratedCertificate: We can't import GeneratedCertificate from LMS here, so we roll our own minimal Certificate model for testing. """ + def __init__(self, user=None, course_id=None, mode=None, status=None): self.user = user self.course_id = course_id @@ -1165,24 +1091,22 @@ class CertificatesApiTestCase(TestCase): """ API tests """ + def setUp(self): super().setUp() self.course = CourseOverviewFactory.create( start=datetime(2017, 1, 1, tzinfo=pytz.UTC), end=datetime(2017, 1, 31, tzinfo=pytz.UTC), - certificate_available_date=None + certificate_available_date=None, ) self.user = UserFactory.create() self.enrollment = CourseEnrollmentFactory( user=self.user, course_id=self.course.id, is_active=True, - mode='audit', - ) - self.certificate = MockGeneratedCertificate( - user=self.user, - course_id=self.course.id + mode="audit", ) + self.certificate = MockGeneratedCertificate(user=self.user, course_id=self.course.id) @ddt.data(True, False) def test_auto_certificate_generation_enabled(self, feature_enabled): @@ -1196,9 +1120,7 @@ def test_auto_certificate_generation_enabled(self, feature_enabled): (False, False, False), # feature not enabled and instructor-paced should return False ) @ddt.unpack - def test_can_show_certificate_available_date_field( - self, feature_enabled, is_self_paced, expected_value - ): + def test_can_show_certificate_available_date_field(self, feature_enabled, is_self_paced, expected_value): self.course.self_paced = is_self_paced with configure_waffle_namespace(feature_enabled): assert expected_value == can_show_certificate_available_date_field(self.course) @@ -1210,9 +1132,7 @@ def test_can_show_certificate_available_date_field( (False, False, False), # feature not enabled and instructor-paced should return False ) @ddt.unpack - def test_available_vs_display_date( - self, feature_enabled, is_self_paced, uses_avail_date - ): + def test_available_vs_display_date(self, feature_enabled, is_self_paced, uses_avail_date): self.course.self_paced = is_self_paced with configure_waffle_namespace(feature_enabled): @@ -1245,6 +1165,7 @@ class CertificatesMessagingTestCase(ModuleStoreTestCase): """ API tests for certificate messaging """ + def setUp(self): super().setUp() self.course = CourseOverviewFactory.create() @@ -1275,6 +1196,7 @@ class CertificatesLearnerRetirementFunctionality(ModuleStoreTestCase): API tests for utility functions used as part of the learner retirement pipeline to remove PII from certificate records. """ + def setUp(self): super().setUp() self.user = UserFactory() diff --git a/openedx/core/djangoapps/catalog/tests/test_utils.py b/openedx/core/djangoapps/catalog/tests/test_utils.py index 36feb63a6738..fc85b949e80b 100644 --- a/openedx/core/djangoapps/catalog/tests/test_utils.py +++ b/openedx/core/djangoapps/catalog/tests/test_utils.py @@ -1,4 +1,5 @@ """Tests covering utilities for integrating with the catalog service.""" + # pylint: disable=missing-docstring @@ -16,6 +17,7 @@ from common.djangoapps.course_modes.helpers import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from openedx.core.constants import COURSE_UNPUBLISHED from openedx.core.djangoapps.catalog.cache import ( CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL, @@ -25,7 +27,7 @@ PROGRAMS_BY_TYPE_CACHE_KEY_TPL, PROGRAMS_BY_TYPE_SLUG_CACHE_KEY_TPL, SITE_PATHWAY_IDS_CACHE_KEY_TPL, - SITE_PROGRAM_UUIDS_CACHE_KEY_TPL + SITE_PROGRAM_UUIDS_CACHE_KEY_TPL, ) from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.catalog.tests.factories import ( @@ -33,14 +35,13 @@ CourseRunFactory, PathwayFactory, ProgramFactory, + ProgramTypeAttrsFactory, ProgramTypeFactory, - ProgramTypeAttrsFactory ) from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin from openedx.core.djangoapps.catalog.utils import ( child_programs, course_run_keys_for_program, - is_course_run_in_program, get_course_run_details, get_course_runs, get_course_runs_for_course, @@ -53,23 +54,23 @@ get_programs_by_type, get_programs_by_type_slug, get_visible_sessions_for_entitlement, + is_course_run_in_program, normalize_program_type, ) from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context +from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms -UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils' +UTILS_MODULE = "openedx.core.djangoapps.catalog.utils" User = get_user_model() # pylint: disable=invalid-name @skip_unless_lms -@mock.patch(UTILS_MODULE + '.logger.info') -@mock.patch(UTILS_MODULE + '.logger.warning') +@mock.patch(UTILS_MODULE + ".logger.info") +@mock.patch(UTILS_MODULE + ".logger.warning") class TestGetPrograms(CacheIsolationTestCase): - ENABLED_CACHES = ['default'] + ENABLED_CACHES = ["default"] def setUp(self): super().setUp() @@ -79,36 +80,35 @@ def test_get_many(self, mock_warning, mock_info): programs = ProgramFactory.create_batch(3) # Cache details for 2 of 3 programs. - partial_programs = { - PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']): program for program in programs[:2] - } + partial_programs = {PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]): program for program in programs[:2]} cache.set_many(partial_programs, None) # When called before UUIDs are cached, the function should return an # empty list and log a warning. - with with_site_configuration_context(domain=self.site.name, configuration={'COURSE_CATALOG_API_URL': 'foo'}): + with with_site_configuration_context(domain=self.site.name, configuration={"COURSE_CATALOG_API_URL": "foo"}): assert get_programs(site=self.site) == [] mock_warning.assert_called_once_with( - f'Failed to get program UUIDs from the cache for site {self.site.domain}.' + f"Failed to get program UUIDs from the cache for site {self.site.domain}." ) mock_warning.reset_mock() # Cache UUIDs for all 3 programs. cache.set( SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=self.site.domain), - [program['uuid'] for program in programs], - None + [program["uuid"] for program in programs], + None, ) actual_programs = get_programs(site=self.site) # The 2 cached programs should be returned while info and warning # messages should be logged for the missing one. - assert {program['uuid'] for program in actual_programs} == \ - {program['uuid'] for program in partial_programs.values()} - mock_info.assert_called_with('Failed to get details for 1 programs. Retrying.') + assert {program["uuid"] for program in actual_programs} == { + program["uuid"] for program in partial_programs.values() + } + mock_info.assert_called_with("Failed to get details for 1 programs. Retrying.") mock_warning.assert_called_with( - 'Failed to get details for program {uuid} from the cache.'.format(uuid=programs[2]['uuid']) + "Failed to get details for program {uuid} from the cache.".format(uuid=programs[2]["uuid"]) ) mock_warning.reset_mock() @@ -117,77 +117,67 @@ def test_get_many(self, mock_warning, mock_info): # of the cache above, so all we need to do here is verify the accuracy of # the data itself. for program in actual_programs: - key = PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']) + key = PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]) assert program == partial_programs[key] # Cache details for all 3 programs. - all_programs = { - PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']): program for program in programs - } + all_programs = {PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]): program for program in programs} cache.set_many(all_programs, None) actual_programs = get_programs(site=self.site) # All 3 programs should be returned. - assert {program['uuid'] for program in actual_programs} ==\ - {program['uuid'] for program in all_programs.values()} + assert {program["uuid"] for program in actual_programs} == { + program["uuid"] for program in all_programs.values() + } assert not mock_warning.called for program in actual_programs: - key = PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']) + key = PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]) assert program == all_programs[key] - @mock.patch(UTILS_MODULE + '.cache') + @mock.patch(UTILS_MODULE + ".cache") def test_get_many_with_missing(self, mock_cache, mock_warning, mock_info): programs = ProgramFactory.create_batch(3) - all_programs = { - PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']): program for program in programs - } + all_programs = {PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]): program for program in programs} - partial_programs = { - PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']): program for program in programs[:2] - } + partial_programs = {PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]): program for program in programs[:2]} def fake_get_many(keys): if len(keys) == 1: - return {PROGRAM_CACHE_KEY_TPL.format(uuid=programs[-1]['uuid']): programs[-1]} + return {PROGRAM_CACHE_KEY_TPL.format(uuid=programs[-1]["uuid"]): programs[-1]} else: return partial_programs - mock_cache.get.return_value = [program['uuid'] for program in programs] + mock_cache.get.return_value = [program["uuid"] for program in programs] mock_cache.get_many.side_effect = fake_get_many - with with_site_configuration_context(domain=self.site.name, configuration={'COURSE_CATALOG_API_URL': 'foo'}): + with with_site_configuration_context(domain=self.site.name, configuration={"COURSE_CATALOG_API_URL": "foo"}): actual_programs = get_programs(site=self.site) - # All 3 cached programs should be returned. An info message should be - # logged about the one that was initially missing, but the code should - # be able to stitch together all the details. - assert {program['uuid'] for program in actual_programs} ==\ - {program['uuid'] for program in all_programs.values()} + # All 3 cached programs should be returned. An info message should be + # logged about the one that was initially missing, but the code should + # be able to stitch together all the details. + assert {program["uuid"] for program in actual_programs} == { + program["uuid"] for program in all_programs.values() + } assert not mock_warning.called - mock_info.assert_called_with('Failed to get details for 1 programs. Retrying.') + mock_info.assert_called_with("Failed to get details for 1 programs. Retrying.") for program in actual_programs: - key = PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']) + key = PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]) assert program == all_programs[key] def test_get_one(self, mock_warning, _mock_info): expected_program = ProgramFactory() - expected_uuid = expected_program['uuid'] + expected_uuid = expected_program["uuid"] assert get_programs(uuid=expected_uuid) is None - mock_warning.assert_called_once_with( - f'Failed to get details for program {expected_uuid} from the cache.' - ) + mock_warning.assert_called_once_with(f"Failed to get details for program {expected_uuid} from the cache.") mock_warning.reset_mock() - cache.set( - PROGRAM_CACHE_KEY_TPL.format(uuid=expected_uuid), - expected_program, - None - ) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=expected_uuid), expected_program, None) actual_program = get_programs(uuid=expected_uuid) assert actual_program == expected_program @@ -195,20 +185,12 @@ def test_get_one(self, mock_warning, _mock_info): def test_get_from_course(self, mock_warning, _mock_info): expected_program = ProgramFactory() - expected_course = expected_program['courses'][0]['course_runs'][0]['key'] + expected_course = expected_program["courses"][0]["course_runs"][0]["key"] assert get_programs(course=expected_course) == [] - cache.set( - COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=expected_course), - [expected_program['uuid']], - None - ) - cache.set( - PROGRAM_CACHE_KEY_TPL.format(uuid=expected_program['uuid']), - expected_program, - None - ) + cache.set(COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=expected_course), [expected_program["uuid"]], None) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=expected_program["uuid"]), expected_program, None) actual_program = get_programs(course=expected_course) assert actual_program == [expected_program] @@ -218,18 +200,10 @@ def test_get_via_uuids(self, mock_warning, _mock_info): first_program = ProgramFactory() second_program = ProgramFactory() - cache.set( - PROGRAM_CACHE_KEY_TPL.format(uuid=first_program['uuid']), - first_program, - None - ) - cache.set( - PROGRAM_CACHE_KEY_TPL.format(uuid=second_program['uuid']), - second_program, - None - ) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=first_program["uuid"]), first_program, None) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=second_program["uuid"]), second_program, None) - results = get_programs(uuids=[first_program['uuid'], second_program['uuid']]) + results = get_programs(uuids=[first_program["uuid"], second_program["uuid"]]) assert first_program in results assert second_program in results @@ -237,32 +211,28 @@ def test_get_via_uuids(self, mock_warning, _mock_info): def test_get_from_catalog_course(self, mock_warning, _mock_info): expected_program = ProgramFactory() - expected_catalog_course = expected_program['courses'][0] + expected_catalog_course = expected_program["courses"][0] - assert get_programs(catalog_course_uuid=expected_catalog_course['uuid']) == [] + assert get_programs(catalog_course_uuid=expected_catalog_course["uuid"]) == [] cache.set( - CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=expected_catalog_course['uuid']), - [expected_program['uuid']], - None - ) - cache.set( - PROGRAM_CACHE_KEY_TPL.format(uuid=expected_program['uuid']), - expected_program, - None + CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=expected_catalog_course["uuid"]), + [expected_program["uuid"]], + None, ) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=expected_program["uuid"]), expected_program, None) - actual_program = get_programs(catalog_course_uuid=expected_catalog_course['uuid']) + actual_program = get_programs(catalog_course_uuid=expected_catalog_course["uuid"]) assert actual_program == [expected_program] assert not mock_warning.called @skip_unless_lms -@mock.patch(UTILS_MODULE + '.logger.info') -@mock.patch(UTILS_MODULE + '.logger.warning') +@mock.patch(UTILS_MODULE + ".logger.info") +@mock.patch(UTILS_MODULE + ".logger.warning") class TestGetPathways(CacheIsolationTestCase): - ENABLED_CACHES = ['default'] + ENABLED_CACHES = ["default"] def setUp(self): super().setUp() @@ -272,33 +242,32 @@ def test_get_many(self, mock_warning, mock_info): pathways = PathwayFactory.create_batch(3) # Cache details for 2 of 3 programs. - partial_pathways = { - PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']): pathway for pathway in pathways[:2] - } + partial_pathways = {PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]): pathway for pathway in pathways[:2]} cache.set_many(partial_pathways, None) # When called before pathways are cached, the function should return an # empty list and log a warning. assert get_pathways(self.site) == [] - mock_warning.assert_called_once_with('Failed to get credit pathway ids from the cache.') + mock_warning.assert_called_once_with("Failed to get credit pathway ids from the cache.") mock_warning.reset_mock() # Cache all 3 pathways cache.set( SITE_PATHWAY_IDS_CACHE_KEY_TPL.format(domain=self.site.domain), - [pathway['id'] for pathway in pathways], - None + [pathway["id"] for pathway in pathways], + None, ) actual_pathways = get_pathways(self.site) # The 2 cached pathways should be returned while info and warning # messages should be logged for the missing one. - assert {pathway['id'] for pathway in actual_pathways} ==\ - {pathway['id'] for pathway in partial_pathways.values()} - mock_info.assert_called_with('Failed to get details for 1 pathways. Retrying.') + assert {pathway["id"] for pathway in actual_pathways} == { + pathway["id"] for pathway in partial_pathways.values() + } + mock_info.assert_called_with("Failed to get details for 1 pathways. Retrying.") mock_warning.assert_called_with( - 'Failed to get details for credit pathway {id} from the cache.'.format(id=pathways[2]['id']) + "Failed to get details for credit pathway {id} from the cache.".format(id=pathways[2]["id"]) ) mock_warning.reset_mock() @@ -307,45 +276,38 @@ def test_get_many(self, mock_warning, mock_info): # of the cache above, so all we need to do here is verify the accuracy of # the data itself. for pathway in actual_pathways: - key = PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']) + key = PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]) assert pathway == partial_pathways[key] # Cache details for all 3 pathways. - all_pathways = { - PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']): pathway for pathway in pathways - } + all_pathways = {PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]): pathway for pathway in pathways} cache.set_many(all_pathways, None) actual_pathways = get_pathways(self.site) # All 3 pathways should be returned. - assert {pathway['id'] for pathway in actual_pathways} ==\ - {pathway['id'] for pathway in all_pathways.values()} + assert {pathway["id"] for pathway in actual_pathways} == {pathway["id"] for pathway in all_pathways.values()} assert not mock_warning.called for pathway in actual_pathways: - key = PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']) + key = PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]) assert pathway == all_pathways[key] - @mock.patch(UTILS_MODULE + '.cache') + @mock.patch(UTILS_MODULE + ".cache") def test_get_many_with_missing(self, mock_cache, mock_warning, mock_info): pathways = PathwayFactory.create_batch(3) - all_pathways = { - PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']): pathway for pathway in pathways - } + all_pathways = {PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]): pathway for pathway in pathways} - partial_pathways = { - PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']): pathway for pathway in pathways[:2] - } + partial_pathways = {PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]): pathway for pathway in pathways[:2]} def fake_get_many(keys): if len(keys) == 1: - return {PATHWAY_CACHE_KEY_TPL.format(id=pathways[-1]['id']): pathways[-1]} + return {PATHWAY_CACHE_KEY_TPL.format(id=pathways[-1]["id"]): pathways[-1]} else: return partial_pathways - mock_cache.get.return_value = [pathway['id'] for pathway in pathways] + mock_cache.get.return_value = [pathway["id"] for pathway in pathways] mock_cache.get_many.side_effect = fake_get_many actual_pathways = get_pathways(self.site) @@ -353,40 +315,34 @@ def fake_get_many(keys): # All 3 cached pathways should be returned. An info message should be # logged about the one that was initially missing, but the code should # be able to stitch together all the details. - assert {pathway['id'] for pathway in actual_pathways} ==\ - {pathway['id'] for pathway in all_pathways.values()} + assert {pathway["id"] for pathway in actual_pathways} == {pathway["id"] for pathway in all_pathways.values()} assert not mock_warning.called - mock_info.assert_called_with('Failed to get details for 1 pathways. Retrying.') + mock_info.assert_called_with("Failed to get details for 1 pathways. Retrying.") for pathway in actual_pathways: - key = PATHWAY_CACHE_KEY_TPL.format(id=pathway['id']) + key = PATHWAY_CACHE_KEY_TPL.format(id=pathway["id"]) assert pathway == all_pathways[key] def test_get_one(self, mock_warning, _mock_info): expected_pathway = PathwayFactory() - expected_id = expected_pathway['id'] + expected_id = expected_pathway["id"] assert get_pathways(self.site, pathway_id=expected_id) is None - mock_warning.assert_called_once_with( - f'Failed to get details for credit pathway {expected_id} from the cache.' - ) + mock_warning.assert_called_once_with(f"Failed to get details for credit pathway {expected_id} from the cache.") mock_warning.reset_mock() - cache.set( - PATHWAY_CACHE_KEY_TPL.format(id=expected_id), - expected_pathway, - None - ) + cache.set(PATHWAY_CACHE_KEY_TPL.format(id=expected_id), expected_pathway, None) actual_pathway = get_pathways(self.site, pathway_id=expected_id) assert actual_pathway == expected_pathway assert not mock_warning.called -@mock.patch(UTILS_MODULE + '.get_api_data') +@mock.patch(UTILS_MODULE + ".get_api_data") class TestGetProgramTypes(CatalogIntegrationMixin, TestCase): """Tests covering retrieval of program types from the catalog service.""" - @override_settings(COURSE_CATALOG_API_URL='https://api.example.com/v1/') + + @override_settings(COURSE_CATALOG_API_URL="https://api.example.com/v1/") def test_get_program_types(self, mock_get_edx_api_data): """Verify get_program_types returns the expected list of program types.""" program_types = ProgramTypeFactory.create_batch(3) @@ -402,21 +358,18 @@ def test_get_program_types(self, mock_get_edx_api_data): assert data == program_types program = program_types[0] - data = get_program_types(name=program['name']) + data = get_program_types(name=program["name"]) assert data == program -@mock.patch(UTILS_MODULE + '.get_api_data') +@mock.patch(UTILS_MODULE + ".get_api_data") class TestGetCurrency(CatalogIntegrationMixin, TestCase): """Tests covering retrieval of currency data from the catalog service.""" - @override_settings(COURSE_CATALOG_API_URL='https://api.example.com/v1/') + + @override_settings(COURSE_CATALOG_API_URL="https://api.example.com/v1/") def test_get_currency_data(self, mock_get_edx_api_data): """Verify get_currency_data returns the currency data.""" - currency_data = { - "code": "CAD", - "rate": 1.257237, - "symbol": "$" - } + currency_data = {"code": "CAD", "rate": 1.257237, "symbol": "$"} mock_get_edx_api_data.return_value = currency_data # Catalog integration is disabled. @@ -429,11 +382,12 @@ def test_get_currency_data(self, mock_get_edx_api_data): assert data == currency_data -@mock.patch(UTILS_MODULE + '.get_currency_data') +@mock.patch(UTILS_MODULE + ".get_currency_data") class TestGetLocalizedPriceText(TestCase): """ Tests covering converting prices to a localized currency """ + def test_localized_string(self, mock_get_currency_data): currency_data = { "BEL": {"rate": 0.835621, "code": "EUR", "symbol": "\u20ac"}, @@ -442,20 +396,19 @@ def test_localized_string(self, mock_get_currency_data): } mock_get_currency_data.return_value = currency_data - request = RequestFactory().get('/dummy-url') - request.session = { - 'country_code': 'CA' - } - expected_result = '$20 CAD' + request = RequestFactory().get("/dummy-url") + request.session = {"country_code": "CA"} + expected_result = "$20 CAD" assert get_localized_price_text(10, request) == expected_result @skip_unless_lms -@mock.patch(UTILS_MODULE + '.get_api_data') +@mock.patch(UTILS_MODULE + ".get_api_data") class TestGetCourseRuns(CatalogIntegrationMixin, CacheIsolationTestCase): """ Tests covering retrieval of course runs from the catalog service. """ + def setUp(self): super().setUp() @@ -468,17 +421,19 @@ def assert_contract(self, call_args): """ args, kwargs = call_args - for arg in (self.catalog_integration, 'course_runs'): + for arg in (self.catalog_integration, "course_runs"): assert arg in args - assert kwargs['base_api_url'] == self.catalog_integration.get_internal_api_url() # pylint: disable=protected-access, line-too-long + assert ( + kwargs["base_api_url"] == self.catalog_integration.get_internal_api_url() + ) # pylint: disable=protected-access, line-too-long querystring = { - 'page_size': 20, - 'exclude_utm': 1, + "page_size": 20, + "exclude_utm": 1, } - assert kwargs['querystring'] == querystring + assert kwargs["querystring"] == querystring return args, kwargs @@ -493,16 +448,16 @@ def test_config_missing(self, mock_get_edx_api_data): assert not mock_get_edx_api_data.called assert data == [] - @mock.patch(UTILS_MODULE + '.logger.error') + @mock.patch(UTILS_MODULE + ".logger.error") def test_service_user_missing(self, mock_log_error, mock_get_edx_api_data): """ Verify that no errors occur when the catalog service user is missing. """ - catalog_integration = self.create_catalog_integration(service_username='nonexistent-user') + catalog_integration = self.create_catalog_integration(service_username="nonexistent-user") data = get_course_runs() mock_log_error.any_call( - 'Catalog service user with username [%s] does not exist. Course runs will not be retrieved.', + "Catalog service user with username [%s] does not exist. Course runs will not be retrieved.", catalog_integration.service_username, ) assert not mock_get_edx_api_data.called @@ -528,17 +483,18 @@ def test_get_course_runs_by_course(self, mock_get_edx_api_data): catalog_course = CourseFactory(course_runs=catalog_course_runs) mock_get_edx_api_data.return_value = catalog_course - data = get_course_runs_for_course(course_uuid=str(catalog_course['uuid'])) + data = get_course_runs_for_course(course_uuid=str(catalog_course["uuid"])) assert mock_get_edx_api_data.called assert data == catalog_course_runs @skip_unless_lms -@mock.patch(UTILS_MODULE + '.get_api_data') +@mock.patch(UTILS_MODULE + ".get_api_data") class TestGetCourseOwners(CatalogIntegrationMixin, TestCase): """ Tests covering retrieval of course runs from the catalog service. """ + def setUp(self): super().setUp() @@ -553,17 +509,18 @@ def test_get_course_owners_by_course(self, mock_get_edx_api_data): catalog_course = CourseFactory(course_runs=catalog_course_runs) mock_get_edx_api_data.return_value = catalog_course - data = get_owners_for_course(course_uuid=str(catalog_course['uuid'])) + data = get_owners_for_course(course_uuid=str(catalog_course["uuid"])) assert mock_get_edx_api_data.called - assert data == catalog_course['owners'] + assert data == catalog_course["owners"] @skip_unless_lms -@mock.patch(UTILS_MODULE + '.get_api_data') +@mock.patch(UTILS_MODULE + ".get_api_data") class TestSessionEntitlement(CatalogIntegrationMixin, TestCase): """ Test Covering data related Entitlements. """ + def setUp(self): super().setUp() @@ -578,12 +535,10 @@ def test_get_visible_sessions_for_entitlement(self, mock_get_edx_api_data): catalog_course_run = CourseRunFactory.create() catalog_course = CourseFactory(course_runs=[catalog_course_run]) mock_get_edx_api_data.return_value = catalog_course - course_key = CourseKey.from_string(catalog_course_run.get('key')) + course_key = CourseKey.from_string(catalog_course_run.get("key")) course_overview = CourseOverviewFactory.create(id=course_key, start=self.tomorrow) CourseModeFactory.create(mode_slug=CourseMode.VERIFIED, min_price=100, course_id=course_overview.id) - course_enrollment = CourseEnrollmentFactory( - user=self.user, course=course_overview, mode=CourseMode.VERIFIED - ) + course_enrollment = CourseEnrollmentFactory(user=self.user, course=course_overview, mode=CourseMode.VERIFIED) entitlement = CourseEntitlementFactory( user=self.user, enrollment_course_run=course_enrollment, mode=CourseMode.VERIFIED ) @@ -598,17 +553,15 @@ def test_get_visible_sessions_for_entitlement_expired_mode(self, mock_get_edx_ap catalog_course_run = CourseRunFactory.create() catalog_course = CourseFactory(course_runs=[catalog_course_run]) mock_get_edx_api_data.return_value = catalog_course - course_key = CourseKey.from_string(catalog_course_run.get('key')) + course_key = CourseKey.from_string(catalog_course_run.get("key")) course_overview = CourseOverviewFactory.create(id=course_key, start=self.tomorrow) CourseModeFactory.create( mode_slug=CourseMode.VERIFIED, min_price=100, course_id=course_overview.id, - expiration_datetime=now() - timedelta(days=1) - ) - course_enrollment = CourseEnrollmentFactory( - user=self.user, course=course_overview, mode=CourseMode.VERIFIED + expiration_datetime=now() - timedelta(days=1), ) + course_enrollment = CourseEnrollmentFactory(user=self.user, course=course_overview, mode=CourseMode.VERIFIED) entitlement = CourseEntitlementFactory( user=self.user, enrollment_course_run=course_enrollment, mode=CourseMode.VERIFIED ) @@ -624,17 +577,15 @@ def test_unpublished_sessions_for_entitlement_when_enrolled(self, mock_get_edx_a catalog_course_run = CourseRunFactory.create(status=COURSE_UNPUBLISHED) catalog_course = CourseFactory(course_runs=[catalog_course_run]) mock_get_edx_api_data.return_value = catalog_course - course_key = CourseKey.from_string(catalog_course_run.get('key')) + course_key = CourseKey.from_string(catalog_course_run.get("key")) course_overview = CourseOverviewFactory.create(id=course_key, start=self.tomorrow) CourseModeFactory.create( mode_slug=CourseMode.VERIFIED, min_price=100, course_id=course_overview.id, - expiration_datetime=now() - timedelta(days=1) - ) - course_enrollment = CourseEnrollmentFactory( - user=self.user, course=course_overview, mode=CourseMode.VERIFIED + expiration_datetime=now() - timedelta(days=1), ) + course_enrollment = CourseEnrollmentFactory(user=self.user, course=course_overview, mode=CourseMode.VERIFIED) entitlement = CourseEntitlementFactory( user=self.user, enrollment_course_run=course_enrollment, mode=CourseMode.VERIFIED ) @@ -650,28 +601,27 @@ def test_unpublished_sessions_for_entitlement(self, mock_get_edx_api_data): catalog_course_run = CourseRunFactory.create(status=COURSE_UNPUBLISHED) catalog_course = CourseFactory(course_runs=[catalog_course_run]) mock_get_edx_api_data.return_value = catalog_course - course_key = CourseKey.from_string(catalog_course_run.get('key')) + course_key = CourseKey.from_string(catalog_course_run.get("key")) course_overview = CourseOverviewFactory.create(id=course_key, start=self.tomorrow) CourseModeFactory.create( mode_slug=CourseMode.VERIFIED, min_price=100, course_id=course_overview.id, - expiration_datetime=now() - timedelta(days=1) - ) - entitlement = CourseEntitlementFactory( - user=self.user, mode=CourseMode.VERIFIED + expiration_datetime=now() - timedelta(days=1), ) + entitlement = CourseEntitlementFactory(user=self.user, mode=CourseMode.VERIFIED) session_entitlements = get_visible_sessions_for_entitlement(entitlement) assert not session_entitlements @skip_unless_lms -@mock.patch(UTILS_MODULE + '.get_api_data') +@mock.patch(UTILS_MODULE + ".get_api_data") class TestGetCourseRunDetails(CatalogIntegrationMixin, TestCase): """ Tests covering retrieval of information about a specific course run from the catalog service. """ + def setUp(self): super().setUp() self.catalog_integration = self.create_catalog_integration(cache_ttl=1) @@ -683,12 +633,12 @@ def test_get_course_run_details(self, mock_get_edx_api_data): """ course_run = CourseRunFactory() course_run_details = { - 'content_language': course_run['content_language'], - 'weeks_to_complete': course_run['weeks_to_complete'], - 'max_effort': course_run['max_effort'] + "content_language": course_run["content_language"], + "weeks_to_complete": course_run["weeks_to_complete"], + "max_effort": course_run["max_effort"], } mock_get_edx_api_data.return_value = course_run_details - data = get_course_run_details(course_run['key'], ['content_language', 'weeks_to_complete', 'max_effort']) + data = get_course_run_details(course_run["key"], ["content_language", "weeks_to_complete", "max_effort"]) assert mock_get_edx_api_data.called assert data == course_run_details @@ -698,87 +648,95 @@ class TestProgramCourseRunCrawling(TestCase): def setUpClass(cls): super().setUpClass() cls.grandchild_1 = { - 'title': 'grandchild 1', - 'curricula': [{'is_active': True, 'courses': [], 'programs': []}], + "title": "grandchild 1", + "curricula": [{"is_active": True, "courses": [], "programs": []}], } cls.grandchild_2 = { - 'title': 'grandchild 2', - 'curricula': [ + "title": "grandchild 2", + "curricula": [ { - 'is_active': True, - 'courses': [{ - 'course_runs': [ - {'key': 'course-run-4'}, - ], - }], - 'programs': [], + "is_active": True, + "courses": [ + { + "course_runs": [ + {"key": "course-run-4"}, + ], + } + ], + "programs": [], }, ], } cls.grandchild_3 = { - 'title': 'grandchild 3', - 'curricula': [{'is_active': False}], + "title": "grandchild 3", + "curricula": [{"is_active": False}], } cls.child_1 = { - 'title': 'child 1', - 'curricula': [{'is_active': True, 'courses': [], 'programs': [cls.grandchild_1]}], + "title": "child 1", + "curricula": [{"is_active": True, "courses": [], "programs": [cls.grandchild_1]}], } cls.child_2 = { - 'title': 'child 2', - 'curricula': [ + "title": "child 2", + "curricula": [ { - 'is_active': True, - 'courses': [{ - 'course_runs': [ - {'key': 'course-run-3'}, - ], - }], - 'programs': [cls.grandchild_2, cls.grandchild_3], + "is_active": True, + "courses": [ + { + "course_runs": [ + {"key": "course-run-3"}, + ], + } + ], + "programs": [cls.grandchild_2, cls.grandchild_3], }, ], } cls.complex_program = { - 'title': 'complex program', - 'curricula': [ + "title": "complex program", + "curricula": [ { - 'is_active': True, - 'courses': [{ - 'course_runs': [ - {'key': 'course-run-2'}, - ], - }], - 'programs': [cls.child_1, cls.child_2], + "is_active": True, + "courses": [ + { + "course_runs": [ + {"key": "course-run-2"}, + ], + } + ], + "programs": [cls.child_1, cls.child_2], }, ], } cls.simple_program = { - 'title': 'simple program', - 'curricula': [ + "title": "simple program", + "curricula": [ { - 'is_active': True, - 'courses': [{ - 'course_runs': [ - {'key': 'course-run-1'}, - ], - }], - 'programs': [cls.grandchild_1] + "is_active": True, + "courses": [ + { + "course_runs": [ + {"key": "course-run-1"}, + ], + } + ], + "programs": [cls.grandchild_1], }, ], } cls.empty_program = { - 'title': 'notice that I have a curriculum, but no programs inside it', - 'curricula': [ + "title": "notice that I have a curriculum, but no programs inside it", + "curricula": [ { - 'is_active': True, - 'courses': [], - 'programs': [], + "is_active": True, + "courses": [], + "programs": [], }, ], } def test_child_programs_no_curriculum(self): program = { - 'title': 'notice that I do not have a curriculum', + "title": "notice that I do not have a curriculum", } assert not child_programs(program) @@ -802,116 +760,104 @@ def test_course_run_keys_for_program_no_courses(self): assert set() == course_run_keys_for_program(self.empty_program) def test_course_run_keys_for_program_one_course(self): - assert {'course-run-1'} == course_run_keys_for_program(self.simple_program) + assert {"course-run-1"} == course_run_keys_for_program(self.simple_program) def test_course_run_keys_for_program_many_courses(self): expected_course_runs = { - 'course-run-2', - 'course-run-3', - 'course-run-4', + "course-run-2", + "course-run-3", + "course-run-4", } assert expected_course_runs == course_run_keys_for_program(self.complex_program) def test_is_course_run_in_program(self): - assert is_course_run_in_program('course-run-4', self.complex_program) - assert not is_course_run_in_program('course-run-5', self.complex_program) - assert not is_course_run_in_program('course-run-4', self.simple_program) + assert is_course_run_in_program("course-run-4", self.complex_program) + assert not is_course_run_in_program("course-run-5", self.complex_program) + assert not is_course_run_in_program("course-run-4", self.simple_program) @skip_unless_lms class TestGetProgramsByType(CacheIsolationTestCase): - """ Test for the ``get_programs_by_type()`` and the ``get_programs_by_type_slug()`` functions. """ - ENABLED_CACHES = ['default'] + """Test for the ``get_programs_by_type()`` and the ``get_programs_by_type_slug()`` functions.""" + + ENABLED_CACHES = ["default"] @classmethod def setUpClass(cls): - """ Sets up program data. """ + """Sets up program data.""" super().setUpClass() cls.site = SiteFactory() cls.other_site = SiteFactory() cls.masters_program_1 = ProgramFactory.create( - type='Masters', - type_attrs=ProgramTypeAttrsFactory.create(slug="masters") + type="Masters", type_attrs=ProgramTypeAttrsFactory.create(slug="masters") ) cls.masters_program_2 = ProgramFactory.create( - type='Masters', - type_attrs=ProgramTypeAttrsFactory.create(slug="masters") + type="Masters", type_attrs=ProgramTypeAttrsFactory.create(slug="masters") ) cls.masters_program_other_site = ProgramFactory.create( - type='Masters', - type_attrs=ProgramTypeAttrsFactory.create(slug="masters") + type="Masters", type_attrs=ProgramTypeAttrsFactory.create(slug="masters") ) cls.bachelors_program = ProgramFactory.create( - type='Bachelors', - type_attrs=ProgramTypeAttrsFactory.create(slug="bachelors") - ) - cls.no_type_program = ProgramFactory.create( - type=None, - type_attrs=None + type="Bachelors", type_attrs=ProgramTypeAttrsFactory.create(slug="bachelors") ) + cls.no_type_program = ProgramFactory.create(type=None, type_attrs=None) def setUp(self): - """ Loads program data into the cache before each test function. """ + """Loads program data into the cache before each test function.""" super().setUp() self.init_cache() def init_cache(self): - """ This function plays the role of the ``cache_programs`` management command. """ + """This function plays the role of the ``cache_programs`` management command.""" all_programs = [ self.masters_program_1, self.masters_program_2, self.bachelors_program, self.no_type_program, - self.masters_program_other_site + self.masters_program_other_site, ] - cached_programs = { - PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']): program for program in all_programs - } + cached_programs = {PROGRAM_CACHE_KEY_TPL.format(uuid=program["uuid"]): program for program in all_programs} cache.set_many(cached_programs, None) programs_by_type = defaultdict(list) programs_by_type_slug = defaultdict(list) for program in all_programs: - program_type = normalize_program_type(program.get('type')) - program_type_slug = (program.get('type_attrs') or {}).get('slug') + program_type = normalize_program_type(program.get("type")) + program_type_slug = (program.get("type_attrs") or {}).get("slug") site_id = self.site.id if program == self.masters_program_other_site: site_id = self.other_site.id - program_type_cache_key = PROGRAMS_BY_TYPE_CACHE_KEY_TPL.format( - site_id=site_id, - program_type=program_type - ) + program_type_cache_key = PROGRAMS_BY_TYPE_CACHE_KEY_TPL.format(site_id=site_id, program_type=program_type) program_type_slug_cache_key = PROGRAMS_BY_TYPE_SLUG_CACHE_KEY_TPL.format( - site_id=site_id, - program_slug=program_type_slug + site_id=site_id, program_slug=program_type_slug ) - programs_by_type[program_type_cache_key].append(program['uuid']) - programs_by_type_slug[program_type_slug_cache_key].append(program['uuid']) + programs_by_type[program_type_cache_key].append(program["uuid"]) + programs_by_type_slug[program_type_slug_cache_key].append(program["uuid"]) cache.set_many(programs_by_type, None) cache.set_many(programs_by_type_slug, None) def test_get_masters_programs(self): expected_programs = [self.masters_program_1, self.masters_program_2] - self.assertCountEqual(expected_programs, get_programs_by_type(self.site, 'masters')) - self.assertCountEqual(expected_programs, get_programs_by_type_slug(self.site, 'masters')) + self.assertCountEqual(expected_programs, get_programs_by_type(self.site, "masters")) + self.assertCountEqual(expected_programs, get_programs_by_type_slug(self.site, "masters")) def test_get_bachelors_programs(self): expected_programs = [self.bachelors_program] - assert expected_programs == get_programs_by_type(self.site, 'bachelors') - assert expected_programs == get_programs_by_type_slug(self.site, 'bachelors') + assert expected_programs == get_programs_by_type(self.site, "bachelors") + assert expected_programs == get_programs_by_type_slug(self.site, "bachelors") def test_get_no_such_type_programs(self): expected_programs = [] - assert expected_programs == get_programs_by_type(self.site, 'doctorate') - assert expected_programs == get_programs_by_type_slug(self.site, 'doctorate') + assert expected_programs == get_programs_by_type(self.site, "doctorate") + assert expected_programs == get_programs_by_type_slug(self.site, "doctorate") def test_get_masters_programs_other_site(self): expected_programs = [self.masters_program_other_site] - assert expected_programs == get_programs_by_type(self.other_site, 'masters') - assert expected_programs == get_programs_by_type_slug(self.other_site, 'masters') + assert expected_programs == get_programs_by_type(self.other_site, "masters") + assert expected_programs == get_programs_by_type_slug(self.other_site, "masters") def test_get_programs_null_type(self): expected_programs = [self.no_type_program] @@ -924,9 +870,9 @@ def test_get_programs_false_type(self): assert expected_programs == get_programs_by_type_slug(self.site, False) def test_normalize_program_type(self): - assert 'none' == normalize_program_type(None) - assert 'false' == normalize_program_type(False) - assert 'true' == normalize_program_type(True) - assert '' == normalize_program_type('') - assert 'masters' == normalize_program_type('Masters') - assert 'masters' == normalize_program_type('masters') + assert "none" == normalize_program_type(None) + assert "false" == normalize_program_type(False) + assert "true" == normalize_program_type(True) + assert "" == normalize_program_type("") + assert "masters" == normalize_program_type("Masters") + assert "masters" == normalize_program_type("masters") diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index f9b46ee546f7..cc5c202fd4b8 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -1,6 +1,5 @@ """Helper functions for working with the catalog service.""" - import copy import datetime import logging @@ -11,6 +10,7 @@ from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from edx_rest_api_client.auth import SuppliedJwtAuth +from edx_rest_api_client.client import USER_AGENT from opaque_keys.edx.keys import CourseKey from pytz import UTC @@ -25,17 +25,15 @@ PROGRAMS_BY_TYPE_CACHE_KEY_TPL, PROGRAMS_BY_TYPE_SLUG_CACHE_KEY_TPL, SITE_PATHWAY_IDS_CACHE_KEY_TPL, - SITE_PROGRAM_UUIDS_CACHE_KEY_TPL + SITE_PROGRAM_UUIDS_CACHE_KEY_TPL, ) from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.lib.edx_api_utils import get_api_data -from edx_rest_api_client.client import USER_AGENT - logger = logging.getLogger(__name__) -missing_details_msg_tpl = 'Failed to get details for program {uuid} from the cache.' +missing_details_msg_tpl = "Failed to get details for program {uuid} from the cache." def get_catalog_api_base_url(site=None): @@ -43,7 +41,7 @@ def get_catalog_api_base_url(site=None): Returns a base API url used to make Catalog API requests. """ if site: - return site.configuration.get_value('COURSE_CATALOG_API_URL') + return site.configuration.get_value("COURSE_CATALOG_API_URL") return CatalogIntegration.current().get_internal_api_url() @@ -54,7 +52,7 @@ def get_catalog_api_client(user): """ jwt = create_jwt_for_user(user) client = requests.Session() - client.headers.update({'User-Agent': USER_AGENT}) + client.headers.update({"User-Agent": USER_AGENT}) client.auth = SuppliedJwtAuth(jwt) return client @@ -82,8 +80,8 @@ def check_catalog_integration_and_get_user(error_message_field): user = catalog_integration.get_service_user() except ObjectDoesNotExist: logger.error( - 'Catalog service user with username [{username}] does not exist. ' - '{field} will not be retrieved.'.format( + "Catalog service user with username [{username}] does not exist. " + "{field} will not be retrieved.".format( username=catalog_integration.service_username, field=error_message_field, ) @@ -92,7 +90,7 @@ def check_catalog_integration_and_get_user(error_message_field): return user, catalog_integration else: logger.error( - 'Unable to retrieve details about {field} because Catalog Integration is not enabled'.format( + "Unable to retrieve details about {field} because Catalog Integration is not enabled".format( field=error_message_field, ) ) @@ -118,7 +116,7 @@ def get_programs(site=None, uuid=None, uuids=None, course=None, catalog_course_u dict, if a specific program is requested. """ if len([arg for arg in (site, uuid, uuids, course, catalog_course_uuid, organization) if arg is not None]) != 1: - raise TypeError('get_programs takes exactly one argument') + raise TypeError("get_programs takes exactly one argument") if uuid: program = cache.get(PROGRAM_CACHE_KEY_TPL.format(uuid=uuid)) @@ -139,12 +137,12 @@ def get_programs(site=None, uuid=None, uuids=None, course=None, catalog_course_u # without programs. After this is changed, log any cache misses here. return [] elif site: - site_config = getattr(site, 'configuration', None) - catalog_url = site_config.get_value('COURSE_CATALOG_API_URL') if site_config else None + site_config = getattr(site, "configuration", None) + catalog_url = site_config.get_value("COURSE_CATALOG_API_URL") if site_config else None if site_config and catalog_url: uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), []) if not uuids: - logger.warning(f'Failed to get program UUIDs from the cache for site {site.domain}.') + logger.warning(f"Failed to get program UUIDs from the cache for site {site.domain}.") else: uuids = [] elif organization: @@ -169,9 +167,7 @@ def get_programs_by_type(site, program_type): ) uuids = cache.get(program_type_cache_key, []) if not uuids: - logger.warning(str( - f'Failed to get program UUIDs from cache for site {site.id} and type {program_type}' - )) + logger.warning(str(f"Failed to get program UUIDs from cache for site {site.id} and type {program_type}")) return get_programs_by_uuids(uuids) @@ -192,9 +188,9 @@ def get_programs_by_type_slug(site, program_type_slug): ) uuids = cache.get(program_type_slug_cache_key, []) if not uuids: - logger.warning(str( - f'Failed to get program UUIDs from cache for site {site.id} and type slug {program_type_slug}' - )) + logger.warning( + str(f"Failed to get program UUIDs from cache for site {site.id} and type slug {program_type_slug}") + ) return get_programs_by_uuids(uuids) @@ -216,16 +212,14 @@ def get_programs_by_uuids(uuids): # immediately afterwards will succeed in bringing back all the keys. This # behavior can be mitigated by trying again for the missing keys, which is # what we do here. Splitting the get_many into smaller chunks may also help. - missing_uuids = set(uuid_strings) - {program['uuid'] for program in programs} + missing_uuids = set(uuid_strings) - {program["uuid"] for program in programs} if missing_uuids: - logger.info( - f'Failed to get details for {len(missing_uuids)} programs. Retrying.' - ) + logger.info(f"Failed to get details for {len(missing_uuids)} programs. Retrying.") retried_programs = cache.get_many([PROGRAM_CACHE_KEY_TPL.format(uuid=uuid) for uuid in missing_uuids]) programs += list(retried_programs.values()) - still_missing_uuids = set(uuid_strings) - {program['uuid'] for program in programs} + still_missing_uuids = set(uuid_strings) - {program["uuid"] for program in programs} for uuid in still_missing_uuids: logger.warning(missing_details_msg_tpl.format(uuid=uuid)) @@ -242,21 +236,21 @@ def get_program_types(name=None): list of dict, representing program types. dict, if a specific program type is requested. """ - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Program types') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Program types") if user: - cache_key = f'{catalog_integration.CACHE_KEY}.program_types' + cache_key = f"{catalog_integration.CACHE_KEY}.program_types" data = get_api_data( catalog_integration, "program_types", api_client=get_catalog_api_client(user), base_api_url=get_catalog_api_base_url(), - cache_key=cache_key if catalog_integration.is_cache_enabled else None + cache_key=cache_key if catalog_integration.is_cache_enabled else None, ) # Filter by name if a name was provided if name: - data = next(program_type for program_type in data if program_type['name'] == name) + data = next(program_type for program_type in data if program_type["name"] == name) return data else: @@ -278,7 +272,7 @@ def get_pathways(site, pathway_id=None): list of dict, representing pathways. dict, if a specific pathway is requested. """ - missing_details_msg_tpl = 'Failed to get details for credit pathway {id} from the cache.' + missing_details_msg_tpl = "Failed to get details for credit pathway {id} from the cache." if pathway_id: pathway = cache.get(PATHWAY_CACHE_KEY_TPL.format(id=pathway_id)) @@ -288,7 +282,7 @@ def get_pathways(site, pathway_id=None): return pathway pathway_ids = cache.get(SITE_PATHWAY_IDS_CACHE_KEY_TPL.format(domain=site.domain), []) if not pathway_ids: - logger.warning('Failed to get credit pathway ids from the cache.') + logger.warning("Failed to get credit pathway ids from the cache.") pathways = cache.get_many([PATHWAY_CACHE_KEY_TPL.format(id=pathway_id) for pathway_id in pathway_ids]) pathways = list(pathways.values()) @@ -301,18 +295,14 @@ def get_pathways(site, pathway_id=None): # immediately afterwards will succeed in bringing back all the keys. This # behavior can be mitigated by trying again for the missing keys, which is # what we do here. Splitting the get_many into smaller chunks may also help. - missing_ids = set(pathway_ids) - {pathway['id'] for pathway in pathways} + missing_ids = set(pathway_ids) - {pathway["id"] for pathway in pathways} if missing_ids: - logger.info( - f'Failed to get details for {len(missing_ids)} pathways. Retrying.' - ) + logger.info(f"Failed to get details for {len(missing_ids)} pathways. Retrying.") - retried_pathways = cache.get_many( - [PATHWAY_CACHE_KEY_TPL.format(id=pathway_id) for pathway_id in missing_ids] - ) + retried_pathways = cache.get_many([PATHWAY_CACHE_KEY_TPL.format(id=pathway_id) for pathway_id in missing_ids]) pathways += list(retried_pathways.values()) - still_missing_ids = set(pathway_ids) - {pathway['id'] for pathway in pathways} + still_missing_ids = set(pathway_ids) - {pathway["id"] for pathway in pathways} for missing_id in still_missing_ids: logger.warning(missing_details_msg_tpl.format(id=missing_id)) @@ -326,9 +316,9 @@ def get_currency_data(): list of dict, representing program types. dict, if a specific program type is requested. """ - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Currency data') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Currency data") if user: - cache_key = f'{catalog_integration.CACHE_KEY}.currency' + cache_key = f"{catalog_integration.CACHE_KEY}.currency" return get_api_data( catalog_integration, @@ -336,12 +326,13 @@ def get_currency_data(): api_client=get_catalog_api_client(user), base_api_url=get_catalog_api_base_url(), traverse_pagination=False, - cache_key=cache_key if catalog_integration.is_cache_enabled else None) + cache_key=cache_key if catalog_integration.is_cache_enabled else None, + ) else: return [] -def format_price(price, symbol='$', code='USD'): +def format_price(price, symbol="$", code="USD"): """ Format the price to have the appropriate currency and digits.. @@ -351,8 +342,8 @@ def format_price(price, symbol='$', code='USD'): :return: A formatted price string, i.e. '$10 USD', '$10.52 USD'. """ if int(price) == price: - return f'{symbol}{int(price)} {code}' - return f'{symbol}{price:0.2f} {code}' + return f"{symbol}{int(price)} {code}" + return f"{symbol}{price:0.2f} {code}" def get_localized_price_text(price, request): @@ -362,14 +353,10 @@ def get_localized_price_text(price, request): If the users location has been added to the request, this will return the given price based on conversion rate from the Catalog service and return a localized string otherwise will return the default price in USD """ - user_currency = { - 'symbol': '$', - 'rate': 1, - 'code': 'USD' - } + user_currency = {"symbol": "$", "rate": 1, "code": "USD"} # session.country_code is added via CountryMiddleware in the LMS - user_location = getattr(request, 'session', {}).get('country_code') + user_location = getattr(request, "session", {}).get("country_code") # Override default user_currency if location is available if user_location and get_currency_data: @@ -378,9 +365,7 @@ def get_localized_price_text(price, request): user_currency = currency_data.get(user_country.alpha_3, user_currency) return format_price( - price=(price * user_currency['rate']), - symbol=user_currency['symbol'], - code=user_currency['code'] + price=(price * user_currency["rate"]), symbol=user_currency["symbol"], code=user_currency["code"] ) @@ -404,18 +389,18 @@ def get_programs_with_type(site, include_hidden=True): programs = get_programs(site) if programs: - program_types = {program_type['name']: program_type for program_type in get_program_types()} + program_types = {program_type["name"]: program_type for program_type in get_program_types()} for program in programs: - if program['type'] not in program_types: + if program["type"] not in program_types: continue - if program['hidden'] and not include_hidden: + if program["hidden"] and not include_hidden: continue # deepcopy the program dict here so we are not adding # the type to the cached object program_with_type = copy.deepcopy(program) - program_with_type['type'] = program_types[program['type']] + program_with_type["type"] = program_types[program["type"]] programs_with_type.append(program_with_type) return programs_with_type @@ -429,19 +414,19 @@ def get_course_runs(): list of dict with each record representing a course run. """ course_runs = [] - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course runs') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Course runs") if user: querystring = { - 'page_size': catalog_integration.page_size, - 'exclude_utm': 1, + "page_size": catalog_integration.page_size, + "exclude_utm": 1, } course_runs = get_api_data( catalog_integration, - 'course_runs', + "course_runs", api_client=get_catalog_api_client(user), base_api_url=get_catalog_api_base_url(), - querystring=querystring + querystring=querystring, ) return course_runs @@ -449,22 +434,22 @@ def get_course_runs(): def get_course_runs_for_course(course_uuid): # lint-amnesty, pylint: disable=missing-function-docstring if course_uuid is None: - raise ValueError('missing course_uuid') - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course runs') + raise ValueError("missing course_uuid") + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Course runs") if user: cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_uuid}.course_runs" data = get_api_data( catalog_integration, - 'courses', + "courses", resource_id=course_uuid, api_client=get_catalog_api_client(user), base_api_url=get_catalog_api_base_url(), cache_key=cache_key if catalog_integration.is_cache_enabled else None, long_term_cache=True, - many=False + many=False, ) - return data.get('course_runs', []) + return data.get("course_runs", []) else: return [] @@ -479,22 +464,22 @@ def get_owners_for_course(course_uuid): if course_uuid is None: return [] - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Owners') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Owners") if user: cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_uuid}.course_runs" data = get_api_data( catalog_integration, - 'courses', + "courses", resource_id=course_uuid, api_client=get_catalog_api_client(user), base_api_url=get_catalog_api_base_url(), cache_key=cache_key if catalog_integration.is_cache_enabled else None, traverse_pagination=False, long_term_cache=True, - many=False + many=False, ) - return data.get('owners', []) + return data.get("owners", []) return [] @@ -513,7 +498,7 @@ def get_course_uuid_for_course(course_run_key): if course_run_key is None: return None - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course UUID') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Course UUID") if user: api_client = get_catalog_api_client(user) base_api_url = get_catalog_api_base_url() @@ -522,7 +507,7 @@ def get_course_uuid_for_course(course_run_key): course_run_data = get_api_data( catalog_integration, - 'course_runs', + "course_runs", resource_id=str(course_run_key), api_client=api_client, base_api_url=base_api_url, @@ -532,14 +517,14 @@ def get_course_uuid_for_course(course_run_key): traverse_pagination=False, ) - course_key_str = course_run_data.get('course', None) + course_key_str = course_run_data.get("course", None) if course_key_str: run_cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_key_str}" data = get_api_data( catalog_integration, - 'courses', + "courses", resource_id=course_key_str, api_client=api_client, base_api_url=base_api_url, @@ -548,7 +533,7 @@ def get_course_uuid_for_course(course_run_key): many=False, traverse_pagination=False, ) - uuid_str = data.get('uuid', None) + uuid_str = data.get("uuid", None) if uuid_str: return uuid.UUID(uuid_str) return None @@ -599,22 +584,23 @@ def get_fulfillable_course_runs_for_entitlement(entitlement, course_runs): # Only retrieve list of published course runs that can still be enrolled and upgraded search_time = datetime.datetime.now(UTC) for course_run in course_runs: - course_id = CourseKey.from_string(course_run.get('key')) + course_id = CourseKey.from_string(course_run.get("key")) (user_enrollment_mode, is_active) = CourseEnrollment.enrollment_mode_for_user( - user=entitlement.user, - course_id=course_id + user=entitlement.user, course_id=course_id ) is_enrolled_in_mode = is_active and (user_enrollment_mode == entitlement.mode) - if (is_enrolled_in_mode and - entitlement.enrollment_course_run and - course_id == entitlement.enrollment_course_run.course_id): + if ( + is_enrolled_in_mode + and entitlement.enrollment_course_run + and course_id == entitlement.enrollment_course_run.course_id + ): # User is enrolled in the course so we should include it in the list of enrollable sessions always # this will ensure it is available for the UI enrollable_sessions.append(course_run) elif not is_enrolled_in_mode and is_course_run_entitlement_fulfillable(course_id, entitlement, search_time): enrollable_sessions.append(course_run) - enrollable_sessions.sort(key=lambda session: session.get('start')) + enrollable_sessions.sort(key=lambda session: session.get("start")) return enrollable_sessions @@ -633,18 +619,21 @@ def get_course_run_details(course_run_key, fields): return course_run_details user, catalog_integration = check_catalog_integration_and_get_user( - error_message_field=f'Data for course_run {course_run_key}' + error_message_field=f"Data for course_run {course_run_key}" ) if user: - cache_key = f'{catalog_integration.CACHE_KEY}.course_runs' + cache_key = f"{catalog_integration.CACHE_KEY}.course_runs" course_run_details = get_api_data( catalog_integration, - 'course_runs', + "course_runs", api_client=get_catalog_api_client(user), base_api_url=get_catalog_api_base_url(), resource_id=course_run_key, - cache_key=cache_key, many=False, traverse_pagination=False, fields=fields + cache_key=cache_key, + many=False, + traverse_pagination=False, + fields=fields, ) return course_run_details @@ -664,10 +653,7 @@ def is_course_run_in_program(course_run_key, program): # walks the structure to collect the set of course run keys, # and then sees if `course_run_key` is in that set. # If we need to optimize this later, we can. - course_run_key_str = ( - str(course_run_key) if isinstance(course_run_key, CourseKey) - else course_run_key - ) + course_run_key_str = str(course_run_key) if isinstance(course_run_key, CourseKey) else course_run_key course_run_keys = course_run_keys_for_program(program) return course_run_key_str in course_run_keys @@ -711,7 +697,7 @@ def child_programs(program): if not curriculum: return [] result = [] - for child in curriculum.get('programs', []): + for child in curriculum.get("programs", []): result.append(child) result.extend(child_programs(child)) return result @@ -722,7 +708,7 @@ def _primary_active_curriculum(program): Returns the first active curriculum in the given program, or None. """ try: - return next(c for c in program.get('curricula', []) if c.get('is_active')) + return next(c for c in program.get("curricula", []) if c.get("is_active")) except StopIteration: return @@ -734,9 +720,7 @@ def _course_runs_from_container(container): a program itself (since either may contain a ``courses`` list). """ return [ - course_run.get('key') - for course in container.get('courses', []) - for course_run in course.get('course_runs', []) + course_run.get("key") for course in container.get("courses", []) for course_run in course.get("course_runs", []) ] @@ -746,14 +730,11 @@ def _courses_from_container(container): which is either the ``curriculum`` field of a program, or a program itself (since either may contain a ``courses`` list). """ - return [ - course.get('uuid') - for course in container.get('courses', []) - ] + return [course.get("uuid") for course in container.get("courses", [])] def normalize_program_type(program_type): - """ Function that normalizes a program type string for use in a cache key. """ + """Function that normalizes a program type string for use in a cache key.""" return str(program_type).lower() @@ -776,15 +757,15 @@ def get_course_data(course_key_str, fields, querystring=None): Returns: dict with details about specified course. """ - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course UUID') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Course UUID") if user: api_client = get_catalog_api_client(user) base_api_url = get_catalog_api_base_url() if course_key_str: - course_cache_key = f'{catalog_integration.CACHE_KEY}.course.{course_key_str}' + course_cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_key_str}" data = get_api_data( catalog_integration, - 'courses', + "courses", resource_id=course_key_str, api_client=api_client, base_api_url=base_api_url, @@ -792,7 +773,7 @@ def get_course_data(course_key_str, fields, querystring=None): long_term_cache=True, many=False, fields=fields, - querystring=querystring + querystring=querystring, ) if data: return data @@ -809,22 +790,22 @@ def get_course_run_data(course_run_id, fields): Returns: dict with details about specified course run. """ - user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course Run ID') + user, catalog_integration = check_catalog_integration_and_get_user(error_message_field="Course Run ID") if user: api_client = get_catalog_api_client(user) base_api_url = get_catalog_api_base_url() if course_run_id: - course_run_cache_key = f'{catalog_integration.CACHE_KEY}.course_run.{course_run_id}' + course_run_cache_key = f"{catalog_integration.CACHE_KEY}.course_run.{course_run_id}" data = get_api_data( catalog_integration, - 'course_runs', + "course_runs", resource_id=course_run_id, api_client=api_client, base_api_url=base_api_url, cache_key=course_run_cache_key if catalog_integration.is_cache_enabled else None, long_term_cache=True, many=False, - fields=fields + fields=fields, ) if data: return data diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py index 2e24dba1a536..59bf3d86cda7 100644 --- a/openedx/core/djangoapps/credentials/tests/test_utils.py +++ b/openedx/core/djangoapps/credentials/tests/test_utils.py @@ -1,4 +1,5 @@ """Tests covering Credentials utilities.""" + import uuid from unittest import mock @@ -13,19 +14,19 @@ from openedx.core.djangoapps.credentials.utils import ( get_courses_completion_status, get_credentials, - get_credentials_records_url + get_credentials_records_url, ) from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms -UTILS_MODULE = 'openedx.core.djangoapps.credentials.utils' +UTILS_MODULE = "openedx.core.djangoapps.credentials.utils" @skip_unless_lms class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase): - """ Tests for credentials utility functions. """ + """Tests for credentials utility functions.""" - ENABLED_CACHES = ['default'] + ENABLED_CACHES = ["default"] def setUp(self): super().setUp() @@ -35,7 +36,7 @@ def setUp(self): self.credentials_config = self.create_credentials_config(cache_ttl=1) self.user = UserFactory() - @mock.patch(UTILS_MODULE + '.get_api_data') + @mock.patch(UTILS_MODULE + ".get_api_data") def test_get_many(self, mock_get_edx_api_data): expected = factories.UserCredential.create_batch(3) mock_get_edx_api_data.return_value = expected @@ -47,17 +48,17 @@ def test_get_many(self, mock_get_edx_api_data): __, __, kwargs = call querystring = { - 'username': self.user.username, - 'status': 'awarded', - 'only_visible': 'True', + "username": self.user.username, + "status": "awarded", + "only_visible": "True", } - cache_key = f'{self.credentials_config.CACHE_KEY}.{self.user.username}' - assert kwargs['querystring'] == querystring - assert kwargs['cache_key'] == cache_key + cache_key = f"{self.credentials_config.CACHE_KEY}.{self.user.username}" + assert kwargs["querystring"] == querystring + assert kwargs["cache_key"] == cache_key assert actual == expected - @mock.patch(UTILS_MODULE + '.get_api_data') + @mock.patch(UTILS_MODULE + ".get_api_data") def test_get_one(self, mock_get_edx_api_data): expected = factories.UserCredential() mock_get_edx_api_data.return_value = expected @@ -70,32 +71,32 @@ def test_get_one(self, mock_get_edx_api_data): __, __, kwargs = call querystring = { - 'username': self.user.username, - 'status': 'awarded', - 'only_visible': 'True', - 'program_uuid': program_uuid, + "username": self.user.username, + "status": "awarded", + "only_visible": "True", + "program_uuid": program_uuid, } - cache_key = f'{self.credentials_config.CACHE_KEY}.{self.user.username}.{program_uuid}' - assert kwargs['querystring'] == querystring - assert kwargs['cache_key'] == cache_key + cache_key = f"{self.credentials_config.CACHE_KEY}.{self.user.username}.{program_uuid}" + assert kwargs["querystring"] == querystring + assert kwargs["cache_key"] == cache_key assert actual == expected - @mock.patch(UTILS_MODULE + '.get_api_data') + @mock.patch(UTILS_MODULE + ".get_api_data") def test_type_filter(self, mock_get_edx_api_data): - get_credentials(self.user, credential_type='program') + get_credentials(self.user, credential_type="program") mock_get_edx_api_data.assert_called_once() call = mock_get_edx_api_data.mock_calls[0] __, __, kwargs = call querystring = { - 'username': self.user.username, - 'status': 'awarded', - 'only_visible': 'True', - 'type': 'program', + "username": self.user.username, + "status": "awarded", + "only_visible": "True", + "type": "program", } - assert kwargs['querystring'] == querystring + assert kwargs["querystring"] == querystring def test_get_credentials_records_url(self): """ @@ -107,30 +108,32 @@ def test_get_credentials_records_url(self): result = get_credentials_records_url("abcdefgh-ijkl-mnop-qrst-uvwxyz123456") assert result == "https://credentials.example.com/records/programs/abcdefghijklmnopqrstuvwxyz123456" - @mock.patch('requests.Response.raise_for_status') - @mock.patch('requests.Response.json') - @mock.patch(UTILS_MODULE + '.get_credentials_api_client') + @mock.patch("requests.Response.raise_for_status") + @mock.patch("requests.Response.json") + @mock.patch(UTILS_MODULE + ".get_credentials_api_client") def test_get_courses_completion_status(self, mock_get_api_client, mock_json, mock_raise): """ Test to verify the functionality of get_courses_completion_status """ UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME) course_statuses = factories.UserCredentialsCourseRunStatus.create_batch(3) - response_data = [course_status['course_run']['key'] for course_status in course_statuses] + response_data = [course_status["course_run"]["key"] for course_status in course_statuses] mock_raise.return_value = None - mock_json.return_value = {'lms_user_id': self.user.id, - 'status': course_statuses, - 'username': self.user.username} + mock_json.return_value = { + "lms_user_id": self.user.id, + "status": course_statuses, + "username": self.user.username, + } mock_get_api_client.return_value.post.return_value = Response() - course_run_keys = [course_status['course_run']['key'] for course_status in course_statuses] + course_run_keys = [course_status["course_run"]["key"] for course_status in course_statuses] api_response, is_exception = get_courses_completion_status(self.user.id, course_run_keys) assert api_response == response_data assert is_exception is False - @mock.patch('requests.Response.raise_for_status') + @mock.patch("requests.Response.raise_for_status") def test_get_courses_completion_status_api_error(self, mock_raise): - mock_raise.return_value = HTTPError('An Error occured') + mock_raise.return_value = HTTPError("An Error occured") UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME) - api_response, is_exception = get_courses_completion_status(self.user.id, ['fake1', 'fake2', 'fake3']) + api_response, is_exception = get_courses_completion_status(self.user.id, ["fake1", "fake2", "fake3"]) assert api_response == [] assert is_exception is True diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index 437f7c8c95f0..33f4fc33e93e 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -1,4 +1,5 @@ """Helper functions for working with Credentials.""" + import logging from typing import Dict, List from urllib.parse import urljoin @@ -104,9 +105,7 @@ def get_credentials( # 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}" @@ -139,14 +138,9 @@ def get_courses_completion_status(username, course_run_ids): 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) - ) + api_client = get_credentials_api_client(User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME)) api_response = api_client.post( completion_status_url, json={ diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index c9858a7bd27c..03d767364aa0 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -1,6 +1,5 @@ """Helper functions for working with Programs.""" - import datetime import logging from collections import defaultdict @@ -36,7 +35,7 @@ from openedx.core.djangoapps.catalog.utils import ( get_fulfillable_course_runs_for_entitlement, get_pathways, - get_programs + get_programs, ) from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -69,16 +68,17 @@ def get_buy_subscription_url(program_uuid, skus): """ Returns the URL to the Subscription Purchase page for the given program UUID and course Skus. """ - formatted_skus = urlencode({'sku': skus}, doseq=True) - url = f'{settings.SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL}{program_uuid}/?{formatted_skus}' + formatted_skus = urlencode({"sku": skus}, doseq=True) + url = f"{settings.SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL}{program_uuid}/?{formatted_skus}" return url def get_program_urls(program_data): """Returns important urls of program.""" from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, strip_course_id - program_uuid = program_data.get('uuid') - skus = program_data.get('skus') + + program_uuid = program_data.get("uuid") + skus = program_data.get("skus") ecommerce_service = EcommerceService() # TODO: Don't have business logic of course-certificate==record-available here in LMS. @@ -87,17 +87,15 @@ def get_program_urls(program_data): # a waffle flag. This feature is in active developoment. program_record_url = get_credentials_records_url(program_uuid=program_uuid) urls = { - 'program_listing_url': reverse('program_listing_view'), - 'track_selection_url': strip_course_id( - reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY}) - ), - 'commerce_api_url': reverse('commerce_api:v0:baskets:create'), - 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus), - 'program_record_url': program_record_url, - 'buy_subscription_url': get_buy_subscription_url(program_uuid, skus), - 'manage_subscription_url': settings.SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL, - 'orders_and_subscriptions_url': settings.ORDER_HISTORY_MICROFRONTEND_URL, - 'subscriptions_learner_help_center_url': settings.SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL + "program_listing_url": reverse("program_listing_view"), + "track_selection_url": strip_course_id(reverse("course_modes_choose", kwargs={"course_id": FAKE_COURSE_KEY})), + "commerce_api_url": reverse("commerce_api:v0:baskets:create"), + "buy_button_url": ecommerce_service.get_checkout_page_url(*skus), + "program_record_url": program_record_url, + "buy_subscription_url": get_buy_subscription_url(program_uuid, skus), + "manage_subscription_url": settings.SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL, + "orders_and_subscriptions_url": settings.ORDER_HISTORY_MICROFRONTEND_URL, + "subscriptions_learner_help_center_url": settings.SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL, } return urls @@ -107,12 +105,12 @@ def get_industry_and_credit_pathways(program_data, site): industry_pathways = [] credit_pathways = [] try: - for pathway_id in program_data['pathway_ids']: + for pathway_id in program_data["pathway_ids"]: pathway = get_pathways(site, pathway_id) - if pathway and pathway['email']: - if pathway['pathway_type'] == PathwayType.CREDIT.value: + if pathway and pathway["email"]: + if pathway["pathway_type"] == PathwayType.CREDIT.value: credit_pathways.append(pathway) - elif pathway['pathway_type'] == PathwayType.INDUSTRY.value: + elif pathway["pathway_type"] == PathwayType.INDUSTRY.value: industry_pathways.append(pathway) # if pathway caching did not complete fully (no pathway_ids) except KeyError: @@ -124,9 +122,9 @@ def get_industry_and_credit_pathways(program_data, site): def get_program_marketing_url(programs_config, mobile_only=False): """Build a URL used to link to programs on the marketing site.""" if mobile_only: - marketing_url = 'edxapp://course?programs' + marketing_url = "edxapp://course?programs" else: - marketing_url = urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/') + marketing_url = urljoin(settings.MKTG_URLS.get("ROOT"), programs_config.marketing_path).rstrip("/") return marketing_url @@ -135,8 +133,8 @@ def get_program_subscriptions_marketing_url(): """Build a URL used to link to subscription eligible programs on the marketing site.""" marketing_urls = settings.MKTG_URLS return urljoin( - marketing_urls.get('ROOT'), - marketing_urls.get('PROGRAM_SUBSCRIPTIONS'), + marketing_urls.get("ROOT"), + marketing_urls.get("PROGRAM_SUBSCRIPTIONS"), ) @@ -153,13 +151,13 @@ def attach_program_detail_url(programs, mobile_only=False): """ for program in programs: if mobile_only: - detail_fragment_url = reverse('program_details_fragment_view', kwargs={'program_uuid': program['uuid']}) - path_id = detail_fragment_url.replace('/dashboard/', '') - detail_url = f'edxapp://enrolled_program_info?path_id={path_id}' + detail_fragment_url = reverse("program_details_fragment_view", kwargs={"program_uuid": program["uuid"]}) + path_id = detail_fragment_url.replace("/dashboard/", "") + detail_url = f"edxapp://enrolled_program_info?path_id={path_id}" else: - detail_url = reverse('program_details_view', kwargs={'program_uuid': program['uuid']}) + detail_url = reverse("program_details_view", kwargs={"program_uuid": program["uuid"]}) - program['detail_url'] = detail_url + program["detail_url"] = detail_url return programs @@ -220,14 +218,14 @@ def invert_programs(self): inverted_programs = defaultdict(list) for program in self.programs: - for course in program['courses']: - course_uuid = course['uuid'] + for course in program["courses"]: + course_uuid = course["uuid"] if course_uuid in self.course_uuids: program_list = inverted_programs[course_uuid] if program not in program_list: program_list.append(program) - for course_run in course['course_runs']: - course_run_id = course_run['key'] + for course_run in course["course_runs"]: + course_run_id = course_run["key"] if course_run_id in self.course_run_ids: program_list = inverted_programs[course_run_id] if program not in program_list: @@ -235,7 +233,7 @@ def invert_programs(self): # Sort programs by title for consistent presentation. for program_list in inverted_programs.values(): - program_list.sort(key=lambda p: p['title']) + program_list.sort(key=lambda p: p["title"]) return inverted_programs @@ -280,25 +278,22 @@ def _is_course_in_progress(self, now, course): Returns: bool, indicating whether the course is in progress. """ - enrolled_runs = [run for run in course['course_runs'] if run['key'] in self.course_run_ids] + enrolled_runs = [run for run in course["course_runs"] if run["key"] in self.course_run_ids] # Check if the user is enrolled in a required run and mode/seat. - runs_with_required_mode = [ - run for run in enrolled_runs - if run['type'] == self.enrolled_run_modes[run['key']] - ] + runs_with_required_mode = [run for run in enrolled_runs if run["type"] == self.enrolled_run_modes[run["key"]]] if runs_with_required_mode: - not_failed_runs = [run for run in runs_with_required_mode if run['key'] not in self.failed_course_runs] + not_failed_runs = [run for run in runs_with_required_mode if run["key"] not in self.failed_course_runs] if not_failed_runs: return True # Check if seats required for course completion are still available. upgrade_deadlines = [] for run in enrolled_runs: - for seat in run['seats']: - if seat['type'] == run['type'] and run['type'] != self.enrolled_run_modes[run['key']]: - upgrade_deadlines.append(seat['upgrade_deadline']) + for seat in run["seats"]: + if seat["type"] == run["type"] and run["type"] != self.enrolled_run_modes[run["key"]]: + upgrade_deadlines.append(seat["upgrade_deadline"]) # An upgrade deadline of None means the course is always upgradeable. return any(not deadline or deadline and parse(deadline) > now for deadline in upgrade_deadlines) @@ -326,24 +321,21 @@ def progress(self, programs=None, count_only=True): program_copy = deepcopy(program) completed, in_progress, not_started = [], [], [] - for course in program_copy['courses']: + for course in program_copy["courses"]: active_entitlement = CourseEntitlement.get_entitlement_if_active( - user=self.user, - course_uuid=course['uuid'] + user=self.user, course_uuid=course["uuid"] ) if self._is_course_complete(course): completed.append(course) elif self._is_course_enrolled(course) or active_entitlement: # Show all currently enrolled courses and active entitlements as in progress if active_entitlement: - course['course_runs'] = get_fulfillable_course_runs_for_entitlement( - active_entitlement, - course['course_runs'] + course["course_runs"] = get_fulfillable_course_runs_for_entitlement( + active_entitlement, course["course_runs"] ) - course['user_entitlement'] = active_entitlement.to_dict() - course['enroll_url'] = reverse( - 'entitlements_api:v1:enrollments', - args=[str(active_entitlement.uuid)] + course["user_entitlement"] = active_entitlement.to_dict() + course["enroll_url"] = reverse( + "entitlements_api:v1:enrollments", args=[str(active_entitlement.uuid)] ) in_progress.append(course) else: @@ -351,20 +343,20 @@ def progress(self, programs=None, count_only=True): if course_in_progress: in_progress.append(course) else: - course['expired'] = not course_in_progress + course["expired"] = not course_in_progress not_started.append(course) else: not_started.append(course) - progress.append({ - 'uuid': program_copy['uuid'], - 'completed': len(completed) if count_only else completed, - 'in_progress': len(in_progress) if count_only else in_progress, - 'not_started': len(not_started) if count_only else not_started, - 'all_unenrolled': all( - not self._is_course_enrolled(course) for course in program_copy['courses'] - ), - }) + progress.append( + { + "uuid": program_copy["uuid"], + "completed": len(completed) if count_only else completed, + "in_progress": len(in_progress) if count_only else in_progress, + "not_started": len(not_started) if count_only else not_started, + "all_unenrolled": all(not self._is_course_enrolled(course) for course in program_copy["courses"]), + } + ) return progress @@ -383,7 +375,7 @@ def completed_programs_with_available_dates(self): for program in self.programs: available_date = self._available_date_for_program(program, certificates_by_run) if available_date: - completed[program['uuid']] = available_date + completed[program["uuid"]] = available_date return completed def _available_date_for_program(self, program_data, certificates): @@ -397,11 +389,11 @@ def _available_date_for_program(self, program_data, certificates): Returns a datetime object or None if the program is not complete. """ program_available_date = None - for course in program_data['courses']: + for course in program_data["courses"]: earliest_course_run_date = None - for course_run in course['course_runs']: - key = CourseKey.from_string(course_run['key']) + for course_run in course["course_runs"]: + key = CourseKey.from_string(course_run["key"]) # Get a certificate if one exists certificate = certificates.get(key) @@ -409,20 +401,15 @@ def _available_date_for_program(self, program_data, certificates): continue # Modes must match (see _is_course_complete() comments for why) - course_run_mode = self._course_run_mode_translation(course_run['type']) + course_run_mode = self._course_run_mode_translation(course_run["type"]) certificate_mode = self._certificate_mode_translation(certificate.mode) modes_match = course_run_mode == certificate_mode # Grab the available date and keep it if it's the earliest one for this catalog course. if modes_match and CertificateStatuses.is_passing_status(certificate.status): course_overview = CourseOverview.get_from_id(key) - available_date = certificate_api.available_date_for_certificate( - course_overview, - certificate - ) - earliest_course_run_date = min( - date for date in [available_date, earliest_course_run_date] if date - ) + available_date = certificate_api.available_date_for_certificate(course_overview, certificate) + earliest_course_run_date = min(date for date in [available_date, earliest_course_run_date] if date) # If we're missing a cert for a course, the program isn't completed and we should just bail now if earliest_course_run_date is None: @@ -477,7 +464,7 @@ def reshape(course_run): with course run certificates. """ return { - 'course_run_id': course_run['key'], + "course_run_id": course_run["key"], # A course run's type is assumed to indicate which mode must be # completed in order for the run to count towards program completion. # This supports the same flexible program construction allowed by the @@ -485,10 +472,10 @@ def reshape(course_run): # count towards completion of a course in a program). This may change # in the future to make use of the more rigid set of "applicable seat # types" associated with each program type in the catalog. - 'type': self._course_run_mode_translation(course_run['type']), + "type": self._course_run_mode_translation(course_run["type"]), } - return any(reshape(course_run) in self.completed_course_runs for course_run in course['course_runs']) + return any(reshape(course_run) in self.completed_course_runs for course_run in course["course_runs"]) @cached_property def completed_course_runs(self): @@ -498,7 +485,7 @@ def completed_course_runs(self): Returns: list of dicts, each representing a course run certificate """ - return self.course_runs_with_state['completed'] + return self.course_runs_with_state["completed"] @cached_property def failed_course_runs(self): @@ -508,7 +495,7 @@ def failed_course_runs(self): Returns: list of strings, each a course run ID """ - return [run['course_run_id'] for run in self.course_runs_with_state['failed']] + return [run["course_run_id"] for run in self.course_runs_with_state["failed"]] @cached_property def course_runs_with_state(self): @@ -525,10 +512,10 @@ def course_runs_with_state(self): completed_runs, failed_runs = [], [] for certificate in course_run_certificates: - course_key = certificate['course_key'] + course_key = certificate["course_key"] course_data = { - 'course_run_id': str(course_key), - 'type': self._certificate_mode_translation(certificate['type']), + "course_run_id": str(course_key), + "type": self._certificate_mode_translation(certificate["type"]), } try: @@ -538,15 +525,12 @@ def course_runs_with_state(self): else: may_certify = certificate_api.certificates_viewable_for_course(course_overview) - if ( - CertificateStatuses.is_passing_status(certificate['status']) - and may_certify - ): + if CertificateStatuses.is_passing_status(certificate["status"]) and may_certify: completed_runs.append(course_data) else: failed_runs.append(course_data) - return {'completed': completed_runs, 'failed': failed_runs} + return {"completed": completed_runs, "failed": failed_runs} def _is_course_enrolled(self, course): """Check if a user is enrolled in a course. @@ -560,7 +544,7 @@ def _is_course_enrolled(self, course): Returns: bool, indicating whether the course is in progress. """ - return any(course_run['key'] in self.course_run_ids for course_run in course['course_runs']) + return any(course_run["key"] in self.course_run_ids for course_run in course["course_runs"]) # pylint: disable=missing-docstring @@ -578,7 +562,7 @@ def __init__(self, program_data, user, mobile_only=False): self.data = program_data self.user = user self.mobile_only = mobile_only - self.data.update({'is_mobile_only': self.mobile_only}) + self.data.update({"is_mobile_only": self.mobile_only}) self.course_run_key = None self.course_overview = None @@ -586,7 +570,7 @@ def __init__(self, program_data, user, mobile_only=False): def extend(self): """Execute extension handlers, returning the extended data.""" - self._execute('_extend') + self._execute("_extend") self._collect_one_click_purchase_eligibility_data() return self.data @@ -601,51 +585,55 @@ def _handlers(cls, prefix): def _extend_course_runs(self): """Execute course run data handlers.""" - for course in self.data['courses']: - for course_run in course['course_runs']: + for course in self.data["courses"]: + for course_run in course["course_runs"]: # State to be shared across handlers. - self.course_run_key = CourseKey.from_string(course_run['key']) + self.course_run_key = CourseKey.from_string(course_run["key"]) # Some (old) course runs may exist for a program which do not exist in LMS. In that case, # continue without the course run. try: self.course_overview = CourseOverview.get_from_id(self.course_run_key) except CourseOverview.DoesNotExist: - log.warning('Failed to get course overview for course run key: %s', course_run.get('key')) + log.warning("Failed to get course overview for course run key: %s", course_run.get("key")) else: self.enrollment_start = self.course_overview.enrollment_start or DEFAULT_ENROLLMENT_START_DATE - self._execute('_attach_course_run', course_run) + self._execute("_attach_course_run", course_run) def _attach_course_run_certificate_url(self, run_mode): certificate_data = certificate_api.certificate_downloadable_status(self.user, self.course_run_key) - certificate_uuid = certificate_data.get('uuid') - run_mode['certificate_url'] = certificate_api.get_certificate_url( - user_id=self.user.id, # Providing user_id allows us to fall back to PDF certificates - # if web certificates are not configured for a given course. - course_id=self.course_run_key, - uuid=certificate_uuid, - ) if certificate_uuid else None + certificate_uuid = certificate_data.get("uuid") + run_mode["certificate_url"] = ( + certificate_api.get_certificate_url( + user_id=self.user.id, # Providing user_id allows us to fall back to PDF certificates + # if web certificates are not configured for a given course. + course_id=self.course_run_key, + uuid=certificate_uuid, + ) + if certificate_uuid + else None + ) def _attach_course_run_course_url(self, run_mode): if self.mobile_only: - run_mode['course_url'] = 'edxapp://enrolled_course_info?course_id={}'.format(run_mode.get('key')) + run_mode["course_url"] = "edxapp://enrolled_course_info?course_id={}".format(run_mode.get("key")) else: - run_mode['course_url'] = reverse('course_root', args=[self.course_run_key]) + run_mode["course_url"] = reverse("course_root", args=[self.course_run_key]) def _attach_course_run_enrollment_open_date(self, run_mode): - run_mode['enrollment_open_date'] = strftime_localized(self.enrollment_start, 'SHORT_DATE') + run_mode["enrollment_open_date"] = strftime_localized(self.enrollment_start, "SHORT_DATE") def _attach_course_run_is_course_ended(self, run_mode): end_date = self.course_overview.end or datetime.datetime.max.replace(tzinfo=utc) - run_mode['is_course_ended'] = end_date < datetime.datetime.now(utc) + run_mode["is_course_ended"] = end_date < datetime.datetime.now(utc) def _attach_course_run_is_enrolled(self, run_mode): - run_mode['is_enrolled'] = CourseEnrollment.is_enrolled(self.user, self.course_run_key) + run_mode["is_enrolled"] = CourseEnrollment.is_enrolled(self.user, self.course_run_key) def _attach_course_run_is_enrollment_open(self, run_mode): enrollment_end = self.course_overview.enrollment_end or datetime.datetime.max.replace(tzinfo=utc) - run_mode['is_enrollment_open'] = self.enrollment_start <= datetime.datetime.now(utc) < enrollment_end + run_mode["is_enrollment_open"] = self.enrollment_start <= datetime.datetime.now(utc) < enrollment_end def _attach_course_run_advertised_start(self, run_mode): """ @@ -654,10 +642,10 @@ def _attach_course_run_advertised_start(self, run_mode): to start on December 1, 2016, the author might provide 'Winter 2016' as the advertised start. """ - run_mode['advertised_start'] = self.course_overview.advertised_start + run_mode["advertised_start"] = self.course_overview.advertised_start def _attach_course_run_upgrade_url(self, run_mode): - required_mode_slug = run_mode['type'] + required_mode_slug = run_mode["type"] enrolled_mode_slug, _ = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_run_key) is_mode_mismatch = required_mode_slug != enrolled_mode_slug is_upgrade_required = is_mode_mismatch and CourseEnrollment.is_enrolled(self.user, self.course_run_key) @@ -666,19 +654,19 @@ def _attach_course_run_upgrade_url(self, run_mode): # Requires that the ecommerce service be in use. required_mode = CourseMode.mode_for_course(self.course_run_key, required_mode_slug) ecommerce = EcommerceService() - sku = getattr(required_mode, 'sku', None) + sku = getattr(required_mode, "sku", None) if ecommerce.is_enabled(self.user) and sku: - run_mode['upgrade_url'] = ecommerce.get_checkout_page_url(required_mode.sku) + run_mode["upgrade_url"] = ecommerce.get_checkout_page_url(required_mode.sku) else: - run_mode['upgrade_url'] = None + run_mode["upgrade_url"] = None else: - run_mode['upgrade_url'] = None + run_mode["upgrade_url"] = None def _attach_course_run_may_certify(self, run_mode): - run_mode['may_certify'] = certificate_api.certificates_viewable_for_course(self.course_overview) + run_mode["may_certify"] = certificate_api.certificates_viewable_for_course(self.course_overview) def _attach_course_run_is_mobile_only(self, run_mode): - run_mode['is_mobile_only'] = self.mobile_only + run_mode["is_mobile_only"] = self.mobile_only def _filter_out_courses_with_entitlements(self, courses): """ @@ -694,17 +682,17 @@ def _filter_out_courses_with_entitlements(self, courses): Returns: A subset of the given list of course dicts """ - course_uuids = {course['uuid'] for course in courses} + course_uuids = {course["uuid"] for course in courses} # Filter the entitlements' modes with a case-insensitive match against applicable seat_types entitlements = self.user.courseentitlement_set.filter( - mode__in=self.data['applicable_seat_types'], + mode__in=self.data["applicable_seat_types"], course_uuid__in=course_uuids, ) # Here we check the entitlements' expired_at_datetime property rather than filter by the expired_at attribute # to ensure that the expiration status is as up to date as possible entitlements = [e for e in entitlements if not e.expired_at_datetime] courses_with_entitlements = {str(entitlement.course_uuid) for entitlement in entitlements} - return [course for course in courses if course['uuid'] not in courses_with_entitlements] + return [course for course in courses if course["uuid"] not in courses_with_entitlements] def _filter_out_courses_with_enrollments(self, courses): """ @@ -717,14 +705,11 @@ def _filter_out_courses_with_enrollments(self, courses): Returns: A subset of the given list of course dicts """ - enrollments = self.user.courseenrollment_set.filter( - is_active=True, - mode__in=self.data['applicable_seat_types'] - ) + enrollments = self.user.courseenrollment_set.filter(is_active=True, mode__in=self.data["applicable_seat_types"]) course_runs_with_enrollments = {str(enrollment.course_id) for enrollment in enrollments} courses_without_enrollments = [] for course in courses: - if all(str(run['key']) not in course_runs_with_enrollments for run in course['course_runs']): + if all(str(run["key"]) not in course_runs_with_enrollments for run in course["course_runs"]): courses_without_enrollments.append(course) return courses_without_enrollments @@ -734,40 +719,40 @@ def _collect_one_click_purchase_eligibility_data(self): # lint-amnesty, pylint: Extend the program data with data about learner's eligibility for one click purchase, discount data of the program and SKUs of seats that should be added to basket. """ - if 'professional' in self.data['applicable_seat_types']: - self.data['applicable_seat_types'].append('no-id-professional') - applicable_seat_types = {seat for seat in self.data['applicable_seat_types'] if seat != 'credit'} + if "professional" in self.data["applicable_seat_types"]: + self.data["applicable_seat_types"].append("no-id-professional") + applicable_seat_types = {seat for seat in self.data["applicable_seat_types"] if seat != "credit"} - is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase'] - bundle_uuid = self.data.get('uuid') + is_learner_eligible_for_one_click_purchase = self.data["is_program_eligible_for_one_click_purchase"] + bundle_uuid = self.data.get("uuid") skus = [] - bundle_variant = 'full' + bundle_variant = "full" if is_learner_eligible_for_one_click_purchase: # lint-amnesty, pylint: disable=too-many-nested-blocks - courses = self.data['courses'] + courses = self.data["courses"] if not self.user.is_anonymous: courses = self._filter_out_courses_with_enrollments(courses) courses = self._filter_out_courses_with_entitlements(courses) - if len(courses) < len(self.data['courses']): - bundle_variant = 'partial' + if len(courses) < len(self.data["courses"]): + bundle_variant = "partial" for course in courses: entitlement_product = False - for entitlement in course.get('entitlements', []): + for entitlement in course.get("entitlements", []): # We add the first entitlement product found with an applicable seat type because, at this time, # we are assuming that, for any given course, there is at most one paid entitlement available. - if entitlement['mode'] in applicable_seat_types: - skus.append(entitlement['sku']) + if entitlement["mode"] in applicable_seat_types: + skus.append(entitlement["sku"]) entitlement_product = True break if not entitlement_product: - course_runs = course.get('course_runs', []) - published_course_runs = [run for run in course_runs if run['status'] == 'published'] + course_runs = course.get("course_runs", []) + published_course_runs = [run for run in course_runs if run["status"] == "published"] if len(published_course_runs) == 1: - for seat in published_course_runs[0]['seats']: - if seat['type'] in applicable_seat_types and seat['sku']: - skus.append(seat['sku']) + for seat in published_course_runs[0]["seats"]: + if seat["type"] in applicable_seat_types and seat["sku"]: + skus.append(seat["sku"]) break else: # If a course in the program has more than 1 published course run @@ -798,36 +783,36 @@ def _collect_one_click_purchase_eligibility_data(self): # lint-amnesty, pylint: params = dict(sku=skus, is_anonymous=True) else: if bundle_uuid: - params = dict( - sku=skus, username=self.user.username, bundle=bundle_uuid - ) + params = dict(sku=skus, username=self.user.username, bundle=bundle_uuid) else: params = dict(sku=skus, username=self.user.username) response = api_client.get(api_url, params=params) response.raise_for_status() discount_data = response.json() - program_discounted_price = discount_data['total_incl_tax'] - program_full_price = discount_data['total_incl_tax_excl_discounts'] - discount_data['is_discounted'] = program_discounted_price < program_full_price - discount_data['discount_value'] = program_full_price - program_discounted_price - - self.data.update({ - 'discount_data': discount_data, - 'full_program_price': discount_data['total_incl_tax'], - 'variant': bundle_variant - }) + program_discounted_price = discount_data["total_incl_tax"] + program_full_price = discount_data["total_incl_tax_excl_discounts"] + discount_data["is_discounted"] = program_discounted_price < program_full_price + discount_data["discount_value"] = program_full_price - program_discounted_price + + self.data.update( + { + "discount_data": discount_data, + "full_program_price": discount_data["total_incl_tax"], + "variant": bundle_variant, + } + ) except RequestException: - log.exception('Failed to get discount price for following product SKUs: %s ', ', '.join(skus)) - self.data.update({ - 'discount_data': {'is_discounted': False} - }) + log.exception("Failed to get discount price for following product SKUs: %s ", ", ".join(skus)) + self.data.update({"discount_data": {"is_discounted": False}}) else: is_learner_eligible_for_one_click_purchase = False - self.data.update({ - 'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase, - 'skus': skus, - }) + self.data.update( + { + "is_learner_eligible_for_one_click_purchase": is_learner_eligible_for_one_click_purchase, + "skus": skus, + } + ) def get_certificates(user, extended_program): @@ -846,42 +831,43 @@ def get_certificates(user, extended_program): """ certificates = [] - for course in extended_program['courses']: - for course_run in course['course_runs']: - url = course_run.get('certificate_url') - if url and course_run.get('may_certify'): - certificates.append({ - 'type': 'course', - 'title': course_run['title'], - 'url': url, - }) + for course in extended_program["courses"]: + for course_run in course["course_runs"]: + url = course_run.get("certificate_url") + if url and course_run.get("may_certify"): + certificates.append( + { + "type": "course", + "title": course_run["title"], + "url": url, + } + ) # We only want one certificate per course to be returned. break - program_credentials = get_credentials(user, program_uuid=extended_program['uuid'], credential_type='program') + program_credentials = get_credentials(user, program_uuid=extended_program["uuid"], credential_type="program") # only include a program certificate if a certificate is available for every course - if program_credentials and (len(certificates) == len(extended_program['courses'])): - enabled_force_program_cert_auth = configuration_helpers.get_value( - 'force_program_cert_auth', - True - ) - cert_url = program_credentials[0]['certificate_url'] + if program_credentials and (len(certificates) == len(extended_program["courses"])): + enabled_force_program_cert_auth = configuration_helpers.get_value("force_program_cert_auth", True) + cert_url = program_credentials[0]["certificate_url"] url = get_logged_in_program_certificate_url(cert_url) if enabled_force_program_cert_auth else cert_url - certificates.append({ - 'type': 'program', - 'title': extended_program['title'], - 'url': url, - }) + certificates.append( + { + "type": "program", + "title": extended_program["title"], + "url": url, + } + ) return certificates def get_logged_in_program_certificate_url(certificate_url): parsed_url = urlparse(certificate_url) - query_string = 'next=' + parsed_url.path - url_parts = (parsed_url.scheme, parsed_url.netloc, '/login/', '', query_string, '') + query_string = "next=" + parsed_url.path + url_parts = (parsed_url.scheme, parsed_url.netloc, "/login/", "", query_string, "") return urlunparse(url_parts) @@ -903,49 +889,44 @@ def __init__(self, program_data, user): self.instructors = [] # Values for programs' price calculation. - self.data['avg_price_per_course'] = 0.0 - self.data['number_of_courses'] = 0 - self.data['full_program_price'] = 0.0 + self.data["avg_price_per_course"] = 0.0 + self.data["number_of_courses"] = 0 + self.data["full_program_price"] = 0.0 def _extend_program(self): """Aggregates data from the program data structure.""" - cache_key = 'program.instructors.{uuid}'.format( - uuid=self.data['uuid'] - ) + cache_key = "program.instructors.{uuid}".format(uuid=self.data["uuid"]) program_instructors = cache.get(cache_key) - for course in self.data['courses']: - self._execute('_collect_course', course) + for course in self.data["courses"]: + self._execute("_collect_course", course) if not program_instructors: - for course_run in course['course_runs']: - self._execute('_collect_instructors', course_run) + for course_run in course["course_runs"]: + self._execute("_collect_instructors", course_run) if not program_instructors: # We cache the program instructors list to avoid repeated modulestore queries program_instructors = self.instructors cache.set(cache_key, program_instructors, 3600) - if 'instructor_ordering' not in self.data: + if "instructor_ordering" not in self.data: # If no instructor ordering is set in discovery, it doesn't populate this key - self.data['instructor_ordering'] = [] + self.data["instructor_ordering"] = [] sorted_instructor_names = [ - ' '.join([name for name in (instructor['given_name'], instructor['family_name']) if name]) - for instructor in self.data['instructor_ordering'] + " ".join([name for name in (instructor["given_name"], instructor["family_name"]) if name]) + for instructor in self.data["instructor_ordering"] ] instructors_to_be_sorted = [ - instructor for instructor in program_instructors - if instructor['name'] in sorted_instructor_names + instructor for instructor in program_instructors if instructor["name"] in sorted_instructor_names ] instructors_to_not_be_sorted = [ - instructor for instructor in program_instructors - if instructor['name'] not in sorted_instructor_names + instructor for instructor in program_instructors if instructor["name"] not in sorted_instructor_names ] sorted_instructors = sorted( - instructors_to_be_sorted, - key=lambda item: sorted_instructor_names.index(item['name']) + instructors_to_be_sorted, key=lambda item: sorted_instructor_names.index(item["name"]) ) - self.data['instructors'] = sorted_instructors + instructors_to_not_be_sorted + self.data["instructors"] = sorted_instructors + instructors_to_not_be_sorted def extend(self): """Execute extension handlers, returning the extended data.""" @@ -961,7 +942,7 @@ def _handlers(cls, prefix): return {name for name in chain(cls.__dict__, ProgramDataExtender.__dict__) if name.startswith(prefix)} def _attach_course_run_can_enroll(self, run_mode): - run_mode['can_enroll'] = bool(self.user.has_perm(ENROLL_IN_COURSE, self.course_overview)) + run_mode["can_enroll"] = bool(self.user.has_perm(ENROLL_IN_COURSE, self.course_overview)) def _attach_course_run_certificate_url(self, run_mode): """ @@ -977,16 +958,16 @@ def _attach_course_run_upgrade_url(self, run_mode): if not self.user.is_anonymous: super()._attach_course_run_upgrade_url(run_mode) else: - run_mode['upgrade_url'] = None + run_mode["upgrade_url"] = None def _collect_course_pricing(self, course): - self.data['number_of_courses'] += 1 - course_runs = course['course_runs'] + self.data["number_of_courses"] += 1 + course_runs = course["course_runs"] if course_runs: - seats = course_runs[0]['seats'] + seats = course_runs[0]["seats"] if seats: - self.data['full_program_price'] += float(seats[0]['price']) - self.data['avg_price_per_course'] = self.data['full_program_price'] / self.data['number_of_courses'] + self.data["full_program_price"] += float(seats[0]["price"]) + self.data["avg_price_per_course"] = self.data["full_program_price"] / self.data["number_of_courses"] def _collect_instructors(self, course_run): """ @@ -996,19 +977,21 @@ def _collect_instructors(self, course_run): instructor data into the catalog, retrieve it via the catalog API, and remove this code. """ module_store = modulestore() - course_run_key = CourseKey.from_string(course_run['key']) + course_run_key = CourseKey.from_string(course_run["key"]) course_block = module_store.get_course(course_run_key) if course_block: - course_instructors = getattr(course_block, 'instructor_info', {}) + course_instructors = getattr(course_block, "instructor_info", {}) # Deduplicate program instructors using instructor name - curr_instructors_names = [instructor.get('name', '').strip() for instructor in self.instructors] - for instructor in course_instructors.get('instructors', []): - if instructor.get('name', '').strip() not in curr_instructors_names: + curr_instructors_names = [instructor.get("name", "").strip() for instructor in self.instructors] + for instructor in course_instructors.get("instructors", []): + if instructor.get("name", "").strip() not in curr_instructors_names: self.instructors.append(instructor) -def is_user_enrolled_in_program_type(user, program_type_slug, paid_modes_only=False, enrollments=None, entitlements=None): # lint-amnesty, pylint: disable=line-too-long +def is_user_enrolled_in_program_type( + user, program_type_slug, paid_modes_only=False, enrollments=None, entitlements=None +): # lint-amnesty, pylint: disable=line-too-long """ This method will look at the learners Enrollments and Entitlements to determine if a learner is enrolled in a Program of the given type. @@ -1036,10 +1019,10 @@ def is_user_enrolled_in_program_type(user, program_type_slug, paid_modes_only=Fa return False for program in programs: - for course in program.get('courses', []): - course_uuids.add(course.get('uuid')) - for course_run in course.get('course_runs', []): - course_runs.add(course_run['key']) + for course in program.get("courses", []): + course_uuids.add(course.get("uuid")) + for course_run in course.get("course_runs", []): + course_runs.add(course_run["key"]) # Check Entitlements first, because there will be less Course Entitlements than # Course Run Enrollments. @@ -1050,11 +1033,11 @@ def is_user_enrolled_in_program_type(user, program_type_slug, paid_modes_only=Fa student_enrollments = enrollments if enrollments is not None else get_enrollments(user.username) for enrollment in student_enrollments: - course_run_id = enrollment['course_details']['course_id'] + course_run_id = enrollment["course_details"]["course_id"] if paid_modes_only: course_run_key = CourseKey.from_string(course_run_id) paid_modes = [mode.slug for mode in get_paid_modes_for_course(course_run_key)] - if enrollment['mode'] in paid_modes and course_run_id in course_runs: + if enrollment["mode"] in paid_modes and course_run_id in course_runs: return True elif course_run_id in course_runs: return True @@ -1065,11 +1048,7 @@ def get_subscription_api_client(user): """ Returns an API client which can be used to make Subscriptions API requests. """ - scopes = [ - 'user_id', - 'email', - 'profile' - ] + scopes = ["user_id", "email", "profile"] jwt = create_jwt_for_user(user, scopes=scopes) client = requests.Session() client.auth = SuppliedJwtAuth(jwt) @@ -1086,24 +1065,28 @@ def get_programs_subscription_data(user, program_uuid=None): api_path = f"{settings.SUBSCRIPTIONS_API_PATH}" subscription_data = [] - log.info(f"B2C_SUBSCRIPTIONS: Requesting Program subscription data for user: {user}" + - (f" for program_uuid: {program_uuid}" if program_uuid is not None else "")) + log.info( + f"B2C_SUBSCRIPTIONS: Requesting Program subscription data for user: {user}" + + (f" for program_uuid: {program_uuid}" if program_uuid is not None else "") + ) try: if program_uuid: - response = client.get(api_path, params={'resource_id': program_uuid, 'most_active_and_recent': 'true'}) + response = client.get(api_path, params={"resource_id": program_uuid, "most_active_and_recent": "true"}) response.raise_for_status() - subscription_data = response.json().get('results', []) + subscription_data = response.json().get("results", []) else: next_page = 1 while next_page: response = client.get(api_path, params=dict(page=next_page)) response.raise_for_status() - subscription_data.extend(response.json().get('results', [])) - next_page = response.json().get('next') + subscription_data.extend(response.json().get("results", [])) + next_page = response.json().get("next") except Exception as exc: # pylint: disable=broad-except log.exception( f"B2C_SUBSCRIPTIONS: Failed to retrieve Program Subscription Data for user: {user} with error: {exc}" - + f" for program_uuid: {str(program_uuid)}" if program_uuid is not None else "" + + f" for program_uuid: {str(program_uuid)}" + if program_uuid is not None + else "" ) return subscription_data