From e4a589d1d1a70f9b1ffd35b86de6b328ac526aa3 Mon Sep 17 00:00:00 2001 From: taoerman Date: Wed, 3 Dec 2025 10:53:21 -0800 Subject: [PATCH 01/22] Create ChannelVersion model with token support --- .../composables/usePublishedData.js | 2 +- .../frontend/shared/data/resources.js | 14 +- .../migrations/0160_auto_20251203_0300.py | 44 +++++++ contentcuration/contentcuration/models.py | 120 ++++++++++++++++++ .../tests/test_channel_version.py | 57 +++++++++ .../tests/utils/test_publish.py | 96 +++++++++++++- .../tests/viewsets/test_channel.py | 79 +++++++++++- .../test_community_library_submission.py | 82 +++++++++++- .../contentcuration/utils/publish.py | 105 ++++++++++++++- .../contentcuration/viewsets/channel.py | 36 +++++- 10 files changed, 605 insertions(+), 30 deletions(-) create mode 100644 contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py create mode 100644 contentcuration/contentcuration/tests/test_channel_version.py diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js index 3b35b3b7dc..9f35e56fa6 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/usePublishedData.js @@ -2,5 +2,5 @@ import { useFetch } from '../../../../composables/useFetch'; import { Channel } from 'shared/data/resources'; export function usePublishedData(channelId) { - return useFetch({ asyncFetchFunc: () => Channel.getPublishedData(channelId) }); + return useFetch({ asyncFetchFunc: () => Channel.getVersionDetail(channelId) }); } diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index f04944080b..8d5cd1f4c8 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -116,7 +116,7 @@ export function formatUUID4(uuid) { function mix(...mixins) { // Inherit from the last class to allow constructor inheritance - class Mix extends mixins.slice(-1)[0] {} + class Mix extends mixins.slice(-1)[0] { } // Programmatically add all the methods and accessors // of the mixins to class Mix. @@ -581,10 +581,10 @@ class IndexedDBResource { results: results.slice(0, maxResults), more: hasMore ? { - ...params, - // Dynamically set the pagination cursor based on the pagination field and operator. - [`${paginationField}__${operator}`]: results[maxResults - 1][paginationField], - } + ...params, + // Dynamically set the pagination cursor based on the pagination field and operator. + [`${paginationField}__${operator}`]: results[maxResults - 1][paginationField], + } : null, }; } @@ -1412,8 +1412,8 @@ export const Channel = new CreateModelResource({ .then(response => response.data.languages); return uniq(compact(localLanguages.concat(remoteLanguages))); }, - async getPublishedData(id) { - const response = await client.get(window.Urls.channel_published_data(id)); + async getVersionDetail(id) { + const response = await client.get(window.Urls.channel_version_detail(id)); return response.data; }, }); diff --git a/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py b/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py new file mode 100644 index 0000000000..6e8c49d066 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.24 on 2025-12-03 03:00 + +import contentcuration.models +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0159_update_community_library_submission_date_updated'), + ] + + operations = [ + migrations.CreateModel( + name='ChannelVersion', + fields=[ + ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), + ('version', models.PositiveIntegerField(blank=True, null=True)), + ('version_notes', models.TextField(blank=True, null=True)), + ('size', models.PositiveIntegerField(blank=True, null=True)), + ('date_published', models.DateTimeField(blank=True, null=True)), + ('resource_count', models.PositiveIntegerField(blank=True, null=True)), + ('kind_count', django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(), blank=True, null=True, size=None, validators=[contentcuration.models.validate_kind_count_item])), + ('included_licenses', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'CC BY'), (2, 'CC BY-SA'), (3, 'CC BY-ND'), (4, 'CC BY-NC'), (5, 'CC BY-NC-SA'), (6, 'CC BY-NC-ND'), (7, 'All Rights Reserved'), (8, 'Public Domain'), (9, 'Special Permissions')]), blank=True, null=True, size=None)), + ('included_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('d&WXdXWF.qs0Xlaxq.0t5msbL5', 'd&WXdXWF.qs0Xlaxq.0t5msbL5'), ('d&WXdXWF.K80UMYnW.ViBlbQR&', 'd&WXdXWF.K80UMYnW.ViBlbQR&'), ('d&WXdXWF.qs0Xlaxq.nG96nHDc', 'd&WXdXWF.qs0Xlaxq.nG96nHDc'), ('d&WXdXWF.5QAjgfv7', 'd&WXdXWF.5QAjgfv7'), ('d&WXdXWF.i1IdaNwr.mjSF4QlF', 'd&WXdXWF.i1IdaNwr.mjSF4QlF'), ('d&WXdXWF.i1IdaNwr.uErN4PdS', 'd&WXdXWF.i1IdaNwr.uErN4PdS'), ('d&WXdXWF.qs0Xlaxq.8rJ57ht6', 'd&WXdXWF.qs0Xlaxq.8rJ57ht6'), ('d&WXdXWF.i1IdaNwr.#r5ocgid', 'd&WXdXWF.i1IdaNwr.#r5ocgid'), ('d&WXdXWF.K80UMYnW.F863vKiF', 'd&WXdXWF.K80UMYnW.F863vKiF'), ('d&WXdXWF.e#RTW9E#', 'd&WXdXWF.e#RTW9E#'), ('PbGoe2MV.J7CU1IxN', 'PbGoe2MV.J7CU1IxN'), ('PbGoe2MV', 'PbGoe2MV'), ('d&WXdXWF.5QAjgfv7.BUMJJBnS', 'd&WXdXWF.5QAjgfv7.BUMJJBnS'), ('BCG3&slG.wZ3EAedB', 'BCG3&slG.wZ3EAedB'), ('PbGoe2MV.EHcbjuKq', 'PbGoe2MV.EHcbjuKq'), ('d&WXdXWF.5QAjgfv7.XsWznP4o', 'd&WXdXWF.5QAjgfv7.XsWznP4o'), ('d&WXdXWF.i1IdaNwr.zbDzxDE7', 'd&WXdXWF.i1IdaNwr.zbDzxDE7'), ('PbGoe2MV.kyxTNsRS', 'PbGoe2MV.kyxTNsRS'), ('PbGoe2MV.tS7WKnZ7', 'PbGoe2MV.tS7WKnZ7'), ('PbGoe2MV.HGIc9sZq', 'PbGoe2MV.HGIc9sZq'), ('ziJ6PCuU', 'ziJ6PCuU'), ('BCG3&slG', 'BCG3&slG'), ('BCG3&slG.0&d0qTqS', 'BCG3&slG.0&d0qTqS'), ('d&WXdXWF.qs0Xlaxq.lb7ELcK5', 'd&WXdXWF.qs0Xlaxq.lb7ELcK5'), ('ziJ6PCuU.RLfhp37t', 'ziJ6PCuU.RLfhp37t'), ('d&WXdXWF.zWtcJ&F2', 'd&WXdXWF.zWtcJ&F2'), ('l7DsPDlm.ISEXeZt&.pRvOzJTE', 'l7DsPDlm.ISEXeZt&.pRvOzJTE'), ('d&WXdXWF.JDUfJNXc', 'd&WXdXWF.JDUfJNXc'), ('BCG3&slG.fP2j70bj', 'BCG3&slG.fP2j70bj'), ('ziJ6PCuU.lOBPr5ix', 'ziJ6PCuU.lOBPr5ix'), ('BCG3&slG.HLo9TbNq', 'BCG3&slG.HLo9TbNq'), ('d&WXdXWF.kHKJ&PbV.DJLBbaEk', 'd&WXdXWF.kHKJ&PbV.DJLBbaEk'), ('d&WXdXWF.kHKJ&PbV.YMBXStib', 'd&WXdXWF.kHKJ&PbV.YMBXStib'), ('d&WXdXWF.qs0Xlaxq', 'd&WXdXWF.qs0Xlaxq'), ('d&WXdXWF.e#RTW9E#.8ZoaPsVW', 'd&WXdXWF.e#RTW9E#.8ZoaPsVW'), ('PbGoe2MV.UOTL#KIV', 'PbGoe2MV.UOTL#KIV'), ('PbGoe2MV.d8&gCo2N', 'PbGoe2MV.d8&gCo2N'), ('d&WXdXWF.5QAjgfv7.u0aKjT4i', 'd&WXdXWF.5QAjgfv7.u0aKjT4i'), ('BCG3&slG.Tsyej9ta', 'BCG3&slG.Tsyej9ta'), ('d&WXdXWF.i1IdaNwr.r#wbt#jF', 'd&WXdXWF.i1IdaNwr.r#wbt#jF'), ('d&WXdXWF.K80UMYnW.K72&pITr', 'd&WXdXWF.K80UMYnW.K72&pITr'), ('l7DsPDlm.#N2VymZo', 'l7DsPDlm.#N2VymZo'), ('d&WXdXWF.e#RTW9E#.CfnlTDZ#', 'd&WXdXWF.e#RTW9E#.CfnlTDZ#'), ('PbGoe2MV.kivAZaeX', 'PbGoe2MV.kivAZaeX'), ('d&WXdXWF.kHKJ&PbV', 'd&WXdXWF.kHKJ&PbV'), ('d&WXdXWF.kHKJ&PbV.r7RxB#9t', 'd&WXdXWF.kHKJ&PbV.r7RxB#9t'), ('d&WXdXWF', 'd&WXdXWF'), ('d&WXdXWF.i1IdaNwr', 'd&WXdXWF.i1IdaNwr'), ('l7DsPDlm.ISEXeZt&.&1WpYE&n', 'l7DsPDlm.ISEXeZt&.&1WpYE&n'), ('d&WXdXWF.K80UMYnW', 'd&WXdXWF.K80UMYnW'), ('d&WXdXWF.K80UMYnW.75WBu1ZS', 'd&WXdXWF.K80UMYnW.75WBu1ZS'), ('d&WXdXWF.qs0Xlaxq.jNm15RLB', 'd&WXdXWF.qs0Xlaxq.jNm15RLB'), ('l7DsPDlm.ISEXeZt&', 'l7DsPDlm.ISEXeZt&'), ('l7DsPDlm.ISEXeZt&.1JfIbP&N', 'l7DsPDlm.ISEXeZt&.1JfIbP&N'), ('d&WXdXWF.5QAjgfv7.4LskOFXj', 'd&WXdXWF.5QAjgfv7.4LskOFXj'), ('d&WXdXWF.e#RTW9E#.P7s8FxQ8', 'd&WXdXWF.e#RTW9E#.P7s8FxQ8'), ('l7DsPDlm', 'l7DsPDlm'), ('d&WXdXWF.kHKJ&PbV.KFJOCr&6', 'd&WXdXWF.kHKJ&PbV.KFJOCr&6')], max_length=100), blank=True, null=True, size=None)), + ('included_languages', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None, validators=[contentcuration.models.validate_language_code])), + ('non_distributable_licenses_included', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'CC BY'), (2, 'CC BY-SA'), (3, 'CC BY-ND'), (4, 'CC BY-NC'), (5, 'CC BY-NC-SA'), (6, 'CC BY-NC-ND'), (7, 'All Rights Reserved'), (8, 'Public Domain'), (9, 'Special Permissions')]), blank=True, null=True, size=None)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='channel_versions', to='contentcuration.channel')), + ('secret_token', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contentcuration.secrettoken')), + ('special_permissions_included', models.ManyToManyField(blank=True, related_name='channel_versions', to='contentcuration.AuditedSpecialPermissionsLicense')), + ], + options={ + 'unique_together': {('channel', 'version')}, + }, + ), + migrations.AddField( + model_name='channel', + name='version_info', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channel_version_info', to='contentcuration.channelversion'), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 32727f0159..5c49139476 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -54,6 +54,8 @@ from le_utils.constants import file_formats from le_utils.constants import format_presets from le_utils.constants import languages +from le_utils.constants import licenses +from le_utils.constants.labels import subjects from le_utils.constants import roles from model_utils import FieldTracker from mptt.models import MPTTModel @@ -71,6 +73,7 @@ from contentcuration.constants import feedback from contentcuration.constants import user_history from contentcuration.constants.contentnode import kind_activity_map +from django.contrib.postgres.fields import ArrayField from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove from contentcuration.db.models.functions import Unnest @@ -975,6 +978,13 @@ class Channel(models.Model): verbose_name="languages", blank=True, ) + version_info = models.OneToOneField( + "ChannelVersion", + related_name="channel_version_info", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) _field_updates = FieldTracker( fields=[ @@ -1192,6 +1202,11 @@ def on_update(self): # noqa C901 ): delete_public_channel_cache_keys() + if self.version and (not self.version_info or self.version_info.version != self.version): + self.version_info, _ = ChannelVersion.objects.get_or_create( + channel=self, version=self.version + ) + def save(self, *args, **kwargs): self._actor_id = kwargs.pop("actor_id", None) creating = self._state.adding @@ -1347,6 +1362,105 @@ class Meta: index_together = [["deleted", "public"]] + + +def validate_kind_count_item(value): + + if not isinstance(value, dict): + raise ValidationError("Each kind_count item must be a dictionary") + + if "count" not in value or "kind" not in value: + raise ValidationError("Each kind_count item must have 'count' and 'kind' keys") + + if not isinstance(value["count"], int) or value["count"] < 0: + raise ValidationError("'count' must be a non-negative integer") + + if not isinstance(value["kind"], str) or not value["kind"]: + raise ValidationError("'kind' must be a non-empty string") + + +def validate_language_code(value): + """ + Validator for language codes in included_languages array. + """ + valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST] + if value not in valid_language_codes: + raise ValidationError(f"'{value}' is not a valid language code") + + +class ChannelVersion(models.Model): + """ + Stores version-specific information for a channel. This allows retrieving + specific channel versions using secret tokens. + """ + id = UUIDField(primary_key=True, default=uuid.uuid4) + channel = models.ForeignKey( + Channel, + on_delete=models.CASCADE, + related_name="channel_versions" + ) + version = models.PositiveIntegerField(null=True, blank=True) + secret_token = models.ForeignKey( + SecretToken, + on_delete=models.SET_NULL, + null=True, + blank=True + ) + version_notes = models.TextField(null=True, blank=True) + size = models.PositiveIntegerField(null=True, blank=True) + date_published = models.DateTimeField(null=True, blank=True) + resource_count = models.PositiveIntegerField(null=True, blank=True) + kind_count = ArrayField( + JSONField(), + validators=[validate_kind_count_item], + null=True, + blank=True + ) + included_licenses = ArrayField( + models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]), + null=True, + blank=True + ) + included_categories = ArrayField( + models.CharField(max_length=100, choices=[(subj, subj) for subj in subjects.SUBJECTSLIST]), + null=True, + blank=True + ) + included_languages = ArrayField( + models.CharField(max_length=100), + validators=[validate_language_code], + null=True, + blank=True + ) + non_distributable_licenses_included = ArrayField( + models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]), + null=True, + blank=True + ) + special_permissions_included = models.ManyToManyField( + "AuditedSpecialPermissionsLicense", + related_name="channel_versions", + blank=True, + ) + + class Meta: + unique_together = ("channel", "version") + + def save(self, *args, **kwargs): + if self.version is not None and self.version > self.channel.version: + raise ValidationError("Version cannot be greater than channel version") + super(ChannelVersion, self).save(*args, **kwargs) + + def new_token(self): + if not self.secret_token: + self.secret_token = SecretToken.objects.create( + token=SecretToken.generate_new_token(), + is_primary=False + ) + self.save() + return self.secret_token + + CHANNEL_HISTORY_CHANNEL_INDEX_NAME = "idx_channel_history_channel_id" @@ -2658,6 +2772,12 @@ def save(self, *args, **kwargs): channel_id=self.channel.id, channel_version=self.channel.version, ) + # Create a ChannelVersion and token for this submission + channel_version, _ = ChannelVersion.objects.get_or_create( + channel=self.channel, + version=self.channel_version + ) + channel_version.new_token() super().save(*args, **kwargs) diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py new file mode 100644 index 0000000000..f9baa34e31 --- /dev/null +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -0,0 +1,57 @@ +from contentcuration.models import Channel, ChannelVersion, SecretToken +from contentcuration.tests.base import StudioTestCase +from contentcuration.tests import testdata + +class ChannelVersionTestCase(StudioTestCase): + def setUp(self): + super(ChannelVersionTestCase, self).setUp() + self.channel = testdata.channel() + self.channel.version = 10 + self.channel.save() + self.user = testdata.user() + self.channel.editors.add(self.user) + + def test_create_channel_version(self): + """Test creating a ChannelVersion.""" + cv = ChannelVersion.objects.create( + channel=self.channel, + version=1, + ) + self.assertEqual(cv.channel, self.channel) + self.assertEqual(cv.version, 1) + self.assertIsNone(cv.secret_token) + + def test_new_token_creates_token(self): + """Test new_token creates a token.""" + cv = ChannelVersion.objects.create( + channel=self.channel, + version=1, + ) + token = cv.new_token() + self.assertIsInstance(token, SecretToken) + self.assertFalse(token.is_primary) + self.assertEqual(cv.secret_token, token) + + def test_new_token_is_idempotent(self): + """Test new_token returns existing token if present.""" + cv = ChannelVersion.objects.create( + channel=self.channel, + version=1, + ) + token1 = cv.new_token() + token2 = cv.new_token() + self.assertEqual(token1, token2) + self.assertEqual(cv.secret_token, token1) + + def test_unique_constraint(self): + """Test unique constraint on channel and version.""" + ChannelVersion.objects.create( + channel=self.channel, + version=1, + ) + from django.db.utils import IntegrityError + with self.assertRaises(IntegrityError): + ChannelVersion.objects.create( + channel=self.channel, + version=1, + ) diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index 9a2a08811f..b74675513f 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -4,12 +4,16 @@ from django.conf import settings +from contentcuration.models import ChannelVersion from contentcuration.tests import testdata from contentcuration.tests.base import StudioTestCase from contentcuration.tests.utils.restricted_filesystemstorage import ( RestrictedFileSystemStorage, ) -from contentcuration.utils.publish import ensure_versioned_database_exists +from contentcuration.utils.publish import ( + ensure_versioned_database_exists, + increment_channel_version, +) class EnsureVersionedDatabaseTestCase(StudioTestCase): @@ -51,10 +55,6 @@ def tearDown(self): super().tearDown() def test_versioned_database_exists(self): - # In reality, the versioned database for the current version - # and the unversioned database would have the same content, - # but here we provide different content so that we can test - # that the versioned database is not overwritten. versioned_db_content = "Versioned content" unversioned_db_content = "Unversioned content" @@ -92,3 +92,89 @@ def test_not_published(self): with self.assertRaises(ValueError): ensure_versioned_database_exists(self.channel.id, self.channel.version) + + +class IncrementChannelVersionTestCase(StudioTestCase): + """Test increment_channel_version function with ChannelVersion integration.""" + + def setUp(self): + super().setUp() + self.channel = testdata.channel() + self.channel.version = 1 + self.channel.save() + + ChannelVersion.objects.filter(channel=self.channel).delete() + self.channel.version_info = None + self.channel.save() + + def test_increment_published_version(self): + """Test incrementing version for published channel.""" + initial_version = self.channel.version + + channel_version = increment_channel_version(self.channel, is_draft_version=False) + + self.channel.refresh_from_db() + + self.assertEqual(self.channel.version, initial_version + 1) + + self.assertIsNotNone(channel_version) + self.assertEqual(channel_version.version, self.channel.version) + self.assertEqual(channel_version.channel, self.channel) + + self.assertEqual(self.channel.version_info, channel_version) + + self.assertIsNone(channel_version.secret_token) + + def test_increment_draft_version(self): + """Test incrementing version for draft channel.""" + initial_version = self.channel.version + + channel_version = increment_channel_version(self.channel, is_draft_version=True) + + self.channel.refresh_from_db() + + self.assertEqual(self.channel.version, initial_version) + + self.assertIsNotNone(channel_version) + self.assertIsNone(channel_version.version) + self.assertEqual(channel_version.channel, self.channel) + + if self.channel.version_info: + self.assertNotEqual(self.channel.version_info, channel_version) + + self.assertIsNotNone(channel_version.secret_token) + self.assertFalse(channel_version.secret_token.is_primary) + + def test_multiple_published_versions(self): + """Test creating multiple published versions.""" + v1 = increment_channel_version(self.channel, is_draft_version=False) + v2 = increment_channel_version(self.channel, is_draft_version=False) + v3 = increment_channel_version(self.channel, is_draft_version=False) + + self.channel.refresh_from_db() + + self.assertEqual(self.channel.version, 4) + + self.assertEqual(self.channel.channel_versions.count(), 4) + + self.assertEqual(self.channel.version_info, v3) + self.assertEqual(self.channel.version_info.version, 4) + + def test_mixed_draft_and_published_versions(self): + """Test creating mix of draft and published versions.""" + published = increment_channel_version(self.channel, is_draft_version=False) + draft1 = increment_channel_version(self.channel, is_draft_version=True) + draft2 = increment_channel_version(self.channel, is_draft_version=True) + + self.channel.refresh_from_db() + + self.assertEqual(self.channel.version, 2) + + self.assertEqual(self.channel.channel_versions.count(), 4) + + self.assertIsNotNone(draft1.secret_token) + self.assertIsNotNone(draft2.secret_token) + + self.assertIsNone(published.secret_token) + + self.assertEqual(self.channel.version_info, published) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 3bf627f6a2..d1666b28e4 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -17,6 +17,8 @@ from contentcuration.models import CommunityLibrarySubmission from contentcuration.models import ContentNode from contentcuration.models import Country +from contentcuration.models import ChannelVersion +from contentcuration.models import Channel from contentcuration.tasks import apply_channel_changes_task from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase @@ -1266,27 +1268,29 @@ def test_get_published_data__is_editor(self): self.client.force_authenticate(user=self.editor_user) response = self.client.get( - reverse("channel-published-data", kwargs={"pk": self.channel.id}), + reverse("channel-version-detail", kwargs={"pk": self.channel.id}), format="json", ) self.assertEqual(response.status_code, 200, response.content) - self.assertEqual(response.json(), self.channel.published_data) + # version-detail returns empty dict when no version_info exists + self.assertEqual(response.json(), {}) def test_get_published_data__is_admin(self): self.client.force_authenticate(user=self.admin_user) response = self.client.get( - reverse("channel-published-data", kwargs={"pk": self.channel.id}), + reverse("channel-version-detail", kwargs={"pk": self.channel.id}), format="json", ) self.assertEqual(response.status_code, 200, response.content) - self.assertEqual(response.json(), self.channel.published_data) + # version-detail returns empty dict when no version_info exists + self.assertEqual(response.json(), {}) def test_get_published_data__is_forbidden_user(self): self.client.force_authenticate(user=self.forbidden_user) response = self.client.get( - reverse("channel-published-data", kwargs={"pk": self.channel.id}), + reverse("channel-version-detail", kwargs={"pk": self.channel.id}), format="json", ) self.assertEqual(response.status_code, 404, response.content) @@ -1405,3 +1409,68 @@ def test_audit_licenses__channel_not_published(self): else response_data[0] ) self.assertIn("must be published", str(error_message)) + + +class GetVersionDetailEndpointTestCase(StudioAPITestCase): + """Test get_version_detail API endpoint.""" + + def setUp(self): + super(GetVersionDetailEndpointTestCase, self).setUp() + self.user = testdata.user() + self.client.force_authenticate(user=self.user) + + self.channel = testdata.channel() + self.channel.version = 3 + self.channel.published = True + self.channel.editors.add(self.user) + self.channel.save() + + self.channel_version, _ = ChannelVersion.objects.update_or_create( + channel=self.channel, + version=3, + defaults={ + "version_notes": "Test version", + "size": 5000, + "resource_count": 25, + "kind_count": [ + {"count": 10, "kind": "video"}, + {"count": 15, "kind": "document"}, + ], + "included_languages": ["en", "es"], + "included_licenses": [1, 2], + "included_categories": ["math", "science"], + } + ) + + self.channel.version_info = self.channel_version + self.channel.save() + + def test_get_version_detail_success(self): + """Test successfully retrieving version detail.""" + url = reverse('channel-version-detail', kwargs={'pk': self.channel.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertEqual(data['version'], 3) + self.assertEqual(data['version_notes'], "Test version") + self.assertEqual(data['size'], 5000) + self.assertEqual(data['resource_count'], 25) + + def test_get_version_detail_no_version_info(self): + """Test endpoint when channel has no version_info.""" + channel2 = testdata.channel() + channel2.version = 1 + channel2.editors.add(self.user) + channel2.save() + + ChannelVersion.objects.filter(channel=channel2).delete() + + Channel.objects.filter(pk=channel2.pk).update(version_info=None) + + url = reverse('channel-version-detail', kwargs={'pk': channel2.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {}) diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index d8a07e94e6..cf91f16da2 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -8,11 +8,11 @@ community_library_submission as community_library_submission_constants, ) from contentcuration.models import Change -from contentcuration.models import CommunityLibrarySubmission from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.helpers import reverse_with_query from contentcuration.viewsets.sync.constants import ADDED_TO_COMMUNITY_LIBRARY +from contentcuration.models import Channel, ChannelVersion, CommunityLibrarySubmission, User class CRUDTestCase(StudioAPITestCase): @@ -1224,3 +1224,83 @@ def test_admin_can_use_filters(self): results = response.data self.assertEqual(len(results), 1) self.assertEqual(results[0]["id"], self.recent_approved_submission.id) + + +class CommunityLibrarySubmissionChannelVersionTestCase(StudioAPITestCase): + """Test CommunityLibrarySubmission creates ChannelVersion and tokens.""" + + def setUp(self): + self.user = User.objects.create(email="test@test.com", is_admin=True) + self.channel = Channel.objects.create( + name="Test Channel", + version=10, + actor_id=self.user.id, + ) + self.channel.editors.add(self.user) + + def test_submission_creates_channel_version(self): + """Test that creating a submission creates a ChannelVersion.""" + initial_count = ChannelVersion.objects.filter(channel=self.channel).count() + + submission = CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=5, + author=self.user, + description="Test submission", + ) + + self.assertEqual( + ChannelVersion.objects.filter(channel=self.channel).count(), + initial_count + 1 + ) + + channel_version = ChannelVersion.objects.get( + channel=self.channel, + version=5 + ) + self.assertIsNotNone(channel_version) + + def test_submission_creates_token(self): + """Test that creating a submission creates a token for the ChannelVersion.""" + submission = CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=5, + author=self.user, + description="Test submission", + ) + + channel_version = ChannelVersion.objects.get( + channel=self.channel, + version=5 + ) + + self.assertIsNotNone(channel_version.secret_token) + self.assertFalse(channel_version.secret_token.is_primary) + + + + def test_submissions_different_versions(self): + """Test that submissions for different versions create different tokens.""" + self.channel.version = 6 + self.channel.save() + + submission1 = CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=5, + author=self.user, + description="Version 5 submission", + ) + + submission2 = CommunityLibrarySubmission.objects.create( + channel=self.channel, + channel_version=6, + author=self.user, + description="Version 6 submission", + ) + + v5 = ChannelVersion.objects.get(channel=self.channel, version=5) + v6 = ChannelVersion.objects.get(channel=self.channel, version=6) + + self.assertIsNotNone(v5.secret_token) + self.assertIsNotNone(v6.secret_token) + self.assertNotEqual(v5.secret_token, v6.secret_token) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index fda40cadd6..50fde61b1f 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -210,9 +210,31 @@ def create_kolibri_license_object(ccnode): ) -def increment_channel_version(channel): - channel.version += 1 - channel.save() +def increment_channel_version(channel, is_draft_version=False): + + if not is_draft_version: + channel.version += 1 + channel.save() + + if is_draft_version: + channel_version = ccmodels.ChannelVersion.objects.create( + channel=channel, + version=None + ) + else: + channel_version, created = ccmodels.ChannelVersion.objects.get_or_create( + channel=channel, + version=channel.version + ) + + if not is_draft_version: + channel.version_info = channel_version + channel.save() + + if is_draft_version: + channel_version.new_token() + + return channel_version def assign_license_to_contentcuration_nodes(channel, license): @@ -945,6 +967,81 @@ def fill_published_fields(channel, version_notes): } } ) + + # Calculate non-distributable licenses (All Rights Reserved) + all_rights_reserved_id = ccmodels.License.objects.filter( + license_name=licenses.ALL_RIGHTS_RESERVED + ).values_list('id', flat=True).first() + + non_distributable_licenses = ( + [all_rights_reserved_id] if all_rights_reserved_id and all_rights_reserved_id in license_list else [] + ) + + # 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 = ccmodels.License.objects.filter( + license_name=licenses.SPECIAL_PERMISSIONS + ).values_list('id', flat=True).first() + + special_permissions_ids = [] + if special_permissions_id and special_permissions_id in license_list: + 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: + existing_licenses = ccmodels.AuditedSpecialPermissionsLicense.objects.filter( + description__in=special_perms_descriptions + ) + existing_descriptions = set( + existing_licenses.values_list("description", flat=True) + ) + + new_licenses = [ + ccmodels.AuditedSpecialPermissionsLicense( + description=description, distributable=False + ) + for description in special_perms_descriptions + if description not in existing_descriptions + ] + + if new_licenses: + ccmodels.AuditedSpecialPermissionsLicense.objects.bulk_create( + new_licenses, ignore_conflicts=True + ) + + special_permissions_ids = list( + ccmodels.AuditedSpecialPermissionsLicense.objects.filter( + description__in=special_perms_descriptions + ).values_list("id", flat=True) + ) + + if channel.version_info: + channel.version_info.resource_count = channel.total_resource_count + channel.version_info.kind_count = kind_counts + channel.version_info.size = int(channel.published_size) + channel.version_info.date_published = channel.last_published + channel.version_info.version_notes = version_notes + channel.version_info.included_languages = language_list + channel.version_info.included_licenses = license_list + channel.version_info.included_categories = category_list + channel.version_info.non_distributable_licenses_included = non_distributable_licenses + channel.version_info.save() + + if special_permissions_ids: + channel.version_info.special_permissions_included.set( + ccmodels.AuditedSpecialPermissionsLicense.objects.filter( + id__in=special_permissions_ids + ) + ) + else: + channel.version_info.special_permissions_included.clear() + + channel.save() @@ -1047,8 +1144,8 @@ def publish_channel( # noqa: C901 use_staging_tree=use_staging_tree, ) add_tokens_to_channel(channel) + increment_channel_version(channel, is_draft_version=is_draft_version) if not is_draft_version: - increment_channel_version(channel) sync_contentnode_and_channel_tsvectors(channel_id=channel.id) mark_all_nodes_as_published(base_tree) fill_published_fields(channel, version_notes) diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 6bba2ad08d..424cd6b3d3 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -884,23 +884,45 @@ def get_languages_in_channel( @action( detail=True, methods=["get"], - url_path="published_data", - url_name="published-data", + url_path="version_detail", + url_name="version-detail", ) - def get_published_data(self, request, pk=None) -> Response: + def get_version_detail(self, request, pk=None) -> Response: """ - Get the published data for a channel. + Get the version detail for a channel. :param request: The request object :param pk: The ID of the channel - :return: Response with the published data of the channel + :return: Response with the version detail of the channel :rtype: Response """ # Allow exactly users with permission to edit the channel to - # access the published data. + # access the version detail. channel = self.get_edit_object() - return Response(channel.published_data) + if not channel.version_info: + return Response({}) + + version_data = { + "id": str(channel.version_info.id), + "version": channel.version_info.version, + "resource_count": channel.version_info.resource_count, + "kind_count": channel.version_info.kind_count, + "size": channel.version_info.size, + "date_published": channel.version_info.date_published.strftime( + settings.DATE_TIME_FORMAT + ) if channel.version_info.date_published else None, + "version_notes": channel.version_info.version_notes, + "included_languages": channel.version_info.included_languages, + "included_licenses": channel.version_info.included_licenses, + "included_categories": channel.version_info.included_categories, + "non_distributable_licenses_included": channel.version_info.non_distributable_licenses_included, + "special_permissions_included": list( + channel.version_info.special_permissions_included.values_list('id', flat=True) + ), + } + + return Response(version_data) @action( detail=True, From 66f50b74d00019145f4e79b5b26423e83aa07350 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:59:00 +0000 Subject: [PATCH 02/22] [pre-commit.ci lite] apply automatic fixes --- .../frontend/shared/data/resources.js | 10 +- .../migrations/0160_auto_20251203_0300.py | 296 ++++++++++++++++-- contentcuration/contentcuration/models.py | 53 ++-- .../tests/test_channel_version.py | 8 +- .../tests/utils/test_publish.py | 56 ++-- .../tests/viewsets/test_channel.py | 36 +-- .../test_community_library_submission.py | 37 +-- .../contentcuration/utils/publish.py | 67 ++-- .../contentcuration/viewsets/channel.py | 10 +- 9 files changed, 413 insertions(+), 160 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index 8d5cd1f4c8..d6745e3380 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -116,7 +116,7 @@ export function formatUUID4(uuid) { function mix(...mixins) { // Inherit from the last class to allow constructor inheritance - class Mix extends mixins.slice(-1)[0] { } + class Mix extends mixins.slice(-1)[0] {} // Programmatically add all the methods and accessors // of the mixins to class Mix. @@ -581,10 +581,10 @@ class IndexedDBResource { results: results.slice(0, maxResults), more: hasMore ? { - ...params, - // Dynamically set the pagination cursor based on the pagination field and operator. - [`${paginationField}__${operator}`]: results[maxResults - 1][paginationField], - } + ...params, + // Dynamically set the pagination cursor based on the pagination field and operator. + [`${paginationField}__${operator}`]: results[maxResults - 1][paginationField], + } : null, }; } diff --git a/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py b/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py index 6e8c49d066..e387d6f7fd 100644 --- a/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py +++ b/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py @@ -1,44 +1,294 @@ # Generated by Django 3.2.24 on 2025-12-03 03:00 +import uuid -import contentcuration.models import django.contrib.postgres.fields -from django.db import migrations, models import django.db.models.deletion -import uuid +from django.db import migrations +from django.db import models + +import contentcuration.models class Migration(migrations.Migration): dependencies = [ - ('contentcuration', '0159_update_community_library_submission_date_updated'), + ("contentcuration", "0159_update_community_library_submission_date_updated"), ] operations = [ migrations.CreateModel( - name='ChannelVersion', + name="ChannelVersion", fields=[ - ('id', contentcuration.models.UUIDField(default=uuid.uuid4, max_length=32, primary_key=True, serialize=False)), - ('version', models.PositiveIntegerField(blank=True, null=True)), - ('version_notes', models.TextField(blank=True, null=True)), - ('size', models.PositiveIntegerField(blank=True, null=True)), - ('date_published', models.DateTimeField(blank=True, null=True)), - ('resource_count', models.PositiveIntegerField(blank=True, null=True)), - ('kind_count', django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(), blank=True, null=True, size=None, validators=[contentcuration.models.validate_kind_count_item])), - ('included_licenses', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'CC BY'), (2, 'CC BY-SA'), (3, 'CC BY-ND'), (4, 'CC BY-NC'), (5, 'CC BY-NC-SA'), (6, 'CC BY-NC-ND'), (7, 'All Rights Reserved'), (8, 'Public Domain'), (9, 'Special Permissions')]), blank=True, null=True, size=None)), - ('included_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('d&WXdXWF.qs0Xlaxq.0t5msbL5', 'd&WXdXWF.qs0Xlaxq.0t5msbL5'), ('d&WXdXWF.K80UMYnW.ViBlbQR&', 'd&WXdXWF.K80UMYnW.ViBlbQR&'), ('d&WXdXWF.qs0Xlaxq.nG96nHDc', 'd&WXdXWF.qs0Xlaxq.nG96nHDc'), ('d&WXdXWF.5QAjgfv7', 'd&WXdXWF.5QAjgfv7'), ('d&WXdXWF.i1IdaNwr.mjSF4QlF', 'd&WXdXWF.i1IdaNwr.mjSF4QlF'), ('d&WXdXWF.i1IdaNwr.uErN4PdS', 'd&WXdXWF.i1IdaNwr.uErN4PdS'), ('d&WXdXWF.qs0Xlaxq.8rJ57ht6', 'd&WXdXWF.qs0Xlaxq.8rJ57ht6'), ('d&WXdXWF.i1IdaNwr.#r5ocgid', 'd&WXdXWF.i1IdaNwr.#r5ocgid'), ('d&WXdXWF.K80UMYnW.F863vKiF', 'd&WXdXWF.K80UMYnW.F863vKiF'), ('d&WXdXWF.e#RTW9E#', 'd&WXdXWF.e#RTW9E#'), ('PbGoe2MV.J7CU1IxN', 'PbGoe2MV.J7CU1IxN'), ('PbGoe2MV', 'PbGoe2MV'), ('d&WXdXWF.5QAjgfv7.BUMJJBnS', 'd&WXdXWF.5QAjgfv7.BUMJJBnS'), ('BCG3&slG.wZ3EAedB', 'BCG3&slG.wZ3EAedB'), ('PbGoe2MV.EHcbjuKq', 'PbGoe2MV.EHcbjuKq'), ('d&WXdXWF.5QAjgfv7.XsWznP4o', 'd&WXdXWF.5QAjgfv7.XsWznP4o'), ('d&WXdXWF.i1IdaNwr.zbDzxDE7', 'd&WXdXWF.i1IdaNwr.zbDzxDE7'), ('PbGoe2MV.kyxTNsRS', 'PbGoe2MV.kyxTNsRS'), ('PbGoe2MV.tS7WKnZ7', 'PbGoe2MV.tS7WKnZ7'), ('PbGoe2MV.HGIc9sZq', 'PbGoe2MV.HGIc9sZq'), ('ziJ6PCuU', 'ziJ6PCuU'), ('BCG3&slG', 'BCG3&slG'), ('BCG3&slG.0&d0qTqS', 'BCG3&slG.0&d0qTqS'), ('d&WXdXWF.qs0Xlaxq.lb7ELcK5', 'd&WXdXWF.qs0Xlaxq.lb7ELcK5'), ('ziJ6PCuU.RLfhp37t', 'ziJ6PCuU.RLfhp37t'), ('d&WXdXWF.zWtcJ&F2', 'd&WXdXWF.zWtcJ&F2'), ('l7DsPDlm.ISEXeZt&.pRvOzJTE', 'l7DsPDlm.ISEXeZt&.pRvOzJTE'), ('d&WXdXWF.JDUfJNXc', 'd&WXdXWF.JDUfJNXc'), ('BCG3&slG.fP2j70bj', 'BCG3&slG.fP2j70bj'), ('ziJ6PCuU.lOBPr5ix', 'ziJ6PCuU.lOBPr5ix'), ('BCG3&slG.HLo9TbNq', 'BCG3&slG.HLo9TbNq'), ('d&WXdXWF.kHKJ&PbV.DJLBbaEk', 'd&WXdXWF.kHKJ&PbV.DJLBbaEk'), ('d&WXdXWF.kHKJ&PbV.YMBXStib', 'd&WXdXWF.kHKJ&PbV.YMBXStib'), ('d&WXdXWF.qs0Xlaxq', 'd&WXdXWF.qs0Xlaxq'), ('d&WXdXWF.e#RTW9E#.8ZoaPsVW', 'd&WXdXWF.e#RTW9E#.8ZoaPsVW'), ('PbGoe2MV.UOTL#KIV', 'PbGoe2MV.UOTL#KIV'), ('PbGoe2MV.d8&gCo2N', 'PbGoe2MV.d8&gCo2N'), ('d&WXdXWF.5QAjgfv7.u0aKjT4i', 'd&WXdXWF.5QAjgfv7.u0aKjT4i'), ('BCG3&slG.Tsyej9ta', 'BCG3&slG.Tsyej9ta'), ('d&WXdXWF.i1IdaNwr.r#wbt#jF', 'd&WXdXWF.i1IdaNwr.r#wbt#jF'), ('d&WXdXWF.K80UMYnW.K72&pITr', 'd&WXdXWF.K80UMYnW.K72&pITr'), ('l7DsPDlm.#N2VymZo', 'l7DsPDlm.#N2VymZo'), ('d&WXdXWF.e#RTW9E#.CfnlTDZ#', 'd&WXdXWF.e#RTW9E#.CfnlTDZ#'), ('PbGoe2MV.kivAZaeX', 'PbGoe2MV.kivAZaeX'), ('d&WXdXWF.kHKJ&PbV', 'd&WXdXWF.kHKJ&PbV'), ('d&WXdXWF.kHKJ&PbV.r7RxB#9t', 'd&WXdXWF.kHKJ&PbV.r7RxB#9t'), ('d&WXdXWF', 'd&WXdXWF'), ('d&WXdXWF.i1IdaNwr', 'd&WXdXWF.i1IdaNwr'), ('l7DsPDlm.ISEXeZt&.&1WpYE&n', 'l7DsPDlm.ISEXeZt&.&1WpYE&n'), ('d&WXdXWF.K80UMYnW', 'd&WXdXWF.K80UMYnW'), ('d&WXdXWF.K80UMYnW.75WBu1ZS', 'd&WXdXWF.K80UMYnW.75WBu1ZS'), ('d&WXdXWF.qs0Xlaxq.jNm15RLB', 'd&WXdXWF.qs0Xlaxq.jNm15RLB'), ('l7DsPDlm.ISEXeZt&', 'l7DsPDlm.ISEXeZt&'), ('l7DsPDlm.ISEXeZt&.1JfIbP&N', 'l7DsPDlm.ISEXeZt&.1JfIbP&N'), ('d&WXdXWF.5QAjgfv7.4LskOFXj', 'd&WXdXWF.5QAjgfv7.4LskOFXj'), ('d&WXdXWF.e#RTW9E#.P7s8FxQ8', 'd&WXdXWF.e#RTW9E#.P7s8FxQ8'), ('l7DsPDlm', 'l7DsPDlm'), ('d&WXdXWF.kHKJ&PbV.KFJOCr&6', 'd&WXdXWF.kHKJ&PbV.KFJOCr&6')], max_length=100), blank=True, null=True, size=None)), - ('included_languages', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None, validators=[contentcuration.models.validate_language_code])), - ('non_distributable_licenses_included', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'CC BY'), (2, 'CC BY-SA'), (3, 'CC BY-ND'), (4, 'CC BY-NC'), (5, 'CC BY-NC-SA'), (6, 'CC BY-NC-ND'), (7, 'All Rights Reserved'), (8, 'Public Domain'), (9, 'Special Permissions')]), blank=True, null=True, size=None)), - ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='channel_versions', to='contentcuration.channel')), - ('secret_token', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contentcuration.secrettoken')), - ('special_permissions_included', models.ManyToManyField(blank=True, related_name='channel_versions', to='contentcuration.AuditedSpecialPermissionsLicense')), + ( + "id", + contentcuration.models.UUIDField( + default=uuid.uuid4, + max_length=32, + primary_key=True, + serialize=False, + ), + ), + ("version", models.PositiveIntegerField(blank=True, null=True)), + ("version_notes", models.TextField(blank=True, null=True)), + ("size", models.PositiveIntegerField(blank=True, null=True)), + ("date_published", models.DateTimeField(blank=True, null=True)), + ("resource_count", models.PositiveIntegerField(blank=True, null=True)), + ( + "kind_count", + django.contrib.postgres.fields.ArrayField( + base_field=models.JSONField(), + blank=True, + null=True, + size=None, + validators=[contentcuration.models.validate_kind_count_item], + ), + ), + ( + "included_licenses", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField( + choices=[ + (1, "CC BY"), + (2, "CC BY-SA"), + (3, "CC BY-ND"), + (4, "CC BY-NC"), + (5, "CC BY-NC-SA"), + (6, "CC BY-NC-ND"), + (7, "All Rights Reserved"), + (8, "Public Domain"), + (9, "Special Permissions"), + ] + ), + blank=True, + null=True, + size=None, + ), + ), + ( + "included_categories", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ( + "d&WXdXWF.qs0Xlaxq.0t5msbL5", + "d&WXdXWF.qs0Xlaxq.0t5msbL5", + ), + ( + "d&WXdXWF.K80UMYnW.ViBlbQR&", + "d&WXdXWF.K80UMYnW.ViBlbQR&", + ), + ( + "d&WXdXWF.qs0Xlaxq.nG96nHDc", + "d&WXdXWF.qs0Xlaxq.nG96nHDc", + ), + ("d&WXdXWF.5QAjgfv7", "d&WXdXWF.5QAjgfv7"), + ( + "d&WXdXWF.i1IdaNwr.mjSF4QlF", + "d&WXdXWF.i1IdaNwr.mjSF4QlF", + ), + ( + "d&WXdXWF.i1IdaNwr.uErN4PdS", + "d&WXdXWF.i1IdaNwr.uErN4PdS", + ), + ( + "d&WXdXWF.qs0Xlaxq.8rJ57ht6", + "d&WXdXWF.qs0Xlaxq.8rJ57ht6", + ), + ( + "d&WXdXWF.i1IdaNwr.#r5ocgid", + "d&WXdXWF.i1IdaNwr.#r5ocgid", + ), + ( + "d&WXdXWF.K80UMYnW.F863vKiF", + "d&WXdXWF.K80UMYnW.F863vKiF", + ), + ("d&WXdXWF.e#RTW9E#", "d&WXdXWF.e#RTW9E#"), + ("PbGoe2MV.J7CU1IxN", "PbGoe2MV.J7CU1IxN"), + ("PbGoe2MV", "PbGoe2MV"), + ( + "d&WXdXWF.5QAjgfv7.BUMJJBnS", + "d&WXdXWF.5QAjgfv7.BUMJJBnS", + ), + ("BCG3&slG.wZ3EAedB", "BCG3&slG.wZ3EAedB"), + ("PbGoe2MV.EHcbjuKq", "PbGoe2MV.EHcbjuKq"), + ( + "d&WXdXWF.5QAjgfv7.XsWznP4o", + "d&WXdXWF.5QAjgfv7.XsWznP4o", + ), + ( + "d&WXdXWF.i1IdaNwr.zbDzxDE7", + "d&WXdXWF.i1IdaNwr.zbDzxDE7", + ), + ("PbGoe2MV.kyxTNsRS", "PbGoe2MV.kyxTNsRS"), + ("PbGoe2MV.tS7WKnZ7", "PbGoe2MV.tS7WKnZ7"), + ("PbGoe2MV.HGIc9sZq", "PbGoe2MV.HGIc9sZq"), + ("ziJ6PCuU", "ziJ6PCuU"), + ("BCG3&slG", "BCG3&slG"), + ("BCG3&slG.0&d0qTqS", "BCG3&slG.0&d0qTqS"), + ( + "d&WXdXWF.qs0Xlaxq.lb7ELcK5", + "d&WXdXWF.qs0Xlaxq.lb7ELcK5", + ), + ("ziJ6PCuU.RLfhp37t", "ziJ6PCuU.RLfhp37t"), + ("d&WXdXWF.zWtcJ&F2", "d&WXdXWF.zWtcJ&F2"), + ( + "l7DsPDlm.ISEXeZt&.pRvOzJTE", + "l7DsPDlm.ISEXeZt&.pRvOzJTE", + ), + ("d&WXdXWF.JDUfJNXc", "d&WXdXWF.JDUfJNXc"), + ("BCG3&slG.fP2j70bj", "BCG3&slG.fP2j70bj"), + ("ziJ6PCuU.lOBPr5ix", "ziJ6PCuU.lOBPr5ix"), + ("BCG3&slG.HLo9TbNq", "BCG3&slG.HLo9TbNq"), + ( + "d&WXdXWF.kHKJ&PbV.DJLBbaEk", + "d&WXdXWF.kHKJ&PbV.DJLBbaEk", + ), + ( + "d&WXdXWF.kHKJ&PbV.YMBXStib", + "d&WXdXWF.kHKJ&PbV.YMBXStib", + ), + ("d&WXdXWF.qs0Xlaxq", "d&WXdXWF.qs0Xlaxq"), + ( + "d&WXdXWF.e#RTW9E#.8ZoaPsVW", + "d&WXdXWF.e#RTW9E#.8ZoaPsVW", + ), + ("PbGoe2MV.UOTL#KIV", "PbGoe2MV.UOTL#KIV"), + ("PbGoe2MV.d8&gCo2N", "PbGoe2MV.d8&gCo2N"), + ( + "d&WXdXWF.5QAjgfv7.u0aKjT4i", + "d&WXdXWF.5QAjgfv7.u0aKjT4i", + ), + ("BCG3&slG.Tsyej9ta", "BCG3&slG.Tsyej9ta"), + ( + "d&WXdXWF.i1IdaNwr.r#wbt#jF", + "d&WXdXWF.i1IdaNwr.r#wbt#jF", + ), + ( + "d&WXdXWF.K80UMYnW.K72&pITr", + "d&WXdXWF.K80UMYnW.K72&pITr", + ), + ("l7DsPDlm.#N2VymZo", "l7DsPDlm.#N2VymZo"), + ( + "d&WXdXWF.e#RTW9E#.CfnlTDZ#", + "d&WXdXWF.e#RTW9E#.CfnlTDZ#", + ), + ("PbGoe2MV.kivAZaeX", "PbGoe2MV.kivAZaeX"), + ("d&WXdXWF.kHKJ&PbV", "d&WXdXWF.kHKJ&PbV"), + ( + "d&WXdXWF.kHKJ&PbV.r7RxB#9t", + "d&WXdXWF.kHKJ&PbV.r7RxB#9t", + ), + ("d&WXdXWF", "d&WXdXWF"), + ("d&WXdXWF.i1IdaNwr", "d&WXdXWF.i1IdaNwr"), + ( + "l7DsPDlm.ISEXeZt&.&1WpYE&n", + "l7DsPDlm.ISEXeZt&.&1WpYE&n", + ), + ("d&WXdXWF.K80UMYnW", "d&WXdXWF.K80UMYnW"), + ( + "d&WXdXWF.K80UMYnW.75WBu1ZS", + "d&WXdXWF.K80UMYnW.75WBu1ZS", + ), + ( + "d&WXdXWF.qs0Xlaxq.jNm15RLB", + "d&WXdXWF.qs0Xlaxq.jNm15RLB", + ), + ("l7DsPDlm.ISEXeZt&", "l7DsPDlm.ISEXeZt&"), + ( + "l7DsPDlm.ISEXeZt&.1JfIbP&N", + "l7DsPDlm.ISEXeZt&.1JfIbP&N", + ), + ( + "d&WXdXWF.5QAjgfv7.4LskOFXj", + "d&WXdXWF.5QAjgfv7.4LskOFXj", + ), + ( + "d&WXdXWF.e#RTW9E#.P7s8FxQ8", + "d&WXdXWF.e#RTW9E#.P7s8FxQ8", + ), + ("l7DsPDlm", "l7DsPDlm"), + ( + "d&WXdXWF.kHKJ&PbV.KFJOCr&6", + "d&WXdXWF.kHKJ&PbV.KFJOCr&6", + ), + ], + max_length=100, + ), + blank=True, + null=True, + size=None, + ), + ), + ( + "included_languages", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=100), + blank=True, + null=True, + size=None, + validators=[contentcuration.models.validate_language_code], + ), + ), + ( + "non_distributable_licenses_included", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField( + choices=[ + (1, "CC BY"), + (2, "CC BY-SA"), + (3, "CC BY-ND"), + (4, "CC BY-NC"), + (5, "CC BY-NC-SA"), + (6, "CC BY-NC-ND"), + (7, "All Rights Reserved"), + (8, "Public Domain"), + (9, "Special Permissions"), + ] + ), + blank=True, + null=True, + size=None, + ), + ), + ( + "channel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="channel_versions", + to="contentcuration.channel", + ), + ), + ( + "secret_token", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="contentcuration.secrettoken", + ), + ), + ( + "special_permissions_included", + models.ManyToManyField( + blank=True, + related_name="channel_versions", + to="contentcuration.AuditedSpecialPermissionsLicense", + ), + ), ], options={ - 'unique_together': {('channel', 'version')}, + "unique_together": {("channel", "version")}, }, ), migrations.AddField( - model_name='channel', - name='version_info', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channel_version_info', to='contentcuration.channelversion'), + model_name="channel", + name="version_info", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="channel_version_info", + to="contentcuration.channelversion", + ), ), ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 5c49139476..21a7aba432 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -11,6 +11,7 @@ from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import PermissionsMixin +from django.contrib.postgres.fields import ArrayField from django.contrib.sessions.models import Session from django.core.cache import cache from django.core.exceptions import MultipleObjectsReturned @@ -55,8 +56,8 @@ from le_utils.constants import format_presets from le_utils.constants import languages from le_utils.constants import licenses -from le_utils.constants.labels import subjects from le_utils.constants import roles +from le_utils.constants.labels import subjects from model_utils import FieldTracker from mptt.models import MPTTModel from mptt.models import raise_if_unsaved @@ -73,7 +74,6 @@ from contentcuration.constants import feedback from contentcuration.constants import user_history from contentcuration.constants.contentnode import kind_activity_map -from django.contrib.postgres.fields import ArrayField from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove from contentcuration.db.models.functions import Unnest @@ -1202,7 +1202,9 @@ def on_update(self): # noqa C901 ): delete_public_channel_cache_keys() - if self.version and (not self.version_info or self.version_info.version != self.version): + if self.version and ( + not self.version_info or self.version_info.version != self.version + ): self.version_info, _ = ChannelVersion.objects.get_or_create( channel=self, version=self.version ) @@ -1362,19 +1364,17 @@ class Meta: index_together = [["deleted", "public"]] - - def validate_kind_count_item(value): - + if not isinstance(value, dict): raise ValidationError("Each kind_count item must be a dictionary") - + if "count" not in value or "kind" not in value: raise ValidationError("Each kind_count item must have 'count' and 'kind' keys") - + if not isinstance(value["count"], int) or value["count"] < 0: raise ValidationError("'count' must be a non-negative integer") - + if not isinstance(value["kind"], str) or not value["kind"]: raise ValidationError("'kind' must be a non-empty string") @@ -1393,49 +1393,44 @@ class ChannelVersion(models.Model): Stores version-specific information for a channel. This allows retrieving specific channel versions using secret tokens. """ + id = UUIDField(primary_key=True, default=uuid.uuid4) channel = models.ForeignKey( - Channel, - on_delete=models.CASCADE, - related_name="channel_versions" + Channel, on_delete=models.CASCADE, related_name="channel_versions" ) version = models.PositiveIntegerField(null=True, blank=True) secret_token = models.ForeignKey( - SecretToken, - on_delete=models.SET_NULL, - null=True, - blank=True + SecretToken, on_delete=models.SET_NULL, null=True, blank=True ) version_notes = models.TextField(null=True, blank=True) size = models.PositiveIntegerField(null=True, blank=True) date_published = models.DateTimeField(null=True, blank=True) resource_count = models.PositiveIntegerField(null=True, blank=True) kind_count = ArrayField( - JSONField(), - validators=[validate_kind_count_item], - null=True, - blank=True + JSONField(), validators=[validate_kind_count_item], null=True, blank=True ) included_licenses = ArrayField( models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]), null=True, - blank=True + blank=True, ) included_categories = ArrayField( - models.CharField(max_length=100, choices=[(subj, subj) for subj in subjects.SUBJECTSLIST]), + models.CharField( + max_length=100, choices=[(subj, subj) for subj in subjects.SUBJECTSLIST] + ), null=True, - blank=True + blank=True, ) included_languages = ArrayField( models.CharField(max_length=100), validators=[validate_language_code], null=True, - blank=True + blank=True, ) non_distributable_licenses_included = ArrayField( models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]), null=True, - blank=True + blank=True, ) special_permissions_included = models.ManyToManyField( "AuditedSpecialPermissionsLicense", @@ -1454,8 +1449,7 @@ def save(self, *args, **kwargs): def new_token(self): if not self.secret_token: self.secret_token = SecretToken.objects.create( - token=SecretToken.generate_new_token(), - is_primary=False + token=SecretToken.generate_new_token(), is_primary=False ) self.save() return self.secret_token @@ -2774,8 +2768,7 @@ def save(self, *args, **kwargs): ) # Create a ChannelVersion and token for this submission channel_version, _ = ChannelVersion.objects.get_or_create( - channel=self.channel, - version=self.channel_version + channel=self.channel, version=self.channel_version ) channel_version.new_token() @@ -3477,7 +3470,7 @@ def _create_from_change( table=None, rev=None, unpublishable=False, - **data + **data, ): change_type = data.pop("type") if table is None or table not in ALL_TABLES: diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py index f9baa34e31..886ff3a901 100644 --- a/contentcuration/contentcuration/tests/test_channel_version.py +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -1,6 +1,9 @@ -from contentcuration.models import Channel, ChannelVersion, SecretToken -from contentcuration.tests.base import StudioTestCase +from contentcuration.models import Channel +from contentcuration.models import ChannelVersion +from contentcuration.models import SecretToken from contentcuration.tests import testdata +from contentcuration.tests.base import StudioTestCase + class ChannelVersionTestCase(StudioTestCase): def setUp(self): @@ -50,6 +53,7 @@ def test_unique_constraint(self): version=1, ) from django.db.utils import IntegrityError + with self.assertRaises(IntegrityError): ChannelVersion.objects.create( channel=self.channel, diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index b74675513f..31c6e31d1f 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -10,10 +10,8 @@ from contentcuration.tests.utils.restricted_filesystemstorage import ( RestrictedFileSystemStorage, ) -from contentcuration.utils.publish import ( - ensure_versioned_database_exists, - increment_channel_version, -) +from contentcuration.utils.publish import ensure_versioned_database_exists +from contentcuration.utils.publish import increment_channel_version class EnsureVersionedDatabaseTestCase(StudioTestCase): @@ -102,7 +100,7 @@ def setUp(self): self.channel = testdata.channel() self.channel.version = 1 self.channel.save() - + ChannelVersion.objects.filter(channel=self.channel).delete() self.channel.version_info = None self.channel.save() @@ -110,38 +108,40 @@ def setUp(self): def test_increment_published_version(self): """Test incrementing version for published channel.""" initial_version = self.channel.version - - channel_version = increment_channel_version(self.channel, is_draft_version=False) - + + channel_version = increment_channel_version( + self.channel, is_draft_version=False + ) + self.channel.refresh_from_db() - + self.assertEqual(self.channel.version, initial_version + 1) - + self.assertIsNotNone(channel_version) self.assertEqual(channel_version.version, self.channel.version) self.assertEqual(channel_version.channel, self.channel) - + self.assertEqual(self.channel.version_info, channel_version) - + self.assertIsNone(channel_version.secret_token) def test_increment_draft_version(self): """Test incrementing version for draft channel.""" initial_version = self.channel.version - + channel_version = increment_channel_version(self.channel, is_draft_version=True) - + self.channel.refresh_from_db() - + self.assertEqual(self.channel.version, initial_version) - + self.assertIsNotNone(channel_version) self.assertIsNone(channel_version.version) self.assertEqual(channel_version.channel, self.channel) - + if self.channel.version_info: self.assertNotEqual(self.channel.version_info, channel_version) - + self.assertIsNotNone(channel_version.secret_token) self.assertFalse(channel_version.secret_token.is_primary) @@ -150,13 +150,13 @@ def test_multiple_published_versions(self): v1 = increment_channel_version(self.channel, is_draft_version=False) v2 = increment_channel_version(self.channel, is_draft_version=False) v3 = increment_channel_version(self.channel, is_draft_version=False) - + self.channel.refresh_from_db() - + self.assertEqual(self.channel.version, 4) - + self.assertEqual(self.channel.channel_versions.count(), 4) - + self.assertEqual(self.channel.version_info, v3) self.assertEqual(self.channel.version_info.version, 4) @@ -165,16 +165,16 @@ def test_mixed_draft_and_published_versions(self): published = increment_channel_version(self.channel, is_draft_version=False) draft1 = increment_channel_version(self.channel, is_draft_version=True) draft2 = increment_channel_version(self.channel, is_draft_version=True) - + self.channel.refresh_from_db() - + self.assertEqual(self.channel.version, 2) - + self.assertEqual(self.channel.channel_versions.count(), 4) - + self.assertIsNotNone(draft1.secret_token) self.assertIsNotNone(draft2.secret_token) - + self.assertIsNone(published.secret_token) - + self.assertEqual(self.channel.version_info, published) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index d1666b28e4..6b4a57d9cc 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -14,11 +14,11 @@ from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission from contentcuration.models import Change +from contentcuration.models import Channel +from contentcuration.models import ChannelVersion from contentcuration.models import CommunityLibrarySubmission from contentcuration.models import ContentNode from contentcuration.models import Country -from contentcuration.models import ChannelVersion -from contentcuration.models import Channel from contentcuration.tasks import apply_channel_changes_task from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase @@ -1418,13 +1418,13 @@ def setUp(self): super(GetVersionDetailEndpointTestCase, self).setUp() self.user = testdata.user() self.client.force_authenticate(user=self.user) - + self.channel = testdata.channel() self.channel.version = 3 self.channel.published = True self.channel.editors.add(self.user) self.channel.save() - + self.channel_version, _ = ChannelVersion.objects.update_or_create( channel=self.channel, version=3, @@ -1439,24 +1439,24 @@ def setUp(self): "included_languages": ["en", "es"], "included_licenses": [1, 2], "included_categories": ["math", "science"], - } + }, ) - + self.channel.version_info = self.channel_version self.channel.save() def test_get_version_detail_success(self): """Test successfully retrieving version detail.""" - url = reverse('channel-version-detail', kwargs={'pk': self.channel.id}) + url = reverse("channel-version-detail", kwargs={"pk": self.channel.id}) response = self.client.get(url) - + self.assertEqual(response.status_code, 200) - + data = response.json() - self.assertEqual(data['version'], 3) - self.assertEqual(data['version_notes'], "Test version") - self.assertEqual(data['size'], 5000) - self.assertEqual(data['resource_count'], 25) + self.assertEqual(data["version"], 3) + self.assertEqual(data["version_notes"], "Test version") + self.assertEqual(data["size"], 5000) + self.assertEqual(data["resource_count"], 25) def test_get_version_detail_no_version_info(self): """Test endpoint when channel has no version_info.""" @@ -1464,13 +1464,13 @@ def test_get_version_detail_no_version_info(self): channel2.version = 1 channel2.editors.add(self.user) channel2.save() - + ChannelVersion.objects.filter(channel=channel2).delete() - + Channel.objects.filter(pk=channel2.pk).update(version_info=None) - - url = reverse('channel-version-detail', kwargs={'pk': channel2.id}) + + url = reverse("channel-version-detail", kwargs={"pk": channel2.id}) response = self.client.get(url) - + self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {}) diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index cf91f16da2..3817ad4032 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -8,11 +8,14 @@ community_library_submission as community_library_submission_constants, ) from contentcuration.models import Change +from contentcuration.models import Channel +from contentcuration.models import ChannelVersion +from contentcuration.models import CommunityLibrarySubmission +from contentcuration.models import User from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.helpers import reverse_with_query from contentcuration.viewsets.sync.constants import ADDED_TO_COMMUNITY_LIBRARY -from contentcuration.models import Channel, ChannelVersion, CommunityLibrarySubmission, User class CRUDTestCase(StudioAPITestCase): @@ -1241,23 +1244,20 @@ def setUp(self): def test_submission_creates_channel_version(self): """Test that creating a submission creates a ChannelVersion.""" initial_count = ChannelVersion.objects.filter(channel=self.channel).count() - + submission = CommunityLibrarySubmission.objects.create( channel=self.channel, channel_version=5, author=self.user, description="Test submission", ) - + self.assertEqual( ChannelVersion.objects.filter(channel=self.channel).count(), - initial_count + 1 - ) - - channel_version = ChannelVersion.objects.get( - channel=self.channel, - version=5 + initial_count + 1, ) + + channel_version = ChannelVersion.objects.get(channel=self.channel, version=5) self.assertIsNotNone(channel_version) def test_submission_creates_token(self): @@ -1268,39 +1268,34 @@ def test_submission_creates_token(self): author=self.user, description="Test submission", ) - - channel_version = ChannelVersion.objects.get( - channel=self.channel, - version=5 - ) - - self.assertIsNotNone(channel_version.secret_token) - self.assertFalse(channel_version.secret_token.is_primary) + channel_version = ChannelVersion.objects.get(channel=self.channel, version=5) + self.assertIsNotNone(channel_version.secret_token) + self.assertFalse(channel_version.secret_token.is_primary) def test_submissions_different_versions(self): """Test that submissions for different versions create different tokens.""" self.channel.version = 6 self.channel.save() - + submission1 = CommunityLibrarySubmission.objects.create( channel=self.channel, channel_version=5, author=self.user, description="Version 5 submission", ) - + submission2 = CommunityLibrarySubmission.objects.create( channel=self.channel, channel_version=6, author=self.user, description="Version 6 submission", ) - + v5 = ChannelVersion.objects.get(channel=self.channel, version=5) v6 = ChannelVersion.objects.get(channel=self.channel, version=6) - + self.assertIsNotNone(v5.secret_token) self.assertIsNotNone(v6.secret_token) self.assertNotEqual(v5.secret_token, v6.secret_token) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 50fde61b1f..176563b39f 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -215,25 +215,23 @@ def increment_channel_version(channel, is_draft_version=False): if not is_draft_version: channel.version += 1 channel.save() - + if is_draft_version: channel_version = ccmodels.ChannelVersion.objects.create( - channel=channel, - version=None + channel=channel, version=None ) else: channel_version, created = ccmodels.ChannelVersion.objects.get_or_create( - channel=channel, - version=channel.version + channel=channel, version=channel.version ) - + if not is_draft_version: channel.version_info = channel_version channel.save() - + if is_draft_version: channel_version.new_token() - + return channel_version @@ -967,22 +965,28 @@ def fill_published_fields(channel, version_notes): } } ) - + # Calculate non-distributable licenses (All Rights Reserved) - all_rights_reserved_id = ccmodels.License.objects.filter( - license_name=licenses.ALL_RIGHTS_RESERVED - ).values_list('id', flat=True).first() - + all_rights_reserved_id = ( + ccmodels.License.objects.filter(license_name=licenses.ALL_RIGHTS_RESERVED) + .values_list("id", flat=True) + .first() + ) + non_distributable_licenses = ( - [all_rights_reserved_id] if all_rights_reserved_id and all_rights_reserved_id in license_list else [] + [all_rights_reserved_id] + if all_rights_reserved_id and all_rights_reserved_id in license_list + else [] ) - + # 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 = ccmodels.License.objects.filter( - license_name=licenses.SPECIAL_PERMISSIONS - ).values_list('id', flat=True).first() - + special_permissions_id = ( + ccmodels.License.objects.filter(license_name=licenses.SPECIAL_PERMISSIONS) + .values_list("id", flat=True) + .first() + ) + special_permissions_ids = [] if special_permissions_id and special_permissions_id in license_list: special_perms_descriptions = list( @@ -992,15 +996,17 @@ def fill_published_fields(channel, version_notes): .values_list("license_description", flat=True) .distinct() ) - + if special_perms_descriptions: - existing_licenses = ccmodels.AuditedSpecialPermissionsLicense.objects.filter( - description__in=special_perms_descriptions + existing_licenses = ( + ccmodels.AuditedSpecialPermissionsLicense.objects.filter( + description__in=special_perms_descriptions + ) ) existing_descriptions = set( existing_licenses.values_list("description", flat=True) ) - + new_licenses = [ ccmodels.AuditedSpecialPermissionsLicense( description=description, distributable=False @@ -1008,18 +1014,18 @@ def fill_published_fields(channel, version_notes): for description in special_perms_descriptions if description not in existing_descriptions ] - + if new_licenses: ccmodels.AuditedSpecialPermissionsLicense.objects.bulk_create( new_licenses, ignore_conflicts=True ) - + special_permissions_ids = list( ccmodels.AuditedSpecialPermissionsLicense.objects.filter( description__in=special_perms_descriptions ).values_list("id", flat=True) ) - + if channel.version_info: channel.version_info.resource_count = channel.total_resource_count channel.version_info.kind_count = kind_counts @@ -1029,9 +1035,11 @@ def fill_published_fields(channel, version_notes): channel.version_info.included_languages = language_list channel.version_info.included_licenses = license_list channel.version_info.included_categories = category_list - channel.version_info.non_distributable_licenses_included = non_distributable_licenses + channel.version_info.non_distributable_licenses_included = ( + non_distributable_licenses + ) channel.version_info.save() - + if special_permissions_ids: channel.version_info.special_permissions_included.set( ccmodels.AuditedSpecialPermissionsLicense.objects.filter( @@ -1040,8 +1048,7 @@ def fill_published_fields(channel, version_notes): ) else: channel.version_info.special_permissions_included.clear() - - + channel.save() diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 424cd6b3d3..1b1a5efb67 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -902,7 +902,7 @@ def get_version_detail(self, request, pk=None) -> Response: if not channel.version_info: return Response({}) - + version_data = { "id": str(channel.version_info.id), "version": channel.version_info.version, @@ -911,14 +911,18 @@ def get_version_detail(self, request, pk=None) -> Response: "size": channel.version_info.size, "date_published": channel.version_info.date_published.strftime( settings.DATE_TIME_FORMAT - ) if channel.version_info.date_published else None, + ) + if channel.version_info.date_published + else None, "version_notes": channel.version_info.version_notes, "included_languages": channel.version_info.included_languages, "included_licenses": channel.version_info.included_licenses, "included_categories": channel.version_info.included_categories, "non_distributable_licenses_included": channel.version_info.non_distributable_licenses_included, "special_permissions_included": list( - channel.version_info.special_permissions_included.values_list('id', flat=True) + channel.version_info.special_permissions_included.values_list( + "id", flat=True + ) ), } From 31039f6c128e2ca1c44687240127b58538c88bd8 Mon Sep 17 00:00:00 2001 From: taoerman Date: Wed, 3 Dec 2025 11:06:55 -0800 Subject: [PATCH 03/22] fix linting --- ...uto_20251203_0300.py => 0160_add_channel_version_model.py} | 0 contentcuration/contentcuration/tests/test_channel_version.py | 1 - contentcuration/contentcuration/tests/utils/test_publish.py | 3 +++ .../tests/viewsets/test_community_library_submission.py | 4 ++++ contentcuration/contentcuration/utils/publish.py | 1 + 5 files changed, 8 insertions(+), 1 deletion(-) rename contentcuration/contentcuration/migrations/{0160_auto_20251203_0300.py => 0160_add_channel_version_model.py} (100%) diff --git a/contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py similarity index 100% rename from contentcuration/contentcuration/migrations/0160_auto_20251203_0300.py rename to contentcuration/contentcuration/migrations/0160_add_channel_version_model.py diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py index 886ff3a901..11b69fd5b3 100644 --- a/contentcuration/contentcuration/tests/test_channel_version.py +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -1,4 +1,3 @@ -from contentcuration.models import Channel from contentcuration.models import ChannelVersion from contentcuration.models import SecretToken from contentcuration.tests import testdata diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index 31c6e31d1f..70482a017e 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -157,6 +157,9 @@ def test_multiple_published_versions(self): self.assertEqual(self.channel.channel_versions.count(), 4) + # Verify all versions were created + self.assertIsNotNone(v1) + self.assertIsNotNone(v2) self.assertEqual(self.channel.version_info, v3) self.assertEqual(self.channel.version_info.version, 4) diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index 3817ad4032..3994d33187 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -1252,6 +1252,7 @@ def test_submission_creates_channel_version(self): description="Test submission", ) + self.assertIsNotNone(submission) self.assertEqual( ChannelVersion.objects.filter(channel=self.channel).count(), initial_count + 1, @@ -1269,6 +1270,7 @@ def test_submission_creates_token(self): description="Test submission", ) + self.assertIsNotNone(submission) channel_version = ChannelVersion.objects.get(channel=self.channel, version=5) self.assertIsNotNone(channel_version.secret_token) @@ -1293,6 +1295,8 @@ def test_submissions_different_versions(self): description="Version 6 submission", ) + self.assertIsNotNone(submission1) + self.assertIsNotNone(submission2) v5 = ChannelVersion.objects.get(channel=self.channel, version=5) v6 = ChannelVersion.objects.get(channel=self.channel, version=6) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 176563b39f..7475aee54c 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -35,6 +35,7 @@ from le_utils.constants import exercises from le_utils.constants import file_formats from le_utils.constants import format_presets +from le_utils.constants import licenses from le_utils.constants import roles from search.models import ChannelFullTextSearch from search.models import ContentNodeFullTextSearch From 82dad0fed4af779c84fe11b307207c6e136c0604 Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 11:03:26 -0800 Subject: [PATCH 04/22] fix code --- .../index.vue | 9 +-- .../0160_add_channel_version_model.py | 34 +++++++++-- contentcuration/contentcuration/models.py | 45 ++++++++++----- .../tests/test_channel_version.py | 11 ++++ .../tests/utils/test_publish.py | 48 ++++++++-------- .../tests/viewsets/test_channel.py | 53 +++++++++++++++--- .../contentcuration/utils/publish.py | 56 ++++++------------- .../contentcuration/viewsets/channel.py | 52 +++++++++-------- 8 files changed, 188 insertions(+), 120 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 7b08ee54e8..aa4f1f1d3a 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -390,17 +390,14 @@ // Use the latest version available from either channel or publishedData const displayedVersion = computed(() => { const channelVersion = currentChannelVersion.value || 0; - if (publishedData.value && Object.keys(publishedData.value).length > 0) { - const publishedVersions = Object.keys(publishedData.value).map(v => parseInt(v, 10)); - const maxPublishedVersion = Math.max(...publishedVersions); - return Math.max(channelVersion, maxPublishedVersion); + if (publishedData.value && publishedData.value.version) { + return Math.max(channelVersion, publishedData.value.version); } return channelVersion; }); const latestPublishedData = computed(() => { - if (!publishedData.value || !displayedVersion.value) return undefined; - return publishedData.value[displayedVersion.value]; + return publishedData.value; }); // Watch for when publishing completes - fetch publishedData to get the new version's data diff --git a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py index e387d6f7fd..07402c8431 100644 --- a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py +++ b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py @@ -3,10 +3,33 @@ import django.contrib.postgres.fields import django.db.models.deletion +from django.core.exceptions import ValidationError from django.db import migrations from django.db import models -import contentcuration.models + +def validate_kind_count_item(value): + """Validator for kind_count array items.""" + if not isinstance(value, dict): + raise ValidationError("Each kind_count item must be a dictionary") + + if "count" not in value or "kind" not in value: + raise ValidationError("Each kind_count item must have 'count' and 'kind' keys") + + if not isinstance(value["count"], int) or value["count"] < 0: + raise ValidationError("'count' must be a non-negative integer") + + if not isinstance(value["kind"], str) or not value["kind"]: + raise ValidationError("'kind' must be a non-empty string") + + +def validate_language_code(value): + """Validator for language codes in included_languages array.""" + from le_utils.constants import languages + + valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST] + if value not in valid_language_codes: + raise ValidationError(f"'{value}' is not a valid language code") class Migration(migrations.Migration): @@ -21,9 +44,8 @@ class Migration(migrations.Migration): fields=[ ( "id", - contentcuration.models.UUIDField( + models.UUIDField( default=uuid.uuid4, - max_length=32, primary_key=True, serialize=False, ), @@ -40,7 +62,7 @@ class Migration(migrations.Migration): blank=True, null=True, size=None, - validators=[contentcuration.models.validate_kind_count_item], + validators=[validate_kind_count_item], ), ), ( @@ -226,7 +248,7 @@ class Migration(migrations.Migration): blank=True, null=True, size=None, - validators=[contentcuration.models.validate_language_code], + validators=[validate_language_code], ), ), ( @@ -287,7 +309,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="channel_version_info", + related_name="+", to="contentcuration.channelversion", ), ), diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 21a7aba432..20302c9764 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1,5 +1,6 @@ import hashlib import json +import jsonschema import logging import os import urllib.parse @@ -980,7 +981,7 @@ class Channel(models.Model): ) version_info = models.OneToOneField( "ChannelVersion", - related_name="channel_version_info", + related_name="+", null=True, blank=True, on_delete=models.SET_NULL, @@ -1364,19 +1365,22 @@ class Meta: index_together = [["deleted", "public"]] -def validate_kind_count_item(value): - - if not isinstance(value, dict): - raise ValidationError("Each kind_count item must be a dictionary") - - if "count" not in value or "kind" not in value: - raise ValidationError("Each kind_count item must have 'count' and 'kind' keys") +KIND_COUNT_ITEM_SCHEMA = { + "type": "object", + "required": ["count", "kind"], + "properties": { + "count": {"type": "integer", "minimum": 0}, + "kind": {"type": "string", "minLength": 1} + }, + "additionalProperties": False +} - if not isinstance(value["count"], int) or value["count"] < 0: - raise ValidationError("'count' must be a non-negative integer") - if not isinstance(value["kind"], str) or not value["kind"]: - raise ValidationError("'kind' must be a non-empty string") +def validate_kind_count_item(value): + try: + jsonschema.validate(instance=value, schema=KIND_COUNT_ITEM_SCHEMA) + except jsonschema.ValidationError as e: + raise ValidationError(str(e)) def validate_language_code(value): @@ -1388,6 +1392,17 @@ def validate_language_code(value): raise ValidationError(f"'{value}' is not a valid language code") +def get_license_choices(): + """Helper function to get license choices for ArrayField.""" + return [(lic[0], lic[1]) for lic in licenses.LICENSELIST] + + +def get_categories_choices(): + """Helper function to get category choices for ArrayField.""" + return [(subj, subj) for subj in subjects.SUBJECTSLIST] + + + class ChannelVersion(models.Model): """ Stores version-specific information for a channel. This allows retrieving @@ -1410,13 +1425,13 @@ class ChannelVersion(models.Model): JSONField(), validators=[validate_kind_count_item], null=True, blank=True ) included_licenses = ArrayField( - models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]), + models.IntegerField(choices=get_license_choices()), null=True, blank=True, ) included_categories = ArrayField( models.CharField( - max_length=100, choices=[(subj, subj) for subj in subjects.SUBJECTSLIST] + max_length=100, choices=get_categories_choices() ), null=True, blank=True, @@ -1428,7 +1443,7 @@ class ChannelVersion(models.Model): blank=True, ) non_distributable_licenses_included = ArrayField( - models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]), + models.IntegerField(choices=get_license_choices()), null=True, blank=True, ) diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py index 11b69fd5b3..275daf2fca 100644 --- a/contentcuration/contentcuration/tests/test_channel_version.py +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -58,3 +58,14 @@ def test_unique_constraint(self): channel=self.channel, version=1, ) + + def test_version_cannot_exceed_channel_version(self): + """Test that we can't create versions greater than channel.version.""" + from django.core.exceptions import ValidationError + + cv = ChannelVersion( + channel=self.channel, + version=11, + ) + with self.assertRaises(ValidationError): + cv.save() diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index 70482a017e..251d446f52 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -1,6 +1,7 @@ import os import tempfile from unittest import mock +import uuid from django.conf import settings @@ -12,6 +13,7 @@ ) from contentcuration.utils.publish import ensure_versioned_database_exists from contentcuration.utils.publish import increment_channel_version +from contentcuration.utils.publish import create_draft_channel_version class EnsureVersionedDatabaseTestCase(StudioTestCase): @@ -53,6 +55,10 @@ def tearDown(self): super().tearDown() def test_versioned_database_exists(self): + # In reality, the versioned database for the current version + # and the unversioned database would have the same content, + # but here we provide different content so that we can test + # that the versioned database is not overwritten. versioned_db_content = "Versioned content" unversioned_db_content = "Unversioned content" @@ -109,27 +115,23 @@ def test_increment_published_version(self): """Test incrementing version for published channel.""" initial_version = self.channel.version - channel_version = increment_channel_version( - self.channel, is_draft_version=False - ) + increment_channel_version(self.channel) self.channel.refresh_from_db() self.assertEqual(self.channel.version, initial_version + 1) - self.assertIsNotNone(channel_version) - self.assertEqual(channel_version.version, self.channel.version) - self.assertEqual(channel_version.channel, self.channel) - - self.assertEqual(self.channel.version_info, channel_version) + self.assertIsNotNone(self.channel.version_info) + self.assertEqual(self.channel.version_info.version, self.channel.version) + self.assertEqual(self.channel.version_info.channel, self.channel) - self.assertIsNone(channel_version.secret_token) + self.assertIsNone(self.channel.version_info.secret_token) def test_increment_draft_version(self): """Test incrementing version for draft channel.""" initial_version = self.channel.version - channel_version = increment_channel_version(self.channel, is_draft_version=True) + channel_version = create_draft_channel_version(self.channel) self.channel.refresh_from_db() @@ -147,9 +149,9 @@ def test_increment_draft_version(self): def test_multiple_published_versions(self): """Test creating multiple published versions.""" - v1 = increment_channel_version(self.channel, is_draft_version=False) - v2 = increment_channel_version(self.channel, is_draft_version=False) - v3 = increment_channel_version(self.channel, is_draft_version=False) + increment_channel_version(self.channel) + increment_channel_version(self.channel) + increment_channel_version(self.channel) self.channel.refresh_from_db() @@ -157,27 +159,25 @@ def test_multiple_published_versions(self): self.assertEqual(self.channel.channel_versions.count(), 4) - # Verify all versions were created - self.assertIsNotNone(v1) - self.assertIsNotNone(v2) - self.assertEqual(self.channel.version_info, v3) + self.assertIsNotNone(self.channel.version_info) self.assertEqual(self.channel.version_info.version, 4) def test_mixed_draft_and_published_versions(self): """Test creating mix of draft and published versions.""" - published = increment_channel_version(self.channel, is_draft_version=False) - draft1 = increment_channel_version(self.channel, is_draft_version=True) - draft2 = increment_channel_version(self.channel, is_draft_version=True) + increment_channel_version(self.channel) + draft1 = create_draft_channel_version(self.channel) + draft2 = create_draft_channel_version(self.channel) self.channel.refresh_from_db() self.assertEqual(self.channel.version, 2) - self.assertEqual(self.channel.channel_versions.count(), 4) + self.assertEqual(self.channel.channel_versions.count(), 3) + self.assertEqual(uuid.UUID(str(draft1.id)), uuid.UUID(str(draft2.id))) self.assertIsNotNone(draft1.secret_token) self.assertIsNotNone(draft2.secret_token) - self.assertIsNone(published.secret_token) - - self.assertEqual(self.channel.version_info, published) + self.assertIsNotNone(self.channel.version_info) + self.assertEqual(self.channel.version_info.version, 2) + self.assertIsNone(self.channel.version_info.secret_token) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 6b4a57d9cc..152a6a6d00 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1264,29 +1264,64 @@ def setUp(self): } self.channel.save() - def test_get_published_data__is_editor(self): + def test_get_version_detail__is_editor(self): + """Test that editors can get version detail with populated versionInfo.""" + from contentcuration.models import ChannelVersion + self.client.force_authenticate(user=self.editor_user) + + # Create a ChannelVersion and set it as version_info + channel_version = ChannelVersion.objects.create( + channel=self.channel, + version=1, + resource_count=100, + size=1024000, + ) + self.channel.version_info = channel_version + self.channel.save() response = self.client.get( reverse("channel-version-detail", kwargs={"pk": self.channel.id}), format="json", ) self.assertEqual(response.status_code, 200, response.content) - # version-detail returns empty dict when no version_info exists - self.assertEqual(response.json(), {}) - - def test_get_published_data__is_admin(self): + data = response.json() + + # Assert against the most recent ChannelVersion + self.assertEqual(data["version"], 1) + self.assertEqual(data["resource_count"], 100) + self.assertEqual(data["size"], 1024000) + + def test_get_version_detail__is_admin(self): + """Test that admins can get version detail with populated versionInfo.""" + from contentcuration.models import ChannelVersion + self.client.force_authenticate(user=self.admin_user) + + # Create a ChannelVersion and set it as version_info + channel_version = ChannelVersion.objects.create( + channel=self.channel, + version=2, + resource_count=200, + size=2048000, + ) + self.channel.version_info = channel_version + self.channel.save() response = self.client.get( reverse("channel-version-detail", kwargs={"pk": self.channel.id}), format="json", ) self.assertEqual(response.status_code, 200, response.content) - # version-detail returns empty dict when no version_info exists - self.assertEqual(response.json(), {}) - - def test_get_published_data__is_forbidden_user(self): + data = response.json() + + # Assert against the most recent ChannelVersion + self.assertEqual(data["version"], 2) + self.assertEqual(data["resource_count"], 200) + self.assertEqual(data["size"], 2048000) + + def test_get_version_detail__is_forbidden_user(self): + """Test that forbidden users cannot access version detail.""" self.client.force_authenticate(user=self.forbidden_user) response = self.client.get( diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 7475aee54c..86abd1b9fc 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -211,26 +211,20 @@ def create_kolibri_license_object(ccnode): ) -def increment_channel_version(channel, is_draft_version=False): +def increment_channel_version(channel): - if not is_draft_version: - channel.version += 1 - channel.save() + channel.version += 1 + channel.save() - if is_draft_version: - channel_version = ccmodels.ChannelVersion.objects.create( - channel=channel, version=None - ) - else: - channel_version, created = ccmodels.ChannelVersion.objects.get_or_create( - channel=channel, version=channel.version - ) - if not is_draft_version: - channel.version_info = channel_version - channel.save() +def create_draft_channel_version(channel): + + channel_version, created = ccmodels.ChannelVersion.objects.get_or_create( + channel=channel, + version=None, + ) - if is_draft_version: + if created: channel_version.new_token() return channel_version @@ -999,32 +993,15 @@ def fill_published_fields(channel, version_notes): ) if special_perms_descriptions: - existing_licenses = ( - ccmodels.AuditedSpecialPermissionsLicense.objects.filter( - description__in=special_perms_descriptions - ) - ) - existing_descriptions = set( - existing_licenses.values_list("description", flat=True) - ) - new_licenses = [ ccmodels.AuditedSpecialPermissionsLicense( description=description, distributable=False ) for description in special_perms_descriptions - if description not in existing_descriptions ] - if new_licenses: - ccmodels.AuditedSpecialPermissionsLicense.objects.bulk_create( - new_licenses, ignore_conflicts=True - ) - - special_permissions_ids = list( - ccmodels.AuditedSpecialPermissionsLicense.objects.filter( - description__in=special_perms_descriptions - ).values_list("id", flat=True) + ccmodels.AuditedSpecialPermissionsLicense.objects.bulk_create( + new_licenses, ignore_conflicts=True ) if channel.version_info: @@ -1041,10 +1018,10 @@ def fill_published_fields(channel, version_notes): ) channel.version_info.save() - if special_permissions_ids: + if special_perms_descriptions: channel.version_info.special_permissions_included.set( ccmodels.AuditedSpecialPermissionsLicense.objects.filter( - id__in=special_permissions_ids + description__in=special_perms_descriptions ) ) else: @@ -1152,7 +1129,10 @@ def publish_channel( # noqa: C901 use_staging_tree=use_staging_tree, ) add_tokens_to_channel(channel) - increment_channel_version(channel, is_draft_version=is_draft_version) + if is_draft_version: + create_draft_channel_version(channel) + else: + increment_channel_version(channel) if not is_draft_version: sync_contentnode_and_channel_tsvectors(channel_id=channel.id) mark_all_nodes_as_published(base_tree) diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 1b1a5efb67..3bc9e9ddda 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -49,6 +49,7 @@ from contentcuration.decorators import cache_no_user_data from contentcuration.models import Change from contentcuration.models import Channel +from contentcuration.models import ChannelVersion from contentcuration.models import CommunityLibrarySubmission from contentcuration.models import ContentNode from contentcuration.models import Country @@ -896,35 +897,42 @@ def get_version_detail(self, request, pk=None) -> Response: :return: Response with the version detail of the channel :rtype: Response """ - # Allow exactly users with permission to edit the channel to - # access the version detail. channel = self.get_edit_object() if not channel.version_info: return Response({}) - version_data = { - "id": str(channel.version_info.id), - "version": channel.version_info.version, - "resource_count": channel.version_info.resource_count, - "kind_count": channel.version_info.kind_count, - "size": channel.version_info.size, - "date_published": channel.version_info.date_published.strftime( + version_data = ChannelVersion.objects.filter( + id=channel.version_info.id + ).values( + "id", + "version", + "resource_count", + "kind_count", + "size", + "date_published", + "version_notes", + "included_languages", + "included_licenses", + "included_categories", + "non_distributable_licenses_included", + ).first() + + if not version_data: + return Response({}) + + version_data["id"] = str(version_data["id"]) + + if version_data["date_published"]: + version_data["date_published"] = version_data["date_published"].strftime( settings.DATE_TIME_FORMAT ) - if channel.version_info.date_published - else None, - "version_notes": channel.version_info.version_notes, - "included_languages": channel.version_info.included_languages, - "included_licenses": channel.version_info.included_licenses, - "included_categories": channel.version_info.included_categories, - "non_distributable_licenses_included": channel.version_info.non_distributable_licenses_included, - "special_permissions_included": list( - channel.version_info.special_permissions_included.values_list( - "id", flat=True - ) - ), - } + + version_data["special_permissions_included"] = list( + channel.version_info.special_permissions_included.values_list( + "id", flat=True + ) + ) return Response(version_data) From 774390087e164fed05631d5f5aa4d640b12d76fe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:06:17 +0000 Subject: [PATCH 05/22] [pre-commit.ci lite] apply automatic fixes --- .../0160_add_channel_version_model.py | 2 +- contentcuration/contentcuration/models.py | 11 +++---- .../tests/utils/test_publish.py | 4 +-- .../tests/viewsets/test_channel.py | 12 +++---- .../contentcuration/utils/publish.py | 2 +- .../contentcuration/viewsets/channel.py | 32 ++++++++++--------- 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py index 07402c8431..83f2c11bae 100644 --- a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py +++ b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py @@ -26,7 +26,7 @@ def validate_kind_count_item(value): def validate_language_code(value): """Validator for language codes in included_languages array.""" from le_utils.constants import languages - + valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST] if value not in valid_language_codes: raise ValidationError(f"'{value}' is not a valid language code") diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 20302c9764..a0afea75a0 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1,12 +1,12 @@ import hashlib import json -import jsonschema import logging import os import urllib.parse import uuid from datetime import datetime +import jsonschema import pytz from django.conf import settings from django.contrib.auth.base_user import AbstractBaseUser @@ -1370,9 +1370,9 @@ class Meta: "required": ["count", "kind"], "properties": { "count": {"type": "integer", "minimum": 0}, - "kind": {"type": "string", "minLength": 1} + "kind": {"type": "string", "minLength": 1}, }, - "additionalProperties": False + "additionalProperties": False, } @@ -1402,7 +1402,6 @@ def get_categories_choices(): return [(subj, subj) for subj in subjects.SUBJECTSLIST] - class ChannelVersion(models.Model): """ Stores version-specific information for a channel. This allows retrieving @@ -1430,9 +1429,7 @@ class ChannelVersion(models.Model): blank=True, ) included_categories = ArrayField( - models.CharField( - max_length=100, choices=get_categories_choices() - ), + models.CharField(max_length=100, choices=get_categories_choices()), null=True, blank=True, ) diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index 251d446f52..af2552b104 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -1,7 +1,7 @@ import os import tempfile -from unittest import mock import uuid +from unittest import mock from django.conf import settings @@ -11,9 +11,9 @@ from contentcuration.tests.utils.restricted_filesystemstorage import ( RestrictedFileSystemStorage, ) +from contentcuration.utils.publish import create_draft_channel_version from contentcuration.utils.publish import ensure_versioned_database_exists from contentcuration.utils.publish import increment_channel_version -from contentcuration.utils.publish import create_draft_channel_version class EnsureVersionedDatabaseTestCase(StudioTestCase): diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 152a6a6d00..a0a297f91e 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1267,9 +1267,9 @@ def setUp(self): def test_get_version_detail__is_editor(self): """Test that editors can get version detail with populated versionInfo.""" from contentcuration.models import ChannelVersion - + self.client.force_authenticate(user=self.editor_user) - + # Create a ChannelVersion and set it as version_info channel_version = ChannelVersion.objects.create( channel=self.channel, @@ -1286,7 +1286,7 @@ def test_get_version_detail__is_editor(self): ) self.assertEqual(response.status_code, 200, response.content) data = response.json() - + # Assert against the most recent ChannelVersion self.assertEqual(data["version"], 1) self.assertEqual(data["resource_count"], 100) @@ -1295,9 +1295,9 @@ def test_get_version_detail__is_editor(self): def test_get_version_detail__is_admin(self): """Test that admins can get version detail with populated versionInfo.""" from contentcuration.models import ChannelVersion - + self.client.force_authenticate(user=self.admin_user) - + # Create a ChannelVersion and set it as version_info channel_version = ChannelVersion.objects.create( channel=self.channel, @@ -1314,7 +1314,7 @@ def test_get_version_detail__is_admin(self): ) self.assertEqual(response.status_code, 200, response.content) data = response.json() - + # Assert against the most recent ChannelVersion self.assertEqual(data["version"], 2) self.assertEqual(data["resource_count"], 200) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 86abd1b9fc..ad2672bf6f 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -218,7 +218,7 @@ def increment_channel_version(channel): def create_draft_channel_version(channel): - + channel_version, created = ccmodels.ChannelVersion.objects.get_or_create( channel=channel, version=None, diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 3bc9e9ddda..34bb87a681 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -902,21 +902,23 @@ def get_version_detail(self, request, pk=None) -> Response: if not channel.version_info: return Response({}) - version_data = ChannelVersion.objects.filter( - id=channel.version_info.id - ).values( - "id", - "version", - "resource_count", - "kind_count", - "size", - "date_published", - "version_notes", - "included_languages", - "included_licenses", - "included_categories", - "non_distributable_licenses_included", - ).first() + version_data = ( + ChannelVersion.objects.filter(id=channel.version_info.id) + .values( + "id", + "version", + "resource_count", + "kind_count", + "size", + "date_published", + "version_notes", + "included_languages", + "included_licenses", + "included_categories", + "non_distributable_licenses_included", + ) + .first() + ) if not version_data: return Response({}) From 50f0355cea850f416ca367d7bbbc4a28ef2b712e Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 12:31:24 -0800 Subject: [PATCH 06/22] fix code --- .../0160_add_channel_version_model.py | 30 +++---------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py index 83f2c11bae..e83c4c1737 100644 --- a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py +++ b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py @@ -1,35 +1,13 @@ # Generated by Django 3.2.24 on 2025-12-03 03:00 import uuid +import contentcuration.models import django.contrib.postgres.fields import django.db.models.deletion -from django.core.exceptions import ValidationError from django.db import migrations from django.db import models -def validate_kind_count_item(value): - """Validator for kind_count array items.""" - if not isinstance(value, dict): - raise ValidationError("Each kind_count item must be a dictionary") - - if "count" not in value or "kind" not in value: - raise ValidationError("Each kind_count item must have 'count' and 'kind' keys") - - if not isinstance(value["count"], int) or value["count"] < 0: - raise ValidationError("'count' must be a non-negative integer") - - if not isinstance(value["kind"], str) or not value["kind"]: - raise ValidationError("'kind' must be a non-empty string") - - -def validate_language_code(value): - """Validator for language codes in included_languages array.""" - from le_utils.constants import languages - - valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST] - if value not in valid_language_codes: - raise ValidationError(f"'{value}' is not a valid language code") class Migration(migrations.Migration): @@ -44,7 +22,7 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.UUIDField( + contentcuration.models.UUIDField( default=uuid.uuid4, primary_key=True, serialize=False, @@ -62,7 +40,7 @@ class Migration(migrations.Migration): blank=True, null=True, size=None, - validators=[validate_kind_count_item], + validators=[contentcuration.models.validate_kind_count_item], ), ), ( @@ -248,7 +226,7 @@ class Migration(migrations.Migration): blank=True, null=True, size=None, - validators=[validate_language_code], + validators=[contentcuration.models.validate_language_code], ), ), ( From 64311a2381276ce7eced84d725fc9fd4493e4342 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:34:08 +0000 Subject: [PATCH 07/22] [pre-commit.ci lite] apply automatic fixes --- .../migrations/0160_add_channel_version_model.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py index e83c4c1737..9cd639094a 100644 --- a/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py +++ b/contentcuration/contentcuration/migrations/0160_add_channel_version_model.py @@ -1,13 +1,12 @@ # Generated by Django 3.2.24 on 2025-12-03 03:00 import uuid -import contentcuration.models import django.contrib.postgres.fields import django.db.models.deletion from django.db import migrations from django.db import models - +import contentcuration.models class Migration(migrations.Migration): From 50398cf3b2027402b5800c6c3843bda83c831b85 Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 12:49:30 -0800 Subject: [PATCH 08/22] fix code --- .../SubmitToCommunityLibrarySidePanel/index.vue | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index aa4f1f1d3a..7b08ee54e8 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -390,14 +390,17 @@ // Use the latest version available from either channel or publishedData const displayedVersion = computed(() => { const channelVersion = currentChannelVersion.value || 0; - if (publishedData.value && publishedData.value.version) { - return Math.max(channelVersion, publishedData.value.version); + if (publishedData.value && Object.keys(publishedData.value).length > 0) { + const publishedVersions = Object.keys(publishedData.value).map(v => parseInt(v, 10)); + const maxPublishedVersion = Math.max(...publishedVersions); + return Math.max(channelVersion, maxPublishedVersion); } return channelVersion; }); const latestPublishedData = computed(() => { - return publishedData.value; + if (!publishedData.value || !displayedVersion.value) return undefined; + return publishedData.value[displayedVersion.value]; }); // Watch for when publishing completes - fetch publishedData to get the new version's data From 17c9e4ccf938d5235797562e557a2a95d3da4fe4 Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 13:00:27 -0800 Subject: [PATCH 09/22] fix code --- .../SubmitToCommunityLibrarySidePanel.spec.js | 30 +++++++------------ .../index.vue | 3 +- .../contentcuration/utils/publish.py | 2 +- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js index 5d3d907f88..4ab8e705b7 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js @@ -101,16 +101,10 @@ const nonPublishedChannel = { }; const publishedData = { - 2: { - included_languages: ['en', null], - included_licenses: [1], - included_categories: [Categories.SCHOOL], - }, - 1: { - included_languages: ['en', null], - included_licenses: [1], - included_categories: [Categories.SCHOOL], - }, + version: 2, + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], }; const submittedLatestSubmission = { channel_version: 2, status: CommunityLibraryStatus.PENDING }; @@ -152,7 +146,7 @@ describe('SubmitToCommunityLibrarySidePanel', () => { it('when channel is not published', async () => { const wrapper = await makeWrapper({ channel: nonPublishedChannel, - publishedData: {}, + publishedData: null, latestSubmission: null, }); @@ -304,12 +298,10 @@ describe('SubmitToCommunityLibrarySidePanel', () => { const channel = { ...publishedNonPublicChannel, publishing: true }; const publishedDataWithVersion3 = { - ...publishedData, - 3: { - included_languages: ['en', null], - included_licenses: [1], - included_categories: [Categories.SCHOOL], - }, + version: 3, + included_languages: ['en', null], + included_licenses: [1], + included_categories: [Categories.SCHOOL], }; const wrapper = await makeWrapper({ channel, @@ -437,7 +429,7 @@ describe('SubmitToCommunityLibrarySidePanel', () => { it('when channel is not published', async () => { const wrapper = await makeWrapper({ channel: nonPublishedChannel, - publishedData: {}, + publishedData: null, latestSubmission: null, }); @@ -473,7 +465,7 @@ describe('SubmitToCommunityLibrarySidePanel', () => { it('when channel is not published', async () => { const wrapper = await makeWrapper({ channel: nonPublishedChannel, - publishedData: {}, + publishedData: null, latestSubmission: null, }); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 7b08ee54e8..0fda8749af 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -399,8 +399,7 @@ }); const latestPublishedData = computed(() => { - if (!publishedData.value || !displayedVersion.value) return undefined; - return publishedData.value[displayedVersion.value]; + return publishedData.value; }); // Watch for when publishing completes - fetch publishedData to get the new version's data diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index ad2672bf6f..8e1a7e7a61 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -982,7 +982,7 @@ def fill_published_fields(channel, version_notes): .first() ) - special_permissions_ids = [] + if special_permissions_id and special_permissions_id in license_list: special_perms_descriptions = list( published_nodes.filter(license_id=special_permissions_id) From 48194701149b10286d3109438735a0fefd19fbe4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:03:10 +0000 Subject: [PATCH 10/22] [pre-commit.ci lite] apply automatic fixes --- contentcuration/contentcuration/utils/publish.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 8e1a7e7a61..bb9b4fe17d 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -982,7 +982,6 @@ def fill_published_fields(channel, version_notes): .first() ) - if special_permissions_id and special_permissions_id in license_list: special_perms_descriptions = list( published_nodes.filter(license_id=special_permissions_id) From 2573dcd0226e8a017b547ebd5d7454fef7593b5f Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 15:05:58 -0800 Subject: [PATCH 11/22] fix test --- .../tests/viewsets/test_channel.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index a0a297f91e..794916244c 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1253,10 +1253,12 @@ def setUp(self): super().setUp() self.editor_user = testdata.user(email="editor@user.com") + self.admin_user = testdata.user(email="admin@user.com") self.forbidden_user = testdata.user(email="forbidden@user.com") self.channel = testdata.channel() self.channel.editors.add(self.editor_user) + self.channel.editors.add(self.admin_user) self.channel.published_data = { "key1": "value1", @@ -1270,13 +1272,22 @@ def test_get_version_detail__is_editor(self): self.client.force_authenticate(user=self.editor_user) - # Create a ChannelVersion and set it as version_info - channel_version = ChannelVersion.objects.create( + self.channel.version = 1 + self.channel.save() + + channel_version, created = ChannelVersion.objects.get_or_create( channel=self.channel, version=1, - resource_count=100, - size=1024000, - ) + defaults={ + "resource_count": 100, + "size": 1024000, + } + ) + if not created: + channel_version.resource_count = 100 + channel_version.size = 1024000 + channel_version.save() + self.channel.version_info = channel_version self.channel.save() @@ -1287,7 +1298,6 @@ def test_get_version_detail__is_editor(self): self.assertEqual(response.status_code, 200, response.content) data = response.json() - # Assert against the most recent ChannelVersion self.assertEqual(data["version"], 1) self.assertEqual(data["resource_count"], 100) self.assertEqual(data["size"], 1024000) @@ -1298,13 +1308,22 @@ def test_get_version_detail__is_admin(self): self.client.force_authenticate(user=self.admin_user) - # Create a ChannelVersion and set it as version_info - channel_version = ChannelVersion.objects.create( + self.channel.version = 2 + self.channel.save() + + channel_version, created = ChannelVersion.objects.get_or_create( channel=self.channel, version=2, - resource_count=200, - size=2048000, - ) + defaults={ + "resource_count": 200, + "size": 2048000, + } + ) + if not created: + channel_version.resource_count = 200 + channel_version.size = 2048000 + channel_version.save() + self.channel.version_info = channel_version self.channel.save() From 333963334e37a055ae34732bedb6538da48f2bba Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 23:08:46 +0000 Subject: [PATCH 12/22] [pre-commit.ci lite] apply automatic fixes --- .../contentcuration/tests/viewsets/test_channel.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 794916244c..2daa66227c 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1281,13 +1281,13 @@ def test_get_version_detail__is_editor(self): defaults={ "resource_count": 100, "size": 1024000, - } + }, ) if not created: channel_version.resource_count = 100 channel_version.size = 1024000 channel_version.save() - + self.channel.version_info = channel_version self.channel.save() @@ -1317,13 +1317,13 @@ def test_get_version_detail__is_admin(self): defaults={ "resource_count": 200, "size": 2048000, - } + }, ) if not created: channel_version.resource_count = 200 channel_version.size = 2048000 channel_version.save() - + self.channel.version_info = channel_version self.channel.save() From 2a4e5efcc2041f77049e5d83ae6b9fa58bd9221b Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 18:46:11 -0800 Subject: [PATCH 13/22] fix bug --- contentcuration/contentcuration/utils/publish.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index bb9b4fe17d..8366109e59 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -214,7 +214,7 @@ def create_kolibri_license_object(ccnode): def increment_channel_version(channel): channel.version += 1 - channel.save() + ccmodels.Channel.objects.filter(pk=channel.pk).update(version=channel.version) def create_draft_channel_version(channel): @@ -1026,7 +1026,13 @@ def fill_published_fields(channel, version_notes): else: channel.version_info.special_permissions_included.clear() - channel.save() + ccmodels.Channel.objects.filter(pk=channel.pk).update( + last_published=channel.last_published, + total_resource_count=channel.total_resource_count, + published_kind_count=channel.published_kind_count, + published_size=channel.published_size, + published_data=channel.published_data, + ) def sync_contentnode_and_channel_tsvectors(channel_id): From 4b73ac17ed8def235a18cae4c7d718f2c72fd259 Mon Sep 17 00:00:00 2001 From: taoerman Date: Thu, 4 Dec 2025 19:23:48 -0800 Subject: [PATCH 14/22] fix bug --- .../contentcuration/utils/publish.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 8366109e59..678a0fc874 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -214,7 +214,16 @@ def create_kolibri_license_object(ccnode): def increment_channel_version(channel): channel.version += 1 - ccmodels.Channel.objects.filter(pk=channel.pk).update(version=channel.version) + + new_version_info, created = ccmodels.ChannelVersion.objects.get_or_create( + channel=channel, version=channel.version + ) + + ccmodels.Channel.objects.filter(pk=channel.pk).update( + version=channel.version, + version_info=new_version_info + ) + def create_draft_channel_version(channel): @@ -1026,13 +1035,7 @@ def fill_published_fields(channel, version_notes): else: channel.version_info.special_permissions_included.clear() - ccmodels.Channel.objects.filter(pk=channel.pk).update( - last_published=channel.last_published, - total_resource_count=channel.total_resource_count, - published_kind_count=channel.published_kind_count, - published_size=channel.published_size, - published_data=channel.published_data, - ) + channel.save() def sync_contentnode_and_channel_tsvectors(channel_id): From 33e039183aa4c8b06af3cacb2ed2f2860033138c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:26:36 +0000 Subject: [PATCH 15/22] [pre-commit.ci lite] apply automatic fixes --- contentcuration/contentcuration/utils/publish.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 678a0fc874..2b799da371 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -218,12 +218,10 @@ def increment_channel_version(channel): new_version_info, created = ccmodels.ChannelVersion.objects.get_or_create( channel=channel, version=channel.version ) - + ccmodels.Channel.objects.filter(pk=channel.pk).update( - version=channel.version, - version_info=new_version_info + version=channel.version, version_info=new_version_info ) - def create_draft_channel_version(channel): From e355fb1c899cc8a71d2c908e362fdf8369c68da1 Mon Sep 17 00:00:00 2001 From: taoerman Date: Fri, 5 Dec 2025 12:44:47 -0800 Subject: [PATCH 16/22] fix code --- .../index.vue | 22 +- contentcuration/contentcuration/models.py | 25 ++- .../contentcuration/tests/test_asynctask.py | 8 +- .../contentcuration/tests/test_models.py | 198 ++++++++++++++++++ .../tests/viewsets/test_channel.py | 73 ++++++- contentcuration/contentcuration/urls.py | 6 + .../utils/audit_channel_licenses.py | 4 +- .../contentcuration/utils/publish.py | 14 +- .../contentcuration/viewsets/channel.py | 50 +++-- 9 files changed, 347 insertions(+), 53 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 0fda8749af..585af061b5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -383,25 +383,21 @@ const { isLoading: publishedDataIsLoading, isFinished: publishedDataIsFinished, - data: publishedData, + data: versionDetail, fetchData: fetchPublishedData, } = usePublishedData(props.channel.id); - // Use the latest version available from either channel or publishedData + // Use the latest version available from either channel or versionDetail const displayedVersion = computed(() => { const channelVersion = currentChannelVersion.value || 0; - if (publishedData.value && Object.keys(publishedData.value).length > 0) { - const publishedVersions = Object.keys(publishedData.value).map(v => parseInt(v, 10)); + if (versionDetail.value && Object.keys(versionDetail.value).length > 0) { + const publishedVersions = Object.keys(versionDetail.value).map(v => parseInt(v, 10)); const maxPublishedVersion = Math.max(...publishedVersions); return Math.max(channelVersion, maxPublishedVersion); } return channelVersion; }); - const latestPublishedData = computed(() => { - return publishedData.value; - }); - // Watch for when publishing completes - fetch publishedData to get the new version's data watch(isPublishing, async (newIsPublishing, oldIsPublishing) => { if (oldIsPublishing === true && newIsPublishing === false) { @@ -420,7 +416,7 @@ const detectedLanguages = computed(() => { // We need to filter out null values due to a backend bug // causing null values to sometimes be included in the list - const languageCodes = latestPublishedData.value?.included_languages.filter( + const languageCodes = versionDetail.value?.included_languages.filter( code => code !== null, ); @@ -445,10 +441,10 @@ // not used in the UI and is mostly intended to convey the // state more accurately to the developer in case of debugging. // UI code should rely on XXXIsLoading and XXXIsFinished instead. - if (!latestPublishedData.value?.included_categories) return undefined; - if (latestPublishedData.value.included_categories.length === 0) return null; + if (!versionDetail.value?.included_categories) return undefined; + if (versionDetail.value.included_categories.length === 0) return null; - return latestPublishedData.value.included_categories + return versionDetail.value.included_categories .map(categoryId => categoryIdToName(categoryId)) .join(', '); }); @@ -474,7 +470,7 @@ description: description.value, channel: props.channel.id, countries: countries.value.map(country => countriesUtil.getAlpha2Code(country, 'en')), - categories: latestPublishedData.value.included_categories, + categories: versionDetail.value.included_categories, }) .then(() => { showSnackbar({ text: submittedSnackbar$() }); diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index a0afea75a0..5b542f80b5 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1367,29 +1367,35 @@ class Meta: KIND_COUNT_ITEM_SCHEMA = { "type": "object", - "required": ["count", "kind"], + "required": ["count", "kind_id"], "properties": { "count": {"type": "integer", "minimum": 0}, - "kind": {"type": "string", "minLength": 1}, + "kind_id": {"type": "string", "minLength": 1}, }, "additionalProperties": False, } def validate_kind_count_item(value): - try: - jsonschema.validate(instance=value, schema=KIND_COUNT_ITEM_SCHEMA) - except jsonschema.ValidationError as e: - raise ValidationError(str(e)) + """ + Validator for kind_count items. + """ + for item in value: + try: + jsonschema.validate(instance=item, schema=KIND_COUNT_ITEM_SCHEMA) + except jsonschema.ValidationError as e: + raise ValidationError(f"Invalid kind_count item: {str(e)}") def validate_language_code(value): """ Validator for language codes in included_languages array. """ - valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST] - if value not in valid_language_codes: - raise ValidationError(f"'{value}' is not a valid language code") + valid_language_codes = [lang.code for lang in languages.LANGUAGELIST] + for code in value: + if code not in valid_language_codes: + raise ValidationError(f"'{code}' is not a valid language code") + return def get_license_choices(): @@ -1456,6 +1462,7 @@ class Meta: def save(self, *args, **kwargs): if self.version is not None and self.version > self.channel.version: raise ValidationError("Version cannot be greater than channel version") + self.full_clean() super(ChannelVersion, self).save(*args, **kwargs) def new_token(self): diff --git a/contentcuration/contentcuration/tests/test_asynctask.py b/contentcuration/contentcuration/tests/test_asynctask.py index 9757d05b63..d47ba195f6 100644 --- a/contentcuration/contentcuration/tests/test_asynctask.py +++ b/contentcuration/contentcuration/tests/test_asynctask.py @@ -357,10 +357,10 @@ def test_audit_licenses_task__no_invalid_or_special_permissions( self.assertIn("included_licenses", published_data_version) self.assertIsNone( - published_data_version.get("community_library_invalid_licenses") + published_data_version.get("non_distributable_licenses_included") ) self.assertIsNone( - published_data_version.get("community_library_special_permissions") + published_data_version.get("special_permissions_included") ) @patch("contentcuration.utils.audit_channel_licenses.KolibriContentNode") @@ -414,7 +414,7 @@ def test_audit_licenses_task__with_all_rights_reserved( published_data_version = self.channel.published_data[version_str] self.assertEqual( - published_data_version.get("community_library_invalid_licenses"), + published_data_version.get("non_distributable_licenses_included"), [all_rights_license.id], ) @@ -494,7 +494,7 @@ def test_audit_licenses_task__with_special_permissions( published_data_version = self.channel.published_data[version_str] special_perms = published_data_version.get( - "community_library_special_permissions" + "special_permissions_included" ) self.assertIsNotNone(special_perms) self.assertEqual(len(special_perms), 2) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index 5c5ec67360..82eec285b8 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -10,6 +10,10 @@ from django.utils import timezone from le_utils.constants import content_kinds from le_utils.constants import format_presets +from le_utils.constants import languages +from le_utils.constants.labels import subjects +from django.core.management import call_command +from contentcuration.models import License from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission @@ -31,6 +35,7 @@ from contentcuration.models import object_storage_name from contentcuration.models import RecommendationsEvent from contentcuration.models import RecommendationsInteractionEvent +from contentcuration.models import ChannelVersion from contentcuration.models import User from contentcuration.models import UserHistory from contentcuration.tests import testdata @@ -1667,3 +1672,196 @@ def test_audited_special_permissions_license_str(self): ) self.assertEqual(len(str(audited_license2)), 100) self.assertEqual(str(audited_license2), "A" * 100) + + +class ChannelVersionValidationTestCase(StudioTestCase): + """Test validations for ChannelVersion model.""" + + def setUp(self): + super().setUp() + self.channel = testdata.channel() + self.channel.version = 10 + self.channel.save() + + def test_kind_count_valid_schema(self): + """Test that kind_count accepts valid schema.""" + valid_kind_count = [{"kind_id": "video", "count": 5}] + cv = ChannelVersion( + channel=self.channel, + version=1, + kind_count=valid_kind_count, + ) + cv.full_clean() + cv.save() + self.assertEqual(cv.kind_count, valid_kind_count) + + valid_kind_count_multi = [ + {"kind_id": "video", "count": 5}, + {"kind_id": "exercise", "count": 10}, + ] + cv2 = ChannelVersion( + channel=self.channel, + version=2, + kind_count=valid_kind_count_multi, + ) + cv2.full_clean() + cv2.save() + self.assertEqual(cv2.kind_count, valid_kind_count_multi) + + def test_kind_count_invalid_schema_missing_required_fields(self): + """Test that kind_count rejects items missing required fields.""" + invalid_kind_count = [{"kind_id": "video"}] + cv = ChannelVersion( + channel=self.channel, + version=1, + kind_count=invalid_kind_count, + ) + with self.assertRaises(ValidationError): + cv.full_clean() + + def test_kind_count_invalid_schema_negative_count(self): + """Test that kind_count rejects negative counts.""" + invalid_kind_count = [{"kind_id": "video", "count": -1}] + cv = ChannelVersion( + channel=self.channel, + version=1, + kind_count=invalid_kind_count, + ) + with self.assertRaises(ValidationError): + cv.full_clean() + + def test_kind_count_invalid_schema_additional_properties(self): + """Test that kind_count rejects items with additional properties.""" + invalid_kind_count = [{"kind_id": "video", "count": 5, "extra": "field"}] + cv = ChannelVersion( + channel=self.channel, + version=1, + kind_count=invalid_kind_count, + ) + with self.assertRaises(ValidationError): + cv.full_clean() + + def test_included_languages_valid_codes(self): + """Test that included_languages accepts valid language codes.""" + valid_language_code = 'en' + + cv = ChannelVersion( + channel=self.channel, + version=1, + included_languages=[valid_language_code], + ) + cv.full_clean() + cv.save() + self.assertEqual(cv.included_languages, [valid_language_code]) + + def test_included_languages_invalid_code(self): + """Test that included_languages rejects invalid language codes.""" + invalid_language_code = "invalid_lang_code" + cv = ChannelVersion( + channel=self.channel, + version=1, + included_languages=[invalid_language_code], + ) + with self.assertRaises(ValidationError) as context: + cv.full_clean() + self.assertIn(invalid_language_code, str(context.exception)) + + def test_included_licenses_valid_choices(self): + """Test that included_licenses accepts valid license IDs.""" + from django.core.management import call_command + from contentcuration.models import License + + call_command("loadconstants") + valid_license = License.objects.first() + self.assertIsNotNone(valid_license, "No licenses found. Ensure loadconstants has been run.") + cv = ChannelVersion( + channel=self.channel, + version=1, + included_licenses=[valid_license.id], + ) + cv.full_clean() + cv.save() + self.assertEqual(cv.included_licenses, [valid_license.id]) + + def test_included_licenses_invalid_choice(self): + """Test that included_licenses rejects invalid license IDs.""" + invalid_license_id = 99999 + cv = ChannelVersion( + channel=self.channel, + version=1, + included_licenses=[invalid_license_id], + ) + with self.assertRaises(ValidationError): + cv.full_clean() + + def test_included_categories_valid_choices(self): + """Test that included_categories accepts valid category names.""" + valid_category = subjects.SUBJECTSLIST[0] + cv = ChannelVersion( + channel=self.channel, + version=1, + included_categories=[valid_category], + ) + cv.full_clean() + cv.save() + self.assertEqual(cv.included_categories, [valid_category]) + + def test_included_categories_invalid_choice(self): + """Test that included_categories rejects invalid category names.""" + invalid_category = "InvalidCategory" + cv = ChannelVersion( + channel=self.channel, + version=1, + included_categories=[invalid_category], + ) + with self.assertRaises(ValidationError): + cv.full_clean() + + def test_non_distributable_licenses_included_valid_choices(self): + """Test that non_distributable_licenses_included accepts valid license IDs.""" + + call_command("loadconstants") + valid_license = License.objects.first() + self.assertIsNotNone(valid_license, "No licenses found. Ensure loadconstants has been run.") + cv = ChannelVersion( + channel=self.channel, + version=1, + non_distributable_licenses_included=[valid_license.id], + ) + cv.full_clean() + cv.save() + self.assertEqual( + cv.non_distributable_licenses_included, [valid_license.id] + ) + + def test_non_distributable_licenses_included_invalid_choice(self): + """Test that non_distributable_licenses_included rejects invalid license IDs.""" + invalid_license_id = 99999 + cv = ChannelVersion( + channel=self.channel, + version=1, + non_distributable_licenses_included=[invalid_license_id], + ) + with self.assertRaises(ValidationError): + cv.full_clean() + + def test_full_clean_called_on_save(self): + """Test that full_clean is automatically called on save.""" + invalid_language_code = "invalid_lang_code" + cv = ChannelVersion( + channel=self.channel, + version=1, + included_languages=[invalid_language_code], + ) + with self.assertRaises(ValidationError): + cv.save() + + def test_version_cannot_exceed_channel_version(self): + """Test that version cannot be greater than channel version.""" + cv = ChannelVersion( + channel=self.channel, + version=11, + ) + with self.assertRaises(ValidationError) as context: + cv.save() + self.assertIn("Version cannot be greater than channel version", str(context.exception)) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 2daa66227c..2c109ea16a 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -6,6 +6,7 @@ from django.urls import reverse from kolibri_public.models import ContentNode as PublicContentNode from le_utils.constants import content_kinds +from le_utils.constants.labels import subjects from mock import Mock from mock import patch @@ -13,6 +14,7 @@ from contentcuration import models as cc from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission +from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import ChannelVersion @@ -1487,12 +1489,12 @@ def setUp(self): "size": 5000, "resource_count": 25, "kind_count": [ - {"count": 10, "kind": "video"}, - {"count": 15, "kind": "document"}, + {"count": 10, "kind_id": "video"}, + {"count": 15, "kind_id": "document"}, ], "included_languages": ["en", "es"], "included_licenses": [1, 2], - "included_categories": ["math", "science"], + "included_categories": [subjects.SUBJECTSLIST[0], subjects.SUBJECTSLIST[1]], }, ) @@ -1528,3 +1530,68 @@ def test_get_version_detail_no_version_info(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {}) + + def test_get_version_detail_returns_all_fields(self): + """Test that get_version_detail returns all expected fields.""" + url = reverse("channel-version-detail", kwargs={"pk": self.channel.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + + expected_fields = [ + "id", + "version", + "resource_count", + "kind_count", + "size", + "date_published", + "version_notes", + "included_languages", + "included_licenses", + "included_categories", + "non_distributable_licenses_included", + ] + for field in expected_fields: + self.assertIn(field, data, f"Field '{field}' should be in response") + + def test_get_version_detail_excludes_special_permissions_included(self): + """Test that special_permissions_included is not in the response.""" + special_license = AuditedSpecialPermissionsLicense.objects.create( + description="Test special permissions license" + ) + self.channel_version.special_permissions_included.add(special_license) + + url = reverse("channel-version-detail", kwargs={"pk": self.channel.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertNotIn( + "special_permissions_included", + data, + "special_permissions_included should not be in the response. " + "Use /api/audited_special_permissions_license/?channel_version= instead.", + ) + + def test_get_version_detail_with_special_permissions_licenses(self): + """Test that get_version_detail works correctly even when special permissions licenses exist.""" + license1 = AuditedSpecialPermissionsLicense.objects.create( + description="First special permissions license" + ) + license2 = AuditedSpecialPermissionsLicense.objects.create( + description="Second special permissions license" + ) + + self.channel_version.special_permissions_included.add(license1, license2) + + url = reverse("channel-version-detail", kwargs={"pk": self.channel.id}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertNotIn("special_permissions_included", data) + self.assertEqual(data["version"], 3) + self.assertEqual(data["resource_count"], 25) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 7f47857f8e..ef6ddfed93 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -36,6 +36,7 @@ from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import AdminChannelViewSet +from contentcuration.viewsets.channel import AuditedSpecialPermissionsLicenseViewSet from contentcuration.viewsets.channel import CatalogViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet @@ -69,6 +70,11 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) +router.register( + r"audited_special_permissions_license", + AuditedSpecialPermissionsLicenseViewSet, + basename="audited-special-permissions-license", +) router.register(r"bookmark", BookmarkViewSet, basename="bookmark") router.register(r"channel", ChannelViewSet) router.register(r"channelset", ChannelSetViewSet) diff --git a/contentcuration/contentcuration/utils/audit_channel_licenses.py b/contentcuration/contentcuration/utils/audit_channel_licenses.py index 571087d433..fa201e8a5f 100644 --- a/contentcuration/contentcuration/utils/audit_channel_licenses.py +++ b/contentcuration/contentcuration/utils/audit_channel_licenses.py @@ -151,10 +151,10 @@ def _save_audit_results( user_id, ): """Save audit results to published_data and create change event.""" - published_data_version["community_library_invalid_licenses"] = ( + published_data_version["non_distributable_licenses_included"] = ( invalid_license_ids if invalid_license_ids else None ) - published_data_version["community_library_special_permissions"] = ( + published_data_version["special_permissions_included"] = ( special_permissions_license_ids if special_permissions_license_ids else None ) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 678a0fc874..542da6037e 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -214,15 +214,8 @@ def create_kolibri_license_object(ccnode): def increment_channel_version(channel): channel.version += 1 - - new_version_info, created = ccmodels.ChannelVersion.objects.get_or_create( - channel=channel, version=channel.version - ) + channel.save() - ccmodels.Channel.objects.filter(pk=channel.pk).update( - version=channel.version, - version_info=new_version_info - ) @@ -926,7 +919,9 @@ def fill_published_fields(channel, version_notes): node_languages = published_nodes.exclude(language=None).values_list( "language", flat=True ) - file_languages = published_nodes.values_list("files__language", flat=True) + file_languages = published_nodes.exclude(files__language=None).values_list( + "files__language", flat=True + ) language_list = list(set(chain(node_languages, file_languages))) for lang in language_list: @@ -991,6 +986,7 @@ def fill_published_fields(channel, version_notes): .first() ) + special_perms_descriptions = None if special_permissions_id and special_permissions_id in license_list: special_perms_descriptions = list( published_nodes.filter(license_id=special_permissions_id) diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 34bb87a681..23144f65b5 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -22,6 +22,9 @@ from django_cte import With from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import CharFilter +from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework import FilterSet +from django_filters.rest_framework import UUIDFilter from kolibri_public.utils.export_channel_to_kolibri_public import ( export_channel_to_kolibri_public, ) @@ -48,6 +51,7 @@ ) from contentcuration.decorators import cache_no_user_data from contentcuration.models import Change +from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Channel from contentcuration.models import ChannelVersion from contentcuration.models import CommunityLibrarySubmission @@ -923,19 +927,6 @@ def get_version_detail(self, request, pk=None) -> Response: if not version_data: return Response({}) - version_data["id"] = str(version_data["id"]) - - if version_data["date_published"]: - version_data["date_published"] = version_data["date_published"].strftime( - settings.DATE_TIME_FORMAT - ) - - version_data["special_permissions_included"] = list( - channel.version_info.special_permissions_included.values_list( - "id", flat=True - ) - ) - return Response(version_data) @action( @@ -1339,3 +1330,36 @@ class Meta: model = Channel fields = ("id", "name", "editor_count", "public") read_only_fields = ("id", "name", "editor_count", "public") + + +class AuditedSpecialPermissionsLicenseFilter(FilterSet): + """Filter for AuditedSpecialPermissionsLicense by channelVersion.""" + + channel_version = UUIDFilter( + field_name="channel_versions__id", + help_text="Filter by ChannelVersion ID", + ) + + class Meta: + model = AuditedSpecialPermissionsLicense + fields = ["channel_version"] + + +class AuditedSpecialPermissionsLicenseViewSet(ReadOnlyValuesViewset): + """ + ViewSet for retrieving AuditedSpecialPermissionsLicense objects. + Supports filtering by channelVersion to get licenses for a specific channel version. + """ + + queryset = AuditedSpecialPermissionsLicense.objects.all() + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_class = AuditedSpecialPermissionsLicenseFilter + + values = ("id", "description", "distributable") + + field_map = { + "id": "id", + "description": "description", + "distributable": "distributable", + } From b213fd03ca1701b112b29a113c533f3c4b62bbe5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:53:12 +0000 Subject: [PATCH 17/22] [pre-commit.ci lite] apply automatic fixes --- .../index.vue | 4 +-- contentcuration/contentcuration/models.py | 2 +- .../contentcuration/tests/test_asynctask.py | 8 ++--- .../contentcuration/tests/test_models.py | 32 +++++++++++-------- .../tests/viewsets/test_channel.py | 5 ++- .../contentcuration/viewsets/channel.py | 2 +- 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 92c9686d64..3986898275 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -490,9 +490,7 @@ const detectedLanguages = computed(() => { // We need to filter out null values due to a backend bug // causing null values to sometimes be included in the list - const languageCodes = versionDetail.value?.included_languages.filter( - code => code !== null, - ); + const languageCodes = versionDetail.value?.included_languages.filter(code => code !== null); // We distinguish here between "not loaded yet" (undefined) // and "loaded and none present" (null). This distinction is diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 5b542f80b5..dfd9fd12d1 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -1378,7 +1378,7 @@ class Meta: def validate_kind_count_item(value): """ - Validator for kind_count items. + Validator for kind_count items. """ for item in value: try: diff --git a/contentcuration/contentcuration/tests/test_asynctask.py b/contentcuration/contentcuration/tests/test_asynctask.py index d47ba195f6..80d027bd04 100644 --- a/contentcuration/contentcuration/tests/test_asynctask.py +++ b/contentcuration/contentcuration/tests/test_asynctask.py @@ -359,9 +359,7 @@ def test_audit_licenses_task__no_invalid_or_special_permissions( self.assertIsNone( published_data_version.get("non_distributable_licenses_included") ) - self.assertIsNone( - published_data_version.get("special_permissions_included") - ) + self.assertIsNone(published_data_version.get("special_permissions_included")) @patch("contentcuration.utils.audit_channel_licenses.KolibriContentNode") @patch( @@ -493,9 +491,7 @@ def test_audit_licenses_task__with_special_permissions( version_str = str(self.channel.version) published_data_version = self.channel.published_data[version_str] - special_perms = published_data_version.get( - "special_permissions_included" - ) + special_perms = published_data_version.get("special_permissions_included") self.assertIsNotNone(special_perms) self.assertEqual(len(special_perms), 2) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index 82eec285b8..60578b1187 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError +from django.core.management import call_command from django.db.models import Q from django.db.utils import IntegrityError from django.utils import timezone @@ -12,8 +13,6 @@ from le_utils.constants import format_presets from le_utils.constants import languages from le_utils.constants.labels import subjects -from django.core.management import call_command -from contentcuration.models import License from contentcuration.constants import channel_history from contentcuration.constants import community_library_submission @@ -24,6 +23,7 @@ from contentcuration.models import Channel from contentcuration.models import ChannelHistory from contentcuration.models import ChannelSet +from contentcuration.models import ChannelVersion from contentcuration.models import CommunityLibrarySubmission from contentcuration.models import ContentNode from contentcuration.models import CONTENTNODE_TREE_ID_CACHE_KEY @@ -32,10 +32,10 @@ from contentcuration.models import FlagFeedbackEvent from contentcuration.models import generate_object_storage_name from contentcuration.models import Invitation +from contentcuration.models import License from contentcuration.models import object_storage_name from contentcuration.models import RecommendationsEvent from contentcuration.models import RecommendationsInteractionEvent -from contentcuration.models import ChannelVersion from contentcuration.models import User from contentcuration.models import UserHistory from contentcuration.tests import testdata @@ -1694,7 +1694,7 @@ def test_kind_count_valid_schema(self): cv.full_clean() cv.save() self.assertEqual(cv.kind_count, valid_kind_count) - + valid_kind_count_multi = [ {"kind_id": "video", "count": 5}, {"kind_id": "exercise", "count": 10}, @@ -1710,7 +1710,7 @@ def test_kind_count_valid_schema(self): def test_kind_count_invalid_schema_missing_required_fields(self): """Test that kind_count rejects items missing required fields.""" - invalid_kind_count = [{"kind_id": "video"}] + invalid_kind_count = [{"kind_id": "video"}] cv = ChannelVersion( channel=self.channel, version=1, @@ -1743,8 +1743,8 @@ def test_kind_count_invalid_schema_additional_properties(self): def test_included_languages_valid_codes(self): """Test that included_languages accepts valid language codes.""" - valid_language_code = 'en' - + valid_language_code = "en" + cv = ChannelVersion( channel=self.channel, version=1, @@ -1773,7 +1773,9 @@ def test_included_licenses_valid_choices(self): call_command("loadconstants") valid_license = License.objects.first() - self.assertIsNotNone(valid_license, "No licenses found. Ensure loadconstants has been run.") + self.assertIsNotNone( + valid_license, "No licenses found. Ensure loadconstants has been run." + ) cv = ChannelVersion( channel=self.channel, version=1, @@ -1822,7 +1824,9 @@ def test_non_distributable_licenses_included_valid_choices(self): call_command("loadconstants") valid_license = License.objects.first() - self.assertIsNotNone(valid_license, "No licenses found. Ensure loadconstants has been run.") + self.assertIsNotNone( + valid_license, "No licenses found. Ensure loadconstants has been run." + ) cv = ChannelVersion( channel=self.channel, version=1, @@ -1830,9 +1834,7 @@ def test_non_distributable_licenses_included_valid_choices(self): ) cv.full_clean() cv.save() - self.assertEqual( - cv.non_distributable_licenses_included, [valid_license.id] - ) + self.assertEqual(cv.non_distributable_licenses_included, [valid_license.id]) def test_non_distributable_licenses_included_invalid_choice(self): """Test that non_distributable_licenses_included rejects invalid license IDs.""" @@ -1860,8 +1862,10 @@ def test_version_cannot_exceed_channel_version(self): """Test that version cannot be greater than channel version.""" cv = ChannelVersion( channel=self.channel, - version=11, + version=11, ) with self.assertRaises(ValidationError) as context: cv.save() - self.assertIn("Version cannot be greater than channel version", str(context.exception)) + self.assertIn( + "Version cannot be greater than channel version", str(context.exception) + ) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 2c109ea16a..4b32ccdbe9 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -1494,7 +1494,10 @@ def setUp(self): ], "included_languages": ["en", "es"], "included_licenses": [1, 2], - "included_categories": [subjects.SUBJECTSLIST[0], subjects.SUBJECTSLIST[1]], + "included_categories": [ + subjects.SUBJECTSLIST[0], + subjects.SUBJECTSLIST[1], + ], }, ) diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 23144f65b5..6fe66a8759 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -50,8 +50,8 @@ community_library_submission as community_library_submission_constants, ) from contentcuration.decorators import cache_no_user_data -from contentcuration.models import Change from contentcuration.models import AuditedSpecialPermissionsLicense +from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import ChannelVersion from contentcuration.models import CommunityLibrarySubmission From 609e651f7ada1b5333fc2bd818e2fe461dc48c53 Mon Sep 17 00:00:00 2001 From: taoerman Date: Sat, 6 Dec 2025 10:36:58 -0800 Subject: [PATCH 18/22] fix code --- .../composables/useLicenseAudit.js | 9 +++-- .../composables/useSpecialPermissions.js | 40 +++++++++---------- .../index.vue | 22 +++++----- .../licenseCheck/SpecialPermissionsList.vue | 7 +++- .../tests/utils/test_publish.py | 18 ++++----- contentcuration/contentcuration/urls.py | 6 --- .../audited_special_permissions_license.py | 11 +++-- .../contentcuration/viewsets/channel.py | 37 ----------------- 8 files changed, 53 insertions(+), 97 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js index ea4cc092d4..d0ce03195e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js @@ -36,19 +36,19 @@ export function useLicenseAudit(channelRef, channelVersionRef) { } return ( - 'community_library_invalid_licenses' in versionData && - 'community_library_special_permissions' in versionData + 'non_distributable_licenses_included' in versionData && + 'special_permissions_included' in versionData ); }); const invalidLicenses = computed(() => { const versionData = currentVersionData.value; - return versionData?.community_library_invalid_licenses || []; + return versionData?.non_distributable_licenses_included || []; }); const specialPermissions = computed(() => { const versionData = currentVersionData.value; - return versionData?.community_library_special_permissions || []; + return versionData?.special_permissions_included || []; }); const includedLicenses = computed(() => { @@ -123,5 +123,6 @@ export function useLicenseAudit(channelRef, channelVersionRef) { checkAndTriggerAudit, triggerAudit, fetchPublishedData, + currentVersionData, }; } diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js index e8338b5709..977d26f10e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js @@ -23,7 +23,7 @@ const ITEMS_PER_PAGE = 3; * Reactive state for the fetched, flattened permissions and pagination * helpers used by `SpecialPermissionsList.vue`. */ -export function useSpecialPermissions(permissionIds) { +export function useSpecialPermissions(channelVersionId, permissionIds) { const permissions = ref([]); const isLoading = ref(false); const error = ref(null); @@ -39,20 +39,24 @@ export function useSpecialPermissions(permissionIds) { return permissions.value.slice(start, end); }); - async function fetchPermissions(ids) { - if (!ids || ids.length === 0) { - permissions.value = []; - return; - } - + async function fetchPermissions(versionId, ids) { isLoading.value = true; error.value = null; + permissions.value = []; try { - const response = await AuditedSpecialPermissionsLicense.fetchCollection({ - by_ids: ids.join(','), - distributable: false, - }); + let response = []; + if (versionId) { + response = await AuditedSpecialPermissionsLicense.fetchCollection({ + channel_version: versionId, + distributable: false, + }); + } else if (ids && ids.length > 0) { + response = await AuditedSpecialPermissionsLicense.fetchCollection({ + by_ids: ids.join(','), + distributable: false, + }); + } permissions.value = response.map(permission => ({ id: permission.id, @@ -79,18 +83,10 @@ export function useSpecialPermissions(permissionIds) { } } - const resolvedPermissionIds = computed(() => { - const ids = unref(permissionIds); - if (!ids || ids.length === 0) { - return []; - } - return ids; - }); - watch( - resolvedPermissionIds, - ids => { - fetchPermissions(ids); + [() => unref(channelVersionId), () => unref(permissionIds)], + ([versionId, ids]) => { + fetchPermissions(versionId, ids); }, { immediate: true }, ); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 3986898275..2f5055d940 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -163,7 +163,8 @@
@@ -424,10 +425,8 @@ // Use the latest version available from either channel or versionDetail const displayedVersion = computed(() => { const channelVersion = currentChannelVersion.value || 0; - if (versionDetail.value && Object.keys(versionDetail.value).length > 0) { - const publishedVersions = Object.keys(versionDetail.value).map(v => parseInt(v, 10)); - const maxPublishedVersion = Math.max(...publishedVersions); - return Math.max(channelVersion, maxPublishedVersion); + if (versionDetail.value && versionDetail.value.version) { + return Math.max(channelVersion, versionDetail.value.version); } return channelVersion; }); @@ -439,6 +438,7 @@ specialPermissions, includedLicenses, checkAndTriggerAudit: checkAndTriggerLicenseAudit, + currentVersionData, } = useLicenseAudit(props.channel, currentChannelVersion); const allSpecialPermissionsChecked = ref(true); @@ -465,11 +465,6 @@ return conditions.every(condition => condition); }); - const latestPublishedData = computed(() => { - if (!publishedData.value || !displayedVersion.value) return undefined; - return publishedData.value[displayedVersion.value]; - }); - // Watch for when publishing completes - fetch publishedData to get the new version's data watch(isPublishing, async (newIsPublishing, oldIsPublishing) => { if (oldIsPublishing === true && newIsPublishing === false) { @@ -483,14 +478,15 @@ if (!isPublishing.value) { await fetchPublishedData(); + console.log('versionDetail:', versionDetail.value); await checkAndTriggerLicenseAudit(); + console.log('specialPermissions:', specialPermissions.value); + console.log('currentVersionData:', currentVersionData.value); } }); const detectedLanguages = computed(() => { - // We need to filter out null values due to a backend bug - // causing null values to sometimes be included in the list - const languageCodes = versionDetail.value?.included_languages.filter(code => code !== null); + const languageCodes = versionDetail.value?.included_languages; // We distinguish here between "not loaded yet" (undefined) // and "loaded and none present" (null). This distinction is diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue index 3ad8fe8b22..5bea675d1f 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue @@ -87,7 +87,7 @@ totalPages, nextPage, previousPage, - } = useSpecialPermissions(props.permissionIds); + } = useSpecialPermissions(props.channelVersionId, props.permissionIds); function togglePermission(permissionId) { const currentChecked = [...props.value]; @@ -128,6 +128,11 @@ }; }, props: { + channelVersionId: { + type: String, + required: false, + default: null, + }, permissionIds: { type: Array, required: false, diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index af2552b104..1f2799df2a 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -104,11 +104,7 @@ class IncrementChannelVersionTestCase(StudioTestCase): def setUp(self): super().setUp() self.channel = testdata.channel() - self.channel.version = 1 - self.channel.save() - - ChannelVersion.objects.filter(channel=self.channel).delete() - self.channel.version_info = None + self.channel.version = 0 self.channel.save() def test_increment_published_version(self): @@ -155,12 +151,12 @@ def test_multiple_published_versions(self): self.channel.refresh_from_db() - self.assertEqual(self.channel.version, 4) + self.assertEqual(self.channel.version, 3) - self.assertEqual(self.channel.channel_versions.count(), 4) + self.assertEqual(self.channel.channel_versions.count(), 3) self.assertIsNotNone(self.channel.version_info) - self.assertEqual(self.channel.version_info.version, 4) + self.assertEqual(self.channel.version_info.version, 3) def test_mixed_draft_and_published_versions(self): """Test creating mix of draft and published versions.""" @@ -170,14 +166,14 @@ def test_mixed_draft_and_published_versions(self): self.channel.refresh_from_db() - self.assertEqual(self.channel.version, 2) + self.assertEqual(self.channel.version, 1) - self.assertEqual(self.channel.channel_versions.count(), 3) + self.assertEqual(self.channel.channel_versions.count(), 2) self.assertEqual(uuid.UUID(str(draft1.id)), uuid.UUID(str(draft2.id))) self.assertIsNotNone(draft1.secret_token) self.assertIsNotNone(draft2.secret_token) self.assertIsNotNone(self.channel.version_info) - self.assertEqual(self.channel.version_info.version, 2) + self.assertEqual(self.channel.version_info.version, 1) self.assertIsNone(self.channel.version_info.secret_token) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index c16a94fd7c..5bd9deb355 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -39,7 +39,6 @@ ) from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import AdminChannelViewSet -from contentcuration.viewsets.channel import AuditedSpecialPermissionsLicenseViewSet from contentcuration.viewsets.channel import CatalogViewSet from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet @@ -73,11 +72,6 @@ def get_redirect_url(self, *args, **kwargs): router = routers.DefaultRouter(trailing_slash=False) -router.register( - r"audited_special_permissions_license", - AuditedSpecialPermissionsLicenseViewSet, - basename="audited-special-permissions-license", -) router.register(r"bookmark", BookmarkViewSet, basename="bookmark") router.register(r"channel", ChannelViewSet) router.register(r"channelset", ChannelSetViewSet) diff --git a/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py b/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py index decdaf0773..df4a766971 100644 --- a/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py +++ b/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py @@ -1,6 +1,7 @@ from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import FilterSet +from django_filters.rest_framework import UUIDFilter from rest_framework.permissions import IsAuthenticated from contentcuration.models import AuditedSpecialPermissionsLicense @@ -11,15 +12,19 @@ class AuditedSpecialPermissionsLicenseFilter(FilterSet): """ Filter for AuditedSpecialPermissionsLicense viewset. - Supports filtering by IDs and distributable status. + Supports filtering by IDs, distributable status, and channelVersion. """ by_ids = UUIDInFilter(field_name="id") distributable = BooleanFilter() + channel_version = UUIDFilter( + field_name="channel_versions__id", + help_text="Filter by ChannelVersion ID", + ) class Meta: model = None - fields = ("by_ids", "distributable") + fields = ("by_ids", "distributable", "channel_version") def __init__(self, *args, **kwargs): @@ -30,7 +35,7 @@ def __init__(self, *args, **kwargs): class AuditedSpecialPermissionsLicenseViewSet(ReadOnlyValuesViewset): """ Read-only viewset for AuditedSpecialPermissionsLicense. - Allows filtering by IDs and distributable status. + Allows filtering by IDs, distributable status, and channelVersion. """ permission_classes = [IsAuthenticated] diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 6fe66a8759..a10a3eb04c 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -22,9 +22,6 @@ from django_cte import With from django_filters.rest_framework import BooleanFilter from django_filters.rest_framework import CharFilter -from django_filters.rest_framework import DjangoFilterBackend -from django_filters.rest_framework import FilterSet -from django_filters.rest_framework import UUIDFilter from kolibri_public.utils.export_channel_to_kolibri_public import ( export_channel_to_kolibri_public, ) @@ -50,7 +47,6 @@ community_library_submission as community_library_submission_constants, ) from contentcuration.decorators import cache_no_user_data -from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import ChannelVersion @@ -1330,36 +1326,3 @@ class Meta: model = Channel fields = ("id", "name", "editor_count", "public") read_only_fields = ("id", "name", "editor_count", "public") - - -class AuditedSpecialPermissionsLicenseFilter(FilterSet): - """Filter for AuditedSpecialPermissionsLicense by channelVersion.""" - - channel_version = UUIDFilter( - field_name="channel_versions__id", - help_text="Filter by ChannelVersion ID", - ) - - class Meta: - model = AuditedSpecialPermissionsLicense - fields = ["channel_version"] - - -class AuditedSpecialPermissionsLicenseViewSet(ReadOnlyValuesViewset): - """ - ViewSet for retrieving AuditedSpecialPermissionsLicense objects. - Supports filtering by channelVersion to get licenses for a specific channel version. - """ - - queryset = AuditedSpecialPermissionsLicense.objects.all() - permission_classes = [IsAuthenticated] - filter_backends = [DjangoFilterBackend] - filterset_class = AuditedSpecialPermissionsLicenseFilter - - values = ("id", "description", "distributable") - - field_map = { - "id": "id", - "description": "description", - "distributable": "distributable", - } From 9c1fa511617aa3465be67b822280c845a5379e40 Mon Sep 17 00:00:00 2001 From: taoerman Date: Sat, 6 Dec 2025 10:58:55 -0800 Subject: [PATCH 19/22] fix linting --- .../sidePanels/SubmitToCommunityLibrarySidePanel/index.vue | 6 +----- .../contentcuration/tests/test_channel_version.py | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 2f5055d940..6f21f15bcb 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -438,7 +438,6 @@ specialPermissions, includedLicenses, checkAndTriggerAudit: checkAndTriggerLicenseAudit, - currentVersionData, } = useLicenseAudit(props.channel, currentChannelVersion); const allSpecialPermissionsChecked = ref(true); @@ -478,10 +477,7 @@ if (!isPublishing.value) { await fetchPublishedData(); - console.log('versionDetail:', versionDetail.value); await checkAndTriggerLicenseAudit(); - console.log('specialPermissions:', specialPermissions.value); - console.log('currentVersionData:', currentVersionData.value); } }); @@ -496,7 +492,7 @@ if (!languageCodes) return undefined; if (languageCodes.length === 0) return null; - return languageCodes.map(code => LanguagesMap.get(code).readable_name).join(', '); + return languageCodes.map(code => LanguagesMap.get(code)?.readable_name || code).join(', '); }); function categoryIdToName(categoryId) { diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py index 275daf2fca..02d8d66a33 100644 --- a/contentcuration/contentcuration/tests/test_channel_version.py +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -1,6 +1,7 @@ from contentcuration.models import ChannelVersion from contentcuration.models import SecretToken from contentcuration.tests import testdata +from django.core.exceptions import ValidationError from contentcuration.tests.base import StudioTestCase @@ -51,9 +52,8 @@ def test_unique_constraint(self): channel=self.channel, version=1, ) - from django.db.utils import IntegrityError - with self.assertRaises(IntegrityError): + with self.assertRaises(ValidationError): ChannelVersion.objects.create( channel=self.channel, version=1, From a7c024524a216e8ea02810a49549394eb880be7e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 19:01:20 +0000 Subject: [PATCH 20/22] [pre-commit.ci lite] apply automatic fixes --- contentcuration/contentcuration/tests/test_channel_version.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/tests/test_channel_version.py b/contentcuration/contentcuration/tests/test_channel_version.py index 02d8d66a33..bc69c9c243 100644 --- a/contentcuration/contentcuration/tests/test_channel_version.py +++ b/contentcuration/contentcuration/tests/test_channel_version.py @@ -1,7 +1,8 @@ +from django.core.exceptions import ValidationError + from contentcuration.models import ChannelVersion from contentcuration.models import SecretToken from contentcuration.tests import testdata -from django.core.exceptions import ValidationError from contentcuration.tests.base import StudioTestCase From 458cc763a206d8cbb34ec654567dc07d95f7adaa Mon Sep 17 00:00:00 2001 From: taoerman Date: Sat, 6 Dec 2025 11:05:47 -0800 Subject: [PATCH 21/22] fix linting --- contentcuration/contentcuration/tests/test_models.py | 1 - contentcuration/contentcuration/tests/utils/test_publish.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index 60578b1187..f0baf68855 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -11,7 +11,6 @@ from django.utils import timezone from le_utils.constants import content_kinds from le_utils.constants import format_presets -from le_utils.constants import languages from le_utils.constants.labels import subjects from contentcuration.constants import channel_history diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index 1f2799df2a..43c5521b2e 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -5,7 +5,7 @@ from django.conf import settings -from contentcuration.models import ChannelVersion + from contentcuration.tests import testdata from contentcuration.tests.base import StudioTestCase from contentcuration.tests.utils.restricted_filesystemstorage import ( From a3c279887ccd15ee5c22dd53d18c99d4d4ccbdae Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 19:08:20 +0000 Subject: [PATCH 22/22] [pre-commit.ci lite] apply automatic fixes --- contentcuration/contentcuration/tests/utils/test_publish.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/tests/utils/test_publish.py b/contentcuration/contentcuration/tests/utils/test_publish.py index 43c5521b2e..81d1c23453 100644 --- a/contentcuration/contentcuration/tests/utils/test_publish.py +++ b/contentcuration/contentcuration/tests/utils/test_publish.py @@ -5,7 +5,6 @@ from django.conf import settings - from contentcuration.tests import testdata from contentcuration.tests.base import StudioTestCase from contentcuration.tests.utils.restricted_filesystemstorage import (