-
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
Open
marslanabdulrauf
wants to merge
9
commits into
openedx:master
Choose a base branch
from
mitodl:marslan/10083-export-content-libraries-git
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
aaeebd3
feat: support added to export content libraries to git
marslanabdulrauf 6b55100
fix: separate common part for v2 zip and use that in git export
marslanabdulrauf c027312
fix: move common function to a new file
marslanabdulrauf a63eacd
fix: move export_library_v2_to_zip to backup file as well
marslanabdulrauf d6372e9
fix: lint-import fix
marslanabdulrauf 26ccb6a
refactor: export function renamed
marslanabdulrauf 623471a
test: re-run tests
marslanabdulrauf 9c6d943
test: content library export tests added
marslanabdulrauf b6ec100
Merge branch 'master' into marslan/10083-export-content-libraries-git
pdpinch 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,77 @@ | ||
| """ | ||
| Public API for content library backup (zip export) utilities. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from datetime import datetime | ||
| import shutil | ||
| from tempfile import mkdtemp | ||
| import zipfile | ||
|
|
||
| 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 path import Path | ||
|
|
||
| from openedx_content.api import create_zip_file as create_lib_zip_file | ||
|
|
||
| __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(lp_key=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, user=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 | ||
| user: Username string for the backup API (optional) | ||
|
|
||
| Raises: | ||
| Exception: If backup creation or extraction fails | ||
| """ | ||
| # Get user object for backup API | ||
| user_obj = get_user_model().objects.filter(username=user).first() | ||
| 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(): | ||
|
Comment on lines
+64
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of the code comments are not needed here as the code is simple and readable that we don't really need. Also, there are some unnecessary blank lines as well. |
||
| shutil.rmtree(temp_dir) | ||
Empty file.
89 changes: 89 additions & 0 deletions
89
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,89 @@ | ||
| """ | ||
| 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.filter.return_value.first.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', user='testuser') | ||
|
|
||
| mock_get_user_model.return_value.objects.filter.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.filter.return_value.first.return_value = None | ||
|
|
||
| try: | ||
| with patch('zipfile.ZipFile.extractall', side_effect=OSError('disk full')): | ||
| with pytest.raises(OSError): | ||
| extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', user=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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we could use default dict here.