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.
Files changed (36) hide show
  1. openedx_learning/__init__.py +1 -1
  2. openedx_learning/api/authoring.py +1 -0
  3. openedx_learning/api/authoring_models.py +1 -1
  4. openedx_learning/apps/authoring/components/api.py +3 -3
  5. openedx_learning/apps/authoring/components/apps.py +1 -1
  6. openedx_learning/apps/authoring/components/models.py +9 -14
  7. openedx_learning/apps/authoring/contents/models.py +1 -1
  8. openedx_learning/apps/authoring/publishing/api.py +459 -3
  9. openedx_learning/apps/authoring/publishing/apps.py +9 -0
  10. openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +54 -0
  11. openedx_learning/apps/authoring/publishing/models/__init__.py +27 -0
  12. openedx_learning/apps/authoring/publishing/models/container.py +70 -0
  13. openedx_learning/apps/authoring/publishing/models/draft_published.py +95 -0
  14. openedx_learning/apps/authoring/publishing/models/entity_list.py +69 -0
  15. openedx_learning/apps/authoring/publishing/models/learning_package.py +75 -0
  16. openedx_learning/apps/authoring/publishing/models/publish_log.py +106 -0
  17. openedx_learning/apps/authoring/publishing/{model_mixins.py → models/publishable_entity.py} +284 -41
  18. openedx_learning/apps/authoring/units/__init__.py +0 -0
  19. openedx_learning/apps/authoring/units/api.py +290 -0
  20. openedx_learning/apps/authoring/units/apps.py +25 -0
  21. openedx_learning/apps/authoring/units/migrations/0001_initial.py +36 -0
  22. openedx_learning/apps/authoring/units/migrations/__init__.py +0 -0
  23. openedx_learning/apps/authoring/units/models.py +50 -0
  24. openedx_learning/contrib/media_server/apps.py +1 -1
  25. openedx_learning/lib/managers.py +7 -1
  26. openedx_learning/lib/validators.py +1 -1
  27. {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/METADATA +2 -2
  28. {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/RECORD +35 -23
  29. {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/WHEEL +1 -1
  30. openedx_tagging/core/tagging/api.py +11 -4
  31. openedx_tagging/core/tagging/models/base.py +1 -1
  32. openedx_tagging/core/tagging/rest_api/v1/permissions.py +1 -1
  33. openedx_tagging/core/tagging/rules.py +1 -2
  34. openedx_learning/apps/authoring/publishing/models.py +0 -517
  35. {openedx_learning-0.18.2.dist-info → openedx_learning-0.19.0.dist-info}/LICENSE.txt +0 -0
  36. {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
- Helper mixin classes for content apps that want to use the publishing app.
2
+ PublishableEntity model and PublishableEntityVersion + mixins
3
3
  """
4
- from __future__ import annotations
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 .models import PublishableEntity, PublishableEntityVersion
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
- __all__ = [
15
- "PublishableEntityMixin",
16
- "PublishableEntityVersionMixin",
17
- "PublishableContentModelRegistry",
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
- class PublishableEntityMixinManager(models.Manager):
33
- def get_queryset(self) -> QuerySet:
34
- return super().get_queryset() \
35
- .select_related(
36
- "publishable_entity",
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
- return getattr(pub_ent_version, self.related_name)
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
- class PublishableEntityVersionMixinManager(models.Manager):
298
- def get_queryset(self) -> QuerySet:
299
- return (
300
- super()
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