openedx-learning 0.22.0__py2.py3-none-any.whl → 0.23.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,4 +2,4 @@
2
2
  Open edX Learning ("Learning Core").
3
3
  """
4
4
 
5
- __version__ = "0.22.0"
5
+ __version__ = "0.23.0"
@@ -4,10 +4,19 @@ Django admin for publishing models
4
4
  from __future__ import annotations
5
5
 
6
6
  from django.contrib import admin
7
+ from django.db.models import Count
7
8
 
8
9
  from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html
9
10
 
10
- from .models import LearningPackage, PublishableEntity, Published, PublishLog, PublishLogRecord
11
+ from .models import (
12
+ DraftChangeLog,
13
+ DraftChangeLogRecord,
14
+ LearningPackage,
15
+ PublishableEntity,
16
+ PublishLog,
17
+ PublishLogRecord,
18
+ )
19
+ from .models.publish_log import Published
11
20
 
12
21
 
13
22
  @admin.register(LearningPackage)
@@ -171,3 +180,69 @@ class PublishedAdmin(ReadOnlyModelAdmin):
171
180
 
172
181
  def message(self, published_obj):
173
182
  return published_obj.publish_log_record.publish_log.message
183
+
184
+
185
+ class DraftChangeLogRecordTabularInline(admin.TabularInline):
186
+ """
187
+ Tabular inline for a single Draft change.
188
+ """
189
+ model = DraftChangeLogRecord
190
+
191
+ fields = (
192
+ "entity",
193
+ "title",
194
+ "old_version_num",
195
+ "new_version_num",
196
+ )
197
+ readonly_fields = fields
198
+
199
+ def get_queryset(self, request):
200
+ queryset = super().get_queryset(request)
201
+ return queryset.select_related("entity", "old_version", "new_version") \
202
+ .order_by("entity__key")
203
+
204
+ def old_version_num(self, draft_change: DraftChangeLogRecord):
205
+ if draft_change.old_version is None:
206
+ return "-"
207
+ return draft_change.old_version.version_num
208
+
209
+ def new_version_num(self, draft_change: DraftChangeLogRecord):
210
+ if draft_change.new_version is None:
211
+ return "-"
212
+ return draft_change.new_version.version_num
213
+
214
+ def title(self, draft_change: DraftChangeLogRecord):
215
+ """
216
+ Get the title to display for the DraftChange
217
+ """
218
+ if draft_change.new_version:
219
+ return draft_change.new_version.title
220
+ if draft_change.old_version:
221
+ return draft_change.old_version.title
222
+ return ""
223
+
224
+
225
+ @admin.register(DraftChangeLog)
226
+ class DraftChangeSetAdmin(ReadOnlyModelAdmin):
227
+ """
228
+ Read-only admin to view Draft changes (via inline tables)
229
+ """
230
+ inlines = [DraftChangeLogRecordTabularInline]
231
+ fields = (
232
+ "uuid",
233
+ "learning_package",
234
+ "num_changes",
235
+ "changed_at",
236
+ "changed_by",
237
+ )
238
+ readonly_fields = fields
239
+ list_display = fields
240
+ list_filter = ["learning_package"]
241
+
242
+ def num_changes(self, draft_change_set):
243
+ return draft_change_set.num_changes
244
+
245
+ def get_queryset(self, request):
246
+ queryset = super().get_queryset(request)
247
+ return queryset.select_related("learning_package", "changed_by") \
248
+ .annotate(num_changes=Count("records"))
@@ -6,19 +6,24 @@ are stored in this app.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ from contextlib import nullcontext
9
10
  from dataclasses import dataclass
10
11
  from datetime import datetime, timezone
11
12
  from enum import Enum
12
- from typing import TypeVar
13
+ from typing import ContextManager, TypeVar
13
14
 
14
15
  from django.core.exceptions import ObjectDoesNotExist, ValidationError
15
16
  from django.db.models import F, Q, QuerySet
16
17
  from django.db.transaction import atomic
17
18
 
19
+ from .contextmanagers import DraftChangeLogContext
18
20
  from .models import (
19
21
  Container,
20
22
  ContainerVersion,
21
23
  Draft,
24
+ DraftChangeLog,
25
+ DraftChangeLogRecord,
26
+ DraftSideEffect,
22
27
  EntityList,
23
28
  EntityListRow,
24
29
  LearningPackage,
@@ -27,10 +32,10 @@ from .models import (
27
32
  PublishableEntityMixin,
28
33
  PublishableEntityVersion,
29
34
  PublishableEntityVersionMixin,
30
- Published,
31
35
  PublishLog,
32
36
  PublishLogRecord,
33
37
  )
38
+ from .models.publish_log import Published
34
39
 
35
40
  # A few of the APIs in this file are generic and can be used for Containers in
36
41
  # general, or e.g. Units (subclass of Container) in particular. These type
@@ -82,6 +87,7 @@ __all__ = [
82
87
  "contains_unpublished_changes",
83
88
  "get_containers_with_entity",
84
89
  "get_container_children_count",
90
+ "bulk_draft_changes_for",
85
91
  ]
86
92
 
87
93
 
@@ -220,10 +226,13 @@ def create_publishable_entity_version(
220
226
  created=created,
221
227
  created_by_id=created_by,
222
228
  )
223
- Draft.objects.update_or_create(
224
- entity_id=entity_id,
225
- defaults={"version": version},
229
+ set_draft_version(
230
+ entity_id,
231
+ version.id,
232
+ set_at=created,
233
+ set_by=created_by,
226
234
  )
235
+
227
236
  return version
228
237
 
229
238
 
@@ -447,25 +456,247 @@ def get_published_version(publishable_entity_id: int, /) -> PublishableEntityVer
447
456
 
448
457
 
449
458
  def set_draft_version(
450
- publishable_entity_id: int,
459
+ draft_or_id: Draft | int,
451
460
  publishable_entity_version_pk: int | None,
452
461
  /,
462
+ set_at: datetime | None = None,
463
+ set_by: int | None = None, # User.id
453
464
  ) -> None:
454
465
  """
