Skip to content

Commit 880f679

Browse files
committed
refator: Set False ad default for direct
1 parent 14486bf commit 880f679

4 files changed

Lines changed: 174 additions & 28 deletions

File tree

src/openedx_content/applets/publishing/api.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -848,25 +848,18 @@ def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog)
848848
# represents editing a Component, the side_effect_change is the
849849
# DraftChangeLogRecord that represents the fact that the containing
850850
# Unit was also altered (even if the Unit version doesn't change).
851-
side_effect_defaults: dict = {
852-
# If a change record already exists because the affected
853-
# entity was separately modified, then we don't touch the
854-
# old/new version entries. But if we're creating this change
855-
# record as a pure side-effect, then we use the (old_version
856-
# == new_version) convention to indicate that.
857-
'old_version_id': affected.version_id,
858-
'new_version_id': affected.version_id,
859-
}
860-
if branch_cls == Published:
861-
# Pure side-effect records are never directly requested by
862-
# the user, so mark them as indirect. If the record already
863-
# exists (entity was explicitly selected or is a dependency),
864-
# get_or_create won't overwrite the direct value it already has.
865-
side_effect_defaults['direct'] = False
866851
side_effect_change, _created = change_record_cls.objects.get_or_create(
867852
**change_log_param,
868853
entity_id=affected.entity_id,
869-
defaults=side_effect_defaults,
854+
defaults={
855+
# If a change record already exists because the affected
856+
# entity was separately modified, then we don't touch the
857+
# old/new version entries. But if we're creating this change
858+
# record as a pure side-effect, then we use the (old_version
859+
# == new_version) convention to indicate that.
860+
'old_version_id': affected.version_id,
861+
'new_version_id': affected.version_id,
862+
}
870863
)
871864
# Update the current branch pointer (Draft or Published) for this
872865
# entity to point to the side_effect_change (if it's not already).

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

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,75 @@ 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-
# True if this entity was explicitly requested to be published by the user
115-
# (e.g. they clicked "publish" on this entity or selected it for bulk publish).
116-
# False if it was indirectly published as a child/dependency of a directly
117-
# published entity (e.g. a Component published because its parent Unit was
118-
# published).
119-
# None for historical records created before this field was added.
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.
120179
direct = models.BooleanField(
121180
null=True,
122181
blank=True,
123-
default=None,
182+
default=False,
124183
)
125184

126185
class Meta:

src/openedx_content/migrations/0007_publishlogrecord_direct.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
from django.db import migrations, models
44

55

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+
616
class Migration(migrations.Migration):
717

818
dependencies = [
@@ -13,6 +23,10 @@ class Migration(migrations.Migration):
1323
migrations.AddField(
1424
model_name='publishlogrecord',
1525
name='direct',
16-
field=models.BooleanField(blank=True, default=None, null=True),
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,
1731
),
1832
]

tests/openedx_content/applets/publishing/test_api.py

Lines changed: 84 additions & 4 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()
@@ -937,16 +938,25 @@ def test_simple_publish_log(self) -> None:
937938

938939
def test_publish_all_drafts_sets_direct_true(self) -> None:
939940
"""publish_all_drafts() marks every PublishLogRecord as direct=True."""
940-
entity = publishing_api.create_publishable_entity(
941-
self.learning_package_1.id, "direct_entity",
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",
942951
created=self.now, created_by=None,
943952
)
944953
publishing_api.create_publishable_entity_version(
945-
entity.id, version_num=1, title="Direct Entity",
954+
entity_2.id, version_num=1, title="Direct Entity 2",
946955
created=self.now, created_by=None,
947956
)
948957
publish_log = publishing_api.publish_all_drafts(self.learning_package_1.id)
949-
assert publish_log.records.get(entity=entity).direct is True
958+
assert publish_log.records.get(entity=entity_1).direct is True
959+
assert publish_log.records.get(entity=entity_2).direct is True
950960

951961
def test_publish_from_drafts_sets_direct_true(self) -> None:
952962
"""An explicitly selected entity in publish_from_drafts() gets direct=True."""
@@ -964,6 +974,18 @@ def test_publish_from_drafts_sets_direct_true(self) -> None:
964974
)
965975
assert publish_log.records.get(entity=entity).direct is True
966976

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+
967989

968990
class EntitiesQueryTestCase(TestCase):
969991
"""
@@ -1486,6 +1508,64 @@ def test_direct_field_publishing_container_marks_dependencies_indirect(self) ->
14861508
assert publish_log.records.get(entity=unit.publishable_entity).direct is True
14871509
assert publish_log.records.get(entity=component).direct is False
14881510

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+
14891569
def test_direct_field_publishing_component_marks_parent_indirect(self) -> None:
14901570
"""
14911571
Publishing a Component directly marks the Component as direct=True.

0 commit comments

Comments
 (0)