openedx-learning 0.29.0__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/backup_restore/zipper.py +22 -4
- 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.0.dist-info → openedx_learning-0.30.0.dist-info}/METADATA +5 -5
- {openedx_learning-0.29.0.dist-info → openedx_learning-0.30.0.dist-info}/RECORD +18 -16
- {openedx_learning-0.29.0.dist-info → openedx_learning-0.30.0.dist-info}/WHEEL +0 -0
- {openedx_learning-0.29.0.dist-info → openedx_learning-0.30.0.dist-info}/licenses/LICENSE.txt +0 -0
- {openedx_learning-0.29.0.dist-info → openedx_learning-0.30.0.dist-info}/top_level.txt +0 -0
|
@@ -13,9 +13,11 @@ from enum import Enum
|
|
|
13
13
|
from typing import ContextManager, Optional, TypeVar
|
|
14
14
|
|
|
15
15
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
16
|
-
from django.db.models import F, Q, QuerySet
|
|
16
|
+
from django.db.models import F, Prefetch, Q, QuerySet
|
|
17
17
|
from django.db.transaction import atomic
|
|
18
18
|
|
|
19
|
+
from openedx_learning.lib.fields import create_hash_digest
|
|
20
|
+
|
|
19
21
|
from .contextmanagers import DraftChangeLogContext
|
|
20
22
|
from .models import (
|
|
21
23
|
Container,
|
|
@@ -31,9 +33,11 @@ from .models import (
|
|
|
31
33
|
PublishableEntity,
|
|
32
34
|
PublishableEntityMixin,
|
|
33
35
|
PublishableEntityVersion,
|
|
36
|
+
PublishableEntityVersionDependency,
|
|
34
37
|
PublishableEntityVersionMixin,
|
|
35
38
|
PublishLog,
|
|
36
39
|
PublishLogRecord,
|
|
40
|
+
PublishSideEffect,
|
|
37
41
|
)
|
|
38
42
|
from .models.publish_log import Published
|
|
39
43
|
|
|
@@ -213,6 +217,8 @@ def create_publishable_entity_version(
|
|
|
213
217
|
title: str,
|
|
214
218
|
created: datetime,
|
|
215
219
|
created_by: int | None,
|
|
220
|
+
*,
|
|
221
|
+
dependencies: list[int] | None = None, # PublishableEntity IDs
|
|
216
222
|
) -> PublishableEntityVersion:
|
|
217
223
|
"""
|
|
218
224
|
Create a PublishableEntityVersion.
|
|
@@ -220,7 +226,7 @@ def create_publishable_entity_version(
|
|
|
220
226
|
You'd typically want to call this right before creating your own content
|
|
221
227
|
version model that points to it.
|
|
222
228
|
"""
|
|
223
|
-
with atomic():
|
|
229
|
+
with atomic(savepoint=False):
|
|
224
230
|
version = PublishableEntityVersion.objects.create(
|
|
225
231
|
entity_id=entity_id,
|
|
226
232
|
version_num=version_num,
|
|
@@ -228,16 +234,82 @@ def create_publishable_entity_version(
|
|
|
228
234
|
created=created,
|
|
229
235
|
created_by_id=created_by,
|
|
230
236
|
)
|
|
237
|
+
if dependencies:
|
|
238
|
+
set_version_dependencies(version.id, dependencies)
|
|
239
|
+
|
|
231
240
|
set_draft_version(
|
|
232
241
|
entity_id,
|
|
233
242
|
version.id,
|
|
234
243
|
set_at=created,
|
|
235
244
|
set_by=created_by,
|
|
236
245
|
)
|
|
237
|
-
|
|
238
246
|
return version
|
|
239
247
|
|
|
240
248
|
|
|
249
|
+
def set_version_dependencies(
|
|
250
|
+
version_id: int, # PublishableEntityVersion.id,
|
|
251
|
+
/,
|
|
252
|
+
dependencies: list[int] # List of PublishableEntity.id
|
|
253
|
+
) -> None:
|
|
254
|
+
"""
|
|
255
|
+
Set the dependencies of a publishable entity version.
|
|
256
|
+
|
|
257
|
+
In general, callers should not modify dependencies after creation (i.e. use
|
|
258
|
+
the optional param in create_publishable_entity_version() instead of using
|
|
259
|
+
this function). **This function is not atomic.** If you're doing backfills,
|
|
260
|
+
you must wrap calls to this function with a transaction.atomic() call.
|
|
261
|
+
|
|
262
|
+
The idea behind dependencies is that a PublishableEntity's Versions may
|
|
263
|
+
be declared to reference unpinned PublishableEntities. Changes to those
|
|
264
|
+
referenced PublishableEntities still affect the draft or published state of
|
|
265
|
+
the referent PublishableEntity, even if the referent entity's version is not
|
|
266
|
+
incremented.
|
|
267
|
+
|
|
268
|
+
For example, we only create a new UnitVersion when there are changes to the
|
|
269
|
+
metadata of the Unit itself. So this would happen when the name of the Unit
|
|
270
|
+
is changed, or if a child Component is added, removed, or reordered. No new
|
|
271
|
+
UnitVersion is created when a child Component of that Unit is modified or
|
|
272
|
+
published. Yet we still consider a Unit to be affected when one of its child
|
|
273
|
+
Components is modified or published. Therefore, we say that the child
|
|
274
|
+
Components are dependencies of the UnitVersion.
|
|
275
|
+
|
|
276
|
+
Dependencies vs. Container Rows/Children
|
|
277
|
+
|
|
278
|
+
Dependencies overlap with the concept of container child rows, but the two
|
|
279
|
+
are not exactly the same. For instance:
|
|
280
|
+
|
|
281
|
+
* Dependencies have no sense of ordering.
|
|
282
|
+
* If a row is declared to be pinned to a specific version of a child, then
|
|
283
|
+
it is NOT a dependency. For example, if U1.v1 is declared to have a pinned
|
|
284
|
+
reference to ComponentVersion C1.v1, then future changes to C1 do not
|
|
285
|
+
affect U1.v1 because U1.v1 will just ignore those new ComponentVersions.
|
|
286
|
+
|
|
287
|
+
Guidelines:
|
|
288
|
+
|
|
289
|
+
1. Only declare one level of dependencies, e.g. immediate parent-child
|
|
290
|
+
relationships. The publishing app will calculate transitive dependencies
|
|
291
|
+
like "all descendants" based on this. This is important for saving space,
|
|
292
|
+
because the DAG of trasitive dependency relationships can explode out to
|
|
293
|
+
tens of thousands of nodes per version.
|
|
294
|
+
2. Declare dependencies from the bottom-up when possible. In other words, if
|
|
295
|
+
you're building an entire Subsection, set the Component dependencies for
|
|
296
|
+
the Units before you set the Unit dependencies for the Subsection. This
|
|
297
|
+
code will still work if you build from the top-down, but we'll end up
|
|
298
|
+
doing many redundant re-calculations, since every change to a lower layer
|
|
299
|
+
will cause recalculation to the higher levels that depend on it.
|
|
300
|
+
3. Do not create circular dependencies.
|
|
301
|
+
"""
|
|
302
|
+
PublishableEntityVersionDependency.objects.bulk_create(
|
|
303
|
+
[
|
|
304
|
+
PublishableEntityVersionDependency(
|
|
305
|
+
referring_version_id=version_id,
|
|
306
|
+
referenced_entity_id=dep_entity_id,
|
|
307
|
+
)
|
|
308
|
+
for dep_entity_id in set(dependencies) # dependencies have no ordering
|
|
309
|
+
],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
241
313
|
def get_publishable_entity(publishable_entity_id: int, /) -> PublishableEntity:
|
|
242
314
|
return PublishableEntity.objects.get(id=publishable_entity_id)
|
|
243
315
|
|
|
@@ -285,7 +357,8 @@ def get_entities_with_unpublished_changes(
|
|
|
285
357
|
"""
|
|
286
358
|
Fetch entities that have unpublished changes.
|
|
287
359
|
|
|
288
|
-
By default, this excludes soft-deleted drafts but can be included using
|
|
360
|
+
By default, this excludes soft-deleted drafts but can be included using
|
|
361
|
+
include_deleted_drafts option.
|
|
289
362
|
"""
|
|
290
363
|
entities_qs = (
|
|
291
364
|
PublishableEntity.objects
|
|
@@ -333,65 +406,72 @@ def publish_all_drafts(
|
|
|
333
406
|
Publish everything that is a Draft and is not already published.
|
|
334
407
|
"""
|
|
335
408
|
draft_qset = (
|
|
336
|
-
Draft.objects
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
# Exclude entities where the Published version already matches the
|
|
340
|
-
# Draft version.
|
|
341
|
-
.exclude(entity__published__version_id=F("version_id"))
|
|
342
|
-
|
|
343
|
-
# Account for soft-deletes:
|
|
344
|
-
# NULL != NULL in SQL, so simply excluding entities where the Draft
|
|
345
|
-
# and Published versions match will not catch the case where a
|
|
346
|
-
# soft-delete has been published (i.e. both the Draft and Published
|
|
347
|
-
# versions are NULL). We need to explicitly check for that case
|
|
348
|
-
# instead, or else we will re-publish the same soft-deletes over
|
|
349
|
-
# and over again.
|
|
350
|
-
.exclude(Q(version__isnull=True) & Q(entity__published__version__isnull=True))
|
|
409
|
+
Draft.objects
|
|
410
|
+
.filter(entity__learning_package_id=learning_package_id)
|
|
411
|
+
.with_unpublished_changes()
|
|
351
412
|
)
|
|
352
413
|
return publish_from_drafts(
|
|
353
414
|
learning_package_id, draft_qset, message, published_at, published_by
|
|
354
415
|
)
|
|
355
416
|
|
|
356
417
|
|
|
418
|
+
def _get_dependencies_with_unpublished_changes(
|
|
419
|
+
draft_qset: QuerySet[Draft]
|
|
420
|
+
) -> list[QuerySet[Draft]]:
|
|
421
|
+
"""
|
|
422
|
+
Return all dependencies to publish as a list of Draft QuerySets.
|
|
423
|
+
|
|
424
|
+
This should only return the Drafts that have actual changes, not pure side-
|
|
425
|
+
effects. The side-effect calculations will happen separately.
|
|
426
|
+
"""
|
|
427
|
+
# First we have to do a full crawl of *all* dependencies, regardless of
|
|
428
|
+
# whether they have unpublished changes or not. This is because we might
|
|
429
|
+
# have a dependency-of-a-dependency that has changed somewhere down the
|
|
430
|
+
# line. Example: The draft_qset includes a Subsection. Even if the Unit
|
|
431
|
+
# versions are still the same, there might be a changed Component that needs
|
|
432
|
+
# to be published.
|
|
433
|
+
all_dependency_drafts = []
|
|
434
|
+
dependency_drafts = Draft.objects.filter(
|
|
435
|
+
entity__affects__in=draft_qset.values_list("version_id", flat=True)
|
|
436
|
+
).distinct()
|
|
437
|
+
|
|
438
|
+
while dependency_drafts:
|
|
439
|
+
all_dependency_drafts.append(dependency_drafts)
|
|
440
|
+
dependency_drafts = Draft.objects.filter(
|
|
441
|
+
entity__affects__in=dependency_drafts.all().values_list("version_id", flat=True)
|
|
442
|
+
).distinct()
|
|
443
|
+
|
|
444
|
+
unpublished_dependency_drafts = [
|
|
445
|
+
dependency_drafts_qset.all().with_unpublished_changes()
|
|
446
|
+
for dependency_drafts_qset in all_dependency_drafts
|
|
447
|
+
]
|
|
448
|
+
return unpublished_dependency_drafts
|
|
449
|
+
|
|
450
|
+
|
|
357
451
|
def publish_from_drafts(
|
|
358
452
|
learning_package_id: int, # LearningPackage.id
|
|
359
453
|
/,
|
|
360
|
-
draft_qset: QuerySet,
|
|
454
|
+
draft_qset: QuerySet[Draft],
|
|
361
455
|
message: str = "",
|
|
362
456
|
published_at: datetime | None = None,
|
|
363
457
|
published_by: int | None = None, # User.id
|
|
458
|
+
*,
|
|
459
|
+
publish_dependencies: bool = True,
|
|
364
460
|
) -> PublishLog:
|
|
365
461
|
"""
|
|
366
462
|
Publish the rows in the ``draft_model_qsets`` args passed in.
|
|
463
|
+
|
|
464
|
+
By default, this will also publish all dependencies (e.g. unpinned children)
|
|
465
|
+
of the Drafts that are passed in.
|
|
367
466
|
"""
|
|
368
467
|
if published_at is None:
|
|
369
468
|
published_at = datetime.now(tz=timezone.utc)
|
|
370
469
|
|
|
371
470
|
with atomic():
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
container_version_ids = (
|
|
377
|
-
Container.objects.filter(publishable_entity__draft__in=draft_qset)
|
|
378
|
-
.values_list("publishable_entity__draft__version__containerversion__pk", flat=True)
|
|
379
|
-
)
|
|
380
|
-
if container_version_ids:
|
|
381
|
-
# We are publishing at least one container. Check if it has any child components that aren't already slated
|
|
382
|
-
# to be published.
|
|
383
|
-
unpublished_draft_children = EntityListRow.objects.filter(
|
|
384
|
-
entity_list__container_versions__pk__in=container_version_ids,
|
|
385
|
-
entity_version=None, # Unpinned entities only
|
|
386
|
-
).exclude(
|
|
387
|
-
entity__draft__version=F("entity__published__version") # Exclude already published things
|
|
388
|
-
).values_list("entity__draft__pk", flat=True)
|
|
389
|
-
if unpublished_draft_children:
|
|
390
|
-
# Force these additional child components to be published at the same time by adding them to the qset:
|
|
391
|
-
draft_qset = Draft.objects.filter(
|
|
392
|
-
Q(pk__in=draft_qset.values_list("pk", flat=True)) |
|
|
393
|
-
Q(pk__in=unpublished_draft_children)
|
|
394
|
-
)
|
|
471
|
+
if publish_dependencies:
|
|
472
|
+
dependency_drafts_qsets = _get_dependencies_with_unpublished_changes(draft_qset)
|
|
473
|
+
else:
|
|
474
|
+
dependency_drafts_qsets = []
|
|
395
475
|
|
|
396
476
|
# One PublishLog for this entire publish operation.
|
|
397
477
|
publish_log = PublishLog(
|
|
@@ -403,32 +483,51 @@ def publish_from_drafts(
|
|
|
403
483
|
publish_log.full_clean()
|
|
404
484
|
publish_log.save(force_insert=True)
|
|
405
485
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
#
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
486
|
+
# We're intentionally avoiding union() here because Django ORM unions
|
|
487
|
+
# introduce cumbersome restrictions (can only union once, can't
|
|
488
|
+
# select_related on it after, the extra rows must be exactly compatible
|
|
489
|
+
# in unioned qsets, etc.) Instead, we're going to have one queryset per
|
|
490
|
+
# dependency layer.
|
|
491
|
+
all_draft_qsets = [
|
|
492
|
+
draft_qset.select_related("entity__published__version"),
|
|
493
|
+
*dependency_drafts_qsets, # one QuerySet per layer of dependencies
|
|
494
|
+
]
|
|
495
|
+
published_draft_ids = set()
|
|
496
|
+
for qset in all_draft_qsets:
|
|
497
|
+
for draft in qset:
|
|
498
|
+
# Skip duplicates that we might get from expanding dependencies.
|
|
499
|
+
if draft.pk in published_draft_ids:
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
old_version = draft.entity.published.version
|
|
504
|
+
except ObjectDoesNotExist:
|
|
505
|
+
# This means there is no published version yet.
|
|
506
|
+
old_version = None
|
|
507
|
+
|
|
508
|
+
# Create a record describing publishing this particular
|
|
509
|
+
# Publishable (useful for auditing and reverting).
|
|
510
|
+
publish_log_record = PublishLogRecord(
|
|
511
|
+
publish_log=publish_log,
|
|
512
|
+
entity=draft.entity,
|
|
513
|
+
old_version=old_version,
|
|
514
|
+
new_version=draft.version,
|
|
515
|
+
)
|
|
516
|
+
publish_log_record.full_clean()
|
|
517
|
+
publish_log_record.save(force_insert=True)
|
|
518
|
+
|
|
519
|
+
# Update the lookup we use to fetch the published versions
|
|
520
|
+
Published.objects.update_or_create(
|
|
521
|
+
entity=draft.entity,
|
|
522
|
+
defaults={
|
|
523
|
+
"version": draft.version,
|
|
524
|
+
"publish_log_record": publish_log_record,
|
|
525
|
+
},
|
|
526
|
+
)
|
|
423
527
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
defaults={
|
|
428
|
-
"version": draft.version,
|
|
429
|
-
"publish_log_record": publish_log_record,
|
|
430
|
-
},
|
|
431
|
-
)
|
|
528
|
+
published_draft_ids.add(draft.pk)
|
|
529
|
+
|
|
530
|
+
_create_side_effects_for_change_log(publish_log)
|
|
432
531
|
|
|
433
532
|
return publish_log
|
|
434
533
|
|
|
@@ -505,8 +604,8 @@ def set_draft_version(
|
|
|
505
604
|
from Studio's editing point of view (see ``soft_delete_draft`` for more
|
|
506
605
|
details).
|
|
507
606
|
|
|
508
|
-
Calling this function attaches a new
|
|
509
|
-
DraftChangeLog.
|
|
607
|
+
Calling this function attaches a new DraftChangeLogRecord and attaches it to
|
|
608
|
+
a DraftChangeLog.
|
|
510
609
|
|
|
511
610
|
This function will create DraftSideEffect entries and properly add any
|
|
512
611
|
containers that may have been affected by this draft update, UNLESS it is
|
|
@@ -538,7 +637,6 @@ def set_draft_version(
|
|
|
538
637
|
# The actual update of the Draft model is here. Everything after this
|
|
539
638
|
# block is bookkeeping in our DraftChangeLog.
|
|
540
639
|
draft.version_id = publishable_entity_version_pk
|
|
541
|
-
draft.save()
|
|
542
640
|
|
|
543
641
|
# Check to see if we're inside a context manager for an active
|
|
544
642
|
# DraftChangeLog (i.e. what happens if the caller is using the public
|
|
@@ -548,23 +646,52 @@ def set_draft_version(
|
|
|
548
646
|
learning_package_id
|
|
549
647
|
)
|
|
550
648
|
if active_change_log:
|
|
551
|
-
|
|
649
|
+
draft_log_record = _add_to_existing_draft_change_log(
|
|
552
650
|
active_change_log,
|
|
553
651
|
draft.entity_id,
|
|
554
652
|
old_version_id=old_version_id,
|
|
555
653
|
new_version_id=publishable_entity_version_pk,
|
|
556
654
|
)
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
655
|
+
if draft_log_record:
|
|
656
|
+
# Normal case: a DraftChangeLogRecord was created or updated.
|
|
657
|
+
draft.draft_log_record = draft_log_record
|
|
658
|
+
else:
|
|
659
|
+
# Edge case: this change cancelled out other changes, and the
|
|
660
|
+
# net effect is that the DraftChangeLogRecord shouldn't exist,
|
|
661
|
+
# i.e. the version at the start and end of the DraftChangeLog is
|
|
662
|
+
# the same. In that case, _add_to_existing_draft_change_log will
|
|
663
|
+
# delete the log record for this Draft state, and we have to
|
|
664
|
+
# look for the most recently created DraftLogRecord from another
|
|
665
|
+
# DraftChangeLog. This value may be None.
|
|
666
|
+
#
|
|
667
|
+
# NOTE: There may be some weird edge cases here around soft-
|
|
668
|
+
# deletions and modifications of the same Draft entry in nested
|
|
669
|
+
# bulk_draft_changes_for() calls. I haven't thought that through
|
|
670
|
+
# yet--it might be better to just throw an error rather than try
|
|
671
|
+
# to accommodate it.
|
|
672
|
+
draft.draft_log_record = (
|
|
673
|
+
DraftChangeLogRecord.objects
|
|
674
|
+
.filter(entity_id=draft.entity_id)
|
|
675
|
+
.order_by("-pk")
|
|
676
|
+
.first()
|
|
677
|
+
)
|
|
678
|
+
draft.save()
|
|
679
|
+
|
|
680
|
+
# We also *don't* create container side effects here because there
|
|
681
|
+
# may be many changes in this DraftChangeLog, some of which haven't
|
|
682
|
+
# been made yet. It wouldn't make sense to create a side effect that
|
|
683
|
+
# says, "this Unit changed because this Component in it changed" if
|
|
684
|
+
# we were changing that same Unit later on in the same
|
|
562
685
|
# DraftChangeLog, because that new Unit version might not even
|
|
563
|
-
# include
|
|
564
|
-
#
|
|
686
|
+
# include that child Component. Also, calculating side-effects is
|
|
687
|
+
# expensive, and would result in a lot of wasted queries if we did
|
|
688
|
+
# it for every change will inside an active change log context.
|
|
689
|
+
#
|
|
690
|
+
# Therefore we'll let DraftChangeLogContext do that work when it
|
|
691
|
+
# exits its context.
|
|
565
692
|
else:
|
|
566
693
|
# This means there is no active DraftChangeLog, so we create our own
|
|
567
|
-
# and add our DraftChangeLogRecord to it. This has the minor
|
|
694
|
+
# and add our DraftChangeLogRecord to it. This has the very minor
|
|
568
695
|
# optimization that we don't have to check for an existing
|
|
569
696
|
# DraftChangeLogRecord, because we know it can't exist yet.
|
|
570
697
|
change_log = DraftChangeLog.objects.create(
|
|
@@ -572,13 +699,14 @@ def set_draft_version(
|
|
|
572
699
|
changed_at=set_at,
|
|
573
700
|
changed_by_id=set_by,
|
|
574
701
|
)
|
|
575
|
-
|
|
702
|
+
draft.draft_log_record = DraftChangeLogRecord.objects.create(
|
|
576
703
|
draft_change_log=change_log,
|
|
577
704
|
entity_id=draft.entity_id,
|
|
578
705
|
old_version_id=old_version_id,
|
|
579
706
|
new_version_id=publishable_entity_version_pk,
|
|
580
707
|
)
|
|
581
|
-
|
|
708
|
+
draft.save()
|
|
709
|
+
_create_side_effects_for_change_log(change_log)
|
|
582
710
|
|
|
583
711
|
|
|
584
712
|
def _add_to_existing_draft_change_log(
|
|
@@ -586,9 +714,9 @@ def _add_to_existing_draft_change_log(
|
|
|
586
714
|
entity_id: int,
|
|
587
715
|
old_version_id: int | None,
|
|
588
716
|
new_version_id: int | None,
|
|
589
|
-
) -> DraftChangeLogRecord:
|
|
717
|
+
) -> DraftChangeLogRecord | None:
|
|
590
718
|
"""
|
|
591
|
-
Create or
|
|
719
|
+
Create, update, or delete a DraftChangeLogRecord for the active_change_log.
|
|
592
720
|
|
|
593
721
|
The an active_change_log may have many DraftChangeLogRecords already
|
|
594
722
|
associated with it. A DraftChangeLog can only have one DraftChangeLogRecord
|
|
@@ -602,6 +730,13 @@ def _add_to_existing_draft_change_log(
|
|
|
602
730
|
and the same entity_id but with versions: (None, v1), (v1, v2), (v2, v3);
|
|
603
731
|
we would collapse them into one DraftChangeLogrecord with old_version = None
|
|
604
732
|
and new_version = v3.
|
|
733
|
+
|
|
734
|
+
This also means that if we make a change that undoes the previos change, it
|
|
735
|
+
will delete that DraftChangeLogRecord, e.g. (None, v1) -> (None, v2) ->
|
|
736
|
+
(None -> None), then this entry can be deleted because it didn't change
|
|
737
|
+
anything. This function should never be used for creating side-effect change
|
|
738
|
+
log records (the only place where it's normal to have the same old and new
|
|
739
|
+
versions).
|
|
605
740
|
"""
|
|
606
741
|
try:
|
|
607
742
|
# Check to see if this PublishableEntity has already been changed in
|
|
@@ -622,8 +757,11 @@ def _add_to_existing_draft_change_log(
|
|
|
622
757
|
#
|
|
623
758
|
# It's important that we remove these cases, because we use the
|
|
624
759
|
# old_version == new_version convention to record entities that have
|
|
625
|
-
# changed purely due to side-effects.
|
|
760
|
+
# changed purely due to side-effects. We could technically still
|
|
761
|
+
# differentiate those by actually looking at the DraftSideEffect and
|
|
762
|
+
# PublishSideEffect models, but this is less confusing overall.
|
|
626
763
|
change.delete()
|
|
764
|
+
return None
|
|
627
765
|
else:
|
|
628
766
|
# Normal case: We update the new_version, but leave the old_version
|
|
629
767
|
# as is. The old_version represents what the Draft was pointing to
|
|
@@ -644,87 +782,418 @@ def _add_to_existing_draft_change_log(
|
|
|
644
782
|
return change
|
|
645
783
|
|
|
646
784
|
|
|
647
|
-
def
|
|
648
|
-
"""
|
|
649
|
-
|
|
650
|
-
|
|
785
|
+
def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog):
|
|
786
|
+
"""
|
|
787
|
+
Create the side-effects for a DraftChangeLog or PublishLog.
|
|
788
|
+
|
|
789
|
+
A side-effect is created whenever a dependency of a draft or published
|
|
790
|
+
entity version is altered.
|
|
791
|
+
|
|
792
|
+
For example, say we have a published Unit at version 1 (``U1.v1``).
|
|
793
|
+
``U1.v1`` is defined to have unpinned references to Components ``C1`` and
|
|
794
|
+
``C2``, i.e. ``U1.v1 = [C1, C2]``. This means that ``U1.v1`` always shows
|
|
795
|
+
the latest published versions of ``C1`` and ``C2``. While we do have a more
|
|
796
|
+
sophisticated encoding for the ordered parent-child relationships, we
|
|
797
|
+
capture the basic dependency relationship using the M:M
|
|
798
|
+
``PublishableEntityVersionDependency`` model. In this scenario, C1 and C2
|
|
799
|
+
are dependencies of ``U1.v1``
|
|
800
|
+
|
|
801
|
+
In the above example, publishing a newer version of ``C1`` does *not*
|
|
802
|
+
increment the version for Unit ``U1``. But we still want to record that
|
|
803
|
+
``U1.v1`` was affected by the change in ``C1``. We record this in the
|
|
804
|
+
``DraftSideEffect`` and ``PublishSideEffect`` models. We also add an entry
|
|
805
|
+
for ``U1`` in the change log, saying that it went from version 1 to version
|
|
806
|
+
1--i.e nothing about the Unit's defintion changed, but it was still affected
|
|
807
|
+
by other changes in the log.
|
|
808
|
+
|
|
809
|
+
Only call this function after all the records have already been created.
|
|
810
|
+
|
|
811
|
+
Note: The interface between ``DraftChangeLog`` and ``PublishLog`` is similar
|
|
812
|
+
enough that this function has been made to work with both.
|
|
813
|
+
"""
|
|
814
|
+
branch_cls: type[Draft] | type[Published]
|
|
815
|
+
change_record_cls: type[DraftChangeLogRecord] | type[PublishLogRecord]
|
|
816
|
+
side_effect_cls: type[DraftSideEffect] | type[PublishSideEffect]
|
|
817
|
+
if isinstance(change_log, DraftChangeLog):
|
|
818
|
+
branch_cls = Draft
|
|
819
|
+
change_record_cls = DraftChangeLogRecord
|
|
820
|
+
side_effect_cls = DraftSideEffect
|
|
821
|
+
log_record_rel = "draft_log_record_id"
|
|
822
|
+
elif isinstance(change_log, PublishLog):
|
|
823
|
+
branch_cls = Published
|
|
824
|
+
change_record_cls = PublishLogRecord
|
|
825
|
+
side_effect_cls = PublishSideEffect
|
|
826
|
+
log_record_rel = "publish_log_record_id"
|
|
827
|
+
else:
|
|
828
|
+
raise TypeError(
|
|
829
|
+
f"expected DraftChangeLog or PublishLog, not {type(change_log)}"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# processed_entity_ids holds the entity IDs that we've already calculated
|
|
833
|
+
# side-effects for. This is to save us from recalculating side-effects for
|
|
834
|
+
# the same dependency relationships over and over again. So if we're calling
|
|
835
|
+
# this function in a loop for all the Components in a Unit, we won't be
|
|
836
|
+
# recalculating the Unit's side-effect on its Subsection, and its
|
|
837
|
+
# Subsection's side-effect on its Section each time through the loop.
|
|
838
|
+
# It also guards against infinite parent-child relationship loops, though
|
|
839
|
+
# those aren't *supposed* to happen anyhow.
|
|
651
840
|
processed_entity_ids: set[int] = set()
|
|
652
|
-
for
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
processed_entity_ids=processed_entity_ids,
|
|
841
|
+
for original_change in change_log.records.all():
|
|
842
|
+
affected_by_original_change = branch_cls.objects.filter(
|
|
843
|
+
version__dependencies=original_change.entity
|
|
656
844
|
)
|
|
845
|
+
changes_and_affected = [
|
|
846
|
+
(original_change, current) for current in affected_by_original_change
|
|
847
|
+
]
|
|
657
848
|
|
|
849
|
+
# These are the Published or Draft objects where we need to repoint the
|
|
850
|
+
# log_record (publish_log_record or draft_change_log_record) to point to
|
|
851
|
+
# the side-effect changes, e.g. the DraftChangeLogRecord that says,
|
|
852
|
+
# "This Unit's version stayed the same, but its dependency hash changed
|
|
853
|
+
# because a child Component's draft version was changed." We gather them
|
|
854
|
+
# all up in a list so we can do a bulk_update on them.
|
|
855
|
+
branch_objs_to_update_with_side_effects = []
|
|
856
|
+
|
|
857
|
+
while changes_and_affected:
|
|
858
|
+
change, affected = changes_and_affected.pop()
|
|
859
|
+
change_log_param = {}
|
|
860
|
+
if branch_cls == Draft:
|
|
861
|
+
change_log_param['draft_change_log'] = change.draft_change_log # type: ignore[union-attr]
|
|
862
|
+
elif branch_cls == Published:
|
|
863
|
+
change_log_param['publish_log'] = change.publish_log # type: ignore[union-attr]
|
|
864
|
+
|
|
865
|
+
# Example: If the original_change is a DraftChangeLogRecord that
|
|
866
|
+
# represents editing a Component, the side_effect_change is the
|
|
867
|
+
# DraftChangeLogRecord that represents the fact that the containing
|
|
868
|
+
# Unit was also altered (even if the Unit version doesn't change).
|
|
869
|
+
side_effect_change, _created = change_record_cls.objects.get_or_create(
|
|
870
|
+
**change_log_param,
|
|
871
|
+
entity_id=affected.entity_id,
|
|
872
|
+
defaults={
|
|
873
|
+
# If a change record already exists because the affected
|
|
874
|
+
# entity was separately modified, then we don't touch the
|
|
875
|
+
# old/new version entries. But if we're creating this change
|
|
876
|
+
# record as a pure side-effect, then we use the (old_version
|
|
877
|
+
# == new_version) convention to indicate that.
|
|
878
|
+
'old_version_id': affected.version_id,
|
|
879
|
+
'new_version_id': affected.version_id,
|
|
880
|
+
}
|
|
881
|
+
)
|
|
882
|
+
# Update the current branch pointer (Draft or Published) for this
|
|
883
|
+
# entity to point to the side_effect_change (if it's not already).
|
|
884
|
+
if branch_cls == Published:
|
|
885
|
+
published_obj = affected.entity.published
|
|
886
|
+
if published_obj.publish_log_record != side_effect_change:
|
|
887
|
+
published_obj.publish_log_record = side_effect_change
|
|
888
|
+
branch_objs_to_update_with_side_effects.append(published_obj)
|
|
889
|
+
elif branch_cls == Draft:
|
|
890
|
+
draft_obj = affected.entity.draft
|
|
891
|
+
if draft_obj.draft_log_record != side_effect_change:
|
|
892
|
+
draft_obj.draft_log_record = side_effect_change
|
|
893
|
+
branch_objs_to_update_with_side_effects.append(draft_obj)
|
|
894
|
+
|
|
895
|
+
# Create a new side effect (DraftSideEffect or PublishSideEffect) to
|
|
896
|
+
# record the relationship between the ``change`` and the
|
|
897
|
+
# corresponding ``side_effect_change``. We'll do this regardless of
|
|
898
|
+
# whether we created the ``side_effect_change`` or just pulled back
|
|
899
|
+
# an existing one. This addresses two things:
|
|
900
|
+
#
|
|
901
|
+
# 1. A change in multiple dependencies can generate multiple
|
|
902
|
+
# side effects that point to the same change log record, i.e.
|
|
903
|
+
# multiple changes can cause the same ``effect``.
|
|
904
|
+
# Example: Two draft components in a Unit are changed. Two
|
|
905
|
+
# DraftSideEffects will be created and point to the same Unit
|
|
906
|
+
# DraftChangeLogRecord.
|
|
907
|
+
# 2. A entity and its dependency can change at the same time.
|
|
908
|
+
# Example: If a Unit has a Component, and both the Unit and
|
|
909
|
+
# Component are edited in the same DraftChangeLog, then the Unit
|
|
910
|
+
# has changed in both ways (the Unit's internal metadata as well
|
|
911
|
+
# as the new version of the child component). The version of the
|
|
912
|
+
# Unit will be incremented, but we'll also create the
|
|
913
|
+
# DraftSideEffect.
|
|
914
|
+
side_effect_cls.objects.get_or_create(
|
|
915
|
+
cause=change,
|
|
916
|
+
effect=side_effect_change,
|
|
917
|
+
)
|
|
658
918
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
919
|
+
# Now we find the next layer up by looking at Drafts or Published
|
|
920
|
+
# that have ``affected.entity`` as a dependency.
|
|
921
|
+
next_layer_of_affected = branch_cls.objects.filter(
|
|
922
|
+
version__dependencies=affected.entity
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
# Make sure we never re-add the change we just processed when we
|
|
926
|
+
# queue up the next layer.
|
|
927
|
+
processed_entity_ids.add(change.entity_id)
|
|
928
|
+
|
|
929
|
+
changes_and_affected.extend(
|
|
930
|
+
(side_effect_change, affected)
|
|
931
|
+
for affected in next_layer_of_affected
|
|
932
|
+
if affected.entity_id not in processed_entity_ids
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
branch_cls.objects.bulk_update(
|
|
936
|
+
branch_objs_to_update_with_side_effects, # type: ignore[arg-type]
|
|
937
|
+
[log_record_rel],
|
|
938
|
+
)
|
|
665
939
|
|
|
666
|
-
|
|
667
|
-
fully written out. We want to avoid the scenario where we create a
|
|
668
|
-
side-effect that a Component change affects a Unit if the Unit version is
|
|
669
|
-
also changed (maybe even deleted) in the same DraftChangeLog.
|
|
940
|
+
update_dependencies_hash_digests_for_log(change_log)
|
|
670
941
|
|
|
671
|
-
The `processed_entity_ids` set holds the entity IDs that we've already
|
|
672
|
-
calculated side-effects for. This is to save us from recalculating side-
|
|
673
|
-
effects for the same ancestor relationships over and over again. So if we're
|
|
674
|
-
calling this function in a loop for all the Components in a Unit, we won't
|
|
675
|
-
be recalculating the Unit's side-effect on its Subsection, and its
|
|
676
|
-
Subsection's side-effect on its Section.
|
|
677
942
|
|
|
678
|
-
|
|
679
|
-
|
|
943
|
+
def update_dependencies_hash_digests_for_log(
|
|
944
|
+
change_log: DraftChangeLog | PublishLog,
|
|
945
|
+
backfill: bool = False,
|
|
946
|
+
) -> None:
|
|
680
947
|
"""
|
|
681
|
-
|
|
682
|
-
# An optimization, but also a guard against infinite side-effect loops.
|
|
683
|
-
processed_entity_ids = set()
|
|
948
|
+
Update dependencies_hash_digest for Drafts or Published in a change log.
|
|
684
949
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
950
|
+
All the data for Draft/Published, DraftChangeLog/PublishLog, and
|
|
951
|
+
DraftChangeLogRecord/PublishLogRecord have been set at this point *except*
|
|
952
|
+
the dependencies_hash_digest of DraftChangeLogRecord/PublishLogRecord. Those
|
|
953
|
+
log records are newly created at this point, so dependencies_hash_digest are
|
|
954
|
+
set to their default values.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
change_log: A DraftChangeLog or PublishLog that already has all
|
|
958
|
+
side-effects added to it. The Draft and Published models should
|
|
959
|
+
already be updated to point to the post-change versions.
|
|
960
|
+
backfill: If this is true, we will not trust the hash values stored on
|
|
961
|
+
log records outside of our log, i.e. things that we would normally
|
|
962
|
+
expect to be pre-calculated. This will be important for the initial
|
|
963
|
+
data migration.
|
|
964
|
+
"""
|
|
965
|
+
if isinstance(change_log, DraftChangeLog):
|
|
966
|
+
branch = "draft"
|
|
967
|
+
log_record_relation = "draft_log_record"
|
|
968
|
+
record_cls = DraftChangeLogRecord
|
|
969
|
+
elif isinstance(change_log, PublishLog):
|
|
970
|
+
branch = "published"
|
|
971
|
+
log_record_relation = "publish_log_record"
|
|
972
|
+
record_cls = PublishLogRecord # type: ignore[assignment]
|
|
973
|
+
else:
|
|
974
|
+
raise TypeError(
|
|
975
|
+
f"expected DraftChangeLog or PublishLog, not {type(change_log)}"
|
|
704
976
|
)
|
|
705
977
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
978
|
+
dependencies_prefetch = Prefetch(
|
|
979
|
+
"new_version__dependencies",
|
|
980
|
+
queryset=PublishableEntity.objects
|
|
981
|
+
.select_related(
|
|
982
|
+
f"{branch}__version",
|
|
983
|
+
f"{branch}__{log_record_relation}",
|
|
984
|
+
)
|
|
985
|
+
.order_by(f"{branch}__version__uuid")
|
|
986
|
+
)
|
|
987
|
+
changed_records: QuerySet[DraftChangeLogRecord] | QuerySet[PublishLogRecord]
|
|
988
|
+
changed_records = (
|
|
989
|
+
change_log.records
|
|
990
|
+
.select_related("new_version", f"entity__{branch}")
|
|
991
|
+
.prefetch_related(dependencies_prefetch)
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
record_ids_to_hash_digests: dict[int, str | None] = {}
|
|
995
|
+
record_ids_to_live_deps: dict[int, list[PublishableEntity]] = {}
|
|
996
|
+
records_that_need_hashes = []
|
|
997
|
+
|
|
998
|
+
for record in changed_records:
|
|
999
|
+
# This is a soft-deletion, so the dependency hash is default/blank. We
|
|
1000
|
+
# set this value in our record_ids_to_hash_digests cache, but we don't
|
|
1001
|
+
# need to write it to the database because it's just the default value.
|
|
1002
|
+
if record.new_version is None:
|
|
1003
|
+
record_ids_to_hash_digests[record.id] = ''
|
|
1004
|
+
continue
|
|
1005
|
+
|
|
1006
|
+
# Now check to see if the new version has "live" dependencies, i.e.
|
|
1007
|
+
# dependencies that have not been deleted.
|
|
1008
|
+
deps = list(
|
|
1009
|
+
entity for entity in record.new_version.dependencies.all()
|
|
1010
|
+
if hasattr(entity, branch) and getattr(entity, branch).version
|
|
726
1011
|
)
|
|
727
1012
|
|
|
1013
|
+
# If there are no live dependencies, this log record also gets the
|
|
1014
|
+
# default/blank value.
|
|
1015
|
+
if not deps:
|
|
1016
|
+
record_ids_to_hash_digests[record.id] = ''
|
|
1017
|
+
continue
|
|
1018
|
+
|
|
1019
|
+
# If we've gotten this far, it means that this record has dependencies
|
|
1020
|
+
# and does need to get a hash computed for it.
|
|
1021
|
+
records_that_need_hashes.append(record)
|
|
1022
|
+
record_ids_to_live_deps[record.id] = deps
|
|
1023
|
+
|
|
1024
|
+
if backfill:
|
|
1025
|
+
untrusted_record_id_set = None
|
|
1026
|
+
else:
|
|
1027
|
+
untrusted_record_id_set = set(rec.id for rec in records_that_need_hashes)
|
|
1028
|
+
|
|
1029
|
+
for record in records_that_need_hashes:
|
|
1030
|
+
record.dependencies_hash_digest = hash_for_log_record(
|
|
1031
|
+
record,
|
|
1032
|
+
record_ids_to_hash_digests,
|
|
1033
|
+
record_ids_to_live_deps,
|
|
1034
|
+
untrusted_record_id_set,
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
_bulk_update_hashes(record_cls, records_that_need_hashes)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _bulk_update_hashes(model_cls, records):
|
|
1041
|
+
"""
|
|
1042
|
+
bulk_update using the model class (PublishLogRecord or DraftChangeLogRecord)
|
|
1043
|
+
|
|
1044
|
+
The only reason this function exists is because mypy 1.18.2 throws an
|
|
1045
|
+
exception in validate_bulk_update() during "make quality" checks otherwise
|
|
1046
|
+
(though curiously enough, not when that same version of mypy is called
|
|
1047
|
+
directly). Given that I'm writing this on the night before the Ulmo release
|
|
1048
|
+
cut, I'm not really interested in tracking down the underlying issue.
|
|
1049
|
+
|
|
1050
|
+
The lack of type annotations on this function is very intentional.
|
|
1051
|
+
"""
|
|
1052
|
+
model_cls.objects.bulk_update(records, ['dependencies_hash_digest'])
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def hash_for_log_record(
|
|
1056
|
+
record: DraftChangeLogRecord | PublishLogRecord,
|
|
1057
|
+
record_ids_to_hash_digests: dict,
|
|
1058
|
+
record_ids_to_live_deps: dict,
|
|
1059
|
+
untrusted_record_id_set: set | None,
|
|
1060
|
+
) -> str:
|
|
1061
|
+
"""
|
|
1062
|
+
The hash digest for a given change log record.
|
|
1063
|
+
|
|
1064
|
+
Note that this code is a little convoluted because we're working hard to
|
|
1065
|
+
minimize the number of database requests. All the data we really need could
|
|
1066
|
+
be derived from querying various relations off the record that's passed in
|
|
1067
|
+
as the first parameter, but at a far higher cost.
|
|
1068
|
+
|
|
1069
|
+
The hash calculated here will be used for the dependencies_hash_digest
|
|
1070
|
+
attribute of DraftChangeLogRecord and PublishLogRecord. The hash is intended
|
|
1071
|
+
to calculate the currently "live" (current draft or published) state of all
|
|
1072
|
+
dependencies (and transitive dependencies) of the PublishableEntityVersion
|
|
1073
|
+
pointed to by DraftChangeLogRecord.new_version/PublishLogRecord.new_version.
|
|
1074
|
+
|
|
1075
|
+
The common case we have at the moment is when a container type like a Unit
|
|
1076
|
+
has unpinned child Components as dependencies. In the data model, those
|
|
1077
|
+
dependency relationships are represented by the "dependencies" M:M relation
|
|
1078
|
+
on PublishableEntityVersion. Since the Unit version's references to its
|
|
1079
|
+
child Components are unpinned, the draft Unit is always pointing to the
|
|
1080
|
+
latest draft versions of those Components and the published Unit is always
|
|
1081
|
+
pointing to the latest published versions of those Components.
|
|
1082
|
+
|
|
1083
|
+
This means that the total draft or published state of any PublishableEntity
|
|
1084
|
+
depends on the combination of:
|
|
1085
|
+
|
|
1086
|
+
1. The definition of the current draft/published version of that entity.
|
|
1087
|
+
Example: Version 1 of a Unit would define that it had children [C1, C2].
|
|
1088
|
+
Version 2 of the same Unit might have children [C1, C2, C3].
|
|
1089
|
+
2. The current draft/published versions of all dependencies. Example: What
|
|
1090
|
+
are the current draft and published versions of C1, C2, and C3.
|
|
1091
|
+
|
|
1092
|
+
This is why it makes sense to capture in a log record, since
|
|
1093
|
+
PublishLogRecords or DraftChangeLogRecords are created whenever one of the
|
|
1094
|
+
above two things changes.
|
|
1095
|
+
|
|
1096
|
+
Here are the possible scenarios, including edge cases:
|
|
1097
|
+
|
|
1098
|
+
EntityVersions with no dependencies
|
|
1099
|
+
If record.new_version has no dependencies, dependencies_hash_digest is
|
|
1100
|
+
set to the default value of ''. This will be the most common case.
|
|
1101
|
+
|
|
1102
|
+
EntityVersions with dependencies
|
|
1103
|
+
If an EntityVersion has dependencies, then its draft/published state
|
|
1104
|
+
hash is based on the concatenation of, for each non-deleted dependency:
|
|
1105
|
+
(i) the dependency's draft/published EntityVersion primary key, and
|
|
1106
|
+
(ii) the dependency's own draft/published state hash, recursively re-
|
|
1107
|
+
calculated if necessary.
|
|
1108
|
+
|
|
1109
|
+
Soft-deletions
|
|
1110
|
+
If the record.new_version is None, that means we've just soft-deleted
|
|
1111
|
+
something (or published the soft-delete of something). We adopt the
|
|
1112
|
+
convention that if something is soft-deleted, its dependencies_hash_digest
|
|
1113
|
+
is reset to the default value of ''. This is not strictly necessary for
|
|
1114
|
+
the recursive hash calculation, but deleted entities will not have their
|
|
1115
|
+
hash updated even as their non-deleted dependencies are updated underneath
|
|
1116
|
+
them, so we set to '' to avoid falsely implying that the deleted entity's
|
|
1117
|
+
dep hash is up to date.
|
|
1118
|
+
|
|
1119
|
+
EntityVersions with soft-deleted dependencies
|
|
1120
|
+
A soft-deleted dependency isn't counted (it's as if the dependency were
|
|
1121
|
+
removed). If all of an EntityVersion's dependencies are soft-deleted,
|
|
1122
|
+
then it will go back to having to having the default blank string for its
|
|
1123
|
+
dependencies_hash_digest.
|
|
1124
|
+
"""
|
|
1125
|
+
# Case #1: We've already computed this, or it was bootstrapped for us in the
|
|
1126
|
+
# cache because the record is a deletion or doesn't have dependencies.
|
|
1127
|
+
if record.id in record_ids_to_hash_digests:
|
|
1128
|
+
return record_ids_to_hash_digests[record.id]
|
|
1129
|
+
|
|
1130
|
+
# Case #2: The log_record is a dependency of something that was affected by
|
|
1131
|
+
# a change, but the dependency itself did not change in any way (neither
|
|
1132
|
+
# directly, nor as a side-effect).
|
|
1133
|
+
#
|
|
1134
|
+
# Example: A Unit has two Components. One of the Components changed, forcing
|
|
1135
|
+
# us to recalculate the dependencies_hash_digest for that Unit. Doing that
|
|
1136
|
+
# recalculation requires us to fetch the dependencies_hash_digest of the
|
|
1137
|
+
# unchanged child Component as well.
|
|
1138
|
+
#
|
|
1139
|
+
# If we aren't given an explicit untrusted_record_id_set, it means we can't
|
|
1140
|
+
# trust anything. This would happen when we're bootstrapping things with an
|
|
1141
|
+
# initial data migration.
|
|
1142
|
+
if (untrusted_record_id_set is not None) and (record.id not in untrusted_record_id_set):
|
|
1143
|
+
return record.dependencies_hash_digest
|
|
1144
|
+
|
|
1145
|
+
# Normal recursive case starts here:
|
|
1146
|
+
if isinstance(record, DraftChangeLogRecord):
|
|
1147
|
+
branch = "draft"
|
|
1148
|
+
elif isinstance(record, PublishLogRecord):
|
|
1149
|
+
branch = "published"
|
|
1150
|
+
else:
|
|
1151
|
+
raise TypeError(
|
|
1152
|
+
f"expected DraftChangeLogRecord or PublishLogRecord, not {type(record)}"
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
# This is extra work that only happens in case of a backfill, where we might
|
|
1156
|
+
# need to compute dependency hashes for things outside of our log (because
|
|
1157
|
+
# we don't trust them).
|
|
1158
|
+
if record.id not in record_ids_to_live_deps:
|
|
1159
|
+
if record.new_version is None:
|
|
1160
|
+
record_ids_to_hash_digests[record.id] = ''
|
|
1161
|
+
return ''
|
|
1162
|
+
deps = list(
|
|
1163
|
+
entity for entity in record.new_version.dependencies.all()
|
|
1164
|
+
if hasattr(entity, branch) and getattr(entity, branch).version
|
|
1165
|
+
)
|
|
1166
|
+
# If there are no live dependencies, this log record also gets the
|
|
1167
|
+
# default/blank value.
|
|
1168
|
+
if not deps:
|
|
1169
|
+
record_ids_to_hash_digests[record.id] = ''
|
|
1170
|
+
return ''
|
|
1171
|
+
|
|
1172
|
+
record_ids_to_live_deps[record.id] = deps
|
|
1173
|
+
# End special handling for backfill.
|
|
1174
|
+
|
|
1175
|
+
# Begin normal
|
|
1176
|
+
dependencies: list[PublishableEntity] = sorted(
|
|
1177
|
+
record_ids_to_live_deps[record.id],
|
|
1178
|
+
key=lambda entity: getattr(entity, branch).log_record.new_version_id,
|
|
1179
|
+
)
|
|
1180
|
+
dep_state_entries = []
|
|
1181
|
+
for dep_entity in dependencies:
|
|
1182
|
+
new_version_id = getattr(dep_entity, branch).log_record.new_version_id
|
|
1183
|
+
hash_digest = hash_for_log_record(
|
|
1184
|
+
getattr(dep_entity, branch).log_record,
|
|
1185
|
+
record_ids_to_hash_digests,
|
|
1186
|
+
record_ids_to_live_deps,
|
|
1187
|
+
untrusted_record_id_set,
|
|
1188
|
+
)
|
|
1189
|
+
dep_state_entries.append(f"{new_version_id}:{hash_digest}")
|
|
1190
|
+
summary_text = "\n".join(dep_state_entries)
|
|
1191
|
+
|
|
1192
|
+
digest = create_hash_digest(summary_text.encode(), num_bytes=4)
|
|
1193
|
+
record_ids_to_hash_digests[record.id] = digest
|
|
1194
|
+
|
|
1195
|
+
return digest
|
|
1196
|
+
|
|
728
1197
|
|
|
729
1198
|
def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None:
|
|
730
1199
|
"""
|
|
@@ -974,7 +1443,6 @@ def create_entity_list_with_rows(
|
|
|
974
1443
|
raise ValidationError("Container entity versions must belong to the specified entity.")
|
|
975
1444
|
|
|
976
1445
|
with atomic(savepoint=False):
|
|
977
|
-
|
|
978
1446
|
entity_list = create_entity_list()
|
|
979
1447
|
EntityListRow.objects.bulk_create(
|
|
980
1448
|
[
|
|
@@ -1012,6 +1480,11 @@ def _create_container_version(
|
|
|
1012
1480
|
title=title,
|
|
1013
1481
|
created=created,
|
|
1014
1482
|
created_by=created_by,
|
|
1483
|
+
dependencies=[
|
|
1484
|
+
entity_row.entity_id
|
|
1485
|
+
for entity_row in entity_list.rows
|
|
1486
|
+
if entity_row.is_unpinned()
|
|
1487
|
+
]
|
|
1015
1488
|
)
|
|
1016
1489
|
container_version = container_version_cls.objects.create(
|
|
1017
1490
|
publishable_entity_version=publishable_entity_version,
|
|
@@ -1378,6 +1851,11 @@ def contains_unpublished_changes(container_id: int) -> bool:
|
|
|
1378
1851
|
[ 🛑 UNSTABLE ]
|
|
1379
1852
|
Check recursively if a container has any unpublished changes.
|
|
1380
1853
|
|
|
1854
|
+
Note: I've preserved the API signature for now, but we probably eventually
|
|
1855
|
+
want to make a more general function that operates on PublishableEntities
|
|
1856
|
+
and dependencies, once we introduce those with courses and their files,
|
|
1857
|
+
grading policies, etc.
|
|
1858
|
+
|
|
1381
1859
|
Note: unlike this method, the similar-sounding
|
|
1382
1860
|
`container.versioning.has_unpublished_changes` property only reports
|
|
1383
1861
|
if the container itself has unpublished changes, not
|
|
@@ -1386,50 +1864,37 @@ def contains_unpublished_changes(container_id: int) -> bool:
|
|
|
1386
1864
|
that's in the container, it will be `False`. This method will return `True`
|
|
1387
1865
|
in either case.
|
|
1388
1866
|
"""
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1867
|
+
container = (
|
|
1868
|
+
Container.objects
|
|
1869
|
+
.select_related('publishable_entity__draft__draft_log_record')
|
|
1870
|
+
.select_related('publishable_entity__published__publish_log_record')
|
|
1871
|
+
.get(pk=container_id)
|
|
1872
|
+
)
|
|
1394
1873
|
if container.versioning.has_unpublished_changes:
|
|
1395
1874
|
return True
|
|
1396
1875
|
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1876
|
+
draft = container.publishable_entity.draft
|
|
1877
|
+
published = container.publishable_entity.published
|
|
1878
|
+
|
|
1879
|
+
# Edge case: A container that was created and then immediately soft-deleted
|
|
1880
|
+
# does not contain any unpublished changes.
|
|
1881
|
+
if draft is None and published is None:
|
|
1401
1882
|
return False
|
|
1402
1883
|
|
|
1403
|
-
#
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
"entity__published__version",
|
|
1411
|
-
):
|
|
1412
|
-
try:
|
|
1413
|
-
child_container = row.entity.container
|
|
1414
|
-
except Container.DoesNotExist:
|
|
1415
|
-
child_container = None
|
|
1416
|
-
if child_container:
|
|
1417
|
-
# This is itself a container - check recursively:
|
|
1418
|
-
if contains_unpublished_changes(child_container.pk):
|
|
1419
|
-
return True
|
|
1420
|
-
else:
|
|
1421
|
-
# This is not a container:
|
|
1422
|
-
draft_pk = row.entity.draft.version_id if row.entity.draft else None
|
|
1423
|
-
published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None
|
|
1424
|
-
if draft_pk != published_pk:
|
|
1425
|
-
return True
|
|
1426
|
-
return False
|
|
1884
|
+
# The dependencies_hash_digest captures the state of all descendants, so we
|
|
1885
|
+
# can do this quick comparison instead of iterating through layers of
|
|
1886
|
+
# containers.
|
|
1887
|
+
draft_version_hash_digest = draft.log_record.dependencies_hash_digest
|
|
1888
|
+
published_version_hash_digest = published.log_record.dependencies_hash_digest
|
|
1889
|
+
|
|
1890
|
+
return draft_version_hash_digest != published_version_hash_digest
|
|
1427
1891
|
|
|
1428
1892
|
|
|
1429
1893
|
def get_containers_with_entity(
|
|
1430
1894
|
publishable_entity_pk: int,
|
|
1431
1895
|
*,
|
|
1432
1896
|
ignore_pinned=False,
|
|
1897
|
+
published=False,
|
|
1433
1898
|
) -> QuerySet[Container]:
|
|
1434
1899
|
"""
|
|
1435
1900
|
[ 🛑 UNSTABLE ]
|
|
@@ -1442,20 +1907,31 @@ def get_containers_with_entity(
|
|
|
1442
1907
|
publishable_entity_pk: The ID of the PublishableEntity to search for.
|
|
1443
1908
|
ignore_pinned: if true, ignore any pinned references to the entity.
|
|
1444
1909
|
"""
|
|
1910
|
+
branch = "published" if published else "draft"
|
|
1445
1911
|
if ignore_pinned:
|
|
1446
|
-
|
|
1447
|
-
# Note: these two conditions must be in the same filter() call,
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1912
|
+
filter_dict = {
|
|
1913
|
+
# Note: these two conditions must be in the same filter() call,
|
|
1914
|
+
# or the query won't be correct.
|
|
1915
|
+
(
|
|
1916
|
+
f"publishable_entity__{branch}__version__"
|
|
1917
|
+
"containerversion__entity_list__entitylistrow__entity_id"
|
|
1918
|
+
): publishable_entity_pk,
|
|
1919
|
+
(
|
|
1920
|
+
f"publishable_entity__{branch}__version__"
|
|
1921
|
+
"containerversion__entity_list__entitylistrow__entity_version_id"
|
|
1922
|
+
): None,
|
|
1923
|
+
}
|
|
1924
|
+
qs = Container.objects.filter(**filter_dict)
|
|
1451
1925
|
else:
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1926
|
+
filter_dict = {
|
|
1927
|
+
(
|
|
1928
|
+
f"publishable_entity__{branch}__version__"
|
|
1929
|
+
"containerversion__entity_list__entitylistrow__entity_id"
|
|
1930
|
+
): publishable_entity_pk
|
|
1931
|
+
}
|
|
1932
|
+
qs = Container.objects.filter(**filter_dict)
|
|
1933
|
+
|
|
1934
|
+
return qs.order_by("pk").distinct() # Ordering is mostly for consistent test cases.
|
|
1459
1935
|
|
|
1460
1936
|
|
|
1461
1937
|
def get_container_children_count(
|
|
@@ -1539,6 +2015,6 @@ def bulk_draft_changes_for(
|
|
|
1539
2015
|
changed_at=changed_at,
|
|
1540
2016
|
changed_by=changed_by,
|
|
1541
2017
|
exit_callbacks=[
|
|
1542
|
-
|
|
2018
|
+
_create_side_effects_for_change_log,
|
|
1543
2019
|
]
|
|
1544
2020
|
)
|