Skip to content

Commit 4d1d82d

Browse files
authored
feat: export all course tags as csv (#34091)
1 parent 95b3e88 commit 4d1d82d

File tree

8 files changed

+630
-26
lines changed

8 files changed

+630
-26
lines changed

openedx/core/djangoapps/content_tagging/api.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
"""
44
from __future__ import annotations
55

6+
from itertools import groupby
7+
68
import openedx_tagging.core.tagging.api as oel_tagging
7-
from django.db.models import QuerySet, Exists, OuterRef
8-
from openedx_tagging.core.tagging.models import Taxonomy
9+
from django.db.models import Exists, OuterRef, Q, QuerySet
10+
from opaque_keys.edx.keys import CourseKey, LearningContextKey
11+
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
912
from organizations.models import Organization
1013

1114
from .models import TaxonomyOrg
15+
from .types import ObjectTagByObjectIdDict, TaxonomyDict
1216

1317

1418
def create_taxonomy(
@@ -126,6 +130,43 @@ def get_unassigned_taxonomies(enabled=True) -> QuerySet:
126130
)
127131

128132

133+
def get_all_object_tags(
134+
content_key: LearningContextKey,
135+
) -> tuple[ObjectTagByObjectIdDict, TaxonomyDict]:
136+
"""
137+
Returns a tuple with a dictionary of grouped object tags for all blocks and a dictionary of taxonomies.
138+
"""
139+
# ToDo: Add support for other content types (like LibraryContent and LibraryBlock)
140+
if isinstance(content_key, CourseKey):
141+
course_key_str = str(content_key)
142+
# We use a block_id_prefix (i.e. the modified course id) to get the tags for the children of the Content
143+
# (course) in a single db query.
144+
block_id_prefix = course_key_str.replace("course-v1:", "block-v1:", 1)
145+
else:
146+
raise NotImplementedError(f"Invalid content_key: {type(content_key)} -> {content_key}")
147+
148+
# There is no API method in oel_tagging.api that does this yet,
149+
# so for now we have to build the ORM query directly.
150+
all_object_tags = list(ObjectTag.objects.filter(
151+
Q(object_id__startswith=block_id_prefix) | Q(object_id=course_key_str),
152+
Q(tag__isnull=False, tag__taxonomy__isnull=False),
153+
).select_related("tag__taxonomy"))
154+
155+
grouped_object_tags: ObjectTagByObjectIdDict = {}
156+
taxonomies: TaxonomyDict = {}
157+
158+
for object_id, block_tags in groupby(all_object_tags, lambda x: x.object_id):
159+
grouped_object_tags[object_id] = {}
160+
for taxonomy_id, taxonomy_tags in groupby(block_tags, lambda x: x.tag.taxonomy_id):
161+
object_tags_list = list(taxonomy_tags)
162+
grouped_object_tags[object_id][taxonomy_id] = object_tags_list
163+
164+
if taxonomy_id not in taxonomies:
165+
taxonomies[taxonomy_id] = object_tags_list[0].tag.taxonomy
166+
167+
return grouped_object_tags, taxonomies
168+
169+
129170
# Expose the oel_tagging APIs
130171

131172
get_taxonomy = oel_tagging.get_taxonomy
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
This module contains helper functions to build a object tree with object tags.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from typing import Iterator
8+
9+
from attrs import define
10+
from opaque_keys.edx.keys import CourseKey, LearningContextKey
11+
12+
from xmodule.modulestore.django import modulestore
13+
14+
from ...types import ObjectTagByObjectIdDict, ObjectTagByTaxonomyIdDict
15+
16+
17+
@define
18+
class TaggedContent:
19+
"""
20+
A tagged content, with its tags and children.
21+
"""
22+
display_name: str
23+
block_id: str
24+
category: str
25+
object_tags: ObjectTagByTaxonomyIdDict
26+
children: list[TaggedContent] | None
27+
28+
29+
def iterate_with_level(
30+
tagged_content: TaggedContent, level: int = 0
31+
) -> Iterator[tuple[TaggedContent, int]]:
32+
"""
33+
Iterator that yields the tagged content and the level of the block
34+
"""
35+
yield tagged_content, level
36+
if tagged_content.children:
37+
for child in tagged_content.children:
38+
yield from iterate_with_level(child, level + 1)
39+
40+
41+
def build_object_tree_with_objecttags(
42+
content_key: LearningContextKey,
43+
object_tag_cache: ObjectTagByObjectIdDict,
44+
) -> TaggedContent:
45+
"""
46+
Returns the object with the tags associated with it.
47+
"""
48+
store = modulestore()
49+
50+
if isinstance(content_key, CourseKey):
51+
course = store.get_course(content_key)
52+
if course is None:
53+
raise ValueError(f"Course not found: {content_key}")
54+
else:
55+
raise NotImplementedError(f"Invalid content_key: {type(content_key)} -> {content_key}")
56+
57+
display_name = course.display_name_with_default
58+
course_id = str(course.id)
59+
60+
tagged_course = TaggedContent(
61+
display_name=display_name,
62+
block_id=course_id,
63+
category=course.category,
64+
object_tags=object_tag_cache.get(str(content_key), {}),
65+
children=None,
66+
)
67+
68+
blocks = [(tagged_course, course)]
69+
70+
while blocks:
71+
tagged_block, xblock = blocks.pop()
72+
tagged_block.children = []
73+
74+
if xblock.has_children:
75+
for child_id in xblock.children:
76+
child_block = store.get_item(child_id)
77+
tagged_child = TaggedContent(
78+
display_name=child_block.display_name_with_default,
79+
block_id=str(child_id),
80+
category=child_block.category,
81+
object_tags=object_tag_cache.get(str(child_id), {}),
82+
children=None,
83+
)
84+
tagged_block.children.append(tagged_child)
85+
86+
blocks.append((tagged_child, child_block))
87+
88+
return tagged_course
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""
2+
Test the objecttag_export_helpers module
3+
"""
4+
from unittest.mock import patch
5+
6+
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
7+
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
8+
9+
from .... import api
10+
from ....tests.test_api import TestGetAllObjectTagsMixin
11+
from ..objecttag_export_helpers import TaggedContent, build_object_tree_with_objecttags, iterate_with_level
12+
13+
14+
class TaggedCourseMixin(TestGetAllObjectTagsMixin, ModuleStoreTestCase): # type: ignore[misc]
15+
"""
16+
Mixin with a course structure and taxonomies
17+
"""
18+
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
19+
CREATE_USER = False
20+
21+
def setUp(self):
22+
super().setUp()
23+
24+
# Patch modulestore
25+
self.patcher = patch("openedx.core.djangoapps.content_tagging.tasks.modulestore", return_value=self.store)
26+
self.addCleanup(self.patcher.stop)
27+
self.patcher.start()
28+
29+
# Create course
30+
self.course = CourseFactory.create(
31+
org=self.orgA.short_name,
32+
number="test_course",
33+
run="test_run",
34+
display_name="Test Course",
35+
)
36+
self.expected_tagged_xblock = TaggedContent(
37+
display_name="Test Course",
38+
block_id="course-v1:orgA+test_course+test_run",
39+
category="course",
40+
children=[],
41+
object_tags={
42+
self.taxonomy_1.id: list(self.course_tags),
43+
},
44+
)
45+
46+
# Create XBlocks
47+
self.sequential = BlockFactory.create(
48+
parent=self.course,
49+
category="sequential",
50+
display_name="test sequential",
51+
)
52+
# Tag blocks
53+
tagged_sequential = TaggedContent(
54+
display_name="test sequential",
55+
block_id="block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential",
56+
category="sequential",
57+
children=[],
58+
object_tags={
59+
self.taxonomy_1.id: list(self.sequential_tags1),
60+
self.taxonomy_2.id: list(self.sequential_tags2),
61+
},
62+
)
63+
64+
assert self.expected_tagged_xblock.children is not None # type guard
65+
self.expected_tagged_xblock.children.append(tagged_sequential)
66+
67+
# Untagged blocks
68+
sequential2 = BlockFactory.create(
69+
parent=self.course,
70+
category="sequential",
71+
display_name="untagged sequential",
72+
)
73+
untagged_sequential = TaggedContent(
74+
display_name="untagged sequential",
75+
block_id="block-v1:orgA+test_course+test_run+type@sequential+block@untagged_sequential",
76+
category="sequential",
77+
children=[],
78+
object_tags={},
79+
)
80+
assert self.expected_tagged_xblock.children is not None # type guard
81+
self.expected_tagged_xblock.children.append(untagged_sequential)
82+
BlockFactory.create(
83+
parent=sequential2,
84+
category="vertical",
85+
display_name="untagged vertical",
86+
)
87+
untagged_vertical = TaggedContent(
88+
display_name="untagged vertical",
89+
block_id="block-v1:orgA+test_course+test_run+type@vertical+block@untagged_vertical",
90+
category="vertical",
91+
children=[],
92+
object_tags={},
93+
)
94+
assert untagged_sequential.children is not None # type guard
95+
untagged_sequential.children.append(untagged_vertical)
96+
# /Untagged blocks
97+
98+
vertical = BlockFactory.create(
99+
parent=self.sequential,
100+
category="vertical",
101+
display_name="test vertical1",
102+
)
103+
tagged_vertical = TaggedContent(
104+
display_name="test vertical1",
105+
block_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1",
106+
category="vertical",
107+
children=[],
108+
object_tags={
109+
self.taxonomy_2.id: list(self.vertical1_tags),
110+
},
111+
)
112+
assert tagged_sequential.children is not None # type guard
113+
tagged_sequential.children.append(tagged_vertical)
114+
115+
vertical2 = BlockFactory.create(
116+
parent=self.sequential,
117+
category="vertical",
118+
display_name="test vertical2",
119+
)
120+
untagged_vertical2 = TaggedContent(
121+
display_name="test vertical2",
122+
block_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical2",
123+
category="vertical",
124+
children=[],
125+
object_tags={},
126+
)
127+
assert tagged_sequential.children is not None # type guard
128+
tagged_sequential.children.append(untagged_vertical2)
129+
130+
html = BlockFactory.create(
131+
parent=vertical2,
132+
category="html",
133+
display_name="test html",
134+
)
135+
tagged_text = TaggedContent(
136+
display_name="test html",
137+
block_id="block-v1:orgA+test_course+test_run+type@html+block@test_html",
138+
category="html",
139+
children=[],
140+
object_tags={
141+
self.taxonomy_2.id: list(self.html_tags),
142+
},
143+
)
144+
assert untagged_vertical2.children is not None # type guard
145+
untagged_vertical2.children.append(tagged_text)
146+
147+
self.all_object_tags, _ = api.get_all_object_tags(self.course.id)
148+
self.expected_tagged_content_list = [
149+
(self.expected_tagged_xblock, 0),
150+
(tagged_sequential, 1),
151+
(tagged_vertical, 2),
152+
(untagged_vertical2, 2),
153+
(tagged_text, 3),
154+
(untagged_sequential, 1),
155+
(untagged_vertical, 2),
156+
]
157+
158+
159+
class TestContentTagChildrenExport(TaggedCourseMixin): # type: ignore[misc]
160+
"""
161+
Test helper functions for exporting tagged content
162+
"""
163+
def test_build_object_tree(self) -> None:
164+
"""
165+
Test if we can export a course
166+
"""
167+
with self.assertNumQueries(3):
168+
tagged_xblock = build_object_tree_with_objecttags(self.course.id, self.all_object_tags)
169+
170+
assert tagged_xblock == self.expected_tagged_xblock
171+
172+
def test_iterate_with_level(self) -> None:
173+
"""
174+
Test if we can iterate over the tagged content in the correct order
175+
"""
176+
tagged_content_list = list(iterate_with_level(self.expected_tagged_xblock))
177+
assert tagged_content_list == self.expected_tagged_content_list

0 commit comments

Comments
 (0)