Skip to content

Publish side effects: state summary edition #328

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

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Open edX Learning ("Learning Core").
"""

__version__ = "0.26.0"
__version__ = "0.27.0"
7 changes: 7 additions & 0 deletions openedx_learning/apps/authoring/publishing/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
list_display = [
"key",
"draft_version",
"draft_state",
"published_version",
"uuid",
"learning_package",
Expand All @@ -102,6 +103,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
fields = [
"key",
"draft_version",
"draft_state",
"published_version",
"uuid",
"learning_package",
Expand All @@ -113,6 +115,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
readonly_fields = [
"key",
"draft_version",
"draft_state",
"published_version",
"uuid",
"learning_package",
Expand All @@ -136,6 +139,10 @@ def draft_version(self, entity):
return entity.draft.version.version_num
return None

def draft_state(self, entity):
if entity.draft:
return entity.draft.state_hash

def published_version(self, entity):
if entity.published.version:
return entity.published.version.version_num
Expand Down
314 changes: 275 additions & 39 deletions openedx_learning/apps/authoring/publishing/api.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Generated by Django 4.2.16 on 2025-04-17 08:47

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("oel_publishing", "0008_alter_draftchangelogrecord_options_and_more"),
]

operations = [
migrations.AlterModelOptions(
name="draftchangelogrecord",
options={
"verbose_name": "Draft Change Log Record",
"verbose_name_plural": "Draft Change Log Records",
},
),
migrations.AlterModelOptions(
name="draftsideeffect",
options={
"verbose_name": "Draft Side Effect",
"verbose_name_plural": "Draft Side Effects",
},
),
migrations.AlterField(
model_name="draftchangelogrecord",
name="draft_change_log",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="records",
to="oel_publishing.draftchangelog",
),
),
migrations.AlterField(
model_name="draftsideeffect",
name="effect",
field=models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="affected_by",
to="oel_publishing.draftchangelogrecord",
),
),
migrations.CreateModel(
name="PublishSideEffect",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"cause",
models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="causes",
to="oel_publishing.publishlogrecord",
),
),
(
"effect",
models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="affected_by",
to="oel_publishing.publishlogrecord",
),
),
],
options={
"verbose_name": "Publish Side Effect",
"verbose_name_plural": "Publish Side Effects",
},
),
migrations.AddConstraint(
model_name="publishsideeffect",
constraint=models.UniqueConstraint(
fields=("cause", "effect"), name="oel_pub_pse_uniq_c_e"
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.21 on 2025-06-03 20:40

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('oel_publishing', '0009_create_publishsideeffect'),
]

operations = [
migrations.AddField(
model_name='draft',
name='state_hash',
field=models.CharField(blank=True, default='', editable=False, max_length=40),
),
migrations.CreateModel(
name='DraftDependency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('dependency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='causes_side_effects_for', to='oel_publishing.draft')),
('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dependencies', to='oel_publishing.draft')),
('version_at_creation', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='draft_dependencies_created', to='oel_publishing.publishableentityversion')),
],
),
migrations.AddConstraint(
model_name='draftdependency',
constraint=models.UniqueConstraint(fields=('draft', 'dependency'), name='oel_pub_dd_uniq_draft_dep'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 4.2.21 on 2025-06-07 00:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('oel_publishing', '0010_draft_dependency'),
]

operations = [
migrations.RemoveConstraint(
model_name='draftdependency',
name='oel_pub_dd_uniq_draft_dep',
),
migrations.RenameField(
model_name='draftdependency',
old_name='draft',
new_name='target',
),
migrations.AddConstraint(
model_name='draftdependency',
constraint=models.UniqueConstraint(fields=('target', 'dependency'), name='oel_pub_dd_uniq_target_dep'),
),
]
10 changes: 8 additions & 2 deletions openedx_learning/apps/authoring/publishing/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@
"""

from .container import Container, ContainerVersion
from .draft_log import Draft, DraftChangeLog, DraftChangeLogRecord, DraftSideEffect
from .draft_log import (
Draft,
DraftChangeLog,
DraftChangeLogRecord,
DraftDependency,
DraftSideEffect,
)
from .entity_list import EntityList, EntityListRow
from .learning_package import LearningPackage
from .publish_log import Published, PublishLog, PublishLogRecord
from .publish_log import Published, PublishLog, PublishLogRecord, PublishSideEffect
from .publishable_entity import (
PublishableContentModelRegistry,
PublishableEntity,
Expand Down
62 changes: 61 additions & 1 deletion openedx_learning/apps/authoring/publishing/models/draft_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from django.db import models
from django.utils.translation import gettext_lazy as _

from openedx_learning.lib.fields import immutable_uuid_field, manual_date_time_field
from openedx_learning.lib.fields import (
hash_field,
immutable_uuid_field,
manual_date_time_field,
)

from .learning_package import LearningPackage
from .publishable_entity import PublishableEntity, PublishableEntityVersion
Expand Down Expand Up @@ -56,6 +60,19 @@ class Draft(models.Model):
blank=True,
)

