From dbff2a2ff55b9aa6fdb9edecec6fe5fc14fa3303 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Mon, 19 Jan 2026 14:59:13 -0800 Subject: [PATCH 1/9] first steps for create_channel_versions command --- .../commands/create_channel_versions.py | 26 +++++ .../utils/test_create_channel_versions.py | 95 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 contentcuration/contentcuration/management/commands/create_channel_versions.py create mode 100644 contentcuration/contentcuration/tests/utils/test_create_channel_versions.py diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py new file mode 100644 index 0000000000..2e3b8ef7d2 --- /dev/null +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -0,0 +1,26 @@ +import logging as logmodule + +from contentcuration.models import Channel, ChannelVersion +from django.core.management.base import BaseCommand + +logging = logmodule.getLogger("command") + +class Command(BaseCommand): + def handle(self, *args, **options): + logging.info("Creating channel versions") + + channels = Channel.objects.filter(version__gt=0) + + # Create ChannelVersions for each published version and set version_info + # to the most recent ChannelVersion instance + for channel in channels.iterator(): + last_created_ch_ver = None + for pub_data in channel.published_data.values(): + + # Create a new channel version + last_created_ch_ver = ChannelVersion.objects.create( + channel=channel, + version=channel.version + 1, + published_data=pub_data, + ) + channel.version_info = last_created_ch_ver diff --git a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py new file mode 100644 index 0000000000..6d43584b7a --- /dev/null +++ b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py @@ -0,0 +1,95 @@ +""" +**Testing plan** + +1. Create a channel, version 0, with no published_data entries +2. Create a channel, version 1, with 1 published_data entries +3. Create a channel, version 2, with 2 published_data entries +4. Confirm that the channel versions are created correctly +{ + "published_data": { + "1": { + "size": 2207, + "kind_count": [ + { + "count": 1, + "kind_id": "exercise" + } + ], + "version_notes": "2123123", + "date_published": "2026-01-19 22:03:21", + "resource_count": 1, + "included_licenses": [ + 5 + ], + "included_languages": [], + "included_categories": [] + }, + "2": { + "size": 2207, + "kind_count": [ + { + "count": 1, + "kind_id": "exercise" + } + ], + "version_notes": "223123123", + "date_published": "2026-01-19 22:04:30", + "resource_count": 1, + "included_licenses": [ + 5 + ], + "included_languages": [], + "included_categories": [] + } + } +} +""" +from django.core.management import call_command + +from contentcuration.models import Channel +from contentcuration.models import ChannelVersion +from contentcuration.tests import testdata +from contentcuration.tests.base import StudioTestCase + +def _bump(channel): + channel.version += 1 + channel.published_data[str(channel.version)] = { + "version": channel.version + } + channel.save() + + +class TestCreateChannelVersions(StudioTestCase): + """Tests for the create_channel_versions management command.""" + + + def test_channel_with_no_published_data(self): + """A channel with version 0 and no published_data should create no ChannelVersions.""" + channel = testdata.channel() + channel.version = 0 + channel.published_data = {} + channel.save() + + call_command("create_channel_versions") + + self.assertEqual( + ChannelVersion.objects.filter(channel=channel).count(), + 0, + "No ChannelVersion should be created for a channel with no published_data", + ) + + def test_single_channel_with_published_data(self): + """A channel with version 1 and 1 published_data should create 1 ChannelVersion.""" + channel = testdata.channel() + + _bump(channel) + + call_command("create_channel_versions") + + self.assertEqual( + ChannelVersion.objects.filter(channel=channel).count(), + 1, + "A ChannelVersion should be created for a channel with published_data", + ) + + self.assertEqual(channel.version_info, ChannelVersion.objects.last()) From acc18e5be8df2718d4f53410a7dba195b3cdb7dc Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 22 Jan 2026 12:39:13 -0800 Subject: [PATCH 2/9] improving on tests - building proper channelversion object (mostly) --- .../commands/create_channel_versions.py | 106 +++++++++++++++++- .../utils/test_create_channel_versions.py | 16 ++- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 2e3b8ef7d2..984d861909 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -1,10 +1,97 @@ import logging as logmodule +from itertools import chain -from contentcuration.models import Channel, ChannelVersion +from contentcuration.models import AuditedSpecialPermissionsLicense, Channel, ChannelVersion, License from django.core.management.base import BaseCommand +from le_utils.constants import licenses logging = logmodule.getLogger("command") +def validate_published_data(data, channel): + # Logic for filling each missing field stolen from + # contentcuration.utils.publish.fill_published_fields + published_nodes = ( + channel.main_tree.get_descendants() + .filter(published=True) + .prefetch_related("files") + ) + + if not data: + data = {} + + # Go through each required field and calculate any missing fields if we can + if not data.get('included_categories'): + included_categories_dicts = published_nodes.exclude(categories=None).values_list( + "categories", flat=True + ) + data['included_categories'] = sorted( + set( + chain.from_iterable( + ( + node_categories_dict.keys() + for node_categories_dict in included_categories_dicts + ) + ) + ) + ) + if not data.get('included_languages'): + node_languages = published_nodes.exclude(language=None).values_list( + "language", flat=True + ) + file_languages = published_nodes.exclude(files__language=None).values_list( + "files__language", flat=True + ) + data['included_languages'] = list(set(chain(node_languages, file_languages))) + + if not data.get('included_licenses'): + data['included_licenses'] = list(published_nodes.exclude(license=None).values_list( + "license", flat=True + )) + if not data.get('non_distributable_licenses_included'): + # Calculate non-distributable licenses (All Rights Reserved) + all_rights_reserved_id = ( + License.objects.filter(license_name=licenses.ALL_RIGHTS_RESERVED) + .values_list("id", flat=True) + .first() + ) + + data['non_distributable_licenses'] = ( + [all_rights_reserved_id] + if all_rights_reserved_id and all_rights_reserved_id in data['included_licenses'] + else [] + ) + if not data.get('special_permissions_included'): + # records for each unique description so reviewers can approve/reject them individually. + # This allows centralized tracking of custom licenses across all channels. + special_permissions_id = ( + License.objects.filter(license_name=licenses.SPECIAL_PERMISSIONS) + .values_list("id", flat=True) + .first() + ) + + special_perms_descriptions = None + if special_permissions_id and special_permissions_id in data['included_licenses']: + special_perms_descriptions = list( + published_nodes.filter(license_id=special_permissions_id) + .exclude(license_description__isnull=True) + .exclude(license_description="") + .values_list("license_description", flat=True) + .distinct() + ) + + if special_perms_descriptions: + new_licenses = [ + AuditedSpecialPermissionsLicense( + description=description, distributable=False + ) + for description in special_perms_descriptions + ] + + data['special_permissions_included'] = AuditedSpecialPermissionsLicense.objects.bulk_create( + new_licenses, ignore_conflicts=True + ) + + class Command(BaseCommand): def handle(self, *args, **options): logging.info("Creating channel versions") @@ -14,13 +101,26 @@ def handle(self, *args, **options): # Create ChannelVersions for each published version and set version_info # to the most recent ChannelVersion instance for channel in channels.iterator(): + logging.info(f"Processing channel {channel.id}") last_created_ch_ver = None + + # Validate published_data for pub_data in channel.published_data.values(): + logging.info(f"Validating published data for channel {channel.id} version {pub_data['version']}") + validate_published_data(pub_data, channel) + # TODO This is a m2m field for AuditedSpecialPermissionsLicense do that instead + # special_permissions_included=pub_data.get('special_permissions_included'), # Create a new channel version last_created_ch_ver = ChannelVersion.objects.create( channel=channel, - version=channel.version + 1, - published_data=pub_data, + version=pub_data.get('version'), + included_categories=pub_data.get('included_categories', []), + included_licenses=pub_data.get('included_licenses'), + included_languages=pub_data.get('included_languages'), + non_distributable_licenses_included=pub_data.get('non_distributable_licenses_included'), ) + logging.info(f"Created channel version {last_created_ch_ver.id} for channel {channel.id}") + channel.version_info = last_created_ch_ver + channel.save() diff --git a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py index 6d43584b7a..bcbd06a88f 100644 --- a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py +++ b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py @@ -54,7 +54,7 @@ def _bump(channel): channel.version += 1 channel.published_data[str(channel.version)] = { - "version": channel.version + "version": channel.version, } channel.save() @@ -62,6 +62,16 @@ def _bump(channel): class TestCreateChannelVersions(StudioTestCase): """Tests for the create_channel_versions management command.""" + def setUp(self): + super(TestCreateChannelVersions, self).setUp() + + def _validate_required_fields(self, channel_version): + if (not channel_version.included_licenses or + not channel_version.included_languages or + not channel_version.included_categories or + not channel_version.non_distributable_licenses_included or + not channel_version.special_permissions_included): + self.fail("ChannelVersion is missing required fields") def test_channel_with_no_published_data(self): """A channel with version 0 and no published_data should create no ChannelVersions.""" @@ -71,6 +81,7 @@ def test_channel_with_no_published_data(self): channel.save() call_command("create_channel_versions") + channel.refresh_from_db() self.assertEqual( ChannelVersion.objects.filter(channel=channel).count(), @@ -81,10 +92,11 @@ def test_channel_with_no_published_data(self): def test_single_channel_with_published_data(self): """A channel with version 1 and 1 published_data should create 1 ChannelVersion.""" channel = testdata.channel() - _bump(channel) + ChannelVersion.objects.all().delete() call_command("create_channel_versions") + channel.refresh_from_db() self.assertEqual( ChannelVersion.objects.filter(channel=channel).count(), From a3eda9269e5155a8f63b47e4bbf1d7d59e830176 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 22 Jan 2026 13:04:48 -0800 Subject: [PATCH 3/9] cleanup - set Audited license fields correctly I tried just assigning it in the create() call but got a complaint about the direction of my assignment wrt the m2m relationship --- .../management/commands/create_channel_versions.py | 4 ++-- .../tests/utils/test_create_channel_versions.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 984d861909..152ce347de 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -82,7 +82,7 @@ def validate_published_data(data, channel): if special_perms_descriptions: new_licenses = [ AuditedSpecialPermissionsLicense( - description=description, distributable=False + description=description, distributable=channel.published ) for description in special_perms_descriptions ] @@ -110,7 +110,6 @@ def handle(self, *args, **options): validate_published_data(pub_data, channel) # TODO This is a m2m field for AuditedSpecialPermissionsLicense do that instead - # special_permissions_included=pub_data.get('special_permissions_included'), # Create a new channel version last_created_ch_ver = ChannelVersion.objects.create( channel=channel, @@ -120,6 +119,7 @@ def handle(self, *args, **options): included_languages=pub_data.get('included_languages'), non_distributable_licenses_included=pub_data.get('non_distributable_licenses_included'), ) + last_created_ch_ver.special_permissions_included.set(pub_data.get('special_permissions_included', [])) logging.info(f"Created channel version {last_created_ch_ver.id} for channel {channel.id}") channel.version_info = last_created_ch_ver diff --git a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py index bcbd06a88f..bfa45d4f02 100644 --- a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py +++ b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py @@ -65,14 +65,6 @@ class TestCreateChannelVersions(StudioTestCase): def setUp(self): super(TestCreateChannelVersions, self).setUp() - def _validate_required_fields(self, channel_version): - if (not channel_version.included_licenses or - not channel_version.included_languages or - not channel_version.included_categories or - not channel_version.non_distributable_licenses_included or - not channel_version.special_permissions_included): - self.fail("ChannelVersion is missing required fields") - def test_channel_with_no_published_data(self): """A channel with version 0 and no published_data should create no ChannelVersions.""" channel = testdata.channel() From 49711f0671f5b3403bc2e276e8a1d26d3c1cacb3 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 22 Jan 2026 13:51:39 -0800 Subject: [PATCH 4/9] (claude): generate more tests to account for the Audited license logic --- .../commands/create_channel_versions.py | 90 +++-- .../utils/test_create_channel_versions.py | 360 +++++++++++++++--- 2 files changed, 372 insertions(+), 78 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 152ce347de..3b9d8d1f44 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -7,7 +7,58 @@ logging = logmodule.getLogger("command") +def compute_special_permissions(data, channel, published_nodes): + """ + Compute and create AuditedSpecialPermissionsLicense objects for special permissions content. + + Returns a list of AuditedSpecialPermissionsLicense objects to be associated with the ChannelVersion. + Note: These objects are NOT stored in the data dict since they are not JSON serializable + and the data dict may be saved to a JSONField (channel.published_data). + """ + if data.get('special_permissions_included'): + # Already computed, return empty (will be handled by existing data) + return [] + + special_permissions_id = ( + License.objects.filter(license_name=licenses.SPECIAL_PERMISSIONS) + .values_list("id", flat=True) + .first() + ) + + if not special_permissions_id or special_permissions_id not in data.get('included_licenses', []): + return [] + + special_perms_descriptions = list( + published_nodes.filter(license_id=special_permissions_id) + .exclude(license_description__isnull=True) + .exclude(license_description="") + .values_list("license_description", flat=True) + .distinct() + ) + + if not special_perms_descriptions: + return [] + + new_licenses = [ + AuditedSpecialPermissionsLicense( + description=description, distributable=channel.public + ) + for description in special_perms_descriptions + ] + + return AuditedSpecialPermissionsLicense.objects.bulk_create( + new_licenses, ignore_conflicts=True + ) + + def validate_published_data(data, channel): + """ + Fill in any missing fields in the published_data dict. + Returns a list of AuditedSpecialPermissionsLicense objects for the M2M relation. + + Note: special_permissions_included is returned separately since it contains + model objects that cannot be JSON serialized into the data dict. + """ # Logic for filling each missing field stolen from # contentcuration.utils.publish.fill_published_fields published_nodes = ( @@ -60,36 +111,10 @@ def validate_published_data(data, channel): if all_rights_reserved_id and all_rights_reserved_id in data['included_licenses'] else [] ) - if not data.get('special_permissions_included'): - # records for each unique description so reviewers can approve/reject them individually. - # This allows centralized tracking of custom licenses across all channels. - special_permissions_id = ( - License.objects.filter(license_name=licenses.SPECIAL_PERMISSIONS) - .values_list("id", flat=True) - .first() - ) - special_perms_descriptions = None - if special_permissions_id and special_permissions_id in data['included_licenses']: - special_perms_descriptions = list( - published_nodes.filter(license_id=special_permissions_id) - .exclude(license_description__isnull=True) - .exclude(license_description="") - .values_list("license_description", flat=True) - .distinct() - ) - - if special_perms_descriptions: - new_licenses = [ - AuditedSpecialPermissionsLicense( - description=description, distributable=channel.published - ) - for description in special_perms_descriptions - ] - - data['special_permissions_included'] = AuditedSpecialPermissionsLicense.objects.bulk_create( - new_licenses, ignore_conflicts=True - ) + # Compute special permissions and return them separately (not stored in data dict) + special_permissions = compute_special_permissions(data, channel, published_nodes) + return special_permissions class Command(BaseCommand): @@ -107,9 +132,8 @@ def handle(self, *args, **options): # Validate published_data for pub_data in channel.published_data.values(): logging.info(f"Validating published data for channel {channel.id} version {pub_data['version']}") - validate_published_data(pub_data, channel) + special_permissions = validate_published_data(pub_data, channel) - # TODO This is a m2m field for AuditedSpecialPermissionsLicense do that instead # Create a new channel version last_created_ch_ver = ChannelVersion.objects.create( channel=channel, @@ -119,7 +143,9 @@ def handle(self, *args, **options): included_languages=pub_data.get('included_languages'), non_distributable_licenses_included=pub_data.get('non_distributable_licenses_included'), ) - last_created_ch_ver.special_permissions_included.set(pub_data.get('special_permissions_included', [])) + # Set the M2M relation for special permissions + if special_permissions: + last_created_ch_ver.special_permissions_included.set(special_permissions) logging.info(f"Created channel version {last_created_ch_ver.id} for channel {channel.id}") channel.version_info = last_created_ch_ver diff --git a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py index bfa45d4f02..9ee02c4dcd 100644 --- a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py +++ b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py @@ -1,57 +1,19 @@ -""" -**Testing plan** - -1. Create a channel, version 0, with no published_data entries -2. Create a channel, version 1, with 1 published_data entries -3. Create a channel, version 2, with 2 published_data entries -4. Confirm that the channel versions are created correctly -{ - "published_data": { - "1": { - "size": 2207, - "kind_count": [ - { - "count": 1, - "kind_id": "exercise" - } - ], - "version_notes": "2123123", - "date_published": "2026-01-19 22:03:21", - "resource_count": 1, - "included_licenses": [ - 5 - ], - "included_languages": [], - "included_categories": [] - }, - "2": { - "size": 2207, - "kind_count": [ - { - "count": 1, - "kind_id": "exercise" - } - ], - "version_notes": "223123123", - "date_published": "2026-01-19 22:04:30", - "resource_count": 1, - "included_licenses": [ - 5 - ], - "included_languages": [], - "included_categories": [] - } - } -} -""" from django.core.management import call_command +from le_utils.constants import content_kinds +from le_utils.constants import licenses +from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Channel from contentcuration.models import ChannelVersion +from contentcuration.models import ContentKind +from contentcuration.models import ContentNode +from contentcuration.models import License from contentcuration.tests import testdata from contentcuration.tests.base import StudioTestCase + def _bump(channel): + """Increment channel version and add empty published_data entry.""" channel.version += 1 channel.published_data[str(channel.version)] = { "version": channel.version, @@ -59,11 +21,35 @@ def _bump(channel): channel.save() +def _create_special_permissions_node(parent, description): + """ + Create a content node with a Special Permissions license and the given description. + Returns the created node. + """ + special_permissions_license = License.objects.get( + license_name=licenses.SPECIAL_PERMISSIONS + ) + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + node = ContentNode.objects.create( + title="Special Permissions Content", + kind=video_kind, + parent=parent, + license=special_permissions_license, + license_description=description, + published=True, + complete=True, + ) + return node + + class TestCreateChannelVersions(StudioTestCase): """Tests for the create_channel_versions management command.""" def setUp(self): super(TestCreateChannelVersions, self).setUp() + # Clear any existing AuditedSpecialPermissionsLicense objects to ensure clean test state + AuditedSpecialPermissionsLicense.objects.all().delete() def test_channel_with_no_published_data(self): """A channel with version 0 and no published_data should create no ChannelVersions.""" @@ -97,3 +83,285 @@ def test_single_channel_with_published_data(self): ) self.assertEqual(channel.version_info, ChannelVersion.objects.last()) + + def test_public_channel_special_permissions_distributable_true(self): + """ + When a channel is public (public=True), AuditedSpecialPermissionsLicense objects + created for special permissions content should have distributable=True. + """ + channel = testdata.channel() + channel.public = True + channel.save() + + # Create a node with special permissions license + special_description = "This content can be distributed for educational purposes" + _create_special_permissions_node(channel.main_tree, special_description) + + # Bump channel version without included_licenses so it gets computed + channel.version = 1 + channel.published_data = { + "1": { + "version": 1, + # Intentionally missing special_permissions_included to trigger computation + } + } + channel.save() + + # Clear any existing ChannelVersions + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + channel.refresh_from_db() + + # Check that the AuditedSpecialPermissionsLicense was created with distributable=True + audited_license = AuditedSpecialPermissionsLicense.objects.filter( + description=special_description + ).first() + + self.assertIsNotNone( + audited_license, + "AuditedSpecialPermissionsLicense should be created for special permissions content", + ) + self.assertTrue( + audited_license.distributable, + "AuditedSpecialPermissionsLicense should have distributable=True for public channels", + ) + + def test_private_channel_special_permissions_distributable_false(self): + """ + When a channel is private (public=False), AuditedSpecialPermissionsLicense objects + created for special permissions content should have distributable=False. + """ + channel = testdata.channel() + channel.public = False + channel.save() + + # Create a node with special permissions license + special_description = "This content has restricted distribution" + _create_special_permissions_node(channel.main_tree, special_description) + + # Bump channel version without special_permissions_included to trigger computation + channel.version = 1 + channel.published_data = { + "1": { + "version": 1, + } + } + channel.save() + + # Clear any existing ChannelVersions + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + channel.refresh_from_db() + + # Check that the AuditedSpecialPermissionsLicense was created with distributable=False + audited_license = AuditedSpecialPermissionsLicense.objects.filter( + description=special_description + ).first() + + self.assertIsNotNone( + audited_license, + "AuditedSpecialPermissionsLicense should be created for special permissions content", + ) + self.assertFalse( + audited_license.distributable, + "AuditedSpecialPermissionsLicense should have distributable=False for private channels", + ) + + def test_multiple_versions_with_published_data(self): + """A channel with version 2 and 2 published_data entries should create 2 ChannelVersions.""" + channel = testdata.channel() + channel.version = 2 + channel.published_data = { + "1": { + "version": 1, + "version_notes": "First version", + }, + "2": { + "version": 2, + "version_notes": "Second version", + }, + } + channel.save() + + # Clear any existing ChannelVersions + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + channel.refresh_from_db() + + channel_versions = ChannelVersion.objects.filter(channel=channel).order_by("version") + self.assertEqual( + channel_versions.count(), + 2, + "Two ChannelVersions should be created for a channel with 2 published_data entries", + ) + + # version_info should point to the latest version + self.assertEqual(channel.version_info.version, 2) + + def test_channel_version_info_set_to_latest(self): + """The channel's version_info should point to the latest created ChannelVersion.""" + channel = testdata.channel() + channel.version = 3 + channel.published_data = { + "1": {"version": 1}, + "2": {"version": 2}, + "3": {"version": 3}, + } + channel.save() + + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + channel.refresh_from_db() + + self.assertIsNotNone(channel.version_info) + self.assertEqual( + channel.version_info.version, + 3, + "version_info should point to the latest (version 3) ChannelVersion", + ) + + def test_special_permissions_license_associated_with_channel_version(self): + """ + AuditedSpecialPermissionsLicense should be associated with the ChannelVersion + via the special_permissions_included M2M field. + """ + channel = testdata.channel() + channel.public = True + channel.save() + + # Create a node with special permissions license + special_description = "Content for M2M association test" + _create_special_permissions_node(channel.main_tree, special_description) + + channel.version = 1 + channel.published_data = { + "1": { + "version": 1, + } + } + channel.save() + + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + channel.refresh_from_db() + + # The AuditedSpecialPermissionsLicense should be in the ChannelVersion's M2M relation + self.assertTrue( + channel.version_info.special_permissions_included.filter( + description=special_description + ).exists(), + "AuditedSpecialPermissionsLicense should be associated with the ChannelVersion", + ) + + def test_multiple_special_permissions_same_channel(self): + """ + Multiple nodes with different special permissions descriptions should create + multiple AuditedSpecialPermissionsLicense objects, all with the correct + distributable value based on channel.public. + """ + channel = testdata.channel() + channel.public = True + channel.save() + + # Create multiple nodes with different special permissions descriptions + descriptions = [ + "Educational use only", + "Non-commercial distribution allowed", + "Attribution required for sharing", + ] + for desc in descriptions: + _create_special_permissions_node(channel.main_tree, desc) + + channel.version = 1 + channel.published_data = { + "1": { + "version": 1, + } + } + channel.save() + + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + channel.refresh_from_db() + + for desc in descriptions: + audited_license = AuditedSpecialPermissionsLicense.objects.filter( + description=desc + ).first() + self.assertIsNotNone( + audited_license, + f"AuditedSpecialPermissionsLicense should exist for description: {desc}", + ) + self.assertTrue( + audited_license.distributable, + f"distributable should be True for public channel, description: {desc}", + ) + + def test_command_skips_channels_with_existing_version_info(self): + """ + The command should only process channels that don't already have version_info set. + This tests idempotency behavior. + """ + channel = testdata.channel() + _bump(channel) + ChannelVersion.objects.all().delete() + + # Run command first time + call_command("create_channel_versions") + first_count = ChannelVersion.objects.filter(channel=channel).count() + + self.assertEqual( + first_count, + 1, + "First run should create 1 ChannelVersion", + ) + + # Verify version_info is set + channel.refresh_from_db() + self.assertIsNotNone(channel.version_info) + + def test_channel_with_existing_special_permissions_in_published_data(self): + """ + If published_data already contains special_permissions_included, + the command should not recompute it. + """ + channel = testdata.channel() + channel.public = True + channel.save() + + # Create a node with special permissions license + special_description = "Pre-existing special permissions" + _create_special_permissions_node(channel.main_tree, special_description) + + # Pre-create an AuditedSpecialPermissionsLicense + existing_license = AuditedSpecialPermissionsLicense.objects.create( + description="Already audited license", + distributable=False, # Set to False to distinguish from computed ones + ) + + channel.version = 1 + channel.published_data = { + "1": { + "version": 1, + # Already has special_permissions_included + "special_permissions_included": [str(existing_license.id)], + } + } + channel.save() + + ChannelVersion.objects.all().delete() + + call_command("create_channel_versions") + + # The existing license should remain unchanged (distributable=False) + existing_license.refresh_from_db() + self.assertFalse( + existing_license.distributable, + "Pre-existing AuditedSpecialPermissionsLicense should not be modified", + ) From a70ad5673b08fbc3f2750bbb49bb85ec0447e68b Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Thu, 22 Jan 2026 16:45:00 -0800 Subject: [PATCH 5/9] lint --- .../commands/create_channel_versions.py | 66 ++++++++++++------- .../utils/test_create_channel_versions.py | 5 +- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 3b9d8d1f44..895dfeb3c4 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -1,12 +1,17 @@ import logging as logmodule from itertools import chain -from contentcuration.models import AuditedSpecialPermissionsLicense, Channel, ChannelVersion, License from django.core.management.base import BaseCommand from le_utils.constants import licenses +from contentcuration.models import AuditedSpecialPermissionsLicense +from contentcuration.models import Channel +from contentcuration.models import ChannelVersion +from contentcuration.models import License + logging = logmodule.getLogger("command") + def compute_special_permissions(data, channel, published_nodes): """ Compute and create AuditedSpecialPermissionsLicense objects for special permissions content. @@ -15,7 +20,7 @@ def compute_special_permissions(data, channel, published_nodes): Note: These objects are NOT stored in the data dict since they are not JSON serializable and the data dict may be saved to a JSONField (channel.published_data). """ - if data.get('special_permissions_included'): + if data.get("special_permissions_included"): # Already computed, return empty (will be handled by existing data) return [] @@ -25,7 +30,9 @@ def compute_special_permissions(data, channel, published_nodes): .first() ) - if not special_permissions_id or special_permissions_id not in data.get('included_licenses', []): + if not special_permissions_id or special_permissions_id not in data.get( + "included_licenses", [] + ): return [] special_perms_descriptions = list( @@ -71,11 +78,11 @@ def validate_published_data(data, channel): data = {} # Go through each required field and calculate any missing fields if we can - if not data.get('included_categories'): - included_categories_dicts = published_nodes.exclude(categories=None).values_list( - "categories", flat=True - ) - data['included_categories'] = sorted( + if not data.get("included_categories"): + included_categories_dicts = published_nodes.exclude( + categories=None + ).values_list("categories", flat=True) + data["included_categories"] = sorted( set( chain.from_iterable( ( @@ -85,20 +92,20 @@ def validate_published_data(data, channel): ) ) ) - if not data.get('included_languages'): + if not data.get("included_languages"): node_languages = published_nodes.exclude(language=None).values_list( "language", flat=True ) file_languages = published_nodes.exclude(files__language=None).values_list( "files__language", flat=True ) - data['included_languages'] = list(set(chain(node_languages, file_languages))) + data["included_languages"] = list(set(chain(node_languages, file_languages))) - if not data.get('included_licenses'): - data['included_licenses'] = list(published_nodes.exclude(license=None).values_list( - "license", flat=True - )) - if not data.get('non_distributable_licenses_included'): + if not data.get("included_licenses"): + data["included_licenses"] = list( + published_nodes.exclude(license=None).values_list("license", flat=True) + ) + if not data.get("non_distributable_licenses_included"): # Calculate non-distributable licenses (All Rights Reserved) all_rights_reserved_id = ( License.objects.filter(license_name=licenses.ALL_RIGHTS_RESERVED) @@ -106,9 +113,10 @@ def validate_published_data(data, channel): .first() ) - data['non_distributable_licenses'] = ( + data["non_distributable_licenses"] = ( [all_rights_reserved_id] - if all_rights_reserved_id and all_rights_reserved_id in data['included_licenses'] + if all_rights_reserved_id + and all_rights_reserved_id in data["included_licenses"] else [] ) @@ -131,22 +139,30 @@ def handle(self, *args, **options): # Validate published_data for pub_data in channel.published_data.values(): - logging.info(f"Validating published data for channel {channel.id} version {pub_data['version']}") + logging.info( + f"Validating published data for channel {channel.id} version {pub_data['version']}" + ) special_permissions = validate_published_data(pub_data, channel) # Create a new channel version last_created_ch_ver = ChannelVersion.objects.create( channel=channel, - version=pub_data.get('version'), - included_categories=pub_data.get('included_categories', []), - included_licenses=pub_data.get('included_licenses'), - included_languages=pub_data.get('included_languages'), - non_distributable_licenses_included=pub_data.get('non_distributable_licenses_included'), + version=pub_data.get("version"), + included_categories=pub_data.get("included_categories", []), + included_licenses=pub_data.get("included_licenses"), + included_languages=pub_data.get("included_languages"), + non_distributable_licenses_included=pub_data.get( + "non_distributable_licenses_included" + ), ) # Set the M2M relation for special permissions if special_permissions: - last_created_ch_ver.special_permissions_included.set(special_permissions) - logging.info(f"Created channel version {last_created_ch_ver.id} for channel {channel.id}") + last_created_ch_ver.special_permissions_included.set( + special_permissions + ) + logging.info( + f"Created channel version {last_created_ch_ver.id} for channel {channel.id}" + ) channel.version_info = last_created_ch_ver channel.save() diff --git a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py index 9ee02c4dcd..29883f1372 100644 --- a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py +++ b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py @@ -3,7 +3,6 @@ from le_utils.constants import licenses from contentcuration.models import AuditedSpecialPermissionsLicense -from contentcuration.models import Channel from contentcuration.models import ChannelVersion from contentcuration.models import ContentKind from contentcuration.models import ContentNode @@ -191,7 +190,9 @@ def test_multiple_versions_with_published_data(self): call_command("create_channel_versions") channel.refresh_from_db() - channel_versions = ChannelVersion.objects.filter(channel=channel).order_by("version") + channel_versions = ChannelVersion.objects.filter(channel=channel).order_by( + "version" + ) self.assertEqual( channel_versions.count(), 2, From 98c49b33b7787b56318033b0456821dc8ab24981 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Wed, 28 Jan 2026 12:35:08 -0800 Subject: [PATCH 6/9] set version_info when pub_data ver = channel ver --- .../management/commands/create_channel_versions.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 895dfeb3c4..f787d507ad 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -135,7 +135,6 @@ def handle(self, *args, **options): # to the most recent ChannelVersion instance for channel in channels.iterator(): logging.info(f"Processing channel {channel.id}") - last_created_ch_ver = None # Validate published_data for pub_data in channel.published_data.values(): @@ -145,7 +144,7 @@ def handle(self, *args, **options): special_permissions = validate_published_data(pub_data, channel) # Create a new channel version - last_created_ch_ver = ChannelVersion.objects.create( + channel_version = ChannelVersion.objects.create( channel=channel, version=pub_data.get("version"), included_categories=pub_data.get("included_categories", []), @@ -155,14 +154,17 @@ def handle(self, *args, **options): "non_distributable_licenses_included" ), ) + + if channel.version == pub_data.get("version"): + channel.version_info = channel_version + # Set the M2M relation for special permissions if special_permissions: - last_created_ch_ver.special_permissions_included.set( + channel_version.special_permissions_included.set( special_permissions ) logging.info( - f"Created channel version {last_created_ch_ver.id} for channel {channel.id}" + f"Created channel version {channel_version.id} for channel {channel.id}" ) - channel.version_info = last_created_ch_ver channel.save() From b13bf13b0843b004b0fdc29063b877e3ece11845 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Wed, 28 Jan 2026 13:09:38 -0800 Subject: [PATCH 7/9] include missing fields --- .../commands/create_channel_versions.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index f787d507ad..77fa2cf940 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -2,6 +2,7 @@ from itertools import chain from django.core.management.base import BaseCommand +from django.db.models import Count from le_utils.constants import licenses from contentcuration.models import AuditedSpecialPermissionsLicense @@ -65,7 +66,11 @@ def validate_published_data(data, channel): Note: special_permissions_included is returned separately since it contains model objects that cannot be JSON serialized into the data dict. + + returns tuple (pub_data, special_permissions) - the former a dict of values, the + latter is a list of AuditedSpecialPermissionsLicense objects """ + # Logic for filling each missing field stolen from # contentcuration.utils.publish.fill_published_fields published_nodes = ( @@ -120,9 +125,15 @@ def validate_published_data(data, channel): else [] ) + if not data.get("kind_counts"): + data["kind_counts"] = list( + published_nodes.values("kind_id") + .annotate(count=Count("kind_id")) + .order_by("kind_id") + ) # Compute special permissions and return them separately (not stored in data dict) special_permissions = compute_special_permissions(data, channel, published_nodes) - return special_permissions + return data, special_permissions class Command(BaseCommand): @@ -141,18 +152,23 @@ def handle(self, *args, **options): logging.info( f"Validating published data for channel {channel.id} version {pub_data['version']}" ) - special_permissions = validate_published_data(pub_data, channel) + valid_data, special_permissions = validate_published_data( + pub_data, channel + ) # Create a new channel version channel_version = ChannelVersion.objects.create( channel=channel, - version=pub_data.get("version"), - included_categories=pub_data.get("included_categories", []), - included_licenses=pub_data.get("included_licenses"), - included_languages=pub_data.get("included_languages"), - non_distributable_licenses_included=pub_data.get( + version=valid_data.get("version"), + included_categories=valid_data.get("included_categories"), + included_licenses=valid_data.get("included_licenses"), + included_languages=valid_data.get("included_languages"), + non_distributable_licenses_included=valid_data.get( "non_distributable_licenses_included" ), + kind_count=valid_data.get("kind_count"), + size=int(channel.published_size), + resource_count=channel.total_resource_count, ) if channel.version == pub_data.get("version"): From dd31826cf3467c37bb93dc9ad67198cba6c2b0de Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Wed, 28 Jan 2026 13:58:14 -0800 Subject: [PATCH 8/9] add unit tests for validate_published_data, remove misleading tests - Add TestValidatePublishedData class with direct unit tests for the validate function covering: return type, included_licenses, included_languages, included_categories, kind_counts, non_distributable_licenses, and special_permissions - Remove test_command_skips_channels_with_existing_version_info (didn't actually test idempotency) - Remove test_channel_with_existing_special_permissions_in_published_data (wasn't testing meaningful behavior) Co-Authored-By: Claude Opus 4.5 --- .../utils/test_create_channel_versions.py | 273 ++++++++++++++---- 1 file changed, 210 insertions(+), 63 deletions(-) diff --git a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py index 29883f1372..0332336037 100644 --- a/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py +++ b/contentcuration/contentcuration/tests/utils/test_create_channel_versions.py @@ -2,10 +2,15 @@ from le_utils.constants import content_kinds from le_utils.constants import licenses +from contentcuration.management.commands.create_channel_versions import ( + validate_published_data, +) from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import ChannelVersion from contentcuration.models import ContentKind from contentcuration.models import ContentNode +from contentcuration.models import File +from contentcuration.models import Language from contentcuration.models import License from contentcuration.tests import testdata from contentcuration.tests.base import StudioTestCase @@ -42,6 +47,211 @@ def _create_special_permissions_node(parent, description): return node +class TestValidatePublishedData(StudioTestCase): + """Unit tests for the validate_published_data function.""" + + def setUp(self): + super(TestValidatePublishedData, self).setUp() + self.channel = testdata.channel() + AuditedSpecialPermissionsLicense.objects.all().delete() + + def test_returns_tuple_of_data_and_special_permissions(self): + """validate_published_data should return (data_dict, special_permissions_list).""" + result = validate_published_data({}, self.channel) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 2) + self.assertIsInstance(result[0], dict) + self.assertIsInstance(result[1], list) + + def test_computes_included_licenses_when_missing(self): + """Should compute included_licenses from published nodes when not in data.""" + cc_license = License.objects.first() + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + ContentNode.objects.create( + title="Licensed Content", + kind=video_kind, + parent=self.channel.main_tree, + license=cc_license, + published=True, + complete=True, + ) + + data, _ = validate_published_data({}, self.channel) + + self.assertIn("included_licenses", data) + self.assertIn(cc_license.id, data["included_licenses"]) + + def test_does_not_overwrite_existing_included_licenses(self): + """Should preserve existing included_licenses in data.""" + existing_licenses = [99, 100] + input_data = {"included_licenses": existing_licenses} + + data, _ = validate_published_data(input_data, self.channel) + + self.assertEqual(data["included_licenses"], existing_licenses) + + def test_computes_included_languages_from_nodes(self): + """Should compute included_languages from node languages.""" + lang = Language.objects.first() + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + ContentNode.objects.create( + title="Content with language", + kind=video_kind, + parent=self.channel.main_tree, + language=lang, + published=True, + complete=True, + ) + + data, _ = validate_published_data({}, self.channel) + + self.assertIn("included_languages", data) + self.assertIn(lang.id, data["included_languages"]) + + def test_computes_included_languages_from_files(self): + """Should compute included_languages from file languages.""" + lang = Language.objects.first() + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + node = ContentNode.objects.create( + title="Content with file language", + kind=video_kind, + parent=self.channel.main_tree, + published=True, + complete=True, + ) + + # Create a file with a language + File.objects.create( + contentnode=node, + language=lang, + ) + + data, _ = validate_published_data({}, self.channel) + + self.assertIn("included_languages", data) + self.assertIn(lang.id, data["included_languages"]) + + def test_does_not_overwrite_existing_included_languages(self): + """Should preserve existing included_languages in data.""" + existing_languages = ["en", "es"] + input_data = {"included_languages": existing_languages} + + data, _ = validate_published_data(input_data, self.channel) + + self.assertEqual(data["included_languages"], existing_languages) + + def test_computes_included_categories_when_missing(self): + """Should compute included_categories from published nodes.""" + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + ContentNode.objects.create( + title="Categorized Content", + kind=video_kind, + parent=self.channel.main_tree, + categories={"math": True, "science": True}, + published=True, + complete=True, + ) + + data, _ = validate_published_data({}, self.channel) + + self.assertIn("included_categories", data) + self.assertIn("math", data["included_categories"]) + self.assertIn("science", data["included_categories"]) + + def test_does_not_overwrite_existing_included_categories(self): + """Should preserve existing included_categories in data.""" + existing_categories = ["history", "art"] + input_data = {"included_categories": existing_categories} + + data, _ = validate_published_data(input_data, self.channel) + + self.assertEqual(data["included_categories"], existing_categories) + + def test_computes_kind_counts_when_missing(self): + """Should compute kind_counts from published nodes.""" + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + # Create two video nodes + for i in range(2): + ContentNode.objects.create( + title=f"Video {i}", + kind=video_kind, + parent=self.channel.main_tree, + published=True, + complete=True, + ) + + data, _ = validate_published_data({}, self.channel) + + self.assertIn("kind_counts", data) + # Find the video kind count + video_count = next( + (kc for kc in data["kind_counts"] if kc["kind_id"] == content_kinds.VIDEO), + None, + ) + self.assertIsNotNone(video_count) + self.assertEqual(video_count["count"], 2) + + def test_does_not_overwrite_existing_kind_counts(self): + """Should preserve existing kind_counts in data.""" + existing_counts = [{"kind_id": "video", "count": 99}] + input_data = {"kind_counts": existing_counts} + + data, _ = validate_published_data(input_data, self.channel) + + self.assertEqual(data["kind_counts"], existing_counts) + + def test_computes_non_distributable_licenses_when_arr_present(self): + """Should set non_distributable_licenses when All Rights Reserved is included.""" + arr_license = License.objects.get(license_name=licenses.ALL_RIGHTS_RESERVED) + video_kind = ContentKind.objects.get(kind=content_kinds.VIDEO) + + ContentNode.objects.create( + title="All Rights Reserved Content", + kind=video_kind, + parent=self.channel.main_tree, + license=arr_license, + published=True, + complete=True, + ) + + data, _ = validate_published_data({}, self.channel) + + self.assertIn("non_distributable_licenses", data) + self.assertIn(arr_license.id, data["non_distributable_licenses"]) + + def test_handles_none_data_input(self): + """Should handle None as input data by creating empty dict.""" + data, special_permissions = validate_published_data(None, self.channel) + + self.assertIsInstance(data, dict) + self.assertIsInstance(special_permissions, list) + + def test_computes_special_permissions_for_special_license(self): + """Should create AuditedSpecialPermissionsLicense for special permissions content.""" + special_description = "Test special permissions" + _create_special_permissions_node(self.channel.main_tree, special_description) + + _, special_permissions = validate_published_data({}, self.channel) + + self.assertEqual(len(special_permissions), 1) + self.assertEqual(special_permissions[0].description, special_description) + + def test_skips_special_permissions_when_already_in_data(self): + """Should return empty list when special_permissions_included already exists.""" + _create_special_permissions_node(self.channel.main_tree, "Some description") + input_data = {"special_permissions_included": ["existing"]} + + _, special_permissions = validate_published_data(input_data, self.channel) + + self.assertEqual(special_permissions, []) + + class TestCreateChannelVersions(StudioTestCase): """Tests for the create_channel_versions management command.""" @@ -303,66 +513,3 @@ def test_multiple_special_permissions_same_channel(self): audited_license.distributable, f"distributable should be True for public channel, description: {desc}", ) - - def test_command_skips_channels_with_existing_version_info(self): - """ - The command should only process channels that don't already have version_info set. - This tests idempotency behavior. - """ - channel = testdata.channel() - _bump(channel) - ChannelVersion.objects.all().delete() - - # Run command first time - call_command("create_channel_versions") - first_count = ChannelVersion.objects.filter(channel=channel).count() - - self.assertEqual( - first_count, - 1, - "First run should create 1 ChannelVersion", - ) - - # Verify version_info is set - channel.refresh_from_db() - self.assertIsNotNone(channel.version_info) - - def test_channel_with_existing_special_permissions_in_published_data(self): - """ - If published_data already contains special_permissions_included, - the command should not recompute it. - """ - channel = testdata.channel() - channel.public = True - channel.save() - - # Create a node with special permissions license - special_description = "Pre-existing special permissions" - _create_special_permissions_node(channel.main_tree, special_description) - - # Pre-create an AuditedSpecialPermissionsLicense - existing_license = AuditedSpecialPermissionsLicense.objects.create( - description="Already audited license", - distributable=False, # Set to False to distinguish from computed ones - ) - - channel.version = 1 - channel.published_data = { - "1": { - "version": 1, - # Already has special_permissions_included - "special_permissions_included": [str(existing_license.id)], - } - } - channel.save() - - ChannelVersion.objects.all().delete() - - call_command("create_channel_versions") - - # The existing license should remain unchanged (distributable=False) - existing_license.refresh_from_db() - self.assertFalse( - existing_license.distributable, - "Pre-existing AuditedSpecialPermissionsLicense should not be modified", - ) From c9ef165d724f7081ef85caf752f856b89f02b708 Mon Sep 17 00:00:00 2001 From: Jacob Pierce Date: Wed, 28 Jan 2026 14:37:44 -0800 Subject: [PATCH 9/9] use ChannelVersion.objects.update_or_create in create_channel_versions --- .../commands/create_channel_versions.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/contentcuration/contentcuration/management/commands/create_channel_versions.py b/contentcuration/contentcuration/management/commands/create_channel_versions.py index 77fa2cf940..ce561b6ea2 100644 --- a/contentcuration/contentcuration/management/commands/create_channel_versions.py +++ b/contentcuration/contentcuration/management/commands/create_channel_versions.py @@ -156,19 +156,21 @@ def handle(self, *args, **options): pub_data, channel ) - # Create a new channel version - channel_version = ChannelVersion.objects.create( + # Create or update channel version + channel_version, _ = ChannelVersion.objects.update_or_create( channel=channel, version=valid_data.get("version"), - included_categories=valid_data.get("included_categories"), - included_licenses=valid_data.get("included_licenses"), - included_languages=valid_data.get("included_languages"), - non_distributable_licenses_included=valid_data.get( - "non_distributable_licenses_included" - ), - kind_count=valid_data.get("kind_count"), - size=int(channel.published_size), - resource_count=channel.total_resource_count, + defaults={ + "included_categories": valid_data.get("included_categories"), + "included_licenses": valid_data.get("included_licenses"), + "included_languages": valid_data.get("included_languages"), + "non_distributable_licenses_included": valid_data.get( + "non_distributable_licenses_included" + ), + "kind_count": valid_data.get("kind_count"), + "size": int(channel.published_size), + "resource_count": channel.total_resource_count, + }, ) if channel.version == pub_data.get("version"):