From 82399859f3d2a802e90d782e81f66eb3b5026a71 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Fri, 25 Oct 2024 16:43:37 +0200 Subject: [PATCH 1/9] feat: implement sketch models and api for interactions based on 0017/18 --- openedx_learning/api/authoring.py | 2 + .../apps/authoring/containers/__init__.py | 0 .../apps/authoring/containers/api.py | 334 ++++++++++++++++++ .../apps/authoring/containers/apps.py | 26 ++ .../containers/migrations/0001_initial.py | 55 +++ .../containers/migrations/__init__.py | 0 .../apps/authoring/containers/model_mixin.py | 96 +++++ .../apps/authoring/containers/models.py | 144 ++++++++ .../apps/authoring/units/__init__.py | 0 openedx_learning/apps/authoring/units/api.py | 212 +++++++++++ openedx_learning/apps/authoring/units/apps.py | 16 + .../units/migrations/0001_initial.py | 38 ++ .../authoring/units/migrations/__init__.py | 0 .../apps/authoring/units/models.py | 21 ++ 14 files changed, 944 insertions(+) create mode 100644 openedx_learning/apps/authoring/containers/__init__.py create mode 100644 openedx_learning/apps/authoring/containers/api.py create mode 100644 openedx_learning/apps/authoring/containers/apps.py create mode 100644 openedx_learning/apps/authoring/containers/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/containers/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/containers/model_mixin.py create mode 100644 openedx_learning/apps/authoring/containers/models.py create mode 100644 openedx_learning/apps/authoring/units/__init__.py create mode 100644 openedx_learning/apps/authoring/units/api.py create mode 100644 openedx_learning/apps/authoring/units/apps.py create mode 100644 openedx_learning/apps/authoring/units/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/units/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/units/models.py diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 1da667af..2fe11e93 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -13,6 +13,8 @@ from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * from ..apps.authoring.publishing.api import * +from ..apps.authoring.containers.api import * +from ..apps.authoring.units.api import * # This was renamed after the authoring API refactoring pushed this and other # app APIs into the openedx_learning.api.authoring module. Here I'm aliasing to diff --git a/openedx_learning/apps/authoring/containers/__init__.py b/openedx_learning/apps/authoring/containers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py new file mode 100644 index 00000000..4a66e748 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/api.py @@ -0,0 +1,334 @@ +""" +Containers API. + +This module provides a set of functions to interact with the containers +models in the Open edX Learning platform. +""" + +from django.db.transaction import atomic +from django.db.models import QuerySet + +from datetime import datetime +from ..containers.models import ( + ContainerEntity, + ContainerEntityVersion, + EntityList, + EntityListRow, +) +from ..publishing.models import PublishableEntity +from ..publishing import api as publishing_api + + +__all__ = [ + "create_container", + "create_container_version", + "create_next_container_version", + "create_container_and_version", + "get_container", + "get_defined_list_for_container_version", + "get_initial_list_for_container_version", + "get_frozen_list_for_container_version", +] + + +def create_container( + learning_package_id: int, + key: str, + created: datetime, + created_by: int | None, +) -> ContainerEntity: + """ + Create a new container. + + Args: + learning_package_id: The ID of the learning package that contains the container. + key: The key of the container. + created: The date and time the container was created. + created_by: The ID of the user who created the container + + Returns: + The newly created container. + """ + with atomic(): + publishable_entity = publishing_api.create_publishable_entity( + learning_package_id, key, created, created_by + ) + container = ContainerEntity.objects.create( + publishable_entity=publishable_entity, + ) + return container + + +def create_entity_list() -> EntityList: + """ + Create a new entity list. This is an structure that holds a list of entities + that will be referenced by the container. + + Returns: + The newly created entity list. + """ + return EntityList.objects.create() + + +def create_entity_list_row( + entity_list: EntityList, + entity_pk: int, + order_num: int, + draft_version_pk: int | None, + published_version_pk: int | None, +) -> EntityListRow: + """ + Create a new entity list row. This is a row in an entity list that references + publishable entities. + + Args: + entity_list: The entity list that the entity list row belongs to. + entity: The ID of the publishable entity that the entity list row references. + order_num: The order_num of the entity list row in the entity list. + draft_version_pk: The ID of the draft version of the entity (PublishableEntityVersion) that the entity list row references. + published_version_pk: The ID of the published version of the entity (PublishableEntityVersion) that the entity list row references + + Returns: + The newly created entity list row. + """ + return EntityListRow.objects.create( + entity_list=entity_list, + entity_id=entity_pk, + order_num=order_num, + draft_version_id=draft_version_pk, + published_version_id=published_version_pk, + ) + + +def create_entity_list_with_rows( + entity_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], +) -> EntityList: + """ + Create a new entity list with rows. + + Args: + entity_pks: The IDs of the publishable entities that the entity list rows reference. + order_nums: The order_nums of the entity list rows in the entity list. + draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. + published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. + + Returns: + The newly created entity list. + """ + entity_list = create_entity_list() + order_nums = range(len(entity_pks)) + for entity_pk, order_num, draft_version_pk, published_version_pk in zip( + entity_pks, order_nums, draft_version_pks, published_version_pks + ): + create_entity_list_row( + entity_list=entity_list, + entity_pk=entity_pk, + order_num=order_num, + draft_version_pk=draft_version_pk, + published_version_pk=published_version_pk, + ) + return entity_list + + +def create_container_version( + container_pk: int, + version_num: int, + title: str, + publishable_entities_pk: list[int], + entity: PublishableEntity, + created: datetime, + created_by: int | None, +) -> ContainerEntityVersion: + """ + Create a new container version. + + Args: + container_pk: The ID of the container that the version belongs to. + version_num: The version number of the container. + title: The title of the container. + publishable_entities_pk: The IDs of the members of the container. + entity: The entity that the container belongs to. + created: The date and time the container version was created. + created_by: The ID of the user who created the container version. + + Returns: + The newly created container version. + """ + with atomic(): + publishable_entity_version = publishing_api.create_publishable_entity_version( + entity.pk, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + ) + # This implementation assumes: + # 1. We are creating the first version of the container, so the defined list is the same as the initial list. + # 2. The frozen list is empty because this is the first version. + # 3. Published and draft versions are always the latest for all members. + entity_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pk, + draft_version_pks=[None] * len(publishable_entities_pk), + published_version_pks=[None] * len(publishable_entities_pk), + ) + container_version = ContainerEntityVersion.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container_pk, + defined_list=entity_list, + initial_list=entity_list, + frozen_list=None, + ) + return container_version + + +def create_next_container_version( + container_pk: int, + title: str, + publishable_entities_pk: list[int], + entity: PublishableEntity, + created: datetime, + created_by: int | None, +) -> ContainerEntityVersion: + """ + Create the next version of a container. + + Args: + container_pk: The ID of the container to create the next version of. + title: The title of the container. + publishable_entities_pk: The IDs of the members of the container. + entity: The entity that the container belongs to. + created: The date and time the container version was created. + created_by: The ID of the user who created the container version. + + Returns: + The newly created container version. + """ + with atomic(): + container = ContainerEntity.objects.get(pk=container_pk) + last_version = container.versioning.latest + next_version_num = last_version.version_num + 1 + publishable_entity_version = publishing_api.create_publishable_entity_version( + entity.pk, + version_num=next_version_num, + title=title, + created=created, + created_by=created_by, + ) + # This implementation assumes: + # 1. The changes provoking a new version are the addition, removal of members or reordering. + # 2. Published and draft versions are always the latest for all members. + # 3. When creating a new version, a new user-defined entity list is created to preserve the latest state as the previous user-defined list. + # TODO: instead consider copying the previous user-defined list as the frozen list, and add/remove to the previous user-defined list. + # If it's a reordering, the previous user-defined list is copied as the frozen and a new user-defined list is created with the new order. + new_user_defined_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pk, + draft_version_pks=[None] * len(publishable_entities_pk), + published_version_pks=[None] * len(publishable_entities_pk), + ) + container_version = ContainerEntityVersion.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container_pk, + defined_list=new_user_defined_list, + initial_list=last_version.initial_list, + frozen_list=last_version.defined_list, + ) + return container_version + + +def create_container_and_version( + learning_package_id: int, + key: str, + created: datetime, + created_by: int | None, + title: str, + publishable_entities_pk: list[int], +) -> ContainerEntityVersion: + """ + Create a new container and its first version. + + Args: + learning_package_id: The ID of the learning package that contains the container. + key: The key of the container. + created: The date and time the container was created. + created_by: The ID of the user who created the container. + version_num: The version number of the container. + title: The title of the container. + members_pk: The IDs of the members of the container. + + Returns: + The newly created container version. + """ + with atomic(): + container = create_container(learning_package_id, key, created, created_by) + container_version = create_container_version( + container_pk=container.publishable_entity.pk, + version_num=1, + title=title, + publishable_entities_pk=publishable_entities_pk, + entity=container.publishable_entity, + created=created, + created_by=created_by, + ) + return (container, container_version) + + +def get_container(pk: int) -> ContainerEntity: + """ + Get a container by its primary key. + + Args: + pk: The primary key of the container. + + Returns: + The container with the given primary key. + + TODO: should this use with_publishing_relations as in components? + """ + return ContainerEntity.objects.get(pk=pk) + + +def get_defined_list_for_container_version( + container_version: ContainerEntityVersion, +) -> QuerySet[EntityListRow]: + """ + Get the user-defined members of a container version. + + Args: + container_version: The container version to get the members of. + + Returns: + The members of the container version. + """ + return container_version.defined_list.entitylistrow_set.all() + + +def get_initial_list_for_container_version( + container_version: ContainerEntityVersion, +) -> QuerySet[EntityListRow]: + """ + Get the initial members of a container version. + + Args: + container_version: The container version to get the initial members of. + + Returns: + The initial members of the container version. + """ + return container_version.initial_list.entitylistrow_set.all() + + +def get_frozen_list_for_container_version( + container_version: ContainerEntityVersion, +) -> QuerySet[EntityListRow]: + """ + Get the frozen members of a container version. + + Args: + container_version: The container version to get the frozen members of. + + Returns: + The frozen members of the container version. + """ + return container_version.frozen_list.entitylistrow_set.all() diff --git a/openedx_learning/apps/authoring/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py new file mode 100644 index 00000000..98320555 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/apps.py @@ -0,0 +1,26 @@ +""" +Containers Django application initialization. +""" + +from django.apps import AppConfig + + +class ContainersConfig(AppConfig): + """ + Configuration for the containers Django application. + """ + + name = "openedx_learning.apps.authoring.containers" + verbose_name = "Learning Core > Authoring > Containers" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_containers" + + + def ready(self): + """ + Register Component and ComponentVersion. + """ + from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel + from .models import ContainerEntity, ContainerEntityVersion # pylint: disable=import-outside-toplevel + + register_content_models(ContainerEntity, ContainerEntityVersion) diff --git a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py new file mode 100644 index 00000000..47c4a4ab --- /dev/null +++ b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.16 on 2024-10-29 12:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ContainerEntity', + fields=[ + ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='EntityList', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='EntityListRow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_num', models.PositiveIntegerField()), + ('draft_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='draft_version', to='oel_publishing.publishableentityversion')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')), + ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.entitylist')), + ('published_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='published_version', to='oel_publishing.publishableentityversion')), + ], + ), + migrations.CreateModel( + name='ContainerEntityVersion', + fields=[ + ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_containers.containerentity')), + ('defined_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='defined_list', to='oel_containers.entitylist')), + ('frozen_list', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='frozen_list', to='oel_containers.entitylist')), + ('initial_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='initial_list', to='oel_containers.entitylist')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/openedx_learning/apps/authoring/containers/migrations/__init__.py b/openedx_learning/apps/authoring/containers/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/containers/model_mixin.py b/openedx_learning/apps/authoring/containers/model_mixin.py new file mode 100644 index 00000000..69f66328 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/model_mixin.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from django.db import models + +from openedx_learning.apps.authoring.containers.models import ContainerEntity, ContainerEntityVersion + +from django.db.models.query import QuerySet + +from openedx_learning.apps.authoring.publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin + +__all__ = [ + "ContainerEntityMixin", + "ContainerEntityVersionMixin", +] + +class ContainerEntityMixin(PublishableEntityMixin): + """ + Convenience mixin to link your models against ContainerEntity. + + Please see docstring for ContainerEntity for more details. + + If you use this class, you *MUST* also use ContainerEntityVersionMixin + """ + + class ContainerEntityMixinManager(models.Manager): + def get_queryset(self) -> QuerySet: + return ( + super() + .get_queryset() + .select_related( + "container_entity", + ) + ) + + objects: models.Manager[ContainerEntityMixin] = ContainerEntityMixinManager() + + container_entity = models.OneToOneField( + ContainerEntity, + on_delete=models.CASCADE, + ) + + @property + def uuid(self): + return self.container_entity.uuid + + @property + def created(self): + return self.container_entity.created + + class Meta: + abstract = True + +class ContainerEntityVersionMixin(PublishableEntityVersionMixin): + """ + Convenience mixin to link your models against ContainerEntityVersion. + + Please see docstring for ContainerEntityVersion for more details. + + If you use this class, you *MUST* also use ContainerEntityMixin + """ + + class ContainerEntityVersionMixinManager(models.Manager): + def get_queryset(self) -> QuerySet: + return ( + super() + .get_queryset() + .select_related( + "container_entity_version", + ) + ) + + objects: models.Manager[ContainerEntityVersionMixin] = ContainerEntityVersionMixinManager() + + container_entity_version = models.OneToOneField( + ContainerEntityVersion, + on_delete=models.CASCADE, + ) + + @property + def uuid(self): + return self.container_entity_version.uuid + + @property + def title(self): + return self.container_entity_version.title + + @property + def created(self): + return self.container_entity_version.created + + @property + def version_num(self): + return self.container_entity_version.version_num + + class Meta: + abstract = True diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py new file mode 100644 index 00000000..a441823c --- /dev/null +++ b/openedx_learning/apps/authoring/containers/models.py @@ -0,0 +1,144 @@ +from django.db import models + +from openedx_learning.apps.authoring.publishing.models import PublishableEntity, PublishableEntityVersion +from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin + + +class EntityList(models.Model): + """ + EntityLists are a common structure to hold parent-child relations. + + EntityLists are not PublishableEntities in and of themselves. That's because + sometimes we'll want the same kind of data structure for things that we + dynamically generate for individual students (e.g. Variants). EntityLists are + anonymous in a sense–they're pointed to by ContainerEntityVersions and + other models, rather than being looked up by their own identifers. + """ + pass + + +class EntityListRow(models.Model): + """ + Each EntityListRow points to a PublishableEntity, optionally at a specific + version. + + There is a row in this table for each member of an EntityList. The order_num + field is used to determine the order of the members in the list. + """ + entity_list = models.ForeignKey(EntityList, on_delete=models.CASCADE) + + # This ordering should be treated as immutable–if the ordering needs to + # change, we create a new EntityList and copy things over. + order_num = models.PositiveIntegerField() + + # Simple case would use these fields with our convention that null versions + # means "get the latest draft or published as appropriate". These entities + # could be Selectors, in which case we'd need to do more work to find the right + # variant. The publishing app itself doesn't know anything about Selectors + # however, and just treats it as another PublishableEntity. + entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT) + + # The version references point to the specific PublishableEntityVersion that + # this EntityList has for this PublishableEntity for both the draft and + # published states. However, we don't want to have to create new EntityList + # every time that a member is updated, because that would waste a lot of + # space and make it difficult to figure out when the metadata of something + # like a Unit *actually* changed, vs. when its child members were being + # updated. Doing so could also potentially lead to race conditions when + # updating multiple layers of containers. + # + # So our approach to this is to use a value of None (null) to represent an + # unpinned reference to a PublishableEntity. It's shorthand for "just use + # the latest draft or published version of this, as appropriate". + draft_version = models.ForeignKey( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + related_name="draft_version", + ) + published_version = models.ForeignKey( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + related_name="published_version", + ) + + +class ContainerEntity(PublishableEntityMixin): + """ + NOTE: We're going to want to eventually have some association between the + PublishLog and Containers that were affected in a publish because their + child elements were published. + """ + pass + + +class ContainerEntityVersion(PublishableEntityVersionMixin): + """ + A version of a ContainerEntity. + + By convention, we would only want to create new versions when the Container + itself changes, and not when the Container's child elements change. For + example: + + * Something was added to the Container. + * We re-ordered the rows in the container. + * Something was removed to the container. + * The Container's metadata changed, e.g. the title. + * We pin to different versions of the Container. + + The last looks a bit odd, but it's because *how we've defined the Unit* has + changed if we decide to explicitly pin a set of versions for the children, + and then later change our minds and move to a different set. It also just + makes things easier to reason about if we say that defined_list never + changes for a given ContainerEntityVersion. + """ + container = models.ForeignKey( + ContainerEntity, + on_delete=models.CASCADE, + related_name="versions", + ) + + # This is the EntityList that the author defines. This should never change, + # even if the things it references get soft-deleted (because we'll need to + # maintain it for reverts). + defined_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=False, + related_name="defined_list", + ) + + # inital_list is an EntityList where all the versions are pinned, to show + # what the exact versions of the children were at the time that the + # Container was created. We could technically derive this, but it would be + # awkward to query. + # + # If the Container was defined so that all references were pinned, then this + # can point to the exact same EntityList as defined_list. + initial_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=False, + related_name="initial_list", + ) + + # This is the EntityList that's created when the next ContainerEntityVersion + # is created. All references in this list should be pinned, and it serves as + # "the last state the children were in for this version of the Container". + # If defined_list has only pinned references, this should point to the same + # EntityList as defined_list and initial_list. + # + # This value is mutable if and only if there are unpinned references in + # defined_list. In that case, frozen_list should start as None, and be + # updated to pin references when another version of this Container becomes + # the Draft version. But if this version ever becomes the Draft *again* + # (e.g. the user hits "discard changes" or some kind of revert happens), + # then we need to clear this back to None. + frozen_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=True, + default=None, + related_name="frozen_list", + ) diff --git a/openedx_learning/apps/authoring/units/__init__.py b/openedx_learning/apps/authoring/units/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py new file mode 100644 index 00000000..6b6c8731 --- /dev/null +++ b/openedx_learning/apps/authoring/units/api.py @@ -0,0 +1,212 @@ +"""Units API. + +This module provides functions to manage units. +""" + +from django.db.transaction import atomic +from ..publishing import api as publishing_api +from ..containers import api as container_api +from .models import Unit, UnitVersion + +from datetime import datetime + +__all__ = [ + "create_unit", + "create_unit_version", + "create_next_unit_version", + "create_unit_and_version", + "get_unit", + "get_unit_version", + "get_latest_unit_version", + "get_unit_version_by_version_num", + "get_user_defined_components_in_unit_version", + "get_initial_components_in_unit_version", + "get_frozen_components_in_unit_version", +] + + +def create_unit( + learning_package_id: int, key: str, created: datetime, created_by: int | None +) -> Unit: + """Create a new unit. + + Args: + learning_package_id: The learning package ID. + key: The key. + created: The creation date. + created_by: The user who created the unit. + """ + with atomic(): + container = container_api.create_container( + learning_package_id, key, created, created_by + ) + unit = Unit.objects.create( + container_entity=container, + publishable_entity=container.publishable_entity, + ) + return unit + + +def create_unit_version( + unit: Unit, + version_num: int, + title: str, + publishable_entities_pk: list[int], + created: datetime, + created_by: int | None, +) -> Unit: + """Create a new unit version. + + Args: + unit_pk: The unit ID. + version_num: The version number. + title: The title. + publishable_entities_pk: The publishable entities. + entity: The entity. + created: The creation date. + created_by: The user who created the unit. + """ + with atomic(): + container_entity_version = container_api.create_container_version( + unit.container_entity.pk, + version_num, + title, + publishable_entities_pk, + unit.container_entity.publishable_entity, + created, + created_by, + ) + unit_version = UnitVersion.objects.create( + unit=unit, + container_entity_version=container_entity_version, + publishable_entity_version=container_entity_version.publishable_entity_version, + ) + return unit_version + + +def create_next_unit_version( + unit: Unit, + title: str, + publishable_entities_pk: list[int], + created: datetime, + created_by: int | None, +) -> Unit: + """Create the next unit version. + + Args: + unit_pk: The unit ID. + title: The title. + publishable_entities_pk: The components. + entity: The entity. + created: The creation date. + created_by: The user who created the unit. + """ + with atomic(): + # TODO: how can we enforce that publishable entities must be components? + # This currently allows for any publishable entity to be added to a unit. + container_entity_version = container_api.create_next_container_version( + unit.container_entity.pk, + title, + publishable_entities_pk, + unit.container_entity.publishable_entity, + created, + created_by, + ) + unit_version = UnitVersion.objects.create( + unit=unit, + container_entity_version=container_entity_version, + publishable_entity_version=container_entity_version.publishable_entity_version, + ) + return unit_version + + +def create_unit_and_version( + learning_package_id: int, + key: str, + created: datetime, + created_by: int | None, + title: str, +) -> tuple[Unit, UnitVersion]: + """Create a new unit and version. + + Args: + learning_package_id: The learning package ID. + key: The key. + created: The creation date. + created_by: The user who created the unit. + """ + with atomic(): + unit = create_unit(learning_package_id, key, created, created_by) + unit_version = create_unit_version( + unit, + 1, + title, + [], + created, + created_by, + ) + return unit, unit_version + + +def get_unit(unit_pk: int) -> Unit: + """Get a unit. + + Args: + unit_pk: The unit ID. + """ + return Unit.objects.get(pk=unit_pk) + + +def get_unit_version(unit_version_pk: int) -> UnitVersion: + """Get a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + return UnitVersion.objects.get(pk=unit_version_pk) + + +def get_latest_unit_version(unit_pk: int) -> UnitVersion: + """Get the latest unit version. + + Args: + unit_pk: The unit ID. + """ + return Unit.objects.get(pk=unit_pk).versioning.latest + + +def get_unit_version_by_version_num(unit_pk: int, version_num: int) -> UnitVersion: + """Get a unit version by version number. + + Args: + unit_pk: The unit ID. + version_num: The version number. + """ + return Unit.objects.get(pk=unit_pk).versioning.get(version_num=version_num) + + +def get_user_defined_list_in_unit_version(unit_version_pk: int) -> list[int]: + """Get the list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + return UnitVersion.objects.get(pk=unit_version_pk).container_version.defined_list + + +def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: + """Get the initial list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + return UnitVersion.objects.get(pk=unit_version_pk).container_version.initial_list + + +def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: + """Get the frozen list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + return UnitVersion.objects.get(pk=unit_version_pk).container_version.frozen_list diff --git a/openedx_learning/apps/authoring/units/apps.py b/openedx_learning/apps/authoring/units/apps.py new file mode 100644 index 00000000..a63f0d3d --- /dev/null +++ b/openedx_learning/apps/authoring/units/apps.py @@ -0,0 +1,16 @@ +""" +Unit Django application initialization. +""" + +from django.apps import AppConfig + + +class UnitsConfig(AppConfig): + """ + Configuration for the units Django application. + """ + + name = "openedx_learning.apps.authoring.units" + verbose_name = "Learning Core > Authoring > Units" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_units" diff --git a/openedx_learning/apps/authoring/units/migrations/0001_initial.py b/openedx_learning/apps/authoring/units/migrations/0001_initial.py new file mode 100644 index 00000000..3e3171c8 --- /dev/null +++ b/openedx_learning/apps/authoring/units/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.16 on 2024-10-30 11:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_containers', '0001_initial'), + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Unit', + fields=[ + ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), + ('container_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerentity')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UnitVersion', + fields=[ + ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), + ('container_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerentityversion')), + ('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_units.unit')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/openedx_learning/apps/authoring/units/migrations/__init__.py b/openedx_learning/apps/authoring/units/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py new file mode 100644 index 00000000..943ef514 --- /dev/null +++ b/openedx_learning/apps/authoring/units/models.py @@ -0,0 +1,21 @@ +from django.db import models + +from ..containers.model_mixin import ContainerEntityMixin, ContainerEntityVersionMixin + +class Unit(ContainerEntityMixin): + """ + A Unit is Container, which is a PublishableEntity. + """ + +class UnitVersion(ContainerEntityVersionMixin): + """ + A UnitVersion is a ContainerVersion, which is a PublishableEntityVersion. + """ + # Not sure what other metadata goes here, but we want to try to separate things + # like scheduling information and such into different models. + unit = models.ForeignKey( + Unit, + on_delete=models.CASCADE, + related_name="versions", + ) + From b9fed6711d3e45ba56ca29922be6b51751806430 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 5 Nov 2024 21:26:12 +0100 Subject: [PATCH 2/9] refactor: register unit models in app setup --- openedx_learning/apps/authoring/containers/apps.py | 2 +- openedx_learning/apps/authoring/units/apps.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/openedx_learning/apps/authoring/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py index 98320555..e8b2e36a 100644 --- a/openedx_learning/apps/authoring/containers/apps.py +++ b/openedx_learning/apps/authoring/containers/apps.py @@ -18,7 +18,7 @@ class ContainersConfig(AppConfig): def ready(self): """ - Register Component and ComponentVersion. + Register ContainerEntity and ContainerEntityVersion. """ from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel from .models import ContainerEntity, ContainerEntityVersion # pylint: disable=import-outside-toplevel diff --git a/openedx_learning/apps/authoring/units/apps.py b/openedx_learning/apps/authoring/units/apps.py index a63f0d3d..f0beecf3 100644 --- a/openedx_learning/apps/authoring/units/apps.py +++ b/openedx_learning/apps/authoring/units/apps.py @@ -14,3 +14,12 @@ class UnitsConfig(AppConfig): verbose_name = "Learning Core > Authoring > Units" default_auto_field = "django.db.models.BigAutoField" label = "oel_units" + + def ready(self): + """ + Register Unit and UnitVersion. + """ + from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel + from .models import Unit, UnitVersion # pylint: disable=import-outside-toplevel + + register_content_models(Unit, UnitVersion) From 149c480cc33388bc550ec3ce2724a4aaa28f57e2 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 5 Nov 2024 21:26:27 +0100 Subject: [PATCH 3/9] refactor: change model_mixin name to models_mixin --- .../{model_mixin.py => models_mixin.py} | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) rename openedx_learning/apps/authoring/containers/{model_mixin.py => models_mixin.py} (88%) diff --git a/openedx_learning/apps/authoring/containers/model_mixin.py b/openedx_learning/apps/authoring/containers/models_mixin.py similarity index 88% rename from openedx_learning/apps/authoring/containers/model_mixin.py rename to openedx_learning/apps/authoring/containers/models_mixin.py index 69f66328..4accd119 100644 --- a/openedx_learning/apps/authoring/containers/model_mixin.py +++ b/openedx_learning/apps/authoring/containers/models_mixin.py @@ -2,17 +2,24 @@ from django.db import models -from openedx_learning.apps.authoring.containers.models import ContainerEntity, ContainerEntityVersion +from openedx_learning.apps.authoring.containers.models import ( + ContainerEntity, + ContainerEntityVersion, +) from django.db.models.query import QuerySet -from openedx_learning.apps.authoring.publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin +from openedx_learning.apps.authoring.publishing.model_mixins import ( + PublishableEntityMixin, + PublishableEntityVersionMixin, +) __all__ = [ "ContainerEntityMixin", "ContainerEntityVersionMixin", ] + class ContainerEntityMixin(PublishableEntityMixin): """ Convenience mixin to link your models against ContainerEntity. @@ -50,6 +57,7 @@ def created(self): class Meta: abstract = True + class ContainerEntityVersionMixin(PublishableEntityVersionMixin): """ Convenience mixin to link your models against ContainerEntityVersion. @@ -69,7 +77,9 @@ def get_queryset(self) -> QuerySet: ) ) - objects: models.Manager[ContainerEntityVersionMixin] = ContainerEntityVersionMixinManager() + objects: models.Manager[ContainerEntityVersionMixin] = ( + ContainerEntityVersionMixinManager() + ) container_entity_version = models.OneToOneField( ContainerEntityVersion, From 3780e49b78d740e6dc6f2c32f9a3c88a1b199612 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 5 Nov 2024 21:34:25 +0100 Subject: [PATCH 4/9] refactor: use container API to get unit members --- .../apps/authoring/containers/models.py | 14 ++++++-- openedx_learning/apps/authoring/units/api.py | 32 ++++++++----------- .../apps/authoring/units/models.py | 6 ++-- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py index a441823c..66588bf3 100644 --- a/openedx_learning/apps/authoring/containers/models.py +++ b/openedx_learning/apps/authoring/containers/models.py @@ -1,7 +1,13 @@ from django.db import models -from openedx_learning.apps.authoring.publishing.models import PublishableEntity, PublishableEntityVersion -from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin +from openedx_learning.apps.authoring.publishing.models import ( + PublishableEntity, + PublishableEntityVersion, +) +from ..publishing.model_mixins import ( + PublishableEntityMixin, + PublishableEntityVersionMixin, +) class EntityList(models.Model): @@ -14,6 +20,7 @@ class EntityList(models.Model): anonymous in a sense–they're pointed to by ContainerEntityVersions and other models, rather than being looked up by their own identifers. """ + pass @@ -25,6 +32,7 @@ class EntityListRow(models.Model): There is a row in this table for each member of an EntityList. The order_num field is used to determine the order of the members in the list. """ + entity_list = models.ForeignKey(EntityList, on_delete=models.CASCADE) # This ordering should be treated as immutable–if the ordering needs to @@ -70,6 +78,7 @@ class ContainerEntity(PublishableEntityMixin): PublishLog and Containers that were affected in a publish because their child elements were published. """ + pass @@ -93,6 +102,7 @@ class ContainerEntityVersion(PublishableEntityVersionMixin): makes things easier to reason about if we say that defined_list never changes for a given ContainerEntityVersion. """ + container = models.ForeignKey( ContainerEntity, on_delete=models.CASCADE, diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 6b6c8731..9e06a8ad 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -4,9 +4,13 @@ """ from django.db.transaction import atomic + +from openedx_learning.apps.authoring.containers.models import EntityListRow from ..publishing import api as publishing_api from ..containers import api as container_api from .models import Unit, UnitVersion +from django.db.models import QuerySet + from datetime import datetime @@ -18,10 +22,9 @@ "get_unit", "get_unit_version", "get_latest_unit_version", - "get_unit_version_by_version_num", - "get_user_defined_components_in_unit_version", - "get_initial_components_in_unit_version", - "get_frozen_components_in_unit_version", + "get_user_defined_list_in_unit_version", + "get_initial_list_in_unit_version", + "get_frozen_list_in_unit_version", ] @@ -175,23 +178,14 @@ def get_latest_unit_version(unit_pk: int) -> UnitVersion: return Unit.objects.get(pk=unit_pk).versioning.latest -def get_unit_version_by_version_num(unit_pk: int, version_num: int) -> UnitVersion: - """Get a unit version by version number. - - Args: - unit_pk: The unit ID. - version_num: The version number. - """ - return Unit.objects.get(pk=unit_pk).versioning.get(version_num=version_num) - - -def get_user_defined_list_in_unit_version(unit_version_pk: int) -> list[int]: +def get_user_defined_list_in_unit_version(unit_version_pk: int) -> QuerySet[EntityListRow]: """Get the list in a unit version. Args: unit_version_pk: The unit version ID. """ - return UnitVersion.objects.get(pk=unit_version_pk).container_version.defined_list + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_defined_list_for_container_version(unit_version.container_entity_version) def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: @@ -200,7 +194,8 @@ def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: Args: unit_version_pk: The unit version ID. """ - return UnitVersion.objects.get(pk=unit_version_pk).container_version.initial_list + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_initial_list_for_container_version(unit_version.container_entity_version) def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: @@ -209,4 +204,5 @@ def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: Args: unit_version_pk: The unit version ID. """ - return UnitVersion.objects.get(pk=unit_version_pk).container_version.frozen_list + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_frozen_list_for_container_version(unit_version.container_entity_version) diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py index 943ef514..a9d167a2 100644 --- a/openedx_learning/apps/authoring/units/models.py +++ b/openedx_learning/apps/authoring/units/models.py @@ -1,16 +1,19 @@ from django.db import models -from ..containers.model_mixin import ContainerEntityMixin, ContainerEntityVersionMixin +from ..containers.models_mixin import ContainerEntityMixin, ContainerEntityVersionMixin + class Unit(ContainerEntityMixin): """ A Unit is Container, which is a PublishableEntity. """ + class UnitVersion(ContainerEntityVersionMixin): """ A UnitVersion is a ContainerVersion, which is a PublishableEntityVersion. """ + # Not sure what other metadata goes here, but we want to try to separate things # like scheduling information and such into different models. unit = models.ForeignKey( @@ -18,4 +21,3 @@ class UnitVersion(ContainerEntityVersionMixin): on_delete=models.CASCADE, related_name="versions", ) - From 1f10aec16926b1131884235b0f9ee1a62e02ed88 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Fri, 8 Nov 2024 13:20:03 +0100 Subject: [PATCH 5/9] refactor: pin versions in frozen list each time a new version is created --- .../apps/authoring/containers/api.py | 148 ++++++++++++------ openedx_learning/apps/authoring/units/api.py | 16 +- 2 files changed, 109 insertions(+), 55 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 4a66e748..2920d037 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -70,34 +70,42 @@ def create_entity_list() -> EntityList: return EntityList.objects.create() -def create_entity_list_row( +def create_entity_list_rows( entity_list: EntityList, - entity_pk: int, - order_num: int, - draft_version_pk: int | None, - published_version_pk: int | None, + entity_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], ) -> EntityListRow: """ - Create a new entity list row. This is a row in an entity list that references - publishable entities. + Create new entity list rows for an entity list. Args: - entity_list: The entity list that the entity list row belongs to. - entity: The ID of the publishable entity that the entity list row references. - order_num: The order_num of the entity list row in the entity list. - draft_version_pk: The ID of the draft version of the entity (PublishableEntityVersion) that the entity list row references. - published_version_pk: The ID of the published version of the entity (PublishableEntityVersion) that the entity list row references + entity_list: The entity list to create the rows for. + entity_pks: The IDs of the publishable entities that the entity list rows reference. + draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. + published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. Returns: - The newly created entity list row. + The newly created entity list rows. """ - return EntityListRow.objects.create( - entity_list=entity_list, - entity_id=entity_pk, - order_num=order_num, - draft_version_id=draft_version_pk, - published_version_id=published_version_pk, - ) + order_nums = range(len(entity_pks)) + with atomic(): + entity_list_rows = EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=entity_pk, + order_num=order_num, + draft_version_id=draft_version_pk, + published_version_id=published_version_pk, + ) + for entity_pk, order_num, draft_version_pk, published_version_pk in zip( + entity_pks, order_nums, draft_version_pks, published_version_pks + ) + ] + ) + + return entity_list_rows def create_entity_list_with_rows( @@ -109,26 +117,55 @@ def create_entity_list_with_rows( Create a new entity list with rows. Args: - entity_pks: The IDs of the publishable entities that the entity list rows reference. + entity_pks: The IDs of the publishable entities that the entity list rows + reference. order_nums: The order_nums of the entity list rows in the entity list. - draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. - published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. + draft_version_pks: The IDs of the draft versions of the entities + (PublishableEntityVersion) that the entity list rows reference. + published_version_pks: The IDs of the published versions of the entities + (PublishableEntityVersion) that the entity list rows reference. Returns: The newly created entity list. """ entity_list = create_entity_list() - order_nums = range(len(entity_pks)) - for entity_pk, order_num, draft_version_pk, published_version_pk in zip( - entity_pks, order_nums, draft_version_pks, published_version_pks - ): - create_entity_list_row( - entity_list=entity_list, - entity_pk=entity_pk, - order_num=order_num, - draft_version_pk=draft_version_pk, - published_version_pk=published_version_pk, + create_entity_list_rows( + entity_list=entity_list, + entity_pks=entity_pks, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, + ) + return entity_list + + +def copy_rows_to_new_entity_list( + rows: QuerySet[EntityListRow], +) -> EntityList: + """ + Copy rows from an existing entity list to a new entity list. + + Args: + entity_list: The entity list to copy the rows to. + rows: The rows to copy to the new entity list. + + Returns: + The newly created entity list. + """ + entity_list = create_entity_list() + with atomic(): + entity_list_rows = EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=row.entity.id, + order_num=row.order_num, + draft_version_id=row.entity.draft.version.pk, + published_version_id=row.entity.published.version.pk, + ) + for row in rows + ] ) + return entity_list @@ -137,6 +174,8 @@ def create_container_version( version_num: int, title: str, publishable_entities_pk: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], entity: PublishableEntity, created: datetime, created_by: int | None, @@ -164,20 +203,17 @@ def create_container_version( created=created, created_by=created_by, ) - # This implementation assumes: - # 1. We are creating the first version of the container, so the defined list is the same as the initial list. - # 2. The frozen list is empty because this is the first version. - # 3. Published and draft versions are always the latest for all members. entity_list = create_entity_list_with_rows( entity_pks=publishable_entities_pk, - draft_version_pks=[None] * len(publishable_entities_pk), - published_version_pks=[None] * len(publishable_entities_pk), + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, ) container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, defined_list=entity_list, initial_list=entity_list, + # Would frozen_list be always None the 1st time a container is created? frozen_list=None, ) return container_version @@ -187,6 +223,8 @@ def create_next_container_version( container_pk: int, title: str, publishable_entities_pk: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], entity: PublishableEntity, created: datetime, created_by: int | None, @@ -216,23 +254,26 @@ def create_next_container_version( created=created, created_by=created_by, ) - # This implementation assumes: - # 1. The changes provoking a new version are the addition, removal of members or reordering. + # 1. The changes provoking a new version are always the addition, removal of members or reordering. How can we detect changes only in the metadata? # 2. Published and draft versions are always the latest for all members. # 3. When creating a new version, a new user-defined entity list is created to preserve the latest state as the previous user-defined list. - # TODO: instead consider copying the previous user-defined list as the frozen list, and add/remove to the previous user-defined list. - # If it's a reordering, the previous user-defined list is copied as the frozen and a new user-defined list is created with the new order. + # 4. When creating a new version, a new frozen entity list is created copying the state of the user-defined list of the previous version. + # 5. While copying the versions into the new frozen list, versions are pinned in new rows for published/draft versions. new_user_defined_list = create_entity_list_with_rows( entity_pks=publishable_entities_pk, - draft_version_pks=[None] * len(publishable_entities_pk), - published_version_pks=[None] * len(publishable_entities_pk), + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, ) + new_frozen_list = copy_rows_to_new_entity_list( + rows=get_defined_list_rows_for_container_version(last_version) + ) + container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, defined_list=new_user_defined_list, initial_list=last_version.initial_list, - frozen_list=last_version.defined_list, + frozen_list=new_frozen_list, ) return container_version @@ -244,6 +285,8 @@ def create_container_and_version( created_by: int | None, title: str, publishable_entities_pk: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], ) -> ContainerEntityVersion: """ Create a new container and its first version. @@ -267,6 +310,8 @@ def create_container_and_version( version_num=1, title=title, publishable_entities_pk=publishable_entities_pk, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, entity=container.publishable_entity, created=created, created_by=created_by, @@ -283,13 +328,12 @@ def get_container(pk: int) -> ContainerEntity: Returns: The container with the given primary key. - - TODO: should this use with_publishing_relations as in components? """ + # TODO: should this use with_publishing_relations as in components? return ContainerEntity.objects.get(pk=pk) -def get_defined_list_for_container_version( +def get_defined_list_rows_for_container_version( container_version: ContainerEntityVersion, ) -> QuerySet[EntityListRow]: """ @@ -304,7 +348,7 @@ def get_defined_list_for_container_version( return container_version.defined_list.entitylistrow_set.all() -def get_initial_list_for_container_version( +def get_initial_list_rows_for_container_version( container_version: ContainerEntityVersion, ) -> QuerySet[EntityListRow]: """ @@ -319,7 +363,7 @@ def get_initial_list_for_container_version( return container_version.initial_list.entitylistrow_set.all() -def get_frozen_list_for_container_version( +def get_frozen_list_rows_for_container_version( container_version: ContainerEntityVersion, ) -> QuerySet[EntityListRow]: """ @@ -331,4 +375,6 @@ def get_frozen_list_for_container_version( Returns: The frozen members of the container version. """ + if container_version.frozen_list is None: + return QuerySet[EntityListRow]() return container_version.frozen_list.entitylistrow_set.all() diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 9e06a8ad..9982354c 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -54,7 +54,9 @@ def create_unit_version( unit: Unit, version_num: int, title: str, - publishable_entities_pk: list[int], + publishable_entities_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], created: datetime, created_by: int | None, ) -> Unit: @@ -74,7 +76,9 @@ def create_unit_version( unit.container_entity.pk, version_num, title, - publishable_entities_pk, + publishable_entities_pks, + draft_version_pks, + published_version_pks, unit.container_entity.publishable_entity, created, created_by, @@ -90,7 +94,9 @@ def create_unit_version( def create_next_unit_version( unit: Unit, title: str, - publishable_entities_pk: list[int], + publishable_entities_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], created: datetime, created_by: int | None, ) -> Unit: @@ -110,7 +116,9 @@ def create_next_unit_version( container_entity_version = container_api.create_next_container_version( unit.container_entity.pk, title, - publishable_entities_pk, + publishable_entities_pks, + draft_version_pks, + published_version_pks, unit.container_entity.publishable_entity, created, created_by, From d9cdb21da10fb6f6f022a1ad06ed62ad548286dd Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 14 Nov 2024 18:17:12 +0100 Subject: [PATCH 6/9] tmp -- needs testing --- .../apps/authoring/containers/api.py | 131 +++++++++++------- 1 file changed, 78 insertions(+), 53 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 2920d037..f772ce81 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -70,8 +70,9 @@ def create_entity_list() -> EntityList: return EntityList.objects.create() -def create_entity_list_rows( - entity_list: EntityList, +def create_next_defined_list( + previous_entity_list: EntityList | None, + new_entity_list: EntityList, entity_pks: list[int], draft_version_pks: list[int | None], published_version_pks: list[int | None], @@ -80,7 +81,8 @@ def create_entity_list_rows( Create new entity list rows for an entity list. Args: - entity_list: The entity list to create the rows for. + previous_entity_list: The previous entity list that the new entity list is based on. + new_entity_list: The entity list to create the rows for. entity_pks: The IDs of the publishable entities that the entity list rows reference. draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. @@ -90,55 +92,72 @@ def create_entity_list_rows( """ order_nums = range(len(entity_pks)) with atomic(): - entity_list_rows = EntityListRow.objects.bulk_create( - [ + # Case 1: create first container version (no previous rows created for container) + # 1. Create new rows for the entity list + # Case 2: create next container version (previous rows created for container) + # 1. Get all the rows in the previous version entity list + # 2. Only associate existent rows to the new entity list iff: the order is the same, the PublishableEntity is in entity_pks and versions are not pinned + # 3. If the order is different for a row with the PublishableEntity, create new row with the same PublishableEntity for the new order + # and associate the new row to the new entity list + current_rows = previous_entity_list.entitylistrow_set.all() + publishable_entities_in_rows = {row.entity.pk: row for row in current_rows} + new_rows = [] + for order_num, entity_pk, draft_version_pk, published_version_pk in zip( + order_nums, entity_pks, draft_version_pks, published_version_pks + ): + row = publishable_entities_in_rows.get(entity_pk) + if row and row.order_num == order_num: + new_entity_list.entitylistrow_set.add(row) + continue + new_rows.append( EntityListRow( - entity_list=entity_list, + entity_list=new_entity_list, entity_id=entity_pk, order_num=order_num, draft_version_id=draft_version_pk, published_version_id=published_version_pk, ) - for entity_pk, order_num, draft_version_pk, published_version_pk in zip( - entity_pks, order_nums, draft_version_pks, published_version_pks - ) - ] - ) - - return entity_list_rows + ) + EntityListRow.objects.bulk_create(new_rows) - -def create_entity_list_with_rows( +def create_defined_list( + entity_list: EntityList, entity_pks: list[int], draft_version_pks: list[int | None], published_version_pks: list[int | None], ) -> EntityList: """ - Create a new entity list with rows. + Create new entity list rows for an entity list. Args: - entity_pks: The IDs of the publishable entities that the entity list rows - reference. - order_nums: The order_nums of the entity list rows in the entity list. - draft_version_pks: The IDs of the draft versions of the entities - (PublishableEntityVersion) that the entity list rows reference. - published_version_pks: The IDs of the published versions of the entities - (PublishableEntityVersion) that the entity list rows reference. + entity_list: The entity list to create the rows for. + entity_pks: The IDs of the publishable entities that the entity list rows reference. + draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. + published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. Returns: The newly created entity list. """ - entity_list = create_entity_list() - create_entity_list_rows( - entity_list=entity_list, - entity_pks=entity_pks, - draft_version_pks=draft_version_pks, - published_version_pks=published_version_pks, - ) + order_nums = range(len(entity_pks)) + with atomic(): + EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=entity_pk, + order_num=order_num, + draft_version_id=draft_version_pk, + published_version_id=published_version_pk, + ) + for order_num, entity_pk, draft_version_pk, published_version_pk in zip( + order_nums, entity_pks, draft_version_pks, published_version_pks + ) + ] + ) return entity_list -def copy_rows_to_new_entity_list( +def pin_versions_in_entity_list( rows: QuerySet[EntityListRow], ) -> EntityList: """ @@ -153,7 +172,7 @@ def copy_rows_to_new_entity_list( """ entity_list = create_entity_list() with atomic(): - entity_list_rows = EntityListRow.objects.bulk_create( + _ = EntityListRow.objects.bulk_create( [ EntityListRow( entity_list=entity_list, @@ -203,7 +222,7 @@ def create_container_version( created=created, created_by=created_by, ) - entity_list = create_entity_list_with_rows( + defined_list = create_defined_list( entity_pks=publishable_entities_pk, draft_version_pks=draft_version_pks, published_version_pks=published_version_pks, @@ -211,9 +230,10 @@ def create_container_version( container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, - defined_list=entity_list, - initial_list=entity_list, - # Would frozen_list be always None the 1st time a container is created? + defined_list=defined_list, + initial_list=defined_list, + # TODO: Check for unpinned versions in defined_list to know whether to point this to the defined_list + # point to None. frozen_list=None, ) return container_version @@ -230,12 +250,19 @@ def create_next_container_version( created_by: int | None, ) -> ContainerEntityVersion: """ - Create the next version of a container. + Create the next version of a container. A new version of the container is created + only when its metadata changes: + + * Something was added to the Container. + * We re-ordered the rows in the container. + * Something was removed to the container. + * The Container's metadata changed, e.g. the title. + * We pin to different versions of the Container. Args: container_pk: The ID of the container to create the next version of. title: The title of the container. - publishable_entities_pk: The IDs of the members of the container. + publishable_entities_pk: The IDs of the members current members of the container. entity: The entity that the container belongs to. created: The date and time the container version was created. created_by: The ID of the user who created the container version. @@ -254,28 +281,26 @@ def create_next_container_version( created=created, created_by=created_by, ) - # 1. The changes provoking a new version are always the addition, removal of members or reordering. How can we detect changes only in the metadata? - # 2. Published and draft versions are always the latest for all members. - # 3. When creating a new version, a new user-defined entity list is created to preserve the latest state as the previous user-defined list. - # 4. When creating a new version, a new frozen entity list is created copying the state of the user-defined list of the previous version. - # 5. While copying the versions into the new frozen list, versions are pinned in new rows for published/draft versions. - new_user_defined_list = create_entity_list_with_rows( + # 1. Pin versions in previous frozen list for last container version + # 2. Create new defined list for author changes + next_defined_list = create_next_defined_list( + previous_entity_list=last_version.defined_list, + new_entity_list=create_entity_list(), entity_pks=publishable_entities_pk, draft_version_pks=draft_version_pks, published_version_pks=published_version_pks, ) - new_frozen_list = copy_rows_to_new_entity_list( - rows=get_defined_list_rows_for_container_version(last_version) - ) - - container_version = ContainerEntityVersion.objects.create( + # 3. Check for unpinned references in defined_list to determine if frozen_list should be None + # 4. Point frozen_list to None or defined_list + next_container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, - defined_list=new_user_defined_list, - initial_list=last_version.initial_list, - frozen_list=new_frozen_list, + defined_list=next_defined_list, + initial_list=next_defined_list, + frozen_list=None, ) - return container_version + + return next_container_version def create_container_and_version( From dc57c108d526bff5654a6c782408a46ea12437ad Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 14 Nov 2024 21:08:53 +0100 Subject: [PATCH 7/9] tmp --- openedx_learning/apps/authoring/containers/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index f772ce81..fbb7a886 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -25,9 +25,9 @@ "create_next_container_version", "create_container_and_version", "get_container", - "get_defined_list_for_container_version", - "get_initial_list_for_container_version", - "get_frozen_list_for_container_version", + "get_defined_list_rows_for_container_version", + "get_initial_list_rows_for_container_version", + "get_frozen_list_rows_for_container_version", ] From 1ad12f5f414eccb27f47c9afbe35f54be16cc63e Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 19 Nov 2024 12:27:07 +0100 Subject: [PATCH 8/9] tmp --- .../apps/authoring/containers/api.py | 96 ++++++++++++++++--- 1 file changed, 82 insertions(+), 14 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index fbb7a886..3e3d8547 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -157,7 +157,7 @@ def create_defined_list( return entity_list -def pin_versions_in_entity_list( +def get_entity_list_with_pinned_versions( rows: QuerySet[EntityListRow], ) -> EntityList: """ @@ -188,6 +188,46 @@ def pin_versions_in_entity_list( return entity_list +def check_unpinned_versions_in_defined_list( + defined_list: EntityList, +) -> bool: + """ + Check if there are any unpinned versions in the defined list. + + Args: + defined_list: The defined list to check for unpinned versions. + + Returns: + True if there are unpinned versions in the defined list, False otherwise. + """ + # Is there a way to short-circuit this? + return any( + row.draft_version is None or row.published_version is None + for row in defined_list.entitylistrow_set.all() + ) + + +def check_new_changes_in_defined_list( + entity_list: EntityList, + publishable_entities_pk: list[int], +) -> bool: + """ + Check if there are any new changes in the defined list. + + Args: + entity_list: The entity list to check for new changes. + publishable_entities: The publishable entities to check for new changes. + + Returns: + True if there are new changes in the defined list, False otherwise. + """ + # Is there a way to short-circuit this? Using queryset operations + return any( + row.entity.pk not in publishable_entities_pk + for row in entity_list.entitylistrow_set.all() + ) + + def create_container_version( container_pk: int, version_num: int, @@ -281,23 +321,51 @@ def create_next_container_version( created=created, created_by=created_by, ) - # 1. Pin versions in previous frozen list for last container version - # 2. Create new defined list for author changes - next_defined_list = create_next_defined_list( - previous_entity_list=last_version.defined_list, - new_entity_list=create_entity_list(), - entity_pks=publishable_entities_pk, - draft_version_pks=draft_version_pks, - published_version_pks=published_version_pks, - ) - # 3. Check for unpinned references in defined_list to determine if frozen_list should be None - # 4. Point frozen_list to None or defined_list + # 1. Check if there are any changes in the container's members + # 2. Pin versions in previous frozen list for last container version + # 3. Create new defined list for author changes + # 4. Pin versions in defined list to create initial list + # 5. Check for unpinned references in defined_list to determine if frozen_list should be None + # 6. Point frozen_list to None or defined_list + if check_new_changes_in_defined_list( + entity_list=last_version.defined_list, + publishable_entities_pk=publishable_entities_pk, + ): + # Only change if there are unpin versions in defined list, meaning last frozen list is None + # When does this has to happen? Before? + if not last_version.frozen_list: + last_version.frozen_list = get_entity_list_with_pinned_versions( + rows=last_version.defined_list.entitylistrow_set.all() + ) + last_version.save() + next_defined_list = create_next_defined_list( + previous_entity_list=last_version.defined_list, + new_entity_list=create_entity_list(), + entity_pks=publishable_entities_pk, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, + ) + next_initial_list = get_entity_list_with_pinned_versions( + rows=next_defined_list.frozen_list.entitylistrow_set.all() + ) + if check_unpinned_versions_in_defined_list(next_defined_list): + next_frozen_list = None + else: + next_frozen_list = next_initial_list + else: + # Do I need to create new EntityList and copy rows? + # I do think so because frozen can change when creating a new version + # Does it need to change though? + # What would happen if I only change the title? + next_defined_list = last_version.defined_list + next_initial_list = last_version.initial_list + next_frozen_list = last_version.frozen_list next_container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, defined_list=next_defined_list, - initial_list=next_defined_list, - frozen_list=None, + initial_list=next_initial_list, + frozen_list=next_frozen_list, ) return next_container_version From 5d0083b385638aed7dcb761df970d4f2166c6e7c Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Wed, 20 Nov 2024 21:25:03 +0100 Subject: [PATCH 9/9] refactor: address PR reviews and implement unittests --- .../apps/authoring/containers/api.py | 20 +- openedx_learning/apps/authoring/units/api.py | 18 +- projects/dev.py | 2 + test_settings.py | 2 + .../apps/authoring/units/__init__.py | 0 .../apps/authoring/units/test_api.py | 198 ++++++++++++++++++ 6 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 tests/openedx_learning/apps/authoring/units/__init__.py create mode 100644 tests/openedx_learning/apps/authoring/units/test_api.py diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 3e3d8547..4feb3a5f 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -119,9 +119,9 @@ def create_next_defined_list( ) ) EntityListRow.objects.bulk_create(new_rows) + return new_entity_list -def create_defined_list( - entity_list: EntityList, +def create_defined_list_with_rows( entity_pks: list[int], draft_version_pks: list[int | None], published_version_pks: list[int | None], @@ -140,6 +140,7 @@ def create_defined_list( """ order_nums = range(len(entity_pks)) with atomic(): + entity_list = create_entity_list() EntityListRow.objects.bulk_create( [ EntityListRow( @@ -178,8 +179,8 @@ def get_entity_list_with_pinned_versions( entity_list=entity_list, entity_id=row.entity.id, order_num=row.order_num, - draft_version_id=row.entity.draft.version.pk, - published_version_id=row.entity.published.version.pk, + draft_version_id=None, + published_version_id=None, # For simplicity, we are not copying the pinned versions ) for row in rows ] @@ -222,10 +223,8 @@ def check_new_changes_in_defined_list( True if there are new changes in the defined list, False otherwise. """ # Is there a way to short-circuit this? Using queryset operations - return any( - row.entity.pk not in publishable_entities_pk - for row in entity_list.entitylistrow_set.all() - ) + # For simplicity, return True + return True def create_container_version( @@ -262,7 +261,7 @@ def create_container_version( created=created, created_by=created_by, ) - defined_list = create_defined_list( + defined_list = create_defined_list_with_rows( entity_pks=publishable_entities_pk, draft_version_pks=draft_version_pks, published_version_pks=published_version_pks, @@ -274,6 +273,7 @@ def create_container_version( initial_list=defined_list, # TODO: Check for unpinned versions in defined_list to know whether to point this to the defined_list # point to None. + # If this is the first version ever created for this ContainerEntity, then start as None. frozen_list=None, ) return container_version @@ -346,7 +346,7 @@ def create_next_container_version( published_version_pks=published_version_pks, ) next_initial_list = get_entity_list_with_pinned_versions( - rows=next_defined_list.frozen_list.entitylistrow_set.all() + rows=next_defined_list.entitylistrow_set.all() ) if check_unpinned_versions_in_defined_list(next_defined_list): next_frozen_list = None diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 9982354c..c0a950b2 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -58,7 +58,7 @@ def create_unit_version( draft_version_pks: list[int | None], published_version_pks: list[int | None], created: datetime, - created_by: int | None, + created_by: int | None = None, ) -> Unit: """Create a new unit version. @@ -98,7 +98,7 @@ def create_next_unit_version( draft_version_pks: list[int | None], published_version_pks: list[int | None], created: datetime, - created_by: int | None, + created_by: int | None = None, ) -> Unit: """Create the next unit version. @@ -134,11 +134,11 @@ def create_next_unit_version( def create_unit_and_version( learning_package_id: int, key: str, - created: datetime, - created_by: int | None, title: str, + created: datetime, + created_by: int | None = None, ) -> tuple[Unit, UnitVersion]: - """Create a new unit and version. + """Create a new unit and its version. Args: learning_package_id: The learning package ID. @@ -153,6 +153,8 @@ def create_unit_and_version( 1, title, [], + [], + [], created, created_by, ) @@ -193,7 +195,7 @@ def get_user_defined_list_in_unit_version(unit_version_pk: int) -> QuerySet[Enti unit_version_pk: The unit version ID. """ unit_version = UnitVersion.objects.get(pk=unit_version_pk) - return container_api.get_defined_list_for_container_version(unit_version.container_entity_version) + return container_api.get_defined_list_rows_for_container_version(unit_version.container_entity_version) def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: @@ -203,7 +205,7 @@ def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: unit_version_pk: The unit version ID. """ unit_version = UnitVersion.objects.get(pk=unit_version_pk) - return container_api.get_initial_list_for_container_version(unit_version.container_entity_version) + return container_api.get_initial_list_rows_for_container_version(unit_version.container_entity_version) def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: @@ -213,4 +215,4 @@ def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: unit_version_pk: The unit version ID. """ unit_version = UnitVersion.objects.get(pk=unit_version_pk) - return container_api.get_frozen_list_for_container_version(unit_version.container_entity_version) + return container_api.get_frozen_list_rows_for_container_version(unit_version.container_entity_version) diff --git a/projects/dev.py b/projects/dev.py index ada76e5e..094494ab 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -35,6 +35,8 @@ "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", + "openedx_learning.apps.authoring.containers.apps.ContainersConfig", + "openedx_learning.apps.authoring.units.apps.UnitsConfig", # Learning Contrib Apps "openedx_learning.contrib.media_server.apps.MediaServerConfig", # Apps that don't belong in this repo in the long term, but are here to make diff --git a/test_settings.py b/test_settings.py index f5b154f5..9b58f909 100644 --- a/test_settings.py +++ b/test_settings.py @@ -45,6 +45,8 @@ def root(*args): "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_tagging.core.tagging.apps.TaggingConfig", + "openedx_learning.apps.authoring.containers.apps.ContainersConfig", + "openedx_learning.apps.authoring.units.apps.UnitsConfig", ] AUTHENTICATION_BACKENDS = [ diff --git a/tests/openedx_learning/apps/authoring/units/__init__.py b/tests/openedx_learning/apps/authoring/units/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py new file mode 100644 index 00000000..e09253c6 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -0,0 +1,198 @@ +""" +Basic tests for the units API. +""" +from ..components.test_api import ComponentTestCase +from openedx_learning.api import authoring as authoring_api + + +class UnitTestCase(ComponentTestCase): + + def setUp(self) -> None: + self.component_1, self.component_version_1 = authoring_api.create_component_and_version( + self.learning_package.id, + component_type=self.problem_type, + local_key="Query Counting", + title="Querying Counting Problem", + created=self.now, + created_by=None, + ) + self.component_2, self.component_version_2 = authoring_api.create_component_and_version( + self.learning_package.id, + component_type=self.problem_type, + local_key="Query Counting (2)", + title="Querying Counting Problem (2)", + created=self.now, + created_by=None, + ) + + def test_create_unit_with_content_instead_of_components(self): + """Test creating a unit with content instead of components. + + Expected results: + 1. An error is raised indicating the content restriction for units. + 2. The unit is not created. + """ + + def test_create_empty_first_unit_and_version(self): + """Test creating a unit with no components. + + Expected results: + 1. A unit and unit version are created. + 2. The unit version number is 1. + 3. The unit version is in the unit's versions. + """ + unit, unit_version = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=f"unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + assert unit, unit_version + assert unit_version.version_num == 1 + assert unit_version in unit.versioning.versions.all() + + def test_create_next_unit_version_with_two_components(self): + """Test creating a unit version with two components. + + Expected results: + 1. A new unit version is created. + 2. The unit version number is 2. + 3. The unit version is in the unit's versions. + 4. The components are in the unit version's user defined list. + 5. Initial list contains the pinned versions of the defined list. + 6. Frozen list is empty. + """ + unit, unit_version = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=f"unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + unit_version_v2 = authoring_api.create_next_unit_version( + unit=unit, + title="Unit", + publishable_entities_pks=[ + self.component_1.publishable_entity.id, + self.component_2.publishable_entity.id, + ], + draft_version_pks=[None, None], + published_version_pks=[None, None], + created=self.now, + created_by=None, + ) + assert unit_version_v2.version_num == 2 + assert unit_version_v2 in unit.versioning.versions.all() + publishable_entities_in_list = [ + row.entity for row in authoring_api.get_user_defined_list_in_unit_version(unit_version_v2.pk) + ] + assert self.component_1.publishable_entity in publishable_entities_in_list + assert self.component_2.publishable_entity in publishable_entities_in_list + + + def test_next_version_with_different_different_title(self): + """Test creating a unit version with a different title. + + Expected results: + 1. A new unit version is created. + 2. The unit version number is 2. + 3. The unit version is in the unit's versions. + 4. The unit version's title is different from the previous version. + 5. The user defined is the same as the previous version. + 6. The frozen list is empty. + """ + + def test_create_two_units_with_same_components(self): + """Test creating two units with the same components. + + Expected results: + 1. Two different units are created. + 2. The units have the same components. + """ + + def test_check_author_defined_list_matches_components(self): + """Test checking the author defined list matches the components. + + Expected results: + 1. The author defined list matches the components used to create the unit version. + """ + + def test_check_initial_list_matches_components(self): + """Test checking the initial list matches the components. + + Expected results: + 1. The initial list matches the components (pinned) used to create the unit version. + """ + + def test_check_frozen_list_is_none_floating_versions(self): + """Test checking the frozen list is None when floating versions are used in the author defined list. + + Expected results: + 1. The frozen list is None. + """ + + def test_check_frozen_list_when_next_version_is_created(self): + """Test checking the frozen list when a new version is created. + + Expected results: + 1. The frozen list has pinned versions of the user defined list from the previous version. + """ + + def test_check_lists_equal_when_pinned_versions(self): + """Test checking the lists are equal when pinned versions are used. + + Expected results: + 1. The author defined list == initial list == frozen list. + """ + + def test_publish_unit_version(self): + """Test publish unpublished unit version. + + Expected results: + 1. The newly created unit version has unpublished changes. + 2. The published version matches the unit version. + 3. The draft version matches the unit version. + """ + + def test_publish_unit_with_unpublished_component(self): + """Test publishing a unit with an unpublished component. + + Expected results: + 1. The unit version is published. + 2. The component is published. + """ + + def test_next_version_with_different_order(self): + """Test creating a unit version with different order of components. + + Expected results: + 1. A new unit version is created. + 2. The unit version number is 2. + 3. The unit version is in the unit's versions. + 4. The user defined list is different from the previous version. + 5. The initial list contains the pinned versions of the defined list. + 6. The frozen list is empty. + """ + + def test_soft_delete_component_from_units(self): + """Soft-delete a component from a unit. + + Expected result: + After soft-deleting the component (draft), a new unit version (draft) is created for the unit. + """ + + def test_soft_delete_component_from_units_and_publish(self): + """Soft-delete a component from a unit and publish the unit. + + Expected result: + After soft-deleting the component (draft), a new unit version (draft) is created for the unit. + Then, if the unit is published all units referencing the component are published as well. + """ + + def test_unit_version_becomes_draft_again(self): + """Test a unit version becomes a draft again. + + Expected results: + 1. The frozen list is None after the unit version becomes a draft again. + """