openedx-learning 0.18.2__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.2.dist-info → openedx_learning-0.19.0.dist-info}/METADATA +2 -2
- {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/RECORD +35 -23
- {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/WHEEL +1 -1
- openedx_tagging/core/tagging/api.py +11 -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.2.dist-info → openedx_learning-0.19.0.dist-info}/LICENSE.txt +0 -0
- {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/top_level.txt +0 -0
|
@@ -1,21 +1,260 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
PublishableEntity model and PublishableEntityVersion + mixins
|
|
3
3
|
"""
|
|
4
|
-
from
|
|
5
|
-
|
|
4
|
+
from datetime import datetime
|
|
6
5
|
from functools import cached_property
|
|
6
|
+
from typing import ClassVar, Self
|
|
7
7
|
|
|
8
|
+
from django.conf import settings
|
|
8
9
|
from django.core.exceptions import ImproperlyConfigured
|
|
10
|
+
from django.core.validators import MinValueValidator
|
|
9
11
|
from django.db import models
|
|
10
|
-
from django.db.models.query import QuerySet
|
|
11
12
|
|
|
12
|
-
from .
|
|
13
|
+
from openedx_learning.lib.fields import (
|
|
14
|
+
case_insensitive_char_field,
|
|
15
|
+
immutable_uuid_field,
|
|
16
|
+
key_field,
|
|
17
|
+
manual_date_time_field,
|
|
18
|
+
)
|
|
19
|
+
from openedx_learning.lib.managers import WithRelationsManager
|
|
20
|
+
|
|
21
|
+
from .learning_package import LearningPackage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PublishableEntity(models.Model):
|
|
25
|
+
"""
|
|
26
|
+
This represents any publishable thing that has ever existed in a
|
|
27
|
+
LearningPackage. It serves as a stable model that will not go away even if
|
|
28
|
+
these things are later unpublished or deleted.
|
|
29
|
+
|
|
30
|
+
A PublishableEntity belongs to exactly one LearningPackage.
|
|
31
|
+
|
|
32
|
+
Examples of Publishable Entities
|
|
33
|
+
--------------------------------
|
|
34
|
+
|
|
35
|
+
Components (e.g. VideoBlock, ProblemBlock), Units, and Sections/Subsections
|
|
36
|
+
would all be considered Publishable Entites. But anything that can be
|
|
37
|
+
imported, exported, published, and reverted in a course or library could be
|
|
38
|
+
modeled as a PublishableEntity, including things like Grading Policy or
|
|
39
|
+
possibly Taxonomies (?).
|
|
40
|
+
|
|
41
|
+
How to use this model
|
|
42
|
+
---------------------
|
|
43
|
+
|
|
44
|
+
The publishing app understands that publishable entities exist, along with
|
|
45
|
+
their drafts and published versions. It has some basic metadata, such as
|
|
46
|
+
identifiers, who created it, and when it was created. It's meant to
|
|
47
|
+
encapsulate the draft and publishing related aspects of your content, but
|
|
48
|
+
the ``publishing`` app doesn't know anything about the actual content being
|
|
49
|
+
referenced.
|
|
50
|
+
|
|
51
|
+
You have to provide actual meaning to PublishableEntity by creating your own
|
|
52
|
+
models that will represent your particular content and associating them to
|
|
53
|
+
PublishableEntity via a OneToOneField with primary_key=True. The easiest way
|
|
54
|
+
to do this is to have your model inherit from PublishableEntityMixin.
|
|
55
|
+
|
|
56
|
+
Identifiers
|
|
57
|
+
-----------
|
|
58
|
+
The UUID is globally unique and should be treated as immutable.
|
|
59
|
+
|
|
60
|
+
The key field *is* mutable, but changing it will affect all
|
|
61
|
+
PublishedEntityVersions. They are locally unique within the LearningPackage.
|
|
62
|
+
|
|
63
|
+
If you are referencing this model from within the same process, use a
|
|
64
|
+
foreign key to the id. If you are referencing this PublishedEntity from an
|
|
65
|
+
external system/service, use the UUID. The key is the part that is most
|
|
66
|
+
likely to be human-readable, and may be exported/copied, but try not to rely
|
|
67
|
+
on it, since this value may change.
|
|
68
|
+
|
|
69
|
+
Note: When we actually implement the ability to change identifiers, we
|
|
70
|
+
should make a history table and a modified attribute on this model.
|
|
71
|
+
|
|
72
|
+
Why are Identifiers in this Model?
|
|
73
|
+
----------------------------------
|
|
74
|
+
|
|
75
|
+
A PublishableEntity never stands alone–it's always intended to be used with
|
|
76
|
+
a 1:1 model like Component or Unit. So why have all the identifiers in this
|
|
77
|
+
model instead of storing them in those other models? Two reasons:
|
|
78
|
+
|
|
79
|
+
* Published things need to have the right identifiers so they can be used
|
|
80
|
+
throughout the system, and the UUID is serving the role of ISBN in physical
|
|
81
|
+
book publishing.
|
|
82
|
+
* We want to be able to enforce the idea that "key" is locally unique across
|
|
83
|
+
all PublishableEntities within a given LearningPackage. Component and Unit
|
|
84
|
+
can't do that without a shared model.
|
|
85
|
+
|
|
86
|
+
That being said, models that build on PublishableEntity are free to add
|
|
87
|
+
their own identifiers if it's useful to do so.
|
|
88
|
+
|
|
89
|
+
Why not Inherit from this Model?
|
|
90
|
+
--------------------------------
|
|
91
|
+
|
|
92
|
+
Django supports multi-table inheritance:
|
|
93
|
+
|
|
94
|
+
https://docs.djangoproject.com/en/4.2/topics/db/models/#multi-table-inheritance
|
|
95
|
+
|
|
96
|
+
We don't use that, primarily because we want to more clearly decouple
|
|
97
|
+
publishing concerns from the rest of the logic around Components, Units,
|
|
98
|
+
etc. If you made a Component and ComponentVersion models that subclassed
|
|
99
|
+
PublishableEntity and PublishableEntityVersion, and then accessed
|
|
100
|
+
``component.versions``, you might expect ComponentVersions to come back and
|
|
101
|
+
be surprised when you get EntityVersions instead.
|
|
102
|
+
|
|
103
|
+
In general, we want freedom to add new Publishing models, fields, and
|
|
104
|
+
methods without having to worry about the downstream name collisions with
|
|
105
|
+
other apps (many of which might live in other repositories). The helper
|
|
106
|
+
mixins will provide a little syntactic sugar to make common access patterns
|
|
107
|
+
more convenient, like file access.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
uuid = immutable_uuid_field()
|
|
111
|
+
learning_package = models.ForeignKey(
|
|
112
|
+
LearningPackage,
|
|
113
|
+
on_delete=models.CASCADE,
|
|
114
|
+
related_name="publishable_entities",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# "key" is a reserved word for MySQL, so we're temporarily using the column
|
|
118
|
+
# name of "_key" to avoid breaking downstream tooling. Consider renaming
|
|
119
|
+
# this later.
|
|
120
|
+
key = key_field(db_column="_key")
|
|
121
|
+
|
|
122
|
+
created = manual_date_time_field()
|
|
123
|
+
created_by = models.ForeignKey(
|
|
124
|
+
settings.AUTH_USER_MODEL,
|
|
125
|
+
on_delete=models.SET_NULL,
|
|
126
|
+
null=True,
|
|
127
|
+
blank=True,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
class Meta:
|
|
131
|
+
constraints = [
|
|
132
|
+
# Keys are unique within a given LearningPackage.
|
|
133
|
+
models.UniqueConstraint(
|
|
134
|
+
fields=[
|
|
135
|
+
"learning_package",
|
|
136
|
+
"key",
|
|
137
|
+
],
|
|
138
|
+
name="oel_pub_ent_uniq_lp_key",
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
indexes = [
|
|
142
|
+
# Global Key Index:
|
|
143
|
+
# * Search by key across all PublishableEntities on the site. This
|
|
144
|
+
# would be a support-oriented tool from Django Admin.
|
|
145
|
+
models.Index(
|
|
146
|
+
fields=["key"],
|
|
147
|
+
name="oel_pub_ent_idx_key",
|
|
148
|
+
),
|
|
149
|
+
# LearningPackage (reverse) Created Index:
|
|
150
|
+
# * Search for most recently *created* PublishableEntities for a
|
|
151
|
+
# given LearningPackage, since they're the most likely to be
|
|
152
|
+
# actively worked on.
|
|
153
|
+
models.Index(
|
|
154
|
+
fields=["learning_package", "-created"],
|
|
155
|
+
name="oel_pub_ent_idx_lp_rcreated",
|
|
156
|
+
),
|
|
157
|
+
]
|
|
158
|
+
# These are for the Django Admin UI.
|
|
159
|
+
verbose_name = "Publishable Entity"
|
|
160
|
+
verbose_name_plural = "Publishable Entities"
|
|
161
|
+
|
|
162
|
+
def __str__(self):
|
|
163
|
+
return f"{self.key}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PublishableEntityVersion(models.Model):
|
|
167
|
+
"""
|
|
168
|
+
A particular version of a PublishableEntity.
|
|
169
|
+
|
|
170
|
+
This model has its own ``uuid`` so that it can be referenced directly. The
|
|
171
|
+
``uuid`` should be treated as immutable.
|
|
172
|
+
|
|
173
|
+
PublishableEntityVersions are created once and never updated. So for
|
|
174
|
+
instance, the ``title`` should never be modified.
|
|
175
|
+
|
|
176
|
+
Like PublishableEntity, the data in this model is only enough to cover the
|
|
177
|
+
parts that are most important for the actual process of managing drafts and
|
|
178
|
+
publishes. You will want to create your own models to represent the actual
|
|
179
|
+
content data that's associated with this PublishableEntityVersion, and
|
|
180
|
+
connect them using a OneToOneField with primary_key=True. The easiest way to
|
|
181
|
+
do this is to inherit from PublishableEntityVersionMixin. Be sure to treat
|
|
182
|
+
these versioned models in your app as immutable as well.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
uuid = immutable_uuid_field()
|
|
186
|
+
entity = models.ForeignKey(
|
|
187
|
+
PublishableEntity, on_delete=models.CASCADE, related_name="versions"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Most publishable things will have some sort of title, but blanks are
|
|
191
|
+
# allowed for those that don't require one.
|
|
192
|
+
title = case_insensitive_char_field(max_length=500, blank=True, default="")
|
|
193
|
+
|
|
194
|
+
# The version_num starts at 1 and increments by 1 with each new version for
|
|
195
|
+
# a given PublishableEntity. Doing it this way makes it more convenient for
|
|
196
|
+
# users to refer to than a hash or UUID value. It also helps us catch race
|
|
197
|
+
# conditions on save, by setting a unique constraint on the entity and
|
|
198
|
+
# version_num.
|
|
199
|
+
version_num = models.PositiveIntegerField(
|
|
200
|
+
null=False,
|
|
201
|
+
validators=[MinValueValidator(1)],
|
|
202
|
+
)
|
|
13
203
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
204
|
+
# All PublishableEntityVersions created as part of the same publish should
|
|
205
|
+
# have the exact same created datetime (not off by a handful of
|
|
206
|
+
# microseconds).
|
|
207
|
+
created = manual_date_time_field()
|
|
208
|
+
|
|
209
|
+
# User who created the PublishableEntityVersion. This can be null if the
|
|
210
|
+
# user is later removed. Open edX in general doesn't let you remove users,
|
|
211
|
+
# but we should try to model it so that this is possible eventually.
|
|
212
|
+
created_by = models.ForeignKey(
|
|
213
|
+
settings.AUTH_USER_MODEL,
|
|
214
|
+
on_delete=models.SET_NULL,
|
|
215
|
+
null=True,
|
|
216
|
+
blank=True,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
class Meta:
|
|
220
|
+
constraints = [
|
|
221
|
+
# Prevent the situation where we have multiple
|
|
222
|
+
# PublishableEntityVersions claiming to be the same version_num for
|
|
223
|
+
# a given PublishableEntity. This can happen if there's a race
|
|
224
|
+
# condition between concurrent editors in different browsers,
|
|
225
|
+
# working on the same Publishable. With this constraint, one of
|
|
226
|
+
# those processes will raise an IntegrityError.
|
|
227
|
+
models.UniqueConstraint(
|
|
228
|
+
fields=[
|
|
229
|
+
"entity",
|
|
230
|
+
"version_num",
|
|
231
|
+
],
|
|
232
|
+
name="oel_pv_uniq_entity_version_num",
|
|
233
|
+
)
|
|
234
|
+
]
|
|
235
|
+
indexes = [
|
|
236
|
+
# LearningPackage (reverse) Created Index:
|
|
237
|
+
# * Make it cheap to find the most recently created
|
|
238
|
+
# PublishableEntityVersions for a given LearningPackage. This
|
|
239
|
+
# represents the most recently saved work for a LearningPackage
|
|
240
|
+
# and would be the most likely areas to get worked on next.
|
|
241
|
+
models.Index(
|
|
242
|
+
fields=["entity", "-created"],
|
|
243
|
+
name="oel_pv_idx_entity_rcreated",
|
|
244
|
+
),
|
|
245
|
+
# Title Index:
|
|
246
|
+
# * Search by title.
|
|
247
|
+
models.Index(
|
|
248
|
+
fields=[
|
|
249
|
+
"title",
|
|
250
|
+
],
|
|
251
|
+
name="oel_pv_idx_title",
|
|
252
|
+
),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
# These are for the Django Admin UI.
|
|
256
|
+
verbose_name = "Publishable Entity Version"
|
|
257
|
+
verbose_name_plural = "Publishable Entity Versions"
|
|
19
258
|
|
|
20
259
|
|
|
21
260
|
class PublishableEntityMixin(models.Model):
|
|
@@ -28,17 +267,12 @@ class PublishableEntityMixin(models.Model):
|
|
|
28
267
|
the publishing app's api.register_content_models (see its docstring for
|
|
29
268
|
details).
|
|
30
269
|
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"publishable_entity__published",
|
|
38
|
-
"publishable_entity__draft",
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
objects: models.Manager[PublishableEntityMixin] = PublishableEntityMixinManager()
|
|
270
|
+
# select these related entities by default for all queries
|
|
271
|
+
objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager(
|
|
272
|
+
"publishable_entity",
|
|
273
|
+
"publishable_entity__published",
|
|
274
|
+
"publishable_entity__draft",
|
|
275
|
+
)
|
|
42
276
|
|
|
43
277
|
publishable_entity = models.OneToOneField(
|
|
44
278
|
PublishableEntity, on_delete=models.CASCADE, primary_key=True
|
|
@@ -49,15 +283,15 @@ class PublishableEntityMixin(models.Model):
|
|
|
49
283
|
return self.VersioningHelper(self)
|
|
50
284
|
|
|
51
285
|
@property
|
|
52
|
-
def uuid(self):
|
|
286
|
+
def uuid(self) -> str:
|
|
53
287
|
return self.publishable_entity.uuid
|
|
54
288
|
|
|
55
289
|
@property
|
|
56
|
-
def key(self):
|
|
290
|
+
def key(self) -> str:
|
|
57
291
|
return self.publishable_entity.key
|
|
58
292
|
|
|
59
293
|
@property
|
|
60
|
-
def created(self):
|
|
294
|
+
def created(self) -> datetime:
|
|
61
295
|
return self.publishable_entity.created
|
|
62
296
|
|
|
63
297
|
@property
|
|
@@ -137,11 +371,24 @@ class PublishableEntityMixin(models.Model):
|
|
|
137
371
|
field_to_pev = self.content_version_model_cls._meta.get_field(
|
|
138
372
|
"publishable_entity_version"
|
|
139
373
|
)
|
|
140
|
-
|
|
141
374
|
# Now that we know the field that leads to PublishableEntityVersion,
|
|
142
375
|
# get the reverse related field name so that we can use that later.
|
|
143
376
|
self.related_name = field_to_pev.related_query_name()
|
|
144
377
|
|
|
378
|
+
if field_to_pev.model != self.content_version_model_cls:
|
|
379
|
+
# In the case of multi-table inheritance and mixins, this can get tricky.
|
|
380
|
+
# Example:
|
|
381
|
+
# content_version_model_cls is UnitVersion, which is a subclass of ContainerVersion
|
|
382
|
+
# This versioning helper can be accessed via unit_version.versioning (should return UnitVersion) or
|
|
383
|
+
# via container_version.versioning (should return ContainerVersion)
|
|
384
|
+
intermediate_model = field_to_pev.model # example: ContainerVersion
|
|
385
|
+
# This is the field on the subclass (e.g. UnitVersion) that gets
|
|
386
|
+
# the intermediate (e.g. ContainerVersion). Example: "UnitVersion.container_version" (1:1 foreign key)
|
|
387
|
+
field_to_intermediate = self.content_version_model_cls._meta.get_ancestor_link(intermediate_model)
|
|
388
|
+
if field_to_intermediate:
|
|
389
|
+
# Example: self.related_name = "containerversion.unitversion"
|
|
390
|
+
self.related_name = self.related_name + "." + field_to_intermediate.related_query_name()
|
|
391
|
+
|
|
145
392
|
def _content_obj_version(self, pub_ent_version: PublishableEntityVersion | None):
|
|
146
393
|
"""
|
|
147
394
|
PublishableEntityVersion -> Content object version
|
|
@@ -151,7 +398,10 @@ class PublishableEntityMixin(models.Model):
|
|
|
151
398
|
"""
|
|
152
399
|
if pub_ent_version is None:
|
|
153
400
|
return None
|
|
154
|
-
|
|
401
|
+
obj = pub_ent_version
|
|
402
|
+
for field_name in self.related_name.split("."):
|
|
403
|
+
obj = getattr(obj, field_name)
|
|
404
|
+
return obj
|
|
155
405
|
|
|
156
406
|
@property
|
|
157
407
|
def draft(self):
|
|
@@ -294,36 +544,29 @@ class PublishableEntityVersionMixin(models.Model):
|
|
|
294
544
|
details).
|
|
295
545
|
"""
|
|
296
546
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
.get_queryset()
|
|
302
|
-
.select_related(
|
|
303
|
-
"publishable_entity_version",
|
|
304
|
-
)
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
objects: models.Manager[PublishableEntityVersionMixin] = PublishableEntityVersionMixinManager()
|
|
547
|
+
# select these related entities by default for all queries
|
|
548
|
+
objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager(
|
|
549
|
+
"publishable_entity_version",
|
|
550
|
+
)
|
|
308
551
|
|
|
309
552
|
publishable_entity_version = models.OneToOneField(
|
|
310
553
|
PublishableEntityVersion, on_delete=models.CASCADE, primary_key=True
|
|
311
554
|
)
|
|
312
555
|
|
|
313
556
|
@property
|
|
314
|
-
def uuid(self):
|
|
557
|
+
def uuid(self) -> str:
|
|
315
558
|
return self.publishable_entity_version.uuid
|
|
316
559
|
|
|
317
560
|
@property
|
|
318
|
-
def title(self):
|
|
561
|
+
def title(self) -> str:
|
|
319
562
|
return self.publishable_entity_version.title
|
|
320
563
|
|
|
321
564
|
@property
|
|
322
|
-
def created(self):
|
|
565
|
+
def created(self) -> datetime:
|
|
323
566
|
return self.publishable_entity_version.created
|
|
324
567
|
|
|
325
568
|
@property
|
|
326
|
-
def version_num(self):
|
|
569
|
+
def version_num(self) -> int:
|
|
327
570
|
return self.publishable_entity_version.version_num
|
|
328
571
|
|
|
329
572
|
class Meta:
|
|
File without changes
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Units API.
|
|
2
|
+
|
|
3
|
+
This module provides functions to manage units.
|
|
4
|
+
"""
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from django.db.transaction import atomic
|
|
9
|
+
|
|
10
|
+
from openedx_learning.apps.authoring.components.models import Component, ComponentVersion
|
|
11
|
+
|
|
12
|
+
from ..publishing import api as publishing_api
|
|
13
|
+
from .models import Unit, UnitVersion
|
|
14
|
+
|
|
15
|
+
# 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured
|
|
16
|
+
# out our approach to dynamic content (randomized, A/B tests, etc.)
|
|
17
|
+
__all__ = [
|
|
18
|
+
"create_unit",
|
|
19
|
+
"create_unit_version",
|
|
20
|
+
"create_next_unit_version",
|
|
21
|
+
"create_unit_and_version",
|
|
22
|
+
"get_unit",
|
|
23
|
+
"get_unit_version",
|
|
24
|
+
"get_latest_unit_version",
|
|
25
|
+
"UnitListEntry",
|
|
26
|
+
"get_components_in_unit",
|
|
27
|
+
"get_components_in_unit",
|
|
28
|
+
"get_components_in_published_unit_as_of",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_unit(
|
|
33
|
+
learning_package_id: int, key: str, created: datetime, created_by: int | None
|
|
34
|
+
) -> Unit:
|
|
35
|
+
"""
|
|
36
|
+
[ 🛑 UNSTABLE ] Create a new unit.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
learning_package_id: The learning package ID.
|
|
40
|
+
key: The key.
|
|
41
|
+
created: The creation date.
|
|
42
|
+
created_by: The user who created the unit.
|
|
43
|
+
"""
|
|
44
|
+
return publishing_api.create_container(
|
|
45
|
+
learning_package_id,
|
|
46
|
+
key,
|
|
47
|
+
created,
|
|
48
|
+
created_by,
|
|
49
|
+
container_cls=Unit,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_unit_version(
|
|
54
|
+
unit: Unit,
|
|
55
|
+
version_num: int,
|
|
56
|
+
*,
|
|
57
|
+
title: str,
|
|
58
|
+
publishable_entities_pks: list[int],
|
|
59
|
+
entity_version_pks: list[int | None],
|
|
60
|
+
created: datetime,
|
|
61
|
+
created_by: int | None = None,
|
|
62
|
+
) -> UnitVersion:
|
|
63
|
+
"""
|
|
64
|
+
[ 🛑 UNSTABLE ] Create a new unit version.
|
|
65
|
+
|
|
66
|
+
This is a very low-level API, likely only needed for import/export. In
|
|
67
|
+
general, you will use `create_unit_and_version()` and
|
|
68
|
+
`create_next_unit_version()` instead.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
unit_pk: The unit ID.
|
|
72
|
+
version_num: The version number.
|
|
73
|
+
title: The title.
|
|
74
|
+
publishable_entities_pk: The publishable entities.
|
|
75
|
+
entity: The entity.
|
|
76
|
+
created: The creation date.
|
|
77
|
+
created_by: The user who created the unit.
|
|
78
|
+
"""
|
|
79
|
+
return publishing_api.create_container_version(
|
|
80
|
+
unit.pk,
|
|
81
|
+
version_num,
|
|
82
|
+
title=title,
|
|
83
|
+
publishable_entities_pks=publishable_entities_pks,
|
|
84
|
+
entity_version_pks=entity_version_pks,
|
|
85
|
+
created=created,
|
|
86
|
+
created_by=created_by,
|
|
87
|
+
container_version_cls=UnitVersion,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pub_entities_for_components(
|
|
92
|
+
components: list[Component | ComponentVersion] | None,
|
|
93
|
+
) -> tuple[list[int], list[int | None]] | tuple[None, None]:
|
|
94
|
+
"""
|
|
95
|
+
Helper method: given a list of Component | ComponentVersion, return the
|
|
96
|
+
lists of publishable_entities_pks and entity_version_pks needed for the
|
|
97
|
+
base container APIs.
|
|
98
|
+
|
|
99
|
+
ComponentVersion is passed when we want to pin a specific version, otherwise
|
|
100
|
+
Component is used for unpinned.
|
|
101
|
+
"""
|
|
102
|
+
if components is None:
|
|
103
|
+
# When these are None, that means don't change the entities in the list.
|
|
104
|
+
return None, None
|
|
105
|
+
for c in components:
|
|
106
|
+
if not isinstance(c, (Component, ComponentVersion)):
|
|
107
|
+
raise TypeError("Unit components must be either Component or ComponentVersion.")
|
|
108
|
+
publishable_entities_pks = [
|
|
109
|
+
(c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id)
|
|
110
|
+
for c in components
|
|
111
|
+
]
|
|
112
|
+
entity_version_pks = [
|
|
113
|
+
(cv.pk if isinstance(cv, ComponentVersion) else None)
|
|
114
|
+
for cv in components
|
|
115
|
+
]
|
|
116
|
+
return publishable_entities_pks, entity_version_pks
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def create_next_unit_version(
|
|
120
|
+
unit: Unit,
|
|
121
|
+
*,
|
|
122
|
+
title: str | None = None,
|
|
123
|
+
components: list[Component | ComponentVersion] | None = None,
|
|
124
|
+
created: datetime,
|
|
125
|
+
created_by: int | None = None,
|
|
126
|
+
) -> UnitVersion:
|
|
127
|
+
"""
|
|
128
|
+
[ 🛑 UNSTABLE ] Create the next unit version.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
unit_pk: The unit ID.
|
|
132
|
+
title: The title. Leave as None to keep the current title.
|
|
133
|
+
components: The components, as a list of Components (unpinned) and/or ComponentVersions (pinned). Passing None
|
|
134
|
+
will leave the existing components unchanged.
|
|
135
|
+
created: The creation date.
|
|
136
|
+
created_by: The user who created the unit.
|
|
137
|
+
"""
|
|
138
|
+
publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components)
|
|
139
|
+
unit_version = publishing_api.create_next_container_version(
|
|
140
|
+
unit.pk,
|
|
141
|
+
title=title,
|
|
142
|
+
publishable_entities_pks=publishable_entities_pks,
|
|
143
|
+
entity_version_pks=entity_version_pks,
|
|
144
|
+
created=created,
|
|
145
|
+
created_by=created_by,
|
|
146
|
+
container_version_cls=UnitVersion,
|
|
147
|
+
)
|
|
148
|
+
return unit_version
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_unit_and_version(
|
|
152
|
+
learning_package_id: int,
|
|
153
|
+
key: str,
|
|
154
|
+
*,
|
|
155
|
+
title: str,
|
|
156
|
+
components: list[Component | ComponentVersion] | None = None,
|
|
157
|
+
created: datetime,
|
|
158
|
+
created_by: int | None = None,
|
|
159
|
+
) -> tuple[Unit, UnitVersion]:
|
|
160
|
+
"""
|
|
161
|
+
[ 🛑 UNSTABLE ] Create a new unit and its version.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
learning_package_id: The learning package ID.
|
|
165
|
+
key: The key.
|
|
166
|
+
created: The creation date.
|
|
167
|
+
created_by: The user who created the unit.
|
|
168
|
+
"""
|
|
169
|
+
publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components)
|
|
170
|
+
with atomic():
|
|
171
|
+
unit = create_unit(learning_package_id, key, created, created_by)
|
|
172
|
+
unit_version = create_unit_version(
|
|
173
|
+
unit,
|
|
174
|
+
1,
|
|
175
|
+
title=title,
|
|
176
|
+
publishable_entities_pks=publishable_entities_pks or [],
|
|
177
|
+
entity_version_pks=entity_version_pks or [],
|
|
178
|
+
created=created,
|
|
179
|
+
created_by=created_by,
|
|
180
|
+
)
|
|
181
|
+
return unit, unit_version
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_unit(unit_pk: int) -> Unit:
|
|
185
|
+
"""
|
|
186
|
+
[ 🛑 UNSTABLE ] Get a unit.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
unit_pk: The unit ID.
|
|
190
|
+
"""
|
|
191
|
+
return Unit.objects.get(pk=unit_pk)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_unit_version(unit_version_pk: int) -> UnitVersion:
|
|
195
|
+
"""
|
|
196
|
+
[ 🛑 UNSTABLE ] Get a unit version.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
unit_version_pk: The unit version ID.
|
|
200
|
+
"""
|
|
201
|
+
return UnitVersion.objects.get(pk=unit_version_pk)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_latest_unit_version(unit_pk: int) -> UnitVersion:
|
|
205
|
+
"""
|
|
206
|
+
[ 🛑 UNSTABLE ] Get the latest unit version.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
unit_pk: The unit ID.
|
|
210
|
+
"""
|
|
211
|
+
return Unit.objects.get(pk=unit_pk).versioning.latest
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class UnitListEntry:
|
|
216
|
+
"""
|
|
217
|
+
[ 🛑 UNSTABLE ]
|
|
218
|
+
Data about a single entity in a container, e.g. a component in a unit.
|
|
219
|
+
"""
|
|
220
|
+
component_version: ComponentVersion
|
|
221
|
+
pinned: bool = False
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def component(self):
|
|
225
|
+
return self.component_version.component
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_components_in_unit(
|
|
229
|
+
unit: Unit,
|
|
230
|
+
*,
|
|
231
|
+
published: bool,
|
|
232
|
+
) -> list[UnitListEntry]:
|
|
233
|
+
"""
|
|
234
|
+
[ 🛑 UNSTABLE ]
|
|
235
|
+
Get the list of entities and their versions in the draft or published
|
|
236
|
+
version of the given Unit.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
unit: The Unit, e.g. returned by `get_unit()`
|
|
240
|
+
published: `True` if we want the published version of the unit, or
|
|
241
|
+
`False` for the draft version.
|
|
242
|
+
"""
|
|
243
|
+
assert isinstance(unit, Unit)
|
|
244
|
+
components = []
|
|
245
|
+
for entry in publishing_api.get_entities_in_container(unit, published=published):
|
|
246
|
+
# Convert from generic PublishableEntityVersion to ComponentVersion:
|
|
247
|
+
component_version = entry.entity_version.componentversion
|
|
248
|
+
assert isinstance(component_version, ComponentVersion)
|
|
249
|
+
components.append(UnitListEntry(component_version=component_version, pinned=entry.pinned))
|
|
250
|
+
return components
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_components_in_published_unit_as_of(
|
|
254
|
+
unit: Unit,
|
|
255
|
+
publish_log_id: int,
|
|
256
|
+
) -> list[UnitListEntry] | None:
|
|
257
|
+
"""
|
|
258
|
+
[ 🛑 UNSTABLE ]
|
|
259
|
+
Get the list of entities and their versions in the published version of the
|
|
260
|
+
given container as of the given PublishLog version (which is essentially a
|
|
261
|
+
version for the entire learning package).
|
|
262
|
+
|
|
263
|
+
TODO: This API should be updated to also return the UnitVersion so we can
|
|
264
|
+
see the unit title and any other metadata from that point in time.
|
|
265
|
+
TODO: accept a publish log UUID, not just int ID?
|
|
266
|
+
TODO: move the implementation to be a generic 'containers' implementation
|
|
267
|
+
that this units function merely wraps.
|
|
268
|
+
TODO: optimize, perhaps by having the publishlog store a record of all
|
|
269
|
+
ancestors of every modified PublishableEntity in the publish.
|
|
270
|
+
"""
|
|
271
|
+
assert isinstance(unit, Unit)
|
|
272
|
+
unit_pub_entity_version = publishing_api.get_published_version_as_of(unit.publishable_entity_id, publish_log_id)
|
|
273
|
+
if unit_pub_entity_version is None:
|
|
274
|
+
return None # This unit was not published as of the given PublishLog ID.
|
|
275
|
+
container_version = unit_pub_entity_version.containerversion
|
|
276
|
+
|
|
277
|
+
entity_list = []
|
|
278
|
+
rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
|
|
279
|
+
for row in rows:
|
|
280
|
+
if row.entity_version is not None:
|
|
281
|
+
component_version = row.entity_version.componentversion
|
|
282
|
+
assert isinstance(component_version, ComponentVersion)
|
|
283
|
+
entity_list.append(UnitListEntry(component_version=component_version, pinned=True))
|
|
284
|
+
else:
|
|
285
|
+
# Unpinned component - figure out what its latest published version was.
|
|
286
|
+
# This is not optimized. It could be done in one query per unit rather than one query per component.
|
|
287
|
+
pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
|
|
288
|
+
if pub_entity_version:
|
|
289
|
+
entity_list.append(UnitListEntry(component_version=pub_entity_version.componentversion, pinned=False))
|
|
290
|
+
return entity_list
|