openedx-learning 0.20.0__py2.py3-none-any.whl → 0.23.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openedx_learning/__init__.py +1 -1
- openedx_learning/apps/authoring/collections/api.py +46 -1
- openedx_learning/apps/authoring/components/api.py +1 -55
- openedx_learning/apps/authoring/publishing/admin.py +76 -1
- openedx_learning/apps/authoring/publishing/api.py +417 -76
- openedx_learning/apps/authoring/publishing/contextmanagers.py +157 -0
- openedx_learning/apps/authoring/publishing/migrations/0006_draftchangelog.py +68 -0
- openedx_learning/apps/authoring/publishing/migrations/0007_bootstrap_draftchangelog.py +94 -0
- openedx_learning/apps/authoring/publishing/models/__init__.py +2 -2
- openedx_learning/apps/authoring/publishing/models/draft_log.py +315 -0
- openedx_learning/apps/authoring/publishing/models/publish_log.py +44 -0
- openedx_learning/apps/authoring/units/api.py +23 -24
- {openedx_learning-0.20.0.dist-info → openedx_learning-0.23.0.dist-info}/METADATA +5 -5
- {openedx_learning-0.20.0.dist-info → openedx_learning-0.23.0.dist-info}/RECORD +17 -14
- openedx_learning/apps/authoring/publishing/models/draft_published.py +0 -95
- {openedx_learning-0.20.0.dist-info → openedx_learning-0.23.0.dist-info}/WHEEL +0 -0
- {openedx_learning-0.20.0.dist-info → openedx_learning-0.23.0.dist-info}/licenses/LICENSE.txt +0 -0
- {openedx_learning-0.20.0.dist-info → openedx_learning-0.23.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
@@ -74,12 +79,15 @@ __all__ = [
|
|
|
74
79
|
"get_container",
|
|
75
80
|
"get_container_by_key",
|
|
76
81
|
"get_containers",
|
|
82
|
+
"get_collection_containers",
|
|
77
83
|
"ChildrenEntitiesAction",
|
|
78
84
|
"ContainerEntityListEntry",
|
|
85
|
+
"ContainerEntityRow",
|
|
79
86
|
"get_entities_in_container",
|
|
80
87
|
"contains_unpublished_changes",
|
|
81
88
|
"get_containers_with_entity",
|
|
82
89
|
"get_container_children_count",
|
|
90
|
+
"bulk_draft_changes_for",
|
|
83
91
|
]
|
|
84
92
|
|
|
85
93
|
|
|
@@ -218,10 +226,13 @@ def create_publishable_entity_version(
|
|
|
218
226
|
created=created,
|
|
219
227
|
created_by_id=created_by,
|
|
220
228
|
)
|
|
221
|
-
|
|
222
|
-
entity_id
|
|
223
|
-
|
|
229
|
+
set_draft_version(
|
|
230
|
+
entity_id,
|
|
231
|
+
version.id,
|
|
232
|
+
set_at=created,
|
|
233
|
+
set_by=created_by,
|
|
224
234
|
)
|
|
235
|
+
|
|
225
236
|
return version
|
|
226
237
|
|
|
227
238
|
|
|
@@ -445,25 +456,247 @@ def get_published_version(publishable_entity_id: int, /) -> PublishableEntityVer
|
|
|
445
456
|
|
|
446
457
|
|
|
447
458
|
def set_draft_version(
|
|
448
|
-
|
|
459
|
+
draft_or_id: Draft | int,
|
|
449
460
|
publishable_entity_version_pk: int | None,
|
|
450
461
|
/,
|
|
462
|
+
set_at: datetime | None = None,
|
|
463
|
+
set_by: int | None = None, # User.id
|
|
451
464
|
) -> None:
|
|
452
465
|
"""
|
|
453
466
|
Modify the Draft of a PublishableEntity to be a PublishableEntityVersion.
|
|
454
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
|
+
|
|
455
472
|
This would most commonly be used to set the Draft to point to a newly
|
|
456
473
|
created PublishableEntityVersion that was created in Studio (because someone
|
|
457
474
|
edited some content). Setting a Draft's version to None is like deleting it
|
|
458
475
|
from Studio's editing point of view (see ``soft_delete_draft`` for more
|
|
459
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.
|
|
460
487
|
"""
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
draft.save()
|
|
488
|
+
if set_at is None:
|
|
489
|
+
set_at = datetime.now(tz=timezone.utc)
|
|
464
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
|
+
)
|
|
465
502
|
|
|
466
|
-
|
|
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):
|
|
618
|
+
"""
|
|
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
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None:
|
|
467
700
|
"""
|
|
468
701
|
Sets the Draft version to None.
|
|
469
702
|
|
|
@@ -473,10 +706,15 @@ def soft_delete_draft(publishable_entity_id: int, /) -> None:
|
|
|
473
706
|
of pointing the Draft back to the most recent ``PublishableEntityVersion``
|
|
474
707
|
for a given ``PublishableEntity``.
|
|
475
708
|
"""
|
|
476
|
-
return set_draft_version(publishable_entity_id, None)
|
|
709
|
+
return set_draft_version(publishable_entity_id, None, set_by=deleted_by)
|
|
477
710
|
|
|
478
711
|
|
|
479
|
-
def reset_drafts_to_published(
|
|
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:
|
|
480
718
|
"""
|
|
481
719
|
Reset all Drafts to point to the most recently Published versions.
|
|
482
720
|
|
|
@@ -500,27 +738,55 @@ def reset_drafts_to_published(learning_package_id: int, /) -> None:
|
|
|
500
738
|
it's important that the code creating the "next" version_num looks at the
|
|
501
739
|
latest version created for a PublishableEntity (its ``latest`` attribute),
|
|
502
740
|
rather than basing it off of the version that Draft points to.
|
|
503
|
-
|
|
504
|
-
Also, there is no current immutable record for when a reset happens. It's
|
|
505
|
-
not like a publish that leaves an entry in the ``PublishLog``.
|
|
506
741
|
"""
|
|
742
|
+
if reset_at is None:
|
|
743
|
+
reset_at = datetime.now(tz=timezone.utc)
|
|
744
|
+
|
|
507
745
|
# These are all the drafts that are different from the published versions.
|
|
508
746
|
draft_qset = Draft.objects \
|
|
509
747
|
.select_related("entity__published") \
|
|
510
748
|
.filter(entity__learning_package_id=learning_package_id) \
|
|
511
|
-
.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
|
+
)
|
|
512
776
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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.
|
|
518
783
|
for draft in draft_qset:
|
|
519
784
|
if hasattr(draft.entity, 'published'):
|
|
520
|
-
|
|
785
|
+
published_version_id = draft.entity.published.version_id
|
|
521
786
|
else:
|
|
522
|
-
|
|
523
|
-
|
|
787
|
+
published_version_id = None
|
|
788
|
+
|
|
789
|
+
set_draft_version(draft, published_version_id)
|
|
524
790
|
|
|
525
791
|
|
|
526
792
|
def register_content_models(
|
|
@@ -639,8 +905,7 @@ def create_entity_list() -> EntityList:
|
|
|
639
905
|
|
|
640
906
|
|
|
641
907
|
def create_entity_list_with_rows(
|
|
642
|
-
|
|
643
|
-
entity_version_pks: list[int | None],
|
|
908
|
+
entity_rows: list[ContainerEntityRow],
|
|
644
909
|
*,
|
|
645
910
|
learning_package_id: int | None,
|
|
646
911
|
) -> EntityList:
|
|
@@ -649,10 +914,7 @@ def create_entity_list_with_rows(
|
|
|
649
914
|
Create new entity list rows for an entity list.
|
|
650
915
|
|
|
651
916
|
Args:
|
|
652
|
-
|
|
653
|
-
entity_version_pks: The IDs of the versions of the entities
|
|
654
|
-
(PublishableEntityVersion) that the entity list rows reference, or
|
|
655
|
-
Nones for "unpinned" (default).
|
|
917
|
+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
|
|
656
918
|
learning_package_id: Optional. Verify that all the entities are from
|
|
657
919
|
the specified learning package.
|
|
658
920
|
|
|
@@ -662,13 +924,25 @@ def create_entity_list_with_rows(
|
|
|
662
924
|
# Do a quick check that the given entities are in the right learning package:
|
|
663
925
|
if learning_package_id:
|
|
664
926
|
if PublishableEntity.objects.filter(
|
|
665
|
-
pk__in=
|
|
927
|
+
pk__in=[entity.entity_pk for entity in entity_rows],
|
|
666
928
|
).exclude(
|
|
667
929
|
learning_package_id=learning_package_id,
|
|
668
930
|
).exists():
|
|
669
931
|
raise ValidationError("Container entities must be from the same learning package.")
|
|
670
932
|
|
|
671
|
-
|
|
933
|
+
# Ensure that any pinned entity versions are linked to the correct entity
|
|
934
|
+
pinned_entities = {
|
|
935
|
+
entity.version_pk: entity.entity_pk
|
|
936
|
+
for entity in entity_rows if entity.pinned
|
|
937
|
+
}
|
|
938
|
+
if pinned_entities:
|
|
939
|
+
entity_versions = PublishableEntityVersion.objects.filter(
|
|
940
|
+
pk__in=pinned_entities.keys(),
|
|
941
|
+
).only('pk', 'entity_id')
|
|
942
|
+
for entity_version in entity_versions:
|
|
943
|
+
if pinned_entities[entity_version.pk] != entity_version.entity_id:
|
|
944
|
+
raise ValidationError("Container entity versions must belong to the specified entity.")
|
|
945
|
+
|
|
672
946
|
with atomic(savepoint=False):
|
|
673
947
|
|
|
674
948
|
entity_list = create_entity_list()
|
|
@@ -676,13 +950,11 @@ def create_entity_list_with_rows(
|
|
|
676
950
|
[
|
|
677
951
|
EntityListRow(
|
|
678
952
|
entity_list=entity_list,
|
|
679
|
-
entity_id=entity_pk,
|
|
953
|
+
entity_id=entity.entity_pk,
|
|
680
954
|
order_num=order_num,
|
|
681
|
-
entity_version_id=
|
|
682
|
-
)
|
|
683
|
-
for order_num, entity_pk, entity_version_pk in zip(
|
|
684
|
-
order_nums, entity_pks, entity_version_pks
|
|
955
|
+
entity_version_id=entity.version_pk,
|
|
685
956
|
)
|
|
957
|
+
for order_num, entity in enumerate(entity_rows)
|
|
686
958
|
]
|
|
687
959
|
)
|
|
688
960
|
return entity_list
|
|
@@ -725,8 +997,7 @@ def create_container_version(
|
|
|
725
997
|
version_num: int,
|
|
726
998
|
*,
|
|
727
999
|
title: str,
|
|
728
|
-
|
|
729
|
-
entity_version_pks: list[int | None] | None,
|
|
1000
|
+
entity_rows: list[ContainerEntityRow],
|
|
730
1001
|
created: datetime,
|
|
731
1002
|
created_by: int | None,
|
|
732
1003
|
container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
|
|
@@ -739,8 +1010,7 @@ def create_container_version(
|
|
|
739
1010
|
container_id: The ID of the container that the version belongs to.
|
|
740
1011
|
version_num: The version number of the container.
|
|
741
1012
|
title: The title of the container.
|
|
742
|
-
|
|
743
|
-
entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
|
|
1013
|
+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
|
|
744
1014
|
created: The date and time the container version was created.
|
|
745
1015
|
created_by: The ID of the user who created the container version.
|
|
746
1016
|
container_version_cls: The subclass of ContainerVersion to use, if applicable.
|
|
@@ -749,16 +1019,13 @@ def create_container_version(
|
|
|
749
1019
|
The newly created container version.
|
|
750
1020
|
"""
|
|
751
1021
|
assert title is not None
|
|
752
|
-
assert
|
|
1022
|
+
assert entity_rows is not None
|
|
753
1023
|
|
|
754
1024
|
with atomic(savepoint=False):
|
|
755
1025
|
container = Container.objects.select_related("publishable_entity").get(pk=container_id)
|
|
756
1026
|
entity = container.publishable_entity
|
|
757
|
-
if entity_version_pks is None:
|
|
758
|
-
entity_version_pks = [None] * len(publishable_entities_pks)
|
|
759
1027
|
entity_list = create_entity_list_with_rows(
|
|
760
|
-
|
|
761
|
-
entity_version_pks=entity_version_pks,
|
|
1028
|
+
entity_rows=entity_rows,
|
|
762
1029
|
learning_package_id=entity.learning_package_id,
|
|
763
1030
|
)
|
|
764
1031
|
container_version = _create_container_version(
|
|
@@ -785,8 +1052,7 @@ class ChildrenEntitiesAction(Enum):
|
|
|
785
1052
|
def create_next_entity_list(
|
|
786
1053
|
learning_package_id: int,
|
|
787
1054
|
last_version: ContainerVersion,
|
|
788
|
-
|
|
789
|
-
entity_version_pks: list[int | None] | None,
|
|
1055
|
+
entity_rows: list[ContainerEntityRow],
|
|
790
1056
|
entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
|
|
791
1057
|
) -> EntityList:
|
|
792
1058
|
"""
|
|
@@ -795,55 +1061,53 @@ def create_next_entity_list(
|
|
|
795
1061
|
Args:
|
|
796
1062
|
learning_package_id: Learning package ID
|
|
797
1063
|
last_version: Last version of container.
|
|
798
|
-
|
|
799
|
-
entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
|
|
1064
|
+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
|
|
800
1065
|
entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
|
|
801
1066
|
|
|
802
1067
|
Returns:
|
|
803
1068
|
The newly created entity list.
|
|
804
1069
|
"""
|
|
805
|
-
if entity_version_pks is None:
|
|
806
|
-
entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks) # type: ignore[no-redef]
|
|
807
1070
|
if entities_action == ChildrenEntitiesAction.APPEND:
|
|
808
1071
|
# get previous entity list rows
|
|
809
1072
|
last_entities = last_version.entity_list.entitylistrow_set.only(
|
|
810
1073
|
"entity_id",
|
|
811
1074
|
"entity_version_id"
|
|
812
1075
|
).order_by("order_num")
|
|
813
|
-
# append given
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1076
|
+
# append given entity_rows to the existing children
|
|
1077
|
+
entity_rows = [
|
|
1078
|
+
ContainerEntityRow(
|
|
1079
|
+
entity_pk=entity.entity_id,
|
|
1080
|
+
version_pk=entity.entity_version_id,
|
|
1081
|
+
)
|
|
817
1082
|
for entity in last_entities
|
|
818
|
-
] +
|
|
1083
|
+
] + entity_rows
|
|
819
1084
|
elif entities_action == ChildrenEntitiesAction.REMOVE:
|
|
820
|
-
# get previous entity list
|
|
1085
|
+
# get previous entity list, excluding the entities in entity_rows
|
|
821
1086
|
last_entities = last_version.entity_list.entitylistrow_set.only(
|
|
822
1087
|
"entity_id",
|
|
823
1088
|
"entity_version_id"
|
|
1089
|
+
).exclude(
|
|
1090
|
+
entity_id__in=[entity.entity_pk for entity in entity_rows]
|
|
824
1091
|
).order_by("order_num")
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1092
|
+
entity_rows = [
|
|
1093
|
+
ContainerEntityRow(
|
|
1094
|
+
entity_pk=entity.entity_id,
|
|
1095
|
+
version_pk=entity.entity_version_id,
|
|
1096
|
+
)
|
|
1097
|
+
for entity in last_entities.all()
|
|
830
1098
|
]
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
entity_pks=publishable_entities_pks,
|
|
835
|
-
entity_version_pks=entity_version_pks, # type: ignore[arg-type]
|
|
1099
|
+
|
|
1100
|
+
return create_entity_list_with_rows(
|
|
1101
|
+
entity_rows=entity_rows,
|
|
836
1102
|
learning_package_id=learning_package_id,
|
|
837
1103
|
)
|
|
838
|
-
return next_entity_list
|
|
839
1104
|
|
|
840
1105
|
|
|
841
1106
|
def create_next_container_version(
|
|
842
1107
|
container_pk: int,
|
|
843
1108
|
*,
|
|
844
1109
|
title: str | None,
|
|
845
|
-
|
|
846
|
-
entity_version_pks: list[int | None] | None,
|
|
1110
|
+
entity_rows: list[ContainerEntityRow] | None,
|
|
847
1111
|
created: datetime,
|
|
848
1112
|
created_by: int | None,
|
|
849
1113
|
container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
|
|
@@ -863,8 +1127,8 @@ def create_next_container_version(
|
|
|
863
1127
|
Args:
|
|
864
1128
|
container_pk: The ID of the container to create the next version of.
|
|
865
1129
|
title: The title of the container. None to keep the current title.
|
|
866
|
-
|
|
867
|
-
|
|
1130
|
+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
|
|
1131
|
+
Or None for no change.
|
|
868
1132
|
created: The date and time the container version was created.
|
|
869
1133
|
created_by: The ID of the user who created the container version.
|
|
870
1134
|
container_version_cls: The subclass of ContainerVersion to use, if applicable.
|
|
@@ -879,15 +1143,15 @@ def create_next_container_version(
|
|
|
879
1143
|
last_version = container.versioning.latest
|
|
880
1144
|
assert last_version is not None
|
|
881
1145
|
next_version_num = last_version.version_num + 1
|
|
882
|
-
|
|
1146
|
+
|
|
1147
|
+
if entity_rows is None:
|
|
883
1148
|
# We're only changing metadata. Keep the same entity list.
|
|
884
1149
|
next_entity_list = last_version.entity_list
|
|
885
1150
|
else:
|
|
886
1151
|
next_entity_list = create_next_entity_list(
|
|
887
1152
|
entity.learning_package_id,
|
|
888
1153
|
last_version,
|
|
889
|
-
|
|
890
|
-
entity_version_pks,
|
|
1154
|
+
entity_rows,
|
|
891
1155
|
entities_action
|
|
892
1156
|
)
|
|
893
1157
|
|
|
@@ -955,6 +1219,21 @@ def get_containers(
|
|
|
955
1219
|
return container_cls.objects.filter(publishable_entity__learning_package=learning_package_id)
|
|
956
1220
|
|
|
957
1221
|
|
|
1222
|
+
def get_collection_containers(
|
|
1223
|
+
learning_package_id: int,
|
|
1224
|
+
collection_key: str,
|
|
1225
|
+
) -> QuerySet[Container]:
|
|
1226
|
+
"""
|
|
1227
|
+
Returns a QuerySet of Containers relating to the PublishableEntities in a Collection.
|
|
1228
|
+
|
|
1229
|
+
Containers have a one-to-one relationship with PublishableEntity, but the reverse may not always be true.
|
|
1230
|
+
"""
|
|
1231
|
+
return Container.objects.filter(
|
|
1232
|
+
publishable_entity__learning_package_id=learning_package_id,
|
|
1233
|
+
publishable_entity__collections__key=collection_key,
|
|
1234
|
+
).order_by('pk')
|
|
1235
|
+
|
|
1236
|
+
|
|
958
1237
|
@dataclass(frozen=True)
|
|
959
1238
|
class ContainerEntityListEntry:
|
|
960
1239
|
"""
|
|
@@ -969,6 +1248,23 @@ class ContainerEntityListEntry:
|
|
|
969
1248
|
return self.entity_version.entity
|
|
970
1249
|
|
|
971
1250
|
|
|
1251
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
1252
|
+
class ContainerEntityRow:
|
|
1253
|
+
"""
|
|
1254
|
+
[ 🛑 UNSTABLE ]
|
|
1255
|
+
Used to specify the primary key of PublishableEntity and optional PublishableEntityVersion.
|
|
1256
|
+
|
|
1257
|
+
If version_pk is None (default), then the entity is considered "unpinned",
|
|
1258
|
+
meaning that the latest version of the entity will be used.
|
|
1259
|
+
"""
|
|
1260
|
+
entity_pk: int
|
|
1261
|
+
version_pk: int | None = None
|
|
1262
|
+
|
|
1263
|
+
@property
|
|
1264
|
+
def pinned(self):
|
|
1265
|
+
return self.entity_pk and self.version_pk is not None
|
|
1266
|
+
|
|
1267
|
+
|
|
972
1268
|
def get_entities_in_container(
|
|
973
1269
|
container: Container,
|
|
974
1270
|
*,
|
|
@@ -1071,6 +1367,7 @@ def get_containers_with_entity(
|
|
|
1071
1367
|
ignore_pinned: if true, ignore any pinned references to the entity.
|
|
1072
1368
|
"""
|
|
1073
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
|
|
1074
1371
|
qs = Container.objects.filter(
|
|
1075
1372
|
# Note: these two conditions must be in the same filter() call, or the query won't be correct.
|
|
1076
1373
|
publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501
|
|
@@ -1113,3 +1410,47 @@ def get_container_children_count(
|
|
|
1113
1410
|
else:
|
|
1114
1411
|
filter_deleted = {"entity__draft__version__isnull": False}
|
|
1115
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
|
+
)
|