Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: export tagged course as csv #34091

Merged
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
52bc665
feat: export tagged course as csv
rpenido Jan 25, 2024
f275908
docs: add comment
rpenido Jan 25, 2024
a870dfd
fix: add select_related to ObjectTag query
rpenido Jan 26, 2024
4dd027a
fix: always use objecttag.value
rpenido Jan 26, 2024
5fb03aa
docs: change comment position
rpenido Jan 26, 2024
8f1238e
refactor: rename serializer to a more sane name
rpenido Jan 26, 2024
bdedf93
refactor: remove download param
rpenido Jan 26, 2024
04ca072
refactor: create a new view to export objecttags
rpenido Jan 26, 2024
a8a6e7e
Merge branch 'master' into rpenido/fal-3610-download-course-tag-sprea…
rpenido Jan 26, 2024
b01d6d4
refactor: change api and view structure
rpenido Jan 29, 2024
0871537
Merge branch 'master' into rpenido/fal-3610-download-course-tag-sprea…
rpenido Jan 29, 2024
18a8425
docs: remove old comment
rpenido Jan 29, 2024
38ae353
docs: revert view docstring
rpenido Jan 30, 2024
b65a6c8
fix: remove include_children query param
rpenido Jan 30, 2024
f5ac3a6
fix: removing .all() call from queryset
rpenido Jan 30, 2024
debf254
refactor: method rename
rpenido Jan 30, 2024
9726d6d
fix: filter deleted tags
rpenido Jan 30, 2024
c8c12bb
fix: pylint
rpenido Jan 30, 2024
1f11b17
fix: quote string in csv export
rpenido Jan 30, 2024
484c042
test: add querycount
rpenido Jan 30, 2024
6b6ba34
fix: pylint
rpenido Jan 30, 2024
9096454
fix: pylint..
rpenido Jan 30, 2024
a688689
fix: pylint
rpenido Jan 30, 2024
30e06d0
test: compare results to hardcoded strings
pomegranited Jan 30, 2024
35a3d2b
test: Adds "deleted" object tags to ensure they are omitted from results
pomegranited Jan 30, 2024
1e13f54
test: adds untagged blocks with children
pomegranited Jan 30, 2024
e9335c8
revert: undo removed property
rpenido Jan 30, 2024
ab1a69e
style: fix camelCase
rpenido Jan 30, 2024
db9116d
refactor: remove xblock from TaggedContent and include_children param
rpenido Jan 30, 2024
9b3dee8
fix: remove UsageKey
rpenido Jan 30, 2024
821e216
fix: removing unused import
rpenido Jan 31, 2024
6207915
refactor: cleaning code
rpenido Jan 31, 2024
35bc860
test: refactors tests so shared data can be re-used
pomegranited Jan 31, 2024
7a28742
refactor: refactoring api, helper and view code
rpenido Jan 31, 2024
fabb729
docs: add comment about ObjectTag query
rpenido Jan 31, 2024
548d57c
test: use CourseFactory and BlockFactory
pomegranited Jan 30, 2024
a1d41fd
test: fix variable name
rpenido Feb 1, 2024
f07b841
fix: delete unwanted file
rpenido Feb 1, 2024
233135a
test: fix query count
rpenido Feb 1, 2024
ac98812
test: fix expected value with new tags
rpenido Feb 1, 2024
309ce94
fix: use variables from outer function
rpenido Feb 1, 2024
32cdf93
test: use UserFactory
rpenido Feb 1, 2024
4ed7570
style: removed unused imports
rpenido Feb 1, 2024
ee5bc3d
chore: trigger CI
rpenido Feb 1, 2024
4bc8ca7
Merge branch 'master' into rpenido/fal-3610-download-course-tag-sprea…
rpenido Feb 2, 2024
6d4c23a
fix: disable default staff user from module store mixin
rpenido Feb 2, 2024
da3fdf9
style: fix case
rpenido Feb 5, 2024
726b7ef
Merge branch 'jill/rpenido/fal-3610-download-course-tag-spreadsheet' …
rpenido Feb 6, 2024
fd5a542
docs: adds removed docstring
rpenido Feb 6, 2024
79a1786
Merge branch 'master' into rpenido/fal-3610-download-course-tag-sprea…
rpenido Feb 8, 2024
dfe43be
fix: cleaning merged code
rpenido Feb 8, 2024
5245264
style: run isort
rpenido Feb 8, 2024
c82e9cb
refactor: use taxonomy.export_id in header
rpenido Feb 9, 2024
01b9b5f
refactor: change `object_id` to `context_id`
rpenido Feb 15, 2024
f87fc4c
Merge branch 'master' into rpenido/fal-3610-download-course-tag-sprea…
rpenido Feb 15, 2024
779cc98
chore: trigger CI
rpenido Feb 15, 2024
4a3d092
Merge branch 'master' into rpenido/fal-3610-download-course-tag-sprea…
rpenido Feb 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions openedx/core/djangoapps/content_tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
"""
from __future__ import annotations

from itertools import groupby

import openedx_tagging.core.tagging.api as oel_tagging
from django.db.models import QuerySet, Exists, OuterRef
from openedx_tagging.core.tagging.models import Taxonomy
from django.db.models import Exists, OuterRef, Q, QuerySet
from opaque_keys.edx.keys import CourseKey, LearningContextKey
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
from organizations.models import Organization

from .models import TaxonomyOrg
from .types import ObjectTagByObjectIdDict, TaxonomyDict


def create_taxonomy(
Expand Down Expand Up @@ -126,6 +130,43 @@ def get_unassigned_taxonomies(enabled=True) -> QuerySet:
)


def get_all_object_tags(
content_key: LearningContextKey,
) -> tuple[ObjectTagByObjectIdDict, TaxonomyDict]:
"""
Returns a tuple with a dictionary of grouped object tags for all blocks and a dictionary of taxonomies.
"""
# ToDo: Add support for other content types (like LibraryContent and LibraryBlock)
if isinstance(content_key, CourseKey):
course_key_str = str(content_key)
# We use a block_id_prefix (i.e. the modified course id) to get the tags for the children of the Content
# (course) in a single db query.
block_id_prefix = course_key_str.replace("course-v1:", "block-v1:", 1)
else:
raise NotImplementedError(f"Invalid content_key: {type(content_key)} -> {content_key}")

# There is no API method in oel_tagging.api that does this yet,
# so for now we have to build the ORM query directly.
all_object_tags = list(ObjectTag.objects.filter(
Q(object_id__startswith=block_id_prefix) | Q(object_id=course_key_str),
Q(tag__isnull=False, tag__taxonomy__isnull=False),
).select_related("tag__taxonomy"))

grouped_object_tags: ObjectTagByObjectIdDict = {}
taxonomies: TaxonomyDict = {}

for object_id, block_tags in groupby(all_object_tags, lambda x: x.object_id):
grouped_object_tags[object_id] = {}
for taxonomy_id, taxonomy_tags in groupby(block_tags, lambda x: x.tag.taxonomy_id):
object_tags_list = list(taxonomy_tags)
grouped_object_tags[object_id][taxonomy_id] = object_tags_list

if taxonomy_id not in taxonomies:
taxonomies[taxonomy_id] = object_tags_list[0].tag.taxonomy

return grouped_object_tags, taxonomies


# Expose the oel_tagging APIs

get_taxonomy = oel_tagging.get_taxonomy
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
This module contains helper functions to build a object tree with object tags.
"""

