Skip to content

Commit b01d6d4

Browse files
committed
refactor: change api and view structure
1 parent a8a6e7e commit b01d6d4

File tree

6 files changed

+377
-166
lines changed

6 files changed

+377
-166
lines changed

openedx/core/djangoapps/content_tagging/api.py

Lines changed: 95 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,23 @@
22
Content Tagging APIs
33
"""
44
from __future__ import annotations
5-
from typing import TYPE_CHECKING
65

7-
import csv
86
from itertools import groupby
9-
from io import StringIO
107

118
import openedx_tagging.core.tagging.api as oel_tagging
129
from django.db.models import Q, QuerySet, Exists, OuterRef
1310
from opaque_keys.edx.keys import CourseKey, UsageKey
14-
from openedx_tagging.core.tagging.models import ObjectTag
15-
11+
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
12+
from organizations.models import Organization
1613
from xmodule.modulestore.django import modulestore
1714

1815
from .models import ContentObjectTag, TaxonomyOrg
19-
20-
if TYPE_CHECKING:
21-
from openedx_tagging.core.tagging.models import Taxonomy
22-
from xblock.runtime import Runtime
23-
from organizations.models import Organization
24-
from .types import ContentKey
16+
from .types import (
17+
ContentKey,
18+
ObjectTagByObjectIdDict,
19+
TaggedContent,
20+
TaxonomyDict,
21+
)
2522

2623

2724
def create_taxonomy(
@@ -140,26 +137,30 @@ def get_unassigned_taxonomies(enabled=True) -> QuerySet:
140137
def get_content_tags(
141138
object_key: ContentKey,
142139
taxonomy_id: int | None = None,
143-
) -> QuerySet:
140+
) -> QuerySet[ContentObjectTag]:
144141
"""
145142
Generates a list of content tags for a given object.
146143
147144
Pass taxonomy to limit the returned object_tags to a specific taxonomy.
148145
"""
149-
return oel_tagging.get_object_tags(
146+
147+
tags = oel_tagging.get_object_tags(
150148
object_id=str(object_key),
151149
taxonomy_id=taxonomy_id,
152150
object_tag_class=ContentObjectTag,
153151
)
154152

153+
# Add a generic type to get_object_tags to fix this
154+
return tags # type: ignore
155+
155156

156157
# FixMe: The following method (tag_content_object) is only used in tasks.py for auto-tagging. To tag object we are
157158
# using oel_tagging.tag_object and checking permissions via rule overrides.
158159
def tag_content_object(
159160
object_key: ContentKey,
160161
taxonomy: Taxonomy,
161162
tags: list,
162-
) -> QuerySet:
163+
) -> QuerySet[ContentObjectTag]:
163164
"""
164165
This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or
165166
course).
@@ -189,144 +190,112 @@ def tag_content_object(
189190
return get_content_tags(object_key, taxonomy_id=taxonomy.id)
190191

191192

192-
def export_content_object_children_tags(
193-
course_key_str: str,
194-
) -> str:
193+
def get_content_tags_for_object(
194+
content_key: ContentKey,
195+
include_children: bool,
196+
) -> tuple[TaggedContent, TaxonomyDict]:
195197
"""
196-
Generates a CSV file with the tags for all the children of a course.
198+
Returns the object with the tags associated with it. If include_children is True, then it will also include
199+
the children of the object and their tags.
197200
"""
198-
def _get_course_children_tags(course_key: CourseKey) -> tuple[dict[str, dict[int, list[str]]], dict[int, str]]:
201+
202+
def _get_object_tags(content_key: ContentKey, include_children: bool) -> QuerySet[ObjectTag]:
199203
"""
200-
Returns a tuple with a dictionary of object tags for all blocks of a course,
201-
grouping by the block id and taxonomy id; and a dictionary of taxonomy ids and names.
202-
203-
I.e.
204-
// result
205-
{
206-
// Block with id block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter
207-
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter": {
208-
// ObjectTags from Taxonomy with id 1
209-
"1": (
210-
"Tag1",
211-
"Tag2",
212-
...
213-
),
214-
// ObjectTags from Taxonomy with id 2
215-
"2": (
216-
"Tag3",
217-
...
218-
),
219-
...
220-
},
221-
// Block with id block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential
222-
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential": {
223-
// ObjectTags from Taxonomy with id 1
224-
"1": (
225-
"Tag2",
226-
...
227-
),
228-
...
229-
},
230-
}
231-
232-
// taxonomies
233-
{
234-
"1": "Taxonomy A",
235-
"2": "Taxonomy B",
236-
...
237-
}
204+
Return the tags for the object and its children using a single db query.
238205
"""
239-
block_id_prefix = str(course_key).replace("course-v1:", "block-v1:", 1)
240-
block_tags_records = ObjectTag.objects.filter(object_id__startswith=block_id_prefix) \
206+
content_key_str = str(content_key)
207+
if not include_children:
208+
return ObjectTag.objects.filter(object_id=content_key_str).select_related("tag__taxonomy").all()
209+
210+
# We use a block_id_prefix (i.e. the modified course id) to get the tags for the children of the Content
211+
# (course) in a single db query.
212+
# ToDo: Add support for other content types (like LibraryContent and LibraryBlock)
213+
if isinstance(content_key, UsageKey):
214+
course_key_str = str(content_key.course_key)
215+
block_id_prefix = course_key_str.replace("course-v1:", "block-v1:", 1)
216+
elif isinstance(content_key, CourseKey):
217+
course_key_str = str(content_key)
218+
block_id_prefix = str(content_key).replace("course-v1:", "block-v1:", 1)
219+
else:
220+
raise NotImplementedError(f"Invalid content_key: {type(content_key)} -> {content_key}")
221+
222+
return ObjectTag.objects.filter(Q(object_id__startswith=block_id_prefix) | Q(object_id=course_key_str)) \
241223
.select_related("tag__taxonomy").all()
242224

243-
result: dict[str, dict[int, list[str]]] = {}
244-
taxonomies: dict[int, str] = {}
225+
def _group_object_tags_by_objectid_taxonomy(
226+
all_object_tags: list[ObjectTag]
227+
) -> tuple[ObjectTagByObjectIdDict, TaxonomyDict]:
228+
"""
229+
Returns a tuple with a dictionary of grouped object tags for all blocks and a dictionary of taxonomies.
230+
"""
245231

246-
for object_id, block_tags in groupby(block_tags_records, lambda x: x.object_id):
247-
result[object_id] = {}
232+
groupedObjectTags: ObjectTagByObjectIdDict = {}
233+
taxonomies: TaxonomyDict = {}
234+
235+
for object_id, block_tags in groupby(all_object_tags, lambda x: x.object_id):
236+
groupedObjectTags[object_id] = {}
248237
for taxonomy_id, taxonomy_tags in groupby(block_tags, lambda x: x.tag.taxonomy_id):
249-
object_tag_list = list(taxonomy_tags)
250-
result[object_id][taxonomy_id] = [
251-
objecttag.value
252-
for objecttag in object_tag_list
253-
]
238+
object_tags_list = list(taxonomy_tags)
239+
groupedObjectTags[object_id][taxonomy_id] = object_tags_list
254240

255241
if taxonomy_id not in taxonomies:
256242
# ToDo: Change name -> export_id after done:
257243
# - https://github.com/openedx/modular-learning/issues/183
258-
taxonomies[taxonomy_id] = object_tag_list[0].tag.taxonomy.name
244+
taxonomies[taxonomy_id] = object_tags_list[0].tag.taxonomy
259245

260-
return result, taxonomies
246+
return groupedObjectTags, taxonomies
261247

262-
def _generate_csv(
263-
header: dict[str, str],
264-
blocks: list[tuple[int, UsageKey]],
265-
tags: dict[str, dict[int, list[str]]],
266-
taxonomies: dict[int, str],
267-
runtime: Runtime,
268-
) -> str:
248+
def _get_object_with_tags(
249+
content_key: ContentKey,
250+
include_children: bool,
251+
objectTagCache: ObjectTagByObjectIdDict,
252+
store
253+
) -> TaggedContent:
269254
"""
270-
Receives the blocks, tags and taxonomies and returns a CSV string
255+
Returns the object with the tags associated with it. If include_children is True, then it will also include
256+
the children of the object and their tags.
271257
"""
258+
if isinstance(content_key, CourseKey):
259+
xblock = store.get_course(content_key)
260+
elif isinstance(content_key, UsageKey):
261+
xblock = store.get_item(content_key)
262+
else:
263+
raise NotImplementedError(f"Invalid content_key: {type(content_key)} -> {content_key}")
264+
265+
tagged_xblock = TaggedContent(
266+
xblock=xblock,
267+
object_tags=objectTagCache.get(str(content_key), {}),
268+
children=None,
269+
)
272270

273-
with StringIO() as csv_buffer:
274-
csv_writer = csv.DictWriter(csv_buffer, fieldnames=header.keys())
275-
csv_writer.writerow(header)
276-
277-
# Iterate over the blocks stack and write the block rows
278-
while blocks:
279-
level, block_id = blocks.pop()
280-
# ToDo: fix block typing
281-
block = runtime.get_block(block_id)
282-
283-
block_data = {
284-
"name": level * " " + block.display_name_with_default,
285-
"type": block.category,
286-
"id": block_id
287-
}
271+
if not include_children:
272+
return tagged_xblock
288273

289-
block_id_str = str(block_id)
274+
blocks = [tagged_xblock]
290275

291-
# Add the tags for each taxonomy
292-
for taxonomy_id in taxonomies:
293-
if block_id_str in tags and taxonomy_id in tags[block_id_str]:
294-
block_data[f"taxonomy_{taxonomy_id}"] = ", ".join(tags[block_id_str][taxonomy_id])
276+
while blocks:
277+
block = blocks.pop()
278+
block.children = []
295279

296-
csv_writer.writerow(block_data)
280+
if block.xblock.has_children:
281+
for child_id in block.xblock.children:
282+
child = TaggedContent(
283+
xblock=store.get_item(child_id),
284+
object_tags=objectTagCache.get(str(child_id), {}),
285+
children=None,
286+
)
287+
block.children.append(child)
297288

298-
# Add children to the stack
299-
if block.has_children:
300-
for child_id in block.children:
301-
blocks.append((level + 1, child_id))
289+
blocks.append(child)
302290

303-
return csv_buffer.getvalue()
291+
return tagged_xblock
304292

305293
store = modulestore()
306-
course_key = CourseKey.from_string(course_key_str)
307-
if not course_key.is_course:
308-
raise ValueError(f"Invalid course key {course_key_str}")
309-
310-
# ToDo: fix course typing
311-
course = store.get_course(course_key)
312-
if course is None:
313-
raise ValueError(f"Course {course_key} not found")
314-
315-
tags, taxonomies = _get_course_children_tags(course_key)
316-
317-
blocks = []
318-
# Add children to the stack
319-
if course.has_children:
320-
for child_id in course.children:
321-
blocks.append((0, child_id))
322-
323-
header = {"name": "Name", "type": "Type", "id": "ID"}
324294

325-
# Prepare the header for the taxonomies
326-
for taxonomy_id, name in taxonomies.items():
327-
header[f"taxonomy_{taxonomy_id}"] = name
295+
all_blocks_tag_records = list(_get_object_tags(content_key, include_children))
296+
objectTagCache, taxonomies = _group_object_tags_by_objectid_taxonomy(all_blocks_tag_records)
328297

329-
return _generate_csv(header, blocks, tags, taxonomies, course.runtime)
298+
return _get_object_with_tags(content_key, include_children, objectTagCache, store), taxonomies
330299

331300

332301
# Expose the oel_tagging APIs

openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,10 @@ def get_all_orgs(self, obj) -> bool:
9898
class Meta:
9999
model = TaxonomySerializer.Meta.model
100100
fields = TaxonomySerializer.Meta.fields + ["orgs", "all_orgs"]
101-
read_only_fields = ["orgs", "all_orgs"]
101+
102+
103+
class ExportContentTagsQueryParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method
104+
"""
105+
Serializer for the query params for the export objecttags GET view
106+
"""
107+
include_children = serializers.BooleanField(required=False, default=True)

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
from __future__ import annotations
66

7-
from urllib.parse import parse_qs, urlparse
87
import json
8+
from io import BytesIO
9+
from urllib.parse import parse_qs, urlparse
910
from unittest.mock import MagicMock
1011

1112
import abc
@@ -1663,8 +1664,36 @@ def test_export_course(self, user_attr) -> None:
16631664
response = self.client.get(url)
16641665
assert response.status_code == status.HTTP_200_OK
16651666
assert response.headers['Content-Type'] == 'text/csv'
1666-
assert int(response.headers['Content-Length']) > 0
1667-
assert response.content == self.expected_csv.encode("utf-8")
1667+
1668+
expected_csv = (
1669+
"Name,Type,ID,Taxonomy 1,Taxonomy 2\r\n"
1670+
'Test Course,course,course-v1:orgA+test_course+test_run,Tag 1.1,\r\n'
1671+
' test sequential,sequential,block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential,'
1672+
'"Tag 1.1, Tag 1.2",Tag 2.1\r\n'
1673+
' test vertical1,vertical,block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1,'
1674+
',Tag 2.2\r\n'
1675+
' test vertical2,vertical,block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical2,,\r\n'
1676+
' Text,html,block-v1:orgA+test_course+test_run+type@html+block@test_html,,Tag 2.1\r\n'
1677+
)
1678+
1679+
zip_content = BytesIO(b"".join(response.streaming_content)).getvalue() # type: ignore[attr-defined]
1680+
assert zip_content == expected_csv.encode()
1681+
1682+
def test_export_course_no_children(self) -> None:
1683+
url = OBJECT_TAGS_EXPORT_URL.format(object_id=str(self.course.id))
1684+
1685+
self.client.force_authenticate(user=self.staff)
1686+
response = self.client.get(url, {"include_children": False})
1687+
assert response.status_code == status.HTTP_200_OK
1688+
assert response.headers['Content-Type'] == 'text/csv'
1689+
1690+
expected_csv = (
1691+
"Name,Type,ID,Taxonomy 1\r\n"
1692+
'Test Course,course,course-v1:orgA+test_course+test_run,Tag 1.1\r\n'
1693+
)
1694+
1695+
zip_content = BytesIO(b"".join(response.streaming_content)).getvalue() # type: ignore[attr-defined]
1696+
assert zip_content == expected_csv.encode()
16681697

16691698
def test_export_course_anoymous_forbidden(self) -> None:
16701699
url = OBJECT_TAGS_EXPORT_URL.format(object_id=str(self.course.id))

0 commit comments

Comments
 (0)