|
2 | 2 | Content Tagging APIs |
3 | 3 | """ |
4 | 4 | from __future__ import annotations |
5 | | -from typing import TYPE_CHECKING |
6 | 5 |
|
7 | | -import csv |
8 | 6 | from itertools import groupby |
9 | | -from io import StringIO |
10 | 7 |
|
11 | 8 | import openedx_tagging.core.tagging.api as oel_tagging |
12 | 9 | from django.db.models import Q, QuerySet, Exists, OuterRef |
13 | 10 | 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 |
16 | 13 | from xmodule.modulestore.django import modulestore |
17 | 14 |
|
18 | 15 | 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 | +) |
25 | 22 |
|
26 | 23 |
|
27 | 24 | def create_taxonomy( |
@@ -140,26 +137,30 @@ def get_unassigned_taxonomies(enabled=True) -> QuerySet: |
140 | 137 | def get_content_tags( |
141 | 138 | object_key: ContentKey, |
142 | 139 | taxonomy_id: int | None = None, |
143 | | -) -> QuerySet: |
| 140 | +) -> QuerySet[ContentObjectTag]: |
144 | 141 | """ |
145 | 142 | Generates a list of content tags for a given object. |
146 | 143 |
|
147 | 144 | Pass taxonomy to limit the returned object_tags to a specific taxonomy. |
148 | 145 | """ |
149 | | - return oel_tagging.get_object_tags( |
| 146 | + |
| 147 | + tags = oel_tagging.get_object_tags( |
150 | 148 | object_id=str(object_key), |
151 | 149 | taxonomy_id=taxonomy_id, |
152 | 150 | object_tag_class=ContentObjectTag, |
153 | 151 | ) |
154 | 152 |
|
| 153 | + # Add a generic type to get_object_tags to fix this |
| 154 | + return tags # type: ignore |
| 155 | + |
155 | 156 |
|
156 | 157 | # FixMe: The following method (tag_content_object) is only used in tasks.py for auto-tagging. To tag object we are |
157 | 158 | # using oel_tagging.tag_object and checking permissions via rule overrides. |
158 | 159 | def tag_content_object( |
159 | 160 | object_key: ContentKey, |
160 | 161 | taxonomy: Taxonomy, |
161 | 162 | tags: list, |
162 | | -) -> QuerySet: |
| 163 | +) -> QuerySet[ContentObjectTag]: |
163 | 164 | """ |
164 | 165 | This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or |
165 | 166 | course). |
@@ -189,144 +190,112 @@ def tag_content_object( |
189 | 190 | return get_content_tags(object_key, taxonomy_id=taxonomy.id) |
190 | 191 |
|
191 | 192 |
|
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]: |
195 | 197 | """ |
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. |
197 | 200 | """ |
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]: |
199 | 203 | """ |
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. |
238 | 205 | """ |
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)) \ |
241 | 223 | .select_related("tag__taxonomy").all() |
242 | 224 |
|
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 | + """ |
245 | 231 |
|
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] = {} |
248 | 237 | 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 |
254 | 240 |
|
255 | 241 | if taxonomy_id not in taxonomies: |
256 | 242 | # ToDo: Change name -> export_id after done: |
257 | 243 | # - 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 |
259 | 245 |
|
260 | | - return result, taxonomies |
| 246 | + return groupedObjectTags, taxonomies |
261 | 247 |
|
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: |
269 | 254 | """ |
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. |
271 | 257 | """ |
| 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 | + ) |
272 | 270 |
|
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 |
288 | 273 |
|
289 | | - block_id_str = str(block_id) |
| 274 | + blocks = [tagged_xblock] |
290 | 275 |
|
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 = [] |
295 | 279 |
|
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) |
297 | 288 |
|
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) |
302 | 290 |
|
303 | | - return csv_buffer.getvalue() |
| 291 | + return tagged_xblock |
304 | 292 |
|
305 | 293 | 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"} |
324 | 294 |
|
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) |
328 | 297 |
|
329 | | - return _generate_csv(header, blocks, tags, taxonomies, course.runtime) |
| 298 | + return _get_object_with_tags(content_key, include_children, objectTagCache, store), taxonomies |
330 | 299 |
|
331 | 300 |
|
332 | 301 | # Expose the oel_tagging APIs |
|
0 commit comments