455
466
  Modify the Draft of a PublishableEntity to be a PublishableEntityVersion.
456
467
 
468
+ The ``draft`` argument can be either a Draft model object, or the primary
469
+ key of a Draft/PublishableEntity (Draft is defined so these will be the same
470
+ value).
471
+
457
472
  This would most commonly be used to set the Draft to point to a newly
458
473
  created PublishableEntityVersion that was created in Studio (because someone
459
474
  edited some content). Setting a Draft's version to None is like deleting it
460
475
  from Studio's editing point of view (see ``soft_delete_draft`` for more
461
476
  details).
477
+
478
+ Calling this function attaches a new DraftChangeLogRecordand attaches it to a
479
+ DraftChangeLog.
480
+
481
+ This function will create DraftSideEffect entries and properly add any
482
+ containers that may have been affected by this draft update, UNLESS it is
483
+ called from within a bulk_draft_changes_for block. If it is called from
484
+ inside a bulk_draft_changes_for block, it will not add side-effects for
485
+ containers, as bulk_draft_changes_for will automatically do that when the
486
+ block exits.
487
+ """
488
+ if set_at is None:
489
+ set_at = datetime.now(tz=timezone.utc)
490
+
491
+ with atomic(savepoint=False):
492
+ if isinstance(draft_or_id, Draft):
493
+ draft = draft_or_id
494
+ elif isinstance(draft_or_id, int):
495
+ draft, _created = Draft.objects.select_related("entity") \
496
+ .get_or_create(entity_id=draft_or_id)
497
+ else:
498
+ class_name = draft_or_id.__class__.__name__
499
+ raise TypeError(
500
+ f"draft_or_id must be a Draft or int, not ({class_name})"
501
+ )
502
+
503
+ # If the Draft is already pointing at this version, there's nothing to do.
504
+ old_version_id = draft.version_id
505
+ if old_version_id == publishable_entity_version_pk:
506
+ return
507
+
508
+ # The actual update of the Draft model is here. Everything after this
509
+ # block is bookkeeping in our DraftChangeLog.
510
+ draft.version_id = publishable_entity_version_pk
511
+ draft.save()
512
+
513
+ # Check to see if we're inside a context manager for an active
514
+ # DraftChangeLog (i.e. what happens if the caller is using the public
515
+ # bulk_draft_changes_for() API call), or if we have to make our own.
516
+ learning_package_id = draft.entity.learning_package_id
517
+ active_change_log = DraftChangeLogContext.get_active_draft_change_log(
518
+ learning_package_id
519
+ )
520
+ if active_change_log:
521
+ change = _add_to_existing_draft_change_log(
522
+ active_change_log,
523
+ draft.entity_id,
524
+ old_version_id=old_version_id,
525
+ new_version_id=publishable_entity_version_pk,
526
+ )
527
+ # We explicitly *don't* create container side effects here because
528
+ # there may be many changes in this DraftChangeLog, some of which
529
+ # haven't been made yet. It wouldn't make sense to create a side
530
+ # effect that says, "this Unit changed because this Component in it
531
+ # changed" if we were changing that same Unit later on in the same
532
+ # DraftChangeLog, because that new Unit version might not even
533
+ # include the child Component. So we'll let DraftChangeLogContext
534
+ # do that work when it exits its context.
535
+ else:
536
+ # This means there is no active DraftChangeLog, so we create our own
537
+ # and add our DraftChangeLogRecord to it. This has the minor
538
+ # optimization that we don't have to check for an existing
539
+ # DraftChangeLogRecord, because we know it can't exist yet.
540
+ change_log = DraftChangeLog.objects.create(
541
+ learning_package_id=learning_package_id,
542
+ changed_at=set_at,
543
+ changed_by_id=set_by,
544
+ )
545
+ change = DraftChangeLogRecord.objects.create(
546
+ draft_change_log=change_log,
547
+ entity_id=draft.entity_id,
548
+ old_version_id=old_version_id,
549
+ new_version_id=publishable_entity_version_pk,
550
+ )
551
+ _create_container_side_effects_for_draft_change(change)
552
+
553
+
554
+ def _add_to_existing_draft_change_log(
555
+ active_change_log: DraftChangeLog,
556
+ entity_id: int,
557
+ old_version_id: int | None,
558
+ new_version_id: int | None,
559
+ ) -> DraftChangeLogRecord:
560
+ """
561
+ Create or update a DraftChangeLogRecord for the active_change_log passed in.
562
+
563
+ The an active_change_log may have many DraftChangeLogRecords already
564
+ associated with it. A DraftChangeLog can only have one DraftChangeLogRecord
565
+ per PublishableEntity, e.g. the same Component can't go from v1 to v2 and v2
566
+ to v3 in the same DraftChangeLog. The DraftChangeLogRecord is meant to
567
+ capture the before and after states of the Draft version for that entity,
568
+ so we always keep the first value for old_version, while updating to the
569
+ most recent value for new_version.
570
+
571
+ So for example, if we called this function with the same active_change_log
572
+ and the same entity_id but with versions: (None, v1), (v1, v2), (v2, v3);
573
+ we would collapse them into one DraftChangeLogrecord with old_version = None
574
+ and new_version = v3.
575
+ """
576
+ try:
577
+ # Check to see if this PublishableEntity has already been changed in
578
+ # this DraftChangeLog. If so, we update that record instead of creating
579
+ # a new one.
580
+ change = DraftChangeLogRecord.objects.get(
581
+ draft_change_log=active_change_log,
582
+ entity_id=entity_id,
583
+ )
584
+ if change.old_version_id == new_version_id:
585
+ # Special case: This change undoes the previous change(s). The value
586
+ # in change.old_version_id represents the Draft version before the
587
+ # DraftChangeLog was started, regardless of how many times we've
588
+ # changed it since we entered the bulk_draft_changes_for() context.
589
+ # If we get here in the code, it means that we're now setting the
590
+ # Draft version of this entity to be exactly what it was at the
591
+ # start, and we should remove it entirely from the DraftChangeLog.
592
+ #
593
+ # It's important that we remove these cases, because we use the
594
+ # old_version == new_version convention to record entities that have
595
+ # changed purely due to side-effects.
596
+ change.delete()
597
+ else:
598
+ # Normal case: We update the new_version, but leave the old_version
599
+ # as is. The old_version represents what the Draft was pointing to
600
+ # when the bulk_draft_changes_for() context started, so it persists
601
+ # if we change the same entity multiple times in the DraftChangeLog.
602
+ change.new_version_id = new_version_id
603
+ change.save()
604
+ except DraftChangeLogRecord.DoesNotExist:
605
+ # If we're here, this is the first DraftChangeLogRecord we're making for
606
+ # this PublishableEntity in the active DraftChangeLog.
607
+ change = DraftChangeLogRecord.objects.create(
608
+ draft_change_log=active_change_log,
609
+ entity_id=entity_id,
610
+ old_version_id=old_version_id,
611
+ new_version_id=new_version_id,
612
+ )
613
+
614
+ return change
615
+
616
+
617
+ def _create_container_side_effects_for_draft_change_log(change_log: DraftChangeLog):
462
618
  """
