diff --git a/contentcuration/contentcuration/constants/community_library_submission.py b/contentcuration/contentcuration/constants/community_library_submission.py new file mode 100644 index 0000000000..640b804f92 --- /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 0000000000..051a81a48d --- /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 f193921afb..375cd3a9a6 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -66,6 +66,7 @@ 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 @@ def __str__(self): 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" diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index 2fb728e4a3..d70bdc5d8d 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -12,12 +12,14 @@ 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 @@ def test_make_content_id_unique(self): 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 e938b3b237..22e52eb0d4 100644 --- a/contentcuration/contentcuration/tests/testdata.py +++ b/contentcuration/contentcuration/tests/testdata.py @@ -16,8 +16,12 @@ 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"])