From c9189df8c931344098bc5ac32992da5171526e07 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Fri, 31 Mar 2023 17:51:03 +0000 Subject: [PATCH] feat: new mark orphaned transmisisions command and logic to remove during transmissions --- CHANGELOG.rst | 4 + enterprise/__init__.py | 2 +- .../exporters/content_metadata.py | 77 ++++++++++++- .../mark_orphaned_content_metadata_audits.py | 28 +++++ .../0027_orphanedcontenttransmissions.py | 32 +++++ .../integrated_channel/models.py | 37 ++++++ .../integrated_channel/tasks.py | 43 +++++++ .../transmitters/content_metadata.py | 24 +++- test_utils/factories.py | 20 ++++ .../test_exporters/test_content_metadata.py | 109 ++++++++++++++++++ .../test_content_metadata.py | 40 +++++++ tests/test_management.py | 47 +++++++- 12 files changed, 458 insertions(+), 5 deletions(-) create mode 100644 integrated_channels/integrated_channel/management/commands/mark_orphaned_content_metadata_audits.py create mode 100644 integrated_channels/integrated_channel/migrations/0027_orphanedcontenttransmissions.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 24ec2eae3d..8a2b6ad7ec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,10 @@ Unreleased ---------- * Nothing +[3.61.10] +--------- +feat: new tagging orphaned content tast for integrated channels + [3.61.9] -------- feat: Serialize and create a viewset for enterpriseCatalogQuery as readonly diff --git a/enterprise/__init__.py b/enterprise/__init__.py index ea32363fa6..7f9006d892 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,6 +2,6 @@ Your project description goes here. """ -__version__ = "3.61.9" +__version__ = "3.61.10" default_app_config = "enterprise.apps.EnterpriseConfig" diff --git a/integrated_channels/integrated_channel/exporters/content_metadata.py b/integrated_channels/integrated_channel/exporters/content_metadata.py index 36cd128332..9a9708ad8f 100644 --- a/integrated_channels/integrated_channel/exporters/content_metadata.py +++ b/integrated_channels/integrated_channel/exporters/content_metadata.py @@ -10,6 +10,7 @@ from logging import getLogger from django.apps import apps +from django.conf import settings from django.db.models import Q from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient @@ -299,13 +300,49 @@ def _get_catalog_diff( # We need to remove any potential create transmissions if the content already exists on the customer's instance # under a different catalog for item in items_to_create: - if item.get('content_key') not in existing_content_keys: + # If the catalog system has indicated that the content is new and needs creating, we need to check if the + # content already exists on the customer's instance under a different catalog. If it does, we need to + # check if the content key exists as an orphaned transmission record for this customer and config, + # indicating that the content was previously created but then the config under which it was created was + # deleted. + content_key = item.get('content_key') + orphaned_content = self._get_customer_config_orphaned_content( + max_set_count=1, + content_key=content_key + ).first() + + # if it does exist as an orphaned content record: 1) don't add the item to the list of items to create, + # 2) swap the catalog uuid of the transmission audit associated with the orphaned record, and 3) mark the + # orphaned record resolved + if orphaned_content: + ContentMetadataTransmissionAudit = apps.get_model( + 'integrated_channel', + 'ContentMetadataTransmissionAudit' + ) + ContentMetadataTransmissionAudit.objects.filter( + integrated_channel_code=self.enterprise_configuration.channel_code(), + plugin_configuration_id=self.enterprise_configuration.id, + content_id=content_key + ).update( + enterprise_customer_catalog_uuid=enterprise_catalog.uuid + ) + + self._log_info( + 'Found an orphaned content record while creating. ' + 'Swapping catalog uuid and marking record as resolved.', + course_or_course_run_key=content_key + ) + orphaned_content.resolved = True + orphaned_content.save() + + # if the item to create doesn't exist as an orphaned piece of content, do all the normal checks + elif content_key not in existing_content_keys: unique_new_items_to_create.append(item) else: self._log_info( 'Found an previous content record in another catalog while creating. ' 'Skipping record.', - course_or_course_run_key=item.get('content_key') + course_or_course_run_key=content_key ) content_to_create = self._check_matched_content_to_create( @@ -380,6 +417,24 @@ def _check_matched_content_to_delete(self, enterprise_customer_catalog, items): ) return items_to_delete + def _get_customer_config_orphaned_content(self, max_set_count, content_key=None): + """ + Helper method to retrieve the customer's orphaned content metadata items. + """ + OrphanedContentTransmissions = apps.get_model( + 'integrated_channel', + 'OrphanedContentTransmissions' + ) + content_query = Q(content_id=content_key) if content_key else Q() + base_query = Q( + integrated_channel_code=self.enterprise_configuration.channel_code(), + plugin_configuration_id=self.enterprise_configuration.id, + resolved=False, + ) & content_query + + # Grab orphaned content metadata items for the customer, ordered by oldest to newest + return OrphanedContentTransmissions.objects.filter(base_query).order_by('created')[:max_set_count] + def export(self, **kwargs): """ Export transformed content metadata if there has been an update to the consumer's catalogs @@ -470,6 +525,24 @@ def export(self, **kwargs): for key, item in items_to_delete.items(): delete_payload[key] = item + # If we're not at the max payload count, we can check for orphaned content and shove it in the delete payload + current_payload_count = len(items_to_create) + len(items_to_update) + len(items_to_delete) + if current_payload_count < max_payload_count: + space_left_in_payload = max_payload_count - current_payload_count + orphaned_content_to_delete = self._get_customer_config_orphaned_content( + max_set_count=space_left_in_payload, + ) + + for orphaned_item in orphaned_content_to_delete: + # log the content that would have been deleted because it's orphaned + self._log_info( + f'Exporter intends to delete orphaned content for customer: {self.enterprise_customer.uuid}, ' + f'config {self.enterprise_configuration.channel_code}-{self.enterprise_configuration} with ' + f'content_id: {orphaned_item.content_id}' + ) + if getattr(settings, "ALLOW_ORPHANED_CONTENT_REMOVAL", False): + delete_payload[orphaned_item.content_id] = orphaned_item.transmission + self._log_info( f'Exporter finished for customer: {self.enterprise_customer.uuid} with payloads- create_payload: ' f'{create_payload}, update_payload: {update_payload}, delete_payload: {delete_payload}' diff --git a/integrated_channels/integrated_channel/management/commands/mark_orphaned_content_metadata_audits.py b/integrated_channels/integrated_channel/management/commands/mark_orphaned_content_metadata_audits.py new file mode 100644 index 0000000000..5c3b47a89a --- /dev/null +++ b/integrated_channels/integrated_channel/management/commands/mark_orphaned_content_metadata_audits.py @@ -0,0 +1,28 @@ +""" +Mark all content metadata audit records not directly connected to a customer's catalogs as orphaned. +""" +import logging + +from django.core.management.base import BaseCommand + +from integrated_channels.integrated_channel.management.commands import IntegratedChannelCommandMixin +from integrated_channels.integrated_channel.tasks import mark_orphaned_content_metadata_audit + +LOGGER = logging.getLogger(__name__) + + +class Command(IntegratedChannelCommandMixin, BaseCommand): + """ + Mark all content metadata audit records not directly connected to a customer's catalogs as orphaned. + + ./manage.py lms mark_orphaned_content_metadata_audits + """ + + def handle(self, *args, **options): + """ + Mark all content metadata audit records not directly connected to a customer's catalogs as orphaned. + """ + try: + mark_orphaned_content_metadata_audit.delay() + except Exception as exc: # pylint: disable=broad-except + LOGGER.exception(f'Failed to mark orphaned content metadata audits. Task failed with exception: {exc}') diff --git a/integrated_channels/integrated_channel/migrations/0027_orphanedcontenttransmissions.py b/integrated_channels/integrated_channel/migrations/0027_orphanedcontenttransmissions.py new file mode 100644 index 0000000000..b58a0ddae5 --- /dev/null +++ b/integrated_channels/integrated_channel/migrations/0027_orphanedcontenttransmissions.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.18 on 2023-03-31 18:14 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrated_channel', '0026_genericenterprisecustomerpluginconfiguration_last_modified_at'), + ] + + operations = [ + migrations.CreateModel( + name='OrphanedContentTransmissions', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('integrated_channel_code', models.CharField(max_length=30)), + ('plugin_configuration_id', models.PositiveIntegerField()), + ('content_id', models.CharField(max_length=255)), + ('resolved', models.BooleanField(default=False)), + ('transmission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orphaned_record', to='integrated_channel.contentmetadataitemtransmission')), + ], + options={ + 'index_together': {('integrated_channel_code', 'plugin_configuration_id', 'resolved')}, + }, + ), + ] diff --git a/integrated_channels/integrated_channel/models.py b/integrated_channels/integrated_channel/models.py index ceafc1af51..a2684dabd6 100644 --- a/integrated_channels/integrated_channel/models.py +++ b/integrated_channels/integrated_channel/models.py @@ -45,6 +45,7 @@ class SoftDeletionQuerySet(QuerySet): """ Soft deletion query set. """ + def delete(self): return super().update(deleted_at=localized_utcnow()) @@ -241,6 +242,22 @@ def clean(self): } ) + def fetch_orphaned_content_audits(self): + """ + Helper method attached to customer configs to fetch all orphaned content metadata audits not linked to the + customer's catalogs. + """ + enterprise_customer_catalogs = self.customer_catalogs_to_transmit or \ + self.enterprise_customer.enterprise_customer_catalogs.all() + + customer_catalog_uuids = enterprise_customer_catalogs.values_list('uuid', flat=True) + return ContentMetadataItemTransmission.objects.filter( + integrated_channel_code=self.channel_code(), + enterprise_customer=self.enterprise_customer, + remote_deleted_at__isnull=True, + remote_created_at__isnull=False, + ).exclude(enterprise_customer_catalog_uuid__in=customer_catalog_uuids) + def update_content_synced_at(self, action_happened_at, was_successful): """ Given the last time a Content record sync was attempted and status update the appropriate timestamps. @@ -412,6 +429,7 @@ class GenericEnterpriseCustomerPluginConfiguration(EnterpriseCustomerPluginConfi """ A generic implementation of EnterpriseCustomerPluginConfiguration which can be instantiated """ + def __str__(self): """ Return human-readable string representation. @@ -814,3 +832,22 @@ def __repr__(self): Return uniquely identifying string representation. """ return self.__str__() + + +class OrphanedContentTransmissions(TimeStampedModel): + """ + A model to track content metadata transmissions that were successfully sent to the integrated channel but then + subsequently were orphaned by a removal of their associated catalog from the customer. + """ + class Meta: + index_together = [('integrated_channel_code', 'plugin_configuration_id', 'resolved')] + + integrated_channel_code = models.CharField(max_length=30) + plugin_configuration_id = models.PositiveIntegerField(blank=False, null=False) + content_id = models.CharField(max_length=255, blank=False, null=False) + transmission = models.ForeignKey( + ContentMetadataItemTransmission, + related_name='orphaned_record', + on_delete=models.CASCADE, + ) + resolved = models.BooleanField(default=False) diff --git a/integrated_channels/integrated_channel/tasks.py b/integrated_channels/integrated_channel/tasks.py index 3b8b96313b..41f72b35cb 100644 --- a/integrated_channels/integrated_channel/tasks.py +++ b/integrated_channels/integrated_channel/tasks.py @@ -19,6 +19,7 @@ INTEGRATED_CHANNEL_CHOICES, IntegratedChannelCommandUtils, ) +from integrated_channels.integrated_channel.models import ContentMetadataItemTransmission, OrphanedContentTransmissions from integrated_channels.utils import generate_formatted_log LOGGER = get_task_logger(__name__) @@ -89,6 +90,48 @@ def _log_batch_task_finish(task_name, channel_code, job_user_id, )) +@shared_task +@set_code_owner_attribute +def mark_orphaned_content_metadata_audit(): + """ + Task to mark content metadata audits as orphaned if they are not linked to any customer catalogs. + """ + start = time.time() + _log_batch_task_start('mark_orphaned_content_metadata_audit', None, None, None) + + orphaned_metadata_audits = ContentMetadataItemTransmission.objects.none() + # Go over each integrated channel + for individual_channel in INTEGRATED_CHANNEL_CHOICES.values(): + try: + # Iterate through each configuration for the channel + for config in individual_channel.objects.all(): + # fetch orphaned content + orphaned_metadata_audits |= config.fetch_orphaned_content_audits() + except Exception as exc: # pylint: disable=broad-except + LOGGER.exception( + f'[Integrated Channel] mark_orphaned_content_metadata_audit failed with exception {exc}.', + exc_info=True + ) + # Generate orphaned content records for each fetched audit record + for orphaned_metadata_audit in orphaned_metadata_audits: + OrphanedContentTransmissions.objects.get_or_create( + integrated_channel_code=orphaned_metadata_audit.integrated_channel_code, + plugin_configuration_id=orphaned_metadata_audit.plugin_configuration_id, + transmission=orphaned_metadata_audit, + content_id=orphaned_metadata_audit.content_id, + ) + + duration = time.time() - start + _log_batch_task_finish( + 'mark_orphaned_content_metadata_audit', + channel_code=None, + job_user_id=None, + integrated_channel_full_config=None, + duration_seconds=duration, + extra_message=f'Orphaned content metadata audits marked: {orphaned_metadata_audits.count()}' + ) + + @shared_task @set_code_owner_attribute @locked(expiry_seconds=TASK_LOCK_EXPIRY_SECONDS, lock_name_kwargs=['channel_code', 'channel_pk']) diff --git a/integrated_channels/integrated_channel/transmitters/content_metadata.py b/integrated_channels/integrated_channel/transmitters/content_metadata.py index 0f60bafc09..6f3bca3701 100644 --- a/integrated_channels/integrated_channel/transmitters/content_metadata.py +++ b/integrated_channels/integrated_channel/transmitters/content_metadata.py @@ -135,6 +135,21 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name transmission_limit = settings.INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT.get( self.enterprise_configuration.channel_code() ) + + # If we're deleting, fetch all orphaned, uneresolved content transmissions + is_delete_action = action_name == 'delete' + if is_delete_action: + OrphanedContentTransmissions = apps.get_model( + 'integrated_channel', + 'OrphanedContentTransmissions' + ) + orphaned_items = OrphanedContentTransmissions.objects.filter( + integrated_channel_code=self.enterprise_configuration.channel_code(), + plugin_configuration_id=self.enterprise_configuration.id, + resolved=False, + ) + successfully_removed_content_keys = [] + for chunk in islice(chunk_items, transmission_limit): json_payloads = [item.channel_metadata for item in list(chunk.values())] serialized_chunk = self._serialize_items(json_payloads) @@ -203,11 +218,18 @@ def _transmit_action(self, content_metadata_item_map, client_method, action_name transmission.remote_created_at = action_happened_at elif action_name == 'update': transmission.remote_updated_at = action_happened_at - elif action_name == 'delete': + elif is_delete_action: transmission.remote_deleted_at = action_happened_at + if was_successful: + successfully_removed_content_keys.append(transmission.content_id) transmission.save() if was_successful: transmission.remove_marked_for() self.enterprise_configuration.update_content_synced_at(action_happened_at, was_successful) results.append(transmission) + + if is_delete_action and successfully_removed_content_keys: + # Mark any successfully deleted, orphaned content transmissions as resolved + orphaned_items.filter(content_id__in=successfully_removed_content_keys).update(resolved=True) + return results diff --git a/test_utils/factories.py b/test_utils/factories.py index 7b5beba7f3..d8d04d54c6 100644 --- a/test_utils/factories.py +++ b/test_utils/factories.py @@ -53,6 +53,7 @@ GenericEnterpriseCustomerPluginConfiguration, GenericLearnerDataTransmissionAudit, LearnerDataTransmissionAudit, + OrphanedContentTransmissions, ) from integrated_channels.moodle.models import MoodleEnterpriseCustomerConfiguration from integrated_channels.sap_success_factors.models import ( @@ -943,6 +944,25 @@ class Meta: } +class OrphanedContentTransmissionsFactory(factory.django.DjangoModelFactory): + """ + ``OrphanedContentTransmissions`` factory. + """ + + class Meta: + """ + Meta for ``OrphanedContentTransmissions``. + """ + + model = OrphanedContentTransmissions + + integrated_channel_code = 'GENERIC' + content_id = factory.LazyAttribute(lambda x: FAKER.slug()) + plugin_configuration_id = factory.LazyAttribute(lambda x: FAKER.random_int(min=1)) + resolved = False + transmission = factory.Iterator(ContentMetadataItemTransmission.objects.all()) + + class EnterpriseCustomerInviteKeyFactory(factory.django.DjangoModelFactory): """ EnterpriseCustomerInviteKey factory. diff --git a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py index 31d839ae34..974c4593b7 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_exporters/test_content_metadata.py @@ -11,6 +11,8 @@ from pytest import mark from testfixtures import LogCapture +from django.test.utils import override_settings + from enterprise.constants import EXEC_ED_COURSE_TYPE from enterprise.utils import get_content_metadata_item_id from integrated_channels.integrated_channel.exporters.content_metadata import ContentMetadataExporter @@ -544,3 +546,110 @@ def test__get_catalog_content_keys_failed_creates(self): self.config.enterprise_customer.enterprise_customer_catalogs.first(), ) assert len(matched_records) == 2 + + def test_get_customer_orphaned_content(self): + """ + Test the get_customer_orphaned_content function. + """ + transmission_audit = factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.config.enterprise_customer, + plugin_configuration_id=self.config.id, + integrated_channel_code=self.config.channel_code(), + remote_created_at=datetime.datetime.utcnow(), + ) + orphaned_content = factories.OrphanedContentTransmissionsFactory( + integrated_channel_code=self.config.channel_code(), + plugin_configuration_id=self.config.id, + content_id='fake-content-id', + transmission=transmission_audit, + ) + + exporter = ContentMetadataExporter('fake-user', self.config) + + # pylint: disable=protected-access + retrieved_orphaned_content = exporter._get_customer_config_orphaned_content( + max_set_count=1, + ) + assert len(retrieved_orphaned_content) == 1 + assert retrieved_orphaned_content[0].content_id == orphaned_content.content_id + + def test_get_customer_orphaned_content_under_different_channel(self): + """ + Test the _get_customer_config_orphaned_content function with records under a separate channel. + """ + transmission_audit = factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.config.enterprise_customer, + plugin_configuration_id=self.config.id, + integrated_channel_code='foobar', + remote_created_at=datetime.datetime.utcnow(), + ) + factories.OrphanedContentTransmissionsFactory( + integrated_channel_code='foobar', + plugin_configuration_id=self.config.id, + content_id='fake-content-id-1', + transmission=transmission_audit, + ) + exporter = ContentMetadataExporter('fake-user', self.config) + + # pylint: disable=protected-access + retrieved_orphaned_content = exporter._get_customer_config_orphaned_content( + max_set_count=1, + ) + assert len(retrieved_orphaned_content) == 0 + + @override_settings(ALLOW_ORPHANED_CONTENT_REMOVAL=True) + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_content_metadata') + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_catalog_diff') + def test_content_exporter_fetches_orphaned_content(self, mock_get_catalog_diff, mock_get_content_metadata): + """ + ``ContentMetadataExporter``'s ``export`` fetches orphaned content to delete. + """ + transmission_audit = factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.config.enterprise_customer, + plugin_configuration_id=self.config.id, + integrated_channel_code=self.config.channel_code(), + remote_created_at=datetime.datetime.utcnow(), + ) + orphaned_content = factories.OrphanedContentTransmissionsFactory( + integrated_channel_code=self.config.channel_code(), + plugin_configuration_id=self.config.id, + content_id='fake-content-id', + transmission=transmission_audit, + ) + + mock_exec_ed_content = get_fake_content_metadata() + mock_get_content_metadata.return_value = mock_exec_ed_content + mock_get_catalog_diff.return_value = get_fake_catalog_diff_create() + + exporter = ContentMetadataExporter('fake-user', self.config) + _, __, delete_payload = exporter.export() + + assert delete_payload == {orphaned_content.content_id: transmission_audit} + + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_content_metadata') + @mock.patch('enterprise.api_client.enterprise_catalog.EnterpriseCatalogApiClient.get_catalog_diff') + def test_exporter_skips_orphaned_content_when_at_max_size(self, mock_get_catalog_diff, mock_get_content_metadata): + """ + ``ContentMetadataExporter``'s ``export`` skips orphaned content when at max size. + """ + transmission_audit = factories.ContentMetadataItemTransmissionFactory( + enterprise_customer=self.config.enterprise_customer, + plugin_configuration_id=self.config.id, + integrated_channel_code=self.config.channel_code(), + remote_created_at=datetime.datetime.utcnow(), + ) + factories.OrphanedContentTransmissionsFactory( + integrated_channel_code=self.config.channel_code(), + plugin_configuration_id=self.config.id, + content_id='fake-content-id', + transmission=transmission_audit, + ) + + mock_exec_ed_content = get_fake_content_metadata() + mock_get_content_metadata.return_value = mock_exec_ed_content + mock_get_catalog_diff.return_value = get_fake_catalog_diff_create() + + exporter = ContentMetadataExporter('fake-user', self.config) + _, __, delete_payload = exporter.export(max_payload_count=1) + + assert len(delete_payload) == 0 diff --git a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py index fbb4cf31c3..a60b523d0e 100644 --- a/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py +++ b/tests/test_integrated_channels/test_integrated_channel/test_transmitters/test_content_metadata.py @@ -450,3 +450,43 @@ def test_transmit_delete_failure(self): assert deleted_transmission.remote_deleted_at is not None assert deleted_transmission.api_record.status_code > 400 assert deleted_transmission.api_record.body == 'error occurred' + + @mark.django_db + @ddt.ddt + def test_transmit_success_resolve_orphaned_content(self): + """ + Test that a successful transmission will resolve orphaned content. + """ + content_id_1 = 'course:DemoX' + channel_metadata_1 = {'update': True} + content_1 = factories.ContentMetadataItemTransmissionFactory( + content_id=content_id_1, + enterprise_customer=self.enterprise_config.enterprise_customer, + plugin_configuration_id=self.enterprise_config.id, + integrated_channel_code=self.enterprise_config.channel_code(), + enterprise_customer_catalog_uuid=self.enterprise_catalog.uuid, + channel_metadata=channel_metadata_1, + remote_created_at=datetime.utcnow() + ) + orphaned_content_record = factories.OrphanedContentTransmissionsFactory( + integrated_channel_code=self.enterprise_config.channel_code(), + plugin_configuration_id=self.enterprise_config.id, + content_id=content_1.content_id, + transmission=content_1, + ) + + assert not orphaned_content_record.resolved + + create_payload = {} + update_payload = {} + delete_payload = {content_id_1: content_1} + self.delete_content_metadata_mock.return_value = (self.success_response_code, self.success_response_body) + transmitter = ContentMetadataTransmitter(self.enterprise_config) + transmitter.transmit(create_payload, update_payload, delete_payload) + + self.create_content_metadata_mock.assert_not_called() + self.update_content_metadata_mock.assert_not_called() + self.delete_content_metadata_mock.assert_called() + + orphaned_content_record.refresh_from_db() + assert orphaned_content_record.resolved diff --git a/tests/test_management.py b/tests/test_management.py index 6e1add3b4c..f330ebb313 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -4,6 +4,7 @@ import logging import unittest +import uuid from contextlib import contextmanager from datetime import datetime, timedelta from unittest import mock, skip @@ -22,6 +23,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from django.db.models import signals +from django.test.utils import override_settings from django.utils import timezone from django.utils.dateparse import parse_datetime @@ -51,7 +53,7 @@ INTEGRATED_CHANNEL_CHOICES, IntegratedChannelCommandMixin, ) -from integrated_channels.integrated_channel.models import ContentMetadataItemTransmission +from integrated_channels.integrated_channel.models import ContentMetadataItemTransmission, OrphanedContentTransmissions from integrated_channels.sap_success_factors.client import SAPSuccessFactorsAPIClient from integrated_channels.sap_success_factors.exporters.learner_data import SapSuccessFactorsLearnerManger from integrated_channels.sap_success_factors.models import SAPSuccessFactorsEnterpriseCustomerConfiguration @@ -1889,3 +1891,46 @@ def test_normal_run(self): assert generic1.remote_deleted_at is not None assert csod1.remote_deleted_at is None assert csod2.remote_deleted_at is None + + +@mark.django_db +@ddt.ddt +class TestMarkOrphanedContentMetadataAuditsManagementCommand(unittest.TestCase, EnterpriseMockMixin): + """ + Test the ``mark_orphaned_content_metadata_audits`` management command. + """ + + def setUp(self): + self.enterprise_customer = factories.EnterpriseCustomerFactory( + name='Veridian Dynamics', + ) + ContentMetadataItemTransmission.objects.all().delete() + enterprise_catalog = factories.EnterpriseCustomerCatalogFactory( + enterprise_customer=self.enterprise_customer, + ) + self.enterprise_customer.enterprise_customer_catalogs.set([enterprise_catalog]) + self.enterprise_customer.save() + self.customer_config = factories.DegreedEnterpriseCustomerConfigurationFactory( + enterprise_customer=self.enterprise_customer, + key='key', + secret='secret', + degreed_company_id='Degreed Company', + degreed_base_url='https://www.degreed.com/', + ) + self.orphaned_content = factories.ContentMetadataItemTransmissionFactory( + content_id='DemoX', + enterprise_customer=self.enterprise_customer, + plugin_configuration_id=self.customer_config.id, + integrated_channel_code=self.customer_config.channel_code(), + channel_metadata={}, + enterprise_customer_catalog_uuid=uuid.uuid4(), + remote_created_at=datetime.now() + ) + super().setUp() + + @override_settings(ALLOW_ORPHANED_CONTENT_REMOVAL=True) + def test_normal_run(self): + assert not OrphanedContentTransmissions.objects.all() + call_command('mark_orphaned_content_metadata_audits') + orphaned_content = OrphanedContentTransmissions.objects.first() + assert orphaned_content.content_id == self.orphaned_content.content_id