diff --git a/enterprise/management/commands/validate_default_enrollment_intentions.py b/enterprise/management/commands/validate_default_enrollment_intentions.py new file mode 100644 index 0000000000..a74d168d05 --- /dev/null +++ b/enterprise/management/commands/validate_default_enrollment_intentions.py @@ -0,0 +1,140 @@ +""" +Django management command to validate that DefaultEnterpriseEnrollmentIntention +objects have enrollable content. +""" +import logging +from datetime import timedelta + +from django.core.management import BaseCommand, CommandError +from django.db.models import Max +from django.db.models.functions import Greatest +from django.utils import timezone + +from enterprise.content_metadata.api import get_and_cache_customer_content_metadata +from enterprise.models import EnterpriseCustomer + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Enumerate the catalog filters and log information about how we might migrate them. + """ + + def __init__(self, *args, **kwargs): + self.delay_minutes = None + super().__init__(*args, **kwargs) + + def add_arguments(self, parser): + parser.add_argument( + '--delay-minutes', + dest='delay_minutes', + required=False, + type=int, + default=30, + help="How long after a customer's catalog has been updated are we allowed to evaluate the customer." + ) + + @property + def latest_change_allowed(self): + return timezone.now() - timedelta(minutes=self.delay_minutes) + + def handle_default_enrollment_intention(self, customer, intention): + """ + Check that the default enrollment intention's content_key is contained in any of the customer's catalogs. + + Returns: + bool: True if the default enrollment intention is valid. + """ + content_metadata = get_and_cache_customer_content_metadata( + customer.uuid, + intention.content_key, + ) + contained_in_customer_catalogs = bool(content_metadata) + if contained_in_customer_catalogs: + logger.info( + f"handle_default_enrollment_intention(): Default enrollment intention {intention} " + "is compatible with the customer's catalogs." + ) + else: + logger.error( + f"handle_default_enrollment_intention(): Default enrollment intention {intention} " + "is NOT compatible with the customer's catalogs." + ) + return contained_in_customer_catalogs + + def handle_customer(self, customer): + """ + Try to evaluate an EnterpriseCustomer for any invalid DefaultEnterpriseEnrollmentIntention records. + + Returns: + dict: A structured result object that indicates whether the + customer was skipped, and which intentions are invalid. + """ + result = { + 'skipped': None, + 'invalid_intentions': None, + } + if customer.catalogs_modified_latest > self.latest_change_allowed: + result['skipped'] = True + logger.info( + f"handle_customer(): SKIPPING Evaluating default enrollment intentions for customer {customer}." + ) + return result + result['skipped'] = False + logger.info(f"handle_customer(): Evaluating default enrollment intentions for customer {customer}.") + results = { + intention: self.handle_default_enrollment_intention(customer, intention) + for intention in customer.default_enrollment_intentions.all() + } + result['invalid_intentions'] = [intention for intention, valid in results.items() if not valid] + return result + + def handle(self, *args, **options): + self.delay_minutes = options.get("delay_minutes") + + customers = EnterpriseCustomer.objects.annotate( + catalogs_modified_latest=Greatest( + Max("enterprise_customer_catalogs__modified"), + Max("enterprise_customer_catalogs__enterprise_catalog_query__modified"), + ), + ).prefetch_related( + "default_enrollment_intentions", + ) + + results = {customer: self.handle_customer(customer) for customer in customers} + results_evaluated = {customer: result for customer, result in results.items() if not result['skipped']} + results_failed = { + customer: result + for customer, result in results_evaluated.items() + if result['invalid_intentions']} + invalid_intentions = [ + intention + for failed_result in results_failed.values() + for intention in failed_result['invalid_intentions'] + ] + + count_customers_total = len(results) + count_customers_evaluated = len(results_evaluated) + count_customers_skipped = count_customers_total - count_customers_evaluated + count_customers_failed = len(results_failed) + count_customers_passed = count_customers_evaluated - count_customers_failed + + logger.info( + f"{count_customers_total} total customers found, " + f"and {count_customers_evaluated}/{count_customers_total} customers were evaluated " + f"({count_customers_skipped}/{count_customers_total} skipped)." + ) + logger.info( + f"Out of {count_customers_evaluated} total evaluated customers, " + f"{count_customers_passed}/{count_customers_evaluated} customers passed validation " + f"({count_customers_failed}/{count_customers_evaluated} failed)." + ) + if count_customers_failed: + logger.error("Summary of all {len(invalid_intentions)} invalid intentions: {invalid_intentions}") + logger.error("FAILURE: Some default enrollment intentions were invalid.") + raise CommandError( + f"{len(invalid_intentions)} invalid default enrollment intentions found " + f"across {count_customers_failed} customers." + ) + logger.info("SUCCESS: All default enrollment intentions are valid!") diff --git a/enterprise/models.py b/enterprise/models.py index 912cbc1491..71d3ec8206 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -2726,6 +2726,15 @@ def save(self, *args, **kwargs): # Call the superclass save method super().save(*args, **kwargs) + def __str__(self): + """ + Return human-readable string representation. + """ + return ( + f"" + ) + class DefaultEnterpriseEnrollmentRealization(TimeStampedModel): """ diff --git a/tests/test_enterprise/management/test_validate_default_enrollment_intentions.py b/tests/test_enterprise/management/test_validate_default_enrollment_intentions.py new file mode 100644 index 0000000000..ce3c0ce341 --- /dev/null +++ b/tests/test_enterprise/management/test_validate_default_enrollment_intentions.py @@ -0,0 +1,125 @@ +""" +Tests for the Django management command `validate_default_enrollment_intentions`. +""" + +import logging +from contextlib import nullcontext +from datetime import timedelta +from uuid import uuid4 + +import ddt +import mock +from edx_django_utils.cache import TieredCache +from freezegun.api import freeze_time +from pytest import mark, raises +from testfixtures import LogCapture + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase +from django.utils import timezone + +from enterprise.models import EnterpriseCatalogQuery, EnterpriseCustomerCatalog +from test_utils.factories import DefaultEnterpriseEnrollmentIntentionFactory, EnterpriseCustomerCatalogFactory + +NOW = timezone.now() + + +@mark.django_db +@ddt.ddt +class ValidateDefaultEnrollmentIntentionsCommandTests(TestCase): + """ + Test command `validate_default_enrollment_intentions`. + """ + command = "validate_default_enrollment_intentions" + + def setUp(self): + self.catalog = EnterpriseCustomerCatalogFactory() + self.catalog_query = self.catalog.enterprise_catalog_query + self.customer = self.catalog.enterprise_customer + self.content_key = "edX+DemoX" + self.content_uuid = str(uuid4()) + TieredCache.dangerous_clear_all_tiers() + super().setUp() + + @ddt.data( + # Totally happy case. + {}, + # Happy-ish case (customer was skipped because catalog query was too new). + { + "catalog_query_modified": NOW - timedelta(minutes=29), + "expected_logging": "0/1 customers were evaluated (1/1 skipped)", + }, + # Happy-ish case (customer was skipped because catalog was too new). + { + "catalog_modified": NOW - timedelta(minutes=29), + "expected_logging": "0/1 customers were evaluated (1/1 skipped)", + }, + # Happy-ish case (customer was skipped because catalog was too new). + # This version sets the catalog response to say content is not included, for good measure. + { + "catalog_modified": NOW - timedelta(minutes=29), + "customer_content_metadata_api_success": False, + "expected_logging": "0/1 customers were evaluated (1/1 skipped)", + }, + # Sad case (content was not found in customer's catalogs). + { + "customer_content_metadata_api_success": False, + "expected_logging": "0/1 customers passed validation (1/1 failed).", + "expected_command_error": "1 invalid default enrollment intentions found across 1 customers.", + }, + ) + @ddt.unpack + @mock.patch('enterprise.content_metadata.api.EnterpriseCatalogApiClient') + @freeze_time(NOW) + def test_validate_default_enrollment_intentions( + self, + mock_catalog_api_client, + catalog_query_modified=NOW - timedelta(minutes=31), + catalog_modified=NOW - timedelta(minutes=31), + customer_content_metadata_api_success=True, + expected_logging="1/1 customers were evaluated (0/1 skipped)", + expected_command_error=False, + ): + """ + Test validating default enrollment intentions in cases where customers have + varying ages of catalogs and content inclusion statuses. + """ + mock_catalog_api_client.return_value = mock.Mock( + get_content_metadata_content_identifier=mock.Mock( + return_value={ + "content_type": "course", + "key": self.content_key, + "course_runs": [{ + "uuid": self.content_uuid, + "key": f"course-v1:{self.content_key}+run", + }], + "advertised_course_run_uuid": self.content_uuid, + }, + ), + get_customer_content_metadata_content_identifier=mock.Mock( + return_value={ + "content_type": "course", + "key": self.content_key, + "course_runs": [{ + "uuid": self.content_uuid, + "key": f"course-v1:{self.content_key}+run", + }], + "advertised_course_run_uuid": self.content_uuid, + } if customer_content_metadata_api_success else {}, + ), + ) + self.catalog_query.modified = catalog_query_modified + EnterpriseCatalogQuery.objects.bulk_update([self.catalog_query], ["modified"]) # bulk_update() avoids signals. + self.catalog.modified = catalog_modified + EnterpriseCustomerCatalog.objects.bulk_update([self.catalog], ["modified"]) # bulk_update() avoids signals. + DefaultEnterpriseEnrollmentIntentionFactory( + enterprise_customer=self.customer, + content_key=self.content_key, + ) + cm = raises(CommandError) if expected_command_error else nullcontext() + with LogCapture(level=logging.INFO) as log_capture: + with cm: + call_command(self.command, delay_minutes=30) + logging_messages = [log_msg.getMessage() for log_msg in log_capture.records] + assert any(expected_logging in message for message in logging_messages)