Skip to content

Commit

Permalink
feat: new mgmt command to validate default enrollment intentions
Browse files Browse the repository at this point in the history
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
  • Loading branch information
pwnage101 committed Jan 28, 2025
1 parent 43f762c commit 6f5ff84
Show file tree
Hide file tree
Showing 3 changed files with 274 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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!")
9 changes: 9 additions & 0 deletions enterprise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<DefaultEnterpriseEnrollmentIntention for customer={self.enterprise_customer.uuid} "
f"and content_key={self.content_key}>"
)


class DefaultEnterpriseEnrollmentRealization(TimeStampedModel):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 6f5ff84

Please sign in to comment.