openedx-learning 0.18.3__py2.py3-none-any.whl → 0.19.1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. openedx_learning/__init__.py +1 -1
  2. openedx_learning/api/authoring.py +1 -0
  3. openedx_learning/api/authoring_models.py +1 -1
  4. openedx_learning/apps/authoring/components/api.py +18 -5
  5. openedx_learning/apps/authoring/components/apps.py +1 -1
  6. openedx_learning/apps/authoring/components/models.py +9 -14
  7. openedx_learning/apps/authoring/contents/models.py +1 -1
  8. openedx_learning/apps/authoring/publishing/admin.py +3 -0
  9. openedx_learning/apps/authoring/publishing/api.py +508 -3
  10. openedx_learning/apps/authoring/publishing/apps.py +9 -0
  11. openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +54 -0
  12. openedx_learning/apps/authoring/publishing/migrations/0004_publishableentity_can_stand_alone.py +21 -0
  13. openedx_learning/apps/authoring/publishing/models/__init__.py +27 -0
  14. openedx_learning/apps/authoring/publishing/models/container.py +70 -0
  15. openedx_learning/apps/authoring/publishing/models/draft_published.py +95 -0
  16. openedx_learning/apps/authoring/publishing/models/entity_list.py +69 -0
  17. openedx_learning/apps/authoring/publishing/models/learning_package.py +75 -0
  18. openedx_learning/apps/authoring/publishing/models/publish_log.py +106 -0
  19. openedx_learning/apps/authoring/publishing/{model_mixins.py → models/publishable_entity.py} +289 -41
  20. openedx_learning/apps/authoring/units/__init__.py +0 -0
  21. openedx_learning/apps/authoring/units/api.py +305 -0
  22. openedx_learning/apps/authoring/units/apps.py +25 -0
  23. openedx_learning/apps/authoring/units/migrations/0001_initial.py +36 -0
  24. openedx_learning/apps/authoring/units/migrations/__init__.py +0 -0
  25. openedx_learning/apps/authoring/units/models.py +50 -0
  26. openedx_learning/contrib/media_server/apps.py +1 -1
  27. openedx_learning/lib/managers.py +7 -1
  28. openedx_learning/lib/validators.py +1 -1
  29. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/METADATA +3 -3
  30. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/RECORD +37 -24
  31. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/WHEEL +1 -1
  32. openedx_tagging/core/tagging/api.py +4 -4
  33. openedx_tagging/core/tagging/models/base.py +1 -1
  34. openedx_tagging/core/tagging/rest_api/v1/permissions.py +1 -1
  35. openedx_tagging/core/tagging/rules.py +1 -2
  36. openedx_learning/apps/authoring/publishing/models.py +0 -517
  37. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/LICENSE.txt +0 -0
  38. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.1.dist-info}/top_level.txt +0 -0
