Skip to content

Conversation

@taoerman
Copy link
Member

@taoerman taoerman commented Dec 3, 2025

Summary

Implements the ChannelVersion model to enable version-specific metadata tracking and token-based access for specific channel versions, supporting community library submissions and draft version imports.

References

Fixes: #5460

Reviewer guidance

Run unit tests

Copy link
Member

@AlexVelezLl AlexVelezLl left a comment

Choose a reason for hiding this comment

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

Thanks @taoerman! This is looking great! Just few minor/nitpick comments 😄.

Comment on lines 220 to 227
if is_draft_version:
channel_version = ccmodels.ChannelVersion.objects.create(
channel=channel, version=None
)
else:
channel_version, created = ccmodels.ChannelVersion.objects.get_or_create(
channel=channel, version=channel.version
)
Copy link
Member

Choose a reason for hiding this comment

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

I think we can simplify this to

ccmodels.ChannelVersion.objects.get_or_create(
    channel=channel,
    version=None if is_draft_version else channel.version
)

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed, Thanks!


if not is_draft_version:
channel.version += 1
channel.save()
Copy link
Member

Choose a reason for hiding this comment

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

Let's rely on just one channel.save at the end of the function, and not here, so that the channel on_update check is only run once we have already created the ChannelVersion object.

Copy link
Member Author

Choose a reason for hiding this comment

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

Make sense, fixed, thanks!

Comment on lines 214 to 236
def increment_channel_version(channel, is_draft_version=False):

if not is_draft_version:
channel.version += 1
channel.save()

if is_draft_version:
channel_version = ccmodels.ChannelVersion.objects.create(
channel=channel, version=None
)
else:
channel_version, created = ccmodels.ChannelVersion.objects.get_or_create(
channel=channel, version=channel.version
)

if not is_draft_version:
channel.version_info = channel_version
channel.save()

if is_draft_version:
channel_version.new_token()

return channel_version
Copy link
Member

Choose a reason for hiding this comment

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

(I know this goes against what I wrote in the issue description.) Now that I look at the code, I think we can just create a different method for the draft version creation? And literally left this function untouched as

def increment_channel_version(channel):
    channel.version += 1
    channel.save()

Since the model here will take care of creating the ChannelVersion object, we can just skip it here.

And let the publish_channel method decide which function it should call. What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

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

Make sense, its clearer!

Comment on lines 1001 to 1028
if special_perms_descriptions:
existing_licenses = (
ccmodels.AuditedSpecialPermissionsLicense.objects.filter(
description__in=special_perms_descriptions
)
)
existing_descriptions = set(
existing_licenses.values_list("description", flat=True)
)

new_licenses = [
ccmodels.AuditedSpecialPermissionsLicense(
description=description, distributable=False
)
for description in special_perms_descriptions
if description not in existing_descriptions
]

if new_licenses:
ccmodels.AuditedSpecialPermissionsLicense.objects.bulk_create(
new_licenses, ignore_conflicts=True
)

special_permissions_ids = list(
ccmodels.AuditedSpecialPermissionsLicense.objects.filter(
description__in=special_perms_descriptions
).values_list("id", flat=True)
)
Copy link
Member

Choose a reason for hiding this comment

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

Apologies, I didn't catch this earlier, but it seems we don't actually need to compute existing_licenses for this. We only need to create the new_licenses object that includes all descriptions without filtering, then do the bulk_create with ignore_conflicts=True, and then query the AuditedSpecialPermissionsLicense with the description__in filter. So we would only need to make two db hits: the bulk_create and the final description__in filter. (Not sure if I explained it well 😅)

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, Alex, make sense!!

Comment on lines 1045 to 1049
channel.version_info.special_permissions_included.set(
ccmodels.AuditedSpecialPermissionsLicense.objects.filter(
id__in=special_permissions_ids
)
)
Copy link
Member

Choose a reason for hiding this comment

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

Since we are not actually using the special_permissions_ids we can just avoid fetching the permission_ids and use the description__in filter directly here, or... we can just have the filter query as special_permissions (without doing the values_list call) and then here just do .set(special_permissions).

Comment on lines 1367 to 1379
def validate_kind_count_item(value):

if not isinstance(value, dict):
raise ValidationError("Each kind_count item must be a dictionary")

if "count" not in value or "kind" not in value:
raise ValidationError("Each kind_count item must have 'count' and 'kind' keys")