463
- draft = Draft.objects.get(entity_id=publishable_entity_id)
464
- draft.version_id = publishable_entity_version_pk
465
- draft.save()
619
+ Iterate through the whole DraftChangeLog and process side-effects.
620
+ """
621
+ processed_entity_ids: set[int] = set()
622
+ for change in change_log.records.all():
623
+ _create_container_side_effects_for_draft_change(
624
+ change,
625
+ processed_entity_ids=processed_entity_ids,
626
+ )
627
+
628
+
629
+ def _create_container_side_effects_for_draft_change(
630
+ original_change: DraftChangeLogRecord,
631
+ processed_entity_ids: set | None = None
632
+ ):
633
+ """
634
+ Given a draft change, add side effects for all affected containers.
635
+
636
+ This should only be run after the DraftChangeLogRecord has been otherwise
637
+ fully written out. We want to avoid the scenario where we create a
638
+ side-effect that a Component change affects a Unit if the Unit version is
639
+ also changed (maybe even deleted) in the same DraftChangeLog.
640
+
641
+ The `processed_entity_ids` set holds the entity IDs that we've already
642
+ calculated side-effects for. This is to save us from recalculating side-
643
+ effects for the same ancestor relationships over and over again. So if we're
644
+ calling this function in a loop for all the Components in a Unit, we won't
645
+ be recalculating the Unit's side-effect on its Subsection, and its
646
+ Subsection's side-effect on its Section.
647
+
648
+ TODO: This could get very expensive with the get_containers_with_entity
649
+ calls. We should measure the impact of this.
650
+ """
651
+ if processed_entity_ids is None:
652
+ # An optimization, but also a guard against infinite side-effect loops.
653
+ processed_entity_ids = set()
654
+
655
+ changes_and_containers = [
656
+ (original_change, container)
657
+ for container
658
+ in get_containers_with_entity(original_change.entity_id, ignore_pinned=True)
659
+ ]
660
+ while changes_and_containers:
661
+ change, container = changes_and_containers.pop()
662
+
663
+ # If the container is not already in the DraftChangeLog, we need to
664
+ # add it. Since it's being caused as a DraftSideEffect, we're going
665
+ # add it with the old_version == new_version convention.
666
+ container_draft_version_pk = container.versioning.draft.pk
667
+ container_change, _created = DraftChangeLogRecord.objects.get_or_create(
668
+ draft_change_log=change.draft_change_log,
669
+ entity_id=container.pk,
670
+ defaults={
671
+ 'old_version_id': container_draft_version_pk,
672
+ 'new_version_id': container_draft_version_pk
673
+ }
674
+ )
675
+
676
+ # Mark that change in the current loop has the side effect of changing
677
+ # the parent container. We'll do this regardless of whether the
678
+ # container version itself also changed. If a Unit has a Component and
679
+ # both the Unit and Component have their versions incremented, then the
680
+ # Unit has changed in both ways (the Unit's internal metadata as well as
681
+ # the new version of the child component).
682
+ DraftSideEffect.objects.get_or_create(cause=change, effect=container_change)
683
+ processed_entity_ids.add(change.entity_id)
684
+
685
+ # Now we find the next layer up of containers. So if the originally
686
+ # passed in publishable_entity_id was for a Component, then the
687
+ # ``container`` we've been creating the side effect for in this loop
688
+ # is the Unit, and ``parents_of_container`` would be any Sequences
689
+ # that contain the Unit.
690
+ parents_of_container = get_containers_with_entity(container.pk, ignore_pinned=True)
691
+
692
+ changes_and_containers.extend(
693
+ (container_change, container_parent)
694
+ for container_parent in parents_of_container
695
+ if container_parent.pk not in processed_entity_ids
696
+ )
466
697
 
467
698
 
468
- def soft_delete_draft(publishable_entity_id: int, /) -> None:
699
+ def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None:
469
700
  """