from __future__ import annotations

from typing import Iterator

from attrs import define
from opaque_keys.edx.keys import CourseKey, LearningContextKey

from xmodule.modulestore.django import modulestore

from ...types import ObjectTagByObjectIdDict, ObjectTagByTaxonomyIdDict


@define
class TaggedContent:
"""
A tagged content, with its tags and children.
"""
display_name: str
block_id: str
category: str
object_tags: ObjectTagByTaxonomyIdDict
children: list[TaggedContent] | None


def iterate_with_level(
tagged_content: TaggedContent, level: int = 0
) -> Iterator[tuple[TaggedContent, int]]:
"""
Iterator that yields the tagged content and the level of the block
"""
yield tagged_content, level
if tagged_content.children:
for child in tagged_content.children:
yield from iterate_with_level(child, level + 1)


def build_object_tree_with_objecttags(
content_key: LearningContextKey,
object_tag_cache: ObjectTagByObjectIdDict,
) -> TaggedContent:
"""
Returns the object with the tags associated with it.
"""
store = modulestore()

if isinstance(content_key, CourseKey):
course = store.get_course(content_key)
if course is None:
raise ValueError(f"Course not found: {content_key}")
else:
raise NotImplementedError(f"Invalid content_key: {type(content_key)} -> {content_key}")

display_name = course.display_name_with_default
course_id = str(course.id)

tagged_course = TaggedContent(
display_name=display_name,
block_id=course_id,
category=course.category,
object_tags=object_tag_cache.get(str(content_key), {}),
children=None,
)

blocks = [(tagged_course, course)]

while blocks:
tagged_block, xblock = blocks.pop()
tagged_block.children = []

if xblock.has_children:
for child_id in xblock.children:
child_block = store.get_item(child_id)
tagged_child = TaggedContent(
display_name=child_block.display_name_with_default,
block_id=str(child_id),
category=child_block.category,
object_tags=object_tag_cache.get(str(child_id), {}),
children=None,
)
tagged_block.children.append(tagged_child)

blocks.append((tagged_child, child_block))

