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