470
701
  Sets the Draft version to None.
471
702
 
@@ -475,10 +706,15 @@ def soft_delete_draft(publishable_entity_id: int, /) -> None:
475
706
  of pointing the Draft back to the most recent ``PublishableEntityVersion``
476
707
  for a given ``PublishableEntity``.
477
708
  """
478
- return set_draft_version(publishable_entity_id, None)
709
+ return set_draft_version(publishable_entity_id, None, set_by=deleted_by)
479
710
 
480
711
 
481
- def reset_drafts_to_published(learning_package_id: int, /) -> None:
712
+ def reset_drafts_to_published(
713
+ learning_package_id: int,
714
+ /,
715
+ reset_at: datetime | None = None,
716
+ reset_by: int | None = None, # User.id
717
+ ) -> None:
482
718
  """
483
719
  Reset all Drafts to point to the most recently Published versions.
484
720
 
@@ -502,27 +738,55 @@ def reset_drafts_to_published(learning_package_id: int, /) -> None:
502
738
  it's important that the code creating the "next" version_num looks at the
503
739
  latest version created for a PublishableEntity (its ``latest`` attribute),
504
740
  rather than basing it off of the version that Draft points to.
505
-
506
- Also, there is no current immutable record for when a reset happens. It's
507
- not like a publish that leaves an entry in the ``PublishLog``.
508
741
  """
742
+ if reset_at is None:
743
+ reset_at = datetime.now(tz=timezone.utc)
744
+
509
745
  # These are all the drafts that are different from the published versions.
510
746
  draft_qset = Draft.objects \
511
747
  .select_related("entity__published") \
512
748
  .filter(entity__learning_package_id=learning_package_id) \
513
- .exclude(entity__published__version_id=F("version_id"))
749
+ .exclude(entity__published__version_id=F("version_id")) \
750
+ .exclude(
751
+ # NULL != NULL in SQL, so we want to exclude entries
752
+ # where both the published version and draft version
753
+ # are None. This edge case happens when we create
754
+ # something and then delete it without publishing, and
755
+ # then reset Drafts to their published state.
756
+ Q(entity__published__version__isnull=True) &
757
+ Q(version__isnull=True)
758
+ )
759
+ # If there's nothing to reset because there are no changes from the
760
+ # published version, just return early rather than making an empty
761
+ # DraftChangeLog.
762
+ if not draft_qset:
763
+ return
764
+
765
+ active_change_log = DraftChangeLogContext.get_active_draft_change_log(learning_package_id)
766
+
767
+ # If there's an active DraftChangeLog, we're already in a transaction, so
768
+ # there's no need to open a new one.
769
+ tx_context: ContextManager
770
+ if active_change_log:
771
+ tx_context = nullcontext()
772
+ else:
773
+ tx_context = bulk_draft_changes_for(
774
+ learning_package_id, changed_at=reset_at, changed_by=reset_by
775
+ )
514
776
 
515
- # Note: We can't do an .update with a F() on a joined field in the ORM, so
516
- # we have to loop through the drafts individually to reset them. We can
517
- # rework this into a bulk update or custom SQL if it becomes a performance
518
- # issue.
519
- with atomic():
777
+ with tx_context:
778
+ # Note: We can't do an .update with a F() on a joined field in the ORM,
779
+ # so we have to loop through the drafts individually to reset them
780
+ # anyhow. We can rework this into a bulk update or custom SQL if it
781
+ # becomes a performance issue, as long as we also port over the
782
+ # bookkeeping code in set_draft_version.
520
783
  for draft in draft_qset:
521
784
  if hasattr(draft.entity, 'published'):
522
- draft.version_id = draft.entity.published.version_id
785
+ published_version_id = draft.entity.published.version_id
523
786
  else:
524
- draft.version = None
525
- draft.save()
787
+ published_version_id = None
788
+
789
+ set_draft_version(draft, published_version_id)
526
790
 
527
791
 
528
792
  def register_content_models(
@@ -1103,6 +1367,7 @@ def get_containers_with_entity(
1103
1367
  ignore_pinned: if true, ignore any pinned references to the entity.
1104
1368
  """
