Skip to content
Merged
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
9 changes: 5 additions & 4 deletions openedx_learning/apps/authoring/backup_restore/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import tomlkit

from openedx_learning.apps.authoring.publishing import api as publishing_api
from openedx_learning.apps.authoring.publishing.models import PublishableEntity, PublishableEntityVersion
from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage

Expand All @@ -27,8 +28,8 @@ def toml_learning_package(learning_package: LearningPackage) -> str:
def toml_publishable_entity(entity: PublishableEntity) -> str:
"""Create a TOML representation of a publishable entity."""

current_draft_version = getattr(entity, "draft", None)
current_published_version = getattr(entity, "published", None)
current_draft_version = publishing_api.get_draft_version(entity)
current_published_version = publishing_api.get_published_version(entity)

doc = tomlkit.document()
entity_table = tomlkit.table()
Expand All @@ -37,12 +38,12 @@ def toml_publishable_entity(entity: PublishableEntity) -> str:

if current_draft_version:
draft_table = tomlkit.table()
draft_table.add("version_num", current_draft_version.version.version_num)
draft_table.add("version_num", current_draft_version.version_num)
entity_table.add("draft", draft_table)

published_table = tomlkit.table()
if current_published_version:
published_table.add("version_num", current_published_version.version.version_num)
published_table.add("version_num", current_published_version.version_num)
else:
published_table.add(tomlkit.comment("unpublished: no published_version_num"))
entity_table.add("published", published_table)
Expand Down
82 changes: 49 additions & 33 deletions openedx_learning/apps/authoring/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
"""
import zipfile
from pathlib import Path
from typing import Optional

from django.db.models import QuerySet

from openedx_learning.apps.authoring.backup_restore.toml import toml_learning_package, toml_publishable_entity
from openedx_learning.apps.authoring.publishing import api as publishing_api
from openedx_learning.apps.authoring.publishing.models.learning_package import LearningPackage
from openedx_learning.apps.authoring.publishing.models import (
LearningPackage,
PublishableEntity,
PublishableEntityVersion,
)

TOML_PACKAGE_NAME = "package.toml"

Expand All @@ -19,15 +26,19 @@ class LearningPackageZipper:

def __init__(self, learning_package: LearningPackage):
self.learning_package = learning_package
self.folders_already_created: set[Path] = set()

def create_folder(self, folder_path: Path, zip_file: zipfile.ZipFile) -> None:
"""
Create a folder for the zip file structure.
Skips creating the folder if it already exists based on the folder path.
Args:
folder_path (Path): The path of the folder to create.
"""
zip_info = zipfile.ZipInfo(str(folder_path) + "/")
zip_file.writestr(zip_info, "") # Add explicit empty directory entry
if folder_path not in self.folders_already_created:
zip_info = zipfile.ZipInfo(str(folder_path) + "/")
zip_file.writestr(zip_info, "") # Add explicit empty directory entry
self.folders_already_created.add(folder_path)

def create_zip(self, path: str) -> None:
"""
Expand All @@ -38,7 +49,7 @@ def create_zip(self, path: str) -> None:
Exception: If the learning package cannot be found or if the zip creation fails.
"""
package_toml_content: str = toml_learning_package(self.learning_package)
folders_already_created = set()
lp_id = self.learning_package.pk

with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
# Add the package.toml string
Expand All @@ -53,14 +64,18 @@ def create_zip(self, path: str) -> None:
self.create_folder(collections_folder, zipf)

# Add each entity's TOML file
for entity in publishing_api.get_entities(self.learning_package.pk):
publishable_entities: QuerySet[PublishableEntity] = publishing_api.get_publishable_entities(lp_id)
publishable_entities = publishable_entities.select_related("container", "component__component_type")
for entity in publishable_entities:
# entity: PublishableEntity = entity # Type hint for clarity

# Create a TOML representation of the entity
entity_toml_content: str = toml_publishable_entity(entity)
entity_toml_filename = f"{entity.key}.toml"
entity_toml_path = entities_folder / entity_toml_filename
zipf.writestr(str(entity_toml_path), entity_toml_content)

if hasattr(entity, 'container'):
entity_toml_filename = f"{entity.key}.toml"
entity_toml_path = entities_folder / entity_toml_filename
zipf.writestr(str(entity_toml_path), entity_toml_content)

if hasattr(entity, 'component'):
# Create the component folder structure for the entity. The structure is as follows:
Expand All @@ -75,41 +90,42 @@ def create_zip(self, path: str) -> None:

component_namespace_folder = entities_folder / entity.component.component_type.namespace
# Example of component namespace is: "xblock.v1"
if component_namespace_folder not in folders_already_created:
self.create_folder(component_namespace_folder, zipf)
folders_already_created.add(component_namespace_folder)
self.create_folder(component_namespace_folder, zipf)

component_type_folder = component_namespace_folder / entity.component.component_type.name
# Example of component type is: "html"
if component_type_folder not in folders_already_created:
self.create_folder(component_type_folder, zipf)
folders_already_created.add(component_type_folder)
self.create_folder(component_type_folder, zipf)

component_id_folder = component_type_folder / entity.component.local_key # entity.key
# Example of component id is: "i-dont-like-the-sidebar-aa1645ade4a7"
if component_id_folder not in folders_already_created:
self.create_folder(component_id_folder, zipf)
folders_already_created.add(component_id_folder)
self.create_folder(component_id_folder, zipf)

# Add the entity TOML file inside the component type folder as well
component_entity_toml_path = component_type_folder / f"{entity.component.local_key}.toml"
zipf.writestr(str(component_entity_toml_path), entity_toml_content)

# Add component version folder into the component id folder
component_version_folder = component_id_folder / "component_versions"
if component_version_folder not in folders_already_created:
self.create_folder(component_version_folder, zipf)
folders_already_created.add(component_version_folder)

for entity_version in entity.component.versions.all():
component_number_version_folder = component_version_folder / f"v{entity_version.version_num}"
# Create a folder for each version of the component. Example: "v1", "v2", etc.
if component_number_version_folder not in folders_already_created:
self.create_folder(component_number_version_folder, zipf)
folders_already_created.add(component_number_version_folder)

# Add the static folder inside the component version folder
static_folder = component_number_version_folder / "static"
if static_folder not in folders_already_created:
self.create_folder(static_folder, zipf)
folders_already_created.add(static_folder)
self.create_folder(component_version_folder, zipf)

# ------ COMPONENT VERSIONING -------------
# Focusing on draft and published versions

# Get the draft and published versions
draft_version: Optional[PublishableEntityVersion] = publishing_api.get_draft_version(entity)
published_version: Optional[PublishableEntityVersion] = publishing_api.get_published_version(entity)

versions_to_write = [draft_version] if draft_version else []

if published_version and published_version != draft_version:
versions_to_write.append(published_version)

for version in versions_to_write:
# Create a folder for the version
version_number = f"v{version.version_num}"
version_folder = component_version_folder / version_number
self.create_folder(version_folder, zipf)

# Add static folder for the version
static_folder = version_folder / "static"
self.create_folder(static_folder, zipf)
37 changes: 29 additions & 8 deletions openedx_learning/apps/authoring/publishing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import ContextManager, TypeVar
from typing import ContextManager, Optional, TypeVar

from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import F, Q, QuerySet
Expand Down Expand Up @@ -58,9 +58,9 @@
"create_publishable_entity_version",
"get_publishable_entity",
"get_publishable_entity_by_key",
"get_publishable_entities",
"get_last_publish",
"get_all_drafts",
"get_entities",
"get_entities_with_unpublished_changes",
"get_entities_with_unpublished_deletes",
"publish_all_drafts",
Expand Down Expand Up @@ -262,11 +262,18 @@ def get_all_drafts(learning_package_id: int, /) -> QuerySet[Draft]:
)


def get_entities(learning_package_id: int, /) -> QuerySet[PublishableEntity]:
def get_publishable_entities(learning_package_id: int, /) -> QuerySet[PublishableEntity]:
"""
Get all entities in a learning package.
"""
return PublishableEntity.objects.filter(learning_package_id=learning_package_id)
return (
PublishableEntity.objects
.filter(learning_package_id=learning_package_id)
.select_related(
"draft__version",
"published__version",
)
)


def get_entities_with_unpublished_changes(
Expand Down Expand Up @@ -425,15 +432,22 @@ def publish_from_drafts(
return publish_log


def get_draft_version(publishable_entity_id: int, /) -> PublishableEntityVersion | None:
def get_draft_version(publishable_entity_or_id: PublishableEntity | int, /) -> PublishableEntityVersion | None:
"""
Return current draft PublishableEntityVersion for this PublishableEntity.

