openedx-learning 0.18.3__py2.py3-none-any.whl → 0.19.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.
Files changed (38) 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 +18 -5
  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/admin.py +3 -0
  9. openedx_learning/apps/authoring/publishing/api.py +508 -3
  10. openedx_learning/apps/authoring/publishing/apps.py +9 -0
  11. openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +54 -0
  12. openedx_learning/apps/authoring/publishing/migrations/0004_publishableentity_can_stand_alone.py +21 -0
  13. openedx_learning/apps/authoring/publishing/models/__init__.py +27 -0
  14. openedx_learning/apps/authoring/publishing/models/container.py +70 -0
  15. openedx_learning/apps/authoring/publishing/models/draft_published.py +95 -0
  16. openedx_learning/apps/authoring/publishing/models/entity_list.py +69 -0
  17. openedx_learning/apps/authoring/publishing/models/learning_package.py +75 -0
  18. openedx_learning/apps/authoring/publishing/models/publish_log.py +106 -0
  19. openedx_learning/apps/authoring/publishing/{model_mixins.py → models/publishable_entity.py} +289 -41
  20. openedx_learning/apps/authoring/units/__init__.py +0 -0
  21. openedx_learning/apps/authoring/units/api.py +305 -0
  22. openedx_learning/apps/authoring/units/apps.py +25 -0
  23. openedx_learning/apps/authoring/units/migrations/0001_initial.py +36 -0
  24. openedx_learning/apps/authoring/units/migrations/__init__.py +0 -0
  25. openedx_learning/apps/authoring/units/models.py +50 -0
  26. openedx_learning/contrib/media_server/apps.py +1 -1
  27. openedx_learning/lib/managers.py +7 -1
  28. openedx_learning/lib/validators.py +1 -1
  29. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/METADATA +3 -3
  30. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/RECORD +37 -24
  31. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/WHEEL +1 -1
  32. openedx_tagging/core/tagging/api.py +4 -4
  33. openedx_tagging/core/tagging/models/base.py +1 -1
  34. openedx_tagging/core/tagging/rest_api/v1/permissions.py +1 -1
  35. openedx_tagging/core/tagging/rules.py +1 -2
  36. openedx_learning/apps/authoring/publishing/models.py +0 -517
  37. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/LICENSE.txt +0 -0
  38. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/top_level.txt +0 -0
@@ -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,18 @@ __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
+ "get_container_by_key",
75
+ "get_containers",
76
+ "ContainerEntityListEntry",
77
+ "get_entities_in_container",
78
+ "contains_unpublished_changes",
79
+ "get_containers_with_entity",
54
80
  ]
55
81
 
56
82
 
@@ -149,6 +175,8 @@ def create_publishable_entity(
149
175
  created: datetime,
150
176
  # User ID who created this
151
177
  created_by: int | None,
178
+ *,
179
+ can_stand_alone: bool = True,
152
180
  ) -> PublishableEntity:
