Skip to content

Commit e36b821

Browse files
rtibblesbotclaude
andauthored
fix: populate draft ChannelVersion metadata during draft publishes (learningequality#5841)
* feat: populate draft ChannelVersion metadata during draft publishes 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 * refactor: simplify draft publish metadata implementation after review * fix: use queryset update to bypass ChannelVersion full_clean validation 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> * refactor: address review feedback on draft publish tests and publish_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. * fix: replace channel.included_languages on publish instead of accumulating 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. * fix: use get() instead of create() for Special Permissions license in 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> * fix: move delete_public_channel_cache_keys into else branch of is_draft_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'. * fix: revert queryset update to version_obj.save() for ChannelVersion metadata Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9247207 commit e36b821

2 files changed

Lines changed: 257 additions & 51 deletions

File tree

contentcuration/contentcuration/tests/utils/test_publish.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from unittest import mock
55

66
from django.conf import settings
7+
from le_utils.constants import licenses
78

9+
import contentcuration.models as ccmodels
810
from contentcuration.tests import testdata
911
from contentcuration.tests.base import StudioTestCase
1012
from contentcuration.tests.utils.restricted_filesystemstorage import (
@@ -13,6 +15,7 @@
1315
from contentcuration.utils.publish import create_draft_channel_version
1416
from contentcuration.utils.publish import ensure_versioned_database_exists
1517
from contentcuration.utils.publish import increment_channel_version
18+
from contentcuration.utils.publish import publish_channel
1619

1720

1821
class EnsureVersionedDatabaseTestCase(StudioTestCase):
@@ -176,3 +179,203 @@ def test_mixed_draft_and_published_versions(self):
176179
self.assertIsNotNone(self.channel.version_info)
177180
self.assertEqual(self.channel.version_info.version, 1)
178181
self.assertIsNone(self.channel.version_info.secret_token)
182+
183+
184+
class DraftPublishChannelTestCase(StudioTestCase):
185+
"""
186+
Tests for the draft publish flow using the full publish_channel function.
187+
188+
Verifies that draft publishes correctly populate the draft ChannelVersion
189+
metadata while leaving channel-level fields untouched, and that
190+
special_permissions_included is fully replaced (not accumulated) on each
191+
draft publish.
192+
193+
save_export_database is mocked to avoid file-system operations; all other
194+
publish_channel logic runs as normal.
195+
"""
196+
197+
@classmethod
198+
def setUpClass(cls):
199+
super().setUpClass()
200+
cls._patch_save_export = mock.patch(
201+
"contentcuration.utils.publish.save_export_database"
202+
)
203+
cls._patch_save_export.start()
204+
205+
@classmethod
206+
def tearDownClass(cls):
207+
cls._patch_save_export.stop()
208+
super().tearDownClass()
209+
210+
def setUp(self):
211+
super().setUp()
212+
self.channel = testdata.channel()
213+
214+
# Fetch the Special Permissions license loaded by loadconstants.
215+
# Do NOT call create() — loadconstants inserts licenses with explicit PKs
216+
# so the PK sequence is still at 1, and create() would collide with id=1.
217+
self.special_perms_license = ccmodels.License.objects.get(
218+
license_name=licenses.SPECIAL_PERMISSIONS
219+
)
220+
221+
# Give the channel an existing published version so version_info is set.
222+
# increment_channel_version calls channel.save(), which triggers
223+
# Channel.on_update() (models.py ~line 1392). on_update() runs
224+
# ChannelVersion.objects.get_or_create(channel=self, version=self.version)
225+
# and assigns the result to self.version_info — so after this call,
226+
# channel.version_info is a real ChannelVersion, not None.
227+
increment_channel_version(self.channel)
228+
self.channel.refresh_from_db()
229+
230+
# Snapshot channel state before any draft publish.
231+
self.original_total_resource_count = self.channel.total_resource_count
232+
self.original_published_size = self.channel.published_size
233+
self.original_published_data = dict(self.channel.published_data)
234+
self.original_version_info_id = (
235+
self.channel.version_info.id if self.channel.version_info else None
236+
)
237+
238+
def _run_draft_publish(self, version_notes=""):
239+
publish_channel(
240+
self.admin_user.id,
241+
self.channel.id,
242+
version_notes=version_notes,
243+
force=False,
244+
force_exercises=False,
245+
send_email=False,
246+
progress_tracker=None,
247+
is_draft_version=True,
248+
use_staging_tree=False,
249+
)
250+
251+
def _get_draft_version(self):
252+
return ccmodels.ChannelVersion.objects.get(channel=self.channel, version=None)
253+
254+
# ------------------------------------------------------------------
255+
# Test 1: draft ChannelVersion metadata fields are populated
256+
# ------------------------------------------------------------------
257+
258+
def test_draft_channel_version_fields_are_populated(self):
259+
"""
260+
After a draft publish, the draft ChannelVersion has its metadata fields set.
261+
262+
testdata.channel() has no *published* nodes, so counts are 0 and lists are [].
263+
We assert specific values rather than assertIsNotNone to avoid vacuous passes
264+
(e.g. assertIsNotNone(0) always passes even if the field was never written).
265+
date_published is the only field we can only check for non-None, since its
266+
exact value is non-deterministic.
267+
"""
268+
self._run_draft_publish(version_notes="draft notes")
269+
draft_version = self._get_draft_version()
270+
271+
self.assertEqual(draft_version.resource_count, 0)
272+
self.assertEqual(draft_version.size, 0)
273+
self.assertEqual(draft_version.kind_count, [])
274+
self.assertIsNotNone(draft_version.date_published)
275+
self.assertEqual(draft_version.version_notes, "draft notes")
276+
self.assertEqual(draft_version.included_languages, [])
277+
self.assertEqual(draft_version.included_licenses, [])
278+
self.assertEqual(draft_version.included_categories, [])
279+
self.assertEqual(draft_version.non_distributable_licenses_included, [])
280+
281+
# ------------------------------------------------------------------
282+
# Test 2: channel-level fields are NOT touched
283+
# ------------------------------------------------------------------
284+
285+
def test_channel_fields_not_modified_during_draft_publish(self):
286+
"""
287+
A draft publish must not change channel.total_resource_count,
288+
channel.published_size, channel.published_data, or channel.version_info.
289+
"""
290+
self._run_draft_publish()
291+
self.channel.refresh_from_db()
292+
293+
self.assertEqual(
294+
self.channel.total_resource_count, self.original_total_resource_count
295+
)
296+
self.assertEqual(self.channel.published_size, self.original_published_size)
297+
self.assertEqual(self.channel.published_data, self.original_published_data)
298+
299+
current_version_info_id = (
300+
self.channel.version_info.id if self.channel.version_info else None
301+
)
302+
self.assertEqual(current_version_info_id, self.original_version_info_id)
303+
304+
# ------------------------------------------------------------------
305+
# Test 3: second draft publish replaces special_permissions_included
306+
# ------------------------------------------------------------------
307+
308+
def test_second_draft_publish_replaces_special_permissions_included(self):
309+
"""
310+
On a second consecutive draft publish, special_permissions_included on the
311+
draft ChannelVersion reflects only the current publish — licenses from the
312+
previous draft publish that are no longer present are removed.
313+
314+
Two publish_channel calls are made with a different license_description on
315+
the special-permissions node between them. After the second call the M2M
316+
must contain only the description used in that second call.
317+
"""
318+
# Get a video node from the channel's main tree to use as the content node.
319+
sp_node = (
320+
self.channel.main_tree.get_descendants().filter(kind_id="video").first()
321+
)
322+
323+
# First draft publish: node has Special Permissions with "License A".
324+
sp_node.license = self.special_perms_license
325+
sp_node.license_description = "License A"
326+
sp_node.published = True
327+
sp_node.save()
328+
329+
self._run_draft_publish()
330+
draft_version = self._get_draft_version()
331+
self.assertEqual(draft_version.special_permissions_included.count(), 1)
332+
self.assertEqual(
333+
draft_version.special_permissions_included.first().description, "License A"
334+
)
335+
336+
# Second draft publish: node's description changes to "License B".
337+
sp_node.license_description = "License B"
338+
sp_node.save()
339+
340+
self._run_draft_publish()
341+
draft_version.refresh_from_db()
342+
self.assertEqual(
343+
draft_version.special_permissions_included.count(),
344+
1,
345+
"special_permissions_included should be fully replaced on each draft publish",
346+
)
347+
self.assertEqual(
348+
draft_version.special_permissions_included.first().description, "License B"
349+
)
350+
351+
# ------------------------------------------------------------------
352+
# Test 4: distributable stays False for draft publishes of public channels
353+
# ------------------------------------------------------------------
354+
355+
def test_special_permissions_distributable_false_for_draft_publish(self):
356+
"""
357+
Even when channel.public is True, a draft publish must not mark
358+
AuditedSpecialPermissionsLicense records as distributable — the
359+
distributable field must remain False for all licenses linked to the
360+
draft ChannelVersion.
361+
"""
362+
sp_node = (
363+
self.channel.main_tree.get_descendants().filter(kind_id="video").first()
364+
)
365+
sp_node.license = self.special_perms_license
366+
sp_node.license_description = "Custom License"
367+
sp_node.published = True
368+
sp_node.save()
369+
370+
self.channel.public = True
371+
self.channel.save()
372+
373+
self._run_draft_publish()
374+
draft_version = self._get_draft_version()
375+
376+
self.assertGreater(draft_version.special_permissions_included.count(), 0)
377+
for license_obj in draft_version.special_permissions_included.all():
378+
self.assertFalse(
379+
license_obj.distributable,
380+
"distributable must stay False for draft publishes",
381+
)

contentcuration/contentcuration/utils/publish.py

Lines changed: 54 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -915,23 +915,22 @@ def add_tokens_to_channel(channel):
915915
channel.make_token()
916916

917917

918-
def fill_published_fields(channel, version_notes):
919-
channel.last_published = timezone.now()
918+
def fill_published_fields(channel, version_notes, draft_channel_version=None):
919+
is_draft = draft_channel_version is not None
920+
date_now = timezone.now()
921+
920922
published_nodes = (
921923
channel.main_tree.get_descendants()
922924
.filter(published=True)
923925
.prefetch_related("files")
924926
)
925-
channel.total_resource_count = published_nodes.exclude(
926-
kind_id=content_kinds.TOPIC
927-
).count()
927+
total_resource_count = published_nodes.exclude(kind_id=content_kinds.TOPIC).count()
928928
kind_counts = list(
929929
published_nodes.values("kind_id")
930930
.annotate(count=Count("kind_id"))
931931
.order_by("kind_id")
932932
)
933-
channel.published_kind_count = json.dumps(kind_counts)
934-
channel.published_size = (
933+
published_size = (
935934
published_nodes.values("files__checksum", "files__file_size")
936935
.distinct()
937936
.aggregate(resource_size=Sum("files__file_size"))["resource_size"]
@@ -946,10 +945,6 @@ def fill_published_fields(channel, version_notes):
946945
)
947946
language_list = list(set(chain(node_languages, file_languages)))
948947

949-
for lang in language_list:
950-
if lang:
951-
channel.included_languages.add(lang)
952-
953948
included_licenses = published_nodes.exclude(license=None).values_list(
954949
"license", flat=True
955950
)
@@ -969,24 +964,6 @@ def fill_published_fields(channel, version_notes):
969964
)
970965
)
971966

972-
# TODO: Eventually, consolidate above operations to just use this field for storing historical data
973-
channel.published_data.update(
974-
{
975-
channel.version: {
976-
"resource_count": channel.total_resource_count,
977-
"kind_count": kind_counts,
978-
"size": channel.published_size,
979-
"date_published": channel.last_published.strftime(
980-
settings.DATE_TIME_FORMAT
981-
),
982-
"version_notes": version_notes,
983-
"included_languages": language_list,
984-
"included_licenses": license_list,
985-
"included_categories": category_list,
986-
}
987-
}
988-
)
989-
990967
# Calculate non-distributable licenses (All Rights Reserved)
991968
all_rights_reserved_id = (
992969
ccmodels.License.objects.filter(license_name=licenses.ALL_RIGHTS_RESERVED)
@@ -1030,36 +1007,60 @@ def fill_published_fields(channel, version_notes):
10301007
new_licenses, ignore_conflicts=True
10311008
)
10321009

1033-
if channel.version_info:
1034-
channel.version_info.resource_count = channel.total_resource_count
1035-
channel.version_info.kind_count = kind_counts
1036-
channel.version_info.size = int(channel.published_size)
1037-
channel.version_info.date_published = channel.last_published
1038-
channel.version_info.version_notes = version_notes
1039-
channel.version_info.included_languages = language_list
1040-
channel.version_info.included_licenses = license_list
1041-
channel.version_info.included_categories = category_list
1042-
channel.version_info.non_distributable_licenses_included = (
1010+
if not is_draft:
1011+
channel.last_published = date_now
1012+
channel.total_resource_count = total_resource_count
1013+
channel.published_kind_count = json.dumps(kind_counts)
1014+
channel.published_size = published_size
1015+
1016+
channel.included_languages.set([lang for lang in language_list if lang])
1017+
1018+
# TODO: Eventually, consolidate above operations to just use this field for storing historical data
1019+
channel.published_data.update(
1020+
{
1021+
channel.version: {
1022+
"resource_count": total_resource_count,
1023+
"kind_count": kind_counts,
1024+
"size": published_size,
1025+
"date_published": date_now.strftime(settings.DATE_TIME_FORMAT),
1026+
"version_notes": version_notes,
1027+
"included_languages": language_list,
1028+
"included_licenses": license_list,
1029+
"included_categories": category_list,
1030+
}
1031+
}
1032+
)
1033+
channel.save()
1034+
1035+
version_obj = draft_channel_version if is_draft else channel.version_info
1036+
if version_obj is not None:
1037+
version_obj.resource_count = total_resource_count
1038+
version_obj.kind_count = kind_counts
1039+
version_obj.size = int(published_size)
1040+
version_obj.date_published = date_now
1041+
version_obj.version_notes = version_notes
1042+
version_obj.included_languages = language_list
1043+
version_obj.included_licenses = license_list
1044+
version_obj.included_categories = category_list
1045+
version_obj.non_distributable_licenses_included = (
10431046
non_distributable_licenses_included
10441047
)
1045-
channel.version_info.save()
1048+
version_obj.save()
10461049

10471050
if special_perms_descriptions:
1048-
channel.version_info.special_permissions_included.set(
1051+
version_obj.special_permissions_included.set(
10491052
ccmodels.AuditedSpecialPermissionsLicense.objects.filter(
10501053
description__in=special_perms_descriptions
10511054
)
10521055
)
10531056
else:
1054-
channel.version_info.special_permissions_included.clear()
1057+
version_obj.special_permissions_included.clear()
10551058

1056-
if channel.public:
1059+
if not is_draft and channel.public:
10571060
ccmodels.AuditedSpecialPermissionsLicense.mark_channel_version_as_distributable(
1058-
channel.version_info.id
1061+
version_obj.id
10591062
)
10601063

1061-
channel.save()
1062-
10631064

10641065
def sync_contentnode_and_channel_tsvectors(channel_id):
10651066
"""
@@ -1161,10 +1162,12 @@ def publish_channel( # noqa: C901
11611162
)
11621163
add_tokens_to_channel(channel)
11631164
if is_draft_version:
1164-
create_draft_channel_version(channel)
1165+
draft_channel_version = create_draft_channel_version(channel)
1166+
fill_published_fields(
1167+
channel, version_notes, draft_channel_version=draft_channel_version
1168+
)
11651169
else:
11661170
increment_channel_version(channel)
1167-
if not is_draft_version:
11681171
ccmodels.ChannelVersion.objects.filter(
11691172
channel=channel, version=None
11701173
).delete()
@@ -1180,9 +1183,9 @@ def publish_channel( # noqa: C901
11801183
base_tree.published = True
11811184
base_tree.save()
11821185

1183-
# Delete public channel cache.
1184-
if not is_draft_version and channel.public:
1185-
delete_public_channel_cache_keys()
1186+
# Delete public channel cache.
1187+
if channel.public:
1188+
delete_public_channel_cache_keys()
11861189

11871190
if send_email:
11881191
with override(language):

0 commit comments

Comments
 (0)