diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue index 92782fe904..6486230c77 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue @@ -15,9 +15,26 @@ class="box-content" >
- + +
+
+
+ {{ title }} +
+
+ {{ description }} +
+
-
{ switch (props.kind) { case 'warning': - return paletteTheme.yellow.v_100; + return paletteTheme.red.v_100; case 'info': return paletteTheme.grey.v_100; default: throw new Error(`Unsupported box kind: ${props.kind}`); } }); - const boxTextColor = computed(() => { + const boxBorderColor = computed(() => { switch (props.kind) { case 'warning': - return paletteTheme.red.v_500; + return paletteTheme.red.v_300; case 'info': - return tokensTheme.text; + return 'transparent'; default: - throw new Error(`Unsupported box kind: ${props.kind}`); + return 'transparent'; } }); const icon = computed(() => { switch (props.kind) { case 'warning': - return 'warningIncomplete'; + return 'error'; case 'info': return 'infoOutline'; default: @@ -73,9 +90,19 @@ } }); + const titleColor = computed(() => { + return props.kind === 'warning' ? paletteTheme.red.v_600 : tokensTheme.text; + }); + + const descriptionColor = computed(() => { + return props.kind === 'warning' ? paletteTheme.grey.v_800 : tokensTheme.text; + }); + return { boxBackgroundColor, - boxTextColor, + boxBorderColor, + titleColor, + descriptionColor, icon, }; }, @@ -91,6 +118,16 @@ required: false, default: false, }, + title: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: false, + default: '', + }, }, }; @@ -100,20 +137,46 @@ diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index 43762612be..6ef0ac77c7 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -43,6 +43,10 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: "You're publishing: Version {version}", context: 'Information about the version being published', }, + channelVersion: { + message: '{name} v{version}', + context: 'Formatted channel title that includes the channel name and its version;', + }, incompleteResourcesWarning: { message: '{count, number} {count, plural, one {incomplete resource} other {incomplete resources}}', @@ -70,7 +74,7 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'Error message when language selection is required', }, pendingStatus: { - message: 'Pending', + message: 'Submitted', context: 'Status indicating that an Community Library submission is pending', }, approvedStatus: { @@ -87,17 +91,14 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'The title of the "Submit to Community Library" panel', }, submittedPrimaryInfo: { - message: 'A previous version is still pending review.', + message: + 'A previous version is still pending review. Reviewers will see the latest submission first.', context: 'Information shown in the "Submit to Community Library" panel when a previous version is pending review', }, - reviewersWillSeeLatestFirst: { - message: 'Reviewers will see the latest submission first.', - context: - 'Information shown in the "Submit to Community Library" panel about how reviewers see submissions', - }, approvedPrimaryInfo: { - message: 'A previous version is live in the Community Library.', + message: + 'A previous version is live in the Community Library. Reviewers will see the latest submission first.', context: 'Information shown in the "Submit to Community Library" panel when a previous version is approved and live', }, @@ -113,12 +114,12 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin 'Information shown in the "Submit to Community Library" panel when there are no previous submissions', }, moreDetailsButton: { - message: 'More details about the Community Library', + message: 'More details', context: 'Button in the "Submit to Community Library" panel to show more details about the Community Library', }, lessDetailsButton: { - message: 'Show less', + message: 'Less details', context: 'Button in the "Submit to Community Library" panel to hide details about the Community Library', }, @@ -128,22 +129,36 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin context: 'Detailed description of the Community Library shown in the "Submit to Community Library" panel', }, - notPublishedWarning: { - message: - "This channel isn't published to Kolibri Studio yet. Publish first, then submit to the Community Library.", + notPublishedWarningTitle: { + message: "This channel isn't published to Kolibri Studio yet", context: - 'Warning shown in the "Submit to Community Library" panel when the channel is not published', + 'Title of warning shown in the "Submit to Community Library" panel when the channel is not published', }, - publicWarning: { - message: - 'This channel is currently public in the Content Library. It is not possible to submit public channels to the Community Library.', - context: 'Warning shown in the "Submit to Community Library" panel when the channel is public', + notPublishedWarningDescription: { + message: 'Publish to Studio first, then submit to the Community Library.', + context: + 'Description of warning shown in the "Submit to Community Library" panel when the channel is not published', }, - alreadySubmittedWarning: { + publicWarningTitle: { + message: 'This channel is currently public in the Content Library.', + context: + 'Title of warning shown in the "Submit to Community Library" panel when the channel is public', + }, + publicWarningDescription: { + message: 'It is not possible to submit public channels to the Community Library.', + context: + 'Description of warning shown in the "Submit to Community Library" panel when the channel is public', + }, + alreadySubmittedWarningTitle: { + message: 'This version of the channel has already been submitted to the Community Library.', + context: + 'Title of warning shown in the "Submit to Community Library" panel when the current version is already submitted', + }, + alreadySubmittedWarningDescription: { message: - 'This version of the channel has already been submitted to the Community Library. Please wait for review or make changes and publish a new version before submitting again.', + 'Please wait for review or make changes and publish a new version before submitting again.', context: - 'Warning shown in the "Submit to Community Library" panel when the current version of the channel is already submitted', + 'Description of warning shown in the "Submit to Community Library" panel when the current version is already submitted', }, descriptionLabel: { message: "Describe what's new in this submission", @@ -189,4 +204,8 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: 'Categories', context: 'Label for detected categories in the "Submit to Community Library" panel', }, + confirmReplacementText: { + message: 'I understand this will replace my earlier submission on the review queue', + context: 'Checkbox text shown when there is a pending submission to confirm replacement', + }, }); diff --git a/contentcuration/contentcuration/migrations/0158_remove_unique_constraint_community_library_submission.py b/contentcuration/contentcuration/migrations/0158_remove_unique_constraint_community_library_submission.py new file mode 100644 index 0000000000..13c18d85d7 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0158_remove_unique_constraint_community_library_submission.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.24 on 2025-10-28 21:35 +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0157_merge_20251015_0333"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="communitylibrarysubmission", + name="unique_channel_with_channel_version", + ), + ] diff --git a/contentcuration/contentcuration/migrations/0159_restore_unique_constraint_community_library_submission.py b/contentcuration/contentcuration/migrations/0159_restore_unique_constraint_community_library_submission.py new file mode 100644 index 0000000000..7240ebfa9a --- /dev/null +++ b/contentcuration/contentcuration/migrations/0159_restore_unique_constraint_community_library_submission.py @@ -0,0 +1,59 @@ +# Generated manually to restore unique constraint +from django.db import migrations +from django.db import models + + +def remove_duplicate_submissions(apps, schema_editor): + """ + Remove duplicate submissions for the same channel and version. + Keeps the most recent submission (by date_created) for each channel/version pair. + """ + CommunityLibrarySubmission = apps.get_model( + "contentcuration", "CommunityLibrarySubmission" + ) + + # Find duplicates: group by channel and channel_version + from django.db.models import Max + + # Get the latest submission for each channel/version pair + latest_submissions = ( + CommunityLibrarySubmission.objects.values("channel", "channel_version") + .annotate(max_date=Max("date_created")) + .values_list("channel", "channel_version", "max_date") + ) + + # For each channel/version pair, delete all but the latest + for channel_id, channel_version, max_date in latest_submissions: + CommunityLibrarySubmission.objects.filter( + channel_id=channel_id, channel_version=channel_version + ).exclude(date_created=max_date).delete() + + +def reverse_remove_duplicates(apps, schema_editor): + # Cannot reverse data deletion, so this is a no-op + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "contentcuration", + "0158_remove_unique_constraint_community_library_submission", + ), + ] + + # Run each operation in its own transaction to avoid + # "pending trigger events" when altering the table after deletes + atomic = False + + operations = [ + migrations.RunPython(remove_duplicate_submissions, reverse_remove_duplicates), + migrations.AddConstraint( + model_name="communitylibrarysubmission", + constraint=models.UniqueConstraint( + fields=["channel", "channel_version"], + name="unique_channel_with_channel_version", + ), + ), + ] diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index c04ffaae2b..d8aaad9896 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -778,6 +778,94 @@ def test_mark_live(self, mock_ensure_db_exists_task_fetch_or_enqueue): community_library_submission.STATUS_LIVE, ) + def test_cannot_create_multiple_submissions_same_channel_same_version( + self, mock_ensure_db_exists_task_fetch_or_enqueue + ): + from django.db import IntegrityError, transaction + + channel = testdata.channel() + author = testdata.user() + channel.editors.add(author) + channel.version = 1 + channel.save() + + country = testdata.country() + + submission1 = CommunityLibrarySubmission.objects.create( + description="First submission", + channel=channel, + channel_version=1, + author=author, + categories=["test_category"], + status=community_library_submission.STATUS_PENDING, + ) + submission1.countries.add(country) + + with transaction.atomic(): + with self.assertRaises(IntegrityError): + submission2 = CommunityLibrarySubmission.objects.create( + description="Second submission", + channel=channel, + channel_version=1, + author=author, + categories=["test_category"], + status=community_library_submission.STATUS_PENDING, + ) + submission2.countries.add(country) + + submissions = CommunityLibrarySubmission.objects.filter( + channel=channel, channel_version=1 + ) + self.assertEqual(submissions.count(), 1) + self.assertEqual(submission1.channel, channel) + self.assertEqual(submission1.channel_version, 1) + + def test_can_create_submission_for_new_version_when_previous_pending( + self, mock_ensure_db_exists_task_fetch_or_enqueue + ): + channel = testdata.channel() + author = testdata.user() + channel.editors.add(author) + channel.version = 1 + channel.save() + + country = testdata.country() + + submission_v1 = CommunityLibrarySubmission.objects.create( + description="Pending submission for version 1", + channel=channel, + channel_version=1, + author=author, + categories=["test_category"], + status=community_library_submission.STATUS_PENDING, + ) + submission_v1.countries.add(country) + + channel.version = 2 + channel.save() + + submission_v2 = CommunityLibrarySubmission.objects.create( + description="New submission for version 2", + channel=channel, + channel_version=2, + author=author, + categories=["test_category"], + status=community_library_submission.STATUS_PENDING, + ) + submission_v2.countries.add(country) + + submissions_v1 = CommunityLibrarySubmission.objects.filter( + channel=channel, channel_version=1 + ) + submissions_v2 = CommunityLibrarySubmission.objects.filter( + channel=channel, channel_version=2 + ) + + self.assertEqual(submissions_v1.count(), 1) + self.assertEqual(submissions_v2.count(), 1) + self.assertEqual(submission_v1.channel_version, 1) + self.assertEqual(submission_v2.channel_version, 2) + class AssessmentItemTestCase(PermissionQuerysetTestCase): @property