openedx-learning 0.29.1__py2.py3-none-any.whl → 0.30.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/apps/authoring/publishing/admin.py +110 -12
- openedx_learning/apps/authoring/publishing/api.py +676 -200
- openedx_learning/apps/authoring/publishing/migrations/0009_dependencies_and_hashing.py +62 -0
- openedx_learning/apps/authoring/publishing/migrations/0010_backfill_dependencies.py +149 -0
- openedx_learning/apps/authoring/publishing/models/__init__.py +2 -1
- openedx_learning/apps/authoring/publishing/models/draft_log.py +77 -1
- openedx_learning/apps/authoring/publishing/models/entity_list.py +19 -0
- openedx_learning/apps/authoring/publishing/models/publish_log.py +87 -1
- openedx_learning/apps/authoring/publishing/models/publishable_entity.py +48 -0
- openedx_learning/apps/authoring/units/api.py +1 -1
- openedx_learning/lib/fields.py +13 -11
- {openedx_learning-0.29.1.dist-info → openedx_learning-0.30.0.dist-info}/METADATA +6 -6
- {openedx_learning-0.29.1.dist-info → openedx_learning-0.30.0.dist-info}/RECORD +17 -15
- {openedx_learning-0.29.1.dist-info → openedx_learning-0.30.0.dist-info}/WHEEL +0 -0
- {openedx_learning-0.29.1.dist-info → openedx_learning-0.30.0.dist-info}/licenses/LICENSE.txt +0 -0
- {openedx_learning-0.29.1.dist-info → openedx_learning-0.30.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Generated by Django 5.2.7 on 2025-10-29 17:59
|
|
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', '0008_alter_draftchangelogrecord_options_and_more'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='draft',
|
|
16
|
+
name='draft_log_record',
|
|
17
|
+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='oel_publishing.draftchangelogrecord'),
|
|
18
|
+
),
|
|
19
|
+
migrations.AddField(
|
|
20
|
+
model_name='draftchangelogrecord',
|
|
21
|
+
name='dependencies_hash_digest',
|
|
22
|
+
field=models.CharField(blank=True, default='', editable=False, max_length=8),
|
|
23
|
+
),
|
|
24
|
+
migrations.AddField(
|
|
25
|
+
model_name='publishlogrecord',
|
|
26
|
+
name='dependencies_hash_digest',
|
|
27
|
+
field=models.CharField(blank=True, default='', editable=False, max_length=8),
|
|
28
|
+
),
|
|
29
|
+
migrations.CreateModel(
|
|
30
|
+
name='PublishableEntityVersionDependency',
|
|
31
|
+
fields=[
|
|
32
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
33
|
+
('referenced_entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')),
|
|
34
|
+
('referring_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.publishableentityversion')),
|
|
35
|
+
],
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name='publishableentityversion',
|
|
39
|
+
name='dependencies',
|
|
40
|
+
field=models.ManyToManyField(related_name='affects', through='oel_publishing.PublishableEntityVersionDependency', to='oel_publishing.publishableentity'),
|
|
41
|
+
),
|
|
42
|
+
migrations.CreateModel(
|
|
43
|
+
name='PublishSideEffect',
|
|
44
|
+
fields=[
|
|
45
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
46
|
+
('cause', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='causes', to='oel_publishing.publishlogrecord')),
|
|
47
|
+
('effect', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='affected_by', to='oel_publishing.publishlogrecord')),
|
|
48
|
+
],
|
|
49
|
+
options={
|
|
50
|
+
'verbose_name': 'Publish Side Effect',
|
|
51
|
+
'verbose_name_plural': 'Publish Side Effects',
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
migrations.AddConstraint(
|
|
55
|
+
model_name='publishableentityversiondependency',
|
|
56
|
+
constraint=models.UniqueConstraint(fields=('referring_version', 'referenced_entity'), name='oel_pevd_uniq_rv_re'),
|
|
57
|
+
),
|
|
58
|
+
migrations.AddConstraint(
|
|
59
|
+
model_name='publishsideeffect',
|
|
60
|
+
constraint=models.UniqueConstraint(fields=('cause', 'effect'), name='oel_pub_pse_uniq_c_e'),
|
|
61
|
+
),
|
|
62
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backfill PublishableEntityVersionDependency entries based on ContainerVersions.
|
|
3
|
+
|
|
4
|
+
We're introducing a lower-level publishing concept of a dependency that will be
|
|
5
|
+
used by Containers, but this means we have to backfill that dependency info for
|
|
6
|
+
existing Containers in the system.
|
|
7
|
+
"""
|
|
8
|
+
from django.db import migrations
|
|
9
|
+
from django.db.models import F
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_backfill(apps, schema_editor):
|
|
13
|
+
"""
|
|
14
|
+
Create dependency entries and update dep hashes for Draft and Published.
|
|
15
|
+
"""
|
|
16
|
+
_create_dependencies(apps)
|
|
17
|
+
_update_drafts(apps)
|
|
18
|
+
_update_draft_dependencies_hashes(apps)
|
|
19
|
+
_update_published_dependencies_hashes(apps)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _create_dependencies(apps):
|
|
23
|
+
"""
|
|
24
|
+
Populate the PublishableEntityVersion.dependencies relation.
|
|
25
|
+
|
|
26
|
+
The only ones we should have in the system at this point are the ones from
|
|
27
|
+
containers, so we query ContainerVersion for that.
|
|
28
|
+
"""
|
|
29
|
+
PublishableEntityVersionDependency = apps.get_model(
|
|
30
|
+
"oel_publishing", "PublishableEntityVersionDependency"
|
|
31
|
+
)
|
|
32
|
+
ContainerVersion = apps.get_model("oel_publishing", "ContainerVersion")
|
|
33
|
+
|
|
34
|
+
for container_version in ContainerVersion.objects.all():
|
|
35
|
+
# child_entity_ids is a set to de-dupe. This doesn't handle pinned
|
|
36
|
+
# child references yet, but you can't actually make those in a real
|
|
37
|
+
# library yet, so we shouldn't have that data lying around to migrate.
|
|
38
|
+
child_entity_ids = set(
|
|
39
|
+
container_version
|
|
40
|
+
.entity_list
|
|
41
|
+
.entitylistrow_set
|
|
42
|
+
.all()
|
|
43
|
+
.values_list("entity_id", flat=True)
|
|
44
|
+
)
|
|
45
|
+
PublishableEntityVersionDependency.objects.bulk_create(
|
|
46
|
+
[
|
|
47
|
+
PublishableEntityVersionDependency(
|
|
48
|
+
referring_version_id=container_version.pk,
|
|
49
|
+
referenced_entity_id=entity_id
|
|
50
|
+
)
|
|
51
|
+
for entity_id in child_entity_ids
|
|
52
|
+
],
|
|
53
|
+
ignore_conflicts=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _update_drafts(apps):
|
|
58
|
+
"""
|
|
59
|
+
Update Draft entries to point to their most recent DraftLogRecord.
|
|
60
|
+
|
|
61
|
+
This is slow and expensive.
|
|
62
|
+
"""
|
|
63
|
+
Draft = apps.get_model("oel_publishing", "Draft")
|
|
64
|
+
DraftChangeLogRecord = apps.get_model("oel_publishing", "DraftChangeLogRecord")
|
|
65
|
+
for draft in Draft.objects.all():
|
|
66
|
+
draft_log_record = (
|
|
67
|
+
# Find the most recent DraftChangeLogRecord related to this Draft,
|
|
68
|
+
DraftChangeLogRecord.objects
|
|
69
|
+
.filter(entity_id=draft.pk)
|
|
70
|
+
.order_by('-pk')
|
|
71
|
+
.first()
|
|
72
|
+
)
|
|
73
|
+
draft.draft_log_record = draft_log_record
|
|
74
|
+
draft.save()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _update_draft_dependencies_hashes(apps):
|
|
78
|
+
"""
|
|
79
|
+
Update the dependency_hash_digest for all DraftChangeLogRecords.
|
|
80
|
+
|
|
81
|
+
Backfill dependency state hashes. The important thing here is that things
|
|
82
|
+
without dependencies will have the default (blank) state hash, so we only
|
|
83
|
+
need to query for Draft entries for Containers.
|
|
84
|
+
|
|
85
|
+
We are only backfilling the current DraftChangeLogRecords pointed to by the
|
|
86
|
+
Draft entries now. We are not backfilling all historical
|
|
87
|
+
DraftChangeLogRecords. Full historical reconstruction is probably possible,
|
|
88
|
+
but it's not really worth the cost and complexity.
|
|
89
|
+
"""
|
|
90
|
+
from ..api import update_dependencies_hash_digests_for_log
|
|
91
|
+
from ..models import DraftChangeLog
|
|
92
|
+
|
|
93
|
+
# All DraftChangeLogs that have records that are pointed to by the current
|
|
94
|
+
# Draft and have a possibility of having dependencies.
|
|
95
|
+
change_logs = DraftChangeLog.objects.filter(
|
|
96
|
+
pk=F('records__entity__draft__draft_log_record__draft_change_log'),
|
|
97
|
+
records__entity__draft__version__isnull=False,
|
|
98
|
+
records__entity__container__isnull=False,
|
|
99
|
+
).distinct()
|
|
100
|
+
for change_log in change_logs:
|
|
101
|
+
update_dependencies_hash_digests_for_log(change_log, backfill=True)
|
|
102
|
+
|
|
103
|
+
def _update_published_dependencies_hashes(apps):
|
|
104
|
+
"""
|
|
105
|
+
Update all container Published.dependencies_hash_digest
|
|
106
|
+
|
|
107
|
+
Backfill dependency state hashes. The important thing here is that things
|
|
108
|
+
without dependencies will have the default (blank) state hash, so we only
|
|
109
|
+
need to query for Published entries for Containers.
|
|
110
|
+
"""
|
|
111
|
+
from ..api import update_dependencies_hash_digests_for_log
|
|
112
|
+
from ..models import PublishLog
|
|
113
|
+
|
|
114
|
+
# All PublishLogs that have records that are pointed to by the current
|
|
115
|
+
# Published and have a possibility of having dependencies.
|
|
116
|
+
change_logs = PublishLog.objects.filter(
|
|
117
|
+
pk=F('records__entity__published__publish_log_record__publish_log'),
|
|
118
|
+
records__entity__published__version__isnull=False,
|
|
119
|
+
records__entity__container__isnull=False,
|
|
120
|
+
).distinct()
|
|
121
|
+
for change_log in change_logs:
|
|
122
|
+
update_dependencies_hash_digests_for_log(change_log, backfill=True)
|
|
123
|
+
|
|
124
|
+
def remove_backfill(apps, schema_editor):
|
|
125
|
+
"""
|
|
126
|
+
Reset all dep hash values to default ('') and remove dependencies.
|
|
127
|
+
"""
|
|
128
|
+
Draft = apps.get_model("oel_publishing", "Draft")
|
|
129
|
+
DraftChangeLogRecord = apps.get_model("oel_publishing", "DraftChangeLogRecord")
|
|
130
|
+
PublishLogRecord = apps.get_model("oel_publishing", "PublishLogRecord")
|
|
131
|
+
PublishableEntityVersionDependency = apps.get_model(
|
|
132
|
+
"oel_publishing", "PublishableEntityVersionDependency"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
PublishLogRecord.objects.all().update(dependencies_hash_digest='')
|
|
136
|
+
DraftChangeLogRecord.objects.all().update(dependencies_hash_digest='')
|
|
137
|
+
PublishableEntityVersionDependency.objects.all().delete()
|
|
138
|
+
Draft.objects.all().update(draft_log_record=None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Migration(migrations.Migration):
|
|
142
|
+
|
|
143
|
+
dependencies = [
|
|
144
|
+
('oel_publishing', '0009_dependencies_and_hashing'),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
operations = [
|
|
148
|
+
migrations.RunPython(create_backfill, reverse_code=remove_backfill)
|
|
149
|
+
]
|
|
@@ -17,11 +17,12 @@ from .container import Container, ContainerVersion
|
|
|
17
17
|
from .draft_log import Draft, DraftChangeLog, DraftChangeLogRecord, DraftSideEffect
|
|
18
18
|
from .entity_list import EntityList, EntityListRow
|
|
19
19
|
from .learning_package import LearningPackage
|
|
20
|
-
from .publish_log import Published, PublishLog, PublishLogRecord
|
|
20
|
+
from .publish_log import Published, PublishLog, PublishLogRecord, PublishSideEffect
|
|
21
21
|
from .publishable_entity import (
|
|
22
22
|
PublishableContentModelRegistry,
|
|
23
23
|
PublishableEntity,
|
|
24
24
|
PublishableEntityMixin,
|
|
25
25
|
PublishableEntityVersion,
|
|
26
|
+
PublishableEntityVersionDependency,
|
|
26
27
|
PublishableEntityVersionMixin,
|
|
27
28
|
)
|
|
@@ -3,9 +3,10 @@ Models relating to the creation and management of Draft information.
|
|
|
3
3
|
"""
|
|
4
4
|
from django.conf import settings
|
|
5
5
|
from django.db import models
|
|
6
|
+
from django.db.models import F, Q
|
|
6
7
|
from django.utils.translation import gettext_lazy as _
|
|
7
8
|
|
|
8
|
-
from openedx_learning.lib.fields import immutable_uuid_field, manual_date_time_field
|
|
9
|
+
from openedx_learning.lib.fields import hash_field, immutable_uuid_field, manual_date_time_field
|
|
9
10
|
|
|
10
11
|
from .learning_package import LearningPackage
|
|
11
12
|
from .publishable_entity import PublishableEntity, PublishableEntityVersion
|
|
@@ -55,6 +56,60 @@ class Draft(models.Model):
|
|
|
55
56
|
null=True,
|
|
56
57
|
blank=True,
|
|
57
58
|
)
|
|
59
|
+
# Note: this is actually a 1:1 relation in practice, but I'm keeping the
|
|
60
|
+
# definition more consistent with the Published model, which has an fkey
|
|
61
|
+
# to PublishLogRecord. Unlike PublishLogRecord, this fkey is a late
|
|
62
|
+
# addition to this data model, so we have to allow null values for the
|
|
63
|
+
# initial migration. But making this nullable also has another advantage,
|
|
64
|
+
# in that it allows us to set the draft_log_record to the most recent change
|
|
65
|
+
# while inside a bulk_draft_changes_for operation, and then delete that log
|
|
66
|
+
# record if it is undone in the same bulk operation.
|
|
67
|
+
draft_log_record = models.ForeignKey(
|
|
68
|
+
"DraftChangeLogRecord",
|
|
69
|
+
on_delete=models.SET_NULL,
|
|
70
|
+
null=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def log_record(self):
|
|
75
|
+
return self.draft_log_record
|
|
76
|
+
|
|
77
|
+
class DraftQuerySet(models.QuerySet):
|
|
78
|
+
"""
|
|
79
|
+
Custom QuerySet/Manager so we can chain common queries.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def with_unpublished_changes(self):
|
|
83
|
+
"""
|
|
84
|
+
Drafts with versions that are different from what is Published.
|
|
85
|
+
|
|
86
|
+
This will not return Drafts that have unpublished changes in their
|
|
87
|
+
dependencies. Example: A Unit is published with a Component as one
|
|
88
|
+
of its child. Then someone modifies the draft of the Component. If
|
|
89
|
+
both the Unit and the Component Drafts were part of the queryset,
|
|
90
|
+
this method would return only the changed Component, and not the
|
|
91
|
+
Unit. (We can add this as an optional flag later if we want.)
|
|
92
|
+
"""
|
|
93
|
+
return (
|
|
94
|
+
self.select_related("entity__published__version")
|
|
95
|
+
|
|
96
|
+
# Exclude where draft and published versions are the same
|
|
97
|
+
.exclude(entity__published__version_id=F("version_id"))
|
|
98
|
+
|
|
99
|
+
# Account for soft-deletes:
|
|
100
|
+
# NULL != NULL in SQL, so simply excluding entities where
|
|
101
|
+
# the Draft and Published versions match will not catch the
|
|
102
|
+
# case where a soft-delete has been published (i.e. both the
|
|
103
|
+
# Draft and Published versions are NULL). We need to
|
|
104
|
+
# explicitly check for that case instead, or else we will
|
|
105
|
+
# re-publish the same soft-deletes over and over again.
|
|
106
|
+
.exclude(
|
|
107
|
+
Q(version__isnull=True) &
|
|
108
|
+
Q(entity__published__version__isnull=True)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
objects = DraftQuerySet.as_manager()
|
|
58
113
|
|
|
59
114
|
|
|
60
115
|
class DraftChangeLog(models.Model):
|
|
@@ -202,6 +257,22 @@ class DraftChangeLogRecord(models.Model):
|
|
|
202
257
|
PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True
|
|
203
258
|
)
|
|
204
259
|
|
|
260
|
+
# The dependencies_hash_digest is used when the version alone isn't enough
|
|
261
|
+
# to let us know the full draft state of an entity. This happens any time a
|
|
262
|
+
# Draft version has dependencies (see the PublishableEntityVersionDependency
|
|
263
|
+
# model), because changes in those dependencies will cause changes to the
|
|
264
|
+
# state of the Draft. The main example of this is containers, where changing
|
|
265
|
+
# an unpinned child affects the state of the parent container, even if that
|
|
266
|
+
# container's definition (and thus version) does not change.
|
|
267
|
+
#
|
|
268
|
+
# If a Draft has no dependencies, then its entire state is captured by its
|
|
269
|
+
# version, and the dependencies_hash_digest is blank. (Blank is slightly
|
|
270
|
+
# more convenient for database comparisons than NULL.)
|
|
271
|
+
#
|
|
272
|
+
# Note: There is an equivalent of this field in the Published model and the
|
|
273
|
+
# the values may drift away from each other.
|
|
274
|
+
dependencies_hash_digest = hash_field(blank=True, default='', max_length=8)
|
|
275
|
+
|
|
205
276
|
class Meta:
|
|
206
277
|
constraints = [
|
|
207
278
|
# A PublishableEntity can have only one DraftLogRecord per DraftLog.
|
|
@@ -228,6 +299,11 @@ class DraftChangeLogRecord(models.Model):
|
|
|
228
299
|
verbose_name = _("Draft Change Log Record")
|
|
229
300
|
verbose_name_plural = _("Draft Change Log Records")
|
|
230
301
|
|
|
302
|
+
def __str__(self):
|
|
303
|
+
old_version_num = None if self.old_version is None else self.old_version.version_num
|
|
304
|
+
new_version_num = None if self.new_version is None else self.new_version.version_num
|
|
305
|
+
return f"DraftChangeLogRecord: {self.entity} ({old_version_num} -> {new_version_num})"
|
|
306
|
+
|
|
231
307
|
|
|
232
308
|
class DraftSideEffect(models.Model):
|
|
233
309
|
"""
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Entity List models
|
|
3
3
|
"""
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
|
|
4
6
|
from django.db import models
|
|
5
7
|
|
|
6
8
|
from .publishable_entity import PublishableEntity, PublishableEntityVersion
|
|
@@ -16,6 +18,17 @@ class EntityList(models.Model):
|
|
|
16
18
|
anonymous in a sense–they're pointed to by ContainerVersions and
|
|
17
19
|
other models, rather than being looked up by their own identifiers.
|
|
18
20
|
"""
|
|
21
|
+
@cached_property
|
|
22
|
+
def rows(self):
|
|
23
|
+
"""
|
|
24
|
+
Convenience method to iterate rows.
|
|
25
|
+
|
|
26
|
+
I'd normally make this the reverse lookup name for the EntityListRow ->
|
|
27
|
+
EntityList foreign key relation, but we already have references to
|
|
28
|
+
entitylistrow_set in various places, and I thought this would be better
|
|
29
|
+
than breaking compatibility.
|
|
30
|
+
"""
|
|
31
|
+
return self.entitylistrow_set.order_by("order_num")
|
|
19
32
|
|
|
20
33
|
|
|
21
34
|
class EntityListRow(models.Model):
|
|
@@ -59,6 +72,12 @@ class EntityListRow(models.Model):
|
|
|
59
72
|
related_name="+", # Do we need the reverse relation?
|
|
60
73
|
)
|
|
61
74
|
|
|
75
|
+
def is_pinned(self):
|
|
76
|
+
return self.entity_version_id is not None
|
|
77
|
+
|
|
78
|
+
def is_unpinned(self):
|
|
79
|
+
return self.entity_version_id is None
|
|
80
|
+
|
|
62
81
|
class Meta:
|
|
63
82
|
ordering = ["order_num"]
|
|
64
83
|
constraints = [
|
|
@@ -3,8 +3,14 @@ PublishLog and PublishLogRecord models
|
|
|
3
3
|
"""
|
|
4
4
|
from django.conf import settings
|
|
5
5
|
from django.db import models
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
7
|
|
|
7
|
-
from openedx_learning.lib.fields import
|
|
8
|
+
from openedx_learning.lib.fields import (
|
|
9
|
+
case_insensitive_char_field,
|
|
10
|
+
hash_field,
|
|
11
|
+
immutable_uuid_field,
|
|
12
|
+
manual_date_time_field,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
from .learning_package import LearningPackage
|
|
10
16
|
from .publishable_entity import PublishableEntity, PublishableEntityVersion
|
|
@@ -61,6 +67,14 @@ class PublishLogRecord(models.Model):
|
|
|
61
67
|
|
|
62
68
|
To revert a publish, we would make a new publish that swaps ``old_version``
|
|
63
69
|
and ``new_version`` field values.
|
|
70
|
+
|
|
71
|
+
If the old_version and new_version of a PublishLogRecord match, it means
|
|
72
|
+
that the definition of the entity itself did not change (i.e. no new
|
|
73
|
+
PublishableEntityVersion was created), but something else was published that
|
|
74
|
+
had the side-effect of changing the published state of this entity. For
|
|
75
|
+
instance, if a Unit has unpinned references to its child Components (which
|
|
76
|
+
it almost always will), then publishing one of those Components will alter
|
|
77
|
+
the published state of the Unit, even if the UnitVersion does not change.
|
|
64
78
|
"""
|
|
65
79
|
|
|
66
80
|
publish_log = models.ForeignKey(
|
|
@@ -80,6 +94,23 @@ class PublishLogRecord(models.Model):
|
|
|
80
94
|
PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True
|
|
81
95
|
)
|
|
82
96
|
|
|
97
|
+
# The dependencies_hash_digest is used when the version alone isn't enough
|
|
98
|
+
# to let us know the full draft state of an entity. This happens any time a
|
|
99
|
+
# Published version has dependencies (see the
|
|
100
|
+
# PublishableEntityVersionDependency model), because changes in those
|
|
101
|
+
# dependencies will cause changes to the state of the Draft. The main
|
|
102
|
+
# example of this is containers, where changing an unpinned child affects
|
|
103
|
+
# the state of the parent container, even if that container's definition
|
|
104
|
+
# (and thus version) does not change.
|
|
105
|
+
#
|
|
106
|
+
# If a Published version has no dependencies, then its entire state is
|
|
107
|
+
# captured by its version, and the dependencies_hash_digest is blank. (Blank
|
|
108
|
+
# is slightly more convenient for database comparisons than NULL.)
|
|
109
|
+
#
|
|
110
|
+
# Note: There is an equivalent of this field in the Draft model and the
|
|
111
|
+
# the values may drift away from each other.
|
|
112
|
+
dependencies_hash_digest = hash_field(blank=True, default='', max_length=8)
|
|
113
|
+
|
|
83
114
|
class Meta:
|
|
84
115
|
constraints = [
|
|
85
116
|
# A Publishable can have only one PublishLogRecord per PublishLog.
|
|
@@ -105,6 +136,11 @@ class PublishLogRecord(models.Model):
|
|
|
105
136
|
verbose_name = "Publish Log Record"
|
|
106
137
|
verbose_name_plural = "Publish Log Records"
|
|
107
138
|
|
|
139
|
+
def __str__(self):
|
|
140
|
+
old_version_num = None if self.old_version is None else self.old_version.version_num
|
|
141
|
+
new_version_num = None if self.new_version is None else self.new_version.version_num
|
|
142
|
+
return f"PublishLogRecord: {self.entity} ({old_version_num} -> {new_version_num})"
|
|
143
|
+
|
|
108
144
|
|
|
109
145
|
class Published(models.Model):
|
|
110
146
|
"""
|
|
@@ -145,6 +181,56 @@ class Published(models.Model):
|
|
|
145
181
|
on_delete=models.RESTRICT,
|
|
146
182
|
)
|
|
147
183
|
|
|
184
|
+
@property
|
|
185
|
+
def log_record(self):
|
|
186
|
+
return self.publish_log_record
|
|
187
|
+
|
|
148
188
|
class Meta:
|
|
149
189
|
verbose_name = "Published Entity"
|
|
150
190
|
verbose_name_plural = "Published Entities"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PublishSideEffect(models.Model):
|
|
194
|
+
"""
|
|
195
|
+
Model to track when a change in one Published entity affects others.
|
|
196
|
+
|
|
197
|
+
Our first use case for this is that changes involving child components are
|
|
198
|
+
thought to affect parent Units, even if the parent's version doesn't change.
|
|
199
|
+
|
|
200
|
+
Side-effects are recorded in a collapsed form that only captures one level.
|
|
201
|
+
So if Components C1 and C2 are both published and they are part of Unit U1,
|
|
202
|
+
which is in turn a part of Subsection SS1, then the PublishSideEffect
|
|
203
|
+
entries are::
|
|
204
|
+
|
|
205
|
+
(C1, U1)
|
|
206
|
+
(C2, U1)
|
|
207
|
+
(U1, SS1)
|
|
208
|
+
|
|
209
|
+
We do not keep entries for (C1, SS1) or (C2, SS1). This is to make the model
|
|
210
|
+
simpler, so we don't have to differentiate between direct side-effects and
|
|
211
|
+
transitive side-effects in the model.
|
|
212
|
+
|
|
213
|
+
.. no_pii:
|
|
214
|
+
"""
|
|
215
|
+
cause = models.ForeignKey(
|
|
216
|
+
PublishLogRecord,
|
|
217
|
+
on_delete=models.RESTRICT,
|
|
218
|
+
related_name='causes',
|
|
219
|
+
)
|
|
220
|
+
effect = models.ForeignKey(
|
|
221
|
+
PublishLogRecord,
|
|
222
|
+
on_delete=models.RESTRICT,
|
|
223
|
+
related_name='affected_by',
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
class Meta:
|
|
227
|
+
constraints = [
|
|
228
|
+
# Duplicate entries for cause & effect are just redundant. This is
|
|
229
|
+
# here to guard against weird bugs that might introduce this state.
|
|
230
|
+
models.UniqueConstraint(
|
|
231
|
+
fields=["cause", "effect"],
|
|
232
|
+
name="oel_pub_pse_uniq_c_e",
|
|
233
|
+
)
|
|
234
|
+
]
|
|
235
|
+
verbose_name = _("Publish Side Effect")
|
|
236
|
+
verbose_name_plural = _("Publish Side Effects")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
PublishableEntity model and PublishableEntityVersion + mixins
|
|
3
3
|
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
from datetime import datetime
|
|
5
7
|
from functools import cached_property
|
|
6
8
|
from typing import ClassVar, Self
|
|
@@ -185,6 +187,8 @@ class PublishableEntityVersion(models.Model):
|
|
|
185
187
|
connect them using a OneToOneField with primary_key=True. The easiest way to
|
|
186
188
|
do this is to inherit from PublishableEntityVersionMixin. Be sure to treat
|
|
187
189
|
these versioned models in your app as immutable as well.
|
|
190
|
+
|
|
191
|
+
.. no_pii
|
|
188
192
|
"""
|
|
189
193
|
|
|
190
194
|
uuid = immutable_uuid_field()
|
|
@@ -221,6 +225,14 @@ class PublishableEntityVersion(models.Model):
|
|
|
221
225
|
blank=True,
|
|
222
226
|
)
|
|
223
227
|
|
|
228
|
+
dependencies: models.ManyToManyField[
|
|
229
|
+
PublishableEntity, PublishableEntityVersionDependency
|
|
230
|
+
] = models.ManyToManyField(
|
|
231
|
+
PublishableEntity,
|
|
232
|
+
through="PublishableEntityVersionDependency",
|
|
233
|
+
related_name="affects",
|
|
234
|
+
)
|
|
235
|
+
|
|
224
236
|
def __str__(self):
|
|
225
237
|
return f"{self.entity.key} @ v{self.version_num} - {self.title}"
|
|
226
238
|
|
|
@@ -265,6 +277,42 @@ class PublishableEntityVersion(models.Model):
|
|
|
265
277
|
verbose_name_plural = "Publishable Entity Versions"
|
|
266
278
|
|
|
267
279
|
|
|
280
|
+
class PublishableEntityVersionDependency(models.Model):
|
|
281
|
+
"""
|
|
282
|
+
Track the PublishableEntities that a PublishableEntityVersion depends on.
|
|
283
|
+
|
|
284
|
+
For example, a partcular version of a Unit (U1.v1) might be defined to have
|
|
285
|
+
unpinned references to Components C1 and C2. That means that any changes in
|
|
286
|
+
C1 or C2 will affect U1.v1 via DraftSideEffects and PublishedSideEffects. We
|
|
287
|
+
say that C1 and C2 are dependencies of U1.v1.
|
|
288
|
+
|
|
289
|
+
An important restriction is that a PublishableEntityVersion's list of
|
|
290
|
+
dependencies are defined when the version is created. It is not modified
|
|
291
|
+
after that. No matter what happens to C1 or C2 (e.g. edit, deletion,
|
|
292
|
+
un-deletion, reset-draft-version-to-published), they will always be
|
|
293
|
+
dependencies of U1.v1.
|
|
294
|
+
|
|
295
|
+
If someone removes C2 from U1, then that requires creating a new version of
|
|
296
|
+
U1 (so U1.v2).
|
|
297
|
+
|
|
298
|
+
This restriction is important because our ability to calculate and cache the
|
|
299
|
+
state of "this version of this publishable entity and all its dependencies
|
|
300
|
+
(children)" relies on this being true.
|
|
301
|
+
|
|
302
|
+
.. no_pii
|
|
303
|
+
"""
|
|
304
|
+
referring_version = models.ForeignKey(PublishableEntityVersion, on_delete=models.CASCADE)
|
|
305
|
+
referenced_entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT)
|
|
306
|
+
|
|
307
|
+
class Meta:
|
|
308
|
+
constraints = [
|
|
309
|
+
models.UniqueConstraint(
|
|
310
|
+
fields=["referring_version", "referenced_entity"],
|
|
311
|
+
name="oel_pevd_uniq_rv_re",
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
|
|
268
316
|
class PublishableEntityMixin(models.Model):
|
|
269
317
|
"""
|
|
270
318
|
Convenience mixin to link your models against PublishableEntity.
|
|
@@ -194,7 +194,7 @@ def create_unit_and_version(
|
|
|
194
194
|
can_stand_alone: Set to False when created as part of containers
|
|
195
195
|
"""
|
|
196
196
|
entity_rows = _pub_entities_for_components(components)
|
|
197
|
-
with atomic():
|
|
197
|
+
with atomic(savepoint=False):
|
|
198
198
|
unit = create_unit(
|
|
199
199
|
learning_package_id,
|
|
200
200
|
key,
|
openedx_learning/lib/fields.py
CHANGED
|
@@ -19,11 +19,12 @@ from .collations import MultiCollationMixin
|
|
|
19
19
|
from .validators import validate_utc_datetime
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def create_hash_digest(data_bytes: bytes) -> str:
|
|
22
|
+
def create_hash_digest(data_bytes: bytes, num_bytes=20) -> str:
|
|
23
23
|
"""
|
|
24
|
-
Create a
|
|
24
|
+
Create a lower-case hex string representation of a hash digest.
|
|
25
25
|
|
|
26
|
-
The hash
|
|
26
|
+
The hash itself is 20-bytes by default, so 40 characters when we return it
|
|
27
|
+
as a hex-encoded string. We use BLAKE2b for the hashing algorithm.
|
|
27
28
|
|
|
28
29
|
DON'T JUST MODIFY THIS HASH BEHAVIOR!!! We use hashing for de-duplication
|
|
29
30
|
purposes. If this hash function ever changes, that deduplication will fail
|
|
@@ -32,7 +33,7 @@ def create_hash_digest(data_bytes: bytes) -> str:
|
|
|
32
33
|
If we want to change this representation one day, we should create a new
|
|
33
34
|
function for that and do the appropriate data migration.
|
|
34
35
|
"""
|
|
35
|
-
return hashlib.blake2b(data_bytes, digest_size=
|
|
36
|
+
return hashlib.blake2b(data_bytes, digest_size=num_bytes).hexdigest()
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def case_insensitive_char_field(**kwargs) -> MultiCollationCharField:
|
|
@@ -123,7 +124,7 @@ def key_field(**kwargs) -> MultiCollationCharField:
|
|
|
123
124
|
return case_sensitive_char_field(max_length=500, blank=False, **kwargs)
|
|
124
125
|
|
|
125
126
|
|
|
126
|
-
def hash_field() -> models.CharField:
|
|
127
|
+
def hash_field(**kwargs) -> models.CharField:
|
|
127
128
|
"""
|
|
128
129
|
Holds a hash digest meant to identify a piece of content.
|
|
129
130
|
|
|
@@ -144,12 +145,13 @@ def hash_field() -> models.CharField:
|
|
|
144
145
|
didn't seem worthwhile, particularly the possibility of case-sensitivity
|
|
145
146
|
related bugs.
|
|
146
147
|
"""
|
|
147
|
-
|
|
148
|
-
max_length
|
|
149
|
-
blank
|
|
150
|
-
null
|
|
151
|
-
editable
|
|
152
|
-
|
|
148
|
+
default_kwargs = {
|
|
149
|
+
"max_length": 40,
|
|
150
|
+
"blank": False,
|
|
151
|
+
"null": False,
|
|
152
|
+
"editable": False,
|
|
153
|
+
}
|
|
154
|
+
return models.CharField(**(default_kwargs | kwargs))
|
|
153
155
|
|
|
154
156
|
|
|
155
157
|
def manual_date_time_field() -> models.DateTimeField:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openedx-learning
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.0
|
|
4
4
|
Summary: Open edX Learning Core and Tagging.
|
|
5
5
|
Home-page: https://github.com/openedx/openedx-learning
|
|
6
6
|
Author: David Ormsbee
|
|
@@ -19,13 +19,13 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
21
|
License-File: LICENSE.txt
|
|
22
|
-
Requires-Dist: rules<4.0
|
|
23
|
-
Requires-Dist: edx-drf-extensions
|
|
24
|
-
Requires-Dist: Django
|
|
25
22
|
Requires-Dist: tomlkit
|
|
26
|
-
Requires-Dist:
|
|
27
|
-
Requires-Dist:
|
|
23
|
+
Requires-Dist: Django
|
|
24
|
+
Requires-Dist: edx-drf-extensions
|
|
28
25
|
Requires-Dist: celery
|
|
26
|
+
Requires-Dist: attrs
|
|
27
|
+
Requires-Dist: djangorestframework<4.0
|
|
28
|
+
Requires-Dist: rules<4.0
|
|
29
29
|
Dynamic: author
|
|
30
30
|
Dynamic: author-email
|
|
31
31
|
Dynamic: classifier
|