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..4feb3a5f --- /dev/null +++ b/openedx_learning/apps/authoring/containers/api.py @@ -0,0 +1,473 @@ +""" +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_rows_for_container_version", + "get_initial_list_rows_for_container_version", + "get_frozen_list_rows_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_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], +) -> EntityListRow: + """ + Create new entity list rows for an entity list. + + Args: + 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. + + Returns: + The newly created entity list rows. + """ + order_nums = range(len(entity_pks)) + with atomic(): + # 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=new_entity_list, + entity_id=entity_pk, + order_num=order_num, + draft_version_id=draft_version_pk, + published_version_id=published_version_pk, + ) + ) + EntityListRow.objects.bulk_create(new_rows) + return new_entity_list + +def create_defined_list_with_rows( + entity_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], +) -> EntityList: + """ + Create new entity list rows for an entity list. + + Args: + 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. + """ + order_nums = range(len(entity_pks)) + with atomic(): + entity_list = create_entity_list() + 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 get_entity_list_with_pinned_versions( + 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(): + _ = EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=row.entity.id, + order_num=row.order_num, + draft_version_id=None, + published_version_id=None, # For simplicity, we are not copying the pinned versions + ) + for row in rows + ] + ) + + 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 + # For simplicity, return True + return True + + +def create_container_version( + container_pk: int, + 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, +) -> 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, + ) + defined_list = create_defined_list_with_rows( + entity_pks=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=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. + # If this is the first version ever created for this ContainerEntity, then start as None. + frozen_list=None, + ) + return container_version + + +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, +) -> ContainerEntityVersion: + """ + 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 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. + + 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, + ) + # 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.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_initial_list, + frozen_list=next_frozen_list, + ) + + return next_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], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], +) -> 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, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, + 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_rows_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_rows_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_rows_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. + """ + 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/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py new file mode 100644 index 00000000..e8b2e36a --- /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 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 + + 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/models.py b/openedx_learning/apps/authoring/containers/models.py new file mode 100644 index 00000000..66588bf3 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/models.py @@ -0,0 +1,154 @@ +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/containers/models_mixin.py b/openedx_learning/apps/authoring/containers/models_mixin.py new file mode 100644 index 00000000..4accd119 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/models_mixin.py @@ -0,0 +1,106 @@ +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/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..c0a950b2 --- /dev/null +++ b/openedx_learning/apps/authoring/units/api.py @@ -0,0 +1,218 @@ +"""Units API. + +This module provides functions to manage units. +""" + +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 + +__all__ = [ + "create_unit", + "create_unit_version", + "create_next_unit_version", + "create_unit_and_version", + "get_unit", + "get_unit_version", + "get_latest_unit_version", + "get_user_defined_list_in_unit_version", + "get_initial_list_in_unit_version", + "get_frozen_list_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_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], + created: datetime, + created_by: int | None = 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_pks, + draft_version_pks, + published_version_pks, + 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_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], + created: datetime, + created_by: int | None = 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_pks, + draft_version_pks, + published_version_pks, + 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, + title: str, + created: datetime, + created_by: int | None = None, +) -> tuple[Unit, UnitVersion]: + """Create a new unit and its 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_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. + """ + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + 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]: + """Get the initial list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + 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]: + """Get the frozen list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_frozen_list_rows_for_container_version(unit_version.container_entity_version) diff --git a/openedx_learning/apps/authoring/units/apps.py b/openedx_learning/apps/authoring/units/apps.py new file mode 100644 index 00000000..f0beecf3 --- /dev/null +++ b/openedx_learning/apps/authoring/units/apps.py @@ -0,0 +1,25 @@ +""" +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" + + 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) 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..a9d167a2 --- /dev/null +++ b/openedx_learning/apps/authoring/units/models.py @@ -0,0 +1,23 @@ +from django.db import models + +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( + Unit, + on_delete=models.CASCADE, + related_name="versions", + ) 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. + """