From 8a36ff631e5aea54fb1c0a0c98063e51b2a0c764 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Fri, 24 Jan 2025 17:13:38 -0800 Subject: [PATCH] feat: new mgmt command to validate default enrollment intentions Introduced validate_default_enrollment_intentions.py to check that all DefaultEnterpriseEnrollmentIntention objects have a valid content_key which actually belongs to at least one of the related customer's catalogs. ENT-9941 --- .../validate_default_enrollment_intentions.py | 121 +++++++++++++++ enterprise/models.py | 9 ++ ..._validate_default_enrollment_intentions.py | 138 ++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 enterprise/management/commands/validate_default_enrollment_intentions.py create mode 100644 tests/test_enterprise/management/test_validate_default_enrollment_intentions.py 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..680c0f6a30 --- /dev/null +++ b/enterprise/management/commands/validate_default_enrollment_intentions.py @@ -0,0 +1,121 @@ +""" +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 DefaultEnterpriseEnrollmentIntention + +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_intention(self, intention): + """ + Check that the default enrollment intention's content_key is contained in any of the customer's catalogs. + + Returns: + dict: Results dict that indicates whether evaluation was skipped, and whether the intention was valid. + """ + customer = intention.enterprise_customer + result = { + 'skipped': None, + 'invalid': None, + } + + if intention.catalogs_modified_latest > self.latest_change_allowed: + result['skipped'] = True + logger.info(f"handle_intention(): SKIPPING Evaluating enrollment intention {intention}.") + return result + result['skipped'] = False + logger.info(f"handle_intention(): Evaluating enrollment intention {intention}.") + + 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." + ) + result["invalid"] = False + else: + logger.error( + f"handle_default_enrollment_intention(): Default enrollment intention {intention} " + "is NOT compatible with the customer's catalogs." + ) + result["invalid"] = True + return result + + def handle(self, *args, **options): + self.delay_minutes = options.get("delay_minutes") + + intentions = DefaultEnterpriseEnrollmentIntention.objects.select_related( + 'enterprise_customer' + ).prefetch_related( + 'enterprise_customer__enterprise_customer_catalogs' + ).annotate( + catalogs_modified_latest=Greatest( + Max("enterprise_customer__enterprise_customer_catalogs__modified"), + Max("enterprise_customer__enterprise_customer_catalogs__enterprise_catalog_query__modified"), + ) + ) + + results = {intention: self.handle_intention(intention) for intention in intentions} + results_evaluated = {intention: result for intention, result in results.items() if not result['skipped']} + results_invalid = {intention: result for intention, result in results_evaluated.items() if result['invalid']} + + count_total = len(results) + count_evaluated = len(results_evaluated) + count_skipped = count_total - count_evaluated + count_invalid = len(results_invalid) + count_passed = count_evaluated - count_invalid + + invalid_intentions = results_invalid.keys() + + logger.info( + f"{count_total} total enrollment intentions found, " + f"and {count_evaluated}/{count_total} were evaluated " + f"({count_skipped}/{count_total} skipped)." + ) + logger.info( + f"Out of {count_evaluated} total evaluated enrollment intentions, " + f"{count_passed}/{count_evaluated} passed validation " + f"({count_invalid}/{count_evaluated} invalid)." + ) + if count_invalid > 0: + logger.error(f"Summary of all {count_invalid} invalid intentions: {invalid_intentions}") + logger.error("FAILURE: Some default enrollment intentions were invalid.") + raise CommandError(f"{count_invalid} invalid default enrollment intentions found.") + 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..ba95490b61 --- /dev/null +++ b/tests/test_enterprise/management/test_validate_default_enrollment_intentions.py @@ -0,0 +1,138 @@ +""" +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()) + + # Add another catalog/customer/query with an intention that always gets skipped. + self.other_catalog = EnterpriseCustomerCatalogFactory() + + # Add yet another catalog/customer/query without an intention just to spice things up. + EnterpriseCustomerCatalogFactory() + + 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/2 were evaluated (2/2 skipped)", + }, + # Happy-ish case (customer was skipped because catalog was too new). + { + "catalog_modified": NOW - timedelta(minutes=29), + "expected_logging": "0/2 were evaluated (2/2 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/2 were evaluated (2/2 skipped)", + }, + # Sad case (content was not found in customer's catalogs). + { + "customer_content_metadata_api_success": False, + "expected_logging": "0/1 passed validation (1/1 invalid).", + "expected_command_error": "1 invalid default enrollment intentions found.", + }, + ) + @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/2 were evaluated (1/2 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 {}, + ), + ) + # This intention is subject to variable test inputs. + 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, + ) + # This intention should always be skipped. + DefaultEnterpriseEnrollmentIntentionFactory( + enterprise_customer=self.other_catalog.enterprise_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)