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 29, 2025
1 parent 43f762c commit 553eef6
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 1 deletion.
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

[5.6.6]
--------
* feat: new mgmt command to validate default enrollment intentions

[5.6.5]
--------
* fix: enrollment intention saves should not be blocked on catalog inclusion
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__ = "5.6.5"
__version__ = "5.6.6"
Original file line number Diff line number Diff line change
@@ -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!")
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,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)

0 comments on commit 553eef6

Please sign in to comment.