Skip to content

Commit 0aec809

Browse files
committed
temp: a lot more iteration on draft side effect modeling
1 parent 6f8fd6d commit 0aec809

7 files changed

Lines changed: 216 additions & 185 deletions

File tree

openedx_learning/apps/authoring/publishing/api.py

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Draft,
2424
DraftChangeLogRecord,
2525
DraftChangeLog,
26+
DraftSideEffect,
2627
EntityList,
2728
EntityListRow,
2829
LearningPackage,
@@ -474,57 +475,148 @@ def set_draft_version(
474475
# DraftChangeLog (i.e. what happens if the caller is using the public
475476
# bulk_draft_changes_for() API call), or if we have to make our own.
476477
active_change_log = DraftChangeLogContext.get_active_draft_change_log(learning_package_id)
478+
change_log = active_change_log or DraftChangeLog.objects.create(
479+
learning_package_id=learning_package_id,
480+
changed_at=set_at,
481+
changed_by_id=set_by,
482+
)
477483

478484
if active_change_log:
479-
# If we get here, it means there is an active DraftChangeLog that may
480-
# have many DraftChanges associated with it. A DraftChangeLog can only
481-
# have one DraftChange per PublishableEntity, e.g. the same Component
482-
# can't go from v1 to v3 and v1 to v4 in the same DraftChangeLog.
483-
try:
484-
# If there was already a DraftChange for this PublishableEntity,
485-
# we update the new_version_id. We keep the old_version_id value
486-
# though, because that represents what it was before this
487-
# DraftChangeLog, and we don't want to lose that information.
488-
change = DraftChangeLogRecord.objects.get(
489-
draft_change_log=active_change_log,
490-
entity_id=publishable_entity_id,
491-
)
492-
change.new_version_id = publishable_entity_version_pk
493-
change.save()
494-
except DraftChangeLogRecord.DoesNotExist:
495-
# If we're here, this is the first DraftChange we're making for
496-
# this PublishableEntity in the active DraftChangeLog.
497-
change = DraftChangeLogRecord.objects.create(
498-
draft_change_log=active_change_log,
499-
entity_id=publishable_entity_id,
500-
old_version_id=old_version_id,
501-
new_version_id=publishable_entity_version_pk,
502-
)
485+
change = _add_to_existing_draft_change_log(
486+
active_change_log,
487+
publishable_entity_id,
488+
old_version_id=old_version_id,
489+
new_version_id=publishable_entity_version_pk,
490+
)
491+
# We explicitly *don't* create container side effects here because
492+
# there may be many changes in this DraftChangeLog, some of which
493+
# haven't been made yet. It wouldn't make sense to create a side
494+
# effect that says, "this Unit changed because this Component in it
495+
# changed" if we were changing that same Unit later on in the same
496+
# DraftChangeLog, because that new Unit version might not even
497+
# include the child Component. So we'll do that work when the
498+
# DraftChangeLogContext context manager closes.
503499
else:
504500
# This means there is no active DraftChangeLog, so we create our own
505501
# add add our DraftChange to it. This has the minor optimization
506502
# that we don't have to check for an existing DraftChange, because
507503
# we're creating the whole DraftChangeLog right here.
508-
new_change_log = DraftChangeLog.objects.create(
509-
learning_package_id=learning_package_id,
510-
changed_at=set_at,
511-
changed_by_id=set_by,
512-
)
513504
change = DraftChangeLogRecord.objects.create(
514-
draft_change_log=new_change_log,
505+
draft_change_log=change_log,
515506
entity_id=publishable_entity_id,
516507
old_version_id=old_version_id,
517508
new_version_id=publishable_entity_version_pk,
518-
)
509+
)
510+
_create_container_side_effects_for_draft_change(change)
511+
512+
513+
def _add_to_existing_draft_change_log(
514+
active_change_log: DraftChangeLog,
515+
entity_id: int,
516+
old_version_id: int | None,
517+
new_version_id: int | None,
518+
) -> DraftChangeLogRecord:
519+
# If we get here, it means there is an active DraftChangeLog that may
520+
# have many DraftChanges associated with it. A DraftChangeLog can only
521+
# have one DraftChange per PublishableEntity, e.g. the same Component
522+
# can't go from v1 to v3 and v1 to v4 in the same DraftChangeLog.
523+
try:
524+
# If there was already a DraftChange for this PublishableEntity,
525+
# we update the new_version_id. We keep the old_version_id value
526+
# though, because that represents what it was before this
527+
# DraftChangeLog, and we don't want to lose that information.
528+
change = DraftChangeLogRecord.objects.get(
529+
draft_change_log=active_change_log,
530+
entity_id=entity_id,
531+
)
532+
change.new_version_id = new_version_id
533+
change.save()
534+
except DraftChangeLogRecord.DoesNotExist:
535+
# If we're here, this is the first DraftChange we're making for
536+
# this PublishableEntity in the active DraftChangeLog.
537+
change = DraftChangeLogRecord.objects.create(
538+
draft_change_log=active_change_log,
539+
entity_id=entity_id,
540+
old_version_id=old_version_id,
541+
new_version_id=new_version_id,
542+
)
519543