if not isinstance(value["count"], int) or value["count"] < 0:
raise ValidationError("'count' must be a non-negative integer")

if not isinstance(value["kind"], str) or not value["kind"]:
raise ValidationError("'kind' must be a non-empty string")
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed, Thanks!

Comment on lines 1409 to 1420
kind_count = ArrayField(
JSONField(), validators=[validate_kind_count_item], null=True, blank=True
)
included_licenses = ArrayField(
models.IntegerField(choices=[(lic[0], lic[1]) for lic in licenses.LICENSELIST]),
null=True,
blank=True,
)
included_categories = ArrayField(
models.CharField(
max_length=100, choices=[(subj, subj) for subj in subjects.SUBJECTSLIST]
),
Copy link
Member

Choose a reason for hiding this comment

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

Sounds like a good idea if we add separate get_license_choices and get_categories_choices helper functions instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed it, Thanks!

Comment on lines 54 to 57
# In reality, the versioned database for the current version
# and the unversioned database would have the same content,
# but here we provide different content so that we can test
# that the versioned database is not overwritten.
Copy link
Member

Choose a reason for hiding this comment

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

I think we can keep this comment

Copy link
Member Author

Choose a reason for hiding this comment

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

Got it, thanks!

Comment on lines 1275 to 1293
# version-detail returns empty dict when no version_info exists
self.assertEqual(response.json(), {})

def test_get_published_data__is_admin(self):
self.client.force_authenticate(user=self.admin_user)

response = self.client.get(
reverse("channel-published-data", kwargs={"pk": self.channel.id}),
reverse("channel-version-detail", kwargs={"pk": self.channel.id}),
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json(), self.channel.published_data)
# version-detail returns empty dict when no version_info exists
self.assertEqual(response.json(), {})

def test_get_published_data__is_forbidden_user(self):
self.client.force_authenticate(user=self.forbidden_user)

response = self.client.get(
reverse("channel-published-data", kwargs={"pk": self.channel.id}),
reverse("channel-version-detail", kwargs={"pk": self.channel.id}),
Copy link
Member

Choose a reason for hiding this comment

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

Here we should update the test to make sense with the new version detail object. i.e. populate the versionInfo fields, asserting against the most recent ChannelVersion, etc. Also we should rename this test case, since it is still referencing the tests as "test get publish data"

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed it, Thanks!

Copy link
Member

@AlexVelezLl AlexVelezLl left a comment

Choose a reason for hiding this comment

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

Looking better! I found 500 errors while publishing the channel. I have left some notes for them!

Comment on lines 401 to 403
const latestPublishedData = computed(() => {
if (!publishedData.value || !displayedVersion.value) return undefined;
return publishedData.value[displayedVersion.value];
return publishedData.value;
});
Copy link
Member

Choose a reason for hiding this comment

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

Here, we don't actually need to create a computed property to wrap another computed property without doing any other computation. For this, I think it'd be best just to rename the publishedData property to something like versionDetail and use this property instead of the latestPublishedData property.

Copy link
Member

Choose a reason for hiding this comment

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

Note that there are some fields that have changed, like the community_library_invalid_licenses -> non_distributable_licenses_included and community_library_special_permissions -> special_permissions_included

Copy link
Member Author

Choose a reason for hiding this comment

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

Got it, I have deleted the redundant computed property and changed the outdated filed names to the new one.

Comment on lines 218 to 224
new_version_info, created = ccmodels.ChannelVersion.objects.get_or_create(
channel=channel, version=channel.version
)

ccmodels.Channel.objects.filter(pk=channel.pk).update(
version=channel.version, version_info=new_version_info
)
Copy link
Member

Choose a reason for hiding this comment

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

Here we can just skip this because if we call channel.save(), the save method will take care of creating the channelVersion instance here.

So this method would safely be written just like it was before:

def increment_channel_version(channel):
    channel.version += 1
    channel.save()

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried this way before, but i got error like FAILED contentcuration/tests/viewsets/test_contentnode.py::SyncTestCase::test_copy_contentnode_finalization_does_not_make_publishable - AssertionError: 1 != 0. I will try to figure out what went wrong.

Copy link
Member Author

Choose a reason for hiding this comment

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

LOL, I know what wrong with my code. I did not define the special_perms_descriptions first. I used special_perms_descriptions=[] first, which has been deleted by linting check.

Comment on lines +234 to +235
if created:
channel_version.new_token()
Copy link
Member

Choose a reason for hiding this comment

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

Nice!

)
channel.version_info.save()

if special_perms_descriptions:
Copy link
Member

Choose a reason for hiding this comment

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

Note that at this point, if this condition is false, then special_perms_descriptions will not be defined, and will raise this error:

[2025-12-05 15:21:35,310: ERROR/ForkPoolWorker-2] local variable 'special_perms_descriptions' referenced before assignment
Traceback (most recent call last):
  File "/Users/alexvelezll/Documents/work/learningequality/repos/studio/contentcuration/contentcuration/viewsets/channel.py", line 536, in publish_from_changes
    self.publish(
  File "/Users/alexvelezll/Documents/work/learningequality/repos/studio/contentcuration/contentcuration/viewsets/channel.py", line 563, in publish
    channel = publish_channel(
  File "/Users/alexvelezll/.pyenv/versions/3.10.13/lib/python3.10/contextlib.py", line 79, in inner
    return func(*args, **kwds)
  File "/Users/alexvelezll/Documents/work/learningequality/repos/studio/contentcuration/contentcuration/utils/publish.py", line 1138, in publish_channel
    fill_published_fields(channel, version_notes)
  File "/Users/alexvelezll/Documents/work/learningequality/repos/studio/contentcuration/contentcuration/utils/publish.py", line 1020, in fill_published_fields
    if special_perms_descriptions:
UnboundLocalError: local variable 'special_perms_descriptions' referenced before assignment

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed, thanks!

Comment on lines +1411 to +1441
id = UUIDField(primary_key=True, default=uuid.uuid4)
channel = models.ForeignKey(
Channel, on_delete=models.CASCADE, related_name="channel_versions"
)
version = models.PositiveIntegerField(null=True, blank=True)
secret_token = models.ForeignKey(
SecretToken, on_delete=models.SET_NULL, null=True, blank=True
)
version_notes = models.TextField(null=True, blank=True)
size = models.PositiveIntegerField(null=True, blank=True)
date_published = models.DateTimeField(null=True, blank=True)
resource_count = models.PositiveIntegerField(null=True, blank=True)
kind_count = ArrayField(
JSONField(), validators=[validate_kind_count_item], null=True, blank=True
)
included_licenses = ArrayField(
models.IntegerField(choices=get_license_choices()),
null=True,
blank=True,
)
included_categories = ArrayField(
models.CharField(max_length=100, choices=get_categories_choices()),
null=True,
blank=True,
)
included_languages = ArrayField(
models.CharField(max_length=100),
validators=[validate_language_code],
null=True,
blank=True,
)
Copy link
Member

Choose a reason for hiding this comment

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

Seems like validations are not being triggered on save. It seems that for this, we should call the method self.full_clean on the def save() method.

Copy link
Member

Choose a reason for hiding this comment

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

To ensure that we are catching these oversights ⬆️, could we create some tests on the contencuration/tests/test_models.py module, please? Here we can test all these validations properly!

Copy link
Member Author

Choose a reason for hiding this comment

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

Added more tests to cover validation!

channel.version_info.size = int(channel.published_size)
channel.version_info.date_published = channel.last_published
channel.version_info.version_notes = version_notes
channel.version_info.included_languages = language_list
Copy link
Member

Choose a reason for hiding this comment

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

While checking how the publish workflow works with the full_clean call, I noticed that the language_list included some "null" values as items. And this is because in this line we are missing the .exclude(files__language=None), it is clearly not a regression of this PR, but could you fix it, please? Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed, Thanks!

"""
Validator for language codes in included_languages array.
"""
valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST]
Copy link
Member

Choose a reason for hiding this comment

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

Here, the correct validation would be to use lang.code instead of lang[0]

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks!!!

Comment on lines 1379 to 1390
def validate_kind_count_item(value):
try:
jsonschema.validate(instance=value, schema=KIND_COUNT_ITEM_SCHEMA)
except jsonschema.ValidationError as e:
raise ValidationError(str(e))


def validate_language_code(value):
"""
Validator for language codes in included_languages array.
"""
valid_language_codes = [lang[0] for lang in languages.LANGUAGELIST]
Copy link
Member

Choose a reason for hiding this comment

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

Also, note that the value passed to these methods is the entire array of values, not only each item.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks!!!!

Comment on lines 926 to 931
version_data["id"] = str(version_data["id"])

if version_data["date_published"]:
version_data["date_published"] = version_data["date_published"].strftime(
settings.DATE_TIME_FORMAT
)
Copy link
Member

Choose a reason for hiding this comment

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

Here, we can live without reformatting these two fields, we usually handle dates with the format returned from the .values( call. The same with UUID's

Copy link
Member Author

Choose a reason for hiding this comment

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

removed!

Comment on lines 933 to 937
version_data["special_permissions_included"] = list(
channel.version_info.special_permissions_included.values_list(
"id", flat=True
)
)
Copy link
Member

Choose a reason for hiding this comment

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

Now that I think about it, since we will do another call to fetch the actual descriptions anyways, then it may probably be a better Idea just to add a channelVersion filter to the AuditedSpecialPermissionsLicenseViewset, what do you think? Would it be a good idea to do this here, or do you prefer a followup issue for this?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think your idea is better for separation, performance and more flexible. I tried to implement your idea, could you please check it if i need to fix it? Thanks!

Copy link
Member

Choose a reason for hiding this comment

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

Nice! Just note that we will also need to update the frontend here to use this filter instead of the by_ids

Copy link
Member

@AlexVelezLl AlexVelezLl left a comment

Choose a reason for hiding this comment

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

Starts looking really great! Just a few things left 👐.

Comment on lines 427 to 428
if (versionDetail.value && Object.keys(versionDetail.value).length > 0) {
const publishedVersions = Object.keys(versionDetail.value).map(v => parseInt(v, 10));
Copy link
Member

Choose a reason for hiding this comment

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

Noting that now versionDetail is not a mapping object of versions anymore, it is just a single version object. so this algorithm would no longer apply

Copy link
Member Author

Choose a reason for hiding this comment

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

Make sense!

Comment on lines 491 to 493
// We need to filter out null values due to a backend bug
// causing null values to sometimes be included in the list
const languageCodes = latestPublishedData.value?.included_languages.filter(
code => code !== null,
);
const languageCodes = versionDetail.value?.included_languages.filter(code => code !== null);
Copy link
Member

Choose a reason for hiding this comment

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

Since now, by definition, included_languages will only hold valid language codes (by the validator we just wrote in the model) then we can remove this comment, and this filter! 🎉

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed, thanks!

Comment on lines 110 to 111
ChannelVersion.objects.filter(channel=self.channel).delete()
self.channel.version_info = None
Copy link
Member

Choose a reason for hiding this comment

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

Just curious, why do we need these lines? 😅

Copy link
Member Author

Choose a reason for hiding this comment

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

Lol i used another way to set up tests. Thanks!

Comment on lines 1335 to 1365
class AuditedSpecialPermissionsLicenseFilter(FilterSet):
"""Filter for AuditedSpecialPermissionsLicense by channelVersion."""

channel_version = UUIDFilter(
field_name="channel_versions__id",
help_text="Filter by ChannelVersion ID",
)

class Meta:
model = AuditedSpecialPermissionsLicense
fields = ["channel_version"]


class AuditedSpecialPermissionsLicenseViewSet(ReadOnlyValuesViewset):
"""
ViewSet for retrieving AuditedSpecialPermissionsLicense objects.
Supports filtering by channelVersion to get licenses for a specific channel version.
"""

queryset = AuditedSpecialPermissionsLicense.objects.all()
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend]
filterset_class = AuditedSpecialPermissionsLicenseFilter

values = ("id", "description", "distributable")

field_map = {
"id": "id",
"description": "description",
"distributable": "distributable",
}
Copy link
Member

Choose a reason for hiding this comment

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

In theory we created this viewset here right? https://github.com/learningequality/studio/pull/5563/files#diff-0bf7eefe9deb327a221bc831f03f9f1585476b5718cff5c956e67ba71aa17253R30 😅

Could we just update the filter set of that view set?

Copy link
Member Author

Choose a reason for hiding this comment

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

OMG, I forgot I wrote a AuditedSpecialPermissionsLicenseViewSet before. Thanks!!!!

Comment on lines 933 to 937
version_data["special_permissions_included"] = list(
channel.version_info.special_permissions_included.values_list(
"id", flat=True
)
)
Copy link
Member

Choose a reason for hiding this comment

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

Nice! Just note that we will also need to update the frontend here to use this filter instead of the by_ids

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create ChannelVersion model with token support

2 participants