Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"),
)
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
86 changes: 86 additions & 0 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually do these validations at api level, but can imagine the benefits of doing this at model level. This doesn't seem to be a common pattern in our codebase thought, would like to hear @rtibbles thoughts, as we may also need to do the check of the version being version > 0 and version <= channel.version

Copy link
Copy Markdown
Member

@rtibbles rtibbles Jul 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we have done these kinds of validations on the model itself, we have usually done it in the save method rather than the clean method - for precisely the reasons noted in the docstring - clean is not called on every save, but only when using a Django model form (which we rarely use).

If we want to ensure that this is being used every time we save a model, we should put it in the save method instead, otherwise, I think it would sit better in the API endpoint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, if you agree, I will move the logic to the save method.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, lets move this logic to the save method, along with the version check. Thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 7f17512 and f0a05eb.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank youu! <3 Just one little thing before we merge (I promise this is the last one 😅):

Could you remove the translate function from the error messages? We use to return these validation error messages without translating them e.g. here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed translation in cb61cb1.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

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"


Expand Down
143 changes: 143 additions & 0 deletions contentcuration/contentcuration/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading