Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/openedx_content/applets/backup_restore/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ..publishing.api import get_learning_package_by_ref
from .zipper import LearningPackageUnzipper, LearningPackageZipper


# The public API that will be re-exported by openedx_content.api
# is listed in the __all__ entries below. Internal helper functions that are
# private to this module should start with an underscore. If a function does not
Expand Down
10 changes: 3 additions & 7 deletions src/openedx_content/applets/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,15 +774,15 @@ def _save(
learning_package_obj = publishing_api.create_learning_package(**learning_package)
self.learning_package_id = learning_package_obj.id

with publishing_api.bulk_draft_changes_for(learning_package_obj.id):
with publishing_api.draft_changes_for(learning_package_obj.id, self.user):
self._save_components(learning_package_obj, components, component_static_files)
self._save_units(learning_package_obj, containers)
self._save_subsections(learning_package_obj, containers)
self._save_sections(learning_package_obj, containers)
self._save_collections(learning_package_obj, collections)
publishing_api.publish_all_drafts(learning_package_obj.id)
publishing_api.publish_all_drafts(learning_package_obj.id, self.user)

with publishing_api.bulk_draft_changes_for(learning_package_obj.id):
with publishing_api.draft_changes_for(learning_package_obj.id, self.user):
self._save_draft_versions(components, containers, component_static_files)

return learning_package_obj
Expand Down Expand Up @@ -849,7 +849,6 @@ def _save_container(
# entity-container. BUT, this assumpion may not hold true v2+.
container_code=entity_ref,
**data, # should this be allowed to override any of the following fields?
created_by=self.user_id,
container_cls=container_cls,
)
container_map[entity_ref] = container # e.g. `self.units_map_by_ref[entity_ref] = unit`
Expand All @@ -864,7 +863,6 @@ def _save_container(
force_version_num=version_num,
**valid_published, # should this be allowed to override any of the following fields?
entities=children,
created_by=self.user_id,
)

def _save_units(self, learning_package, containers):
Expand Down Expand Up @@ -915,7 +913,6 @@ def _save_draft_versions(self, components, containers, component_static_files):
# Drafts can diverge from published, so we allow ignoring previous media
# Use case: published v1 had files A, B; draft v2 only has file A
ignore_previous_media=True,
created_by=self.user_id,
**valid_draft
)

Expand All @@ -936,7 +933,6 @@ def _process_draft_containers(
**valid_draft, # should this be allowed to override any of the following fields?
entities=children,
force_version_num=version_num,
created_by=self.user_id,
)

_process_draft_containers(Unit, self.units_map_by_ref, children_map=self.components_map_by_ref)
Expand Down
28 changes: 21 additions & 7 deletions src/openedx_content/applets/collections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,29 @@ def create_collection(
collection_code: str,
*,
title: str,
created_by: int | None,
created: publishing_api.DatetimeOrAuto = publishing_api.DATETIME_AUTO,
created_by: publishing_api.AuthorOrAuto = publishing_api.AUTHOR_AUTO,
description: str = "",
enabled: bool = True,
) -> Collection:
"""
Create a new Collection
"""
created_dt = publishing_api.resolve_datetime(learning_package_id, created)
created_by_id = publishing_api.resolve_author(learning_package_id, created_by)
collection = Collection(
learning_package_id=learning_package_id,
collection_code=collection_code,
title=title,
created_by_id=created_by,
created=created_dt,
created_by_id=created_by_id,
description=description,
enabled=enabled,
)
collection.full_clean()
collection.save()
if enabled:
_queue_change_event(collection, created=True, user_id=created_by)
_queue_change_event(collection, created=True, user_id=created_by_id)
return collection


Expand Down Expand Up @@ -281,7 +285,9 @@ def get_collections(learning_package_id: LearningPackage.ID, enabled: bool | Non
def set_collections(
publishable_entity: PublishableEntity,
collection_qset: QuerySet[Collection],
created_by: int | None = None,
*,
created: publishing_api.DatetimeOrAuto = publishing_api.DATETIME_AUTO,
created_by: publishing_api.AuthorOrAuto = publishing_api.AUTHOR_AUTO,
) -> set[Collection]:
"""
Set collections for a given publishable entity.
Expand All @@ -304,10 +310,18 @@ def set_collections(
# Clear other collections for given entity and add only new collections from collection_qset
removed_collections = set(r.collection for r in current_relations.exclude(collection__in=collection_qset))
new_collections = set(collection_qset.exclude(id__in=current_relations.values_list("collection", flat=True)))

created_dt = publishing_api.resolve_datetime(
publishable_entity.learning_package_id, created
)
created_by_id = publishing_api.resolve_author(
publishable_entity.learning_package_id, created_by
)

# Triggers a m2m_changed signal
publishable_entity.collections.set(
objs=collection_qset,
through_defaults={"created_by_id": created_by},
through_defaults={"created_by_id": created_by_id, "created": created_dt},
)
# Update modified date:
affected_collections = removed_collections | new_collections
Expand All @@ -324,8 +338,8 @@ def set_collections(
):
# TODO: test performance of this and potentially send these async if > 1 affected collection.
if collection.id in removed_ids:
_queue_change_event(collection, entities_removed=[publishable_entity.id], user_id=created_by)
_queue_change_event(collection, entities_removed=[publishable_entity.id], user_id=created_by_id)
else:
_queue_change_event(collection, entities_added=[publishable_entity.id], user_id=created_by)
_queue_change_event(collection, entities_added=[publishable_entity.id], user_id=created_by_id)

return affected_collections
68 changes: 35 additions & 33 deletions src/openedx_content/applets/components/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,19 @@ def create_component(
/,
component_type: ComponentType,
component_code: str,
created: datetime,
created_by: int | None,
*,
can_stand_alone: bool = True,
created: publishing_api.DatetimeOrAuto = publishing_api.DATETIME_AUTO,
created_by: publishing_api.AuthorOrAuto = publishing_api.AUTHOR_AUTO,
) -> Component:
"""
Create a new Component (an entity like a Problem or Video).

The ``entity_ref`` is conventionally derived as
``"{namespace}:{type_name}:{component_code}"``, although callers should not assume
that this will always be true.

You must specify `created_by=` unless you're inside a `draft_changes_for` context.
"""
entity_ref = f"{component_type.namespace}:{component_type.name}:{component_code}"
with atomic():
Expand All @@ -114,8 +116,6 @@ def create_component_version(
/,
version_num: int,
title: str,
created: datetime,
created_by: int | None,
*,
media: dict[str, Media.ID | Media | bytes] | None = None,
) -> ComponentVersion:
Expand All @@ -132,21 +132,27 @@ def create_component_version(
almost always want to create a Media object first in actual app code,
because that will give you better control over the MIME type and storage
specifics (file vs. database).

Must be called inside `with draft_changes_for(...):`
"""
with atomic():
publishable_entity_version = publishing_api.create_publishable_entity_version(
component_id,
version_num=version_num,
title=title,
created=created,
created_by=created_by,
)
component_version = ComponentVersion.objects.create(
publishable_entity_version=publishable_entity_version,
component_id=component_id,
publishable_entity_version = publishing_api.create_publishable_entity_version(
component_id,
version_num=version_num,
title=title,
)
component_version = ComponentVersion.objects.create(
publishable_entity_version=publishable_entity_version,
component_id=component_id,
)
if media:
_set_component_version_media(
component_version,
media,
created=publishing_api.resolve_datetime(
publishable_entity_version.entity.learning_package_id,
publishing_api.DATETIME_AUTO,
),
)
if media:
_set_component_version_media(component_version, media, created=created)

return component_version

Expand All @@ -155,9 +161,7 @@ def create_next_component_version(
component_id: Component.ID,
/,
media_to_replace: dict[str, Media.ID | Media | bytes | None],
created: datetime,
title: str | None = None,
created_by: int | None = None,
*,
force_version_num: int | None = None,
ignore_previous_media: bool = False,
Expand All @@ -169,9 +173,7 @@ def create_next_component_version(
component_id (int): The primary key of the Component to version.
media_to_replace (dict): Mapping of file keys to Media IDs,
None (for deletion), or bytes (for new file media).
created (datetime): The creation timestamp for the new version.
title (str, optional): Title for the new version. If None, uses the previous version's title.
created_by (int, optional): User ID of the creator.
force_version_num (int, optional): If provided, overrides the automatic version number increment and sets
this version's number explicitly. Use this if you need to restore or import a version with a specific
version number, such as during data migration or when synchronizing with external systems.
Expand All @@ -195,8 +197,7 @@ def create_next_component_version(
Using `None` for a value in this dict means to delete that key in the next
version.

Make sure to wrap the function call on a atomic statement:
``with transaction.atomic():``
Must be called inside `with draft_changes_for(...):`

It is okay to mark entries for deletion that don't exist. For instance, if a
version has ``a.txt`` and ``b.txt``, sending a ``media_to_replace`` value
Expand Down Expand Up @@ -239,8 +240,6 @@ def create_next_component_version(
component_id,
version_num=next_version_num,
title=title,
created=created,
created_by=created_by,
)
component_version = ComponentVersion.objects.create(
publishable_entity_version=publishable_entity_version,
Expand Down Expand Up @@ -268,41 +267,44 @@ def create_next_component_version(
if media is not None # "media is None" means "delete this"
}

_set_component_version_media(component_version, paths_to_media, created)
_set_component_version_media(
component_version,
paths_to_media,
created=publishing_api.resolve_datetime(
publishable_entity_version.entity.learning_package_id,
publishing_api.DATETIME_AUTO,
),
)

return component_version


def create_component_and_version( # pylint: disable=too-many-positional-arguments
def create_component_and_version(
learning_package_id: LearningPackage.ID,
/,
component_type: ComponentType,
component_code: str,
title: str,
created: datetime,
created_by: int | None = None,
*,
can_stand_alone: bool = True,
media: dict[str, Media.ID | Media | bytes] | None = None,
) -> tuple[Component, ComponentVersion]:
"""
Create a Component and associated ComponentVersion atomically.

Must be called inside `with draft_changes_for(...):`
"""
with atomic():
component = create_component(
learning_package_id,
component_type,
component_code,
created,
created_by,
can_stand_alone=can_stand_alone,
)
component_version = create_component_version(
component.id,
version_num=1,
title=title,
created=created,
created_by=created_by,
media=media or {},
)

Expand Down
Loading
Loading