openedx-learning 0.20.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.
@@ -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 .draft_published import Draft, Published
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")