@@ -1,21 +1,265 @@
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
12
+ from django.utils.translation import gettext as _
13
+
14
+ from openedx_learning.lib.fields import (
15
+ case_insensitive_char_field,
16
+ immutable_uuid_field,
17
+ key_field,
18
+ manual_date_time_field,
19
+ )
20
+ from openedx_learning.lib.managers import WithRelationsManager
21
+
22
+ from .learning_package import LearningPackage
23
+
24
+
25
+ class PublishableEntity(models.Model):
26
+ """
27
+ This represents any publishable thing that has ever existed in a
28
+ LearningPackage. It serves as a stable model that will not go away even if
29
+ these things are later unpublished or deleted.
30
+
31
+ A PublishableEntity belongs to exactly one LearningPackage.
32
+
33
+ Examples of Publishable Entities
34
+ --------------------------------
35
+
36
+ Components (e.g. VideoBlock, ProblemBlock), Units, and Sections/Subsections
37
+ would all be considered Publishable Entites. But anything that can be
38
+ imported, exported, published, and reverted in a course or library could be
39
+ modeled as a PublishableEntity, including things like Grading Policy or
40
+ possibly Taxonomies (?).
41
+
42
+ How to use this model
43
+ ---------------------
44
+
45
+ The publishing app understands that publishable entities exist, along with
46
+ their drafts and published versions. It has some basic metadata, such as
47
+ identifiers, who created it, and when it was created. It's meant to
48
+ encapsulate the draft and publishing related aspects of your content, but
49
+ the ``publishing`` app doesn't know anything about the actual content being
50
+ referenced.
51
+
52
+ You have to provide actual meaning to PublishableEntity by creating your own
53
+ models that will represent your particular content and associating them to
54
+ PublishableEntity via a OneToOneField with primary_key=True. The easiest way
55
+ to do this is to have your model inherit from PublishableEntityMixin.
56
+
57
+ Identifiers
58
+ -----------
59
+ The UUID is globally unique and should be treated as immutable.
60
+
61
+ The key field *is* mutable, but changing it will affect all
62
+ PublishedEntityVersions. They are locally unique within the LearningPackage.
63
+
64
+ If you are referencing this model from within the same process, use a
65
+ foreign key to the id. If you are referencing this PublishedEntity from an
66
+ external system/service, use the UUID. The key is the part that is most
67
+ likely to be human-readable, and may be exported/copied, but try not to rely
68
+ on it, since this value may change.
69
+
70
+ Note: When we actually implement the ability to change identifiers, we
71
+ should make a history table and a modified attribute on this model.
72
+
73
+ Why are Identifiers in this Model?
74
+ ----------------------------------
75
+
76
+ A PublishableEntity never stands alone–it's always intended to be used with
77
+ a 1:1 model like Component or Unit. So why have all the identifiers in this
78
+ model instead of storing them in those other models? Two reasons:
79
+
80
+ * Published things need to have the right identifiers so they can be used
81
+ throughout the system, and the UUID is serving the role of ISBN in physical
82
+ book publishing.
83
+ * We want to be able to enforce the idea that "key" is locally unique across
84
+ all PublishableEntities within a given LearningPackage. Component and Unit
85
+ can't do that without a shared model.
86
+
87
+ That being said, models that build on PublishableEntity are free to add
88
+ their own identifiers if it's useful to do so.
89
+
90
+ Why not Inherit from this Model?
91
+ --------------------------------
92
+
93
+ Django supports multi-table inheritance:
94
+
95
+ https://docs.djangoproject.com/en/4.2/topics/db/models/#multi-table-inheritance
96
+
97
+ We don't use that, primarily because we want to more clearly decouple
98
+ publishing concerns from the rest of the logic around Components, Units,
99
+ etc. If you made a Component and ComponentVersion models that subclassed
100
+ PublishableEntity and PublishableEntityVersion, and then accessed
101
+ ``component.versions``, you might expect ComponentVersions to come back and
102
+ be surprised when you get EntityVersions instead.
103
+
104
+ In general, we want freedom to add new Publishing models, fields, and
105
+ methods without having to worry about the downstream name collisions with
106
+ other apps (many of which might live in other repositories). The helper
107
+ mixins will provide a little syntactic sugar to make common access patterns
108
+ more convenient, like file access.
109
+ """
110
+
111
+ uuid = immutable_uuid_field()
112
+ learning_package = models.ForeignKey(
113
+ LearningPackage,
114
+ on_delete=models.CASCADE,
115
+ related_name="publishable_entities",
116
+ )
11
117
 
12
- from .models import PublishableEntity, PublishableEntityVersion
118
+ # "key" is a reserved word for MySQL, so we're temporarily using the column
119
+ # name of "_key" to avoid breaking downstream tooling. Consider renaming
120
+ # this later.
121
+ key = key_field(db_column="_key")
122
+
123
+ created = manual_date_time_field()
124
+ created_by = models.ForeignKey(
125
+ settings.AUTH_USER_MODEL,
126
+ on_delete=models.SET_NULL,
127
+ null=True,
128
+ blank=True,
129
+ )
130
+ can_stand_alone = models.BooleanField(
131
+ default=True,
132
+ help_text=_("Set to True when created independently, False when created as part of a container."),
133
+ )
13
134
 
