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.
@@ -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 case_insensitive_char_field, immutable_uuid_field, manual_date_time_field
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,
@@ -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 40-byte, lower-case hex string representation of a hash digest.
24
+ Create a lower-case hex string representation of a hash digest.
25
25
 
26
- The hash digest itself is 20-bytes using BLAKE2b.
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=20).hexdigest()
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
- return models.CharField(
148
- max_length=40,
149
- blank=False,
150
- null=False,
151
- editable=False,
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.29.1
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: djangorestframework<4.0
27
- Requires-Dist: attrs
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