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.
- openedx_learning/__init__.py +1 -1
- openedx_learning/api/authoring.py +1 -0
- openedx_learning/api/authoring_models.py +1 -1
- openedx_learning/apps/authoring/components/api.py +18 -5
- openedx_learning/apps/authoring/components/apps.py +1 -1
- openedx_learning/apps/authoring/components/models.py +9 -14
- openedx_learning/apps/authoring/contents/models.py +1 -1
- openedx_learning/apps/authoring/publishing/admin.py +3 -0
- openedx_learning/apps/authoring/publishing/api.py +508 -3
- openedx_learning/apps/authoring/publishing/apps.py +9 -0
- openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +54 -0
- openedx_learning/apps/authoring/publishing/migrations/0004_publishableentity_can_stand_alone.py +21 -0
- openedx_learning/apps/authoring/publishing/models/__init__.py +27 -0
- openedx_learning/apps/authoring/publishing/models/container.py +70 -0
- openedx_learning/apps/authoring/publishing/models/draft_published.py +95 -0
- openedx_learning/apps/authoring/publishing/models/entity_list.py +69 -0
- openedx_learning/apps/authoring/publishing/models/learning_package.py +75 -0
- openedx_learning/apps/authoring/publishing/models/publish_log.py +106 -0
- openedx_learning/apps/authoring/publishing/{model_mixins.py → models/publishable_entity.py} +289 -41
- openedx_learning/apps/authoring/units/__init__.py +0 -0
- openedx_learning/apps/authoring/units/api.py +305 -0
- openedx_learning/apps/authoring/units/apps.py +25 -0
- openedx_learning/apps/authoring/units/migrations/0001_initial.py +36 -0
- openedx_learning/apps/authoring/units/migrations/__init__.py +0 -0
- openedx_learning/apps/authoring/units/models.py +50 -0
- openedx_learning/contrib/media_server/apps.py +1 -1
- openedx_learning/lib/managers.py +7 -1
- openedx_learning/lib/validators.py +1 -1
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/METADATA +3 -3
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/RECORD +37 -24
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/WHEEL +1 -1
- openedx_tagging/core/tagging/api.py +4 -4
- openedx_tagging/core/tagging/models/base.py +1 -1
- openedx_tagging/core/tagging/rest_api/v1/permissions.py +1 -1
- openedx_tagging/core/tagging/rules.py +1 -2
- openedx_learning/apps/authoring/publishing/models.py +0 -517
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/LICENSE.txt +0 -0
- {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
|
|
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
|
+
]
|
openedx_learning/apps/authoring/publishing/migrations/0004_publishableentity_can_stand_alone.py
ADDED
|
@@ -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
|
+
)
|