14
- __all__ = [
15
- "PublishableEntityMixin",
16
- "PublishableEntityVersionMixin",
17
- "PublishableContentModelRegistry",
18
- ]
135
+ class Meta:
136
+ constraints = [
137
+ # Keys are unique within a given LearningPackage.
138
+ models.UniqueConstraint(
139
+ fields=[
140
+ "learning_package",
141
+ "key",
142
+ ],
143
+ name="oel_pub_ent_uniq_lp_key",
144
+ )
145
+ ]
146
+ indexes = [
147
+ # Global Key Index:
148
+ # * Search by key across all PublishableEntities on the site. This
149
+ # would be a support-oriented tool from Django Admin.
150
+ models.Index(
151
+ fields=["key"],
152
+ name="oel_pub_ent_idx_key",
153
+ ),
154
+ # LearningPackage (reverse) Created Index:
155
+ # * Search for most recently *created* PublishableEntities for a
156
+ # given LearningPackage, since they're the most likely to be
157
+ # actively worked on.
158
+ models.Index(
159
+ fields=["learning_package", "-created"],
160
+ name="oel_pub_ent_idx_lp_rcreated",
161
+ ),
162
+ ]
163
+ # These are for the Django Admin UI.
164
+ verbose_name = "Publishable Entity"
165
+ verbose_name_plural = "Publishable Entities"
166
+
167
+ def __str__(self):
168
+ return f"{self.key}"
169
+
170
+
171
+ class PublishableEntityVersion(models.Model):
172
+ """
173
+ A particular version of a PublishableEntity.
174
+
175
+ This model has its own ``uuid`` so that it can be referenced directly. The
176
+ ``uuid`` should be treated as immutable.
177
+
178
+ PublishableEntityVersions are created once and never updated. So for
179
+ instance, the ``title`` should never be modified.
180
+
181
+ Like PublishableEntity, the data in this model is only enough to cover the
182
+ parts that are most important for the actual process of managing drafts and
183
+ publishes. You will want to create your own models to represent the actual
184
+ content data that's associated with this PublishableEntityVersion, and
185
+ connect them using a OneToOneField with primary_key=True. The easiest way to
186
+ do this is to inherit from PublishableEntityVersionMixin. Be sure to treat
187
+ these versioned models in your app as immutable as well.
188
+ """
189
+
190
+ uuid = immutable_uuid_field()
191
+ entity = models.ForeignKey(
192
+ PublishableEntity, on_delete=models.CASCADE, related_name="versions"
193
+ )
194
+
195
+ # Most publishable things will have some sort of title, but blanks are
196
+ # allowed for those that don't require one.
197
+ title = case_insensitive_char_field(max_length=500, blank=True, default="")
198
+
199
+ # The version_num starts at 1 and increments by 1 with each new version for
200
+ # a given PublishableEntity. Doing it this way makes it more convenient for
201
+ # users to refer to than a hash or UUID value. It also helps us catch race
202
+ # conditions on save, by setting a unique constraint on the entity and
203
+ # version_num.
204
+ version_num = models.PositiveIntegerField(
205
+ null=False,
206
+ validators=[MinValueValidator(1)],
207
+ )
208
+
209
+ # All PublishableEntityVersions created as part of the same publish should
210
+ # have the exact same created datetime (not off by a handful of
211
+ # microseconds).
212
+ created = manual_date_time_field()
213
+
214
+ # User who created the PublishableEntityVersion. This can be null if the
215
+ # user is later removed. Open edX in general doesn't let you remove users,
216
+ # but we should try to model it so that this is possible eventually.
217
+ created_by = models.ForeignKey(
218
+ settings.AUTH_USER_MODEL,
219
+ on_delete=models.SET_NULL,
220
+ null=True,
221
+ blank=True,
222
+ )
223
+
224
+ class Meta:
225
+ constraints = [
226
+ # Prevent the situation where we have multiple
227
+ # PublishableEntityVersions claiming to be the same version_num for
228
+ # a given PublishableEntity. This can happen if there's a race
229
+ # condition between concurrent editors in different browsers,
230
+ # working on the same Publishable. With this constraint, one of
231
+ # those processes will raise an IntegrityError.
232
+ models.UniqueConstraint(
233
+ fields=[
234
+ "entity",
235
+ "version_num",
236
+ ],
237
+ name="oel_pv_uniq_entity_version_num",
238
+ )
239
+ ]
240
+ indexes = [
241
+ # LearningPackage (reverse) Created Index:
242
+ # * Make it cheap to find the most recently created
243
+ # PublishableEntityVersions for a given LearningPackage. This
244
+ # represents the most recently saved work for a LearningPackage
245
+ # and would be the most likely areas to get worked on next.
246
+ models.Index(
247
+ fields=["entity", "-created"],
248
+ name="oel_pv_idx_entity_rcreated",
249
+ ),
250
+ # Title Index:
251
+ # * Search by title.
252
+ models.Index(
253
+ fields=[
254
+ "title",
255
+ ],
256
+ name="oel_pv_idx_title",
257
+ ),
258
+ ]
259
+
260
+ # These are for the Django Admin UI.
261
+ verbose_name = "Publishable Entity Version"
262
+ verbose_name_plural = "Publishable Entity Versions"
19
263
 
