Skip to content

Commit

Permalink
feat: Create django admin for default enrollments
Browse files Browse the repository at this point in the history
  • Loading branch information
brobro10000 committed Oct 17, 2024
1 parent a274850 commit f7de046
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "4.28.0"
__version__ = "4.28.1"
27 changes: 27 additions & 0 deletions enterprise/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -227,6 +253,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin):
EnterpriseCustomerBrandingConfigurationInline,
EnterpriseCustomerIdentityProviderInline,
EnterpriseCustomerCatalogInline,
EnterpriseCustomerDefaultEnterpriseEnrollmentIntentionInline,
PendingEnterpriseCustomerAdminUserInline,
]

Expand Down
33 changes: 33 additions & 0 deletions enterprise/cache_utils.py
Original file line number Diff line number Diff line change
@@ -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)
93 changes: 93 additions & 0 deletions enterprise/content_metadata/api.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 37 additions & 2 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down

0 comments on commit f7de046

Please sign in to comment.