Skip to content

Commit ddb407a

Browse files
authored
feat: handle tags when importing/exporting courses (#34356)
1 parent 54eeedf commit ddb407a

File tree

18 files changed

+581
-283
lines changed

18 files changed

+581
-283
lines changed

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
1818
from xmodule.modulestore.tests.factories import CourseFactory
1919

20+
from openedx.core.djangoapps.content_tagging.tests.test_objecttag_export_helpers import TaggedCourseMixin
21+
2022

2123
class TestArgParsingCourseExportOlx(unittest.TestCase):
2224
"""
@@ -31,7 +33,7 @@ def test_no_args(self):
3133
call_command('export_olx')
3234

3335

34-
class TestCourseExportOlx(ModuleStoreTestCase):
36+
class TestCourseExportOlx(TaggedCourseMixin, ModuleStoreTestCase):
3537
"""
3638
Test exporting OLX content from a course or library.
3739
"""
@@ -61,7 +63,7 @@ def create_dummy_course(self, store_type):
6163
)
6264
return course.id
6365

64-
def check_export_file(self, tar_file, course_key):
66+
def check_export_file(self, tar_file, course_key, with_tags=False):
6567
"""Check content of export file."""
6668
names = tar_file.getnames()
6769
dirname = "{0.org}-{0.course}-{0.run}".format(course_key)
@@ -71,6 +73,10 @@ def check_export_file(self, tar_file, course_key):
7173
self.assertIn(f"{dirname}/about/overview.html", names)
7274
self.assertIn(f"{dirname}/assets/assets.xml", names)
7375
self.assertIn(f"{dirname}/policies", names)
76+
if with_tags:
77+
self.assertIn(f"{dirname}/tags.csv", names)
78+
else:
79+
self.assertNotIn(f"{dirname}/tags.csv", names)
7480

7581
def test_export_course(self):
7682
test_course_key = self.create_dummy_course(ModuleStoreEnum.Type.split)
@@ -98,3 +104,11 @@ def __init__(self, bytes_io):
98104
output = output_wrapper.bytes_io.read()
99105
with tarfile.open(fileobj=BytesIO(output), mode="r:gz") as tar_file:
100106
self.check_export_file(tar_file, test_course_key)
107+
108+
def test_export_course_with_tags(self):
109+
tmp_dir = path(mkdtemp())
110+
self.addCleanup(shutil.rmtree, tmp_dir)
111+
filename = tmp_dir / 'test.tar.gz'
112+
call_command('export_olx', '--output', filename, str(self.course.id))
113+
with tarfile.open(filename) as tar_file:
114+
self.check_export_file(tar_file, self.course.id, with_tags=True)

openedx/core/djangoapps/content/search/documents.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,10 @@ def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict:
170170
}
171171
for obj_tag in all_tags:
172172
# Add the taxonomy name:
173-
if obj_tag.name not in result[Fields.tags_taxonomy]:
174-
result[Fields.tags_taxonomy].append(obj_tag.name)
175-
# Taxonomy name plus each level of tags, in a list:
176-
parts = [obj_tag.name] + obj_tag.get_lineage() # e.g. ["Location", "North America", "Canada", "Vancouver"]
173+
if obj_tag.taxonomy.name not in result[Fields.tags_taxonomy]:
174+
result[Fields.tags_taxonomy].append(obj_tag.taxonomy.name)
175+
# Taxonomy name plus each level of tags, in a list: # e.g. ["Location", "North America", "Canada", "Vancouver"]
176+
parts = [obj_tag.taxonomy.name] + obj_tag.get_lineage()
177177
parts = [part.replace(" > ", " _ ") for part in parts] # Escape our separator.
178178
# Now we build each level (tags.level0, tags.level1, etc.) as applicable.
179179
# We have a hard-coded limit of 4 levels of tags for now (see Fields.tags above).

openedx/core/djangoapps/content_tagging/api.py

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@
22
Content Tagging APIs
33
"""
44
from __future__ import annotations
5+
import io
56

67
from itertools import groupby
8+
import csv
9+
from typing import Iterator
10+
from opaque_keys.edx.keys import UsageKey
711

812
import openedx_tagging.core.tagging.api as oel_tagging
913
from django.db.models import Exists, OuterRef, Q, QuerySet
1014
from opaque_keys.edx.keys import CourseKey
1115
from opaque_keys.edx.locator import LibraryLocatorV2
1216
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
17+
from openedx_tagging.core.tagging.models.utils import TAGS_CSV_SEPARATOR
1318
from organizations.models import Organization
19+
from .helpers.objecttag_export_helpers import build_object_tree_with_objecttags, iterate_with_level
1420

1521
from .models import TaxonomyOrg
1622
from .types import ContentKey, TagValuesByObjectIdDict, TagValuesByTaxonomyIdDict, TaxonomyDict
@@ -164,7 +170,7 @@ def get_all_object_tags(
164170
all_object_tags = ObjectTag.objects.filter(
165171
Q(tag__isnull=False, tag__taxonomy__isnull=False),
166172
object_id_clause,
167-
).select_related("tag__taxonomy")
173+
).select_related("tag__taxonomy").order_by("object_id")
168174

169175
if prefetch_orgs:
170176
all_object_tags = all_object_tags.prefetch_related("tag__taxonomy__taxonomyorg_set")
@@ -174,7 +180,8 @@ def get_all_object_tags(
174180

175181
for object_id, block_tags in groupby(all_object_tags, lambda x: x.object_id):
176182
grouped_object_tags[object_id] = {}
177-
for taxonomy_id, taxonomy_tags in groupby(block_tags, lambda x: x.tag.taxonomy_id if x.tag else 0):
183+
block_tags_sorted = sorted(block_tags, key=lambda x: x.tag.taxonomy_id if x.tag else 0) # type: ignore
184+
for taxonomy_id, taxonomy_tags in groupby(block_tags_sorted, lambda x: x.tag.taxonomy_id if x.tag else 0):
178185
object_tags_list = list(taxonomy_tags)
179186
grouped_object_tags[object_id][taxonomy_id] = [
180187
tag.value for tag in object_tags_list
@@ -185,7 +192,7 @@ def get_all_object_tags(
185192
assert object_tags_list[0].tag.taxonomy
186193
taxonomies[taxonomy_id] = object_tags_list[0].tag.taxonomy
187194

188-
return grouped_object_tags, taxonomies
195+
return grouped_object_tags, dict(sorted(taxonomies.items()))
189196

190197

191198
def set_all_object_tags(
@@ -211,6 +218,125 @@ def set_all_object_tags(
211218
)
212219

213220

221+
def generate_csv_rows(object_id, buffer) -> Iterator[str]:
222+
"""
223+
Returns a CSV string with tags and taxonomies of all blocks of `object_id`
224+
"""
225+
content_key = get_content_key_from_string(object_id)
226+
227+
if isinstance(content_key, UsageKey):
228+
raise ValueError("The object_id must be a CourseKey or a LibraryLocatorV2.")
229+
230+
all_object_tags, taxonomies = get_all_object_tags(content_key)
231+
tagged_content = build_object_tree_with_objecttags(content_key, all_object_tags)
232+
233+
header = {"name": "Name", "type": "Type", "id": "ID"}
234+
235+
# Prepare the header for the taxonomies
236+
for taxonomy_id, taxonomy in taxonomies.items():
237+
header[f"taxonomy_{taxonomy_id}"] = taxonomy.export_id
238+
239+
csv_writer = csv.DictWriter(buffer, fieldnames=header.keys(), quoting=csv.QUOTE_NONNUMERIC)
240+
yield csv_writer.writerow(header)
241+
242+
# Iterate over the blocks and yield the rows
243+
for item, level in iterate_with_level(tagged_content):
244+
block_key = get_content_key_from_string(item.block_id)
245+
246+
block_data = {
247+
"name": level * " " + item.display_name,
248+
"type": item.category,
249+
"id": getattr(block_key, 'block_id', item.block_id),
250+
}
251+
252+
# Add the tags for each taxonomy
253+
for taxonomy_id in taxonomies:
254+
if taxonomy_id in item.object_tags:
255+
block_data[f"taxonomy_{taxonomy_id}"] = f"{TAGS_CSV_SEPARATOR} ".join(
256+
list(item.object_tags[taxonomy_id])
257+
)
258+
259+
yield csv_writer.writerow(block_data)
260+
261+
262+
def export_tags_in_csv_file(object_id, file_dir, file_name) -> None:
263+
"""
264+
Writes a CSV file with tags and taxonomies of all blocks of `object_id`
265+
"""
266+
buffer = io.StringIO()
267+
for _ in generate_csv_rows(object_id, buffer):
268+
# The generate_csv_rows function is a generator,
269+
# we don't need to do anything with the result here
270+
pass
271+
272+
with file_dir.open(file_name, 'w') as csv_file:
273+
buffer.seek(0)
274+
csv_file.write(buffer.read())
275+
276+
277+
def set_exported_object_tags(
278+
content_key: ContentKey,
279+
exported_tags: TagValuesByTaxonomyIdDict,
280+
) -> None:
281+
"""
282+
Sets the tags for the given exported content object.
283+
"""
284+
content_key_str = str(content_key)
285+
286+
# Clear all tags related with the content.
287+
oel_tagging.delete_object_tags(content_key_str)
288+
289+
for taxonomy_export_id, tags_values in exported_tags.items():
290+
if not tags_values:
291+
continue
292+
293+
taxonomy = oel_tagging.get_taxonomy_by_export_id(str(taxonomy_export_id))
294+
oel_tagging.tag_object(
295+
object_id=content_key_str,
296+
taxonomy=taxonomy,
297+
tags=tags_values,
298+
create_invalid=True,
299+
taxonomy_export_id=str(taxonomy_export_id),
300+
)
301+
302+
303+
def import_course_tags_from_csv(csv_path, course_id) -> None:
304+
"""
305+
Import tags from a csv file generated on export.
306+
"""
307+
# Open csv file and extract the tags
308+
with open(csv_path, 'r') as csv_file:
309+
csv_reader = csv.DictReader(csv_file)
310+
tags_in_blocks = list(csv_reader)
311+
312+
def get_exported_tags(block) -> TagValuesByTaxonomyIdDict:
313+
"""
314+
Returns a map with taxonomy export_id and tags for this block.
315+
"""
316+
result = {}
317+
for key, value in block.items():
318+
if key in ['Type', 'Name', 'ID'] or not value:
319+
continue
320+
result[key] = value.split(TAGS_CSV_SEPARATOR)
321+
return result
322+
323+
course_key = CourseKey.from_string(str(course_id))
324+
325+
for block in tags_in_blocks:
326+
exported_tags = get_exported_tags(block)
327+
block_type = block.get('Type', '')
328+
block_id = block.get('ID', '')
329+
330+
if not block_type or not block_id:
331+
raise ValueError(f"Invalid format of csv in: '{block}'.")
332+
333+
if block_type == 'course':
334+
set_exported_object_tags(course_key, exported_tags)
335+
else:
336+
block_key = course_key.make_usage_key(block_type, block_id)
337+
set_exported_object_tags(block_key, exported_tags)
338+
339+
214340
def copy_object_tags(
215341
source_content_key: ContentKey,
216342
dest_content_key: ContentKey,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""
2+
Functions to validate the access in content tagging actions
3+
"""
4+
5+
6+
from openedx_tagging.core.tagging import rules as oel_tagging_rules
7+
8+
9+
def has_view_object_tags_access(user, object_id):
10+
return user.has_perm(
11+
"oel_tagging.view_objecttag",
12+
# The obj arg expects a model, but we are passing an object
13+
oel_tagging_rules.ObjectTagPermissionItem(taxonomy=None, object_id=object_id), # type: ignore[arg-type]
14+
)

openedx/core/djangoapps/content_tagging/helpers/__init__.py

Whitespace-only changes.

openedx/core/djangoapps/content_tagging/rest_api/v1/objecttag_export_helpers.py renamed to openedx/core/djangoapps/content_tagging/helpers/objecttag_export_helpers.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@
1212
from xblock.core import XBlock
1313

1414
import openedx.core.djangoapps.content_libraries.api as library_api
15-
from openedx.core.djangoapps.content_libraries.api import LibraryXBlockMetadata
1615
from xmodule.modulestore.django import modulestore
1716

18-
from ...types import TagValuesByObjectIdDict, TagValuesByTaxonomyIdDict
17+
from ..types import TagValuesByObjectIdDict, TagValuesByTaxonomyIdDict
1918

2019

2120
@define
@@ -69,7 +68,7 @@ def _get_course_tagged_object_and_children(
6968

7069
def _get_library_tagged_object_and_children(
7170
library_key: LibraryLocatorV2, object_tag_cache: TagValuesByObjectIdDict
72-
) -> tuple[TaggedContent, list[LibraryXBlockMetadata]]:
71+
) -> tuple[TaggedContent, list[library_api.LibraryXBlockMetadata]]:
7372
"""
7473
Returns a TaggedContent with library metadata with its tags, and its children.
7574
"""
@@ -89,7 +88,7 @@ def _get_library_tagged_object_and_children(
8988

9089
library_components = library_api.get_library_components(library_key)
9190
children = [
92-
LibraryXBlockMetadata.from_component(library_key, component)
91+
library_api.LibraryXBlockMetadata.from_component(library_key, component)
9392
for component in library_components
9493
]
9594

@@ -117,7 +116,7 @@ def _get_xblock_tagged_object_and_children(
117116

118117

119118
def _get_library_block_tagged_object(
120-
library_block: LibraryXBlockMetadata, object_tag_cache: TagValuesByObjectIdDict
119+
library_block: library_api.LibraryXBlockMetadata, object_tag_cache: TagValuesByObjectIdDict
121120
) -> tuple[TaggedContent, None]:
122121
"""
123122
Returns a TaggedContent with library content block metadata and its tags,
@@ -144,7 +143,7 @@ def build_object_tree_with_objecttags(
144143
"""
145144
get_tagged_children: Union[
146145
# _get_course_tagged_object_and_children type
147-
Callable[[LibraryXBlockMetadata, dict[str, dict[int, list[Any]]]], tuple[TaggedContent, None]],
146+
Callable[[library_api.LibraryXBlockMetadata, dict[str, dict[int, list[Any]]]], tuple[TaggedContent, None]],
148147
# _get_library_block_tagged_object type
149148
Callable[[UsageKey, dict[str, dict[int, list[Any]]]], tuple[TaggedContent, list[Any]]]
150149
]

openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@
3737
from openedx.core.djangoapps.content_tagging.utils import rules_cache
3838
from openedx.core.djangolib.testing.utils import skip_unless_cms
3939

40-
41-
from .test_objecttag_export_helpers import TaggedCourseMixin
40+
from ....tests.test_objecttag_export_helpers import TaggedCourseMixin
4241

4342
User = get_user_model()
4443

@@ -1759,6 +1758,7 @@ def test_get_tags(self):
17591758
# Fetch this object's tags for a single taxonomy
17601759
expected_tags = [{
17611760
'name': 'Multiple Taxonomy',
1761+
'export_id': '13-multiple-taxonomy',
17621762
'taxonomy_id': taxonomy.pk,
17631763
'can_tag_object': True,
17641764
'tags': [
@@ -1854,24 +1854,8 @@ def test_export_course(self, user_attr) -> None:
18541854
assert response.status_code == status.HTTP_200_OK
18551855
assert response.headers['Content-Type'] == 'text/csv'
18561856

1857-
expected_csv = (
1858-
'"Name","Type","ID","1-taxonomy-1","2-taxonomy-2"\r\n'
1859-
'"Test Course","course","course-v1:orgA+test_course+test_run","Tag 1.1",""\r\n'
1860-
'" test sequential","sequential","block-v1:orgA+test_course+test_run+type@sequential+block@test_'
1861-
'sequential","Tag 1.1, Tag 1.2","Tag 2.1"\r\n'
1862-
'" test vertical1","vertical","block-v1:orgA+test_course+test_run+type@vertical+block@test_'
1863-
'vertical1","","Tag 2.2"\r\n'
1864-
'" test vertical2","vertical","block-v1:orgA+test_course+test_run+type@vertical+block@test_'
1865-
'vertical2","",""\r\n'
1866-
'" test html","html","block-v1:orgA+test_course+test_run+type@html+block@test_html","","Tag 2.1"\r\n'
1867-
'" untagged sequential","sequential","block-v1:orgA+test_course+test_run+type@sequential+block@untagged_'
1868-
'sequential","",""\r\n'
1869-
'" untagged vertical","vertical","block-v1:orgA+test_course+test_run+type@vertical+block@untagged_'
1870-
'vertical","",""\r\n'
1871-
)
1872-
18731857
zip_content = BytesIO(b"".join(response.streaming_content)).getvalue() # type: ignore[attr-defined]
1874-
assert zip_content == expected_csv.encode()
1858+
assert zip_content == self.expected_csv.encode()
18751859

18761860
def test_export_course_anoymous_forbidden(self) -> None:
18771861
url = OBJECT_TAGS_EXPORT_URL.format(object_id=str(self.course.id))
@@ -1888,7 +1872,7 @@ def test_export_course_invalid_id(self) -> None:
18881872
url = OBJECT_TAGS_EXPORT_URL.format(object_id="invalid")
18891873
self.client.force_authenticate(user=self.staff)
18901874
response = self.client.get(url)
1891-
assert response.status_code == status.HTTP_400_BAD_REQUEST
1875+
assert response.status_code == status.HTTP_403_FORBIDDEN
18921876

18931877

18941878
@skip_unless_cms

0 commit comments

Comments
 (0)