diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index 1e20101ea..b964256a2 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -2,4 +2,4 @@ Open edX Learning ("Learning Core"). """ -__version__ = "0.28.0" +__version__ = "0.29.0" diff --git a/openedx_learning/apps/authoring/backup_restore/api.py b/openedx_learning/apps/authoring/backup_restore/api.py index 339eae934..2324e6e02 100644 --- a/openedx_learning/apps/authoring/backup_restore/api.py +++ b/openedx_learning/apps/authoring/backup_restore/api.py @@ -9,7 +9,7 @@ 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. @@ -17,7 +17,7 @@ def create_zip_file(lp_key: str, path: str) -> None: 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: diff --git a/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py b/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py index 94b16be62..d3e39bda3 100644 --- a/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py +++ b/openedx_learning/apps/authoring/backup_restore/management/commands/lp_dump.py @@ -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 @@ -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. @@ -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)) diff --git a/openedx_learning/apps/authoring/backup_restore/management/commands/lp_load.py b/openedx_learning/apps/authoring/backup_restore/management/commands/lp_load.py index 12a3f55d3..a326a0850 100644 --- a/openedx_learning/apps/authoring/backup_restore/management/commands/lp_load.py +++ b/openedx_learning/apps/authoring/backup_restore/management/commands/lp_load.py @@ -4,7 +4,7 @@ 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 @@ -12,6 +12,8 @@ logger = logging.getLogger(__name__) +User = get_user_model() + class Command(BaseCommand): """ @@ -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 diff --git a/openedx_learning/apps/authoring/backup_restore/serializers.py b/openedx_learning/apps/authoring/backup_restore/serializers.py index eb9ca736b..86f664b49 100644 --- a/openedx_learning/apps/authoring/backup_restore/serializers.py +++ b/openedx_learning/apps/authoring/backup_restore/serializers.py @@ -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) diff --git a/openedx_learning/apps/authoring/backup_restore/toml.py b/openedx_learning/apps/authoring/backup_restore/toml.py index fc5c8240f..a3ab9a03d 100644 --- a/openedx_learning/apps/authoring/backup_restore/toml.py +++ b/openedx_learning/apps/authoring/backup_restore/toml.py @@ -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) diff --git a/openedx_learning/apps/authoring/backup_restore/zipper.py b/openedx_learning/apps/authoring/backup_restore/zipper.py index ed55a99dd..59319c83d 100644 --- a/openedx_learning/apps/authoring/backup_restore/zipper.py +++ b/openedx_learning/apps/authoring/backup_restore/zipper.py @@ -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) @@ -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 @@ -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 @@ -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) diff --git a/tests/openedx_learning/apps/authoring/backup_restore/fixtures/library_backup/package.toml b/tests/openedx_learning/apps/authoring/backup_restore/fixtures/library_backup/package.toml index 7ddae749b..7fe7bf912 100644 --- a/tests/openedx_learning/apps/authoring/backup_restore/fixtures/library_backup/package.toml +++ b/tests/openedx_learning/apps/authoring/backup_restore/fixtures/library_backup/package.toml @@ -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" diff --git a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py index 8a9cafa46..968494fb0 100644 --- a/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py +++ b/tests/openedx_learning/apps/authoring/backup_restore/test_backup.py @@ -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", ) @@ -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()) @@ -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"', ] ) diff --git a/tests/openedx_learning/apps/authoring/backup_restore/test_restore.py b/tests/openedx_learning/apps/authoring/backup_restore/test_restore.py index b1a8db71b..c53d10a89 100644 --- a/tests/openedx_learning/apps/authoring/backup_restore/test_restore.py +++ b/tests/openedx_learning/apps/authoring/backup_restore/test_restore.py @@ -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 @@ -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): @@ -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", @@ -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", }, @@ -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 @@ -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", }, @@ -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."""