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
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Open edX Learning ("Learning Core").
"""

__version__ = "0.28.0"
__version__ = "0.29.0"
4 changes: 2 additions & 2 deletions openedx_learning/apps/authoring/backup_restore/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
from openedx_learning.apps.authoring.publishing.api import get_learning_package_by_key


def create_zip_file(lp_key: str, path: str) -> None:
def create_zip_file(lp_key: str, path: str, user: UserType | None = None) -> None:
"""
Creates a dump zip file for the given learning package key at the given path.
The zip file contains a TOML representation of the learning package and its contents.

Can throw a NotFoundError at get_learning_package_by_key
"""
learning_package = get_learning_package_by_key(lp_key)
LearningPackageZipper(learning_package).create_zip(path)
LearningPackageZipper(learning_package, user).create_zip(path)


def load_learning_package(path: str, key: str | None = None, user: UserType | None = None) -> dict:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import time

from django.contrib.auth import get_user_model
from django.core.management import CommandError
from django.core.management.base import BaseCommand

Expand All @@ -13,6 +14,9 @@
logger = logging.getLogger(__name__)


User = get_user_model()


class Command(BaseCommand):
"""
Django management command to export a learning package to a zip file.
Expand All @@ -22,15 +26,26 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('lp_key', type=str, help='The key of the LearningPackage to dump')
parser.add_argument('file_name', type=str, help='The name of the output zip file')
parser.add_argument(
'--username',
type=str,
help='The username of the user performing the backup operation.',
default=None
)

def handle(self, *args, **options):
lp_key = options['lp_key']
file_name = options['file_name']
username = options['username']
if not file_name.lower().endswith(".zip"):
raise CommandError("Output file name must end with .zip")
try:
# Get the user performing the operation
user = None
if username:
user = User.objects.get(username=username)
start_time = time.time()
create_zip_file(lp_key, file_name)
create_zip_file(lp_key, file_name, user=user)
elapsed = time.time() - start_time
message = f'{lp_key} written to {file_name} (create_zip_file: {elapsed:.2f} seconds)'
self.stdout.write(self.style.SUCCESS(message))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
import logging
import time

from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user
from django.contrib.auth import get_user_model
from django.core.management import CommandError
from django.core.management.base import BaseCommand

from openedx_learning.apps.authoring.backup_restore.api import load_learning_package

logger = logging.getLogger(__name__)

User = get_user_model()


class Command(BaseCommand):
"""
Expand All @@ -30,8 +32,8 @@ def handle(self, *args, **options):
raise CommandError("Input file name must end with .zip")
try:
start_time = time.time()
# Create a tmp user to pass to the load function
user = UserType.objects.get(username=username)
# Get the user performing the operation
user = User.objects.get(username=username)

result = load_learning_package(file_name, user=user)
duration = time.time() - start_time
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class LearningPackageMetadataSerializer(serializers.Serializer): # pylint: disa
"""
format_version = serializers.IntegerField(required=True)
created_by = serializers.CharField(required=False, allow_null=True)
created_by_email = serializers.EmailField(required=False, allow_null=True)
created_at = serializers.DateTimeField(required=True, default_timezone=timezone.utc)
origin_server = serializers.CharField(required=False, allow_null=True)

