Skip to content

Commit 10eae2d

Browse files
authored
feat: track direct vs. indirect publish records [FC-0123] (#539)
Prior to this commit, a PublishLog and its PublishLogRecords tracked what was published, but not what the user requested to be published. For instance, say we have a Unit with no draft changes, and that Unit has a Component that does have draft changes. The user can click the "Publish Changes" button at either the Unit level or the Component level, and both scenarios would have given the exact same PublishLog: - Component was published (version changed) - Unit was affected by the publish (version unchanged) When displaying the history log to the user, the UX requires that we differentiate between these two things. To do this, we're adding a "direct" field to PublishLogRecord. We say that something is a "direct publish" (direct=True) if that PublishLogRecord was explicitly requested by the author, i.e. they clicked "Publish Changes" on that exact entity. Any child elements that are published are considered to be "indirectly published", and any parents are "indirectly affected"—both of these would have direct=False. More details and examples are in the comments for the PublishLogRecord.direct field. Pre-existing PublishLogRecords get direct=None since intent cannot be determined retroactively in many cases.
1 parent 5391abc commit 10eae2d

9 files changed

Lines changed: 318 additions & 7 deletions

File tree

‎src/openedx_content/applets/publishing/admin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class PublishLogRecordTabularInline(admin.TabularInline):
4343
"old_version_num",
4444
"new_version_num",
4545
"dependencies_hash_digest",
46+
"direct",
4647
)
4748
readonly_fields = fields
4849

