Skip to content

Commit 1db2f7e

Browse files
authored
Encapsulate child entity + entity versions in Containers API [FC-0083] (#300)
This PR addresses in #278 (comment) by creating a dataclass that encapsulates a PublishableEntity.pk with an optional PublishableEntityVersion.pk, so that the container API methods don't have to pass in two separate lists when specifying pinned versions for a container's children. This change only affects the (unstable) publishing app's container-related API. The (also unstable) Units API, which is used by edx-platform, remains unchanged.
1 parent 251355c commit 1db2f7e

4 files changed

Lines changed: 117 additions & 78 deletions

File tree

openedx_learning/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Open edX Learning ("Learning Core").
33
"""
44

5-
__version__ = "0.20.0"
5+
__version__ = "0.21.0"

openedx_learning/apps/authoring/publishing/api.py

Lines changed: 68 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"get_containers",
7777
"ChildrenEntitiesAction",
7878
"ContainerEntityListEntry",
79+
"ContainerEntityRow",
7980
"get_entities_in_container",
8081
"contains_unpublished_changes",
8182
"get_containers_with_entity",
@@ -639,8 +640,7 @@ def create_entity_list() -> EntityList:
639640

640641

641642
def create_entity_list_with_rows(
642-
entity_pks: list[int],
643-
entity_version_pks: list[int | None],
643+
entity_rows: list[ContainerEntityRow],
644644
*,
645645
learning_package_id: int | None,
646646
) -> EntityList:
@@ -649,10 +649,7 @@ def create_entity_list_with_rows(
649649
Create new entity list rows for an entity list.
650650
651651
Args:
652-
entity_pks: The IDs of the publishable entities that the entity list rows reference.
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).
652+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
656653
learning_package_id: Optional. Verify that all the entities are from
657654
the specified learning package.
658655
@@ -662,27 +659,37 @@ def create_entity_list_with_rows(
662659
# Do a quick check that the given entities are in the right learning package:
663660
if learning_package_id:
664661
if PublishableEntity.objects.filter(
665-
pk__in=entity_pks,
662+
pk__in=[entity.entity_pk for entity in entity_rows],
666663
).exclude(
667664
learning_package_id=learning_package_id,
668665
).exists():
669666
raise ValidationError("Container entities must be from the same learning package.")
670667

671-
order_nums = range(len(entity_pks))
668+
# Ensure that any pinned entity versions are linked to the correct entity
669+
pinned_entities = {
670+
entity.version_pk: entity.entity_pk
671+
for entity in entity_rows if entity.pinned
672+
}
673+
if pinned_entities:
674+
entity_versions = PublishableEntityVersion.objects.filter(
675+
pk__in=pinned_entities.keys(),
676+
).only('pk', 'entity_id')
677+
for entity_version in entity_versions:
678+
if pinned_entities[entity_version.pk] != entity_version.entity_id:
679+
raise ValidationError("Container entity versions must belong to the specified entity.")
680+
672681
with atomic(savepoint=False):
673682

674683
entity_list = create_entity_list()
675684
EntityListRow.objects.bulk_create(
676685
[
677686
EntityListRow(
678687
entity_list=entity_list,
679-
entity_id=entity_pk,
688+
entity_id=entity.entity_pk,
680689
order_num=order_num,
681-
entity_version_id=entity_version_pk,
682-
)
683-
for order_num, entity_pk, entity_version_pk in zip(
684-
order_nums, entity_pks, entity_version_pks
690+
entity_version_id=entity.version_pk,
685691
)
692+
for order_num, entity in enumerate(entity_rows)
686693
]
687694
)
688695
return entity_list
@@ -725,8 +732,7 @@ def create_container_version(
725732
version_num: int,
726733
*,
727734
title: str,
728-
publishable_entities_pks: list[int],
729-
entity_version_pks: list[int | None] | None,
735+
entity_rows: list[ContainerEntityRow],
730736
created: datetime,
731737
created_by: int | None,
732738
container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
@@ -739,8 +745,7 @@ def create_container_version(
739745
container_id: The ID of the container that the version belongs to.
740746
version_num: The version number of the container.
741747
title: The title of the container.
742-
publishable_entities_pks: The IDs of the members of the container.
743-
entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
748+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
744749
created: The date and time the container version was created.
745750
created_by: The ID of the user who created the container version.
746751
container_version_cls: The subclass of ContainerVersion to use, if applicable.
@@ -749,16 +754,13 @@ def create_container_version(
749754
The newly created container version.
750755
"""
751756
assert title is not None
752-
assert publishable_entities_pks is not None
757+
assert entity_rows is not None
753758

754759
with atomic(savepoint=False):
755760
container = Container.objects.select_related("publishable_entity").get(pk=container_id)
756761
entity = container.publishable_entity
757-
if entity_version_pks is None:
758-
entity_version_pks = [None] * len(publishable_entities_pks)
759762
entity_list = create_entity_list_with_rows(
760-
entity_pks=publishable_entities_pks,
761-
entity_version_pks=entity_version_pks,
763+
entity_rows=entity_rows,
762764
learning_package_id=entity.learning_package_id,
763765
)
764766
container_version = _create_container_version(
@@ -785,8 +787,7 @@ class ChildrenEntitiesAction(Enum):
785787
def create_next_entity_list(
786788
learning_package_id: int,
787789
last_version: ContainerVersion,
788-
publishable_entities_pks: list[int],
789-
entity_version_pks: list[int | None] | None,
790+
entity_rows: list[ContainerEntityRow],
790791
entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
791792
) -> EntityList:
792793
"""
@@ -795,55 +796,53 @@ def create_next_entity_list(
795796
Args:
796797
learning_package_id: Learning package ID
797798
last_version: Last version of container.
798-
publishable_entities_pks: The IDs of the members current members of the container.
799-
entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
799+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
800800
entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
801801
802802
Returns:
803803
The newly created entity list.
804804
"""
805-
if entity_version_pks is None:
806-
entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks) # type: ignore[no-redef]
807805
if entities_action == ChildrenEntitiesAction.APPEND:
808806
# get previous entity list rows
809807
last_entities = last_version.entity_list.entitylistrow_set.only(
810808
"entity_id",
811809
"entity_version_id"
812810
).order_by("order_num")
813-
# append given publishable_entities_pks and entity_version_pks
814-
publishable_entities_pks = [entity.entity_id for entity in last_entities] + publishable_entities_pks
815-
entity_version_pks = [ # type: ignore[operator, assignment]
816-
entity.entity_version_id
811+
# append given entity_rows to the existing children
812+
entity_rows = [
813+
ContainerEntityRow(
814+
entity_pk=entity.entity_id,
815+
version_pk=entity.entity_version_id,
816+
)
817817
for entity in last_entities
818-
] + entity_version_pks
818+
] + entity_rows
819819
elif entities_action == ChildrenEntitiesAction.REMOVE:
820-
# get previous entity list rows
820+
# get previous entity list, excluding the entities in entity_rows
821821
last_entities = last_version.entity_list.entitylistrow_set.only(
822822
"entity_id",
823823
"entity_version_id"
824+
).exclude(
825+
entity_id__in=[entity.entity_pk for entity in entity_rows]
824826
).order_by("order_num")
825-
# Remove entities that are in publishable_entities_pks
826-
new_entities = [
827-
entity
828-
for entity in last_entities
829-
if entity.entity_id not in publishable_entities_pks
827+
entity_rows = [
828+
ContainerEntityRow(
829+
entity_pk=entity.entity_id,
830+
version_pk=entity.entity_version_id,
831+
)
832+
for entity in last_entities.all()
830833
]
831-
publishable_entities_pks = [entity.entity_id for entity in new_entities]
832-
entity_version_pks = [entity.entity_version_id for entity in new_entities]
833-
next_entity_list = create_entity_list_with_rows(
834-
entity_pks=publishable_entities_pks,
835-
entity_version_pks=entity_version_pks, # type: ignore[arg-type]
834+
835+
return create_entity_list_with_rows(
836+
entity_rows=entity_rows,
836837
learning_package_id=learning_package_id,
837838
)
838-
return next_entity_list
839839

840840

841841
def create_next_container_version(
842842
container_pk: int,
843843
*,
844844
title: str | None,
845-
publishable_entities_pks: list[int] | None,
846-
entity_version_pks: list[int | None] | None,
845+
entity_rows: list[ContainerEntityRow] | None,
847846
created: datetime,
848847
created_by: int | None,
849848
container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
@@ -863,8 +862,8 @@ def create_next_container_version(
863862
Args:
864863
container_pk: The ID of the container to create the next version of.
865864
title: The title of the container. None to keep the current title.
866-
publishable_entities_pks: The IDs of the members current members of the container. Or None for no change.
867-
entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
865+
entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
866+
Or None for no change.
868867
created: The date and time the container version was created.
869868
created_by: The ID of the user who created the container version.
870869
container_version_cls: The subclass of ContainerVersion to use, if applicable.
@@ -879,15 +878,15 @@ def create_next_container_version(
879878
last_version = container.versioning.latest
880879
assert last_version is not None
881880
next_version_num = last_version.version_num + 1
882-
if publishable_entities_pks is None:
881+
882+
if entity_rows is None:
883883
# We're only changing metadata. Keep the same entity list.
884884
next_entity_list = last_version.entity_list
885885
else:
886886
next_entity_list = create_next_entity_list(
887887
entity.learning_package_id,
888888
last_version,
889-
publishable_entities_pks,
890-
entity_version_pks,
889+
entity_rows,
891890
entities_action
892891
)
893892

@@ -969,6 +968,23 @@ def entity(self):
969968
return self.entity_version.entity
970969

971970

971+
@dataclass(frozen=True, kw_only=True, slots=True)
972+
class ContainerEntityRow:
973+
"""
974+
[ 🛑 UNSTABLE ]
975+
Used to specify the primary key of PublishableEntity and optional PublishableEntityVersion.
976+
977+
If version_pk is None (default), then the entity is considered "unpinned",
978+
meaning that the latest version of the entity will be used.
979+
"""
980+
entity_pk: int
981+
version_pk: int | None = None
982+
983+
@property
984+
def pinned(self):
985+
return self.entity_pk and self.version_pk is not None
986+
987+
972988
def get_entities_in_container(
973989
container: Container,
974990
*,

openedx_learning/apps/authoring/units/api.py

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ def create_unit_version(
6262
version_num: int,
6363
*,
6464
title: str,
65-
publishable_entities_pks: list[int],
66-
entity_version_pks: list[int | None],
65+
entity_rows: list[publishing_api.ContainerEntityRow],
6766
created: datetime,
6867
created_by: int | None = None,
6968
) -> UnitVersion:
@@ -75,20 +74,18 @@ def create_unit_version(
7574
`create_next_unit_version()` instead.
7675
7776
Args:
78-
unit_pk: The unit ID.
77+
unit: The unit object.
7978
version_num: The version number.
8079
title: The title.
81-
publishable_entities_pk: The publishable entities.
82-
entity: The entity.
80+
entity_rows: child entities/versions
8381
created: The creation date.
8482
created_by: The user who created the unit.
8583
"""
8684
return publishing_api.create_container_version(
8785
unit.pk,
8886
version_num,
8987
title=title,
90-
publishable_entities_pks=publishable_entities_pks,
91-
entity_version_pks=entity_version_pks,
88+
entity_rows=entity_rows,
9289
created=created,
9390
created_by=created_by,
9491
container_version_cls=UnitVersion,
@@ -97,30 +94,34 @@ def create_unit_version(
9794

9895
def _pub_entities_for_components(
9996
components: list[Component | ComponentVersion] | None,
100-
) -> tuple[list[int], list[int | None]] | tuple[None, None]:
97+
) -> list[publishing_api.ContainerEntityRow] | None:
10198
"""
10299
Helper method: given a list of Component | ComponentVersion, return the
103-
lists of publishable_entities_pks and entity_version_pks needed for the
104-
base container APIs.
100+
list of ContainerEntityRows needed for the base container APIs.
105101
106102
ComponentVersion is passed when we want to pin a specific version, otherwise
107103
Component is used for unpinned.
108104
"""
109105
if components is None:
110106
# When these are None, that means don't change the entities in the list.
111-
return None, None
107+
return None
112108
for c in components:
113109
if not isinstance(c, (Component, ComponentVersion)):
114110
raise TypeError("Unit components must be either Component or ComponentVersion.")
115-
publishable_entities_pks = [
116-
(c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id)
111+
return [
112+
(
113+
publishing_api.ContainerEntityRow(
114+
entity_pk=c.publishable_entity_id,
115+
version_pk=None,
116+
) if isinstance(c, Component)
117+
else # isinstance(c, ComponentVersion)
118+
publishing_api.ContainerEntityRow(
119+
entity_pk=c.component.publishable_entity_id,
120+
version_pk=c.pk,
121+
)
122+
)
117123
for c in components
118124
]
119-
entity_version_pks = [
120-
(cv.pk if isinstance(cv, ComponentVersion) else None)
121-
for cv in components
122-
]
123-
return publishable_entities_pks, entity_version_pks
124125

125126

126127
def create_next_unit_version(
@@ -143,12 +144,11 @@ def create_next_unit_version(
143144
created: The creation date.
144145
created_by: The user who created the unit.
145146
"""
146-
publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components)
147+
entity_rows = _pub_entities_for_components(components)
147148
unit_version = publishing_api.create_next_container_version(
148149
unit.pk,
149150
title=title,
150-
publishable_entities_pks=publishable_entities_pks,
151-
entity_version_pks=entity_version_pks,
151+
entity_rows=entity_rows,
152152
created=created,
153153
created_by=created_by,
154154
container_version_cls=UnitVersion,
@@ -177,7 +177,7 @@ def create_unit_and_version(
177177
created_by: The user who created the unit.
178178
can_stand_alone: Set to False when created as part of containers
179179
"""
180-
publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components)
180+
entity_rows = _pub_entities_for_components(components)
181181
with atomic():
182182
unit = create_unit(
183183
learning_package_id,
@@ -190,8 +190,7 @@ def create_unit_and_version(
190190
unit,
191191
1,
192192
title=title,
193-
publishable_entities_pks=publishable_entities_pks or [],
194-
entity_version_pks=entity_version_pks or [],
193+
entity_rows=entity_rows or [],
195194
created=created,
196195
created_by=created_by,
197196
)

0 commit comments

Comments
 (0)