153
181
  """
154
182
  Create a PublishableEntity.
@@ -161,6 +189,7 @@ def create_publishable_entity(
161
189
  key=key,
162
190
  created=created,
163
191
  created_by_id=created_by,
192
+ can_stand_alone=can_stand_alone,
164
193
  )
165
194
 
166
195
 
@@ -310,6 +339,30 @@ def publish_from_drafts(
310
339
  published_at = datetime.now(tz=timezone.utc)
311
340
 
312
341
  with atomic():
342
+ # If the drafts include any containers, we need to auto-publish their descendants:
343
+ # TODO: this only handles one level deep and would need to be updated to support sections > subsections > units
344
+
345
+ # Get the IDs of the ContainerVersion for any Containers whose drafts are slated to be published.
346
+ container_version_ids = (
347
+ Container.objects.filter(publishable_entity__draft__in=draft_qset)
348
+ .values_list("publishable_entity__draft__version__containerversion__pk", flat=True)
349
+ )
350
+ if container_version_ids:
351
+ # We are publishing at least one container. Check if it has any child components that aren't already slated
352
+ # to be published.
353
+ unpublished_draft_children = EntityListRow.objects.filter(
354
+ entity_list__container_versions__pk__in=container_version_ids,
355
+ entity_version=None, # Unpinned entities only
356
+ ).exclude(
357
+ entity__draft__version=F("entity__published__version") # Exclude already published things
358
+ ).values_list("entity__draft__pk", flat=True)
359
+ if unpublished_draft_children:
360
+ # Force these additional child components to be published at the same time by adding them to the qset:
361
+ draft_qset = Draft.objects.filter(
362
+ Q(pk__in=draft_qset.values_list("pk", flat=True)) |
363
+ Q(pk__in=unpublished_draft_children)
364
+ )
365
+
313
366
  # One PublishLog for this entire publish operation.
314
367
  publish_log = PublishLog(
315
368
  learning_package_id=learning_package_id,
@@ -477,7 +530,7 @@ def register_content_models(
477
530
  This is so that we can provide convenience links between content models and
478
531
  content version models *through* the publishing apps, so that you can do
479
532
  things like finding the draft version of a content model more easily. See
480
- the model_mixins.py module for more details.
533
+ the publishable_entity.py module for more details.
481
534
 
482
535
  This should only be imported and run from the your app's AppConfig.ready()
483
536
  method. For example, in the components app, this looks like:
@@ -513,3 +566,455 @@ def filter_publishable_entities(
513
566
  entities = entities.filter(published__version__isnull=not has_published)
514
567
 
515
568
  return entities
569
+
570
+
571
+ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> PublishableEntityVersion | None:
572
+ """
573
+ Get the published version of the given entity, at a specific snapshot in the
574
+ history of this Learning Package, given by the PublishLog ID.
575
+
576
+ This is a semi-private function, only available to other apps in the
577
+ authoring package.
578
+ """
579
+ record = PublishLogRecord.objects.filter(
580
+ entity_id=entity_id,
581
+ publish_log_id__lte=publish_log_id,
582
+ ).order_by('-publish_log_id').first()
583
+ return record.new_version if record else None
584
+
585
+
586
+ def create_container(
587
+ learning_package_id: int,
588
+ key: str,
589
+ created: datetime,
590
+ created_by: int | None,
591
+ *,
592
+ can_stand_alone: bool = True,
593
+ # The types on the following line are correct, but mypy will complain - https://github.com/python/mypy/issues/3737
594
+ container_cls: type[ContainerModel] = Container, # type: ignore[assignment]
595
+ ) -> ContainerModel:
596
+ """
597
+ [ 🛑 UNSTABLE ]
598
+ Create a new container.
599
+
600
+ Args:
601
+ learning_package_id: The ID of the learning package that contains the container.
602
+ key: The key of the container.
603
+ created: The date and time the container was created.
604
+ created_by: The ID of the user who created the container
605
+ can_stand_alone: Set to False when created as part of containers
606
+ container_cls: The subclass of Container to use, if applicable
607
+
608
+ Returns:
609
+ The newly created container.
610
+ """
611
+ assert issubclass(container_cls, Container)
612
+ with atomic():
613
+ publishable_entity = create_publishable_entity(
614
+ learning_package_id,
615
+ key,
616
+ created,
617
+ created_by,
618
+ can_stand_alone=can_stand_alone,
619
+ )
620
+ container = container_cls.objects.create(
621
+ publishable_entity=publishable_entity,
622
+ )
623
+ return container
624
+
625
+
626
+ def create_entity_list() -> EntityList:
627
+ """
628
+ [ 🛑 UNSTABLE ]
629
+ Create a new entity list. This is an structure that holds a list of entities
630
+ that will be referenced by the container.
631
+
632
+ Returns:
633
+ The newly created entity list.
634
+ """
635
+ return EntityList.objects.create()
636
+
637
+
638
+ def create_entity_list_with_rows(
639
+ entity_pks: list[int],
640
+ entity_version_pks: list[int | None],
641
+ *,
642
+ learning_package_id: int | None,
643
+ ) -> EntityList:
644
+ """
645
+ [ 🛑 UNSTABLE ]
646
+ Create new entity list rows for an entity list.
647
+
648
+ Args:
649
+ entity_pks: The IDs of the publishable entities that the entity list rows reference.
650
+ entity_version_pks: The IDs of the versions of the entities
651
+ (PublishableEntityVersion) that the entity list rows reference, or
652
+ Nones for "unpinned" (default).
653
+ learning_package_id: Optional. Verify that all the entities are from
654
+ the specified learning package.
655
+
656
+ Returns:
657
+ The newly created entity list.
658
+ """
659
+ # Do a quick check that the given entities are in the right learning package:
660
+ if learning_package_id:
661
+ if PublishableEntity.objects.filter(
662
+ pk__in=entity_pks,
663
+ ).exclude(
664
+ learning_package_id=learning_package_id,
665
+ ).exists():
666
+ raise ValidationError("Container entities must be from the same learning package.")
667
+
668
+ order_nums = range(len(entity_pks))
669
+ with atomic(savepoint=False):
670
+
671
+ entity_list = create_entity_list()
672
+ EntityListRow.objects.bulk_create(
673
+ [
674
+ EntityListRow(
675
+ entity_list=entity_list,
676
+ entity_id=entity_pk,
677
+ order_num=order_num,
678
+ entity_version_id=entity_version_pk,
679
+ )
680
+ for order_num, entity_pk, entity_version_pk in zip(
681
+ order_nums, entity_pks, entity_version_pks
682
+ )
683
+ ]
684
+ )
685
+ return entity_list
686
+
687
+
688
+ def _create_container_version(
689
+ container: Container,
690
+ version_num: int,
691
+ *,
692
+ title: str,
693
+ entity_list: EntityList,
694
+ created: datetime,
695
+ created_by: int | None,
696
+ container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
697
+ ) -> ContainerVersionModel:
698
+ """
699
+ Private internal method for logic shared by create_container_version() and
700
+ create_next_container_version().
701
+ """
702
+ assert issubclass(container_version_cls, ContainerVersion)
703
+ with atomic(savepoint=False): # Make sure this will happen atomically but we don't need to create a new savepoint.
704
+ publishable_entity_version = create_publishable_entity_version(
705
+ container.publishable_entity_id,
706
+ version_num=version_num,
707
+ title=title,
708
+ created=created,
709
+ created_by=created_by,
710
+ )
711
+ container_version = container_version_cls.objects.create(
712
+ publishable_entity_version=publishable_entity_version,
713
+ container_id=container.pk,
714
+ entity_list=entity_list,
715
+ )
716
+
717
+ return container_version
718
+
719
+
720
+ def create_container_version(
721
+ container_id: int,
722
+ version_num: int,
723
+ *,
724
+ title: str,
725
+ publishable_entities_pks: list[int],
726
+ entity_version_pks: list[int | None] | None,
727
+ created: datetime,
728
+ created_by: int | None,
729
+ container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
730
+ ) -> ContainerVersionModel:
731
+ """
732
+ [ 🛑 UNSTABLE ]
733
+ Create a new container version.
734
+
735
+ Args:
736
+ container_id: The ID of the container that the version belongs to.
737
+ version_num: The version number of the container.
738
+ title: The title of the container.
739
+ publishable_entities_pks: The IDs of the members of the container.
740
+ entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
741
+ created: The date and time the container version was created.
742
+ created_by: The ID of the user who created the container version.
743
+ container_version_cls: The subclass of ContainerVersion to use, if applicable.
744
+
745
+ Returns:
746
+ The newly created container version.
747
+ """
748
+ assert title is not None
749
+ assert publishable_entities_pks is not None
750
+
751
+ with atomic(savepoint=False):
752
+ container = Container.objects.select_related("publishable_entity").get(pk=container_id)
753
+ entity = container.publishable_entity
754
+ if entity_version_pks is None:
755
+ entity_version_pks = [None] * len(publishable_entities_pks)
756
+ entity_list = create_entity_list_with_rows(
757
+ entity_pks=publishable_entities_pks,
758
+ entity_version_pks=entity_version_pks,
759
+ learning_package_id=entity.learning_package_id,
760
+ )
761
+ container_version = _create_container_version(
762
+ container,
763
+ version_num,
764
+ title=title,
765
+ entity_list=entity_list,
766
+ created=created,
767
+ created_by=created_by,
768
+ container_version_cls=container_version_cls,
769
+ )
770
+
771
+ return container_version
772
+
773
+
774
+ def create_next_container_version(
775
+ container_pk: int,
776
+ *,
777
+ title: str | None,
778
+ publishable_entities_pks: list[int] | None,
779
+ entity_version_pks: list[int | None] | None,
780
+ created: datetime,
781
+ created_by: int | None,
782
+ container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
783
+ ) -> ContainerVersionModel:
784
+ """
785
+ [ 🛑 UNSTABLE ]
786
+ Create the next version of a container. A new version of the container is created
787
+ only when its metadata changes:
788
+
789
+ * Something was added to the Container.
790
+ * We re-ordered the rows in the container.
791
+ * Something was removed from the container.
792
+ * The Container's metadata changed, e.g. the title.
793
+ * We pin to different versions of the Container.
794
+
795
+ Args:
796
+ container_pk: The ID of the container to create the next version of.
797
+ title: The title of the container. None to keep the current title.
798
+ publishable_entities_pks: The IDs of the members current members of the container. Or None for no change.
799
+ entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
800
+ created: The date and time the container version was created.
801
+ created_by: The ID of the user who created the container version.
802
+ container_version_cls: The subclass of ContainerVersion to use, if applicable.
803
+
804
+ Returns:
805
+ The newly created container version.
806
+ """
807
+ assert issubclass(container_version_cls, ContainerVersion)
808
+ with atomic():
809
+ container = Container.objects.select_related("publishable_entity").get(pk=container_pk)
810
+ entity = container.publishable_entity
811
+ last_version = container.versioning.latest
812
+ assert last_version is not None
813
+ next_version_num = last_version.version_num + 1
814
+ if publishable_entities_pks is None:
815
+ # We're only changing metadata. Keep the same entity list.
816
+ next_entity_list = last_version.entity_list
817
+ else:
818
+ if entity_version_pks is None:
819
+ entity_version_pks = [None] * len(publishable_entities_pks)
820
+ next_entity_list = create_entity_list_with_rows(
821
+ entity_pks=publishable_entities_pks,
822
+ entity_version_pks=entity_version_pks,
823
+ learning_package_id=entity.learning_package_id,
824
+ )
825
+ next_container_version = _create_container_version(
826
+ container,
827
+ next_version_num,
828
+ title=title if title is not None else last_version.title,
829
+ entity_list=next_entity_list,
830
+ created=created,
831
+ created_by=created_by,
832
+ container_version_cls=container_version_cls,
833
+ )
834
+
835
+ return next_container_version
836
+
837
+
838
+ def get_container(pk: int) -> Container:
839
+ """
840
+ [ 🛑 UNSTABLE ]
841
+ Get a container by its primary key.
842
+
843
+ Args:
844
+ pk: The primary key of the container.
845
+
846
+ Returns:
847
+ The container with the given primary key.
848
+ """
849
+ return Container.objects.get(pk=pk)
850
+
851
+
852
+ def get_container_by_key(learning_package_id: int, /, key: str) -> Container:
853
+ """
854
+ [ 🛑 UNSTABLE ]
855
+ Get a container by its learning package and primary key.
856
+
857
+ Args:
858
+ learning_package_id: The ID of the learning package that contains the container.
859
+ key: The primary key of the container.
860
+
861
+ Returns:
862
+ The container with the given primary key.
863
+ """
864
+ return Container.objects.get(
865
+ publishable_entity__learning_package_id=learning_package_id,
866
+ publishable_entity__key=key,
867
+ )
868
+
869
+
870
+ def get_containers(
871
+ learning_package_id: int,
872
+ container_cls: type[ContainerModel] = Container, # type: ignore[assignment]
873
+ ) -> QuerySet[ContainerModel]:
874
+ """
875
+ [ 🛑 UNSTABLE ]
876
+ Get all containers in the given learning package.
877
+
878
+ Args:
879
+ learning_package_id: The primary key of the learning package
880
+ container_cls: The subclass of Container to use, if applicable
881
+
882
+ Returns:
883
+ A queryset containing the container associated with the given learning package.
884
+ """
885
+ assert issubclass(container_cls, Container)
886
+ return container_cls.objects.filter(publishable_entity__learning_package=learning_package_id)
887
+
888
+
889
+ @dataclass(frozen=True)
890
+ class ContainerEntityListEntry:
891
+ """
892
+ [ 🛑 UNSTABLE ]
893
+ Data about a single entity in a container, e.g. a component in a unit.
894
+ """
895
+ entity_version: PublishableEntityVersion
896
+ pinned: bool
897
+
898
+ @property
899
+ def entity(self):
900
+ return self.entity_version.entity
901
+
902
+
903
+ def get_entities_in_container(
904
+ container: Container,
905
+ *,
906
+ published: bool,
907
+ ) -> list[ContainerEntityListEntry]:
908
+ """
909
+ [ 🛑 UNSTABLE ]
910
+ Get the list of entities and their versions in the current draft or
911
+ published version of the given container.
912
+
913
+ Args:
914
+ container: The Container, e.g. returned by `get_container()`
915
+ published: `True` if we want the published version of the container, or
916
+ `False` for the draft version.
917
+ """
918
+ assert isinstance(container, Container)
919
+ container_version = container.versioning.published if published else container.versioning.draft
920
+ if container_version is None:
921
+ raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted.
922
+ assert isinstance(container_version, ContainerVersion)
923
+ entity_list = []
924
+ for row in container_version.entity_list.entitylistrow_set.order_by("order_num"):
925
+ entity_version = row.entity_version # This will be set if pinned
926
+ if not entity_version: # If this entity is "unpinned", use the latest published/draft version:
927
+ entity_version = row.entity.published.version if published else row.entity.draft.version
928
+ if entity_version is not None: # As long as this hasn't been soft-deleted:
929
+ entity_list.append(ContainerEntityListEntry(
930
+ entity_version=entity_version,
931
+ pinned=row.entity_version is not None,
932
+ ))
933
+ # else we could indicate somehow a deleted item was here, e.g. by returning a ContainerEntityListEntry with
934
+ # deleted=True, but we don't have a use case for that yet.
935
+ return entity_list
936
+
937
+
938
+ def contains_unpublished_changes(container_id: int) -> bool:
939
+ """
940
+ [ 🛑 UNSTABLE ]
941
+ Check recursively if a container has any unpublished changes.
942
+
943
+ Note: unlike this method, the similar-sounding
944
+ `container.versioning.has_unpublished_changes` property only reports
945
+ if the container itself has unpublished changes, not
946
+ if its contents do. So if you change a title or add a new child component,
947
+ `has_unpublished_changes` will be `True`, but if you merely edit a component
948
+ that's in the container, it will be `False`. This method will return `True`
949
+ in either case.
950
+ """
951
+ # This is similar to 'get_container(container.container_id)' but pre-loads more data.
952
+ container = Container.objects.select_related(
953
+ "publishable_entity__draft__version__containerversion__entity_list",
954
+ ).get(pk=container_id)
955
+
956
+ if container.versioning.has_unpublished_changes:
957
+ return True
958
+
959
+ # We only care about children that are un-pinned, since published changes to pinned children don't matter
960
+ entity_list = container.versioning.draft.entity_list
961
+
962
+ # This is a naive and inefficient implementation but should be correct.
963
+ # TODO: Once we have expanded the containers system to support multiple levels (not just Units and Components but
964
+ # also subsections and sections) and we have an expanded test suite for correctness, then we can optimize.
965
+ # We will likely change to a tracking-based approach rather than a "scan for changes" based approach.
966
+ for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related(
967
+ "entity__container",
968
+ "entity__draft__version",
969
+ "entity__published__version",
970
+ ):
971
+ try:
972
+ child_container = row.entity.container
973
+ except Container.DoesNotExist:
974
+ child_container = None
975
+ if child_container:
976
+ # This is itself a container - check recursively:
977
+ if contains_unpublished_changes(child_container.pk):
978
+ return True
979
+ else:
980
+ # This is not a container:
981
+ draft_pk = row.entity.draft.version_id if row.entity.draft else None
982
+ published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None
983
+ if draft_pk != published_pk:
984
+ return True
985
+ return False
986
+
987
+
988
+ def get_containers_with_entity(
989
+ publishable_entity_pk: int,
990
+ *,
991
+ ignore_pinned=False,
992
+ ) -> QuerySet[Container]:
993
+ """
994
+ [ 🛑 UNSTABLE ]
995
+ Find all draft containers that directly contain the given entity.
996
+
997
+ They will always be from the same learning package; cross-package containers
998
+ are not allowed.
999
+
1000
+ Args:
1001
+ publishable_entity_pk: The ID of the PublishableEntity to search for.
1002
+ ignore_pinned: if true, ignore any pinned references to the entity.
1003
+ """
1004
+ if ignore_pinned:
1005
+ qs = Container.objects.filter(
1006
+ # Note: these two conditions must be in the same filter() call, or the query won't be correct.
1007
+ publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
1008
+ publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501
1009
+ ).order_by("pk") # Ordering is mostly for consistent test cases.
1010
+ else:
1011
+ qs = Container.objects.filter(
1012
+ publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
1013
+ ).order_by("pk") # Ordering is mostly for consistent test cases.
1014
+ # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it.
1015
+ # # Find all the EntityLists that contain the given entity:
1016
+ # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True)
1017
+ # qs = Container.objects.filter(
1018
+ # publishable_entity__draft__version__containerversion__entity_list__in=lists
1019
+ # )
1020
+ 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)
@@ -0,0 +1,54 @@
1
+ # Generated by Django 4.2.19 on 2025-03-11 04:10
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('oel_publishing', '0002_alter_learningpackage_key_and_more'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='Container',
16
+ fields=[
17
+ ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')),
18
+ ],
19
+ options={
20
+ 'abstract': False,
21
+ },
22
+ ),
23
+ migrations.CreateModel(
24
+ name='EntityList',
25
+ fields=[
26
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
27
+ ],
28
+ ),
29
+ migrations.CreateModel(
30
+ name='EntityListRow',
31
+ fields=[
32
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
33
+ ('order_num', models.PositiveIntegerField()),
34
+ ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')),
35
+ ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.entitylist')),
36
+ ('entity_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.publishableentityversion')),
37
+ ],
38
+ ),
39
+ migrations.CreateModel(
40
+ name='ContainerVersion',
41
+ fields=[
42
+ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')),
43
+ ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_publishing.container')),
44
+ ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='container_versions', to='oel_publishing.entitylist')),
45
+ ],
46
+ options={
47
+ 'abstract': False,
48
+ },
49
+ ),
50
+ migrations.AddConstraint(
51
+ model_name='entitylistrow',
52
+ constraint=models.UniqueConstraint(fields=('entity_list', 'order_num'), name='oel_publishing_elist_row_order'),
53
+ ),
54
+ ]
@@ -0,0 +1,21 @@
1
+ # Generated by Django 4.2.19 on 2025-03-17 11:07
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('oel_publishing', '0003_containers'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='publishableentity',
15
+ name='can_stand_alone',
16
+ field=models.BooleanField(
17
+ default=True,
18
+ help_text="Set to True when created independently, False when created as part of a container.",
19
+ ),
20
+ ),
21
+ ]
@@ -0,0 +1,27 @@
1
+ """
2
+ The data models here are intended to be used by other apps to publish different
3
+ types of content, such as Components, Units, Sections, etc. These models should
4
+ support the logic for the management of the publishing process:
5
+
6
+ * The relationship between publishable entities and their many versions.
7
+ * Hierarchical relationships between "container" entities and their children
8
+ * The management of drafts.
9
+ * Publishing specific versions of publishable entities.
10
+ * Finding the currently published versions.
11
+ * The act of publishing, and doing so atomically.
12
+ * Managing reverts.
13
+ * Storing and querying publish history.
14
+ """
15
+
16
+ from .container import Container, ContainerVersion
17
+ from .draft_published import Draft, Published
18
+ from .entity_list import EntityList, EntityListRow
19
+ from .learning_package import LearningPackage
20
+ from .publish_log import PublishLog, PublishLogRecord
21
+ from .publishable_entity import (
22
+ PublishableContentModelRegistry,
23
+ PublishableEntity,
24
+ PublishableEntityMixin,
25
+ PublishableEntityVersion,
26
+ PublishableEntityVersionMixin,
27
+ )