20
264
 
21
265
  class PublishableEntityMixin(models.Model):
@@ -28,17 +272,12 @@ class PublishableEntityMixin(models.Model):
28
272
  the publishing app's api.register_content_models (see its docstring for
29
273
  details).
30
274
  """
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()
275
+ # select these related entities by default for all queries
276
+ objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager(
277
+ "publishable_entity",
278
+ "publishable_entity__published",
279
+ "publishable_entity__draft",
280
+ )
42
281
 
43
282
  publishable_entity = models.OneToOneField(
44
283
  PublishableEntity, on_delete=models.CASCADE, primary_key=True
@@ -49,15 +288,15 @@ class PublishableEntityMixin(models.Model):
49
288
  return self.VersioningHelper(self)
50
289
 
51
290
  @property
52
- def uuid(self):
291
+ def uuid(self) -> str:
53
292
  return self.publishable_entity.uuid
54
293
 
55
294
  @property
56
- def key(self):
295
+ def key(self) -> str:
57
296
  return self.publishable_entity.key
58
297
 
59
298
  @property
60
- def created(self):
299
+ def created(self) -> datetime:
61
300
  return self.publishable_entity.created
62
301
 
63
302
  @property
@@ -137,11 +376,24 @@ class PublishableEntityMixin(models.Model):
137
376
  field_to_pev = self.content_version_model_cls._meta.get_field(
138
377
  "publishable_entity_version"
139
378
  )
140
-
141
379
  # Now that we know the field that leads to PublishableEntityVersion,
142
380
  # get the reverse related field name so that we can use that later.
143
381
  self.related_name = field_to_pev.related_query_name()
144
382
 
383
+ if field_to_pev.model != self.content_version_model_cls:
384
+ # In the case of multi-table inheritance and mixins, this can get tricky.
385
+ # Example:
386
+ # content_version_model_cls is UnitVersion, which is a subclass of ContainerVersion
387
+ # This versioning helper can be accessed via unit_version.versioning (should return UnitVersion) or
388
+ # via container_version.versioning (should return ContainerVersion)
389
+ intermediate_model = field_to_pev.model # example: ContainerVersion
390
+ # This is the field on the subclass (e.g. UnitVersion) that gets
391
+ # the intermediate (e.g. ContainerVersion). Example: "UnitVersion.container_version" (1:1 foreign key)
392
+ field_to_intermediate = self.content_version_model_cls._meta.get_ancestor_link(intermediate_model)
393
+ if field_to_intermediate:
394
+ # Example: self.related_name = "containerversion.unitversion"
395
+ self.related_name = self.related_name + "." + field_to_intermediate.related_query_name()
396
+
145
397
  def _content_obj_version(self, pub_ent_version: PublishableEntityVersion | None):
146
398
  """
147
399
  PublishableEntityVersion -> Content object version
@@ -151,7 +403,10 @@ class PublishableEntityMixin(models.Model):
151
403
  """
152
404
  if pub_ent_version is None:
153
405
  return None
154
- return getattr(pub_ent_version, self.related_name)
406
+ obj = pub_ent_version
407
+ for field_name in self.related_name.split("."):
408
+ obj = getattr(obj, field_name)
409
+ return obj
155
410
 
156
411
  @property
157
412
  def draft(self):
@@ -294,36 +549,29 @@ class PublishableEntityVersionMixin(models.Model):
294
549
  details).
295
550
  """
296
551
 
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()
552
+ # select these related entities by default for all queries
553
+ objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager(
554
+ "publishable_entity_version",
555
+ )
308
556
 
309
557
  publishable_entity_version = models.OneToOneField(
310
558
  PublishableEntityVersion, on_delete=models.CASCADE, primary_key=True
311
559
  )
312
560
 
313
561
  @property
314
- def uuid(self):
562
+ def uuid(self) -> str:
315
563
  return self.publishable_entity_version.uuid
316
564
 
317
565
  @property
318
- def title(self):
566
+ def title(self) -> str:
319
567
  return self.publishable_entity_version.title
320
568
 
321
569
  @property
322
- def created(self):
570
+ def created(self) -> datetime:
323
571
  return self.publishable_entity_version.created
324
572
 
325
573
  @property
326
- def version_num(self):
574
+ def version_num(self) -> int:
327
575
  return self.publishable_entity_version.version_num
328
576
 
329
577
  class Meta:
File without changes