openedx-learning 0.29.1__py2.py3-none-any.whl → 0.30.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,9 +13,11 @@ from enum import Enum
13
13
  from typing import ContextManager, Optional, TypeVar
14
14
 
15
15
  from django.core.exceptions import ObjectDoesNotExist, ValidationError
16
- from django.db.models import F, Q, QuerySet
16
+ from django.db.models import F, Prefetch, Q, QuerySet
17
17
  from django.db.transaction import atomic
18
18
 
19
+ from openedx_learning.lib.fields import create_hash_digest
20
+
19
21
  from .contextmanagers import DraftChangeLogContext
20
22
  from .models import (
21
23
  Container,
@@ -31,9 +33,11 @@ from .models import (
31
33
  PublishableEntity,
32
34
  PublishableEntityMixin,
33
35
  PublishableEntityVersion,
36
+ PublishableEntityVersionDependency,
34
37
  PublishableEntityVersionMixin,
35
38
  PublishLog,
36
39
  PublishLogRecord,
40
+ PublishSideEffect,
37
41
  )
38
42
  from .models.publish_log import Published
39
43
 
@@ -213,6 +217,8 @@ def create_publishable_entity_version(
213
217
  title: str,
214
218
  created: datetime,
215
219
  created_by: int | None,
220
+ *,
221
+ dependencies: list[int] | None = None, # PublishableEntity IDs
216
222
  ) -> PublishableEntityVersion:
217
223
  """
218
224
  Create a PublishableEntityVersion.
@@ -220,7 +226,7 @@ def create_publishable_entity_version(
220
226
  You'd typically want to call this right before creating your own content
221
227
  version model that points to it.
222
228
  """
223
- with atomic():
229
+ with atomic(savepoint=False):
224
230
  version = PublishableEntityVersion.objects.create(
225
231
  entity_id=entity_id,
226
232
  version_num=version_num,
@@ -228,16 +234,82 @@ def create_publishable_entity_version(
228
234
  created=created,
229
235
  created_by_id=created_by,
230
236
  )
237
+ if dependencies:
238
+ set_version_dependencies(version.id, dependencies)
239
+
231
240
  set_draft_version(
232
241
  entity_id,
233
242
  version.id,
234
243
  set_at=created,
235
244
  set_by=created_by,
236
245
  )
237
-
238
246
  return version
239
247
 
240
248
 
249
+ def set_version_dependencies(
250
+ version_id: int, # PublishableEntityVersion.id,
251
+ /,
252
+ dependencies: list[int] # List of PublishableEntity.id
253
+ ) -> None:
254
+ """
255
+ Set the dependencies of a publishable entity version.
256
+
257
+ In general, callers should not modify dependencies after creation (i.e. use
258
+ the optional param in create_publishable_entity_version() instead of using
259
+ this function). **This function is not atomic.** If you're doing backfills,
260
+ you must wrap calls to this function with a transaction.atomic() call.
261
+
262
+ The idea behind dependencies is that a PublishableEntity's Versions may
263
+ be declared to reference unpinned PublishableEntities. Changes to those
264
+ referenced PublishableEntities still affect the draft or published state of
265
+ the referent PublishableEntity, even if the referent entity's version is not
266
+ incremented.
267
+
268
+ For example, we only create a new UnitVersion when there are changes to the
269
+ metadata of the Unit itself. So this would happen when the name of the Unit
270
+ is changed, or if a child Component is added, removed, or reordered. No new
271
+ UnitVersion is created when a child Component of that Unit is modified or
272
+ published. Yet we still consider a Unit to be affected when one of its child
273
+ Components is modified or published. Therefore, we say that the child
274
+ Components are dependencies of the UnitVersion.
275
+
276
+ Dependencies vs. Container Rows/Children
277
+
278
+ Dependencies overlap with the concept of container child rows, but the two
279
+ are not exactly the same. For instance:
280
+
281
+ * Dependencies have no sense of ordering.
282
+ * If a row is declared to be pinned to a specific version of a child, then
283
+ it is NOT a dependency. For example, if U1.v1 is declared to have a pinned
284
+ reference to ComponentVersion C1.v1, then future changes to C1 do not
285
+ affect U1.v1 because U1.v1 will just ignore those new ComponentVersions.
286
+
287
+ Guidelines:
288
+
289
+ 1. Only declare one level of dependencies, e.g. immediate parent-child
290
+ relationships. The publishing app will calculate transitive dependencies
291
+ like "all descendants" based on this. This is important for saving space,
292
+ because the DAG of trasitive dependency relationships can explode out to
293
+ tens of thousands of nodes per version.
294
+ 2. Declare dependencies from the bottom-up when possible. In other words, if
295
+ you're building an entire Subsection, set the Component dependencies for
296
+ the Units before you set the Unit dependencies for the Subsection. This
297
+ code will still work if you build from the top-down, but we'll end up
298
+ doing many redundant re-calculations, since every change to a lower layer
299
+ will cause recalculation to the higher levels that depend on it.
300
+ 3. Do not create circular dependencies.
301
+ """
302
+ PublishableEntityVersionDependency.objects.bulk_create(
303
+ [
304
+ PublishableEntityVersionDependency(
305
+ referring_version_id=version_id,
306
+ referenced_entity_id=dep_entity_id,
307
+ )
308
+ for dep_entity_id in set(dependencies) # dependencies have no ordering
309
+ ],
310
+ )
311
+
312
+
241
313
  def get_publishable_entity(publishable_entity_id: int, /) -> PublishableEntity:
242
314
  return PublishableEntity.objects.get(id=publishable_entity_id)
243
315
 
@@ -285,7 +357,8 @@ def get_entities_with_unpublished_changes(
285
357
  """
286
358
  Fetch entities that have unpublished changes.
287
359
 
288
- By default, this excludes soft-deleted drafts but can be included using include_deleted_drafts option.
360
+ By default, this excludes soft-deleted drafts but can be included using
361
+ include_deleted_drafts option.
289
362
  """
290
363
  entities_qs = (
291
364
  PublishableEntity.objects
@@ -333,65 +406,72 @@ def publish_all_drafts(
333
406
  Publish everything that is a Draft and is not already published.
334
407
  """
335
408
  draft_qset = (
336
- Draft.objects.select_related("entity__published")
337
- .filter(entity__learning_package_id=learning_package_id)
338
-
339
- # Exclude entities where the Published version already matches the
340
- # Draft version.
341
- .exclude(entity__published__version_id=F("version_id"))
342
-
343
- # Account for soft-deletes:
344
- # NULL != NULL in SQL, so simply excluding entities where the Draft
345
- # and Published versions match will not catch the case where a
346
- # soft-delete has been published (i.e. both the Draft and Published
347
- # versions are NULL). We need to explicitly check for that case
348
- # instead, or else we will re-publish the same soft-deletes over
349
- # and over again.
350
- .exclude(Q(version__isnull=True) & Q(entity__published__version__isnull=True))
409
+ Draft.objects
410
+ .filter(entity__learning_package_id=learning_package_id)
411
+ .with_unpublished_changes()
351
412
  )
352
413
  return publish_from_drafts(
353
414
  learning_package_id, draft_qset, message, published_at, published_by
354
415
  )
355
416
 
356
417
 
418
+ def _get_dependencies_with_unpublished_changes(
419
+ draft_qset: QuerySet[Draft]
420
+ ) -> list[QuerySet[Draft]]:
421
+ """
422
+ Return all dependencies to publish as a list of Draft QuerySets.
423
+
424
+ This should only return the Drafts that have actual changes, not pure side-
425
+ effects. The side-effect calculations will happen separately.
426
+ """
427
+ # First we have to do a full crawl of *all* dependencies, regardless of
428
+ # whether they have unpublished changes or not. This is because we might
429
+ # have a dependency-of-a-dependency that has changed somewhere down the
430
+ # line. Example: The draft_qset includes a Subsection. Even if the Unit
431
+ # versions are still the same, there might be a changed Component that needs
432
+ # to be published.
433
+ all_dependency_drafts = []
434
+ dependency_drafts = Draft.objects.filter(
435
+ entity__affects__in=draft_qset.values_list("version_id", flat=True)
436
+ ).distinct()
437
+
438
+ while dependency_drafts:
439
+ all_dependency_drafts.append(dependency_drafts)
440
+ dependency_drafts = Draft.objects.filter(
441
+ entity__affects__in=dependency_drafts.all().values_list("version_id", flat=True)
442
+ ).distinct()
443
+
444
+ unpublished_dependency_drafts = [
445
+ dependency_drafts_qset.all().with_unpublished_changes()
446
+ for dependency_drafts_qset in all_dependency_drafts
447
+ ]
448
+ return unpublished_dependency_drafts
449
+
450
+
357
451
  def publish_from_drafts(
358
452
  learning_package_id: int, # LearningPackage.id
359
453
  /,
360
- draft_qset: QuerySet,
454
+ draft_qset: QuerySet[Draft],
361
455
  message: str = "",
362
456
  published_at: datetime | None = None,
363
457
  published_by: int | None = None, # User.id
458
+ *,
459
+ publish_dependencies: bool = True,
364
460
  ) -> PublishLog:
365
461
  """
366
462
  Publish the rows in the ``draft_model_qsets`` args passed in.
463
+
464
+ By default, this will also publish all dependencies (e.g. unpinned children)
465
+ of the Drafts that are passed in.
367
466
  """
368
467
  if published_at is None:
369
468
  published_at = datetime.now(tz=timezone.utc)
370
469
 
371
470
  with atomic():
372
- # If the drafts include any containers, we need to auto-publish their descendants:
373
- # TODO: this only handles one level deep and would need to be updated to support sections > subsections > units
374
-
375
- # Get the IDs of the ContainerVersion for any Containers whose drafts are slated to be published.
376
- container_version_ids = (
377
- Container.objects.filter(publishable_entity__draft__in=draft_qset)
378
- .values_list("publishable_entity__draft__version__containerversion__pk", flat=True)
379
- )
380
- if container_version_ids:
381
- # We are publishing at least one container. Check if it has any child components that aren't already slated
382
- # to be published.
383
- unpublished_draft_children = EntityListRow.objects.filter(
384
- entity_list__container_versions__pk__in=container_version_ids,
385
- entity_version=None, # Unpinned entities only
386
- ).exclude(
387
- entity__draft__version=F("entity__published__version") # Exclude already published things
388
- ).values_list("entity__draft__pk", flat=True)
389
- if unpublished_draft_children:
390
- # Force these additional child components to be published at the same time by adding them to the qset:
391
- draft_qset = Draft.objects.filter(
392
- Q(pk__in=draft_qset.values_list("pk", flat=True)) |
393
- Q(pk__in=unpublished_draft_children)
394
- )
471
+ if publish_dependencies:
472
+ dependency_drafts_qsets = _get_dependencies_with_unpublished_changes(draft_qset)
473
+ else:
474
+ dependency_drafts_qsets = []
395
475
 
396
476
  # One PublishLog for this entire publish operation.
397
477
  publish_log = PublishLog(
@@ -403,32 +483,51 @@ def publish_from_drafts(
403
483
  publish_log.full_clean()
404
484
  publish_log.save(force_insert=True)
405
485
 
406
- for draft in draft_qset.select_related("entity__published__version"):
407
- try:
408
- old_version = draft.entity.published.version
409
- except ObjectDoesNotExist:
410
- # This means there is no published version yet.
411
- old_version = None
412
-
413
- # Create a record describing publishing this particular Publishable
414
- # (useful for auditing and reverting).
415
- publish_log_record = PublishLogRecord(
416
- publish_log=publish_log,
417
- entity=draft.entity,
418
- old_version=old_version,
419
- new_version=draft.version,
420
- )
421
- publish_log_record.full_clean()
422
- publish_log_record.save(force_insert=True)
486
+ # We're intentionally avoiding union() here because Django ORM unions
487
+ # introduce cumbersome restrictions (can only union once, can't
488
+ # select_related on it after, the extra rows must be exactly compatible
489
+ # in unioned qsets, etc.) Instead, we're going to have one queryset per
490
+ # dependency layer.
491
+ all_draft_qsets = [
492
+ draft_qset.select_related("entity__published__version"),
493
+ *dependency_drafts_qsets, # one QuerySet per layer of dependencies
494
+ ]
495
+ published_draft_ids = set()
496
+ for qset in all_draft_qsets:
497
+ for draft in qset:
498
+ # Skip duplicates that we might get from expanding dependencies.
499
+ if draft.pk in published_draft_ids:
500
+ continue
501
+
502
+ try:
503
+ old_version = draft.entity.published.version
504
+ except ObjectDoesNotExist:
505
+ # This means there is no published version yet.
506
+ old_version = None
507
+
508
+ # Create a record describing publishing this particular
509
+ # Publishable (useful for auditing and reverting).
510
+ publish_log_record = PublishLogRecord(
511
+ publish_log=publish_log,
512
+ entity=draft.entity,
513
+ old_version=old_version,
514
+ new_version=draft.version,
515
+ )
516
+ publish_log_record.full_clean()
517
+ publish_log_record.save(force_insert=True)
518
+
519
+ # Update the lookup we use to fetch the published versions
520
+ Published.objects.update_or_create(
521
+ entity=draft.entity,
522
+ defaults={
523
+ "version": draft.version,
524
+ "publish_log_record": publish_log_record,
525
+ },
526
+ )
423
527
 
424
- # Update the lookup we use to fetch the published versions
425
- Published.objects.update_or_create(
426
- entity=draft.entity,
427
- defaults={
428
- "version": draft.version,
429
- "publish_log_record": publish_log_record,
430
- },
431
- )
528
+ published_draft_ids.add(draft.pk)
529
+
530
+ _create_side_effects_for_change_log(publish_log)
432
531
 
433
532
  return publish_log
434
533
 
@@ -505,8 +604,8 @@ def set_draft_version(
505
604
  from Studio's editing point of view (see ``soft_delete_draft`` for more
506
605
  details).
507
606
 
508
- Calling this function attaches a new DraftChangeLogRecordand attaches it to a
509
- DraftChangeLog.
607
+ Calling this function attaches a new DraftChangeLogRecord and attaches it to
608
+ a DraftChangeLog.
510
609
 
511
610
  This function will create DraftSideEffect entries and properly add any
512
611
  containers that may have been affected by this draft update, UNLESS it is
@@ -538,7 +637,6 @@ def set_draft_version(
538
637
  # The actual update of the Draft model is here. Everything after this
539
638
  # block is bookkeeping in our DraftChangeLog.
540
639
  draft.version_id = publishable_entity_version_pk
541
- draft.save()
542
640
 
543
641
  # Check to see if we're inside a context manager for an active
544
642
  # DraftChangeLog (i.e. what happens if the caller is using the public
@@ -548,23 +646,52 @@ def set_draft_version(
548
646
  learning_package_id
549
647
  )
550
648
  if active_change_log:
551
- change = _add_to_existing_draft_change_log(
649
+ draft_log_record = _add_to_existing_draft_change_log(
552
650
  active_change_log,
553
651
  draft.entity_id,
554
652
  old_version_id=old_version_id,
555
653
  new_version_id=publishable_entity_version_pk,
556
654
  )
557
- # We explicitly *don't* create container side effects here because
558
- # there may be many changes in this DraftChangeLog, some of which
559
- # haven't been made yet. It wouldn't make sense to create a side
560
- # effect that says, "this Unit changed because this Component in it
561
- # changed" if we were changing that same Unit later on in the same
655
+ if draft_log_record:
656
+ # Normal case: a DraftChangeLogRecord was created or updated.
657
+ draft.draft_log_record = draft_log_record
658
+ else:
659
+ # Edge case: this change cancelled out other changes, and the
660
+ # net effect is that the DraftChangeLogRecord shouldn't exist,
661
+ # i.e. the version at the start and end of the DraftChangeLog is
662
+ # the same. In that case, _add_to_existing_draft_change_log will
663
+ # delete the log record for this Draft state, and we have to
664
+ # look for the most recently created DraftLogRecord from another
665
+ # DraftChangeLog. This value may be None.
666
+ #
667
+ # NOTE: There may be some weird edge cases here around soft-
668
+ # deletions and modifications of the same Draft entry in nested
669
+ # bulk_draft_changes_for() calls. I haven't thought that through
670
+ # yet--it might be better to just throw an error rather than try
671
+ # to accommodate it.
672
+ draft.draft_log_record = (
673
+ DraftChangeLogRecord.objects
674
+ .filter(entity_id=draft.entity_id)
675
+ .order_by("-pk")
676
+ .first()
677
+ )
678
+ draft.save()
679
+
680
+ # We also *don't* create container side effects here because there
681
+ # may be many changes in this DraftChangeLog, some of which haven't
682
+ # been made yet. It wouldn't make sense to create a side effect that
683
+ # says, "this Unit changed because this Component in it changed" if
684
+ # we were changing that same Unit later on in the same
562
685
  # DraftChangeLog, because that new Unit version might not even
563
- # include the child Component. So we'll let DraftChangeLogContext
564
- # do that work when it exits its context.
686
+ # include that child Component. Also, calculating side-effects is
687
+ # expensive, and would result in a lot of wasted queries if we did
688
+ # it for every change will inside an active change log context.
689
+ #
690
+ # Therefore we'll let DraftChangeLogContext do that work when it
691
+ # exits its context.
565
692
  else:
566
693
  # This means there is no active DraftChangeLog, so we create our own
567
- # and add our DraftChangeLogRecord to it. This has the minor
694
+ # and add our DraftChangeLogRecord to it. This has the very minor
568
695
  # optimization that we don't have to check for an existing
569
696
  # DraftChangeLogRecord, because we know it can't exist yet.
570
697
  change_log = DraftChangeLog.objects.create(
@@ -572,13 +699,14 @@ def set_draft_version(
572
699
  changed_at=set_at,
573
700
  changed_by_id=set_by,
574
701
  )
575
- change = DraftChangeLogRecord.objects.create(
702
+ draft.draft_log_record = DraftChangeLogRecord.objects.create(
576
703
  draft_change_log=change_log,
577
704
  entity_id=draft.entity_id,
578
705
  old_version_id=old_version_id,
579
706
  new_version_id=publishable_entity_version_pk,
580
707
  )
581
- _create_container_side_effects_for_draft_change(change)
708
+ draft.save()
709
+ _create_side_effects_for_change_log(change_log)
582
710
 
583
711
 
584
712
  def _add_to_existing_draft_change_log(
@@ -586,9 +714,9 @@ def _add_to_existing_draft_change_log(
586
714
  entity_id: int,
587
715
  old_version_id: int | None,
588
716
  new_version_id: int | None,
589
- ) -> DraftChangeLogRecord:
717
+ ) -> DraftChangeLogRecord | None:
590
718
  """
591
- Create or update a DraftChangeLogRecord for the active_change_log passed in.
719
+ Create, update, or delete a DraftChangeLogRecord for the active_change_log.
592
720
 
593
721
  The an active_change_log may have many DraftChangeLogRecords already
594
722
  associated with it. A DraftChangeLog can only have one DraftChangeLogRecord
@@ -602,6 +730,13 @@ def _add_to_existing_draft_change_log(
602
730
  and the same entity_id but with versions: (None, v1), (v1, v2), (v2, v3);
603
731
  we would collapse them into one DraftChangeLogrecord with old_version = None
604
732
  and new_version = v3.
733
+
734
+ This also means that if we make a change that undoes the previos change, it
735
+ will delete that DraftChangeLogRecord, e.g. (None, v1) -> (None, v2) ->
736
+ (None -> None), then this entry can be deleted because it didn't change
737
+ anything. This function should never be used for creating side-effect change
738
+ log records (the only place where it's normal to have the same old and new
739
+ versions).
605
740
  """
606
741
  try:
607
742
  # Check to see if this PublishableEntity has already been changed in
@@ -622,8 +757,11 @@ def _add_to_existing_draft_change_log(
622
757
  #
623
758
  # It's important that we remove these cases, because we use the
624
759
  # old_version == new_version convention to record entities that have
625
- # changed purely due to side-effects.
760
+ # changed purely due to side-effects. We could technically still
761
+ # differentiate those by actually looking at the DraftSideEffect and
762
+ # PublishSideEffect models, but this is less confusing overall.
626
763
  change.delete()
764
+ return None
627
765
  else:
628
766
  # Normal case: We update the new_version, but leave the old_version
629
767
  # as is. The old_version represents what the Draft was pointing to
@@ -644,87 +782,418 @@ def _add_to_existing_draft_change_log(
644
782
  return change
645
783
 
646
784
 
647
- def _create_container_side_effects_for_draft_change_log(change_log: DraftChangeLog):
648
- """
649
- Iterate through the whole DraftChangeLog and process side-effects.
650
- """
785
+ def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog):
786
+ """
787
+ Create the side-effects for a DraftChangeLog or PublishLog.
788
+
789
+ A side-effect is created whenever a dependency of a draft or published
790
+ entity version is altered.
791
+
792
+ For example, say we have a published Unit at version 1 (``U1.v1``).
793
+ ``U1.v1`` is defined to have unpinned references to Components ``C1`` and
794
+ ``C2``, i.e. ``U1.v1 = [C1, C2]``. This means that ``U1.v1`` always shows
795
+ the latest published versions of ``C1`` and ``C2``. While we do have a more
796
+ sophisticated encoding for the ordered parent-child relationships, we
797
+ capture the basic dependency relationship using the M:M
798
+ ``PublishableEntityVersionDependency`` model. In this scenario, C1 and C2
799
+ are dependencies of ``U1.v1``
800
+
801
+ In the above example, publishing a newer version of ``C1`` does *not*
802
+ increment the version for Unit ``U1``. But we still want to record that
803
+ ``U1.v1`` was affected by the change in ``C1``. We record this in the
804
+ ``DraftSideEffect`` and ``PublishSideEffect`` models. We also add an entry
805
+ for ``U1`` in the change log, saying that it went from version 1 to version
806
+ 1--i.e nothing about the Unit's defintion changed, but it was still affected
807
+ by other changes in the log.
808
+
809
+ Only call this function after all the records have already been created.
810
+
811
+ Note: The interface between ``DraftChangeLog`` and ``PublishLog`` is similar
812
+ enough that this function has been made to work with both.
813
+ """
814
+ branch_cls: type[Draft] | type[Published]
815
+ change_record_cls: type[DraftChangeLogRecord] | type[PublishLogRecord]
816
+ side_effect_cls: type[DraftSideEffect] | type[PublishSideEffect]
817
+ if isinstance(change_log, DraftChangeLog):
818
+ branch_cls = Draft
819
+ change_record_cls = DraftChangeLogRecord
820
+ side_effect_cls = DraftSideEffect
821
+ log_record_rel = "draft_log_record_id"
822
+ elif isinstance(change_log, PublishLog):
823
+ branch_cls = Published
824
+ change_record_cls = PublishLogRecord
825
+ side_effect_cls = PublishSideEffect
826
+ log_record_rel = "publish_log_record_id"
827
+ else:
828
+ raise TypeError(
829
+ f"expected DraftChangeLog or PublishLog, not {type(change_log)}"
830
+ )
831
+
832
+ # processed_entity_ids holds the entity IDs that we've already calculated
833
+ # side-effects for. This is to save us from recalculating side-effects for
834
+ # the same dependency relationships over and over again. So if we're calling
835
+ # this function in a loop for all the Components in a Unit, we won't be
836
+ # recalculating the Unit's side-effect on its Subsection, and its
837
+ # Subsection's side-effect on its Section each time through the loop.
838
+ # It also guards against infinite parent-child relationship loops, though
839
+ # those aren't *supposed* to happen anyhow.
651
840
  processed_entity_ids: set[int] = set()
652
- for change in change_log.records.all():
653
- _create_container_side_effects_for_draft_change(
654
- change,
655
- processed_entity_ids=processed_entity_ids,
841
+ for original_change in change_log.records.all():
842
+ affected_by_original_change = branch_cls.objects.filter(
843
+ version__dependencies=original_change.entity
656
844
  )
845
+ changes_and_affected = [
846
+ (original_change, current) for current in affected_by_original_change
847
+ ]
657
848
 
849
+ # These are the Published or Draft objects where we need to repoint the
850
+ # log_record (publish_log_record or draft_change_log_record) to point to
851
+ # the side-effect changes, e.g. the DraftChangeLogRecord that says,
852
+ # "This Unit's version stayed the same, but its dependency hash changed
853
+ # because a child Component's draft version was changed." We gather them
854
+ # all up in a list so we can do a bulk_update on them.
855
+ branch_objs_to_update_with_side_effects = []
856
+
857
+ while changes_and_affected:
858
+ change, affected = changes_and_affected.pop()
859
+ change_log_param = {}
860
+ if branch_cls == Draft:
861
+ change_log_param['draft_change_log'] = change.draft_change_log # type: ignore[union-attr]
862
+ elif branch_cls == Published:
863
+ change_log_param['publish_log'] = change.publish_log # type: ignore[union-attr]
864
+
865
+ # Example: If the original_change is a DraftChangeLogRecord that
866
+ # represents editing a Component, the side_effect_change is the
867
+ # DraftChangeLogRecord that represents the fact that the containing
868
+ # Unit was also altered (even if the Unit version doesn't change).
869
+ side_effect_change, _created = change_record_cls.objects.get_or_create(
870
+ **change_log_param,
871
+ entity_id=affected.entity_id,
872
+ defaults={
873
+ # If a change record already exists because the affected
874
+ # entity was separately modified, then we don't touch the
875
+ # old/new version entries. But if we're creating this change
876
+ # record as a pure side-effect, then we use the (old_version
877
+ # == new_version) convention to indicate that.
878
+ 'old_version_id': affected.version_id,
879
+ 'new_version_id': affected.version_id,
880
+ }
881
+ )
882
+ # Update the current branch pointer (Draft or Published) for this
883
+ # entity to point to the side_effect_change (if it's not already).
884
+ if branch_cls == Published:
885
+ published_obj = affected.entity.published
886
+ if published_obj.publish_log_record != side_effect_change:
887
+ published_obj.publish_log_record = side_effect_change
888
+ branch_objs_to_update_with_side_effects.append(published_obj)
889
+ elif branch_cls == Draft:
890
+ draft_obj = affected.entity.draft
891
+ if draft_obj.draft_log_record != side_effect_change:
892
+ draft_obj.draft_log_record = side_effect_change
893
+ branch_objs_to_update_with_side_effects.append(draft_obj)
894
+
895
+ # Create a new side effect (DraftSideEffect or PublishSideEffect) to
896
+ # record the relationship between the ``change`` and the
897
+ # corresponding ``side_effect_change``. We'll do this regardless of
898
+ # whether we created the ``side_effect_change`` or just pulled back
899
+ # an existing one. This addresses two things:
900
+ #
901
+ # 1. A change in multiple dependencies can generate multiple
902
+ # side effects that point to the same change log record, i.e.
903
+ # multiple changes can cause the same ``effect``.
904
+ # Example: Two draft components in a Unit are changed. Two
905
+ # DraftSideEffects will be created and point to the same Unit
906
+ # DraftChangeLogRecord.
907
+ # 2. A entity and its dependency can change at the same time.
908
+ # Example: If a Unit has a Component, and both the Unit and
909
+ # Component are edited in the same DraftChangeLog, then the Unit
910
+ # has changed in both ways (the Unit's internal metadata as well
911
+ # as the new version of the child component). The version of the
912
+ # Unit will be incremented, but we'll also create the
913
+ # DraftSideEffect.
914
+ side_effect_cls.objects.get_or_create(
915
+ cause=change,
916
+ effect=side_effect_change,
917
+ )
658
918
 
659
- def _create_container_side_effects_for_draft_change(
660
- original_change: DraftChangeLogRecord,
661
- processed_entity_ids: set | None = None
662
- ):
663
- """
664
- Given a draft change, add side effects for all affected containers.
919
+ # Now we find the next layer up by looking at Drafts or Published
920
+ # that have ``affected.entity`` as a dependency.
921
+ next_layer_of_affected = branch_cls.objects.filter(
922
+ version__dependencies=affected.entity
923
+ )
924
+
925
+ # Make sure we never re-add the change we just processed when we
926
+ # queue up the next layer.
927
+ processed_entity_ids.add(change.entity_id)
928
+
929
+ changes_and_affected.extend(
930
+ (side_effect_change, affected)
931
+ for affected in next_layer_of_affected
932
+ if affected.entity_id not in processed_entity_ids
933
+ )
934
+
935
+ branch_cls.objects.bulk_update(
936
+ branch_objs_to_update_with_side_effects, # type: ignore[arg-type]
937
+ [log_record_rel],
938
+ )
665
939
 
666
- This should only be run after the DraftChangeLogRecord has been otherwise
667
- fully written out. We want to avoid the scenario where we create a
668
- side-effect that a Component change affects a Unit if the Unit version is
669
- also changed (maybe even deleted) in the same DraftChangeLog.
940
+ update_dependencies_hash_digests_for_log(change_log)
670
941
 
671
- The `processed_entity_ids` set holds the entity IDs that we've already
672
- calculated side-effects for. This is to save us from recalculating side-
673
- effects for the same ancestor relationships over and over again. So if we're
674
- calling this function in a loop for all the Components in a Unit, we won't
675
- be recalculating the Unit's side-effect on its Subsection, and its
676
- Subsection's side-effect on its Section.
677
942
 
678
- TODO: This could get very expensive with the get_containers_with_entity
679
- calls. We should measure the impact of this.
943
+ def update_dependencies_hash_digests_for_log(
944
+ change_log: DraftChangeLog | PublishLog,
945
+ backfill: bool = False,
946
+ ) -> None:
680
947
  """
681
- if processed_entity_ids is None:
682
- # An optimization, but also a guard against infinite side-effect loops.
683
- processed_entity_ids = set()
948
+ Update dependencies_hash_digest for Drafts or Published in a change log.
684
949
 
685
- changes_and_containers = [
686
- (original_change, container)
687
- for container
688
- in get_containers_with_entity(original_change.entity_id, ignore_pinned=True)
689
- ]
690
- while changes_and_containers:
691
- change, container = changes_and_containers.pop()
692
-
693
- # If the container is not already in the DraftChangeLog, we need to
694
- # add it. Since it's being caused as a DraftSideEffect, we're going
695
- # add it with the old_version == new_version convention.
696
- container_draft_version_pk = container.versioning.draft.pk
697
- container_change, _created = DraftChangeLogRecord.objects.get_or_create(
698
- draft_change_log=change.draft_change_log,
699
- entity_id=container.pk,
700
- defaults={
701
- 'old_version_id': container_draft_version_pk,
702
- 'new_version_id': container_draft_version_pk
703
- }
950
+ All the data for Draft/Published, DraftChangeLog/PublishLog, and
951
+ DraftChangeLogRecord/PublishLogRecord have been set at this point *except*
952
+ the dependencies_hash_digest of DraftChangeLogRecord/PublishLogRecord. Those
953
+ log records are newly created at this point, so dependencies_hash_digest are
954
+ set to their default values.
955
+
956
+ Args:
957
+ change_log: A DraftChangeLog or PublishLog that already has all
958
+ side-effects added to it. The Draft and Published models should
959
+ already be updated to point to the post-change versions.
960
+ backfill: If this is true, we will not trust the hash values stored on
961
+ log records outside of our log, i.e. things that we would normally
962
+ expect to be pre-calculated. This will be important for the initial
963
+ data migration.
964
+ """
965
+ if isinstance(change_log, DraftChangeLog):
966
+ branch = "draft"
967
+ log_record_relation = "draft_log_record"
968
+ record_cls = DraftChangeLogRecord
969
+ elif isinstance(change_log, PublishLog):
970
+ branch = "published"
971
+ log_record_relation = "publish_log_record"
972
+ record_cls = PublishLogRecord # type: ignore[assignment]
973
+ else:
974
+ raise TypeError(
975
+ f"expected DraftChangeLog or PublishLog, not {type(change_log)}"
704
976
  )
705
977
 
706
- # Mark that change in the current loop has the side effect of changing
707
- # the parent container. We'll do this regardless of whether the
708
- # container version itself also changed. If a Unit has a Component and
709
- # both the Unit and Component have their versions incremented, then the
710
- # Unit has changed in both ways (the Unit's internal metadata as well as
711
- # the new version of the child component).
712
- DraftSideEffect.objects.get_or_create(cause=change, effect=container_change)
713
- processed_entity_ids.add(change.entity_id)
714
-
715
- # Now we find the next layer up of containers. So if the originally
716
- # passed in publishable_entity_id was for a Component, then the
717
- # ``container`` we've been creating the side effect for in this loop
718
- # is the Unit, and ``parents_of_container`` would be any Sequences
719
- # that contain the Unit.
720
- parents_of_container = get_containers_with_entity(container.pk, ignore_pinned=True)
721
-
722
- changes_and_containers.extend(
723
- (container_change, container_parent)
724
- for container_parent in parents_of_container
725
- if container_parent.pk not in processed_entity_ids
978
+ dependencies_prefetch = Prefetch(
979
+ "new_version__dependencies",
980
+ queryset=PublishableEntity.objects
981
+ .select_related(
982
+ f"{branch}__version",
983
+ f"{branch}__{log_record_relation}",
984
+ )
985
+ .order_by(f"{branch}__version__uuid")
986
+ )
987
+ changed_records: QuerySet[DraftChangeLogRecord] | QuerySet[PublishLogRecord]
988
+ changed_records = (
989
+ change_log.records
990
+ .select_related("new_version", f"entity__{branch}")
991
+ .prefetch_related(dependencies_prefetch)
992
+ )
993
+
994
+ record_ids_to_hash_digests: dict[int, str | None] = {}
995
+ record_ids_to_live_deps: dict[int, list[PublishableEntity]] = {}
996
+ records_that_need_hashes = []
997
+
998
+ for record in changed_records:
999
+ # This is a soft-deletion, so the dependency hash is default/blank. We
1000
+ # set this value in our record_ids_to_hash_digests cache, but we don't
1001
+ # need to write it to the database because it's just the default value.
1002
+ if record.new_version is None:
1003
+ record_ids_to_hash_digests[record.id] = ''
1004
+ continue
1005
+
1006
+ # Now check to see if the new version has "live" dependencies, i.e.
1007
+ # dependencies that have not been deleted.
1008
+ deps = list(
1009
+ entity for entity in record.new_version.dependencies.all()
1010
+ if hasattr(entity, branch) and getattr(entity, branch).version
726
1011
  )
727
1012
 
1013
+ # If there are no live dependencies, this log record also gets the
1014
+ # default/blank value.
1015
+ if not deps:
1016
+ record_ids_to_hash_digests[record.id] = ''
1017
+ continue
1018
+
1019
+ # If we've gotten this far, it means that this record has dependencies
1020
+ # and does need to get a hash computed for it.
1021
+ records_that_need_hashes.append(record)
1022
+ record_ids_to_live_deps[record.id] = deps
1023
+
1024
+ if backfill:
1025
+ untrusted_record_id_set = None
1026
+ else:
1027
+ untrusted_record_id_set = set(rec.id for rec in records_that_need_hashes)
1028
+
1029
+ for record in records_that_need_hashes:
1030
+ record.dependencies_hash_digest = hash_for_log_record(
1031
+ record,
1032
+ record_ids_to_hash_digests,
1033
+ record_ids_to_live_deps,
1034
+ untrusted_record_id_set,
1035
+ )
1036
+
1037
+ _bulk_update_hashes(record_cls, records_that_need_hashes)
1038
+
1039
+
1040
+ def _bulk_update_hashes(model_cls, records):
1041
+ """
1042
+ bulk_update using the model class (PublishLogRecord or DraftChangeLogRecord)
1043
+
1044
+ The only reason this function exists is because mypy 1.18.2 throws an
1045
+ exception in validate_bulk_update() during "make quality" checks otherwise
1046
+ (though curiously enough, not when that same version of mypy is called
1047
+ directly). Given that I'm writing this on the night before the Ulmo release
1048
+ cut, I'm not really interested in tracking down the underlying issue.
1049
+
1050
+ The lack of type annotations on this function is very intentional.
1051
+ """
1052
+ model_cls.objects.bulk_update(records, ['dependencies_hash_digest'])
1053
+
1054
+
1055
+ def hash_for_log_record(
1056
+ record: DraftChangeLogRecord | PublishLogRecord,
1057
+ record_ids_to_hash_digests: dict,
1058
+ record_ids_to_live_deps: dict,
1059
+ untrusted_record_id_set: set | None,
1060
+ ) -> str:
1061
+ """
1062
+ The hash digest for a given change log record.
1063
+
1064
+ Note that this code is a little convoluted because we're working hard to
1065
+ minimize the number of database requests. All the data we really need could
1066
+ be derived from querying various relations off the record that's passed in
1067
+ as the first parameter, but at a far higher cost.
1068
+
1069
+ The hash calculated here will be used for the dependencies_hash_digest
1070
+ attribute of DraftChangeLogRecord and PublishLogRecord. The hash is intended
1071
+ to calculate the currently "live" (current draft or published) state of all
1072
+ dependencies (and transitive dependencies) of the PublishableEntityVersion
1073
+ pointed to by DraftChangeLogRecord.new_version/PublishLogRecord.new_version.
1074
+
1075
+ The common case we have at the moment is when a container type like a Unit
1076
+ has unpinned child Components as dependencies. In the data model, those
1077
+ dependency relationships are represented by the "dependencies" M:M relation
1078
+ on PublishableEntityVersion. Since the Unit version's references to its
1079
+ child Components are unpinned, the draft Unit is always pointing to the
1080
+ latest draft versions of those Components and the published Unit is always
1081
+ pointing to the latest published versions of those Components.
1082
+
1083
+ This means that the total draft or published state of any PublishableEntity
1084
+ depends on the combination of:
1085
+
1086
+ 1. The definition of the current draft/published version of that entity.
1087
+ Example: Version 1 of a Unit would define that it had children [C1, C2].
1088
+ Version 2 of the same Unit might have children [C1, C2, C3].
1089
+ 2. The current draft/published versions of all dependencies. Example: What
1090
+ are the current draft and published versions of C1, C2, and C3.
1091
+
1092
+ This is why it makes sense to capture in a log record, since
1093
+ PublishLogRecords or DraftChangeLogRecords are created whenever one of the
1094
+ above two things changes.
1095
+
1096
+ Here are the possible scenarios, including edge cases:
1097
+
1098
+ EntityVersions with no dependencies
1099
+ If record.new_version has no dependencies, dependencies_hash_digest is
1100
+ set to the default value of ''. This will be the most common case.
1101
+
1102
+ EntityVersions with dependencies
1103
+ If an EntityVersion has dependencies, then its draft/published state
1104
+ hash is based on the concatenation of, for each non-deleted dependency:
1105
+ (i) the dependency's draft/published EntityVersion primary key, and
1106
+ (ii) the dependency's own draft/published state hash, recursively re-
1107
+ calculated if necessary.
1108
+
1109
+ Soft-deletions
1110
+ If the record.new_version is None, that means we've just soft-deleted
1111
+ something (or published the soft-delete of something). We adopt the
1112
+ convention that if something is soft-deleted, its dependencies_hash_digest
1113
+ is reset to the default value of ''. This is not strictly necessary for
1114
+ the recursive hash calculation, but deleted entities will not have their
1115
+ hash updated even as their non-deleted dependencies are updated underneath
1116
+ them, so we set to '' to avoid falsely implying that the deleted entity's
1117
+ dep hash is up to date.
1118
+
1119
+ EntityVersions with soft-deleted dependencies
1120
+ A soft-deleted dependency isn't counted (it's as if the dependency were
1121
+ removed). If all of an EntityVersion's dependencies are soft-deleted,
1122
+ then it will go back to having to having the default blank string for its
1123
+ dependencies_hash_digest.
1124
+ """
1125
+ # Case #1: We've already computed this, or it was bootstrapped for us in the
1126
+ # cache because the record is a deletion or doesn't have dependencies.
1127
+ if record.id in record_ids_to_hash_digests:
1128
+ return record_ids_to_hash_digests[record.id]
1129
+
1130
+ # Case #2: The log_record is a dependency of something that was affected by
1131
+ # a change, but the dependency itself did not change in any way (neither
1132
+ # directly, nor as a side-effect).
1133
+ #
1134
+ # Example: A Unit has two Components. One of the Components changed, forcing
1135
+ # us to recalculate the dependencies_hash_digest for that Unit. Doing that
1136
+ # recalculation requires us to fetch the dependencies_hash_digest of the
1137
+ # unchanged child Component as well.
1138
+ #
1139
+ # If we aren't given an explicit untrusted_record_id_set, it means we can't
1140
+ # trust anything. This would happen when we're bootstrapping things with an
1141
+ # initial data migration.
1142
+ if (untrusted_record_id_set is not None) and (record.id not in untrusted_record_id_set):
1143
+ return record.dependencies_hash_digest
1144
+
1145
+ # Normal recursive case starts here:
1146
+ if isinstance(record, DraftChangeLogRecord):
1147
+ branch = "draft"
1148
+ elif isinstance(record, PublishLogRecord):
1149
+ branch = "published"
1150
+ else:
1151
+ raise TypeError(
1152
+ f"expected DraftChangeLogRecord or PublishLogRecord, not {type(record)}"
1153
+ )
1154
+
1155
+ # This is extra work that only happens in case of a backfill, where we might
1156
+ # need to compute dependency hashes for things outside of our log (because
1157
+ # we don't trust them).
1158
+ if record.id not in record_ids_to_live_deps:
1159
+ if record.new_version is None:
1160
+ record_ids_to_hash_digests[record.id] = ''
1161
+ return ''
1162
+ deps = list(
1163
+ entity for entity in record.new_version.dependencies.all()
1164
+ if hasattr(entity, branch) and getattr(entity, branch).version
1165
+ )
1166
+ # If there are no live dependencies, this log record also gets the
1167
+ # default/blank value.
1168
+ if not deps:
1169
+ record_ids_to_hash_digests[record.id] = ''
1170
+ return ''
1171
+
1172
+ record_ids_to_live_deps[record.id] = deps
1173
+ # End special handling for backfill.
1174
+
1175
+ # Begin normal
1176
+ dependencies: list[PublishableEntity] = sorted(
1177
+ record_ids_to_live_deps[record.id],
1178
+ key=lambda entity: getattr(entity, branch).log_record.new_version_id,
1179
+ )
1180
+ dep_state_entries = []
1181
+ for dep_entity in dependencies:
1182
+ new_version_id = getattr(dep_entity, branch).log_record.new_version_id
1183
+ hash_digest = hash_for_log_record(
1184
+ getattr(dep_entity, branch).log_record,
1185
+ record_ids_to_hash_digests,
1186
+ record_ids_to_live_deps,
1187
+ untrusted_record_id_set,
1188
+ )
1189
+ dep_state_entries.append(f"{new_version_id}:{hash_digest}")
1190
+ summary_text = "\n".join(dep_state_entries)
1191
+
1192
+ digest = create_hash_digest(summary_text.encode(), num_bytes=4)
1193
+ record_ids_to_hash_digests[record.id] = digest
1194
+
1195
+ return digest
1196
+
728
1197
 
729
1198
  def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None:
730
1199
  """
@@ -974,7 +1443,6 @@ def create_entity_list_with_rows(
974
1443
  raise ValidationError("Container entity versions must belong to the specified entity.")
975
1444
 
976
1445
  with atomic(savepoint=False):
977
-
978
1446
  entity_list = create_entity_list()
979
1447
  EntityListRow.objects.bulk_create(
980
1448
  [
@@ -1012,6 +1480,11 @@ def _create_container_version(
1012
1480
  title=title,
1013
1481
  created=created,
1014
1482
  created_by=created_by,
1483
+ dependencies=[
1484
+ entity_row.entity_id
1485
+ for entity_row in entity_list.rows
1486
+ if entity_row.is_unpinned()
1487
+ ]
1015
1488
  )
1016
1489
  container_version = container_version_cls.objects.create(
1017
1490
  publishable_entity_version=publishable_entity_version,
@@ -1378,6 +1851,11 @@ def contains_unpublished_changes(container_id: int) -> bool:
1378
1851
  [ 🛑 UNSTABLE ]
1379
1852
  Check recursively if a container has any unpublished changes.
1380
1853
 
1854
+ Note: I've preserved the API signature for now, but we probably eventually
1855
+ want to make a more general function that operates on PublishableEntities
1856
+ and dependencies, once we introduce those with courses and their files,
1857
+ grading policies, etc.
1858
+
1381
1859
  Note: unlike this method, the similar-sounding
1382
1860
  `container.versioning.has_unpublished_changes` property only reports
1383
1861
  if the container itself has unpublished changes, not
@@ -1386,50 +1864,37 @@ def contains_unpublished_changes(container_id: int) -> bool:
1386
1864
  that's in the container, it will be `False`. This method will return `True`
1387
1865
  in either case.
1388
1866
  """
1389
- # This is similar to 'get_container(container.container_id)' but pre-loads more data.
1390
- container = Container.objects.select_related(
1391
- "publishable_entity__draft__version__containerversion__entity_list",
1392
- ).get(pk=container_id)
1393
-
1867
+ container = (
1868
+ Container.objects
1869
+ .select_related('publishable_entity__draft__draft_log_record')
1870
+ .select_related('publishable_entity__published__publish_log_record')
1871
+ .get(pk=container_id)
1872
+ )
1394
1873
  if container.versioning.has_unpublished_changes:
1395
1874
  return True
1396
1875
 
1397
- # We only care about children that are un-pinned, since published changes to pinned children don't matter
1398
- entity_list = getattr(container.versioning.draft, "entity_list", None)
1399
- if entity_list is None:
1400
- # This container has been soft-deleted, so it has no children.
1876
+ draft = container.publishable_entity.draft
1877
+ published = container.publishable_entity.published
1878
+
1879
+ # Edge case: A container that was created and then immediately soft-deleted
1880
+ # does not contain any unpublished changes.
1881
+ if draft is None and published is None:
1401
1882
  return False
1402
1883
 
1403
- # This is a naive and inefficient implementation but should be correct.
1404
- # TODO: Once we have expanded the containers system to support multiple levels (not just Units and Components but
1405
- # also subsections and sections) and we have an expanded test suite for correctness, then we can optimize.
1406
- # We will likely change to a tracking-based approach rather than a "scan for changes" based approach.
1407
- for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related(
1408
- "entity__container",
1409
- "entity__draft__version",
1410
- "entity__published__version",
1411
- ):
1412
- try:
1413
- child_container = row.entity.container
1414
- except Container.DoesNotExist:
1415
- child_container = None
1416
- if child_container:
1417
- # This is itself a container - check recursively:
1418
- if contains_unpublished_changes(child_container.pk):
1419
- return True
1420
- else:
1421
- # This is not a container:
1422
- draft_pk = row.entity.draft.version_id if row.entity.draft else None
1423
- published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None
1424
- if draft_pk != published_pk:
1425
- return True
1426
- return False
1884
+ # The dependencies_hash_digest captures the state of all descendants, so we
1885
+ # can do this quick comparison instead of iterating through layers of
1886
+ # containers.
1887
+ draft_version_hash_digest = draft.log_record.dependencies_hash_digest
1888
+ published_version_hash_digest = published.log_record.dependencies_hash_digest
1889
+
1890
+ return draft_version_hash_digest != published_version_hash_digest
1427
1891
 
1428
1892
 
1429
1893
  def get_containers_with_entity(
1430
1894
  publishable_entity_pk: int,
1431
1895
  *,
1432
1896
  ignore_pinned=False,
1897
+ published=False,
1433
1898
  ) -> QuerySet[Container]:
1434
1899
  """
1435
1900
  [ 🛑 UNSTABLE ]
@@ -1442,20 +1907,31 @@ def get_containers_with_entity(
1442
1907
  publishable_entity_pk: The ID of the PublishableEntity to search for.
1443
1908
  ignore_pinned: if true, ignore any pinned references to the entity.
1444
1909
  """
1910
+ branch = "published" if published else "draft"
1445
1911
  if ignore_pinned:
1446
- qs = Container.objects.filter(
1447
- # Note: these two conditions must be in the same filter() call, or the query won't be correct.
1448
- publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
1449
- publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501
1450
- )
1912
+ filter_dict = {
1913
+ # Note: these two conditions must be in the same filter() call,
1914
+ # or the query won't be correct.
1915
+ (
1916
+ f"publishable_entity__{branch}__version__"
1917
+ "containerversion__entity_list__entitylistrow__entity_id"
1918
+ ): publishable_entity_pk,
1919
+ (
1920
+ f"publishable_entity__{branch}__version__"
1921
+ "containerversion__entity_list__entitylistrow__entity_version_id"
1922
+ ): None,
1923
+ }
1924
+ qs = Container.objects.filter(**filter_dict)
1451
1925
  else:
1452
- qs = Container.objects.filter(
1453
- publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
1454
- )
1455
- return qs.select_related(
1456
- "publishable_entity__draft__version__containerversion",
1457
- "publishable_entity__published__version__containerversion",
1458
- ).order_by("pk").distinct() # Ordering is mostly for consistent test cases.
1926
+ filter_dict = {
1927
+ (
1928
+ f"publishable_entity__{branch}__version__"
1929
+ "containerversion__entity_list__entitylistrow__entity_id"
1930
+ ): publishable_entity_pk
1931
+ }
1932
+ qs = Container.objects.filter(**filter_dict)
1933
+
1934
+ return qs.order_by("pk").distinct() # Ordering is mostly for consistent test cases.
1459
1935
 
1460
1936
 
1461
1937
  def get_container_children_count(
@@ -1539,6 +2015,6 @@ def bulk_draft_changes_for(
1539
2015
  changed_at=changed_at,
1540
2016
  changed_by=changed_by,
1541
2017
  exit_callbacks=[
1542
- _create_container_side_effects_for_draft_change_log,
2018
+ _create_side_effects_for_change_log,
1543
2019
  ]
1544
2020
  )