-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat: support added to export content libraries to git #38026
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ormsbee
merged 11 commits into
openedx:master
from
mitodl:marslan/10083-export-content-libraries-git
May 13, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
5b2424b
feat: support added to export content libraries to git
marslanabdulrauf 2c9cd75
fix: separate common part for v2 zip and use that in git export
marslanabdulrauf 17bb33f
fix: move common function to a new file
marslanabdulrauf 93128d9
fix: move export_library_v2_to_zip to backup file as well
marslanabdulrauf ea5cb61
fix: lint-import fix
marslanabdulrauf b85cec8
refactor: export function renamed
marslanabdulrauf 57042cb
test: content library export tests added
marslanabdulrauf 7ce7fa0
fix: move common function to a new file
marslanabdulrauf 9a8375a
fix: move export_library_v2_to_zip to backup file as well
marslanabdulrauf fb31125
fix: replace content_key with context_key
marslanabdulrauf 2227d9e
fix: add from ex in raise instead of disabling the lint
marslanabdulrauf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| """ | ||
| Public API for content library backup (zip export) utilities. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| import shutil | ||
| import zipfile | ||
| from datetime import datetime | ||
| from tempfile import mkdtemp | ||
|
|
||
| from django.conf import settings | ||
| from django.contrib.auth import get_user_model | ||
| from django.utils.text import slugify | ||
| from opaque_keys.edx.locator import LibraryLocatorV2, log | ||
| from openedx_content.api import create_zip_file as create_lib_zip_file | ||
| from path import Path | ||
|
|
||
| __all__ = ["create_library_v2_zip", "extract_library_v2_zip_to_dir"] | ||
|
|
||
|
|
||
| def create_library_v2_zip(library_key: LibraryLocatorV2, user) -> tuple: | ||
| """ | ||
| Create a zip backup of a v2 library and return ``(temp_dir, zip_file_path)``. | ||
|
|
||
| The caller is responsible for cleaning up ``temp_dir`` when done. | ||
|
|
||
| Args: | ||
| library_key: LibraryLocatorV2 identifying the library to export. | ||
| user: User object passed to the backup API. | ||
|
|
||
| Returns: | ||
| A tuple of ``(temp_dir as Path, zip_file_path as str)``. | ||
| """ | ||
| root_dir = Path(mkdtemp()) | ||
| sanitized_lib_key = str(library_key).replace(":", "-") | ||
| sanitized_lib_key = slugify(sanitized_lib_key, allow_unicode=True) | ||
| timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") | ||
| filename = f'{sanitized_lib_key}-{timestamp}.zip' | ||
| file_path = os.path.join(root_dir, filename) | ||
| origin_server = getattr(settings, 'CMS_BASE', None) | ||
| create_lib_zip_file(package_ref=str(library_key), path=file_path, user=user, origin_server=origin_server) | ||
| return root_dir, file_path | ||
|
|
||
|
|
||
| def extract_library_v2_zip_to_dir(library_key, root_dir, library_dir, username=None): | ||
| """ | ||
| Export a v2 library to a directory by creating a zip backup and extracting it. | ||
|
|
||
| V2 libraries are stored in Learning Core and use a zip-based backup mechanism. | ||
| This function creates a temporary zip backup, extracts its contents into | ||
| ``library_dir`` under ``root_dir``, then cleans up the temporary zip. | ||
|
|
||
| Args: | ||
| library_key: LibraryLocatorV2 for the library to export | ||
| root_dir: Root directory where library_dir will be created | ||
| library_dir: Directory name under root_dir to extract the library into | ||
| username: Username string for the backup API (optional) | ||
|
|
||
| Raises: | ||
| Exception: If backup creation or extraction fails | ||
| DoesNotExist: If the specified user does not exist | ||
| """ | ||
| # Get user object for backup API (if username provided) | ||
| user_obj = None | ||
| if username: | ||
| # Let it raise if given user doesn't exist | ||
| user_obj = get_user_model().objects.get(username=username) | ||
|
|
||
| temp_dir, zip_path = create_library_v2_zip(library_key, user_obj) | ||
|
|
||
| try: | ||
| target_dir = os.path.join(root_dir, library_dir) | ||
| os.makedirs(target_dir, exist_ok=True) | ||
| # Extract zip contents (will overwrite existing files) | ||
| with zipfile.ZipFile(zip_path, 'r') as zip_ref: | ||
| zip_ref.extractall(target_dir) | ||
| log.info('Extracted library v2 backup to %s', target_dir) | ||
| finally: | ||
| if temp_dir.exists(): | ||
| shutil.rmtree(temp_dir) | ||
Empty file.
88 changes: 88 additions & 0 deletions
88
openedx/core/djangoapps/content_libraries/api/tests/test_backup.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """ | ||
| Tests for content library backup (zip export) utilities. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import shutil | ||
| import tempfile | ||
| import zipfile | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
| from django.test import TestCase | ||
| from opaque_keys.edx.locator import LibraryLocatorV2 | ||
| from path import Path | ||
|
|
||
| from openedx.core.djangoapps.content_libraries.api.backup import extract_library_v2_zip_to_dir | ||
|
|
||
| LIBRARY_KEY = LibraryLocatorV2(org='TestOrg', slug='test-lib') | ||
|
|
||
|
|
||
| class TestExtractLibraryV2ZipToDir(TestCase): | ||
| """ | ||
| Tests for ``extract_library_v2_zip_to_dir``. | ||
| """ | ||
|
|
||
| def _make_zip_in_temp_dir(self, contents=None): | ||
| """ | ||
| Helper: create a real temp dir + zip file and return (temp_dir_path, zip_path). | ||
| ``contents`` is a dict of {filename: bytes} to write into the zip. | ||
| """ | ||
| temp_dir = Path(tempfile.mkdtemp()) | ||
| zip_path = str(temp_dir / 'library.zip') | ||
| with zipfile.ZipFile(zip_path, 'w') as zf: | ||
| for name, data in (contents or {'data.xml': b'<library/>'}).items(): | ||
| zf.writestr(name, data) | ||
| return temp_dir, zip_path | ||
|
|
||
| @patch('openedx.core.djangoapps.content_libraries.api.backup.get_user_model') | ||
| @patch('openedx.core.djangoapps.content_libraries.api.backup.create_library_v2_zip') | ||
| def test_successful_extraction(self, mock_create_zip, mock_get_user_model): | ||
| """ | ||
| On a successful call the function should: | ||
| - resolve the username to a user object via the user model, | ||
| - pass that user object to ``create_library_v2_zip``, | ||
| - create the target directory if it does not already exist, | ||
| - extract the zip contents into <root_dir>/<library_dir>, | ||
| - clean up the temporary zip directory. | ||
| """ | ||
| root_dir = Path(tempfile.mkdtemp()) | ||
| temp_zip_dir, zip_path = self._make_zip_in_temp_dir({'content.xml': b'<lib/>'}) | ||
| mock_create_zip.return_value = (temp_zip_dir, zip_path) | ||
| mock_user = MagicMock() | ||
| mock_get_user_model.return_value.objects.get.return_value = mock_user | ||
|
|
||
| try: | ||
| target = root_dir / 'my-library' | ||
| assert not target.exists(), "Target dir should not exist before the call" | ||
|
|
||
| extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', username='testuser') | ||
|
|
||
| mock_get_user_model.return_value.objects.get.assert_called_once_with(username='testuser') | ||
| mock_create_zip.assert_called_once_with(LIBRARY_KEY, mock_user) | ||
| assert target.isdir(), "Target dir should have been created" | ||
| assert (target / 'content.xml').exists(), "Zip content should be extracted" | ||
| assert not temp_zip_dir.exists(), "Temp zip dir should have been removed" | ||
| finally: | ||
| shutil.rmtree(root_dir, ignore_errors=True) | ||
| shutil.rmtree(temp_zip_dir, ignore_errors=True) | ||
|
|
||
| @patch('openedx.core.djangoapps.content_libraries.api.backup.get_user_model') | ||
| @patch('openedx.core.djangoapps.content_libraries.api.backup.create_library_v2_zip') | ||
| def test_temp_dir_cleaned_up_even_on_extraction_error(self, mock_create_zip, mock_get_user_model): | ||
| """ | ||
| The temporary directory must be cleaned up even when extraction raises. | ||
| """ | ||
| root_dir = Path(tempfile.mkdtemp()) | ||
| temp_zip_dir, zip_path = self._make_zip_in_temp_dir() | ||
| mock_create_zip.return_value = (temp_zip_dir, zip_path) | ||
| mock_get_user_model.return_value.objects.get.return_value = None | ||
|
|
||
| try: | ||
| with patch('zipfile.ZipFile.extractall', side_effect=OSError('disk full')): | ||
| with pytest.raises(OSError, match='disk full'): | ||
| extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', username=None) | ||
| assert not temp_zip_dir.exists(), "Temp dir should be cleaned up on error" | ||
| finally: | ||
| shutil.rmtree(root_dir, ignore_errors=True) | ||
| shutil.rmtree(temp_zip_dir, ignore_errors=True) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.