From b8e2795465d0bf015fdcc432cae612f34477d8a9 Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Thu, 24 Oct 2024 08:22:32 -0700 Subject: [PATCH 1/4] feat: updating the group name field to 250 characters (#2271) --- CHANGELOG.rst | 4 +++ enterprise/__init__.py | 2 +- ...egroup_applies_to_all_contexts_and_more.py | 31 +++++++++++++++++++ ...226_alter_enterprisegroup_name_and_more.py | 23 ++++++++++++++ enterprise/models.py | 2 +- 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 enterprise/migrations/0225_remove_enterprisegroup_applies_to_all_contexts_and_more.py create mode 100644 enterprise/migrations/0226_alter_enterprisegroup_name_and_more.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dcd2dfba3..7e2fb2dfe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- * nothing unreleased +[4.28.4] +-------- +* feat: updating the character count for group name to 255 + [4.28.3] -------- * feat: removing all references of to-be-deleted field diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 1ce69bd81..c9501e203 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.28.3" +__version__ = "4.28.4" diff --git a/enterprise/migrations/0225_remove_enterprisegroup_applies_to_all_contexts_and_more.py b/enterprise/migrations/0225_remove_enterprisegroup_applies_to_all_contexts_and_more.py new file mode 100644 index 000000000..a7dad1c08 --- /dev/null +++ b/enterprise/migrations/0225_remove_enterprisegroup_applies_to_all_contexts_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.16 on 2024-10-23 22:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0224_alter_enterprisegroup_applies_to_all_contexts_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='enterprisegroup', + name='applies_to_all_contexts', + ), + migrations.RemoveField( + model_name='historicalenterprisegroup', + name='applies_to_all_contexts', + ), + migrations.AlterField( + model_name='enterprisegroup', + name='name', + field=models.CharField(help_text='Specifies enterprise group name.', max_length=250), + ), + migrations.AlterField( + model_name='historicalenterprisegroup', + name='name', + field=models.CharField(help_text='Specifies enterprise group name.', max_length=250), + ), + ] diff --git a/enterprise/migrations/0226_alter_enterprisegroup_name_and_more.py b/enterprise/migrations/0226_alter_enterprisegroup_name_and_more.py new file mode 100644 index 000000000..17b331f81 --- /dev/null +++ b/enterprise/migrations/0226_alter_enterprisegroup_name_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-23 22:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0225_remove_enterprisegroup_applies_to_all_contexts_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='enterprisegroup', + name='name', + field=models.CharField(help_text='Specifies enterprise group name.', max_length=255), + ), + migrations.AlterField( + model_name='historicalenterprisegroup', + name='name', + field=models.CharField(help_text='Specifies enterprise group name.', max_length=255), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index e3f616f6a..0051f20db 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -4484,7 +4484,7 @@ class EnterpriseGroup(TimeStampedModel, SoftDeletableModel): """ uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) name = models.CharField( - max_length=25, + max_length=255, blank=False, help_text=_( 'Specifies enterprise group name.' From 3bd4bd6ed453f469f800441eab1b0b4e119a5066 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 24 Oct 2024 11:54:00 -0400 Subject: [PATCH 2/4] feat: Create django admin for default enrollments (#2264) --- CHANGELOG.rst | 4 + docs/decisions/0015-default-enrollments.rst | 9 +- enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 74 ++++++++- enterprise/cache_utils.py | 25 +++ enterprise/content_metadata/api.py | 60 +++++++ ...rollmentintention_content_type_and_more.py | 27 ++++ enterprise/models.py | 151 ++++++++++++++++-- test_utils/factories.py | 22 +++ tests/test_admin/test_view.py | 1 - tests/test_enterprise/api/test_views.py | 1 - tests/test_models.py | 118 +++++++++++++- 12 files changed, 466 insertions(+), 28 deletions(-) create mode 100644 enterprise/cache_utils.py create mode 100644 enterprise/content_metadata/api.py create mode 100644 enterprise/migrations/0227_alter_defaultenterpriseenrollmentintention_content_type_and_more.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7e2fb2dfe..6d976535d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- * nothing unreleased +[4.29.0] +-------- +* feat: Create django admin for default enrollments + [4.28.4] -------- * feat: updating the character count for group name to 255 diff --git a/docs/decisions/0015-default-enrollments.rst b/docs/decisions/0015-default-enrollments.rst index 4ebd3b83e..262286006 100644 --- a/docs/decisions/0015-default-enrollments.rst +++ b/docs/decisions/0015-default-enrollments.rst @@ -34,10 +34,10 @@ Core requirements Decision ======== We will implement two new models: -* ``DefaultEnterpriseEnrollmentIntention`` to represent the course/runs that - learners should be automatically enrolled into, post-logistration, for a given enterprise. -* ``DefaultEnterpriseEnrollmentRealization``which represents the mapping between the intention - and actual, **realized** enrollment record(s) for the learner/customer. + * ``DefaultEnterpriseEnrollmentIntention`` to represent the course/runs that learners + should be automatically enrolled into, post-logistration, for a given enterprise. + * ``DefaultEnterpriseEnrollmentRealization`` which represents the mapping between the intention + and actual, **realized** enrollment record(s) for the learner/customer. Qualities --------- @@ -58,6 +58,7 @@ however, we will always discern the correct course run key to use for enrollment Post-enrollment related models (e.g., ``EnterpriseCourseEnrollment`` and ``DefaultEnterpriseEnrollmentRealization``) will always primarily be associated with the course run associated with the ``DefaultEnterpriseEnrollmentIntention``: + * If content_key is a top-level course, the course run key used when enrolling (converting to ``EnterpriseCourseEnrollment`` and ``DefaultEnterpriseEnrollmentRealization``) is the currently advertised course run. diff --git a/enterprise/__init__.py b/enterprise/__init__.py index c9501e203..ab6503366 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.28.4" +__version__ = "4.29.0" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 66bb01aaf..cc4edccb1 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,34 @@ 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_key', 'course_run_key_for_enrollment',) + readonly_fields = ('course_key', 'course_run_key_for_enrollment',) + extra = 0 + can_delete = True + + @admin.display(description='Course key') + def course_key(self, obj): + """ + Returns the course run key. + """ + return obj.course_key + + @admin.display(description='Course run key for enrollment') + def course_run_key_for_enrollment(self, obj): + """ + Returns the course run key. + """ + return obj.course_run_key + + class PendingEnterpriseCustomerAdminUserInline(admin.TabularInline): """ Django admin inline model for PendingEnterpriseCustomerAdminUser. @@ -227,6 +256,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): EnterpriseCustomerBrandingConfigurationInline, EnterpriseCustomerIdentityProviderInline, EnterpriseCustomerCatalogInline, + EnterpriseCustomerDefaultEnterpriseEnrollmentIntentionInline, PendingEnterpriseCustomerAdminUserInline, ] @@ -1294,7 +1324,6 @@ class LearnerCreditEnterpriseCourseEnrollmentAdmin(admin.ModelAdmin): 'uuid', 'fulfillment_type', 'enterprise_course_enrollment', - 'is_revoked', 'modified', ) @@ -1326,14 +1355,32 @@ class DefaultEnterpriseEnrollmentIntentionAdmin(admin.ModelAdmin): list_display = ( 'uuid', 'enterprise_customer', + 'content_key', 'content_type', + 'is_removed', + ) + + list_filter = ('is_removed',) + + fields = ( + 'enterprise_customer', 'content_key', + 'uuid', + 'is_removed', + 'content_type', + 'course_key', + 'course_run_key', + 'created', + 'modified', ) readonly_fields = ( - 'current_course_run_key', - 'current_course_run_enrollable', - 'current_course_run_enroll_by_date', + 'uuid', + 'content_type', + 'course_key', + 'course_run_key', + 'created', + 'modified', ) search_fields = ( @@ -1345,5 +1392,22 @@ class DefaultEnterpriseEnrollmentIntentionAdmin(admin.ModelAdmin): ordering = ('-modified',) class Meta: - fields = '__all__' model = models.DefaultEnterpriseEnrollmentIntention + + def get_queryset(self, request): # pylint: disable=unused-argument + """ + Return a QuerySet of all model instances. + """ + return self.model.all_objects.get_queryset() + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """ + Customize the form field for the `is_removed` field. + """ + formfield = super().formfield_for_dbfield(db_field, request, **kwargs) + + if db_field.name == 'is_removed': + formfield.help_text = 'Whether this record is soft-deleted. Soft-deleted records ' \ + 'are not used but may be re-enabled if needed.' + + return formfield diff --git a/enterprise/cache_utils.py b/enterprise/cache_utils.py new file mode 100644 index 000000000..7e8252460 --- /dev/null +++ b/enterprise/cache_utils.py @@ -0,0 +1,25 @@ +""" +Utils for interacting with cache interfaces. +""" +import hashlib + +from django.conf import settings + +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() diff --git a/enterprise/content_metadata/api.py b/enterprise/content_metadata/api.py new file mode 100644 index 000000000..4023c1603 --- /dev/null +++ b/enterprise/content_metadata/api.py @@ -0,0 +1,60 @@ +""" +Python API for interacting with content metadata. +""" +import logging + +from edx_django_utils.cache import TieredCache +from requests.exceptions import HTTPError + +from django.conf import settings + +from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient +from enterprise.cache_utils import versioned_cache_key + +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_TIMEOUT = getattr(settings, 'CONTENT_METADATA_CACHE_TIMEOUT', 60 * 5) + + +def get_and_cache_customer_content_metadata(enterprise_customer_uuid, content_key, timeout=None): + """ + Returns the metadata corresponding to the requested + ``content_key`` within catalogs associated to the provided ``enterprise_customer``. + + The response is cached in a ``TieredCache`` (meaning in both the RequestCache, + _and_ the django cache for the configured expiration period). + + Returns: A dict with content metadata for the given key. + Raises: An HTTPError if there's a problem getting the content metadata + via the enterprise-catalog service. + """ + cache_key = versioned_cache_key('get_content_metadata_content_identifier', enterprise_customer_uuid, content_key) + cached_response = TieredCache.get_cached_response(cache_key) + if cached_response.is_found: + logger.info(f'cache hit for enterprise customer {enterprise_customer_uuid} and content {content_key}') + return cached_response.value + + try: + result = EnterpriseCatalogApiClient().get_content_metadata_content_identifier( + enterprise_uuid=enterprise_customer_uuid, + content_id=content_key, + ) + except HTTPError as exc: + raise exc + + if not result: + logger.warning( + 'No content found for customer %s and content_key %s', + enterprise_customer_uuid, + content_key, + ) + return {} + + logger.info( + 'Fetched catalog for customer %s and content_key %s. Result = %s', + enterprise_customer_uuid, + content_key, + result, + ) + TieredCache.set_all_tiers(cache_key, result, timeout or DEFAULT_CACHE_TIMEOUT) + return result diff --git a/enterprise/migrations/0227_alter_defaultenterpriseenrollmentintention_content_type_and_more.py b/enterprise/migrations/0227_alter_defaultenterpriseenrollmentintention_content_type_and_more.py new file mode 100644 index 000000000..bfbb2fc56 --- /dev/null +++ b/enterprise/migrations/0227_alter_defaultenterpriseenrollmentintention_content_type_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-10-24 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0226_alter_enterprisegroup_name_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='defaultenterpriseenrollmentintention', + name='content_type', + field=models.CharField(blank=True, choices=[('course', 'Course'), ('course_run', 'Course Run')], help_text='The type of content (e.g. a course vs. a course run).', max_length=127, null=True), + ), + migrations.AlterField( + model_name='historicaldefaultenterpriseenrollmentintention', + name='content_type', + field=models.CharField(blank=True, choices=[('course', 'Course'), ('course_run', 'Course Run')], help_text='The type of content (e.g. a course vs. a course run).', max_length=127, null=True), + ), + migrations.AddConstraint( + model_name='defaultenterpriseenrollmentintention', + constraint=models.UniqueConstraint(fields=('enterprise_customer', 'content_key'), name='unique_default_enrollment_intention'), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 0051f20db..ea0619acd 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_customer_content_metadata from enterprise.errors import LinkUserToEnterpriseError from enterprise.event_bus import send_learner_credit_course_enrollment_revoked_event from enterprise.logging import getEnterpriseLogger @@ -71,6 +72,7 @@ CourseEnrollmentDowngradeError, CourseEnrollmentPermissionError, NotConnectedToOpenEdX, + get_advertised_course_run, get_configuration_value, get_default_invite_key_expiration_date, get_ecommerce_worker_user, @@ -2468,9 +2470,11 @@ class DefaultEnterpriseEnrollmentIntention(TimeStampedModel, SoftDeletableModel) .. no_pii: """ + COURSE = 'course' + COURSE_RUN = 'course_run' DEFAULT_ENROLLMENT_CONTENT_TYPE_CHOICES = [ - ('course', 'Course'), - ('course_run', 'Course Run'), + (COURSE, 'Course'), + (COURSE_RUN, 'Course Run'), ] uuid = models.UUIDField( primary_key=True, @@ -2489,8 +2493,8 @@ class DefaultEnterpriseEnrollmentIntention(TimeStampedModel, SoftDeletableModel) ) content_type = models.CharField( max_length=127, - blank=False, - null=False, + blank=True, + null=True, choices=DEFAULT_ENROLLMENT_CONTENT_TYPE_CHOICES, help_text=_( "The type of content (e.g. a course vs. a course run)." @@ -2511,34 +2515,152 @@ class DefaultEnterpriseEnrollmentIntention(TimeStampedModel, SoftDeletableModel) ) history = HistoricalRecords() - @cached_property - def current_course_run(self): # pragma: no cover + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['enterprise_customer', 'content_key'], + name='unique_default_enrollment_intention', + ) + ] + + @property + def content_metadata_for_content_key(self): + """ + Retrieves the content metadata for the instance's enterprise customer and content key. + """ + try: + return get_and_cache_customer_content_metadata( + enterprise_customer_uuid=self.enterprise_customer.uuid, + content_key=self.content_key, + ) + except HTTPError as e: + LOGGER.error( + f"Error retrieving content metadata for content key {self.content_key} " + f"and enterprise customer {self.enterprise_customer}: {e}" + ) + return {} + + @property + def course_run(self): """ Metadata describing the current course run for this default enrollment intention. """ - return {} + if not (content_metadata := self.content_metadata_for_content_key): + return {} + + if self.determine_content_type() == self.COURSE: + course_run = get_advertised_course_run(content_metadata) + return course_run or {} + + course_runs = content_metadata.get('course_runs', []) + return next( + (course_run for course_run in course_runs if course_run['key'].lower() == self.content_key.lower()), + {} + ) @property - def current_course_run_key(self): # pragma: no cover + def course_key(self): """ - The current course run key to use for realized course enrollments. + The resolved course key derived from the content_key. """ - return self.current_course_run.get('key') + return self.content_metadata_for_content_key.get('key') @property - def current_course_run_enrollable(self): # pragma: no cover + def course_run_key(self): """ - Whether the current course run is enrollable. + The resolved course run key derived from the content_key. This property will return the advertised + course run key if the configured content_key is a course; otherwise, it will return the key of the + course run that matches the content_key (i.e., course_run_key == content_key). + """ + return self.course_run.get('key') + + @property + def is_course_run_enrollable(self): # pragma: no cover + """ + Whether the course run is enrollable. """ return False @property - def current_course_run_enroll_by_date(self): # pragma: no cover + def course_run_enroll_by_date(self): # pragma: no cover """ - The enrollment deadline for this course. + The enrollment deadline for the course run. """ return datetime.datetime.min + def determine_content_type(self): + """ + Determines the content_type for a given content_key by validating the return value + from `content_metadata_for_content_key`. First determines if the configured content_key + matches the returned key, then checks if it matches any of the returned course runs. + + Returns either COURSE, COURSE_RUN, or None (if neither can be determined). + """ + if not (content_metadata := self.content_metadata_for_content_key): + return None + + # Determine whether the returned key matches the configured content_key and + # the returned metadata denotes the content type as a course. + content_metadata_key = content_metadata.get('key', '') + content_metadata_content_type = content_metadata.get('content_type', '') + if content_metadata_key.lower() == self.content_key.lower() and content_metadata_content_type == self.COURSE: + return self.COURSE + + # Determine if the content_key matches any of the course runs + # in the content metadata. + course_runs = content_metadata.get('course_runs', []) + course_run = next( + (course_run for course_run in course_runs if course_run['key'].lower() == self.content_key.lower()), + None + ) + return self.COURSE_RUN if course_run is not None else None + + def clean(self): + """ + Raise ValidationError if no course run or content type exists. + """ + super().clean() + + existing_record = DefaultEnterpriseEnrollmentIntention.all_objects.filter( + enterprise_customer=self.enterprise_customer, + content_key=self.content_key, + ).exclude(uuid=self.uuid).first() + + if existing_record and existing_record.is_removed: + existing_record_admin_url = reverse( + 'admin:enterprise_defaultenterpriseenrollmentintention_change', + args=[existing_record.uuid], + ) + message = _( + 'A default enrollment intention with this enterprise customer and ' + 'content key already exists, but is soft-deleted. Please restore ' + 'it here.', + ).format(existing_record_admin_url=existing_record_admin_url) + raise ValidationError({ + 'content_key': mark_safe(message) + }) + + if not self.course_run: + # NOTE: This validation check also acts as an inferred check on the derived content_type + # from the content metadata. + raise ValidationError({ + 'content_key': _('The content key did not resolve to a valid course run.') + }) + + def save(self, *args, **kwargs): + """ + Override save to ensure that the content_type is set correctly before saving. + """ + # Ensure the model instance is cleaned before saving + self.full_clean() + + # Set content_type field + if content_type := self.determine_content_type(): + self.content_type = content_type + + # Call the superclass save method + super().save(*args, **kwargs) + class DefaultEnterpriseEnrollmentRealization(TimeStampedModel): """ @@ -2637,7 +2759,6 @@ def clean(self): except Exception as exc: raise ValidationError({'content_filter': f'Failed to validate with exception: {exc}'}) from exc if hash_catalog_response: - print(f'hash_catalog_response: {hash_catalog_response}') err_msg = f'Duplicate value, see {hash_catalog_response["uuid"]}({hash_catalog_response["title"]})' raise ValidationError({'content_filter': err_msg}) diff --git a/test_utils/factories.py b/test_utils/factories.py index 4adcef200..ded8506fa 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -17,6 +17,7 @@ from enterprise.constants import FulfillmentTypes from enterprise.models import ( AdminNotification, + DefaultEnterpriseEnrollmentIntention, EnrollmentNotificationEmailTemplate, EnterpriseCatalogQuery, EnterpriseCourseEnrollment, @@ -1137,3 +1138,24 @@ class Meta: group = factory.SubFactory(EnterpriseGroupFactory) enterprise_customer_user = factory.SubFactory(EnterpriseCustomerUserFactory) pending_enterprise_customer_user = factory.SubFactory(PendingEnterpriseCustomerUserFactory) + + +class DefaultEnterpriseEnrollmentIntentionFactory(factory.django.DjangoModelFactory): + """ + DefaultEnterpriseEnrollmentIntention factory. + + Creates an instance of the DefaultEnterpriseEnrollmentIntention + """ + + class Meta: + """ + Meta for DefaultEnterpriseEnrollmentIntention. + """ + + model = DefaultEnterpriseEnrollmentIntention + + uuid = factory.LazyAttribute(lambda x: UUID(FAKER.uuid4())) + enterprise_customer = factory.SubFactory(EnterpriseCustomerFactory) + content_type = "course" + content_key = "edX+demoX" + factory.SubFactory(EnterpriseCourseEnrollmentFactory) diff --git a/tests/test_admin/test_view.py b/tests/test_admin/test_view.py index 128e24178..7bb7ade0e 100644 --- a/tests/test_admin/test_view.py +++ b/tests/test_admin/test_view.py @@ -1680,7 +1680,6 @@ def test_post_create_course_enrollments( bulk_upload_errors = [] if manage_learners_form: bulk_upload_errors = manage_learners_form.errors[ManageLearnersForm.Fields.BULK_UPLOAD] - print('bulk_upload_errors', bulk_upload_errors) if valid_course_id and course_in_catalog: # valid input, no errors diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index 4e50bba26..fa90d7acf 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -6806,7 +6806,6 @@ def test_reporting_config_enterprise_catalogs_update(self, request_or_stub_mock) format='json', ) # validate the existing associated catalogs. - print(response.content) assert response.status_code == status.HTTP_200_OK ec_catalog_uuids = [item['uuid'] for item in response.json()['enterprise_customer_catalogs']] assert [str(enterprise_catalog.uuid)] == ec_catalog_uuids diff --git a/tests/test_models.py b/tests/test_models.py index 0006081fc..1e9962327 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -19,6 +19,7 @@ from freezegun.api import freeze_time from opaque_keys.edx.keys import CourseKey from pytest import mark, raises +from requests.exceptions import HTTPError from slumber.exceptions import HttpClientError from testfixtures import LogCapture @@ -28,6 +29,7 @@ from django.core.files.storage import Storage from django.db.utils import IntegrityError from django.http import QueryDict +from django.test import override_settings from django.test.testcases import TransactionTestCase from django.urls import reverse @@ -43,6 +45,7 @@ ) from enterprise.errors import LinkUserToEnterpriseError from enterprise.models import ( + DefaultEnterpriseEnrollmentIntention, EnrollmentNotificationEmailTemplate, EnterpriseCatalogQuery, EnterpriseCourseEnrollment, @@ -203,6 +206,120 @@ def test_get_license(self): assert self.enrollment.license.license_uuid == self.LICENSE_UUID +@mark.django_db +@ddt.ddt +class TestDefaultEnterpriseEnrollmentIntention(unittest.TestCase): + """ + Tests for DefaultEnterpriseEnrollmentIntention + """ + def setUp(self): + self.test_enterprise_customer_1 = factories.EnterpriseCustomerFactory() + + self.faker = FakerFactory.create() + self.advertised_course_run_uuid = self.faker.uuid4() + self.course_run_1_uuid = self.faker.uuid4() + self.mock_course_run_1 = { + 'key': 'course-v1:edX+DemoX+2T2023', + 'title': 'Demo Course', + 'parent_content_key': 'edX+DemoX', + 'uuid': self.course_run_1_uuid + } + self.mock_advertised_course_run = { + 'key': 'course-v1:edX+DemoX+3T2024', + 'title': 'Demo Course', + 'parent_content_key': 'edX+DemoX', + 'uuid': self.advertised_course_run_uuid + } + self.mock_course_runs = [ + self.mock_course_run_1, + self.mock_advertised_course_run, + ] + self.mock_course = { + 'key': 'edX+DemoX', + 'content_type': DefaultEnterpriseEnrollmentIntention.COURSE, + 'course_runs': self.mock_course_runs, + 'advertised_course_run_uuid': self.advertised_course_run_uuid + } + super().setUp() + + def tearDown(self): + super().tearDown() + DefaultEnterpriseEnrollmentIntention.objects.all().delete() + + @mock.patch( + 'enterprise.models.get_and_cache_customer_content_metadata', + return_value=mock.MagicMock(), + ) + def test_retrieve_course_run_advertised_course_run(self, mock_get_and_cache_customer_content_metadata): + mock_get_and_cache_customer_content_metadata.return_value = self.mock_course + default_enterprise_enrollment_intention = DefaultEnterpriseEnrollmentIntention.objects.create( + enterprise_customer=self.test_enterprise_customer_1, + content_key='edX+DemoX', + ) + assert default_enterprise_enrollment_intention.course_run == self.mock_advertised_course_run + assert default_enterprise_enrollment_intention.course_key == self.mock_course['key'] + assert default_enterprise_enrollment_intention.course_run_key == self.mock_advertised_course_run['key'] + assert default_enterprise_enrollment_intention.content_type == DefaultEnterpriseEnrollmentIntention.COURSE + + @mock.patch( + 'enterprise.models.get_and_cache_customer_content_metadata', + return_value=mock.MagicMock(), + ) + def test_retrieve_course_run_with_explicit_course_run(self, mock_get_and_cache_customer_content_metadata): + mock_get_and_cache_customer_content_metadata.return_value = self.mock_course + default_enterprise_enrollment_intention = DefaultEnterpriseEnrollmentIntention.objects.create( + enterprise_customer=self.test_enterprise_customer_1, + content_key='course-v1:edX+DemoX+2T2023', + ) + assert default_enterprise_enrollment_intention.course_run == self.mock_course_run_1 + assert default_enterprise_enrollment_intention.course_key == self.mock_course['key'] + assert default_enterprise_enrollment_intention.course_run_key == self.mock_course_run_1['key'] + assert default_enterprise_enrollment_intention.content_type == DefaultEnterpriseEnrollmentIntention.COURSE_RUN + + @mock.patch( + 'enterprise.models.get_and_cache_customer_content_metadata', + return_value=mock.MagicMock(), + ) + def test_validate_missing_course_run(self, mock_get_and_cache_customer_content_metadata): + # Simulate a 404 Not Found by raising an HTTPError exception + mock_get_and_cache_customer_content_metadata.side_effect = HTTPError + with self.assertRaises(ValidationError) as exc_info: + DefaultEnterpriseEnrollmentIntention.objects.create( + enterprise_customer=self.test_enterprise_customer_1, + content_key='invalid_content_key', + ) + self.assertIn('The content key did not resolve to a valid course run.', str(exc_info.exception)) + + @mock.patch( + 'enterprise.models.get_and_cache_customer_content_metadata', + return_value=mock.MagicMock(), + ) + @override_settings(ROOT_URLCONF="test_utils.admin_urls") + def test_validate_existing_soft_deleted_record(self, mock_get_and_cache_customer_content_metadata): + mock_get_and_cache_customer_content_metadata.return_value = self.mock_course + existing_record = DefaultEnterpriseEnrollmentIntention.objects.create( + enterprise_customer=self.test_enterprise_customer_1, + content_key='edX+DemoX', + is_removed=True, + ) + # Attempt to re-create the above entry + with self.assertRaises(ValidationError) as exc_info: + DefaultEnterpriseEnrollmentIntention.objects.create( + enterprise_customer=self.test_enterprise_customer_1, + content_key='edX+DemoX', + ) + existing_record_admin_url = reverse( + 'admin:enterprise_defaultenterpriseenrollmentintention_change', + args=[existing_record.uuid], + ) + message = ( + f'A default enrollment intention with this enterprise customer and ' + f'content key already exists, but is soft-deleted. Please restore ' + f'it here.' + ) + self.assertIn(message, str(exc_info.exception)) + + @mark.django_db class TestEnterpriseCustomerManager(unittest.TestCase): """ @@ -1084,7 +1201,6 @@ def test_logo_path(self): storage_mock = mock.MagicMock(spec=Storage, name="StorageMock") with mock.patch("django.core.files.storage.default_storage._wrapped", storage_mock): path = logo_path(branding_config, branding_config.logo.name) - print(path) self.assertTrue(re.search(self.BRANDING_PATH_REGEX, path)) def test_logo_path_after_save(self): From 64f925d3e2f5a879d50eb21955581793391afeb1 Mon Sep 17 00:00:00 2001 From: salman2013 Date: Thu, 24 Oct 2024 15:22:35 +0500 Subject: [PATCH 3/4] chore: Update catalog-info.yaml file for release data and delete openedx.yaml file --- catalog-info.yaml | 17 +++++++++++++++++ openedx.yaml | 14 -------------- 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 catalog-info.yaml delete mode 100644 openedx.yaml diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 000000000..3595df889 --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,17 @@ +# This file records information about this repo. Its use is described in OEP-55: +# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html + +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'edx-enterprise' + description: "The Open edx Enterprise Service app provides enterprise features to the Open edX platform" + tags: + - enterprise + - ent + - library +spec: + owner: group:openedx-unmaintained + type: 'service' + lifecycle: 'production' + \ No newline at end of file diff --git a/openedx.yaml b/openedx.yaml deleted file mode 100644 index ef8552fb7..000000000 --- a/openedx.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# This file describes this Open edX repo, as described in OEP-2: -# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification - -nick: edx-enterprise -oeps: - oep-30: - state: true - oep-7: true # Python 3 - oep-18: true - -tags: - - enterprise - - ent - - library From 8164619dda5e664d15f4acc977a1acb082a1b413 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 25 Oct 2024 13:50:52 -0400 Subject: [PATCH 4/4] docs: Update catalog-info.yaml --- catalog-info.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catalog-info.yaml b/catalog-info.yaml index 3595df889..f15bb384c 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -12,6 +12,6 @@ metadata: - library spec: owner: group:openedx-unmaintained - type: 'service' + type: 'library' lifecycle: 'production' \ No newline at end of file