Skip to content

Commit 8299654

Browse files
feat: support added to export content libraries to git (#38026)
1 parent d5111c7 commit 8299654

7 files changed

Lines changed: 280 additions & 28 deletions

File tree

cms/djangoapps/contentstore/git_export_utils.py

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
1414
from django.utils import timezone
1515
from django.utils.translation import gettext_lazy as _
16+
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
1617

18+
from openedx.core.djangoapps.content_libraries.api import extract_library_v2_zip_to_dir
1719
from xmodule.contentstore.django import contentstore
1820
from xmodule.modulestore.django import modulestore
19-
from xmodule.modulestore.xml_exporter import export_course_to_xml
21+
from xmodule.modulestore.xml_exporter import CourseLocator, export_course_to_xml, export_library_to_xml
2022

2123
log = logging.getLogger(__name__)
2224

@@ -66,10 +68,28 @@ def cmd_log(cmd, cwd):
6668
return output
6769

6870

69-
def export_to_git(course_id, repo, user='', rdir=None):
70-
"""Export a course to git."""
71+
def export_to_git(context_key, repo, user='', rdir=None):
72+
"""
73+
Export a course or library to git.
74+
75+
Args:
76+
context_key: LearningContextKey for the content to export
77+
repo (str): Git repository URL
78+
user (str): Optional username for git commit identity
79+
rdir (str): Optional custom directory name for the repository
80+
81+
Raises:
82+
GitExportError: For various git operation failures
83+
"""
7184
# pylint: disable=too-many-statements
7285

86+
# Validate context_key type and determine export function and content type label
87+
if not isinstance(context_key, (LibraryLocatorV2, LibraryLocator, CourseLocator)):
88+
raise TypeError(
89+
f"{context_key!r} for git export must be LibraryLocatorV2, LibraryLocator, "
90+
f"or CourseLocator, not {type(context_key)}"
91+
)
92+
7393
if not GIT_REPO_EXPORT_DIR:
7494
raise GitExportError(GitExportError.NO_EXPORT_DIR)
7595

@@ -128,15 +148,31 @@ def export_to_git(course_id, repo, user='', rdir=None):
128148
log.exception('Failed to pull git repository: %r', ex.output)
129149
raise GitExportError(GitExportError.CANNOT_PULL) from ex
130150

131-
# export course as xml before commiting and pushing
151+
# export content as xml (or zip for v2 libraries) before commiting and pushing
132152
root_dir = os.path.dirname(rdirp)
133-
course_dir = os.path.basename(rdirp).rsplit('.git', 1)[0]
153+
content_dir = os.path.basename(rdirp).rsplit('.git', 1)[0]
154+
155+
content_type_label = "course" if context_key.is_course else "library"
156+
157+
is_library_v2 = isinstance(context_key, LibraryLocatorV2)
158+
if is_library_v2:
159+
# V2 libraries use backup API with zip extraction
160+
content_export_func = extract_library_v2_zip_to_dir
161+
elif isinstance(context_key, LibraryLocator):
162+
content_export_func = export_library_to_xml
163+
else:
164+
content_export_func = export_course_to_xml
165+
134166
try:
135-
export_course_to_xml(modulestore(), contentstore(), course_id,
136-
root_dir, course_dir)
137-
except (OSError, AttributeError):
138-
log.exception('Failed export to xml')
139-
raise GitExportError(GitExportError.XML_EXPORT_FAIL) # pylint: disable=raise-missing-from # noqa: B904
167+
if is_library_v2:
168+
content_export_func(context_key, root_dir, content_dir, user)
169+
else:
170+
# V1 libraries and courses: use XML export (no user parameter)
171+
content_export_func(modulestore(), contentstore(), context_key,
172+
root_dir, content_dir)
173+
except (OSError, AttributeError) as ex:
174+
log.exception('Failed to export %s', content_type_label)
175+
raise GitExportError(GitExportError.XML_EXPORT_FAIL) from ex
140176

141177
# Get current branch if not already set
142178
if not branch:
@@ -160,9 +196,7 @@ def export_to_git(course_id, repo, user='', rdir=None):
160196
ident = GIT_EXPORT_DEFAULT_IDENT
161197
time_stamp = timezone.now()
162198
cwd = os.path.abspath(rdirp)
163-
commit_msg = "Export from Studio at {time_stamp}".format( # noqa: UP032
164-
time_stamp=time_stamp,
165-
)
199+
commit_msg = f"Export {content_type_label} from Studio at {time_stamp}"
166200
try:
167201
cmd_log(['git', 'config', 'user.email', ident['email']], cwd)
168202
cmd_log(['git', 'config', 'user.name', ident['name']], cwd)
@@ -180,3 +214,10 @@ def export_to_git(course_id, repo, user='', rdir=None):
180214
except subprocess.CalledProcessError as ex:
181215
log.exception('Error running git push command: %r', ex.output)
182216
raise GitExportError(GitExportError.CANNOT_PUSH) from ex
217+
218+
log.info(
219+
'%s %s exported to git repository %s successfully',
220+
content_type_label.capitalize(),
221+
context_key,
222+
repo,
223+
)

cms/djangoapps/contentstore/management/commands/tests/test_git_export.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
import subprocess
1010
import unittest
1111
from io import StringIO
12+
from unittest.mock import patch
1213
from uuid import uuid4
1314

1415
from django.conf import settings
1516
from django.core.management import call_command
1617
from django.core.management.base import CommandError
1718
from django.test.utils import override_settings
18-
from opaque_keys.edx.locator import CourseLocator
19+
from opaque_keys.edx.locator import CourseLocator, LibraryLocator, LibraryLocatorV2
1920

2021
import cms.djangoapps.contentstore.git_export_utils as git_export_utils
2122
from cms.djangoapps.contentstore.git_export_utils import GitExportError
@@ -34,6 +35,9 @@ class TestGitExport(CourseTestCase):
3435
Excercise the git_export django management command with various inputs.
3536
"""
3637

38+
LIBRARY_V2_KEY = LibraryLocatorV2(org='TestOrg', slug='test-lib')
39+
LIBRARY_V1_KEY = LibraryLocator(org='TestOrg', library='test-lib')
40+
3741
def setUp(self):
3842
"""
3943
Create/reinitialize bare repo and folders needed
@@ -182,3 +186,52 @@ def test_no_change(self):
182186
with self.assertRaisesRegex(GitExportError, str(GitExportError.CANNOT_COMMIT)): # noqa: PT027
183187
git_export_utils.export_to_git(
184188
self.course.id, f'file://{self.bare_repo_dir}')
189+
190+
@patch('cms.djangoapps.contentstore.git_export_utils.cmd_log', return_value=b'main')
191+
@patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir')
192+
def test_library_v2_export_selects_correct_function(self, mock_extract, mock_cmd_log):
193+
"""
194+
When ``export_to_git`` is given a LibraryLocatorV2 key it must call
195+
``extract_library_v2_zip_to_dir`` and must not call the v1 XML export
196+
functions (``export_library_to_xml`` or ``export_course_to_xml``).
197+
cmd_log is mocked so no real git subprocess or repo state is needed.
198+
"""
199+
mock_extract.return_value = None
200+
repo_url = f'file://{self.bare_repo_dir}'
201+
202+
with patch('cms.djangoapps.contentstore.git_export_utils.export_course_to_xml') as mock_course_xml, \
203+
patch('cms.djangoapps.contentstore.git_export_utils.export_library_to_xml') as mock_lib_xml:
204+
git_export_utils.export_to_git(self.LIBRARY_V2_KEY, repo_url, self.user.username)
205+
206+
assert mock_extract.call_args[0][0] == self.LIBRARY_V2_KEY
207+
mock_course_xml.assert_not_called()
208+
mock_lib_xml.assert_not_called()
209+
210+
@patch('cms.djangoapps.contentstore.git_export_utils.cmd_log', return_value=b'main')
211+
@patch('cms.djangoapps.contentstore.git_export_utils.export_library_to_xml')
212+
def test_library_v1_export_selects_correct_function(self, mock_lib_xml, mock_cmd_log):
213+
"""
214+
When ``export_to_git`` is given a LibraryLocator (v1) key it must call
215+
``export_library_to_xml`` and must not call ``extract_library_v2_zip_to_dir``.
216+
cmd_log is mocked so no real git subprocess or repo state is needed.
217+
"""
218+
mock_lib_xml.return_value = None
219+
repo_url = f'file://{self.bare_repo_dir}'
220+
221+
with patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir') as mock_v2, \
222+
patch('cms.djangoapps.contentstore.git_export_utils.export_course_to_xml') as mock_course_xml:
223+
git_export_utils.export_to_git(self.LIBRARY_V1_KEY, repo_url, self.user.username)
224+
225+
assert mock_lib_xml.called
226+
mock_v2.assert_not_called()
227+
mock_course_xml.assert_not_called()
228+
229+
@patch('cms.djangoapps.contentstore.git_export_utils.extract_library_v2_zip_to_dir',
230+
side_effect=OSError('disk full'))
231+
def test_library_v2_export_failure_raises_xml_export_fail(self, mock_extract):
232+
"""
233+
If ``extract_library_v2_zip_to_dir`` raises, ``export_to_git`` should
234+
wrap it in ``GitExportError.XML_EXPORT_FAIL``.
235+
"""
236+
with self.assertRaisesRegex(GitExportError, str(GitExportError.XML_EXPORT_FAIL)): # noqa: PT027
237+
git_export_utils.export_to_git(self.LIBRARY_V2_KEY, f'file://{self.bare_repo_dir}')

openedx/core/djangoapps/content_libraries/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Python API for working with content libraries
33
"""
44
from . import permissions # noqa: F401
5+
from .backup import * # noqa: F403
56
from .block_metadata import * # noqa: F403
67
from .blocks import * # noqa: F403
78
from .collections import * # noqa: F403
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Public API for content library backup (zip export) utilities.
3+
"""
4+
from __future__ import annotations
5+
6+
import os
7+
import shutil
8+
import zipfile
9+
from datetime import datetime
10+
from tempfile import mkdtemp
11+
12+
from django.conf import settings
13+
from django.contrib.auth import get_user_model
14+
from django.utils.text import slugify
15+
from opaque_keys.edx.locator import LibraryLocatorV2, log
16+
from openedx_content.api import create_zip_file as create_lib_zip_file
17+
from path import Path
18+
19+
__all__ = ["create_library_v2_zip", "extract_library_v2_zip_to_dir"]
20+
21+
22+
def create_library_v2_zip(library_key: LibraryLocatorV2, user) -> tuple:
23+
"""
24+
Create a zip backup of a v2 library and return ``(temp_dir, zip_file_path)``.
25+
26+
The caller is responsible for cleaning up ``temp_dir`` when done.
27+
28+
Args:
29+
library_key: LibraryLocatorV2 identifying the library to export.
30+
user: User object passed to the backup API.
31+
32+
Returns:
33+
A tuple of ``(temp_dir as Path, zip_file_path as str)``.
34+
"""
35+
root_dir = Path(mkdtemp())
36+
sanitized_lib_key = str(library_key).replace(":", "-")
37+
sanitized_lib_key = slugify(sanitized_lib_key, allow_unicode=True)
38+
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
39+
filename = f'{sanitized_lib_key}-{timestamp}.zip'
40+
file_path = os.path.join(root_dir, filename)
41+
origin_server = getattr(settings, 'CMS_BASE', None)
42+
create_lib_zip_file(package_ref=str(library_key), path=file_path, user=user, origin_server=origin_server)
43+
return root_dir, file_path
44+
45+
46+
def extract_library_v2_zip_to_dir(library_key, root_dir, library_dir, username=None):
47+
"""
48+
Export a v2 library to a directory by creating a zip backup and extracting it.
49+
50+
V2 libraries are stored in Learning Core and use a zip-based backup mechanism.
51+
This function creates a temporary zip backup, extracts its contents into
52+
``library_dir`` under ``root_dir``, then cleans up the temporary zip.
53+
54+
Args:
55+
library_key: LibraryLocatorV2 for the library to export
56+
root_dir: Root directory where library_dir will be created
57+
library_dir: Directory name under root_dir to extract the library into
58+
username: Username string for the backup API (optional)
59+
60+
Raises:
61+
Exception: If backup creation or extraction fails
62+
DoesNotExist: If the specified user does not exist
63+
"""
64+
# Get user object for backup API (if username provided)
65+
user_obj = None
66+
if username:
67+
# Let it raise if given user doesn't exist
68+
user_obj = get_user_model().objects.get(username=username)
69+
70+
temp_dir, zip_path = create_library_v2_zip(library_key, user_obj)
71+
72+
try:
73+
target_dir = os.path.join(root_dir, library_dir)
74+
os.makedirs(target_dir, exist_ok=True)
75+
# Extract zip contents (will overwrite existing files)
76+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
77+
zip_ref.extractall(target_dir)
78+
log.info('Extracted library v2 backup to %s', target_dir)
79+
finally:
80+
if temp_dir.exists():
81+
shutil.rmtree(temp_dir)

openedx/core/djangoapps/content_libraries/api/tests/__init__.py

Whitespace-only changes.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Tests for content library backup (zip export) utilities.
3+
"""
4+
from __future__ import annotations
5+
6+
import shutil
7+
import tempfile
8+
import zipfile
9+
from unittest.mock import MagicMock, patch
10+
11+
import pytest
12+
from django.test import TestCase
13+
from opaque_keys.edx.locator import LibraryLocatorV2
14+
from path import Path
15+
16+
from openedx.core.djangoapps.content_libraries.api.backup import extract_library_v2_zip_to_dir
17+
18+
LIBRARY_KEY = LibraryLocatorV2(org='TestOrg', slug='test-lib')
19+
20+
21+
class TestExtractLibraryV2ZipToDir(TestCase):
22+
"""
23+
Tests for ``extract_library_v2_zip_to_dir``.
24+
"""
25+
26+
def _make_zip_in_temp_dir(self, contents=None):
27+
"""
28+
Helper: create a real temp dir + zip file and return (temp_dir_path, zip_path).
29+
``contents`` is a dict of {filename: bytes} to write into the zip.
30+
"""
31+
temp_dir = Path(tempfile.mkdtemp())
32+
zip_path = str(temp_dir / 'library.zip')
33+
with zipfile.ZipFile(zip_path, 'w') as zf:
34+
for name, data in (contents or {'data.xml': b'<library/>'}).items():
35+
zf.writestr(name, data)
36+
return temp_dir, zip_path
37+
38+
@patch('openedx.core.djangoapps.content_libraries.api.backup.get_user_model')
39+
@patch('openedx.core.djangoapps.content_libraries.api.backup.create_library_v2_zip')
40+
def test_successful_extraction(self, mock_create_zip, mock_get_user_model):
41+
"""
42+
On a successful call the function should:
43+
- resolve the username to a user object via the user model,
44+
- pass that user object to ``create_library_v2_zip``,
45+
- create the target directory if it does not already exist,
46+
- extract the zip contents into <root_dir>/<library_dir>,
47+
- clean up the temporary zip directory.
48+
"""
49+
root_dir = Path(tempfile.mkdtemp())
50+
temp_zip_dir, zip_path = self._make_zip_in_temp_dir({'content.xml': b'<lib/>'})
51+
mock_create_zip.return_value = (temp_zip_dir, zip_path)
52+
mock_user = MagicMock()
53+
mock_get_user_model.return_value.objects.get.return_value = mock_user
54+
55+
try:
56+
target = root_dir / 'my-library'
57+
assert not target.exists(), "Target dir should not exist before the call"
58+
59+
extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', username='testuser')
60+
61+
mock_get_user_model.return_value.objects.get.assert_called_once_with(username='testuser')
62+
mock_create_zip.assert_called_once_with(LIBRARY_KEY, mock_user)
63+
assert target.isdir(), "Target dir should have been created"
64+
assert (target / 'content.xml').exists(), "Zip content should be extracted"
65+
assert not temp_zip_dir.exists(), "Temp zip dir should have been removed"
66+
finally:
67+
shutil.rmtree(root_dir, ignore_errors=True)
68+
shutil.rmtree(temp_zip_dir, ignore_errors=True)
69+
70+
@patch('openedx.core.djangoapps.content_libraries.api.backup.get_user_model')
71+
@patch('openedx.core.djangoapps.content_libraries.api.backup.create_library_v2_zip')
72+
def test_temp_dir_cleaned_up_even_on_extraction_error(self, mock_create_zip, mock_get_user_model):
73+
"""
74+
The temporary directory must be cleaned up even when extraction raises.
75+
"""
76+
root_dir = Path(tempfile.mkdtemp())
77+
temp_zip_dir, zip_path = self._make_zip_in_temp_dir()
78+
mock_create_zip.return_value = (temp_zip_dir, zip_path)
79+
mock_get_user_model.return_value.objects.get.return_value = None
80+
81+
try:
82+
with patch('zipfile.ZipFile.extractall', side_effect=OSError('disk full')):
83+
with pytest.raises(OSError, match='disk full'):
84+
extract_library_v2_zip_to_dir(LIBRARY_KEY, str(root_dir), 'my-library', username=None)
85+
assert not temp_zip_dir.exists(), "Temp dir should be cleaned up on error"
86+
finally:
87+
shutil.rmtree(root_dir, ignore_errors=True)
88+
shutil.rmtree(temp_zip_dir, ignore_errors=True)

0 commit comments

Comments
 (0)