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.
- 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 +3 -3
- 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/api.py +459 -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/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} +284 -41
- openedx_learning/apps/authoring/units/__init__.py +0 -0
- openedx_learning/apps/authoring/units/api.py +290 -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.0.dist-info}/METADATA +3 -3
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/RECORD +35 -23
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.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.0.dist-info}/LICENSE.txt +0 -0
- {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/top_level.txt +0 -0
openedx_learning/__init__.py
CHANGED
|
@@ -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,
|
|
@@ -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.
|
|
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):
|
|
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
|
-
#
|
|
124
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
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)
|