Expand Down
1 change: 1 addition & 0 deletions openedx_learning/apps/authoring/backup_restore/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def toml_learning_package(
metadata.add("format_version", format_version)
if user:
metadata.add("created_by", user.username)
metadata.add("created_by_email", user.email)
metadata.add("created_at", timestamp)
if origin_server:
metadata.add("origin_server", origin_server)
Expand Down
8 changes: 6 additions & 2 deletions openedx_learning/apps/authoring/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ class LearningPackageZipper:
A class to handle the zipping of learning content for backup and restore.
"""

def __init__(self, learning_package: LearningPackage):
def __init__(self, learning_package: LearningPackage, user: UserType | None = None):
self.learning_package = learning_package
self.user = user
self.folders_already_created: set[Path] = set()
self.entities_filenames_already_created: set[str] = set()
self.utc_now = datetime.now(tz=timezone.utc)
Expand Down Expand Up @@ -267,7 +268,7 @@ def create_zip(self, path: str) -> None:

with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
# Add the package.toml file
package_toml_content: str = toml_learning_package(self.learning_package, self.utc_now)
package_toml_content: str = toml_learning_package(self.learning_package, self.utc_now, user=self.user)
self.add_file_to_zip(zipf, Path(TOML_PACKAGE_NAME), package_toml_content, self.learning_package.updated)

# Add the entities directory
Expand Down Expand Up @@ -420,6 +421,7 @@ class BackupMetadata:
format_version: int
created_at: str
created_by: str | None = None
created_by_email: str | None = None
original_server: str | None = None


Expand Down Expand Up @@ -587,7 +589,9 @@ def load(self) -> dict[str, Any]:
backup_metadata=BackupMetadata(
format_version=lp_metadata.get("format_version", 1),
created_by=lp_metadata.get("created_by"),
created_by_email=lp_metadata.get("created_by_email"),
created_at=lp_metadata.get("created_at"),
original_server=lp_metadata.get("origin_server"),
) if lp_metadata else None,
)
return asdict(result)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[meta]
format_version = 1
created_by = "dormsbee"
created_by = "lp_user"
created_by_email = "lp_user@example.com"
created_at = 2025-10-05T18:23:45.180535Z
origin_server = "cms.test"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def setUpTestData(cls):
# Create a user for the test
cls.user = User.objects.create(
username="user",
first_name="Learning",
last_name="Package User",
email="user@example.com",
)

Expand Down Expand Up @@ -221,7 +223,7 @@ def test_lp_dump_command(self):
out = StringIO()

# Call the management command to dump the learning package
call_command("lp_dump", lp_key, file_name, stdout=out)
call_command("lp_dump", lp_key, file_name, username=self.user.username, stdout=out)

# Check that the zip file was created
self.assertTrue(Path(file_name).exists())
Expand All @@ -243,6 +245,8 @@ def test_lp_dump_command(self):
'[meta]',
'format_version = 1',
'created_at =',
'created_by = "user"',
'created_by_email = "user@example.com"',
]
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from io import StringIO
from unittest.mock import patch

from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user
from django.contrib.auth import get_user_model
from django.core.management import call_command

from openedx_learning.apps.authoring.backup_restore.zipper import LearningPackageUnzipper, generate_staged_lp_key
Expand All @@ -14,16 +14,22 @@
from openedx_learning.lib.test_utils import TestCase
from test_utils.zip_file_utils import folder_to_inmemory_zip

User = get_user_model()

class RestoreLearningPackageCommandTest(TestCase):
"""Tests for the lp_load management command."""

class RestoreTestCase(TestCase):
"""Base test case for restore tests."""

def setUp(self):
super().setUp()
self.fixtures_folder = os.path.join(os.path.dirname(__file__), "fixtures/library_backup")
self.zip_file = folder_to_inmemory_zip(self.fixtures_folder)
self.lp_key = "lib:WGU:LIB_C001"
self.user = UserType.objects.create_user(username='lp_user', password='12345')
self.user = User.objects.create_user(username='lp_user', password='12345')


class RestoreLearningPackageCommandTest(RestoreTestCase):
"""Tests for the lp_load management command."""

@patch("openedx_learning.apps.authoring.backup_restore.api.load_learning_package")
def test_restore_command(self, mock_load_learning_package):
Expand Down Expand Up @@ -152,13 +158,12 @@ def verify_collections(self, lp):
assert set(entity_keys) == set(expected_entity_keys)


class RestoreLearningPackageTest(TestCase):
class RestoreLearningPackageTest(RestoreTestCase):
"""Tests for restoring learning packages without using the management command."""

def test_successful_restore_with_no_command_line(self):
"""Test restoring a learning package without using the management command."""
zip_file = folder_to_inmemory_zip(os.path.join(os.path.dirname(__file__), "fixtures/library_backup"))
result = LearningPackageUnzipper(zip_file, key="lib-xx:WGU:LIB_C001").load()
result = LearningPackageUnzipper(self.zip_file, key="lib-xx:WGU:LIB_C001").load()

expected = {
"status": "success",
Expand All @@ -179,7 +184,7 @@ def test_successful_restore_with_no_command_line(self):
},
"backup_metadata": {
"format_version": 1,
"created_by": "dormsbee",
"created_by": "lp_user",
"created_at": datetime(2025, 10, 5, 18, 23, 45, 180535, tzinfo=timezone.utc),
"origin_server": "cms.test",
},
Expand All @@ -202,9 +207,7 @@ def test_successful_restore_with_no_command_line(self):

def test_successful_restore_with_staged_key(self):
"""Test restoring a learning package with a staged key."""
user = UserType.objects.create_user(username='lp_user', password='12345')
zip_file = folder_to_inmemory_zip(os.path.join(os.path.dirname(__file__), "fixtures/library_backup"))
result = LearningPackageUnzipper(zip_file, user=user).load()
result = LearningPackageUnzipper(self.zip_file, user=self.user).load()

assert result["status"] == "success"
assert result["lp_restored_data"] is not None
Expand Down Expand Up @@ -254,7 +257,7 @@ def test_error_learning_package_missing_key(self):
},
"meta": {
"format_version": 1,
"created_by": "dormsbee",
"created_by": "lp_user",
"created_at": "2025-09-03T17:50:59.536190Z",
"origin_server": "cms.test",
},
Expand Down Expand Up @@ -295,6 +298,23 @@ def test_error_no_metadata_section(self):
expected_error = "Errors encountered during restore:\npackage.toml meta section: {'non_field_errors': [Er"
assert expected_error in log_content

def test_success_metadata_using_user_context(self):
"""Test that metadata is correctly extracted from learning_package.toml."""
restore_result = LearningPackageUnzipper(self.zip_file, user=self.user).load()
metadata = restore_result.get("backup_metadata", {})

assert restore_result["status"] == "success"

expected_metadata = {
"format_version": 1,
"created_by": "lp_user",
"created_by_email": "lp_user@example.com",
"created_at": datetime(2025, 10, 5, 18, 23, 45, 180535, tzinfo=timezone.utc),
"original_server": "cms.test",
}

assert metadata == expected_metadata


class RestoreUtilitiesTest(TestCase):
"""Tests for utility functions used in the restore process."""
Expand Down
Loading