diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 35fb7b99a..dfeba45e4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- * nothing unreleased +[4.28.1] +-------- +* feat: Create django admin for default enrollments + [4.28.0] -------- * feat: add default enrollment models diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 495986fe5..49318e874 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.28.0" +__version__ = "4.28.1" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 70058ffb5..f8f467ae1 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -43,6 +43,7 @@ ) from enterprise.api_client.lms import CourseApiClient, EnrollmentApiClient from enterprise.config.models import UpdateRoleAssignmentsWithCustomersConfig +from enterprise.models import DefaultEnterpriseEnrollmentIntention from enterprise.utils import ( discovery_query_url, get_all_field_names, @@ -109,6 +110,31 @@ def get_formset(self, request, obj=None, **kwargs): return formset +class EnterpriseCustomerDefaultEnterpriseEnrollmentIntentionInline(admin.TabularInline): + """ + Django admin model for EnterpriseCustomerCatalog. + The admin interface has the ability to edit models on the same page as a parent model. These are called inlines. + https://docs.djangoproject.com/en/1.8/ref/contrib/admin/#django.contrib.admin.StackedInline + """ + + model = models.DefaultEnterpriseEnrollmentIntention + fields = ('content_key', 'course_run_key_for_enrollment',) + readonly_fields = ('course_run_key_for_enrollment',) + extra = 0 + can_delete = True + + @admin.display(description='Course run key for enrollment') + def course_run_key_for_enrollment(self, obj): + """ + Returns the course run key based on the content type. + If the content type is a course, we retrieve the advertised_course_run key. + """ + content_type = obj.content_type + if content_type == 'course_run': + return obj.content_key + return obj.current_course_run_key + + class PendingEnterpriseCustomerAdminUserInline(admin.TabularInline): """ Django admin inline model for PendingEnterpriseCustomerAdminUser. @@ -227,6 +253,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): EnterpriseCustomerBrandingConfigurationInline, EnterpriseCustomerIdentityProviderInline, EnterpriseCustomerCatalogInline, + EnterpriseCustomerDefaultEnterpriseEnrollmentIntentionInline, PendingEnterpriseCustomerAdminUserInline, ] diff --git a/enterprise/cache_utils.py b/enterprise/cache_utils.py new file mode 100644 index 000000000..82917e572 --- /dev/null +++ b/enterprise/cache_utils.py @@ -0,0 +1,33 @@ +""" +Utils for interacting with cache interfaces. +""" +import hashlib + +from django.conf import settings +from edx_django_utils.cache import RequestCache + +from enterprise import __version__ as code_version + +CACHE_KEY_SEP = ':' +DEFAULT_NAMESPACE = 'edx-enterprise-default' + + +def versioned_cache_key(*args): + """ + Utility to produce a versioned cache key, which includes + an optional settings variable and the current code version, + so that we can perform key-based cache invalidation. + """ + components = [str(arg) for arg in args] + components.append(code_version) + if stamp_from_settings := getattr(settings, 'CACHE_KEY_VERSION_STAMP', None): + components.append(stamp_from_settings) + decoded_cache_key = CACHE_KEY_SEP.join(components) + return hashlib.sha512(decoded_cache_key.encode()).hexdigest() + + +def request_cache(namespace=DEFAULT_NAMESPACE): + """ + Helper that returns a namespaced RequestCache instance. + """ + return RequestCache(namespace=namespace) diff --git a/enterprise/content_metadata/api.py b/enterprise/content_metadata/api.py new file mode 100644 index 000000000..5a1154649 --- /dev/null +++ b/enterprise/content_metadata/api.py @@ -0,0 +1,93 @@ +""" +Python API for interacting with content metadata. +TODO: refactor subsidy_access_policy/content_metadata_api.py +into this module. +""" +import logging + +from django.conf import settings +from django.core.cache import cache +from requests.exceptions import HTTPError + +from enterprise.cache_utils import versioned_cache_key + +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient + +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_TIMEOUT = getattr(settings, 'CONTENT_METADATA_CACHE_TIMEOUT', 60 * 5) + + +def get_and_cache_catalog_content_metadata(enterprise_customer, content_keys, timeout=None): + """ + Returns the metadata corresponding to the requested + ``content_keys`` within the provided ``enterprise_catalog_uuid``, + as told by the enterprise-access service. Utilizes a cache per-content-record, + that is, each combination of (enterprise_catalog_uuid, key) for key in content_keys + is cached independently. + + Returns: A list of dictionaries containing content metadata for the given keys. + Raises: An HTTPError if there's a problem getting the content metadata + via the enterprise-catalog service. + """ + # List of content metadata dicts we'll ultimately return + metadata_results_list = [] + + # We'll start with the assumption that we need to fetch every key + # from the catalog service, and then prune down as we find records + # in the cache + keys_to_fetch = list(set(content_keys)) + + # Maintains a mapping of cache keys for each content key + cache_keys_by_content_key = {} + for content_key in content_keys: + cache_key = versioned_cache_key( + 'get_catalog_content_metadata', + enterprise_customer, + content_key, + ) + cache_keys_by_content_key[content_key] = cache_key + + # Use our computed cache keys to do a bulk get from the Django cache + cached_content_metadata = cache.get_many(cache_keys_by_content_key.values()) + + # Go through our cache hits, append data to results and prune + # from the list of keys to fetch from the catalog service. + for content_key, cache_key in cache_keys_by_content_key.items(): + if cache_key in cached_content_metadata: + logger.info(f'cache hit for enterprise customer {enterprise_customer} and content {content_key}') + metadata_results_list.append(cached_content_metadata[cache_key]) + keys_to_fetch.remove(content_key) + + # Here's the list of results fetched from the catalog service + fetched_metadata = [] + if keys_to_fetch: + fetched_metadata = EnterpriseCatalogApiClient().get_content_metadata( + enterprise_customer=enterprise_customer, + enterprise_catalogs=None, + content_keys_filter=keys_to_fetch + ) + + # Do a bulk set into the cache of everything we just had to fetch from the catalog service + content_metadata_to_cache = {} + for fetched_record in fetched_metadata: + cache_key = cache_keys_by_content_key.get(fetched_record.get('key')) + content_metadata_to_cache[cache_key] = fetched_record + + cache.set_many(content_metadata_to_cache, timeout or DEFAULT_CACHE_TIMEOUT) + + # Add to our results list everything we just had to fetch + metadata_results_list.extend(fetched_metadata) + + # Log a warning for any content key that the caller asked for metadata about, + # but which was not found in cache OR from the catalog service. + missing_keys = set(content_keys) - {record.get('key') for record in metadata_results_list} + if missing_keys: + logger.warning( + 'Could not fetch content keys %s from catalog %s', + missing_keys, + enterprise_catalog_uuid, + ) + + # Return our results list + return metadata_results_list diff --git a/enterprise/models.py b/enterprise/models.py index 3e85a3a45..1852fdd8c 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -61,6 +61,7 @@ FulfillmentTypes, json_serialized_course_modes, ) +from enterprise.content_metadata.api import get_and_cache_catalog_content_metadata from enterprise.errors import LinkUserToEnterpriseError from enterprise.event_bus import send_learner_credit_course_enrollment_revoked_event from enterprise.logging import getEnterpriseLogger @@ -81,7 +82,7 @@ localized_utcnow, logo_path, serialize_notification_content, - track_enrollment, + track_enrollment, get_advertised_course_run, ) from enterprise.validators import ( validate_content_filter_fields, @@ -2511,12 +2512,41 @@ class DefaultEnterpriseEnrollmentIntention(TimeStampedModel, SoftDeletableModel) ) history = HistoricalRecords() + def get_cached_content_metadata(self): + """ + Retrieves the cached content metadata for the instance content_key + Determines catalog from the catalogs associated to the enterprise customer + """ + try: + content_metadata = get_and_cache_catalog_content_metadata( + enterprise_customer=self.enterprise_customer, + content_keys=[self.content_key], + ) + return content_metadata + except Exception as exc: # pylint: disable=broad-except + LOGGER.exception( + "Unable to retrieve content_metadata for customer" + "enterprise_customer:{}, " + "content_key:{}" + .format(self.enterprise_customer, self.content_key), + exc_info=exc, + ) + return None + @cached_property def current_course_run(self): # pragma: no cover """ Metadata describing the current course run for this default enrollment intention. """ - return {} + content_metadata_items = self.get_cached_content_metadata() + if not content_metadata_items: + return {} + + content_metadata_item = content_metadata_items[0] + if self.content_type == 'course': + return get_advertised_course_run(content_metadata_item) + course_runs = content_metadata_item.get('course_runs', {}) + return course_runs.get(self.content_key, {}) @property def current_course_run_key(self): # pragma: no cover @@ -2539,6 +2569,11 @@ def current_course_run_enroll_by_date(self): # pragma: no cover """ return datetime.datetime.min + def save(self, *args, **kwargs): + if not self.content_type: + self.content_type = 'course_run' if self.content_key.startswith('course-v1') else 'course' + super().save(*args, **kwargs) + class DefaultEnterpriseEnrollmentRealization(TimeStampedModel): """