520-
# One way or another, we've created our DraftChange at this point. Now
521-
# to see if we need to add any parent Draft containers that this change
522-
# may have affected (i.e. all unpinned references from Draft containers
523-
# to the entity that we just changed the Draft entry for)...
524-
containers = get_containers_with_entity(publishable_entity_id, ignore_pinned=True)
544+
return change
525545

526546

547+
def _create_container_side_effects_for_draft_change_log(change_log: DraftChangeLogRecord):
548+
seen_container_pks = set()
549+
for change in change_log.changes.all():
550+
_create_container_side_effects_for_draft_change(
551+
change,
552+
already_processed_containers_pks=seen_container_pks,
553+
)
554+
555+
556+
def _create_container_side_effects_for_draft_change(
557+
original_change: DraftChangeLogRecord,
558+
already_processed_containers_pks=None
559+
):
560+
"""
561+
Given a draft change, add side effects for all affected containers.
527562
563+
This should only be run after the DraftChangeLogRecord has been otherwise
564+
fully written out. We want to avoid the scenario where we create a
565+
side-effect that a Component change affects a Unit if the Unit version is
566+
also changed in the same DraftChangeLog.
567+
568+
TODO: This could get very expensive with the get_containers_with_entity
569+
calls. We should measure the impact of this.
570+
"""
571+
if already_processed_containers_pks is not None:
572+
seen_container_pks = already_processed_containers_pks
573+
else:
574+
seen_container_pks = set()
575+
576+
changes_and_containers = [
577+
(original_change, container)
578+
for container
579+
in get_containers_with_entity(original_change.entity_id, ignore_pinned=True)
580+
]
581+
while changes_and_containers:
582+
change, container = changes_and_containers.pop()
583+
# If we've already seen this container, no need to handle it again.
584+
# This also guards against infinite cycle chains of containers.
585+
if container.pk in seen_container_pks:
586+
continue
587+
seen_container_pks.add(container.pk)
588+
589+
# If the container is not already in the DraftChangeLog, we need to
590+
# add it. Since it's being caused as a DraftSideEffect, we're going
591+
# add it with the old_version == new_version convention.
592+
container_draft_version_pk = container.versioning.draft.pk
593+
container_change, _created = DraftChangeLogRecord.objects.get_or_create(
594+
draft_change_log=change.draft_change_log,
595+
entity_id=container.pk,
596+
old_version_id=container_draft_version_pk,
597+
defaults={
598+
'new_version_id': container_draft_version_pk
599+
}
600+
)
601+
602+
# Mark that change in the current loop has the side effect of changing
603+
# the parent. We'll do this regardless of whether the container version
604+
# itself also changed. If a Unit has a Component and both the Unit and
605+
# Component have their versions incremented, then the Unit has changed
606+
# in both ways (the Unit's internal metadata as well as the new version
607+
# of the child component).
608+
DraftSideEffect.objects.get_or_create(cause=change, effect=container_change)
609+
610+
# Now we find the next layer up of containers. So if the originally
611+
# passed in publishable_entity_id was for a Component, then the
612+
# ``container`` we've been creating the side effect for in this loop
613+
# is the Unit, and ``parents_of_container`` would be any Sequences
614+
# that contain the Unit.
615+
parents_of_container = get_containers_with_entity(container.pk, ignore_pinned=True)
616+
changes_and_containers.extend(
617+
(container_change, container_parent)
618+
for container_parent in parents_of_container
619+
)
528620

529621
def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None:
530622
"""
@@ -1107,10 +1199,17 @@ def get_containers_with_entity(
11071199
return qs
11081200

11091201

1110-
def bulk_draft_changes_for(learning_package_id: int) -> DraftChangeLog:
1202+
def bulk_draft_changes_for(learning_package_id: int, changed_by=None, changed_at=None) -> DraftChangeLog:
11111203
"""
11121204
Context manager to do a single batch of Draft changes in.
11131205
11141206
TODO: better description here with example usage.
11151207
"""
1116-
return DraftChangeLogContext(learning_package_id)
1208+
return DraftChangeLogContext(
1209+
learning_package_id,
1210+
changed_by=changed_by,
1211+
changed_at=changed_at,
1212+
exit_callbacks=[
1213+
_create_container_side_effects_for_draft_change_log,
1214+
]
1215+
)

openedx_learning/apps/authoring/publishing/contextmanagers.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ class DraftChangeLogContext(Atomic):
4040
any operation on multiple Drafts as part of a DraftChangeSet will want to be
4141
an atomic operation.
4242
"""
43-
_draft_change_sets: ContextVar[list | None] = ContextVar('_draft_change_sets', default=None)
43+
_draft_change_logs: ContextVar[list | None] = ContextVar('_draft_change_logs', default=None)
4444

45-
def __init__(self, learning_package_id, changed_by=None, changed_at=None):
45+
def __init__(self, learning_package_id, changed_by=None, changed_at=None, exit_callbacks=None):
4646
super().__init__(using=None, savepoint=False, durable=False)
4747

