openedx-learning 0.22.0__py2.py3-none-any.whl → 0.23.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 +76 -1
- openedx_learning/apps/authoring/publishing/api.py +333 -24
- openedx_learning/apps/authoring/publishing/contextmanagers.py +157 -0
- openedx_learning/apps/authoring/publishing/migrations/0006_draftchangelog.py +68 -0
- openedx_learning/apps/authoring/publishing/migrations/0007_bootstrap_draftchangelog.py +94 -0
- openedx_learning/apps/authoring/publishing/models/__init__.py +2 -2
- openedx_learning/apps/authoring/publishing/models/draft_log.py +315 -0
- openedx_learning/apps/authoring/publishing/models/publish_log.py +44 -0
- {openedx_learning-0.22.0.dist-info → openedx_learning-0.23.0.dist-info}/METADATA +4 -4
- {openedx_learning-0.22.0.dist-info → openedx_learning-0.23.0.dist-info}/RECORD +14 -11
- openedx_learning/apps/authoring/publishing/models/draft_published.py +0 -95
- {openedx_learning-0.22.0.dist-info → openedx_learning-0.23.0.dist-info}/WHEEL +0 -0
- {openedx_learning-0.22.0.dist-info → openedx_learning-0.23.0.dist-info}/licenses/LICENSE.txt +0 -0
- {openedx_learning-0.22.0.dist-info → openedx_learning-0.23.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context Managers for internal use in the publishing app.
|
|
3
|
+
|
|
4
|
+
Do not use this directly outside the publishing app. Use the public API's
|
|
5
|
+
bulk_draft_changes_for instead (which will invoke this internally).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from django.db.transaction import Atomic
|
|
14
|
+
|
|
15
|
+
from .models import DraftChangeLog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DraftChangeLogContext(Atomic):
|
|
19
|
+
"""
|
|
20
|
+
Context manager for batching draft changes into DraftChangeChangeLogs.
|
|
21
|
+
|
|
22
|
+
🛑 This is a PRIVATE implementation. Outside of the publishing app, clients
|
|
23
|
+
should use the bulk_draft_changes_for() API call instead of using this
|
|
24
|
+
manager directly, since this is a bit experimental and the implementation
|
|
25
|
+
may shift around a bit.
|
|
26
|
+
|
|
27
|
+
The main idea is that we want to create a context manager that will keep
|
|
28
|
+
track of what the "active" DraftChangeChangeLogs is for a given
|
|
29
|
+
LearningPackage. The problem is that declaring the context can happen many
|
|
30
|
+
layers higher in the stack than doing the draft changes. For instance,
|
|
31
|
+
imagine::
|
|
32
|
+
|
|
33
|
+
with bulk_draft_changes_for(learning_package.id):
|
|
34
|
+
for section in course:
|
|
35
|
+
update_section_drafts(learning_package_id, section)
|
|
36
|
+
|
|
37
|
+
In this hypothetical code block, update_section_drafts might call a function
|
|
38
|
+
to update sequences, which calls something to update units, etc. Only at the
|
|
39
|
+
very bottom layer of the stack will those code paths actually alter the
|
|
40
|
+
drafts themselves. It would make the API too cumbersome to explicitly pass
|
|
41
|
+
the active DraftChangeChangeLog through every layer. So instead, we use this
|
|
42
|
+
class to essentially keep the active DraftChangeChangeLog in a global
|
|
43
|
+
(ContextVar) so that the publishing API draft-related code can access it
|
|
44
|
+
later.
|
|
45
|
+
|
|
46
|
+
Since it is possible to nest context managers, we keep a list of the
|
|
47
|
+
DraftChangeChangeLogs that have been created and treat it like a stack that
|
|
48
|
+
gets pushed to whenever we __enter__ and popped off whenever we __exit__.
|
|
49
|
+
|
|
50
|
+
DraftChangeLogContext also subclasses Django's Atomic context manager, since
|
|
51
|
+
any operation on multiple Drafts as part of a DraftChangeLog will want to be
|
|
52
|
+
an atomic operation.
|
|
53
|
+
"""
|
|
54
|
+
_draft_change_logs: ContextVar[list | None] = ContextVar('_draft_change_logs', default=None)
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
learning_package_id: int,
|
|
59
|
+
changed_at: datetime | None = None,
|
|
60
|
+
changed_by: int | None = None,
|
|
61
|
+
exit_callbacks: list[Callable[[DraftChangeLog], None]] | None = None
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(using=None, savepoint=False, durable=False)
|
|
64
|
+
|
|
65
|
+
self.learning_package_id = learning_package_id
|
|
66
|
+
self.changed_by = changed_by
|
|
67
|
+
self.changed_at = changed_at or datetime.now(tz=timezone.utc)
|
|
68
|
+
|
|
69
|
+
# This will get properly initialized on __enter__()
|
|
70
|
+
self.draft_change_log = None
|
|
71
|
+
|
|
72
|
+
# We currently use self.exit_callbacks as a way to run parent/child
|
|
73
|
+
# side-effect creation. DraftChangeLogContext itself is a lower-level
|
|
74
|
+
# part of the code that doesn't understand what containers are.
|
|
75
|
+
self.exit_callbacks = exit_callbacks or []
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def get_active_draft_change_log(cls, learning_package_id: int) -> DraftChangeLog | None:
|
|
79
|
+
"""
|
|
80
|
+
Get the DraftChangeLogContext for new DraftChangeLogRecords.
|
|
81
|
+
|
|
82
|
+
This is expected to be called internally by the publishing API when it
|
|
83
|
+
modifies Drafts. If there is no active DraftChangeLog, this method will
|
|
84
|
+
return None, and the caller should create their own DraftChangeLog.
|
|
85
|
+
"""
|
|
86
|
+
draft_change_logs = cls._draft_change_logs.get()
|
|
87
|
+
|
|
88
|
+
# If we've never used this manager...
|
|
89
|
+
if draft_change_logs is None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# Otherwise, find the most recently created DraftChangeLog *that matches
|
|
93
|
+
# the learning_package_id*. This is for two reasons:
|
|
94
|
+
#
|
|
95
|
+
# 1. We might nest operations so that we're working across multiple
|
|
96
|
+
# LearningPackages at once, e.g. copying content from different
|
|
97
|
+
# libraries and importing them into another library.
|
|
98
|
+
# 2. It's a guard in case we somehow got the global state management
|
|
99
|
+
# wrong. We want the failure mode to be "we didn't find the
|
|
100
|
+
# DraftChangeLog you were looking for, so make another one and suffer
|
|
101
|
+
# a performance penalty", as opposed to, "we accidentally gave you a
|
|
102
|
+
# DraftChangeLog for an entirely different LearningPackage, and now
|
|
103
|
+
# your Draft data is corrupted."
|
|
104
|
+
for draft_change_log in reversed(draft_change_logs):
|
|
105
|
+
if draft_change_log.learning_package_id == learning_package_id:
|
|
106
|
+
return draft_change_log
|
|
107
|
+
|
|
108
|
+
# If we got here, then either the list was empty (the manager was used
|
|
109
|
+
# at some point but exited), or none of the DraftChangeLogs are for the
|
|
110
|
+
# correct LearningPackage.
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def __enter__(self):
|
|
114
|
+
"""
|
|
115
|
+
Enter our context.
|
|
116
|
+
|
|
117
|
+
This starts the transaction and sets up our active DraftChangeLog.
|
|
118
|
+
"""
|
|
119
|
+
super().__enter__()
|
|
120
|
+
|
|
121
|
+
self.draft_change_log = DraftChangeLog.objects.create(
|
|
122
|
+
learning_package_id=self.learning_package_id,
|
|
123
|
+
changed_by_id=self.changed_by,
|
|
124
|
+
changed_at=self.changed_at,
|
|
125
|
+
)
|
|
126
|
+
draft_change_sets = self._draft_change_logs.get()
|
|
127
|
+
if not draft_change_sets:
|
|
128
|
+
draft_change_sets = []
|
|
129
|
+
draft_change_sets.append(self.draft_change_log)
|
|
130
|
+
self._draft_change_logs.set(draft_change_sets)
|
|
131
|
+
|
|
132
|
+
return self.draft_change_log
|
|
133
|
+
|
|
134
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
135
|
+
"""
|
|
136
|
+
Exit our context.
|
|
137
|
+
|
|
138
|
+
Pops the active DraftChangeLog off of our stack and run any
|
|
139
|
+
post-processing callbacks needed. This callback mechanism is how child-
|
|
140
|
+
parent side-effects are calculated.
|
|
141
|
+
"""
|
|
142
|
+
draft_change_logs = self._draft_change_logs.get()
|
|
143
|
+
if draft_change_logs:
|
|
144
|
+
draft_change_log = draft_change_logs.pop()
|
|
145
|
+
for exit_callback in self.exit_callbacks:
|
|
146
|
+
exit_callback(draft_change_log)
|
|
147
|
+
|
|
148
|
+
# Edge case: the draft changes that accumulated during our context
|
|
149
|
+
# cancel each other out, and there are no actual
|
|
150
|
+
# DraftChangeLogRecords at the end. In this case, we might as well
|
|
151
|
+
# delete the entire DraftChangeLog.
|
|
152
|
+
if not draft_change_log.records.exists():
|
|
153
|
+
draft_change_log.delete()
|
|
154
|
+
|
|
155
|
+
self._draft_change_logs.set(draft_change_logs)
|
|
156
|
+
|
|
157
|
+
return super().__exit__(exc_type, exc_value, traceback)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Generated by Django 4.2.18 on 2025-03-28 02:21
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import django.db.models.deletion
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.db import migrations, models
|
|
8
|
+
|
|
9
|
+
import openedx_learning.lib.validators
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Migration(migrations.Migration):
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
16
|
+
('oel_publishing', '0005_alter_entitylistrow_options'),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
operations = [
|
|
20
|
+
migrations.CreateModel(
|
|
21
|
+
name='DraftChangeLog',
|
|
22
|
+
fields=[
|
|
23
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
24
|
+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')),
|
|
25
|
+
('changed_at', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
|
|
26
|
+
('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
|
27
|
+
('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')),
|
|
28
|
+
],
|
|
29
|
+
options={
|
|
30
|
+
'verbose_name': 'Draft Change Log',
|
|
31
|
+
'verbose_name_plural': 'Draft Change Logs',
|
|
32
|
+
},
|
|
33
|
+
),
|
|
34
|
+
migrations.CreateModel(
|
|
35
|
+
name='DraftChangeLogRecord',
|
|
36
|
+
fields=[
|
|
37
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
38
|
+
('draft_change_log', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='oel_publishing.draftchangelog')),
|
|
39
|
+
('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')),
|
|
40
|
+
('new_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentityversion')),
|
|
41
|
+
('old_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.publishableentityversion')),
|
|
42
|
+
],
|
|
43
|
+
options={
|
|
44
|
+
'verbose_name': 'Draft Log',
|
|
45
|
+
'verbose_name_plural': 'Draft Log',
|
|
46
|
+
},
|
|
47
|
+
),
|
|
48
|
+
migrations.CreateModel(
|
|
49
|
+
name='DraftSideEffect',
|
|
50
|
+
fields=[
|
|
51
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
52
|
+
('cause', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='causes', to='oel_publishing.draftchangelogrecord')),
|
|
53
|
+
('effect', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='caused_by', to='oel_publishing.draftchangelogrecord')),
|
|
54
|
+
],
|
|
55
|
+
),
|
|
56
|
+
migrations.AddConstraint(
|
|
57
|
+
model_name='draftsideeffect',
|
|
58
|
+
constraint=models.UniqueConstraint(fields=('cause', 'effect'), name='oel_pub_dse_uniq_c_e'),
|
|
59
|
+
),
|
|
60
|
+
migrations.AddIndex(
|
|
61
|
+
model_name='draftchangelogrecord',
|
|
62
|
+
index=models.Index(fields=['entity', '-draft_change_log'], name='oel_dlr_idx_entity_rdcl'),
|
|
63
|
+
),
|
|
64
|
+
migrations.AddConstraint(
|
|
65
|
+
model_name='draftchangelogrecord',
|
|
66
|
+
constraint=models.UniqueConstraint(fields=('draft_change_log', 'entity'), name='oel_dlr_uniq_dcl'),
|
|
67
|
+
),
|
|
68
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bootstrap DraftChangeLogs
|
|
3
|
+
|
|
4
|
+
DraftChangeLog and DraftChangeLogRecord are being introduced after Drafts, so
|
|
5
|
+
we're going to retroactively make entries for all the changes that were in our
|
|
6
|
+
Learning Packages.
|
|
7
|
+
|
|
8
|
+
This migration will try to reconstruct create, edit, reset-to-published, and
|
|
9
|
+
delete operations, but it won't be fully accurate because we only have the
|
|
10
|
+
create dates of the versions and the current state of active Drafts to go on.
|
|
11
|
+
This means we won't accurately capture when things were deleted and then reset,
|
|
12
|
+
or when reset and then later edited. We're also missing the user for a number of
|
|
13
|
+
these operations, so we'll add those with null created_by entries. Addressing
|
|
14
|
+
these gaps is a big part of why we created DraftChangeLogs in the first place.
|
|
15
|
+
"""
|
|
16
|
+
# Generated by Django 4.2.18 on 2025-03-13 10:29
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from django.db import migrations
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bootstrap_draft_change_logs(apps, schema_editor):
|
|
26
|
+
"""
|
|
27
|
+
Create a fake DraftChangeSet that encompasses the state of current Drafts.
|
|
28
|
+
"""
|
|
29
|
+
LearningPackage = apps.get_model("oel_publishing", "LearningPackage")
|
|
30
|
+
PublishableEntityVersion = apps.get_model("oel_publishing", "PublishableEntityVersion")
|
|
31
|
+
|
|
32
|
+
Draft = apps.get_model("oel_publishing", "Draft")
|
|
33
|
+
DraftChangeLogRecord = apps.get_model("oel_publishing", "DraftChangeLogRecord")
|
|
34
|
+
DraftChangeLog = apps.get_model("oel_publishing", "DraftChangeLog")
|
|
35
|
+
now = datetime.now(tz=timezone.utc)
|
|
36
|
+
|
|
37
|
+
for learning_package in LearningPackage.objects.all().order_by("key"):
|
|
38
|
+
logger.info(f"Creating bootstrap DraftChangeLogs for {learning_package.key}")
|
|
39
|
+
pub_ent_versions = PublishableEntityVersion.objects.filter(
|
|
40
|
+
entity__learning_package=learning_package
|
|
41
|
+
).select_related("entity")
|
|
42
|
+
|
|
43
|
+
# First cycle though all the simple create/edit operations...
|
|
44
|
+
last_version_seen = {} # PublishableEntity.id -> PublishableEntityVersion.id
|
|
45
|
+
for pub_ent_version in pub_ent_versions.order_by("pk"):
|
|
46
|
+
draft_change_log = DraftChangeLog.objects.create(
|
|
47
|
+
learning_package=learning_package,
|
|
48
|
+
changed_at=pub_ent_version.created,
|
|
49
|
+
changed_by=pub_ent_version.created_by,
|
|
50
|
+
)
|
|
51
|
+
DraftChangeLogRecord.objects.create(
|
|
52
|
+
draft_change_log=draft_change_log,
|
|
53
|
+
entity=pub_ent_version.entity,
|
|
54
|
+
old_version_id=last_version_seen.get(pub_ent_version.entity.id),
|
|
55
|
+
new_version_id=pub_ent_version.id,
|
|
56
|
+
)
|
|
57
|
+
last_version_seen[pub_ent_version.entity.id] = pub_ent_version.id
|
|
58
|
+
|
|
59
|
+
# Now that we've created change sets for create/edit operations, we look
|
|
60
|
+
# at the latest state of the Draft model in order to determine whether
|
|
61
|
+
# we need to apply deletes or resets.
|
|
62
|
+
for draft in Draft.objects.filter(entity__learning_package=learning_package).order_by("entity_id"):
|
|
63
|
+
last_version_id = last_version_seen.get(draft.entity_id)
|
|
64
|
+
if draft.version_id == last_version_id:
|
|
65
|
+
continue
|
|
66
|
+
# We don't really know who did this or when, so we use None and now.
|
|
67
|
+
draft_change_log = DraftChangeLog.objects.create(
|
|
68
|
+
learning_package=learning_package,
|
|
69
|
+
changed_at=now,
|
|
70
|
+
changed_by=None,
|
|
71
|
+
)
|
|
72
|
+
DraftChangeLogRecord.objects.create(
|
|
73
|
+
draft_change_log=draft_change_log,
|
|
74
|
+
entity_id=draft.entity_id,
|
|
75
|
+
old_version_id=last_version_id,
|
|
76
|
+
new_version_id=draft.version_id,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def delete_draft_change_logs(apps, schema_editor):
|
|
81
|
+
logger.info(f"Deleting all DraftChangeLogs (reverse migration)")
|
|
82
|
+
DraftChangeLog = apps.get_model("oel_publishing", "DraftChangeLog")
|
|
83
|
+
DraftChangeLog.objects.all().delete()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Migration(migrations.Migration):
|
|
87
|
+
|
|
88
|
+
dependencies = [
|
|
89
|
+
('oel_publishing', '0006_draftchangelog'),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
operations = [
|
|
93
|
+
migrations.RunPython(bootstrap_draft_change_logs, reverse_code=delete_draft_change_logs)
|
|
94
|
+
]
|
|
@@ -14,10 +14,10 @@ support the logic for the management of the publishing process:
|
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from .container import Container, ContainerVersion
|
|
17
|
-
from .
|
|
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 PublishLog, PublishLogRecord
|
|
20
|
+
from .publish_log import Published, PublishLog, PublishLogRecord
|
|
21
21
|
from .publishable_entity import (
|
|
22
22
|
PublishableContentModelRegistry,
|
|
23
23
|
PublishableEntity,
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models relating to the creation and management of Draft information.
|
|
3
|
+
"""
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
|
|
8
|
+
from openedx_learning.lib.fields import immutable_uuid_field, manual_date_time_field
|
|
9
|
+
|
|
10
|
+
from .learning_package import LearningPackage
|
|
11
|
+
from .publishable_entity import PublishableEntity, PublishableEntityVersion
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Draft(models.Model):
|
|
15
|
+
"""
|
|
16
|
+
Find the active draft version of an entity (usually most recently created).
|
|
17
|
+
|
|
18
|
+
This model mostly only exists to allow us to join against a bunch of
|
|
19
|
+
PublishableEntity objects at once and get all their latest drafts. You might
|
|
20
|
+
use this together with Published in order to see which Drafts haven't been
|
|
21
|
+
published yet.
|
|
22
|
+
|
|
23
|
+
A Draft entry should be created whenever a new PublishableEntityVersion is
|
|
24
|
+
created. This means there are three possible states:
|
|
25
|
+
|
|
26
|
+
1. No Draft entry for a PublishableEntity: This means a PublishableEntity
|
|
27
|
+
was created, but no PublishableEntityVersion was ever made for it, so
|
|
28
|
+
there was never a Draft version.
|
|
29
|
+
2. A Draft entry exists and points to a PublishableEntityVersion: This is
|
|
30
|
+
the most common state.
|
|
31
|
+
3. A Draft entry exists and points to a null version: This means a version
|
|
32
|
+
used to be the draft, but it's been functionally "deleted". The versions
|
|
33
|
+
still exist in our history, but we're done using it.
|
|
34
|
+
|
|
35
|
+
It would have saved a little space to add this data to the Published model
|
|
36
|
+
(and possibly call the combined model something else). Split Modulestore did
|
|
37
|
+
this with its active_versions table. I keep it separate here to get a better
|
|
38
|
+
separation of lifecycle events: i.e. this table *only* changes when drafts
|
|
39
|
+
are updated, not when publishing happens. The Published model only changes
|
|
40
|
+
when something is published.
|
|
41
|
+
|
|
42
|
+
.. no_pii
|
|
43
|
+
"""
|
|
44
|
+
# If we're removing a PublishableEntity entirely, also remove the Draft
|
|
45
|
+
# entry for it. This isn't a normal operation, but can happen if you're
|
|
46
|
+
# deleting an entire LearningPackage.
|
|
47
|
+
entity = models.OneToOneField(
|
|
48
|
+
PublishableEntity,
|
|
49
|
+
on_delete=models.CASCADE,
|
|
50
|
+
primary_key=True,
|
|
51
|
+
)
|
|
52
|
+
version = models.OneToOneField(
|
|
53
|
+
PublishableEntityVersion,
|
|
54
|
+
on_delete=models.RESTRICT,
|
|
55
|
+
null=True,
|
|
56
|
+
blank=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DraftChangeLog(models.Model):
|
|
61
|
+
"""
|
|
62
|
+
There is one row in this table for every time Drafts are created/modified.
|
|
63
|
+
|
|
64
|
+
There are some operations that affect many Drafts at once, such as
|
|
65
|
+
discarding changes (i.e. reset to the published versions) or doing an
|
|
66
|
+
import. These would be represented by one DraftChangeLog with many
|
|
67
|
+
DraftChangeLogRecords in it--one DraftChangeLogRecord for every
|
|
68
|
+
PublishableEntity that was modified.
|
|
69
|
+
|
|
70
|
+
Even if we're only directly changing the draft version of one
|
|
71
|
+
PublishableEntity, we will get multiple DraftChangeLogRecords if changing
|
|
72
|
+
that entity causes side-effects. See the docstrings for DraftChangeLogRecord
|
|
73
|
+
and DraftSideEffect for more details.
|
|
74
|
+
|
|
75
|
+
.. no_pii:
|
|
76
|
+
"""
|
|
77
|
+
uuid = immutable_uuid_field()
|
|
78
|
+
learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
|
|
79
|
+
changed_at = manual_date_time_field()
|
|
80
|
+
changed_by = models.ForeignKey(
|
|
81
|
+
settings.AUTH_USER_MODEL,
|
|
82
|
+
on_delete=models.SET_NULL,
|
|
83
|
+
null=True,
|
|
84
|
+
blank=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
class Meta:
|
|
88
|
+
verbose_name = _("Draft Change Log")
|
|
89
|
+
verbose_name_plural = _("Draft Change Logs")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DraftChangeLogRecord(models.Model):
|
|
93
|
+
"""
|
|
94
|
+
A single change in the PublishableEntity that Draft points to.
|
|
95
|
+
|
|
96
|
+
Within a single DraftChangeLog, there can be only one DraftChangeLogRecord
|
|
97
|
+
per PublishableEntity. If a PublishableEntity goes from v1 -> v2 and then v2
|
|
98
|
+
-> v3 within the same DraftChangeLog, the expectation is that these will be
|
|
99
|
+
collapsed into one DraftChangeLogRecord that goes from v1 -> v3. A single
|
|
100
|
+
PublishableEntity may have many DraftChangeLogRecords that describe its full
|
|
101
|
+
draft edit history, but each DraftChangeLogRecord will be a part of a
|
|
102
|
+
different DraftChangeLog.
|
|
103
|
+
|
|
104
|
+
New PublishableEntityVersions are created with a monotonically increasing
|
|
105
|
+
version_num for their PublishableEntity. However, knowing that is not enough
|
|
106
|
+
to accurately reconstruct how the Draft changes over time because the Draft
|
|
107
|
+
does not always point to the most recently created PublishableEntityVersion.
|
|
108
|
+
We also have the concept of side-effects, where we consider a
|
|
109
|
+
PublishableEntity to have changed in some way, even if no new version is
|
|
110
|
+
explicitly created.
|
|
111
|
+
|
|
112
|
+
The following scenarios may occur:
|
|
113
|
+
|
|
114
|
+
Scenario 1: old_version is None, new_version.version_num = 1
|
|
115
|
+
|
|
116
|
+
This is the common case when we're creating the first version for editing.
|
|
117
|
+
|
|
118
|
+
Scenario 2: old_version.version_num + 1 == new_version.version_num
|
|
119
|
+
|
|
120
|
+
This is the common case when we've made an edit to something, which
|
|
121
|
+
creates the next version of an entity, which we then point the Draft at.
|
|
122
|
+
|
|
123
|
+
Scenario 3: old_version.version_num >=1, new_version is None
|
|
124
|
+
|
|
125
|
+
This is a soft-deletion. We never actually delete a row from the
|
|
126
|
+
PublishableEntity model, but set its current Draft version to be None
|
|
127
|
+
instead.
|
|
128
|
+
|
|
129
|
+
Scenario 4: old_version.version_num > new_version.version_num
|
|
130
|
+
|
|
131
|
+
This can happen if we "discard changes", meaning that we call
|
|
132
|
+
reset_drafts_to_published(). The versions we created before resetting
|
|
133
|
+
don't get deleted, but the Draft model's pointer to the current version
|
|
134
|
+
has been reset to match the Published model.
|
|
135
|
+
|
|
136
|
+
Scenario 5: old_version.version_num + 1 < new_version.version_num
|
|
137
|
+
|
|
138
|
+
Sometimes we'll have a gap between the two version numbers that is > 1.
|
|
139
|
+
This can happen if we make edits (new versions) after we called
|
|
140
|
+
reset_drafts_to_published. PublishableEntityVersions are created with a
|
|
141
|
+
monotonically incrementing version_num which will continue to go up with
|
|
142
|
+
the next edit, regardless of whether Draft is pointing to the most
|
|
143
|
+
recently created version or not. In terms of (old_version, new version)
|
|
144
|
+
changes, it could look like this:
|
|
145
|
+
|
|
146
|
+
- (None, v1): Initial creation
|
|
147
|
+
- # Publish happens here, so v1 of this PublishableEntity is published.
|
|
148
|
+
- (v1, v2): Normal edit in draft
|
|
149
|
+
- (v2, v3): Normal edit in draft
|
|
150
|
+
- (v3, v1): Reset to published happened here.
|
|
151
|
+
- (v1, v4): Normal edit in draft
|
|
152
|
+
|
|
153
|
+
This could also technically happen if we change the same entity more than
|
|
154
|
+
once in the the same bulk_draft_changes_for() context, thereby putting
|
|
155
|
+
them into the same DraftChangeLog, which forces us to squash the changes
|
|
156
|
+
together into one DraftChangeLogRecord.
|
|
157
|
+
|
|
158
|
+
Scenario 6: old_version is None, new_version > 1
|
|
159
|
+
|
|
160
|
+
This edge case can happen if we soft-deleted a published entity, and then
|
|
161
|
+
called reset_drafts_to_published before we published that soft-deletion.
|
|
162
|
+
It would effectively undo our soft-delete because the published version
|
|
163
|
+
was not yet marked as deleted.
|
|
164
|
+
|
|
165
|
+
Scenario 7: old_version == new_version
|
|
166
|
+
|
|
167
|
+
This means that the data associated with the Draft version of an entity
|
|
168
|
+
has changed purely as a side-effect of some other entity changing.
|
|
169
|
+
|
|
170
|
+
The main example we have of this are containers. Imagine that we have a
|
|
171
|
+
Unit that is at v1, and has unpinned references to various Components that
|
|
172
|
+
are its children. The Unit's version does not get incremented when the
|
|
173
|
+
Components are edited, because the Unit container is defined to always get
|
|
174
|
+
the most recent version of those Components. We would only make a new
|
|
175
|
+
version of the Unit if we changed the metadata of the Unit itself (e.g.
|
|
176
|
+
the title), or if we added, removed, or reordered the children.
|
|
177
|
+
|
|
178
|
+
Yet updating a Component intuitively changes what we think of as the
|
|
179
|
+
content of the Unit. Users who are working on Units also expect that a
|
|
180
|
+
change to a Component will be reflected when looking at a Unit's
|
|
181
|
+
"last updated" info. The old_version == new_version convention lets us
|
|
182
|
+
represent that in a useful way because that Unit *is* a part of the change
|
|
183
|
+
set represented by a DraftChangeLog, even if its own versioned data hasn't
|
|
184
|
+
changed.
|
|
185
|
+
|
|
186
|
+
.. no_pii:
|
|
187
|
+
"""
|
|
188
|
+
draft_change_log = models.ForeignKey(
|
|
189
|
+
DraftChangeLog,
|
|
190
|
+
on_delete=models.CASCADE,
|
|
191
|
+
related_name="records",
|
|
192
|
+
)
|
|
193
|
+
entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT)
|
|
194
|
+
old_version = models.ForeignKey(
|
|
195
|
+
PublishableEntityVersion,
|
|
196
|
+
on_delete=models.RESTRICT,
|
|
197
|
+
null=True,
|
|
198
|
+
blank=True,
|
|
199
|
+
related_name="+",
|
|
200
|
+
)
|
|
201
|
+
new_version = models.ForeignKey(
|
|
202
|
+
PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
class Meta:
|
|
206
|
+
constraints = [
|
|
207
|
+
# A PublishableEntity can have only one DraftLogRecord per DraftLog.
|
|
208
|
+
# You can't simultaneously change the same thing in two different
|
|
209
|
+
# ways, e.g. set the Draft to version 1 and version 2 at the same
|
|
210
|
+
# time; or delete a Draft and set it to version 2 at the same time.
|
|
211
|
+
models.UniqueConstraint(
|
|
212
|
+
fields=[
|
|
213
|
+
"draft_change_log",
|
|
214
|
+
"entity",
|
|
215
|
+
],
|
|
216
|
+
name="oel_dlr_uniq_dcl",
|
|
217
|
+
)
|
|
218
|
+
]
|
|
219
|
+
indexes = [
|
|
220
|
+
# Entity (reverse) DraftLog Index:
|
|
221
|
+
# * Find the history of draft changes for a given entity, starting
|
|
222
|
+
# with the most recent (since IDs are ascending ints).
|
|
223
|
+
models.Index(
|
|
224
|
+
fields=["entity", "-draft_change_log"],
|
|
225
|
+
name="oel_dlr_idx_entity_rdcl",
|
|
226
|
+
),
|
|
227
|
+
]
|
|
228
|
+
verbose_name = _("Draft Change Log Record")
|
|
229
|
+
verbose_name_plural = _("Draft Change Log Records")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class DraftSideEffect(models.Model):
|
|
233
|
+
"""
|
|
234
|
+
Model to track when a change in one Draft affects other Drafts.
|
|
235
|
+
|
|
236
|
+
Our first use case for this is that changes involving child components are
|
|
237
|
+
thought to affect parent Units, even if the parent's version doesn't change.
|
|
238
|
+
|
|
239
|
+
Side-effects are recorded in a collapsed form that only captures one level.
|
|
240
|
+
So if Components C1 and C2 are both changed and they are part of Unit U1,
|
|
241
|
+
which is in turn a part of Subsection SS1, then the DraftSideEffect entries
|
|
242
|
+
are::
|
|
243
|
+
|
|
244
|
+
(C1, U1)
|
|
245
|
+
(C2, U1)
|
|
246
|
+
(U1, SS1)
|
|
247
|
+
|
|
248
|
+
We do not keep entries for (C1, SS1) or (C2, SS1). This is to make the model
|
|
249
|
+
simpler, so we don't have to differentiate between direct side-effects and
|
|
250
|
+
transitive side-effects in the model.
|
|
251
|
+
|
|
252
|
+
We will record side-effects on a parent container whenever a child changes,
|
|
253
|
+
even if the parent container is also changing in the same DraftChangeLog.
|
|
254
|
+
The child change is still affecting the parent container, whether the
|
|
255
|
+
container happens to be changing for other reasons as well. Whether a parent
|
|
256
|
+
-child relationship exists or not depends on the draft state of the
|
|
257
|
+
container at the *end* of a bulk_draft_changes_for context. To give concrete
|
|
258
|
+
examples:
|
|
259
|
+
|
|
260
|
+
Setup: A Unit version U1.v1 has defined C1 to be a child. The current draft
|
|
261
|
+
version of C1 is C1.v1.
|
|
262
|
+
|
|
263
|
+
Scenario 1: In the a bulk_draft_changes_for context, we edit C1 so that the
|
|
264
|
+
draft version of C1 is now C1.v2. Result:
|
|
265
|
+
- a DraftChangeLogRecord is created for C1.v1 -> C1.v2
|
|
266
|
+
- a DraftChangeLogRecord is created for U1.v1 -> U1.v1
|
|
267
|
+
- a DraftSideEffect is created with cause (C1.v1 -> C1.v2) and effect
|
|
268
|
+
(U1.v1 -> U1.v1). The Unit draft version has not been incremented because
|
|
269
|
+
the metadata a Unit defines for itself hasn't been altered, but the Unit
|
|
270
|
+
has *changed* in some way because of the side effect of its child being
|
|
271
|
+
edited.
|
|
272
|
+
|
|
273
|
+
Scenario 2: In a bulk_draft_changes_for context, we edit C1 so that the
|
|
274
|
+
draft version of C1 is now C1.v2. In the same context, we edit U1's metadata
|
|
275
|
+
so that the draft version of U1 is now U1.v2. U1.v2 still lists C1 as a
|
|
276
|
+
child entity. Result:
|
|
277
|
+
- a DraftChangeLogRecord is created for C1.v1 -> C1.v2
|
|
278
|
+
- a DraftChangeLogRecord is created for U1.v1 -> U1.v2
|
|
279
|
+
- a DraftSideEffect is created with cause (C1.v1 -> C1.v2) and effect
|
|
280
|
+
(U1.v1 -> U1.v2)
|
|
281
|
+
|
|
282
|
+
Scenario 3: In a bulk_draft_changes_for context, we edit C1 so that the
|
|
283
|
+
draft version of C1 is now C1.v2. In the same context, we edit U1's list of
|
|
284
|
+
children so that C1 is no longer a child of U1.v2. Result:
|
|
285
|
+
- a DraftChangeLogRecord is created for C1.v1 -> C1.v2
|
|
286
|
+
- a DraftChangeLogRecord is created for U1.v1 -> U1.v2
|
|
287
|
+
- no SideEffect is created, since changing C1 does not have an impact on the
|
|
288
|
+
current draft of U1 (U1.v2). A DraftChangeLog is considered a single
|
|
289
|
+
atomic operation, so there was never a point at which C1.v1 -> C1.v2
|
|
290
|
+
affected the draft state of U1.
|
|
291
|
+
|
|
292
|
+
.. no_pii:
|
|
293
|
+
"""
|
|
294
|
+
cause = models.ForeignKey(
|
|
295
|
+
DraftChangeLogRecord,
|
|
296
|
+
on_delete=models.RESTRICT,
|
|
297
|
+
related_name='causes',
|
|
298
|
+
)
|
|
299
|
+
effect = models.ForeignKey(
|
|
300
|
+
DraftChangeLogRecord,
|
|
301
|
+
on_delete=models.RESTRICT,
|
|
302
|
+
related_name='affected_by',
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
class Meta:
|
|
306
|
+
constraints = [
|
|
307
|
+
# Duplicate entries for cause & effect are just redundant. This is
|
|
308
|
+
# here to guard against weird bugs that might introduce this state.
|
|
309
|
+
models.UniqueConstraint(
|
|
310
|
+
fields=["cause", "effect"],
|
|
311
|
+
name="oel_pub_dse_uniq_c_e",
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
verbose_name = _("Draft Side Effect")
|
|
315
|
+
verbose_name_plural = _("Draft Side Effects")
|