openedx-learning 0.20.0__py2.py3-none-any.whl → 0.23.0__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.
@@ -6,19 +6,24 @@ are stored in this app.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ from contextlib import nullcontext
9
10
  from dataclasses import dataclass
10
11
  from datetime import datetime, timezone
11
12
  from enum import Enum
12
- from typing import TypeVar
13
+ from typing import ContextManager, TypeVar
13
14
 
14
15
  from django.core.exceptions import ObjectDoesNotExist, ValidationError
15
16
  from django.db.models import F, Q, QuerySet
16
17
  from django.db.transaction import atomic
17
18
 
19
+ from .contextmanagers import DraftChangeLogContext
18
20
  from .models import (
19
21
  Container,
20
22
  ContainerVersion,
21
23
  Draft,
24
+ DraftChangeLog,
25
+ DraftChangeLogRecord,
26
+ DraftSideEffect,
22
27
  EntityList,
23
28
  EntityListRow,
24
29
  LearningPackage,
@@ -27,10 +32,10 @@ from .models import (
27
32
  PublishableEntityMixin,
28
33
  PublishableEntityVersion,
29
34
  PublishableEntityVersionMixin,
30
- Published,
31
35
  PublishLog,
32
36
  PublishLogRecord,
33
37
  )
38
+ from .models.publish_log import Published
34
39
 
35
40
  # A few of the APIs in this file are generic and can be used for Containers in
36
41
  # general, or e.g. Units (subclass of Container) in particular. These type
@@ -74,12 +79,15 @@ __all__ = [
74
79
  "get_container",
75
80
  "get_container_by_key",
76
81
  "get_containers",
82
+ "get_collection_containers",
77
83
  "ChildrenEntitiesAction",
78
84
  "ContainerEntityListEntry",
85
+ "ContainerEntityRow",
79
86
  "get_entities_in_container",
80
87
  "contains_unpublished_changes",
81
88
  "get_containers_with_entity",
82
89
  "get_container_children_count",
90
+ "bulk_draft_changes_for",
83
91
  ]
84
92
 
85
93
 
@@ -218,10 +226,13 @@ def create_publishable_entity_version(
218
226
  created=created,
219
227
  created_by_id=created_by,
220
228
  )
221
- Draft.objects.update_or_create(
222
- entity_id=entity_id,
223
- defaults={"version": version},
229
+ set_draft_version(
230
+ entity_id,
231
+ version.id,
232
+ set_at=created,
233
+ set_by=created_by,
224
234
  )
235
+
225
236
  return version
226
237
 
227
238
 
@@ -445,25 +456,247 @@ def get_published_version(publishable_entity_id: int, /) -> PublishableEntityVer
445
456
 
446
457
 
447
458
  def set_draft_version(
448
- publishable_entity_id: int,
459
+ draft_or_id: Draft | int,
449
460
  publishable_entity_version_pk: int | None,
450
461
  /,
462
+ set_at: datetime | None = None,
463
+ set_by: int | None = None, # User.id
451
464
  ) -> None:
452
465
  """
453
466
  Modify the Draft of a PublishableEntity to be a PublishableEntityVersion.
454
467
 
468
+ The ``draft`` argument can be either a Draft model object, or the primary
469
+ key of a Draft/PublishableEntity (Draft is defined so these will be the same
470
+ value).
471
+
455
472
  This would most commonly be used to set the Draft to point to a newly
456
473
  created PublishableEntityVersion that was created in Studio (because someone
457
474
  edited some content). Setting a Draft's version to None is like deleting it
458
475
  from Studio's editing point of view (see ``soft_delete_draft`` for more
459
476
  details).
477
+
478
+ Calling this function attaches a new DraftChangeLogRecordand attaches it to a
479
+ DraftChangeLog.
480
+
481
+ This function will create DraftSideEffect entries and properly add any
482
+ containers that may have been affected by this draft update, UNLESS it is
483
+ called from within a bulk_draft_changes_for block. If it is called from
484
+ inside a bulk_draft_changes_for block, it will not add side-effects for
485
+ containers, as bulk_draft_changes_for will automatically do that when the
486
+ block exits.
460
487
  """
461
- draft = Draft.objects.get(entity_id=publishable_entity_id)
462
- draft.version_id = publishable_entity_version_pk
463
- draft.save()
488
+ if set_at is None:
489
+ set_at = datetime.now(tz=timezone.utc)
464
490
 
491
+ with atomic(savepoint=False):
492
+ if isinstance(draft_or_id, Draft):
493
+ draft = draft_or_id
494
+ elif isinstance(draft_or_id, int):
495
+ draft, _created = Draft.objects.select_related("entity") \
496
+ .get_or_create(entity_id=draft_or_id)
497
+ else:
498
+ class_name = draft_or_id.__class__.__name__
499
+ raise TypeError(
500
+ f"draft_or_id must be a Draft or int, not ({class_name})"
501
+ )
465
502
 
466
- def soft_delete_draft(publishable_entity_id: int, /) -> None:
503
+ # If the Draft is already pointing at this version, there's nothing to do.
504
+ old_version_id = draft.version_id
505
+ if old_version_id == publishable_entity_version_pk:
506
+ return
507
+
508
+ # The actual update of the Draft model is here. Everything after this
509
+ # block is bookkeeping in our DraftChangeLog.
510
+ draft.version_id = publishable_entity_version_pk
511
+ draft.save()
512
+
513
+ # Check to see if we're inside a context manager for an active
514
+ # DraftChangeLog (i.e. what happens if the caller is using the public
515
+ # bulk_draft_changes_for() API call), or if we have to make our own.
516
+ learning_package_id = draft.entity.learning_package_id
517
+ active_change_log = DraftChangeLogContext.get_active_draft_change_log(
518
+ learning_package_id
519
+ )
520
+ if active_change_log:
521
+ change = _add_to_existing_draft_change_log(
522
+ active_change_log,
523
+ draft.entity_id,
524
+ old_version_id=old_version_id,
525
+ new_version_id=publishable_entity_version_pk,
526
+ )
527
+ # We explicitly *don't* create container side effects here because
528
+ # there may be many changes in this DraftChangeLog, some of which
529
+ # haven't been made yet. It wouldn't make sense to create a side
530
+ # effect that says, "this Unit changed because this Component in it
531
+ # changed" if we were changing that same Unit later on in the same
532
+ # DraftChangeLog, because that new Unit version might not even
533
+ # include the child Component. So we'll let DraftChangeLogContext
534
+ # do that work when it exits its context.
535
+ else:
536
+ # This means there is no active DraftChangeLog, so we create our own
537
+ # and add our DraftChangeLogRecord to it. This has the minor
538
+ # optimization that we don't have to check for an existing
539
+ # DraftChangeLogRecord, because we know it can't exist yet.
540
+ change_log = DraftChangeLog.objects.create(
541
+ learning_package_id=learning_package_id,
542
+ changed_at=set_at,
543
+ changed_by_id=set_by,
544
+ )
545
+ change = DraftChangeLogRecord.objects.create(
546
+ draft_change_log=change_log,
547
+ entity_id=draft.entity_id,
548
+ old_version_id=old_version_id,
549
+ new_version_id=publishable_entity_version_pk,
550
+ )
551
+ _create_container_side_effects_for_draft_change(change)
552
+
553
+
554
+ def _add_to_existing_draft_change_log(
555
+ active_change_log: DraftChangeLog,
556
+ entity_id: int,
557
+ old_version_id: int | None,
558
+ new_version_id: int | None,
559
+ ) -> DraftChangeLogRecord:
560
+ """
561
+ Create or update a DraftChangeLogRecord for the active_change_log passed in.
562
+
563
+ The an active_change_log may have many DraftChangeLogRecords already
564
+ associated with it. A DraftChangeLog can only have one DraftChangeLogRecord
565
+ per PublishableEntity, e.g. the same Component can't go from v1 to v2 and v2
566
+ to v3 in the same DraftChangeLog. The DraftChangeLogRecord is meant to
567
+ capture the before and after states of the Draft version for that entity,
568
+ so we always keep the first value for old_version, while updating to the
569
+ most recent value for new_version.
570
+
571
+ So for example, if we called this function with the same active_change_log
572
+ and the same entity_id but with versions: (None, v1), (v1, v2), (v2, v3);
573
+ we would collapse them into one DraftChangeLogrecord with old_version = None
574
+ and new_version = v3.
575
+ """
576
+ try:
577
+ # Check to see if this PublishableEntity has already been changed in
578
+ # this DraftChangeLog. If so, we update that record instead of creating
579
+ # a new one.
580
+ change = DraftChangeLogRecord.objects.get(
581
+ draft_change_log=active_change_log,
582
+ entity_id=entity_id,
583
+ )
584
+ if change.old_version_id == new_version_id:
585
+ # Special case: This change undoes the previous change(s). The value
586
+ # in change.old_version_id represents the Draft version before the
587
+ # DraftChangeLog was started, regardless of how many times we've
588
+ # changed it since we entered the bulk_draft_changes_for() context.
589
+ # If we get here in the code, it means that we're now setting the
590
+ # Draft version of this entity to be exactly what it was at the
591
+ # start, and we should remove it entirely from the DraftChangeLog.
592
+ #
593
+ # It's important that we remove these cases, because we use the
594
+ # old_version == new_version convention to record entities that have
595
+ # changed purely due to side-effects.
596
+ change.delete()
597
+ else:
598
+ # Normal case: We update the new_version, but leave the old_version
599
+ # as is. The old_version represents what the Draft was pointing to
600
+ # when the bulk_draft_changes_for() context started, so it persists
601
+ # if we change the same entity multiple times in the DraftChangeLog.
602
+ change.new_version_id = new_version_id
603
+ change.save()
604
+ except DraftChangeLogRecord.DoesNotExist:
605
+ # If we're here, this is the first DraftChangeLogRecord we're making for
606
+ # this PublishableEntity in the active DraftChangeLog.
607
+ change = DraftChangeLogRecord.objects.create(
608
+ draft_change_log=active_change_log,
609
+ entity_id=entity_id,
610
+ old_version_id=old_version_id,
611
+ new_version_id=new_version_id,
612
+ )
613
+
614
+ return change
615
+
616
+
617
+ def _create_container_side_effects_for_draft_change_log(change_log: DraftChangeLog):
618
+ """
619
+ Iterate through the whole DraftChangeLog and process side-effects.
620
+ """
621
+ processed_entity_ids: set[int] = set()
622
+ for change in change_log.records.all():
623
+ _create_container_side_effects_for_draft_change(
624
+ change,
625
+ processed_entity_ids=processed_entity_ids,
626
+ )
627
+
628
+
629
+ def _create_container_side_effects_for_draft_change(
630
+ original_change: DraftChangeLogRecord,
631
+ processed_entity_ids: set | None = None
632
+ ):
633
+ """
634
+ Given a draft change, add side effects for all affected containers.
635
+
636
+ This should only be run after the DraftChangeLogRecord has been otherwise
637
+ fully written out. We want to avoid the scenario where we create a
638
+ side-effect that a Component change affects a Unit if the Unit version is
639
+ also changed (maybe even deleted) in the same DraftChangeLog.
640
+
641
+ The `processed_entity_ids` set holds the entity IDs that we've already
642
+ calculated side-effects for. This is to save us from recalculating side-
643
+ effects for the same ancestor relationships over and over again. So if we're
644
+ calling this function in a loop for all the Components in a Unit, we won't
645
+ be recalculating the Unit's side-effect on its Subsection, and its
646
+ Subsection's side-effect on its Section.
647
+
648
+ TODO: This could get very expensive with the get_containers_with_entity
649
+ calls. We should measure the impact of this.
650
+ """
651
+ if processed_entity_ids is None:
652
+ # An optimization, but also a guard against infinite side-effect loops.
653
+ processed_entity_ids = set()
654
+
655
+ changes_and_containers = [
656
+ (original_change, container)
657
+ for container
658
+ in get_containers_with_entity(original_change.entity_id, ignore_pinned=True)
659
+ ]
660
+ while changes_and_containers:
661
+ change, container = changes_and_containers.pop()
662
+
663
+ # If the container is not already in the DraftChangeLog, we need to
664
+ # add it. Since it's being caused as a DraftSideEffect, we're going
665
+ # add it with the old_version == new_version convention.
666
+ container_draft_version_pk = container.versioning.draft.pk
667
+ container_change, _created = DraftChangeLogRecord.objects.get_or_create(
668
+ draft_change_log=change.draft_change_log,
669
+ entity_id=container.pk,
670
+ defaults={
671
+ 'old_version_id': container_draft_version_pk,
672
+ 'new_version_id': container_draft_version_pk
673
+ }
674
+ )
675
+
676
+ # Mark that change in the current loop has the side effect of changing
677
+ # the parent container. We'll do this regardless of whether the
678
+ # container version itself also changed. If a Unit has a Component and
679
+ # both the Unit and Component have their versions incremented, then the
680
+ # Unit has changed in both ways (the Unit's internal metadata as well as
681
+ # the new version of the child component).
682
+ DraftSideEffect.objects.get_or_create(cause=change, effect=container_change)
683
+ processed_entity_ids.add(change.entity_id)
684
+
685
+ # Now we find the next layer up of containers. So if the originally
686
+ # passed in publishable_entity_id was for a Component, then the
687
+ # ``container`` we've been creating the side effect for in this loop
688
+ # is the Unit, and ``parents_of_container`` would be any Sequences
689
+ # that contain the Unit.
690
+ parents_of_container = get_containers_with_entity(container.pk, ignore_pinned=True)
691
+
692
+ changes_and_containers.extend(
693
+ (container_change, container_parent)
694
+ for container_parent in parents_of_container
695
+ if container_parent.pk not in processed_entity_ids
696
+ )
697
+
698
+
699
+ def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None:
467
700
  """
468
701
  Sets the Draft version to None.
469
702
 
@@ -473,10 +706,15 @@ def soft_delete_draft(publishable_entity_id: int, /) -> None:
473
706
  of pointing the Draft back to the most recent ``PublishableEntityVersion``
474
707
  for a given ``PublishableEntity``.
475
708
  """
476
- return set_draft_version(publishable_entity_id, None)
709
+ return set_draft_version(publishable_entity_id, None, set_by=deleted_by)
477
710
 
478
711
 
479
- def reset_drafts_to_published(learning_package_id: int, /) -> None:
712
+ def reset_drafts_to_published(
713
+ learning_package_id: int,
714
+ /,
715
+ reset_at: datetime | None = None,
716
+ reset_by: int | None = None, # User.id
717
+ ) -> None:
480
718
  """
481
719
  Reset all Drafts to point to the most recently Published versions.
482
720
 
@@ -500,27 +738,55 @@ def reset_drafts_to_published(learning_package_id: int, /) -> None:
500
738
  it's important that the code creating the "next" version_num looks at the
501
739
  latest version created for a PublishableEntity (its ``latest`` attribute),
502
740
  rather than basing it off of the version that Draft points to.
503
-
504
- Also, there is no current immutable record for when a reset happens. It's
505
- not like a publish that leaves an entry in the ``PublishLog``.
506
741
  """
742
+ if reset_at is None:
743
+ reset_at = datetime.now(tz=timezone.utc)
744
+
507
745
  # These are all the drafts that are different from the published versions.
508
746
  draft_qset = Draft.objects \
509
747
  .select_related("entity__published") \
510
748
  .filter(entity__learning_package_id=learning_package_id) \
511
- .exclude(entity__published__version_id=F("version_id"))
749
+ .exclude(entity__published__version_id=F("version_id")) \
750
+ .exclude(
751
+ # NULL != NULL in SQL, so we want to exclude entries
752
+ # where both the published version and draft version
753
+ # are None. This edge case happens when we create
754
+ # something and then delete it without publishing, and
755
+ # then reset Drafts to their published state.
756
+ Q(entity__published__version__isnull=True) &
757
+ Q(version__isnull=True)
758
+ )
759
+ # If there's nothing to reset because there are no changes from the
760
+ # published version, just return early rather than making an empty
761
+ # DraftChangeLog.
762
+ if not draft_qset:
763
+ return
764
+
765
+ active_change_log = DraftChangeLogContext.get_active_draft_change_log(learning_package_id)
766
+
767
+ # If there's an active DraftChangeLog, we're already in a transaction, so
768
+ # there's no need to open a new one.
769
+ tx_context: ContextManager
770
+ if active_change_log:
771
+ tx_context = nullcontext()
772
+ else:
773
+ tx_context = bulk_draft_changes_for(
774
+ learning_package_id, changed_at=reset_at, changed_by=reset_by
775
+ )
512
776
 
513
- # Note: We can't do an .update with a F() on a joined field in the ORM, so
514
- # we have to loop through the drafts individually to reset them. We can
515
- # rework this into a bulk update or custom SQL if it becomes a performance
516
- # issue.
517
- with atomic():
777
+ with tx_context:
778
+ # Note: We can't do an .update with a F() on a joined field in the ORM,
779
+ # so we have to loop through the drafts individually to reset them
780
+ # anyhow. We can rework this into a bulk update or custom SQL if it
781
+ # becomes a performance issue, as long as we also port over the
782
+ # bookkeeping code in set_draft_version.
518
783
  for draft in draft_qset:
519
784
  if hasattr(draft.entity, 'published'):
520
- draft.version_id = draft.entity.published.version_id
785
+ published_version_id = draft.entity.published.version_id
521
786
  else:
522
- draft.version = None
523
- draft.save()
787
+ published_version_id = None
788
+
789
+ set_draft_version(draft, published_version_id)
524
790
 
525
791
 
526
792
  def register_content_models(
@@ -639,8 +905,7 @@ def create_entity_list() -> EntityList:
639
905
 
640
906
 
641
907
  def create_entity_list_with_rows(
642
- entity_pks: list[int],
643
- entity_version_pks: list[int | None],
908
+ entity_rows: list[ContainerEntityRow],
644
909
  *,
645
910
  learning_package_id: int | None,
646
911
  ) -> EntityList:
@@ -649,10 +914,7 @@ def create_entity_list_with_rows(
649
914
  Create new entity list rows for an entity list.
650
915
 
651
916
  Args:
652
- entity_pks: The IDs of the publishable entities that the entity list rows reference.
653
- entity_version_pks: The IDs of the versions of the entities
654
- (PublishableEntityVersion) that the entity list rows reference, or
655
- Nones for "unpinned" (default).
917
+ entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
656
918
  learning_package_id: Optional. Verify that all the entities are from
657
919
  the specified learning package.
658
920
 
@@ -662,13 +924,25 @@ def create_entity_list_with_rows(
662
924
  # Do a quick check that the given entities are in the right learning package:
663
925
  if learning_package_id:
664
926
  if PublishableEntity.objects.filter(
665
- pk__in=entity_pks,
927
+ pk__in=[entity.entity_pk for entity in entity_rows],
666
928
  ).exclude(
667
929
  learning_package_id=learning_package_id,
668
930
  ).exists():
669
931
  raise ValidationError("Container entities must be from the same learning package.")
670
932
 
671
- order_nums = range(len(entity_pks))
933
+ # Ensure that any pinned entity versions are linked to the correct entity
934
+ pinned_entities = {
935
+ entity.version_pk: entity.entity_pk
936
+ for entity in entity_rows if entity.pinned
937
+ }
938
+ if pinned_entities:
939
+ entity_versions = PublishableEntityVersion.objects.filter(
940
+ pk__in=pinned_entities.keys(),
941
+ ).only('pk', 'entity_id')
942
+ for entity_version in entity_versions:
943
+ if pinned_entities[entity_version.pk] != entity_version.entity_id:
944
+ raise ValidationError("Container entity versions must belong to the specified entity.")
945
+
672
946
  with atomic(savepoint=False):
673
947
 
674
948
  entity_list = create_entity_list()
@@ -676,13 +950,11 @@ def create_entity_list_with_rows(
676
950
  [
677
951
  EntityListRow(
678
952
  entity_list=entity_list,
679
- entity_id=entity_pk,
953
+ entity_id=entity.entity_pk,
680
954
  order_num=order_num,
681
- entity_version_id=entity_version_pk,
682
- )
683
- for order_num, entity_pk, entity_version_pk in zip(
684
- order_nums, entity_pks, entity_version_pks
955
+ entity_version_id=entity.version_pk,
685
956
  )
957
+ for order_num, entity in enumerate(entity_rows)
686
958
  ]
687
959
  )
688
960
  return entity_list
@@ -725,8 +997,7 @@ def create_container_version(
725
997
  version_num: int,
726
998
  *,
727
999
  title: str,
728
- publishable_entities_pks: list[int],
729
- entity_version_pks: list[int | None] | None,
1000
+ entity_rows: list[ContainerEntityRow],
730
1001
  created: datetime,
731
1002
  created_by: int | None,
732
1003
  container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
@@ -739,8 +1010,7 @@ def create_container_version(
739
1010
  container_id: The ID of the container that the version belongs to.
740
1011
  version_num: The version number of the container.
741
1012
  title: The title of the container.
742
- publishable_entities_pks: The IDs of the members of the container.
743
- entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
1013
+ entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
744
1014
  created: The date and time the container version was created.
745
1015
  created_by: The ID of the user who created the container version.
746
1016
  container_version_cls: The subclass of ContainerVersion to use, if applicable.
@@ -749,16 +1019,13 @@ def create_container_version(
749
1019
  The newly created container version.
750
1020
  """
751
1021
  assert title is not None
752
- assert publishable_entities_pks is not None
1022
+ assert entity_rows is not None
753
1023
 
754
1024
  with atomic(savepoint=False):
755
1025
  container = Container.objects.select_related("publishable_entity").get(pk=container_id)
756
1026
  entity = container.publishable_entity
757
- if entity_version_pks is None:
758
- entity_version_pks = [None] * len(publishable_entities_pks)
759
1027
  entity_list = create_entity_list_with_rows(
760
- entity_pks=publishable_entities_pks,
761
- entity_version_pks=entity_version_pks,
1028
+ entity_rows=entity_rows,
762
1029
  learning_package_id=entity.learning_package_id,
763
1030
  )
764
1031
  container_version = _create_container_version(
@@ -785,8 +1052,7 @@ class ChildrenEntitiesAction(Enum):
785
1052
  def create_next_entity_list(
786
1053
  learning_package_id: int,
787
1054
  last_version: ContainerVersion,
788
- publishable_entities_pks: list[int],
789
- entity_version_pks: list[int | None] | None,
1055
+ entity_rows: list[ContainerEntityRow],
790
1056
  entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
791
1057
  ) -> EntityList:
792
1058
  """
@@ -795,55 +1061,53 @@ def create_next_entity_list(
795
1061
  Args:
796
1062
  learning_package_id: Learning package ID
797
1063
  last_version: Last version of container.
798
- publishable_entities_pks: The IDs of the members current members of the container.
799
- entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
1064
+ entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
800
1065
  entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
801
1066
 
802
1067
  Returns:
803
1068
  The newly created entity list.
804
1069
  """
805
- if entity_version_pks is None:
806
- entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks) # type: ignore[no-redef]
807
1070
  if entities_action == ChildrenEntitiesAction.APPEND:
808
1071
  # get previous entity list rows
809
1072
  last_entities = last_version.entity_list.entitylistrow_set.only(
810
1073
  "entity_id",
811
1074
  "entity_version_id"
812
1075
  ).order_by("order_num")
813
- # append given publishable_entities_pks and entity_version_pks
814
- publishable_entities_pks = [entity.entity_id for entity in last_entities] + publishable_entities_pks
815
- entity_version_pks = [ # type: ignore[operator, assignment]
816
- entity.entity_version_id
1076
+ # append given entity_rows to the existing children
1077
+ entity_rows = [
1078
+ ContainerEntityRow(
1079
+ entity_pk=entity.entity_id,
1080
+ version_pk=entity.entity_version_id,
1081
+ )
817
1082
  for entity in last_entities
818
- ] + entity_version_pks
1083
+ ] + entity_rows
819
1084
  elif entities_action == ChildrenEntitiesAction.REMOVE:
820
- # get previous entity list rows
1085
+ # get previous entity list, excluding the entities in entity_rows
821
1086
  last_entities = last_version.entity_list.entitylistrow_set.only(
822
1087
  "entity_id",
823
1088
  "entity_version_id"
1089
+ ).exclude(
1090
+ entity_id__in=[entity.entity_pk for entity in entity_rows]
824
1091
  ).order_by("order_num")
825
- # Remove entities that are in publishable_entities_pks
826
- new_entities = [
827
- entity
828
- for entity in last_entities
829
- if entity.entity_id not in publishable_entities_pks
1092
+ entity_rows = [
1093
+ ContainerEntityRow(
1094
+ entity_pk=entity.entity_id,
1095
+ version_pk=entity.entity_version_id,
1096
+ )
1097
+ for entity in last_entities.all()
830
1098
  ]
831
- publishable_entities_pks = [entity.entity_id for entity in new_entities]
832
- entity_version_pks = [entity.entity_version_id for entity in new_entities]
833
- next_entity_list = create_entity_list_with_rows(
834
- entity_pks=publishable_entities_pks,
835
- entity_version_pks=entity_version_pks, # type: ignore[arg-type]
1099
+
1100
+ return create_entity_list_with_rows(
1101
+ entity_rows=entity_rows,
836
1102
  learning_package_id=learning_package_id,
837
1103
  )
838
- return next_entity_list
839
1104
 
840
1105
 
841
1106
  def create_next_container_version(
842
1107
  container_pk: int,
843
1108
  *,
844
1109
  title: str | None,
845
- publishable_entities_pks: list[int] | None,
846
- entity_version_pks: list[int | None] | None,
1110
+ entity_rows: list[ContainerEntityRow] | None,
847
1111
  created: datetime,
848
1112
  created_by: int | None,
849
1113
  container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
@@ -863,8 +1127,8 @@ def create_next_container_version(
863
1127
  Args:
864
1128
  container_pk: The ID of the container to create the next version of.
865
1129
  title: The title of the container. None to keep the current title.
866
- publishable_entities_pks: The IDs of the members current members of the container. Or None for no change.
867
- entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
1130
+ entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
1131
+ Or None for no change.
868
1132
  created: The date and time the container version was created.
869
1133
  created_by: The ID of the user who created the container version.
870
1134
  container_version_cls: The subclass of ContainerVersion to use, if applicable.
@@ -879,15 +1143,15 @@ def create_next_container_version(
879
1143
  last_version = container.versioning.latest
880
1144
  assert last_version is not None
881
1145
  next_version_num = last_version.version_num + 1
882
- if publishable_entities_pks is None:
1146
+
1147
+ if entity_rows is None:
883
1148
  # We're only changing metadata. Keep the same entity list.
884
1149
  next_entity_list = last_version.entity_list
885
1150
  else:
886
1151
  next_entity_list = create_next_entity_list(
887
1152
  entity.learning_package_id,
888
1153
  last_version,
889
- publishable_entities_pks,
890
- entity_version_pks,
1154
+ entity_rows,
891
1155
  entities_action
892
1156
  )
893
1157
 
@@ -955,6 +1219,21 @@ def get_containers(
955
1219
  return container_cls.objects.filter(publishable_entity__learning_package=learning_package_id)
956
1220
 
957
1221
 
1222
+ def get_collection_containers(
1223
+ learning_package_id: int,
1224
+ collection_key: str,
1225
+ ) -> QuerySet[Container]:
1226
+ """
1227
+ Returns a QuerySet of Containers relating to the PublishableEntities in a Collection.
1228
+
1229
+ Containers have a one-to-one relationship with PublishableEntity, but the reverse may not always be true.
1230
+ """
1231
+ return Container.objects.filter(
1232
+ publishable_entity__learning_package_id=learning_package_id,
1233
+ publishable_entity__collections__key=collection_key,
1234
+ ).order_by('pk')
1235
+
1236
+
958
1237
  @dataclass(frozen=True)
959
1238
  class ContainerEntityListEntry:
960
1239
  """
@@ -969,6 +1248,23 @@ class ContainerEntityListEntry:
969
1248
  return self.entity_version.entity
970
1249
 
971
1250
 
1251
+ @dataclass(frozen=True, kw_only=True, slots=True)
1252
+ class ContainerEntityRow:
1253
+ """
1254
+ [ 🛑 UNSTABLE ]
1255
+ Used to specify the primary key of PublishableEntity and optional PublishableEntityVersion.
1256
+
1257
+ If version_pk is None (default), then the entity is considered "unpinned",
1258
+ meaning that the latest version of the entity will be used.
1259
+ """
1260
+ entity_pk: int
1261
+ version_pk: int | None = None
1262
+
1263
+ @property
1264
+ def pinned(self):
1265
+ return self.entity_pk and self.version_pk is not None
1266
+
1267
+
972
1268
  def get_entities_in_container(
973
1269
  container: Container,
974
1270
  *,
@@ -1071,6 +1367,7 @@ def get_containers_with_entity(
1071
1367
  ignore_pinned: if true, ignore any pinned references to the entity.
1072
1368
  """
1073
1369
  if ignore_pinned:
1370
+ # TODO: Do we need to run distinct() on this? Will fix in https://github.com/openedx/openedx-learning/issues/303
1074
1371
  qs = Container.objects.filter(
1075
1372
  # Note: these two conditions must be in the same filter() call, or the query won't be correct.
1076
1373
  publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
@@ -1113,3 +1410,47 @@ def get_container_children_count(
1113
1410
  else:
1114
1411
  filter_deleted = {"entity__draft__version__isnull": False}
1115
1412
  return container_version.entity_list.entitylistrow_set.filter(**filter_deleted).count()
1413
+
1414
+
1415
+ def bulk_draft_changes_for(
1416
+ learning_package_id: int,
1417
+ changed_by: int | None = None,
1418
+ changed_at: datetime | None = None
1419
+ ) -> DraftChangeLogContext:
1420
+ """
1421
+ Context manager to do a single batch of Draft changes in.
1422
+
1423
+ Each publishable entity that is edited in this context will be tied to a
1424
+ single DraftChangeLogRecord, representing the cumulative changes made to
1425
+ that entity. Upon closing of the context, side effects of these changes will
1426
+ be calcuated, which may result in more DraftChangeLogRecords being created
1427
+ or updated. The resulting DraftChangeLogRecords and DraftChangeSideEffects
1428
+ will be tied together into a single DraftChangeLog, representing the
1429
+ collective changes to the learning package that happened in this context.
1430
+ All changes will be committed in a single atomic transaction.
1431
+
1432
+ Example::
1433
+
1434
+ with bulk_draft_changes_for(learning_package.id):
1435
+ for section in course:
1436
+ update_section_drafts(learning_package.id, section)
1437
+
1438
+ If you make a change to an entity *without* using this context manager, then
1439
+ the individual change (and its side effects) will be automatically wrapped
1440
+ in a one-off change context. For example, this::
1441
+
1442
+ update_one_component(component.learning_package, component)
1443
+
1444
+ is identical to this::
1445
+
1446
+ with bulk_draft_changes_for(component.learning_package.id):
1447
+ update_one_component(component.learning_package.id, component)
1448
+ """
1449
+ return DraftChangeLogContext(
1450
+ learning_package_id,
1451
+ changed_at=changed_at,
1452
+ changed_by=changed_by,
1453
+ exit_callbacks=[
1454
+ _create_container_side_effects_for_draft_change_log,
1455
+ ]
1456
+ )