# The state_hash is used when the version alone isn't enough to let us know
# the full draft state of an entity. This happens any time a Draft has
# dependencies (see the DraftDependency model), because changes in those
# dependencies will cause changes to the state of the Draft. An example of
# this is contianers, where changing an unpinned child affects the state of
# the parent container, even if that container's definition (and thus
# version) does not change.
#
# If a Draft has no dependencies, then its entire state is captured by its
# version, and the state_hash is blank. (Blank is slightly more convenient
# for database comparisons than NULL.)
state_hash = hash_field(blank=True, default='')


class DraftChangeLog(models.Model):
"""
Expand Down Expand Up @@ -313,3 +330,46 @@ class Meta:
]
verbose_name = _("Draft Side Effect")
verbose_name_plural = _("Draft Side Effects")


# This needs to go in the Draft model?
# The new_state_hash is used when the version alone isn't enough to let us
# know the full published state of an entity. This happens with Containers,
# where the published state of the container can change because its children
# are published.
# state_hash = hash_field(blank=True, default='')
#
# It needs to exist in the Published model too, but we *can't assume it'll copy*
# because different subsets of children could be published, so we need to be
# able to recalculate based on the published state of the dependencies.


class DraftDependency(models.Model):
"""

"""
target = models.ForeignKey(
Draft,
on_delete=models.CASCADE,
related_name="dependencies",
)
dependency = models.ForeignKey(
Draft,
on_delete=models.CASCADE,
related_name="causes_side_effects_for",
)
version_at_creation = models.ForeignKey(
PublishableEntityVersion,
related_name="draft_dependencies_created",
on_delete=models.RESTRICT,
)

class Meta:
constraints = [
# Duplicate entries for a dependency are just redundant. This is
# here to guard against weird bugs that might introduce this state.
models.UniqueConstraint(
fields=["target", "dependency"],
name="oel_pub_dd_uniq_target_dep",
)
]
64 changes: 63 additions & 1 deletion openedx_learning/apps/authoring/publishing/models/publish_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
"""
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _

from openedx_learning.lib.fields import case_insensitive_char_field, immutable_uuid_field, manual_date_time_field
from openedx_learning.lib.fields import (
case_insensitive_char_field,
immutable_uuid_field,
manual_date_time_field,
)

from .learning_package import LearningPackage
from .publishable_entity import PublishableEntity, PublishableEntityVersion
Expand Down Expand Up @@ -61,6 +66,15 @@ class PublishLogRecord(models.Model):

To revert a publish, we would make a new publish that swaps ``old_version``
and ``new_version`` field values.

If the old_version and new_version of a PublishLogRecord match, it means
that the definition of the entity itself did not change (i.e. no new
PublishableEntityVersion was created), but something else was published that
had the side-effect of changing the published state of this entity. For
instance, if a Unit has unpinned references to its child Components (which
it almost always will), then publishing one of those Components will alter
the published state of the Unit, even if the UnitVersion does not change. In
that case, we still consider the Unit to have been "published".
"""

publish_log = models.ForeignKey(
Expand Down Expand Up @@ -148,3 +162,51 @@ class Published(models.Model):
class Meta:
verbose_name = "Published Entity"
verbose_name_plural = "Published Entities"


class PublishSideEffect(models.Model):
"""
Model to track when a change in one Published entity affects others.

Our first use case for this is that changes involving child components are
thought to affect parent Units, even if the parent's version doesn't change.

Side-effects are recorded in a collapsed form that only captures one level.
So if Components C1 and C2 are both published and they are part of Unit U1,
which is in turn a part of Subsection SS1, then the PublishSideEffect
entries are::

(C1, U1)
(C2, U1)
(U1, SS1)

We do not keep entries for (C1, SS1) or (C2, SS1). This is to make the model
simpler, so we don't have to differentiate between direct side-effects and
transitive side-effects in the model.
.. no_pii:
"""
cause = models.ForeignKey(
PublishLogRecord,
on_delete=models.RESTRICT,
related_name='causes',
)
effect = models.ForeignKey(
PublishLogRecord,
on_delete=models.RESTRICT,
related_name='affected_by',
)

class Meta:
constraints = [
# Duplicate entries for cause & effect are just redundant. This is
# here to guard against weird bugs that might introduce this state.
models.UniqueConstraint(
fields=["cause", "effect"],
name="oel_pub_pse_uniq_c_e",
)
]
verbose_name = _("Publish Side Effect")
verbose_name_plural = _("Publish Side Effects")



Loading
Loading