openedx-learning 0.18.3__py2.py3-none-any.whl → 0.19.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.
Files changed (36) hide show
  1. openedx_learning/__init__.py +1 -1
  2. openedx_learning/api/authoring.py +1 -0
  3. openedx_learning/api/authoring_models.py +1 -1
  4. openedx_learning/apps/authoring/components/api.py +3 -3
  5. openedx_learning/apps/authoring/components/apps.py +1 -1
  6. openedx_learning/apps/authoring/components/models.py +9 -14
  7. openedx_learning/apps/authoring/contents/models.py +1 -1
  8. openedx_learning/apps/authoring/publishing/api.py +459 -3
  9. openedx_learning/apps/authoring/publishing/apps.py +9 -0
  10. openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +54 -0
  11. openedx_learning/apps/authoring/publishing/models/__init__.py +27 -0
  12. openedx_learning/apps/authoring/publishing/models/container.py +70 -0
  13. openedx_learning/apps/authoring/publishing/models/draft_published.py +95 -0
  14. openedx_learning/apps/authoring/publishing/models/entity_list.py +69 -0
  15. openedx_learning/apps/authoring/publishing/models/learning_package.py +75 -0
  16. openedx_learning/apps/authoring/publishing/models/publish_log.py +106 -0
  17. openedx_learning/apps/authoring/publishing/{model_mixins.py → models/publishable_entity.py} +284 -41
  18. openedx_learning/apps/authoring/units/__init__.py +0 -0
  19. openedx_learning/apps/authoring/units/api.py +290 -0
  20. openedx_learning/apps/authoring/units/apps.py +25 -0
  21. openedx_learning/apps/authoring/units/migrations/0001_initial.py +36 -0
  22. openedx_learning/apps/authoring/units/migrations/__init__.py +0 -0
  23. openedx_learning/apps/authoring/units/models.py +50 -0
  24. openedx_learning/contrib/media_server/apps.py +1 -1
  25. openedx_learning/lib/managers.py +7 -1
  26. openedx_learning/lib/validators.py +1 -1
  27. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/METADATA +3 -3
  28. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/RECORD +35 -23
  29. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/WHEEL +1 -1
  30. openedx_tagging/core/tagging/api.py +4 -4
  31. openedx_tagging/core/tagging/models/base.py +1 -1
  32. openedx_tagging/core/tagging/rest_api/v1/permissions.py +1 -1
  33. openedx_tagging/core/tagging/rules.py +1 -2
  34. openedx_learning/apps/authoring/publishing/models.py +0 -517
  35. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/LICENSE.txt +0 -0
  36. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/top_level.txt +0 -0
@@ -2,4 +2,4 @@
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
4
 
5
- __version__ = "0.18.3"
5
+ __version__ = "0.19.0"
@@ -13,6 +13,7 @@ from ..apps.authoring.collections.api import *
13
13
  from ..apps.authoring.components.api import *
14
14
  from ..apps.authoring.contents.api import *
15
15
  from ..apps.authoring.publishing.api import *
16
+ from ..apps.authoring.units.api import *
16
17
 
17
18
  # This was renamed after the authoring API refactoring pushed this and other
18
19
  # app APIs into the openedx_learning.api.authoring module. Here I'm aliasing to
@@ -10,5 +10,5 @@ consistent.
10
10
  from ..apps.authoring.collections.models import *
11
11
  from ..apps.authoring.components.models import *
12
12
  from ..apps.authoring.contents.models import *
13
- from ..apps.authoring.publishing.model_mixins import *
14
13
  from ..apps.authoring.publishing.models import *
