-
Notifications
You must be signed in to change notification settings - Fork 256
Create ChannelVersion model with token support #5589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: unstable
Are you sure you want to change the base?
Create ChannelVersion model with token support #5589
Conversation
AlexVelezLl
left a comment
There was a problem hiding this 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 😄.
| 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 | ||
| ) |
There was a problem hiding this comment.
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
)There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sense, fixed, thanks!
| 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sense, its clearer!
| 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) | ||
| ) |
There was a problem hiding this comment.
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 😅)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, Alex, make sense!!
| channel.version_info.special_permissions_included.set( | ||
| ccmodels.AuditedSpecialPermissionsLicense.objects.filter( | ||
| id__in=special_permissions_ids | ||
| ) | ||
| ) |
There was a problem hiding this comment.
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).
| 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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use the jsonschema package to do this validation? Just like https://github.com/AlexVelezLl/studio/blob/31039f6c128e2ca1c44687240127b58538c88bd8/contentcuration/contentcuration/viewsets/recommendation.py#L19
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed, Thanks!
| 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] | ||
| ), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed it, Thanks!
| # 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. |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it, thanks!
| # 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}), |
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed it, Thanks!
…rt' of https://github.com/taoerman/studio into issue-5460-Create-ChannelVersion-model-with-token-support megr# the commit.
AlexVelezLl
left a comment
There was a problem hiding this 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!
| const latestPublishedData = computed(() => { | ||
| if (!publishedData.value || !displayedVersion.value) return undefined; | ||
| return publishedData.value[displayedVersion.value]; | ||
| return publishedData.value; | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| 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 | ||
| ) |
There was a problem hiding this comment.
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()There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| if created: | ||
| channel_version.new_token() |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed, thanks!
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!!!
| 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] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!!!!
| 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 | ||
| ) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed!
| version_data["special_permissions_included"] = list( | ||
| channel.version_info.special_permissions_included.values_list( | ||
| "id", flat=True | ||
| ) | ||
| ) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
AlexVelezLl
left a comment
There was a problem hiding this 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 👐.
| if (versionDetail.value && Object.keys(versionDetail.value).length > 0) { | ||
| const publishedVersions = Object.keys(versionDetail.value).map(v => parseInt(v, 10)); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sense!
| // 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); |
There was a problem hiding this comment.
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! 🎉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed, thanks!
| ChannelVersion.objects.filter(channel=self.channel).delete() | ||
| self.channel.version_info = None |
There was a problem hiding this comment.
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? 😅
There was a problem hiding this comment.
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!
| 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", | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!!!!
| version_data["special_permissions_included"] = list( | ||
| channel.version_info.special_permissions_included.values_list( | ||
| "id", flat=True | ||
| ) | ||
| ) |
There was a problem hiding this comment.
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
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