‎src/openedx_content/applets/publishing/api.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,9 @@ def publish_from_drafts(
445445
else:
446446
dependency_drafts_qsets = []
447447

448+
# Collect PKs of directly-requested drafts before expanding dependencies.
449+
direct_draft_ids = set(draft_qset.values_list('pk', flat=True))
450+
448451
# One PublishLog for this entire publish operation.
449452
publish_log = PublishLog(
450453
learning_package_id=learning_package_id,
@@ -484,6 +487,7 @@ def publish_from_drafts(
484487
entity=draft.entity,
485488
old_version=old_version,
486489
new_version=draft.version,
490+
direct=draft.pk in direct_draft_ids,
487491
)
488492
publish_log_record.full_clean()
489493
publish_log_record.save(force_insert=True)

‎src/openedx_content/applets/publishing/models/publish_log.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,77 @@ class PublishLogRecord(models.Model):
111111
# the values may drift away from each other.
112112
dependencies_hash_digest = hash_field(blank=True, default='', max_length=8)
113113

114+
# The "direct" field captures user intent during the publishing process. It
115+
# is True if the user explicitly requested to publish the entity represented
116+
# by this PublishLogRecord—i.e. they clicked "publish" on this entity or
117+
# selected it for bulk publish.
118+
#
119+
# This field is False if this entity was indirectly published either as a
120+
# child/dependency or side-effect of a directly published entity.
121+
#
122+
# If this field is None, that means that this PublishLogRecord was created
123+
# before we started capturing user intent (pre-Verawood release), and we
124+
# cannot reliably infer what the user clicked on. For example, say we had a
125+
# Subsection > Unit > Component arrangement where the Component had an
126+
# unpublished change. The user is allowed to press the "publish" button at
127+
# the Subsection, Unit, or Component levels in the UI. Before we started
128+
# recording this field, the resulting PublishLogs would have looked
129+
# identical in all three cases: a version change PublishLogRecord for
130+
# Component, and side-effect records for the Unit and Subsection. Therefore,
131+
# we cannot accurately backfill this field.
132+
#
133+
# Here are some examples to illustrate how "direct" gets set and why:
134+
#
135+
# Example 1: The user clicks "publish" on a Component that's in a Unit.
136+
#
137+
# The Component has direct=True, but the side-effect PublishLogRecord for
138+
# the Unit has direct=False. Likewise, any side-effect records at higher
139+
# levels (subsection, section) also have direct=False.
140+
#
141+
# Example 2: The user clicks "publish" on a Unit, where both the Unit and
142+
# Component have unpublished changes:
143+
#
144+
# In this case, the Unit has direct=True, and the Component has
145+
# direct=False. The draft status of the Component is irrelevant. The user
146+
# asked for the Unit to the published, so the Unit's PublishLogRecord is
147+
# the only thing that gets direct=True.
148+
#
149+
# Example 3: The user clicks "publish" on a Unit that has no changes of its
150+
# own (draft version == published version), but the Unit contains a Component
151+
# that has changes.
152+
#
153+
# Again, only the PublishLogRecord for the Unit has direct=True. The
154+
# Component's PublishLogRecord has direct=False. Even though the Unit's
155+
# published version_num does not change (i.e. it is purely a side-effect
156+
# publish), the user intent was to publish the Unit (and anything it
157+
# contains), so the Unit gets direct=True.
158+
#
159+
# Example 4: The user selects multiple entities for bulk publishing.
160+
#
161+
# Those exact entities that the user selected get direct=True. It does not
162+
# matter if some of those entities are children of other selected items or
163+
# not. Other entries like dependencies or side-effects have direct=False.
164+
#
165+
# Example 5: The user selects "publish all".
166+
#
167+
# Selecting "publish all" in our system currently translates into "publish
168+
# all the entities that have a draft version that is different from its
169+
# published version". Those entities would get PublishLogRecords with
170+
# direct=True, while all side-effects would get records with direct=False.
171+
# So if a Unit's draft and published versions match, and one of its
172+
# Components has unpublished changes, then "publish all" would cause the
173+
# Component's record to have direct=True and the Unit's record to have
174+
# direct=False.
175+
#
176+
# All PublishLogRecords in the PublishLog have direct=True. The "publish
177+
# all" operation is indistinguishable from bulk publishing and selecting
178+
# every single item.
179+
direct = models.BooleanField(
180+
null=True,
181+
blank=True,
182+
default=False,
183+
)
184+
114185
class Meta:
115186
constraints = [
116187
# A Publishable can have only one PublishLogRecord per PublishLog.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.2.13 on 2026-04-13 21:28
2+
3+
from django.db import migrations, models
4+
5+
6+
def backfill_direct_to_none(apps, schema_editor):
7+
"""
8+
Set direct=None for all pre-existing PublishLogRecords so they are treated
9+
as historical records whose user intent cannot be determined retroactively.
10+
New records created after this migration will default to direct=False.
11+
"""
12+
PublishLogRecord = apps.get_model('openedx_content', 'PublishLogRecord')
13+
PublishLogRecord.objects.update(direct=None)
14+
15+
16+
class Migration(migrations.Migration):
17+
18+
dependencies = [
19+
('openedx_content', '0006_typed_ids'),
20+
]
21+
22+
operations = [
23+
migrations.AddField(
24+
model_name='publishlogrecord',
25+
name='direct',
26+
field=models.BooleanField(blank=True, default=False, null=True),
27+
),
28+
migrations.RunPython(
29+
backfill_direct_to_none,
30+
reverse_code=migrations.RunPython.noop,
31+
),
32+
]

‎tests/openedx_content/applets/containers/test_api.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ def test_contains_unpublished_changes_queries(
925925
assert containers_api.contains_unpublished_changes(grandparent.id)
926926

927927
# Publish grandparent and all its descendants:
928-
with django_assert_num_queries(135): # TODO: investigate as this seems high!
928+
with django_assert_num_queries(136): # TODO: investigate as this seems high!
929929
publish_entity(grandparent)
930930

931931
# Tests:
@@ -1244,7 +1244,7 @@ def test_uninstalled_publish(
12441244
"""Simple test of publishing a container of uninstalled type, plus its child, and reviewing the publish log"""
12451245
# Publish container_of_uninstalled_type (and child_entity1). Should not affect anything else,
12461246
# but we should see "child_entity1" omitted from the subsequent publish.
1247-
with django_assert_num_queries(49):
1247+
with django_assert_num_queries(50):
12481248
publish_log = publish_entity(container_of_uninstalled_type)
12491249
# Nothing else should have been affected by the publish:
12501250
assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
@@ -1282,7 +1282,7 @@ def test_deep_publish_log(
12821282
)
12831283
# Publish container_of_uninstalled_type (and child_entity1). Should not affect anything else,
12841284
# but we should see "child_entity1" omitted from the subsequent publish.
1285-
with django_assert_num_queries(49):
1285+
with django_assert_num_queries(50):
12861286
publish_log = publish_entity(container_of_uninstalled_type)
12871287
# Nothing else should have been affected by the publish:
12881288
assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
@@ -1291,7 +1291,7 @@ def test_deep_publish_log(
12911291
]
12921292

12931293
# Publish great_grandparent. Should publish the whole tree.
1294-
with django_assert_num_queries(126):
1294+
with django_assert_num_queries(127):
12951295
publish_log = publish_entity(great_grandparent)
12961296
assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
12971297
"child_entity2",

‎tests/openedx_content/applets/publishing/test_api.py‎

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
LearningPackage,
2121
PublishableEntity,
2222
PublishLog,
23+
PublishLogRecord,
2324
)
2425

2526
User = get_user_model()
@@ -935,6 +936,56 @@ def test_simple_publish_log(self) -> None:
935936
assert e1_pub_record.old_version == entity1_v1
936937
assert e1_pub_record.new_version == entity1_v2
937938

939+
def test_publish_all_drafts_sets_direct_true(self) -> None:
940+
"""publish_all_drafts() marks every PublishLogRecord as direct=True."""
941+
entity_1 = publishing_api.create_publishable_entity(
942+
self.learning_package_1.id, "direct_entity_1",
943+
created=self.now, created_by=None,
944+
)
945+
publishing_api.create_publishable_entity_version(
946+
entity_1.id, version_num=1, title="Direct Entity 1",
947+
created=self.now, created_by=None,
948+
)
949+
entity_2 = publishing_api.create_publishable_entity(
950+
self.learning_package_1.id, "direct_entity_2",
951+
created=self.now, created_by=None,
952+
)
953+
publishing_api.create_publishable_entity_version(
954+
entity_2.id, version_num=1, title="Direct Entity 2",
955+
created=self.now, created_by=None,
956+
)
957+
publish_log = publishing_api.publish_all_drafts(self.learning_package_1.id)
958+
assert publish_log.records.get(entity=entity_1).direct is True
959+
assert publish_log.records.get(entity=entity_2).direct is True
960+
961+
def test_publish_from_drafts_sets_direct_true(self) -> None:
962+
"""An explicitly selected entity in publish_from_drafts() gets direct=True."""
963+
entity = publishing_api.create_publishable_entity(
964+
self.learning_package_1.id, "explicit_entity",
965+
created=self.now, created_by=None,
966+
)
967+
publishing_api.create_publishable_entity_version(
968+
entity.id, version_num=1, title="Explicit Entity",
969+
created=self.now, created_by=None,
970+
)
971+
publish_log = publishing_api.publish_from_drafts(
972+
self.learning_package_1.id,
973+
Draft.objects.filter(entity=entity),
974+
)
975+
assert publish_log.records.get(entity=entity).direct is True
976+
977+
def test_publish_log_record_direct_defaults_to_false(self) -> None:
978+
"""
979+
New PublishLogRecords default to direct=False (not None).
980+
981+
None is reserved for historical records that pre-date the direct field
982+
(set via the backfill data migration). Records created by the
983+
application—e.g. side-effect records in _create_side_effects_for_change_log()
984+
that don't explicitly set direct—should get False, not None.
985+
"""
986+
field = PublishLogRecord._meta.get_field('direct')
987+
assert field.default is False
988+
938989

939990
class EntitiesQueryTestCase(TestCase):
940991
"""
@@ -1429,6 +1480,158 @@ def test_publish_all_layers(self) -> None:
14291480
# the publish log records.
14301481
assert publish_log.records.count() == 3
14311482

1483+
def test_direct_field_publishing_container_marks_dependencies_indirect(self) -> None:
1484+
"""
1485+
Publishing a Unit explicitly marks the Unit as direct=True and its
1486+
unpublished Component dependency as direct=False.
1487+
"""
1488+
component = publishing_api.create_publishable_entity(
1489+
self.learning_package.id, "direct_component",
1490+
created=self.now, created_by=None,
1491+
)
1492+
publishing_api.create_publishable_entity_version(
1493+
component.id, version_num=1, title="Direct Component",
1494+
created=self.now, created_by=None,
1495+
)
1496+
unit = containers_api.create_container(
1497+
self.learning_package.id, "direct_unit",
1498+
created=self.now, created_by=None, container_cls=TestContainer,
1499+
)
1500+
containers_api.create_container_version(
1501+
unit.id, 1, title="Direct Unit", entities=[component],
1502+
created=self.now, created_by=None,
1503+
)
1504+
publish_log = publishing_api.publish_from_drafts(
1505+
self.learning_package.id,
1506+
Draft.objects.filter(entity=unit.publishable_entity),
1507+
)
1508+
assert publish_log.records.get(entity=unit.publishable_entity).direct is True
1509+
assert publish_log.records.get(entity=component).direct is False
1510+
1511+
def test_direct_field_unit_no_version_change_still_direct_true(self) -> None:
1512+
"""
1513+
Publishing a Unit that has no version change of its own (draft version
1514+
== published version) still marks the Unit's record as direct=True.
1515+
1516+
The user explicitly selected the Unit to publish, so it gets direct=True
1517+
even though the only actual change is in its Component child. The Unit's
1518+
record has old_version == new_version (pure side-effect in terms of
1519+
versioning), but user intent was directed at the Unit.
1520+
"""
1521+
component = publishing_api.create_publishable_entity(
1522+
self.learning_package.id, "no_change_component",
1523+
created=self.now, created_by=None,
1524+
)
1525+
component_v1 = publishing_api.create_publishable_entity_version(
1526+
component.id, version_num=1, title="No-change Component",
1527+
created=self.now, created_by=None,
1528+
)
1529+
unit = containers_api.create_container(
1530+
self.learning_package.id, "no_change_unit",
1531+
created=self.now, created_by=None, container_cls=TestContainer,
1532+
)
1533+
unit_v1 = containers_api.create_container_version(
1534+
unit.id, 1, title="No-change Unit", entities=[component],
1535+
created=self.now, created_by=None,
1536+
)
1537+
# Initial publish so both Unit and Component have a published version.
1538+
publishing_api.publish_from_drafts(
1539+
self.learning_package.id,
1540+
Draft.objects.filter(entity=unit.publishable_entity),
1541+
)
1542+
1543+
# Create a new Component version. The Unit's draft stays at unit_v1,
1544+
# but its dependencies_hash_digest now differs from the published state.
1545+
publishing_api.create_publishable_entity_version(
1546+
component.id, version_num=2, title="No-change Component v2",
1547+
created=self.now, created_by=None,
1548+
)
1549+
1550+
# Publish the Unit explicitly. The Unit has no version change of its
1551+
# own (old_version == new_version == unit_v1).
1552+
publish_log = publishing_api.publish_from_drafts(
1553+
self.learning_package.id,
1554+
Draft.objects.filter(entity=unit.publishable_entity),
1555+
)
1556+
unit_record = publish_log.records.get(entity=unit.publishable_entity)
1557+
component_record = publish_log.records.get(entity=component)
1558+
1559+
# User selected the Unit → direct=True despite no version change.
1560+
assert unit_record.direct is True
1561+
assert unit_record.old_version_id == unit_v1.pk
1562+
assert unit_record.new_version_id == unit_v1.pk
1563+
1564+
# Component was pulled in as a dependency → direct=False.
1565+
assert component_record.direct is False
1566+
assert component_record.old_version == component_v1
1567+
assert component_record.new_version != component_v1
1568+
1569+
def test_direct_field_publishing_component_marks_parent_indirect(self) -> None:
1570+
"""
1571+
Publishing a Component directly marks the Component as direct=True.
1572+
The parent Unit also gets a PublishLogRecord (because it has an unpinned
1573+
reference to the Component and its dependencies_hash_digest now differs
1574+
from the published state) with direct=False.
1575+
"""
1576+
component = publishing_api.create_publishable_entity(
1577+
self.learning_package.id, "leaf_component",
1578+
created=self.now, created_by=None,
1579+
)
1580+
publishing_api.create_publishable_entity_version(
1581+
component.id, version_num=1, title="Leaf Component",
1582+
created=self.now, created_by=None,
1583+
)
1584+
unit = containers_api.create_container(
1585+
self.learning_package.id, "leaf_unit",
1586+
created=self.now, created_by=None, container_cls=TestContainer,
1587+
)
1588+
containers_api.create_container_version(
1589+
unit.id, 1, title="Leaf Unit", entities=[component],
1590+
created=self.now, created_by=None,
1591+
)
1592+
# First publish everything to establish a published baseline for the Unit
1593+
publishing_api.publish_all_drafts(self.learning_package.id)
1594+
1595+
# Create a new component version so it has unpublished changes
1596+
publishing_api.create_publishable_entity_version(
1597+
component.id, version_num=2, title="Leaf Component v2",
1598+
created=self.now, created_by=None,
1599+
)
1600+
publish_log = publishing_api.publish_from_drafts(
1601+
self.learning_package.id,
1602+
Draft.objects.filter(entity=component),
1603+
)
1604+
assert publish_log.records.get(entity=component).direct is True
1605+
assert publish_log.records.get(entity=unit.publishable_entity).direct is False
1606+
1607+
def test_direct_field_both_selected_both_direct(self) -> None:
1608+
"""
1609+
When both a Unit and its Component are explicitly selected, both
1610+
get direct=True even though Component is also a dependency of Unit.
1611+
"""
1612+
component = publishing_api.create_publishable_entity(
1613+
self.learning_package.id, "both_component",
1614+
created=self.now, created_by=None,
1615+
)
1616+
publishing_api.create_publishable_entity_version(
1617+
component.id, version_num=1, title="Both Component",
1618+
created=self.now, created_by=None,
1619+
)
1620+
unit = containers_api.create_container(
1621+
self.learning_package.id, "both_unit",
1622+
created=self.now, created_by=None, container_cls=TestContainer,
1623+
)
1624+
containers_api.create_container_version(
1625+
unit.id, 1, title="Both Unit", entities=[component],
1626+
created=self.now, created_by=None,
1627+
)
1628+
publish_log = publishing_api.publish_from_drafts(
1629+
self.learning_package.id,
1630+
Draft.objects.filter(entity__in=[component, unit.publishable_entity]),
1631+
)
1632+
assert publish_log.records.get(entity=component).direct is True
1633+
assert publish_log.records.get(entity=unit.publishable_entity).direct is True
1634+
14321635
def test_container_next_version(self) -> None:
14331636
"""Test that next_version works for containers."""
14341637
child_1 = publishing_api.create_publishable_entity(

0 commit comments

Comments
 (0)