4848
self.learning_package_id = learning_package_id
4949
self.changed_by = changed_by
5050
self.changed_at = changed_at or datetime.now(tz=timezone.utc)
51+
self.exit_callbacks = exit_callbacks or []
5152

5253
@classmethod
5354
def get_active_draft_change_log(cls, learning_package_id: int) -> DraftChangeLog | None:
@@ -58,10 +59,10 @@ def get_active_draft_change_log(cls, learning_package_id: int) -> DraftChangeLog
5859
modifies Drafts. If there is no active DraftChangeSet, this method will
5960
return None, and the caller should create their own DraftChangeSet.
6061
"""
61-
draft_change_sets = cls._draft_change_sets.get()
62+
draft_change_logs = cls._draft_change_logs.get()
6263

6364
# If we've never used this manager...
64-
if draft_change_sets is None:
65+
if draft_change_logs is None:
6566
return None
6667

6768
# Otherwise, find the most recently created DraftChangeSet *that matches
@@ -76,9 +77,9 @@ def get_active_draft_change_log(cls, learning_package_id: int) -> DraftChangeLog
7677
# a performance penalty", as opposed to, "we accidentally gave you a
7778
# DraftChangeSet for an entirely different LearningPackage, and now
7879
# your Draft data is corrupted."
79-
for draft_change_set in reversed(draft_change_sets):
80-
if draft_change_set.learning_package_id == learning_package_id:
81-
return draft_change_set
80+
for draft_change_log in reversed(draft_change_logs):
81+
if draft_change_log.learning_package_id == learning_package_id:
82+
return draft_change_log
8283

8384
# If we got here, then either the list was empty (the manager was used
8485
# at some point but exited), or none of the DraftChangeSets are for the
@@ -88,23 +89,26 @@ def get_active_draft_change_log(cls, learning_package_id: int) -> DraftChangeLog
8889
def __enter__(self):
8990
super().__enter__()
9091

91-
self.draft_change_set = DraftChangeLog.objects.create(
92+
self.draft_change_log = DraftChangeLog.objects.create(
9293
learning_package_id=self.learning_package_id,
9394
changed_by=self.changed_by,
9495
changed_at=self.changed_at,
9596
)
96-
draft_change_sets = self._draft_change_sets.get()
97+
draft_change_sets = self._draft_change_logs.get()
9798
if not draft_change_sets:
9899
draft_change_sets = []
99-
draft_change_sets.append(self.draft_change_set)
100-
self._draft_change_sets.set(draft_change_sets)
100+
draft_change_sets.append(self.draft_change_log)
101+
self._draft_change_logs.set(draft_change_sets)
101102

102-
return self.draft_change_set
103+
return self.draft_change_log
103104

104105
def __exit__(self, type, value, traceback):
105-
draft_change_sets = self._draft_change_sets.get()
106-
if draft_change_sets:
107-
draft_change_sets.pop()
108-
self._draft_change_sets.set(draft_change_sets)
106+
draft_change_logs = self._draft_change_logs.get()
107+
if draft_change_logs:
108+
draft_change_log = draft_change_logs.pop()
109+
for exit_callback in self.exit_callbacks:
110+
exit_callback(draft_change_log)
111+
112+
self._draft_change_logs.set(draft_change_logs)
109113

110114
return super().__exit__(type, value, traceback)

openedx_learning/apps/authoring/publishing/migrations/0004_draftchangelog.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2.18 on 2025-03-27 20:01
1+
# Generated by Django 4.2.18 on 2025-03-28 02:21
22

33
from django.conf import settings
44
from django.db import migrations, models
@@ -25,8 +25,8 @@ class Migration(migrations.Migration):
2525
('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')),
2626
],
2727
options={
28-
'verbose_name': 'Draft Change Set',
29-
'verbose_name_plural': 'Draft Change Sets',
28+
'verbose_name': 'Draft Change Log',
29+
'verbose_name_plural': 'Draft Change Logs',
3030
},
3131
),
3232
migrations.CreateModel(
@@ -47,10 +47,14 @@ class Migration(migrations.Migration):
4747
name='DraftSideEffect',
4848
fields=[
4949
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
50-
('cause', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.draftchangelogrecord')),
51-
('effect', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.draftchangelogrecord')),
50+
('cause', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='causes', to='oel_publishing.draftchangelogrecord')),
51+
('effect', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='caused_by', to='oel_publishing.draftchangelogrecord')),
5252
],
5353
),
54+
migrations.AddConstraint(
55+
model_name='draftsideeffect',
56+
constraint=models.UniqueConstraint(fields=('cause', 'effect'), name='oel_pub_dse_uniq_c_e'),
57+
),
5458
migrations.AddIndex(
5559
model_name='draftchangelogrecord',
5660
index=models.Index(fields=['entity', '-draft_change_log'], name='oel_dlr_idx_entity_rdcl'),

0 commit comments

Comments
 (0)