fix: populate draft ChannelVersion metadata during draft publishes#5841
Conversation
AlexVelezLl
left a comment
There was a problem hiding this comment.
Code changes look good overall, just a request to improve the tests, and a couple of notes to fix things from the previous implementation.
| with mock.patch.object( | ||
| ccmodels.AuditedSpecialPermissionsLicense, | ||
| "mark_channel_version_as_distributable", | ||
| ) as mock_mark: | ||
| fill_published_fields( | ||
| self.channel, "draft notes", draft_channel_version=self.draft_version | ||
| ) | ||
|
|
||
| mock_mark.assert_not_called() |
There was a problem hiding this comment.
Could you set some nodes with special permissions and then actually see that the distributable field is false?
There was a problem hiding this comment.
Done. Replaced the mock-based test with test_special_permissions_distributable_false_for_draft_publish which sets a video node to the Special Permissions license (with a description), calls publish_channel with is_draft_version=True and channel.public=True, then asserts that all AuditedSpecialPermissionsLicense objects in the draft ChannelVersion's special_permissions_included have distributable=False.
| self.assertIsNone(self.channel.version_info.secret_token) | ||
|
|
||
|
|
||
| class FillPublishedFieldsDraftTestCase(StudioTestCase): |
There was a problem hiding this comment.
I think it'd be nice if we had tests for the entire publish flow instead, to make sure that the publish function is working correctly, and that it is actually sending the draft channel_version object correctly.
There was a problem hiding this comment.
Refactored into DraftPublishChannelTestCase which calls publish_channel(is_draft_version=True) with save_export_database mocked (same pattern as PublishDraftUsingMainTreeTestCase). Tests now exercise the full draft publish flow.
| def test_second_draft_publish_replaces_special_permissions_included(self): | ||
| """ | ||
| On a second consecutive draft publish, special_permissions_included on the | ||
| draft ChannelVersion reflects only the current publish — licenses from the | ||
| previous draft publish that are no longer present are removed. | ||
|
|
||
| We simulate 'previous' licenses by pre-loading the M2M, then running a | ||
| fresh fill_published_fields (which finds no special-permissions nodes in | ||
| testdata.channel(), so it calls .clear()), and assert the M2M is empty. | ||
| """ | ||
| stale_license = ccmodels.AuditedSpecialPermissionsLicense.objects.create( | ||
| description="Stale license from previous draft publish", | ||
| distributable=False, | ||
| ) | ||
| self.draft_version.special_permissions_included.add(stale_license) | ||
| self.assertEqual(self.draft_version.special_permissions_included.count(), 1) | ||
|
|
||
| # Second draft publish: testdata.channel() has no special-perms nodes, | ||
| # so fill_published_fields should clear the M2M entirely. | ||
| fill_published_fields( | ||
| self.channel, "second draft", draft_channel_version=self.draft_version | ||
| ) | ||
| self.draft_version.refresh_from_db() | ||
|
|
||
| self.assertEqual( | ||
| self.draft_version.special_permissions_included.count(), | ||
| 0, | ||
| "special_permissions_included should be fully replaced on each draft publish", | ||
| ) |
There was a problem hiding this comment.
After changing these tests to use the actual publish function, it'd be nice if we can do this test with two different draft publish calls with different special permissions.
There was a problem hiding this comment.
Done. test_second_draft_publish_replaces_special_permissions_included now makes two real publish_channel calls: first with license_description='License A', then with 'License B'. Asserts after each call that special_permissions_included contains exactly the current description.
| ) | ||
| else: | ||
| increment_channel_version(channel) | ||
| if not is_draft_version: |
There was a problem hiding this comment.
Now that you are here, could you please just put all content below this if not is_draft_version block in the else block, so that it is more readable?
There was a problem hiding this comment.
Done. Merged the content of the standalone 'if not is_draft_version:' block into the else branch of the is_draft_version conditional, removing the redundant negated check.
There was a problem hiding this comment.
Correction to the previous reply: the prior commit moved the inner if not is_draft_version: block into the else branch, but missed the delete_public_channel_cache_keys call which was also gated by if not is_draft_version. That is now fixed — moved into the else branch as if channel.public: in 6683776.
| for lang in language_list: | ||
| if lang: | ||
| channel.included_languages.add(lang) |
There was a problem hiding this comment.
I know this was copied from the old implementation, but this is a bug, right? @rtibbles. If a language doesn't exist anymore on the channel, it should be removed, right? Or should this included_languages have the list of historical languages on the channel?
There was a problem hiding this comment.
Yes - if it's not being cleared earlier on, this is a mistake!
There was a problem hiding this comment.
This behaviour () is copied from the original implementation and is pre-existing — it's not something introduced by this PR. Since the question is tagged at @rtibbles, I've left this for them to decide on the right semantics. Happy to address it in a follow-up if they confirm it should be a replace rather than an accumulate.
There was a problem hiding this comment.
Fixed in a009cb5 — replaced the loop with so languages no longer present in the channel are removed on each publish rather than accumulated.
fill_published_fields now accepts an optional draft_channel_version parameter. When provided, ChannelVersion-level fields are written to the draft object while channel-level fields (total_resource_count, published_size, published_data, version_info) are left untouched. mark_channel_version_as_distributable is also skipped for draft publishes. publish_channel now calls fill_published_fields in the draft branch, passing the draft ChannelVersion returned by create_draft_channel_version. Closes learningequality#5839
ChannelVersion.save() always calls full_clean(), which validates choices on ArrayField(IntegerField(choices=...)) for included_licenses. Custom license IDs used in existing tests (e.g. IDs 100, 101) are not in the standard choices list from le_utils, so save() raised ValidationError. Replace version_obj.save() with a queryset .update() to write metadata fields directly without triggering model-level validation. The data originates from the DB so validation is unnecessary, and M2M operations (special_permissions_included) continue to use the model instance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…channel structure - Merge redundant 'if not is_draft_version:' block into the 'else:' branch of the is_draft_version conditional in publish_channel, as requested by reviewer. - Refactor FillPublishedFieldsDraftTestCase into DraftPublishChannelTestCase which tests the complete publish_channel flow (with save_export_database mocked) rather than calling fill_published_fields directly. Tests now use a Special Permissions license node with published=True to exercise the special_permissions_included logic. - test_second_draft_publish_replaces_special_permissions_included now makes two real publish_channel calls with different license_description values and verifies the M2M is replaced (not accumulated) between calls. - test_mark_channel_version_as_distributable_not_called replaced by test_special_permissions_distributable_false_for_draft_publish which asserts the distributable field stays False on the resulting AuditedSpecialPermissionsLicense objects after a draft publish of a public channel, rather than mocking the method.
…ating Use .set() instead of .add() so languages removed from a channel are cleared on subsequent publishes rather than accumulated indefinitely. Flagged by AlexVelezLl, confirmed as a bug by rtibbles.
a009cb5 to
067248e
Compare
… tests loadconstants inserts licenses with explicit PKs, leaving the PK sequence at 1. Calling License.objects.create() in setUp() then collides with the existing row. Use get() to fetch the pre-existing license — the same pattern used in test_create_channel_versions.py and test_sync.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ft_version The previous commit claimed to have merged all 'if not is_draft_version' blocks into the else branch, but missed the delete_public_channel_cache_keys call. Move it inside the else block and simplify the condition to 'if channel.public'.
There was a problem hiding this comment.
Code changes look good to me, and manual QA checks out. Thanks @rtibblesbot. cc: @rtibbles Just one last thing.
| ccmodels.ChannelVersion.objects.filter(pk=version_obj.pk).update( | ||
| resource_count=total_resource_count, | ||
| kind_count=kind_counts, | ||
| size=int(published_size), | ||
| date_published=date_now, | ||
| version_notes=version_notes, | ||
| included_languages=language_list, | ||
| included_licenses=license_list, | ||
| included_categories=category_list, | ||
| non_distributable_licenses_included=non_distributable_licenses_included, |
There was a problem hiding this comment.
Nvm in my way to merge this PR, I noted the reason behind the change to use update, could you use the previous .save, please? And if there are any issues with the tests, the tests should be fixed instead.
There was a problem hiding this comment.
Done — reverted to version_obj.save(). The current tests use only le_utils license IDs (1–9) and standard language codes, so full_clean() passes cleanly.
There was a problem hiding this comment.
Thanks, waiting for tests to pass, and then will merge.
…metadata Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
fill_published_fieldswas never called during draft publishes, leaving draftChannelVersionobjects (those withversion=None) without computed metadata. This adds adraft_channel_versionparameter tofill_published_fields: when provided, ChannelVersion-level fields (resource_count,size,kind_count,included_languages, etc.) are written to the draft object while all channel-level fields (total_resource_count,published_size,published_data) andmark_channel_version_as_distributableare skipped.publish_channelnow passes the draftChannelVersionreturned bycreate_draft_channel_versiontofill_published_fields.Also fixes a pre-existing bug where
channel.included_languageswas accumulated (.add()) across publishes instead of replaced; it now uses.set()so removed languages are cleared.References
Closes #5839
Reviewer guidance
The key area to scrutinise is the
if not is_draftguard infill_published_fields(contentcuration/utils/publish.py) — any regression here would corruptchannel.published_dataorchannel.included_languagesfor live (non-draft) publishes. The existing test suite exercises the non-draft path; four new tests inDraftPublishChannelTestCasecover the draft path.AI usage
Implemented with Claude Code following a pre-written plan. Generated code reviewed against all acceptance criteria; unnecessary comments removed in a simplify pass. Full test suite run to confirm no regressions.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?