14
+ from ..apps.authoring.units.models import *
@@ -231,7 +231,7 @@ def create_next_component_version(
231
231
  return component_version
232
232
 
233
233
 
234
- def create_component_and_version(
234
+ def create_component_and_version( # pylint: disable=too-many-positional-arguments
235
235
  learning_package_id: int,
236
236
  /,
237
237
  component_type: ComponentType,
@@ -326,7 +326,7 @@ def component_exists_by_key(
326
326
  return False
327
327
 
328
328
 
329
- def get_components(
329
+ def get_components( # pylint: disable=too-many-positional-arguments
330
330
  learning_package_id: int,
331
331
  /,
332
332
  draft: bool | None = None,
@@ -468,7 +468,7 @@ def _get_component_version_info_headers(component_version: ComponentVersion) ->
468
468
  "X-Open-edX-Component-Uuid": component.uuid,
469
469
  # Component Version
470
470
  "X-Open-edX-Component-Version-Uuid": component_version.uuid,
471
- "X-Open-edX-Component-Version-Num": component_version.version_num,
471
+ "X-Open-edX-Component-Version-Num": str(component_version.version_num),
472
472
  # Learning Package
473
473
  "X-Open-edX-Learning-Package-Key": learning_package.key,
474
474
  "X-Open-edX-Learning-Package-Uuid": learning_package.uuid,
@@ -14,7 +14,7 @@ class ComponentsConfig(AppConfig):
14
14
  default_auto_field = "django.db.models.BigAutoField"
15
15
  label = "oel_components"
16
16
 
17
- def ready(self):
17
+ def ready(self) -> None:
18
18
  """
19
19
  Register Component and ComponentVersion.
20
20
  """
@@ -17,13 +17,14 @@ convention, but it's possible we might want to have special identifiers later.
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
+ from typing import ClassVar
21
+
20
22
  from django.db import models
21
23
 
22
24
  from ....lib.fields import case_sensitive_char_field, immutable_uuid_field, key_field
23
25
  from ....lib.managers import WithRelationsManager
24
26
  from ..contents.models import Content
25
- from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin
26
- from ..publishing.models import LearningPackage
27
+ from ..publishing.models import LearningPackage, PublishableEntityMixin, PublishableEntityVersionMixin
27
28
 
28
29
  __all__ = [
29
30
  "ComponentType",
@@ -72,11 +73,11 @@ class ComponentType(models.Model):
72
73
  ),
73
74
  ]
74
75
 
75
- def __str__(self):
76
+ def __str__(self) -> str:
76
77
  return f"{self.namespace}:{self.name}"
77
78
 
78
79
 
79
- class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
80
+ class Component(PublishableEntityMixin):
80
81
  """
81
82
  This represents any Component that has ever existed in a LearningPackage.
82
83
 
@@ -120,14 +121,12 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
120
121
  Make a foreign key to the Component model when you need a stable reference
121
122
  that will exist for as long as the LearningPackage itself exists.
122
123
  """
123
- # Tell mypy what type our objects manager has.
124
- # It's actually PublishableEntityMixinManager, but that has the exact same
125
- # interface as the base manager class.
126
- objects: models.Manager[Component] = WithRelationsManager(
124
+ # Set up our custom manager. It has the same API as the default one, but selects related objects by default.
125
+ objects: ClassVar[WithRelationsManager[Component]] = WithRelationsManager( # type: ignore[assignment]
127
126
  'component_type'
128
127
  )
129
128
 
130
- with_publishing_relations: models.Manager[Component] = WithRelationsManager(
129
+ with_publishing_relations = WithRelationsManager(
131
130
  'component_type',
132
131
  'publishable_entity',
133
132
  'publishable_entity__draft__version',
@@ -190,7 +189,7 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
190
189
  verbose_name = "Component"
191
190
  verbose_name_plural = "Components"
192
191
 
193
- def __str__(self):
192
+ def __str__(self) -> str:
194
193
  return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}"
195
194
 
196
195
 
@@ -201,10 +200,6 @@ class ComponentVersion(PublishableEntityVersionMixin):
201
200
  This holds the content using a M:M relationship with Content via
202
201
  ComponentVersionContent.
203
202
  """
204
- # Tell mypy what type our objects manager has.
205
- # It's actually PublishableEntityVersionMixinManager, but that has the exact
206
- # same interface as the base manager class.
207
- objects: models.Manager[ComponentVersion]
208
203
 
209
204
  # This is technically redundant, since we can get this through
210
205
  # publishable_entity_version.publishable.component, but this is more
@@ -121,7 +121,7 @@ class MediaType(models.Model):
121
121
  ),
122
122
  ]
123
123
 
124
- def __str__(self):
124
+ def __str__(self) -> str:
125
125
  base = f"{self.type}/{self.sub_type}"
126
126
  if self.suffix:
127
127
  return f"{base}+{self.suffix}"
@@ -6,23 +6,37 @@ are stored in this app.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ from dataclasses import dataclass
9
10
  from datetime import datetime, timezone
11
+ from typing import TypeVar
10
12
 
11
- from django.core.exceptions import ObjectDoesNotExist
13
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError
12
14
  from django.db.models import F, Q, QuerySet
13
15
  from django.db.transaction import atomic
14
16
 
15
- from .model_mixins import PublishableContentModelRegistry, PublishableEntityMixin, PublishableEntityVersionMixin
16
17
  from .models import (
18
+ Container,
19
+ ContainerVersion,
17
20
  Draft,
21
+ EntityList,
22
+ EntityListRow,
18
23
  LearningPackage,
24
+ PublishableContentModelRegistry,
19
25
  PublishableEntity,
26
+ PublishableEntityMixin,
20
27
  PublishableEntityVersion,
28
+ PublishableEntityVersionMixin,
21
29
  Published,
22
30
  PublishLog,
23
31
  PublishLogRecord,
24
32
  )
25
33
 
34
+ # A few of the APIs in this file are generic and can be used for Containers in
35
+ # general, or e.g. Units (subclass of Container) in particular. These type
36
+ # variables are used to provide correct typing for those generic API methods.
37
+ ContainerModel = TypeVar('ContainerModel', bound=Container)
38
+ ContainerVersionModel = TypeVar('ContainerVersionModel', bound=ContainerVersion)
39
+
26
40
  # The public API that will be re-exported by openedx_learning.apps.authoring.api
27
41
  # is listed in the __all__ entries below. Internal helper functions that are
28
42
  # private to this module should start with an underscore. If a function does not
@@ -51,6 +65,16 @@ __all__ = [
51
65
  "reset_drafts_to_published",
52
66
  "register_content_models",
53
67
  "filter_publishable_entities",
68
+ # 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured
69
+ # out our approach to dynamic content (randomized, A/B tests, etc.)
70
+ "create_container",
71
+ "create_container_version",
72
+ "create_next_container_version",
73
+ "get_container",
74
+ "ContainerEntityListEntry",
75
+ "get_entities_in_container",
76
+ "contains_unpublished_changes",
77
+ "get_containers_with_entity",
54
78
  ]
55
79
 
56
80
 
@@ -310,6 +334,30 @@ def publish_from_drafts(
310
334
  published_at = datetime.now(tz=timezone.utc)
311
335
 
312
336
  with atomic():
337
+ # If the drafts include any containers, we need to auto-publish their descendants:
338
+ # TODO: this only handles one level deep and would need to be updated to support sections > subsections > units
339
+
340
+ # Get the IDs of the ContainerVersion for any Containers whose drafts are slated to be published.
341
+ container_version_ids = (
342
+ Container.objects.filter(publishable_entity__draft__in=draft_qset)
343
+ .values_list("publishable_entity__draft__version__containerversion__pk", flat=True)
344
+ )
345
+ if container_version_ids:
346
+ # We are publishing at least one container. Check if it has any child components that aren't already slated
347
+ # to be published.
348
+ unpublished_draft_children = EntityListRow.objects.filter(
349
+ entity_list__container_versions__pk__in=container_version_ids,
350
+ entity_version=None, # Unpinned entities only
351
+ ).exclude(
352
+ entity__draft__version=F("entity__published__version") # Exclude already published things
353
+ ).values_list("entity__draft__pk", flat=True)
354
+ if unpublished_draft_children:
355
+ # Force these additional child components to be published at the same time by adding them to the qset:
356
+ draft_qset = Draft.objects.filter(
357
+ Q(pk__in=draft_qset.values_list("pk", flat=True)) |
358
+ Q(pk__in=unpublished_draft_children)
359
+ )
360
+
313
361
  # One PublishLog for this entire publish operation.
314
362
  publish_log = PublishLog(
315
363
  learning_package_id=learning_package_id,
@@ -477,7 +525,7 @@ def register_content_models(
477
525
  This is so that we can provide convenience links between content models and
478
526
  content version models *through* the publishing apps, so that you can do
479
527
  things like finding the draft version of a content model more easily. See
480
- the model_mixins.py module for more details.
528
+ the publishable_entity.py module for more details.
481
529
 
482
530
  This should only be imported and run from the your app's AppConfig.ready()
483
531
  method. For example, in the components app, this looks like:
@@ -513,3 +561,411 @@ def filter_publishable_entities(
513
561
  entities = entities.filter(published__version__isnull=not has_published)
514
562
 
515
563
  return entities
564
+
565
+
566
+ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> PublishableEntityVersion | None:
567
+ """
568
+ Get the published version of the given entity, at a specific snapshot in the
569
+ history of this Learning Package, given by the PublishLog ID.
570
+
571
+ This is a semi-private function, only available to other apps in the
572
+ authoring package.
573
+ """
574
+ record = PublishLogRecord.objects.filter(
575
+ entity_id=entity_id,
576
+ publish_log_id__lte=publish_log_id,
577
+ ).order_by('-publish_log_id').first()
578
+ return record.new_version if record else None
579
+
580
+
581
+ def create_container(
582
+ learning_package_id: int,
583
+ key: str,
584
+ created: datetime,
585
+ created_by: int | None,
586
+ # The types on the following line are correct, but mypy will complain - https://github.com/python/mypy/issues/3737
587
+ container_cls: type[ContainerModel] = Container, # type: ignore[assignment]
588
+ ) -> ContainerModel:
589
+ """
590
+ [ 🛑 UNSTABLE ]
591
+ Create a new container.
592
+
593
+ Args:
594
+ learning_package_id: The ID of the learning package that contains the container.
595
+ key: The key of the container.
596
+ created: The date and time the container was created.
597
+ created_by: The ID of the user who created the container
598
+ container_cls: The subclass of Container to use, if applicable
599
+
600
+ Returns:
601
+ The newly created container.
602
+ """
603
+ assert issubclass(container_cls, Container)
604
+ with atomic():
605
+ publishable_entity = create_publishable_entity(
606
+ learning_package_id, key, created, created_by
607
+ )
608
+ container = container_cls.objects.create(
609
+ publishable_entity=publishable_entity,
610
+ )
611
+ return container
612
+
613
+
614
+ def create_entity_list() -> EntityList:
615
+ """
616
+ [ 🛑 UNSTABLE ]
617
+ Create a new entity list. This is an structure that holds a list of entities
618
+ that will be referenced by the container.
619
+
620
+ Returns:
621
+ The newly created entity list.
622
+ """
623
+ return EntityList.objects.create()
624
+
625
+
626
+ def create_entity_list_with_rows(
627
+ entity_pks: list[int],
628
+ entity_version_pks: list[int | None],
629
+ *,
630
+ learning_package_id: int | None,
631
+ ) -> EntityList:
632
+ """
633
+ [ 🛑 UNSTABLE ]
634
+ Create new entity list rows for an entity list.
635
+
636
+ Args:
637
+ entity_pks: The IDs of the publishable entities that the entity list rows reference.
638
+ entity_version_pks: The IDs of the versions of the entities
639
+ (PublishableEntityVersion) that the entity list rows reference, or
640
+ Nones for "unpinned" (default).
641
+ learning_package_id: Optional. Verify that all the entities are from
642
+ the specified learning package.
643
+
644
+ Returns:
645
+ The newly created entity list.
646
+ """
647
+ # Do a quick check that the given entities are in the right learning package:
648
+ if learning_package_id:
649
+ if PublishableEntity.objects.filter(
650
+ pk__in=entity_pks,
651
+ ).exclude(
652
+ learning_package_id=learning_package_id,
653
+ ).exists():
654
+ raise ValidationError("Container entities must be from the same learning package.")
655
+
656
+ order_nums = range(len(entity_pks))
657
+ with atomic(savepoint=False):
658
+
659
+ entity_list = create_entity_list()
660
+ EntityListRow.objects.bulk_create(
661
+ [
662
+ EntityListRow(
663
+ entity_list=entity_list,
664
+ entity_id=entity_pk,
665
+ order_num=order_num,
666
+ entity_version_id=entity_version_pk,
667
+ )
668
+ for order_num, entity_pk, entity_version_pk in zip(
669
+ order_nums, entity_pks, entity_version_pks
670
+ )
671
+ ]
672
+ )
673
+ return entity_list
674
+
675
+
676
+ def _create_container_version(
677
+ container: Container,
678
+ version_num: int,
679
+ *,
680
+ title: str,
681
+ entity_list: EntityList,
682
+ created: datetime,
683
+ created_by: int | None,
684
+ container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
685
+ ) -> ContainerVersionModel:
686
+ """
687
+ Private internal method for logic shared by create_container_version() and
688
+ create_next_container_version().
689
+ """
690
+ assert issubclass(container_version_cls, ContainerVersion)
691
+ with atomic(savepoint=False): # Make sure this will happen atomically but we don't need to create a new savepoint.
692
+ publishable_entity_version = create_publishable_entity_version(
693
+ container.publishable_entity_id,
694
+ version_num=version_num,
695
+ title=title,
696
+ created=created,
697
+ created_by=created_by,
698
+ )
699
+ container_version = container_version_cls.objects.create(
700
+ publishable_entity_version=publishable_entity_version,
701
+ container_id=container.pk,
702
+ entity_list=entity_list,
703
+ )
704
+
705
+ return container_version
706
+
707
+
708
+ def create_container_version(
709
+ container_id: int,
710
+ version_num: int,
711
+ *,
712
+ title: str,
713
+ publishable_entities_pks: list[int],
714
+ entity_version_pks: list[int | None] | None,
715
+ created: datetime,
716
+ created_by: int | None,
717
+ container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
718
+ ) -> ContainerVersionModel:
719
+ """
720
+ [ 🛑 UNSTABLE ]
721
+ Create a new container version.
722
+
723
+ Args:
724
+ container_id: The ID of the container that the version belongs to.
725
+ version_num: The version number of the container.
726
+ title: The title of the container.
727
+ publishable_entities_pks: The IDs of the members of the container.
728
+ entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
729
+ created: The date and time the container version was created.
730
+ created_by: The ID of the user who created the container version.
731
+ container_version_cls: The subclass of ContainerVersion to use, if applicable.
732
+
733
+ Returns:
734
+ The newly created container version.
735
+ """
736
+ assert title is not None
737
+ assert publishable_entities_pks is not None
738
+
739
+ with atomic(savepoint=False):
740
+ container = Container.objects.select_related("publishable_entity").get(pk=container_id)
741
+ entity = container.publishable_entity
742
+ if entity_version_pks is None:
743
+ entity_version_pks = [None] * len(publishable_entities_pks)
744
+ entity_list = create_entity_list_with_rows(
745
+ entity_pks=publishable_entities_pks,
746
+ entity_version_pks=entity_version_pks,
747
+ learning_package_id=entity.learning_package_id,
748
+ )
749
+ container_version = _create_container_version(
750
+ container,
751
+ version_num,
752
+ title=title,
753
+ entity_list=entity_list,
754
+ created=created,
755
+ created_by=created_by,
756
+ container_version_cls=container_version_cls,
757
+ )
758
+
759
+ return container_version
760
+
761
+
762
+ def create_next_container_version(
763
+ container_pk: int,
764
+ *,
765
+ title: str | None,
766
+ publishable_entities_pks: list[int] | None,
767
+ entity_version_pks: list[int | None] | None,
768
+ created: datetime,
769
+ created_by: int | None,
770
+ container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
771
+ ) -> ContainerVersionModel:
772
+ """
773
+ [ 🛑 UNSTABLE ]
774
+ Create the next version of a container. A new version of the container is created
775
+ only when its metadata changes:
776
+
777
+ * Something was added to the Container.
778
+ * We re-ordered the rows in the container.
779
+ * Something was removed from the container.
780
+ * The Container's metadata changed, e.g. the title.
781
+ * We pin to different versions of the Container.
782
+
783
+ Args:
784
+ container_pk: The ID of the container to create the next version of.
785
+ title: The title of the container. None to keep the current title.
786
+ publishable_entities_pks: The IDs of the members current members of the container. Or None for no change.
787
+ entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
788
+ created: The date and time the container version was created.
789
+ created_by: The ID of the user who created the container version.
790
+ container_version_cls: The subclass of ContainerVersion to use, if applicable.
791
+
792
+ Returns:
793
+ The newly created container version.
794
+ """
795
+ assert issubclass(container_version_cls, ContainerVersion)
796
+ with atomic():
797
+ container = Container.objects.select_related("publishable_entity").get(pk=container_pk)
798
+ entity = container.publishable_entity
799
+ last_version = container.versioning.latest
800
+ assert last_version is not None
801
+ next_version_num = last_version.version_num + 1
802
+ if publishable_entities_pks is None:
803
+ # We're only changing metadata. Keep the same entity list.
804
+ next_entity_list = last_version.entity_list
805
+ else:
806
+ if entity_version_pks is None:
807
+ entity_version_pks = [None] * len(publishable_entities_pks)
808
+ next_entity_list = create_entity_list_with_rows(
809
+ entity_pks=publishable_entities_pks,
810
+ entity_version_pks=entity_version_pks,
811
+ learning_package_id=entity.learning_package_id,
812
+ )
813
+ next_container_version = _create_container_version(
814
+ container,
815
+ next_version_num,
816
+ title=title if title is not None else last_version.title,
817
+ entity_list=next_entity_list,
818
+ created=created,
819
+ created_by=created_by,
820
+ container_version_cls=container_version_cls,
821
+ )
822
+
823
+ return next_container_version
824
+
825
+
826
+ def get_container(pk: int) -> Container:
827
+ """
828
+ [ 🛑 UNSTABLE ]
829
+ Get a container by its primary key.
830
+
831
+ Args:
832
+ pk: The primary key of the container.
833
+
834
+ Returns:
835
+ The container with the given primary key.
836
+ """
837
+ return Container.objects.get(pk=pk)
838
+
839
+
840
+ @dataclass(frozen=True)
841
+ class ContainerEntityListEntry:
842
+ """
843
+ [ 🛑 UNSTABLE ]
844
+ Data about a single entity in a container, e.g. a component in a unit.
845
+ """
846
+ entity_version: PublishableEntityVersion
847
+ pinned: bool
848
+
849
+ @property
850
+ def entity(self):
851
+ return self.entity_version.entity
852
+
853
+
854
+ def get_entities_in_container(
855
+ container: Container,
856
+ *,
857
+ published: bool,
858
+ ) -> list[ContainerEntityListEntry]:
859
+ """
860
+ [ 🛑 UNSTABLE ]
861
+ Get the list of entities and their versions in the current draft or
862
+ published version of the given container.
863
+
864
+ Args:
865
+ container: The Container, e.g. returned by `get_container()`
866
+ published: `True` if we want the published version of the container, or
867
+ `False` for the draft version.
868
+ """
869
+ assert isinstance(container, Container)
870
+ container_version = container.versioning.published if published else container.versioning.draft
871
+ if container_version is None:
872
+ raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted.
873
+ assert isinstance(container_version, ContainerVersion)
874
+ entity_list = []
875
+ for row in container_version.entity_list.entitylistrow_set.order_by("order_num"):
876
+ entity_version = row.entity_version # This will be set if pinned
877
+ if not entity_version: # If this entity is "unpinned", use the latest published/draft version:
878
+ entity_version = row.entity.published.version if published else row.entity.draft.version
879
+ if entity_version is not None: # As long as this hasn't been soft-deleted:
880
+ entity_list.append(ContainerEntityListEntry(
881
+ entity_version=entity_version,
882
+ pinned=row.entity_version is not None,
883
+ ))
884
+ # else we could indicate somehow a deleted item was here, e.g. by returning a ContainerEntityListEntry with
885
+ # deleted=True, but we don't have a use case for that yet.
886
+ return entity_list
887
+
888
+
889
+ def contains_unpublished_changes(container_id: int) -> bool:
890
+ """
891
+ [ 🛑 UNSTABLE ]
892
+ Check recursively if a container has any unpublished changes.
893
+
894
+ Note: unlike this method, the similar-sounding
895
+ `container.versioning.has_unpublished_changes` property only reports
896
+ if the container itself has unpublished changes, not
897
+ if its contents do. So if you change a title or add a new child component,
898
+ `has_unpublished_changes` will be `True`, but if you merely edit a component
899
+ that's in the container, it will be `False`. This method will return `True`
900
+ in either case.
901
+ """
902
+ # This is similar to 'get_container(container.container_id)' but pre-loads more data.
903
+ container = Container.objects.select_related(
904
+ "publishable_entity__draft__version__containerversion__entity_list",
905
+ ).get(pk=container_id)
906
+
907
+ if container.versioning.has_unpublished_changes:
908
+ return True
909
+
910
+ # We only care about children that are un-pinned, since published changes to pinned children don't matter
911
+ entity_list = container.versioning.draft.entity_list
912
+
913
+ # This is a naive and inefficient implementation but should be correct.
914
+ # TODO: Once we have expanded the containers system to support multiple levels (not just Units and Components but
915
+ # also subsections and sections) and we have an expanded test suite for correctness, then we can optimize.
916
+ # We will likely change to a tracking-based approach rather than a "scan for changes" based approach.
917
+ for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related(
918
+ "entity__container",
919
+ "entity__draft__version",
920
+ "entity__published__version",
921
+ ):
922
+ try:
923
+ child_container = row.entity.container
924
+ except Container.DoesNotExist:
925
+ child_container = None
926
+ if child_container:
927
+ # This is itself a container - check recursively:
928
+ if contains_unpublished_changes(child_container.pk):
929
+ return True
930
+ else:
931
+ # This is not a container:
932
+ draft_pk = row.entity.draft.version_id if row.entity.draft else None
933
+ published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None
934
+ if draft_pk != published_pk:
935
+ return True
936
+ return False
937
+
938
+
939
+ def get_containers_with_entity(
940
+ publishable_entity_pk: int,
941
+ *,
942
+ ignore_pinned=False,
943
+ ) -> QuerySet[Container]:
944
+ """
945
+ [ 🛑 UNSTABLE ]
946
+ Find all draft containers that directly contain the given entity.
947
+
948
+ They will always be from the same learning package; cross-package containers
949
+ are not allowed.
950
+
951
+ Args:
952
+ publishable_entity_pk: The ID of the PublishableEntity to search for.
953
+ ignore_pinned: if true, ignore any pinned references to the entity.
954
+ """
955
+ if ignore_pinned:
956
+ qs = Container.objects.filter(
957
+ # Note: these two conditions must be in the same filter() call, or the query won't be correct.
958
+ publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
959
+ publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501
960
+ ).order_by("pk") # Ordering is mostly for consistent test cases.
961
+ else:
962
+ qs = Container.objects.filter(
963
+ publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
964
+ ).order_by("pk") # Ordering is mostly for consistent test cases.
965
+ # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it.
966
+ # # Find all the EntityLists that contain the given entity:
967
+ # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True)
968
+ # qs = Container.objects.filter(
969
+ # publishable_entity__draft__version__containerversion__entity_list__in=lists
970
+ # )
971
+ return qs
@@ -14,3 +14,12 @@ class PublishingConfig(AppConfig):
14
14
  verbose_name = "Learning Core > Authoring > Publishing"
15
15
  default_auto_field = "django.db.models.BigAutoField"
16
16
  label = "oel_publishing"
17
+
18
+ def ready(self):
19
+ """
20
+ Register Container and ContainerVersion.
21
+ """
22
+ from .api import register_content_models # pylint: disable=import-outside-toplevel
23
+ from .models import Container, ContainerVersion # pylint: disable=import-outside-toplevel
24
+
25
+ register_content_models(Container, ContainerVersion)