This function will return None if there is no current draft.
"""
if isinstance(publishable_entity_or_id, PublishableEntity):
# Fetches the draft version for a given PublishableEntity.
# Gracefully handles cases where no draft is present.
draft: Optional[Draft] = getattr(publishable_entity_or_id, "draft", None)
if draft is None:
return None
return draft.version
try:
draft = Draft.objects.select_related("version").get(
entity_id=publishable_entity_id
entity_id=publishable_entity_or_id
)
except Draft.DoesNotExist:
# No draft was ever created.
Expand All @@ -445,15 +459,22 @@ def get_draft_version(publishable_entity_id: int, /) -> PublishableEntityVersion
return draft.version


def get_published_version(publishable_entity_id: int, /) -> PublishableEntityVersion | None:
def get_published_version(publishable_entity_or_id: PublishableEntity | int, /) -> PublishableEntityVersion | None:
"""
Return current published PublishableEntityVersion for this PublishableEntity.

This function will return None if there is no current published version.
"""
if isinstance(publishable_entity_or_id, PublishableEntity):
# Fetches the published version for a given PublishableEntity.
# Gracefully handles cases where no published version is present.
published: Optional[Published] = getattr(publishable_entity_or_id, "published", None)
if published is None:
return None
return published.version
try:
published = Published.objects.select_related("version").get(
entity_id=publishable_entity_id
entity_id=publishable_entity_or_id
)
except Published.DoesNotExist:
return None
Expand Down
Loading