return tagged_course
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Test the objecttag_export_helpers module
"""
from unittest.mock import patch

from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory

from .... import api
from ....tests.test_api import TestGetAllObjectTagsMixin
from ..objecttag_export_helpers import TaggedContent, build_object_tree_with_objecttags, iterate_with_level


class TaggedCourseMixin(TestGetAllObjectTagsMixin, ModuleStoreTestCase): # type: ignore[misc]
"""
Mixin with a course structure and taxonomies
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
CREATE_USER = False

def setUp(self):
pomegranited marked this conversation as resolved.
Show resolved Hide resolved
super().setUp()

# Patch modulestore
self.patcher = patch("openedx.core.djangoapps.content_tagging.tasks.modulestore", return_value=self.store)
self.addCleanup(self.patcher.stop)
self.patcher.start()

# Create course
self.course = CourseFactory.create(
org=self.orgA.short_name,
number="test_course",
run="test_run",
display_name="Test Course",
)
self.expected_tagged_xblock = TaggedContent(
display_name="Test Course",
block_id="course-v1:orgA+test_course+test_run",
category="course",
children=[],
object_tags={
self.taxonomy_1.id: list(self.course_tags),
},
)

# Create XBlocks
self.sequential = BlockFactory.create(
parent=self.course,
category="sequential",
display_name="test sequential",
)
# Tag blocks
tagged_sequential = TaggedContent(
display_name="test sequential",
block_id="block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential",
category="sequential",
children=[],
object_tags={
self.taxonomy_1.id: list(self.sequential_tags1),
self.taxonomy_2.id: list(self.sequential_tags2),
},
)

assert self.expected_tagged_xblock.children is not None # type guard
self.expected_tagged_xblock.children.append(tagged_sequential)

# Untagged blocks
sequential2 = BlockFactory.create(
parent=self.course,
category="sequential",
display_name="untagged sequential",
)
untagged_sequential = TaggedContent(
display_name="untagged sequential",
block_id="block-v1:orgA+test_course+test_run+type@sequential+block@untagged_sequential",
category="sequential",
children=[],
object_tags={},
)
assert self.expected_tagged_xblock.children is not None # type guard
self.expected_tagged_xblock.children.append(untagged_sequential)
BlockFactory.create(
parent=sequential2,
category="vertical",
display_name="untagged vertical",
)
untagged_vertical = TaggedContent(
display_name="untagged vertical",
block_id="block-v1:orgA+test_course+test_run+type@vertical+block@untagged_vertical",
category="vertical",
children=[],
object_tags={},
)
assert untagged_sequential.children is not None # type guard
untagged_sequential.children.append(untagged_vertical)
# /Untagged blocks

vertical = BlockFactory.create(
parent=self.sequential,
category="vertical",
display_name="test vertical1",
)
tagged_vertical = TaggedContent(
display_name="test vertical1",
block_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1",
category="vertical",
children=[],
object_tags={
self.taxonomy_2.id: list(self.vertical1_tags),
},
)
assert tagged_sequential.children is not None # type guard
tagged_sequential.children.append(tagged_vertical)

vertical2 = BlockFactory.create(
parent=self.sequential,
category="vertical",
display_name="test vertical2",
)
untagged_vertical2 = TaggedContent(
display_name="test vertical2",
block_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical2",
category="vertical",
children=[],
object_tags={},
)
assert tagged_sequential.children is not None # type guard
tagged_sequential.children.append(untagged_vertical2)

html = BlockFactory.create(
parent=vertical2,
category="html",
display_name="test html",
)
tagged_text = TaggedContent(
display_name="test html",
block_id="block-v1:orgA+test_course+test_run+type@html+block@test_html",
category="html",
children=[],
object_tags={
self.taxonomy_2.id: list(self.html_tags),
},
)
assert untagged_vertical2.children is not None # type guard
untagged_vertical2.children.append(tagged_text)

self.all_object_tags, _ = api.get_all_object_tags(self.course.id)
self.expected_tagged_content_list = [
(self.expected_tagged_xblock, 0),
(tagged_sequential, 1),
(tagged_vertical, 2),
(untagged_vertical2, 2),
(tagged_text, 3),
(untagged_sequential, 1),
(untagged_vertical, 2),
]
rpenido marked this conversation as resolved.
Show resolved Hide resolved


class TestContentTagChildrenExport(TaggedCourseMixin): # type: ignore[misc]
"""
Test helper functions for exporting tagged content
"""
def test_build_object_tree(self) -> None:
"""
Test if we can export a course
"""
with self.assertNumQueries(3):
tagged_xblock = build_object_tree_with_objecttags(self.course.id, self.all_object_tags)

assert tagged_xblock == self.expected_tagged_xblock

def test_iterate_with_level(self) -> None:
"""
Test if we can iterate over the tagged content in the correct order
"""
tagged_content_list = list(iterate_with_level(self.expected_tagged_xblock))
assert tagged_content_list == self.expected_tagged_content_list
Loading
Loading