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.
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.3.dist-info → openedx_learning-0.19.0.dist-info}/METADATA +3 -3
  28. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/RECORD +35 -23
  29. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/WHEEL +1 -1
  30. openedx_tagging/core/tagging/api.py +4 -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.3.dist-info → openedx_learning-0.19.0.dist-info}/LICENSE.txt +0 -0
  36. {openedx_learning-0.18.3.dist-info → openedx_learning-0.19.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,54 @@
1
+ # Generated by Django 4.2.19 on 2025-03-11 04:10
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('oel_publishing', '0002_alter_learningpackage_key_and_more'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='Container',
16
+ fields=[
17
+ ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')),
18
+ ],
19
+ options={
20
+ 'abstract': False,
21
+ },
22
+ ),
23
+ migrations.CreateModel(
24
+ name='EntityList',
25
+ fields=[
26
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
27
+ ],
28
+ ),
29
+ migrations.CreateModel(
30
+ name='EntityListRow',
31
+ fields=[
32
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
33
+ ('order_num', models.PositiveIntegerField()),
34
+ ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')),
35
+ ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.entitylist')),
36
+ ('entity_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.publishableentityversion')),
37
+ ],
38
+ ),
39
+ migrations.CreateModel(
40
+ name='ContainerVersion',
41
+ fields=[
42
+ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')),
43
+ ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_publishing.container')),
44
+ ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='container_versions', to='oel_publishing.entitylist')),
45
+ ],
46
+ options={
47
+ 'abstract': False,
48
+ },
49
+ ),
50
+ migrations.AddConstraint(
51
+ model_name='entitylistrow',
52
+ constraint=models.UniqueConstraint(fields=('entity_list', 'order_num'), name='oel_publishing_elist_row_order'),
53
+ ),
54
+ ]
@@ -0,0 +1,27 @@
1
+ """
2
+ The data models here are intended to be used by other apps to publish different
3
+ types of content, such as Components, Units, Sections, etc. These models should
4
+ support the logic for the management of the publishing process:
5
+
6
+ * The relationship between publishable entities and their many versions.
7
+ * Hierarchical relationships between "container" entities and their children
8
+ * The management of drafts.
9
+ * Publishing specific versions of publishable entities.
10
+ * Finding the currently published versions.
11
+ * The act of publishing, and doing so atomically.
12
+ * Managing reverts.
13
+ * Storing and querying publish history.
14
+ """
15
+
16
+ from .container import Container, ContainerVersion
17
+ from .draft_published import Draft, Published
18
+ from .entity_list import EntityList, EntityListRow
19
+ from .learning_package import LearningPackage
20
+ from .publish_log import PublishLog, PublishLogRecord
21
+ from .publishable_entity import (
22
+ PublishableContentModelRegistry,
23
+ PublishableEntity,
24
+ PublishableEntityMixin,
25
+ PublishableEntityVersion,
26
+ PublishableEntityVersionMixin,
27
+ )
@@ -0,0 +1,70 @@
1
+ """
2
+ Container and ContainerVersion models
3
+ """
4
+ from django.core.exceptions import ValidationError
5
+ from django.db import models
6
+
7
+ from .entity_list import EntityList
8
+ from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin
9
+
10
+
11
+ class Container(PublishableEntityMixin):
12
+ """
13
+ A Container is a type of PublishableEntity that holds other
14
+ PublishableEntities. For example, a "Unit" Container might hold several
15
+ Components.
16
+
17
+ For now, all containers have a static "entity list" that defines which
18
+ containers/components/enities they hold. As we complete the Containers API,
19
+ we will also add support for dynamic containers which may contain different
20
+ entities for different learners or at different times.
21
+
22
+ NOTE: We're going to want to eventually have some association between the
23
+ PublishLog and Containers that were affected in a publish because their
24
+ child elements were published.
25
+ """
26
+
27
+
28
+ class ContainerVersion(PublishableEntityVersionMixin):
29
+ """
30
+ A version of a Container.
31
+
32
+ By convention, we would only want to create new versions when the Container
33
+ itself changes, and not when the Container's child elements change. For
34
+ example:
35
+
36
+ * Something was added to the Container.
37
+ * We re-ordered the rows in the container.
38
+ * Something was removed to the container.
39
+ * The Container's metadata changed, e.g. the title.
40
+ * We pin to different versions of the Container.
41
+
42
+ The last looks a bit odd, but it's because *how we've defined the Unit* has
43
+ changed if we decide to explicitly pin a set of versions for the children,
44
+ and then later change our minds and move to a different set. It also just
45
+ makes things easier to reason about if we say that entity_list never
46
+ changes for a given ContainerVersion.
47
+ """
48
+
49
+ container = models.ForeignKey(
50
+ Container,
51
+ on_delete=models.CASCADE,
52
+ related_name="versions",
53
+ )
54
+
55
+ # The list of entities (frozen and/or unfrozen) in this container
56
+ entity_list = models.ForeignKey(
57
+ EntityList,
58
+ on_delete=models.RESTRICT,
59
+ null=False,
60
+ related_name="container_versions",
61
+ )
62
+
63
+ def clean(self):
64
+ """
65
+ Validate this model before saving. Not called normally, but will be
66
+ called if anything is edited via a ModelForm like the Django admin.
67
+ """
68
+ super().clean()
69
+ if self.container_id != self.publishable_entity_version.entity.container.pk: # pylint: disable=no-member
70
+ raise ValidationError("Inconsistent foreign keys to Container")
@@ -0,0 +1,95 @@
1
+ """
2
+ Draft and Published models
3
+ """
4
+ from django.db import models
5
+
6
+ from .publish_log import PublishLogRecord
7
+ from .publishable_entity import PublishableEntity, PublishableEntityVersion
8
+
9
+
10
+ class Draft(models.Model):
11
+ """
12
+ Find the active draft version of an entity (usually most recently created).
13
+
14
+ This model mostly only exists to allow us to join against a bunch of
15
+ PublishableEntity objects at once and get all their latest drafts. You might
16
+ use this together with Published in order to see which Drafts haven't been
17
+ published yet.
18
+
19
+ A Draft entry should be created whenever a new PublishableEntityVersion is
20
+ created. This means there are three possible states:
21
+
22
+ 1. No Draft entry for a PublishableEntity: This means a PublishableEntity
23
+ was created, but no PublishableEntityVersion was ever made for it, so
24
+ there was never a Draft version.
25
+ 2. A Draft entry exists and points to a PublishableEntityVersion: This is
26
+ the most common state.
27
+ 3. A Draft entry exists and points to a null version: This means a version
28
+ used to be the draft, but it's been functionally "deleted". The versions
29
+ still exist in our history, but we're done using it.
30
+
31
+ It would have saved a little space to add this data to the Published model
32
+ (and possibly call the combined model something else). Split Modulestore did
33
+ this with its active_versions table. I keep it separate here to get a better
34
+ separation of lifecycle events: i.e. this table *only* changes when drafts
35
+ are updated, not when publishing happens. The Published model only changes
36
+ when something is published.
37
+ """
38
+ # If we're removing a PublishableEntity entirely, also remove the Draft
39
+ # entry for it. This isn't a normal operation, but can happen if you're
40
+ # deleting an entire LearningPackage.
41
+ entity = models.OneToOneField(
42
+ PublishableEntity,
43
+ on_delete=models.CASCADE,
44
+ primary_key=True,
45
+ )
46
+ version = models.OneToOneField(
47
+ PublishableEntityVersion,
48
+ on_delete=models.RESTRICT,
49
+ null=True,
50
+ blank=True,
51
+ )
52
+
53
+
54
+ class Published(models.Model):
55
+ """
56
+ Find the currently published version of an entity.
57
+
58
+ Notes:
59
+
60
+ * There is only ever one published PublishableEntityVersion per
61
+ PublishableEntity at any given time.
62
+ * It may be possible for a PublishableEntity to exist only as a Draft (and thus
63
+ not show up in this table).
64
+ * If a row exists for a PublishableEntity, but the ``version`` field is
65
+ None, it means that the entity was published at some point, but is no
66
+ longer published now–i.e. it's functionally "deleted", even though all
67
+ the version history is preserved behind the scenes.
68
+
69
+ TODO: Do we need to create a (redundant) title field in this model so that
70
+ we can more efficiently search across titles within a LearningPackage?
71
+ Probably not an immediate concern because the number of rows currently
72
+ shouldn't be > 10,000 in the more extreme cases.
73
+
74
+ TODO: Do we need to make a "most_recently" published version when an entry
75
+ is unpublished/deleted?
76
+ """
77
+
78
+ entity = models.OneToOneField(
79
+ PublishableEntity,
80
+ on_delete=models.CASCADE,
81
+ primary_key=True,
82
+ )
83
+ version = models.OneToOneField(
84
+ PublishableEntityVersion,
85
+ on_delete=models.RESTRICT,
86
+ null=True,
87
+ )
88
+ publish_log_record = models.ForeignKey(
89
+ PublishLogRecord,
90
+ on_delete=models.RESTRICT,
91
+ )
92
+
93
+ class Meta:
94
+ verbose_name = "Published Entity"
95
+ verbose_name_plural = "Published Entities"
@@ -0,0 +1,69 @@
1
+ """
2
+ Entity List models
3
+ """
4
+ from django.db import models
5
+
6
+ from .publishable_entity import PublishableEntity, PublishableEntityVersion
7
+
8
+
9
+ class EntityList(models.Model):
10
+ """
11
+ EntityLists are a common structure to hold parent-child relations.
12
+
13
+ EntityLists are not PublishableEntities in and of themselves. That's because
14
+ sometimes we'll want the same kind of data structure for things that we
15
+ dynamically generate for individual students (e.g. Variants). EntityLists are
16
+ anonymous in a sense–they're pointed to by ContainerVersions and
17
+ other models, rather than being looked up by their own identifiers.
18
+ """
19
+
20
+
21
+ class EntityListRow(models.Model):
22
+ """
23
+ Each EntityListRow points to a PublishableEntity, optionally at a specific
24
+ version.
25
+
26
+ There is a row in this table for each member of an EntityList. The order_num
27
+ field is used to determine the order of the members in the list.
28
+ """
29
+
30
+ entity_list = models.ForeignKey(EntityList, on_delete=models.CASCADE)
31
+
32
+ # This ordering should be treated as immutable–if the ordering needs to
33
+ # change, we create a new EntityList and copy things over.
34
+ order_num = models.PositiveIntegerField()
35
+
36
+ # Simple case would use these fields with our convention that null versions
37
+ # means "get the latest draft or published as appropriate". These entities
38
+ # could be Selectors, in which case we'd need to do more work to find the right
39
+ # variant. The publishing app itself doesn't know anything about Selectors
40
+ # however, and just treats it as another PublishableEntity.
41
+ entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT)
42
+
43
+ # The version references point to the specific PublishableEntityVersion that
44
+ # this EntityList has for this PublishableEntity for both the draft and
45
+ # published states. However, we don't want to have to create new EntityList
46
+ # every time that a member is updated, because that would waste a lot of
47
+ # space and make it difficult to figure out when the metadata of something
48
+ # like a Unit *actually* changed, vs. when its child members were being
49
+ # updated. Doing so could also potentially lead to race conditions when
50
+ # updating multiple layers of containers.
51
+ #
52
+ # So our approach to this is to use a value of None (null) to represent an
53
+ # unpinned reference to a PublishableEntity. It's shorthand for "just use
54
+ # the latest draft or published version of this, as appropriate".
55
+ entity_version = models.ForeignKey(
56
+ PublishableEntityVersion,
57
+ on_delete=models.RESTRICT,
58
+ null=True,
59
+ related_name="+", # Do we need the reverse relation?
60
+ )
61
+
62
+ class Meta:
63
+ constraints = [
64
+ # If (entity_list, order_num) is not unique, it likely indicates a race condition - so force uniqueness.
65
+ models.UniqueConstraint(
66
+ fields=["entity_list", "order_num"],
67
+ name="oel_publishing_elist_row_order",
68
+ ),
69
+ ]
@@ -0,0 +1,75 @@
1
+ """
2
+ LearningPackage model
3
+ """
4
+ from django.db import models
5
+
6
+ from openedx_learning.lib.fields import (
7
+ MultiCollationTextField,
8
+ case_insensitive_char_field,
9
+ immutable_uuid_field,
10
+ key_field,
11
+ manual_date_time_field,
12
+ )
13
+
14
+
15
+ class LearningPackage(models.Model):
16
+ """
17
+ Top level container for a grouping of authored content.
18
+
19
+ Each PublishableEntity belongs to exactly one LearningPackage.
20
+ """
21
+ # Explictly declare a 4-byte ID instead of using the app-default 8-byte ID.
22
+ # We do not expect to have more than 2 billion LearningPackages on a given
23
+ # site. Furthermore, many, many things have foreign keys to this model and
24
+ # uniqueness indexes on those foreign keys + their own fields, so the 4
25
+ # bytes saved will add up over time.
26
+ id = models.AutoField(primary_key=True)
27
+
28
+ uuid = immutable_uuid_field()
29
+
30
+ # "key" is a reserved word for MySQL, so we're temporarily using the column
31
+ # name of "_key" to avoid breaking downstream tooling. There's an open
32
+ # question as to whether this field needs to exist at all, or whether the
33
+ # top level library key it's currently used for should be entirely in the
34
+ # LibraryContent model.
35
+ key = key_field(db_column="_key")
36
+
37
+ title = case_insensitive_char_field(max_length=500, blank=False)
38
+
39
+ # TODO: We should probably defer this field, since many things pull back
40
+ # LearningPackage as select_related. Usually those relations only care about
41
+ # the UUID and key, so maybe it makes sense to separate the model at some
42
+ # point.
43
+ description = MultiCollationTextField(
44
+ blank=True,
45
+ null=False,
46
+ default="",
47
+ max_length=10_000,
48
+ # We don't really expect to ever sort by the text column, but we may
49
+ # want to do case-insensitive searches, so it's useful to have a case
50
+ # and accent insensitive collation.
51
+ db_collations={
52
+ "sqlite": "NOCASE",
53
+ "mysql": "utf8mb4_unicode_ci",
54
+ }
55
+ )
56
+
57
+ created = manual_date_time_field()
58
+ updated = manual_date_time_field()
59
+
60
+ def __str__(self):
61
+ return f"{self.key}"
62
+
63
+ class Meta:
64
+ constraints = [
65
+ # LearningPackage keys must be globally unique. This is something
66
+ # that might be relaxed in the future if this system were to be
67
+ # extensible to something like multi-tenancy, in which case we'd tie
68
+ # it to something like a Site or Org.
69
+ models.UniqueConstraint(
70
+ fields=["key"],
71
+ name="oel_publishing_lp_uniq_key",
72
+ )
73
+ ]
74
+ verbose_name = "Learning Package"
75
+ verbose_name_plural = "Learning Packages"
@@ -0,0 +1,106 @@
1
+ """
2
+ PublishLog and PublishLogRecord models
3
+ """
4
+ from django.conf import settings
5
+ from django.db import models
6
+
7
+ from openedx_learning.lib.fields import case_insensitive_char_field, immutable_uuid_field, manual_date_time_field
8
+
9
+ from .learning_package import LearningPackage
10
+ from .publishable_entity import PublishableEntity, PublishableEntityVersion
11
+
12
+
13
+ class PublishLog(models.Model):
14
+ """
15
+ There is one row in this table for every time content is published.
16
+
17
+ Each PublishLog has 0 or more PublishLogRecords describing exactly which
18
+ PublishableEntites were published and what the version changes are. A
19
+ PublishLog is like a git commit in that sense, with individual
20
+ PublishLogRecords representing the files changed.
21
+
22
+ Open question: Empty publishes are allowed at this time, and might be useful
23
+ for "fake" publishes that are necessary to invoke other post-publish
24
+ actions. It's not clear at this point how useful this will actually be.
25
+
26
+ The absence of a ``version_num`` field in this model is intentional, because
27
+ having one would potentially cause write contention/locking issues when
28
+ there are many people working on different entities in a very large library.
29
+ We already see some contention issues occuring in ModuleStore for courses,
30
+ and we want to support Libraries that are far larger.
31
+
32
+ If you need a LearningPackage-wide indicator for version and the only thing
33
+ you care about is "has *something* changed?", you can make a foreign key to
34
+ the most recent PublishLog, or use the most recent PublishLog's primary key.
35
+ This should be monotonically increasing, though there will be large gaps in
36
+ values, e.g. (5, 190, 1291, etc.). Be warned that this value will not port
37
+ across sites. If you need site-portability, the UUIDs for this model are a
38
+ safer bet, though there's a lot about import/export that we haven't fully
39
+ mapped out yet.
40
+ """
41
+
42
+ uuid = immutable_uuid_field()
43
+ learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
44
+ message = case_insensitive_char_field(max_length=500, blank=True, default="")
45
+ published_at = manual_date_time_field()
46
+ published_by = models.ForeignKey(
47
+ settings.AUTH_USER_MODEL,
48
+ on_delete=models.SET_NULL,
49
+ null=True,
50
+ blank=True,
51
+ )
52
+
53
+ class Meta:
54
+ verbose_name = "Publish Log"
55
+ verbose_name_plural = "Publish Logs"
56
+
57
+
58
+ class PublishLogRecord(models.Model):
59
+ """
60
+ A record for each publishable entity version changed, for each publish.
61
+
62
+ To revert a publish, we would make a new publish that swaps ``old_version``
63
+ and ``new_version`` field values.
64
+ """
65
+
66
+ publish_log = models.ForeignKey(
67
+ PublishLog,
68
+ on_delete=models.CASCADE,
69
+ related_name="records",
70
+ )
71
+ entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT)
72
+ old_version = models.ForeignKey(
73
+ PublishableEntityVersion,
74
+ on_delete=models.RESTRICT,
75
+ null=True,
76
+ blank=True,
77
+ related_name="+",
78
+ )
79
+ new_version = models.ForeignKey(
80
+ PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True
81
+ )
82
+
83
+ class Meta:
84
+ constraints = [
85
+ # A Publishable can have only one PublishLogRecord per PublishLog.
86
+ # You can't simultaneously publish two different versions of the
87
+ # same publishable.
88
+ models.UniqueConstraint(
89
+ fields=[
90
+ "publish_log",
91
+ "entity",
92
+ ],
93
+ name="oel_plr_uniq_pl_publishable",
94
+ )
95
+ ]
96
+ indexes = [
97
+ # Publishable (reverse) Publish Log Index:
98
+ # * Find the history of publishes for a given Publishable,
99
+ # starting with the most recent (since IDs are ascending ints).
100
+ models.Index(
101
+ fields=["entity", "-publish_log"],
102
+ name="oel_plr_idx_entity_rplr",
103
+ ),
104
+ ]
105
+ verbose_name = "Publish Log Record"
106
+ verbose_name_plural = "Publish Log Records"