Repository: learningequality/studio (python)

Issue:
ESoCC: Create CommunityLibrarySubmission model
<!-- DO NOT REMOVE THE HEADER -->
<!---HEADER START-->

<img height="20px" src="https://i.imgur.com/c7hUeb5.jpeg">

❌ **This issue is not open for contribution. Visit <a href="https://learningequality.org/contributing-to-our-open-code-base/" target="_blank">Contributing guidelines</a>** to learn about the contributing process and how to find suitable issues.

<img height="20px" src="https://i.imgur.com/c7hUeb5.jpeg">

<!---HEADER END-->

## Overview
Let's start with the Easy Sharing of Community Library with one of its most basic blocks: the data model for the "Community Library Submission" entity.

This entity will encapsulate all the information related to a single request for a channel to be included in our Community Library.

Channels in the Community Library will have special metadata, such as target countries and the subjects it contains. This metadata, once approved, will be applied to the Community Library Channel.

## Technical requirements

We will need to create a new Django model in [contentcuration/models.py](https://github.com/learningequality/studio/blob/5a96a25dc16781c7016c9ddfa61ae5a0e9aaa8ad/contentcuration/contentcuration/models.py) that will include the following fields:
* **Description:** A detailed explanation of the reason for submitting the channel to the Community Library.
* **Channel:** A foreign key to the [Channel model](https://github.com/learningequality/studio/blob/5a96a25dc16781c7016c9ddfa61ae5a0e9aaa8ad/contentcuration/contentcuration/models.py#L847), linking to the channel associated with the submission. (Required)
* **Channel version:** Describes the specific version of the channel being submitted for review. (Required)
* **Author:** A foreign key to the [User model](https://github.com/learningequality/studio/blob/5a96a25dc16781c7016c9ddfa61ae5a0e9aaa8ad/contentcuration/contentcuration/models.py#L194), referencing the user who initiated the channel submission for review. (Required)
* **Countries** (optional): We will need to create a `Country` table and have a many-to-many relationship. For now, let's just create the model; populating this table will be addressed in a separate task. This table will contain:
	* code: Primary Key - The Alpha-2 code of the country.
	* name: The name in English of the country.
* **Categories:** A text field for storing categories, separated by commas.
* **Date created**: A datetime field for storing the timestamp of when the submission was created.
- **Status:** A `ChoiceEnum` that will contain:
    - **Pending:** The initial and default status of a ContentLibrarySubmission
    - **Flagged:** Indicates the submission has been flagged for review, meaning the sender should review and correct any noted issues.
    - **Approved:** The submission has been approved.

Constraints:
* The `channel` and `channel_version` fields must be unique together.

There will be other fields related to the resolution of the Community Library but we won't address it for now.

Additionally, we will need to override these class methods:
* `filter_view_queryset`: Admins can view all submissions. Users can only view submissions for channels they are authorized to edit (i.e., by checking `User.editable_channels`) (e.g. [here](https://github.com/learningequality/studio/blob/5a96a25dc16781c7016c9ddfa61ae5a0e9aaa8ad/contentcuration/contentcuration/models.py#L505)).
* `filter_edit_queryset`: Admins can edit all submissions. Otherwise, only the user who sent the submission can edit it, also checking that user is also an editor of the channel being submitted (e.g. [here](https://github.com/learningequality/studio/blob/5a96a25dc16781c7016c9ddfa61ae5a0e9aaa8ad/contentcuration/contentcuration/models.py#L535)).

Finally, we should add some smoke tests for the model instance creation, and add unit tests for the `filter_view_queryset` and `filter_edit_queryset` methods (e.g. [here](https://github.com/learningequality/studio/blob/5a96a25dc16781c7016c9ddfa61ae5a0e9aaa8ad/contentcuration/contentcuration/tests/test_models.py#L159)).

## Acceptance criteria
- [ ] A new CommunityLibraySubmission model has been created with the described fields and methods.
- [ ] We have the corresponding migrations for this new model.
- [ ] Described unit tests have been added.

Pull request description:
CommunityLibrarySubmission model
## Summary

I implemented CommunityLibrarySubmission model and tests for it according to specs in #5145.

I have tested the changes by running `pytest` tests and making sure that the new tests pass and the existing tests do not break, and I have made sure that the devserver still starts.

## References

Resolves #5145.

## Reviewer guidance

 I have decided to override the `clean` method to validate that the author of the submission should be an editor of the corresponding channel. This cannot be achieved using a database constraint because traversing related fields is not supported inside them. This way, the constraint will be checked when using forms and when explicitly calling `full_clean()` (not on every save). Please check that this solution matches your expectations.

Patch:
diff --git a/contentcuration/contentcuration/constants/community_library_submission.py b/contentcuration/contentcuration/constants/community_library_submission.py
new file mode 100644
index 000000000..640b804f9
--- /dev/null
+++ b/contentcuration/contentcuration/constants/community_library_submission.py
@@ -0,0 +1,11 @@
+STATUS_PENDING = "PENDING"
+STATUS_APPROVED = "APPROVED"
+STATUS_REJECTED = "REJECTED"
+STATUS_LIVE = "LIVE"
+
+status_choices = (
+    (STATUS_PENDING, "Pending"),
+    (STATUS_APPROVED, "Approved"),
+    (STATUS_REJECTED, "Rejected"),
+    (STATUS_LIVE, "Live"),
+)
diff --git a/contentcuration/contentcuration/migrations/0154_community_library_submission.py b/contentcuration/contentcuration/migrations/0154_community_library_submission.py
new file mode 100644
index 000000000..051a81a48
--- /dev/null
+++ b/contentcuration/contentcuration/migrations/0154_community_library_submission.py
@@ -0,0 +1,91 @@
+# Generated by Django 3.2.24 on 2025-07-03 17:06
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contentcuration", "0153_alter_recommendationsevent_time_hidden"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Country",
+            fields=[
+                (
+                    "code",
+                    models.CharField(
+                        help_text="alpha-2 country code",
+                        max_length=2,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("name", models.CharField(max_length=100, unique=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="CommunityLibrarySubmission",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("description", models.TextField(blank=True)),
+                ("channel_version", models.PositiveIntegerField()),
+                ("categories", models.JSONField(blank=True, null=True)),
+                ("date_created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "status",
+                    models.CharField(
+                        choices=[
+                            ("PENDING", "Pending"),
+                            ("APPROVED", "Approved"),
+                            ("REJECTED", "Rejected"),
+                            ("LIVE", "Live"),
+                        ],
+                        default="PENDING",
+                        max_length=20,
+                    ),
+                ),
+                (
+                    "author",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="community_library_submissions",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                (
+                    "channel",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="community_library_submissions",
+                        to="contentcuration.channel",
+                    ),
+                ),
+                (
+                    "countries",
+                    models.ManyToManyField(
+                        related_name="community_library_submissions",
+                        to="contentcuration.Country",
+                    ),
+                ),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name="communitylibrarysubmission",
+            constraint=models.UniqueConstraint(
+                fields=("channel", "channel_version"),
+                name="unique_channel_with_channel_version",
+            ),
+        ),
+    ]
diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py
index f193921af..375cd3a9a 100644
--- a/contentcuration/contentcuration/models.py
+++ b/contentcuration/contentcuration/models.py
@@ -66,6 +66,7 @@ from rest_framework.fields import get_attribute
 from rest_framework.utils.encoders import JSONEncoder
 
 from contentcuration.constants import channel_history
+from contentcuration.constants import community_library_submission
 from contentcuration.constants import completion_criteria
 from contentcuration.constants import feedback
 from contentcuration.constants import user_history
@@ -2532,6 +2533,91 @@ class Language(models.Model):
         return self.ietf_name()
 
 
+class Country(models.Model):
+    code = models.CharField(
+        max_length=2, primary_key=True, help_text="alpha-2 country code"
+    )
+    name = models.CharField(max_length=100, unique=True)
+
+
+class CommunityLibrarySubmission(models.Model):
+    description = models.TextField(blank=True)
+    channel = models.ForeignKey(
+        Channel,
+        related_name="community_library_submissions",
+        on_delete=models.CASCADE,
+    )
+    channel_version = models.PositiveIntegerField()
+    author = models.ForeignKey(
+        User,
+        related_name="community_library_submissions",
+        on_delete=models.CASCADE,
+    )
+    countries = models.ManyToManyField(
+        Country, related_name="community_library_submissions"
+    )
+    categories = models.JSONField(blank=True, null=True)
+    date_created = models.DateTimeField(auto_now_add=True)
+    status = models.CharField(
+        max_length=20,
+        choices=community_library_submission.status_choices,
+        default=community_library_submission.STATUS_PENDING,
+    )
+
+    def save(self, *args, **kwargs):
+        # Validate on save that the submission author is an editor of the channel
+        # and that the version is not greater than the current channel version.
+        # These cannot be expressed as constraints because traversing
+        # related fields is not supported in constraints.
+        if not self.channel.editors.filter(pk=self.author.pk).exists():
+            raise ValidationError(
+                "The submission author must be an editor of the channel the submission "
+                "belongs to",
+                code="author_not_editor",
+            )
+
+        if self.channel_version <= 0:
+            raise ValidationError(
+                "Channel version must be positive",
+                code="non_positive_channel_version",
+            )
+        if self.channel_version > self.channel.version:
+            raise ValidationError(
+                "Channel version must be less than or equal to the current channel version",
+                code="impossibly_high_channel_version",
+            )
+
+        super().save(*args, **kwargs)
+
+    @classmethod
+    def filter_view_queryset(cls, queryset, user):
+        if user.is_anonymous:
+            return queryset.none()
+
+        if user.is_admin:
+            return queryset
+
+        return queryset.filter(channel__editors=user)
+
+    @classmethod
+    def filter_edit_queryset(cls, queryset, user):
+        if user.is_anonymous:
+            return queryset.none()
+
+        if user.is_admin:
+            return queryset
+
+        return queryset.filter(author=user, channel__editors=user)
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(
+                fields=["channel", "channel_version"],
+                name="unique_channel_with_channel_version",
+            ),
+        ]
+
+
 ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx"
 
 


Test patch:
diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py
index 2fb728e4a..d70bdc5d8 100644
--- a/contentcuration/contentcuration/tests/test_models.py
+++ b/contentcuration/contentcuration/tests/test_models.py
@@ -12,12 +12,14 @@ from le_utils.constants import content_kinds
 from le_utils.constants import format_presets
 
 from contentcuration.constants import channel_history
+from contentcuration.constants import community_library_submission
 from contentcuration.constants import user_history
 from contentcuration.models import AssessmentItem
 from contentcuration.models import Change
 from contentcuration.models import Channel
 from contentcuration.models import ChannelHistory
 from contentcuration.models import ChannelSet
+from contentcuration.models import CommunityLibrarySubmission
 from contentcuration.models import ContentNode
 from contentcuration.models import CONTENTNODE_TREE_ID_CACHE_KEY
 from contentcuration.models import File
@@ -547,6 +549,147 @@ class ContentNodeTestCase(PermissionQuerysetTestCase):
         self.assertNotEqual(copied_node_old_content_id, copied_node.content_id)
 
 
+class CommunityLibrarySubmissionTestCase(PermissionQuerysetTestCase):
+    @property
+    def base_queryset(self):
+        return CommunityLibrarySubmission.objects.all()
+
+    def test_create_submission(self):
+        # Smoke test
+        channel = testdata.channel()
+        author = testdata.user()
+        channel.editors.add(author)
+        channel.version = 1
+        channel.save()
+
+        country = testdata.country()
+
+        submission = CommunityLibrarySubmission.objects.create(
+            description="Test submission",
+            channel=channel,
+            channel_version=1,
+            author=author,
+            categories=["test_category"],
+            status=community_library_submission.STATUS_PENDING,
+        )
+        submission.countries.add(country)
+
+        submission.full_clean()
+        submission.save()
+
+    def test_save__author_not_editor(self):
+        submission = testdata.community_library_submission()
+        user = testdata.user("some@email.com")
+        submission.author = user
+        with self.assertRaises(ValidationError):
+            submission.save()
+
+    def test_save__nonpositive_channel_version(self):
+        submission = testdata.community_library_submission()
+        submission.channel_version = 0
+        with self.assertRaises(ValidationError):
+            submission.save()
+
+    def test_save__matching_channel_version(self):
+        submission = testdata.community_library_submission()
+        submission.channel.version = 5
+        submission.channel.save()
+        submission.channel_version = 5
+        submission.save()
+
+    def test_save__impossibly_high_channel_version(self):
+        submission = testdata.community_library_submission()
+        submission.channel.version = 5
+        submission.channel.save()
+        submission.channel_version = 6
+        with self.assertRaises(ValidationError):
+            submission.save()
+
+    def test_filter_view_queryset__anonymous(self):
+        _ = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_view_queryset(
+            self.base_queryset, user=self.anonymous_user
+        )
+        self.assertFalse(queryset.exists())
+
+    def test_filter_view_queryset__forbidden_user(self):
+        _ = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_view_queryset(
+            self.base_queryset, user=self.forbidden_user
+        )
+        self.assertFalse(queryset.exists())
+
+    def test_filter_view_queryset__channel_editor(self):
+        submission_a = testdata.community_library_submission()
+        submission_b = testdata.community_library_submission()
+
+        user = testdata.user()
+        submission_a.channel.editors.add(user)
+        submission_a.save()
+
+        queryset = CommunityLibrarySubmission.filter_view_queryset(
+            self.base_queryset, user=user
+        )
+        self.assertQuerysetContains(queryset, pk=submission_a.id)
+        self.assertQuerysetDoesNotContain(queryset, pk=submission_b.id)
+
+    def test_filter_view_queryset__admin(self):
+        submission_a = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_view_queryset(
+            self.base_queryset, user=self.admin_user
+        )
+        self.assertQuerysetContains(queryset, pk=submission_a.id)
+
+    def test_filter_edit_queryset__anonymous(self):
+        _ = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_edit_queryset(
+            self.base_queryset, user=self.anonymous_user
+        )
+        self.assertFalse(queryset.exists())
+
+    def test_filter_edit_queryset__forbidden_user(self):
+        _ = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_edit_queryset(
+            self.base_queryset, user=self.forbidden_user
+        )
+        self.assertFalse(queryset.exists())
+
+    def test_filter_edit_queryset__channel_editor(self):
+        submission = testdata.community_library_submission()
+
+        user = testdata.user()
+        submission.channel.editors.add(user)
+        submission.save()
+
+        queryset = CommunityLibrarySubmission.filter_edit_queryset(
+            self.base_queryset, user=user
+        )
+        self.assertFalse(queryset.exists())
+
+    def test_filter_edit_queryset__author(self):
+        submission_a = testdata.community_library_submission()
+        submission_b = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_edit_queryset(
+            self.base_queryset, user=submission_a.author
+        )
+        self.assertQuerysetContains(queryset, pk=submission_a.id)
+        self.assertQuerysetDoesNotContain(queryset, pk=submission_b.id)
+
+    def test_filter_edit_queryset__admin(self):
+        submission_a = testdata.community_library_submission()
+
+        queryset = CommunityLibrarySubmission.filter_edit_queryset(
+            self.base_queryset, user=self.admin_user
+        )
+        self.assertQuerysetContains(queryset, pk=submission_a.id)
+
+
 class AssessmentItemTestCase(PermissionQuerysetTestCase):
     @property
     def base_queryset(self):
diff --git a/contentcuration/contentcuration/tests/testdata.py b/contentcuration/contentcuration/tests/testdata.py
index e938b3b23..22e52eb0d 100644
--- a/contentcuration/contentcuration/tests/testdata.py
+++ b/contentcuration/contentcuration/tests/testdata.py
@@ -16,8 +16,12 @@ from le_utils.constants import format_presets
 from PIL import Image
 
 from contentcuration import models as cc
+from contentcuration.constants import (
+    community_library_submission as community_library_submission_constants,
+)
 from contentcuration.tests.utils import mixer
 
+
 pytestmark = pytest.mark.django_db
 
 
@@ -206,6 +210,27 @@ def node(data, parent=None):  # noqa: C901
     return new_node
 
 
+def country(name="Test Country", code="TC"):
+    return mixer.blend(cc.Country, name=name, code=code)
+
+
+def community_library_submission():
+    channel_obj = channel(name=random_string())
+    user_obj = user(email=random_string())
+    channel_obj.editors.add(user_obj)
+    channel_obj.version = 1
+    channel_obj.save()
+
+    return mixer.blend(
+        cc.CommunityLibrarySubmission,
+        channel=channel_obj,
+        author=user_obj,
+        status=community_library_submission_constants.STATUS_PENDING,
+        categories=list(),
+        channel_version=1,
+    )
+
+
 def tree(parent=None):
     # Read from json fixture
     filepath = os.path.sep.join([os.path.dirname(__file__), "fixtures", "tree.json"])