1105
1369
  if ignore_pinned:
1370
+ # TODO: Do we need to run distinct() on this? Will fix in https://github.com/openedx/openedx-learning/issues/303
1106
1371
  qs = Container.objects.filter(
1107
1372
  # Note: these two conditions must be in the same filter() call, or the query won't be correct.
1108
1373
  publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
@@ -1145,3 +1410,47 @@ def get_container_children_count(
1145
1410
  else:
1146
1411
  filter_deleted = {"entity__draft__version__isnull": False}
1147
1412
  return container_version.entity_list.entitylistrow_set.filter(**filter_deleted).count()
1413
+
1414
+
1415
+ def bulk_draft_changes_for(
1416
+ learning_package_id: int,
1417
+ changed_by: int | None = None,
1418
+ changed_at: datetime | None = None
1419
+ ) -> DraftChangeLogContext:
1420
+ """
1421
+ Context manager to do a single batch of Draft changes in.
1422
+
1423
+ Each publishable entity that is edited in this context will be tied to a
1424
+ single DraftChangeLogRecord, representing the cumulative changes made to
1425
+ that entity. Upon closing of the context, side effects of these changes will
1426
+ be calcuated, which may result in more DraftChangeLogRecords being created
1427
+ or updated. The resulting DraftChangeLogRecords and DraftChangeSideEffects
1428
+ will be tied together into a single DraftChangeLog, representing the
1429
+ collective changes to the learning package that happened in this context.
1430
+ All changes will be committed in a single atomic transaction.
1431
+
1432
+ Example::
1433
+
1434
+ with bulk_draft_changes_for(learning_package.id):
1435
+ for section in course:
1436
+ update_section_drafts(learning_package.id, section)
1437
+
1438
+ If you make a change to an entity *without* using this context manager, then
1439
+ the individual change (and its side effects) will be automatically wrapped
1440
+ in a one-off change context. For example, this::
1441
+
1442
+ update_one_component(component.learning_package, component)
1443
+
1444
+ is identical to this::
1445
+
1446
+ with bulk_draft_changes_for(component.learning_package.id):
1447
+ update_one_component(component.learning_package.id, component)
1448
+ """
1449
+ return DraftChangeLogContext(
1450
+ learning_package_id,
1451
+ changed_at=changed_at,
1452
+ changed_by=changed_by,
1453
+ exit_callbacks=[
1454
+ _create_container_side_effects_for_draft_change_log,
1455
+ ]
1456
+ )