diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 28e08fb16c48..47ffb0bb0158 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -20,7 +20,7 @@ jobs: - module-name: openedx-1 path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/" - module-name: openedx-2 - path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/" + path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/ openedx/core/djangoapps/content_tagging/" - module-name: common path: "--django-settings-module=lms.envs.test common" - module-name: cms diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index c250b00545c9..6be046dfb3dd 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -199,6 +199,7 @@ "openedx-4": { "settings": "cms.envs.test", "paths": [ + "openedx/core/djangoapps/content_tagging/", "openedx/core/djangoapps/geoinfo/", "openedx/core/djangoapps/header_control/", "openedx/core/djangoapps/heartbeat/", diff --git a/cms/envs/common.py b/cms/envs/common.py index 2425c190c2db..c77dcb33c939 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1748,6 +1748,10 @@ # API Documentation 'drf_yasg', + # Tagging + 'openedx_tagging.core.tagging.apps.TaggingConfig', + 'openedx.core.djangoapps.content_tagging', + 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', 'openedx.features.discounts', diff --git a/cms/urls.py b/cms/urls.py index e96fdc8c875d..6bebf002346e 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -336,3 +336,8 @@ urlpatterns += [ path('api/contentstore/', include('cms.djangoapps.contentstore.rest_api.urls')) ] + +# Content tagging +urlpatterns += [ + path('api/content_tagging/', include(('openedx.core.djangoapps.content_tagging.urls', 'content_tagging'))), +] diff --git a/common/djangoapps/student/role_helpers.py b/common/djangoapps/student/role_helpers.py index 132595f5a271..d5be5d76f9c5 100644 --- a/common/djangoapps/student/role_helpers.py +++ b/common/djangoapps/student/role_helpers.py @@ -1,7 +1,9 @@ """ Helpers for student roles """ +from typing import List +from django.contrib.auth import get_user_model from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -12,15 +14,20 @@ ) from openedx.core.lib.cache_utils import request_cached from common.djangoapps.student.roles import ( + CourseAccessRole, CourseBetaTesterRole, CourseInstructorRole, CourseStaffRole, GlobalStaff, OrgInstructorRole, - OrgStaffRole + OrgStaffRole, + RoleCache, ) +User = get_user_model() + + @request_cached() def has_staff_roles(user, course_key): """ @@ -40,3 +47,32 @@ def has_staff_roles(user, course_key): is_org_instructor, is_global_staff, has_forum_role]): return True return False + + +@request_cached() +def get_role_cache(user: User) -> RoleCache: + """ + Returns a populated RoleCache for the given user. + + The returned RoleCache is also cached on the provided `user` to improve performance on future roles checks. + + :param user: User + :return: All roles for all courses that this user has access to. + """ + # pylint: disable=protected-access + if not hasattr(user, '_roles'): + user._roles = RoleCache(user) + return user._roles + + +@request_cached() +def get_course_roles(user: User) -> List[CourseAccessRole]: + """ + Returns a list of all course-level roles that this user has. + + :param user: User + :return: All roles for all courses that this user has access to. + """ + # pylint: disable=protected-access + role_cache = get_role_cache(user) + return list(role_cache._roles) diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 1fcd9887ea07..e61fafe89aa1 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -19,6 +19,9 @@ # A list of registered access roles. REGISTERED_ACCESS_ROLES = {} +# A mapping of roles to the roles that they inherit permissions from. +ACCESS_ROLES_INHERITANCE = {} + def register_access_role(cls): """ @@ -33,6 +36,10 @@ def register_access_role(cls): REGISTERED_ACCESS_ROLES[role_name] = cls except AttributeError: log.exception("Unable to register Access Role with attribute 'ROLE'.") + + if base_role := getattr(cls, "BASE_ROLE", None): + ACCESS_ROLES_INHERITANCE.setdefault(base_role, set()).add(cls.ROLE) + return cls @@ -69,12 +76,19 @@ def __init__(self, user): CourseAccessRole.objects.filter(user=user).all() ) + @staticmethod + def get_roles(role): + """ + Return the roles that should have the same permissions as the specified role. + """ + return ACCESS_ROLES_INHERITANCE.get(role, set()) | {role} + def has_role(self, role, course_id, org): """ Return whether this RoleCache contains a role with the specified role, course_id, and org """ return any( - access_role.role == role and + access_role.role in self.get_roles(role) and access_role.course_id == course_id and access_role.org == org for access_role in self._roles diff --git a/lms/envs/common.py b/lms/envs/common.py index cd67373852d4..f0e8bc9383bb 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3193,6 +3193,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # Course Goals 'lms.djangoapps.course_goals.apps.CourseGoalsConfig', + # Tagging + 'openedx_tagging.core.tagging.apps.TaggingConfig', + 'openedx.core.djangoapps.content_tagging', + # Features 'openedx.features.calendar_sync', 'openedx.features.course_bookmarks', diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 1b03ea5e5a0f..bbed7d123e90 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -86,7 +86,15 @@ from xblock.exceptions import XBlockNotFoundError from edx_rest_api_client.client import OAuthAPIClient from openedx.core.djangoapps.content_libraries import permissions -from openedx.core.djangoapps.content_libraries.constants import DRAFT_NAME, COMPLEX +# pylint: disable=unused-import +from openedx.core.djangoapps.content_libraries.constants import ( + ALL_RIGHTS_RESERVED, + CC_4_BY, + COMPLEX, + DRAFT_NAME, + PROBLEM, + VIDEO, +) from openedx.core.djangoapps.content_libraries.library_bundle import LibraryBundle from openedx.core.djangoapps.content_libraries.libraries_index import ContentLibraryIndexer, LibraryBlockIndexer from openedx.core.djangoapps.content_libraries.models import ( @@ -406,8 +414,15 @@ def get_library(library_key): def create_library( - collection_uuid, library_type, org, slug, title, description, allow_public_learning, allow_public_read, - library_license, + collection_uuid, + org, + slug, + title, + description="", + allow_public_learning=False, + allow_public_read=False, + library_license=ALL_RIGHTS_RESERVED, + library_type=COMPLEX, ): """ Create a new content library. @@ -424,6 +439,8 @@ def create_library( allow_public_read: Allow anyone to view blocks (including source) in Studio? + library_type: Deprecated parameter, not really used. Set to COMPLEX. + Returns a ContentLibraryMetadata instance. """ assert isinstance(collection_uuid, UUID) diff --git a/openedx/core/djangoapps/content_tagging/__init__.py b/openedx/core/djangoapps/content_tagging/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/admin.py b/openedx/core/djangoapps/content_tagging/admin.py new file mode 100644 index 000000000000..a9fa146ce8bd --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/admin.py @@ -0,0 +1,6 @@ +""" Tagging app admin """ +from django.contrib import admin + +from .models import TaxonomyOrg + +admin.site.register(TaxonomyOrg) diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py new file mode 100644 index 000000000000..3bf9fef0f8c3 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -0,0 +1,421 @@ +""" +Content Tagging APIs +""" +from __future__ import annotations +import io + +import datetime +from itertools import groupby +import csv +from typing import Iterator +from opaque_keys.edx.keys import UsageKey + +import openedx_tagging.core.tagging.api as oel_tagging +from django.db.models import Exists, OuterRef, Q, QuerySet +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy +from openedx_tagging.core.tagging.models.utils import TAGS_CSV_SEPARATOR +from organizations.models import Organization +from .helpers.objecttag_export_helpers import build_object_tree_with_objecttags, iterate_with_level +from openedx_events.content_authoring.data import ContentObjectData +from openedx_events.content_authoring.signals import CONTENT_OBJECT_TAGS_CHANGED + +from .models import TaxonomyOrg +from .types import ContentKey, TagValuesByObjectIdDict, TagValuesByTaxonomyIdDict, TaxonomyDict +from .utils import check_taxonomy_context_key_org, get_content_key_from_string, get_context_key_from_key + + +def create_taxonomy( + name: str, + description: str | None = None, + enabled=True, + allow_multiple=True, + allow_free_text=False, + orgs: list[Organization] | None = None, + export_id: str | None = None, +) -> Taxonomy: + """ + Creates, saves, and returns a new Taxonomy with the given attributes. + """ + taxonomy = oel_tagging.create_taxonomy( + name=name, + description=description, + enabled=enabled, + allow_multiple=allow_multiple, + allow_free_text=allow_free_text, + export_id=export_id, + ) + + if orgs is not None: + set_taxonomy_orgs(taxonomy=taxonomy, all_orgs=False, orgs=orgs) + + return taxonomy + + +def set_taxonomy_orgs( + taxonomy: Taxonomy, + all_orgs=False, + orgs: list[Organization] | None = None, + relationship: TaxonomyOrg.RelType = TaxonomyOrg.RelType.OWNER, +): + """ + Updates the list of orgs associated with the given taxonomy. + + Currently, we only have an "owner" relationship, but there may be other types added in future. + + When an org has an "owner" relationship with a taxonomy, that taxonomy is available for use by content in that org, + mies + + If `all_orgs`, then the taxonomy is associated with all organizations, and the `orgs` parameter is ignored. + + If not `all_orgs`, the taxonomy is associated with each org in the `orgs` list. If that list is empty, the + taxonomy is not associated with any orgs. + """ + if taxonomy.system_defined: + raise ValueError("Cannot set orgs for a system-defined taxonomy") + + TaxonomyOrg.objects.filter( + taxonomy=taxonomy, + rel_type=relationship, + ).delete() + + # org=None means the relationship is with "all orgs" + if all_orgs: + orgs = [None] + if orgs: + TaxonomyOrg.objects.bulk_create( + [ + TaxonomyOrg( + taxonomy=taxonomy, + org=org, + rel_type=relationship, + ) + for org in orgs + ] + ) + + +def get_taxonomies_for_org( + enabled=True, + org_short_name: str | None = None, +) -> QuerySet: + """ + Generates a list of the enabled Taxonomies available for the given org, sorted by name. + + We return a QuerySet here for ease of use with Django Rest Framework and other query-based use cases. + So be sure to use `Taxonomy.cast()` to cast these instances to the appropriate subclass before use. + + If no `org` is provided, then only Taxonomies which are available for _all_ Organizations are returned. + + If you want the disabled Taxonomies, pass enabled=False. + If you want all Taxonomies (both enabled and disabled), pass enabled=None. + """ + return oel_tagging.get_taxonomies(enabled=enabled).filter( + Exists( + TaxonomyOrg.get_relationships( + taxonomy=OuterRef("pk"), # type: ignore + rel_type=TaxonomyOrg.RelType.OWNER, + org_short_name=org_short_name, + ) + ) + ) + + +def get_unassigned_taxonomies(enabled=True) -> QuerySet: + """ + Generate a list of the enabled orphaned Taxomonies, i.e. that do not belong to any + organization. We don't use `TaxonomyOrg.get_relationships` as that returns + Taxonomies which are available for all Organizations when no `org` is provided + """ + return oel_tagging.get_taxonomies(enabled=enabled).filter( + ~( + Exists( + TaxonomyOrg.objects.filter( + taxonomy=OuterRef("pk"), + rel_type=TaxonomyOrg.RelType.OWNER, + ) + ) + ) + ) + + +def get_all_object_tags( + content_key: ContentKey, + prefetch_orgs: bool = False, +) -> tuple[TagValuesByObjectIdDict, TaxonomyDict]: + """ + Get all the object tags applied to components in the given course/library. + + Includes any tags applied to the course/library as a whole. + Returns a tuple with a dictionary of grouped object tag values for all blocks and a dictionary of taxonomies. + + If `prefetch_orgs` is set, then the returned ObjectTag taxonomies will have their TaxonomyOrgs prefetched, + which makes checking permissions faster. + """ + context_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/library) in a single db query. + if isinstance(content_key, CourseKey): + block_id_prefix = context_key_str.replace("course-v1:", "block-v1:", 1) + elif isinstance(content_key, LibraryLocatorV2): + block_id_prefix = context_key_str.replace("lib:", "lb:", 1) + else: + # No context, so we'll just match the object_id, with no prefix. + block_id_prefix = None + + # There is no API method in oel_tagging.api that does this yet, + # so for now we have to build the ORM query directly. + object_id_clause = Q(object_id=content_key) + if block_id_prefix: + object_id_clause |= Q(object_id__startswith=block_id_prefix) + + all_object_tags = ObjectTag.objects.filter( + Q(tag__isnull=False, tag__taxonomy__isnull=False), + object_id_clause, + ).select_related("tag__taxonomy").order_by("object_id") + + if prefetch_orgs: + all_object_tags = all_object_tags.prefetch_related("tag__taxonomy__taxonomyorg_set") + + grouped_object_tags: TagValuesByObjectIdDict = {} + taxonomies: TaxonomyDict = {} + + for object_id, block_tags in groupby(all_object_tags, lambda x: x.object_id): + grouped_object_tags[object_id] = {} + block_tags_sorted = sorted(block_tags, key=lambda x: x.tag.taxonomy_id if x.tag else 0) # type: ignore + for taxonomy_id, taxonomy_tags in groupby(block_tags_sorted, lambda x: x.tag.taxonomy_id if x.tag else 0): + object_tags_list = list(taxonomy_tags) + grouped_object_tags[object_id][taxonomy_id] = [ + tag.value for tag in object_tags_list + ] + + if taxonomy_id not in taxonomies: + assert object_tags_list[0].tag + assert object_tags_list[0].tag.taxonomy + taxonomies[taxonomy_id] = object_tags_list[0].tag.taxonomy + + return grouped_object_tags, dict(sorted(taxonomies.items())) + + +def set_all_object_tags( + content_key: ContentKey, + object_tags: TagValuesByTaxonomyIdDict, +) -> None: + """ + Sets the tags for the given content object. + """ + context_key = get_context_key_from_key(content_key) + + for taxonomy_id, tags_values in object_tags.items(): + + taxonomy = oel_tagging.get_taxonomy(taxonomy_id) + + if not taxonomy: + continue + + tag_object( + object_id=str(content_key), + taxonomy=taxonomy, + tags=tags_values, + ) + + +def generate_csv_rows(object_id, buffer) -> Iterator[str]: + """ + Returns a CSV string with tags and taxonomies of all blocks of `object_id` + """ + content_key = get_content_key_from_string(object_id) + + if isinstance(content_key, UsageKey): + raise ValueError("The object_id must be a CourseKey or a LibraryLocatorV2.") + + all_object_tags, taxonomies = get_all_object_tags(content_key) + tagged_content = build_object_tree_with_objecttags(content_key, all_object_tags) + + header = {"name": "Name", "type": "Type", "id": "ID"} + + # Prepare the header for the taxonomies + for taxonomy_id, taxonomy in taxonomies.items(): + header[f"taxonomy_{taxonomy_id}"] = taxonomy.export_id + + csv_writer = csv.DictWriter(buffer, fieldnames=header.keys(), quoting=csv.QUOTE_NONNUMERIC) + yield csv_writer.writerow(header) + + # Iterate over the blocks and yield the rows + for item, level in iterate_with_level(tagged_content): + block_key = get_content_key_from_string(item.block_id) + + block_data = { + "name": level * " " + item.display_name, + "type": item.category, + "id": getattr(block_key, 'block_id', item.block_id), + } + + # Add the tags for each taxonomy + for taxonomy_id in taxonomies: + if taxonomy_id in item.object_tags: + block_data[f"taxonomy_{taxonomy_id}"] = f"{TAGS_CSV_SEPARATOR} ".join( + list(item.object_tags[taxonomy_id]) + ) + + yield csv_writer.writerow(block_data) + + +def export_tags_in_csv_file(object_id, file_dir, file_name) -> None: + """ + Writes a CSV file with tags and taxonomies of all blocks of `object_id` + """ + buffer = io.StringIO() + for _ in generate_csv_rows(object_id, buffer): + # The generate_csv_rows function is a generator, + # we don't need to do anything with the result here + pass + + with file_dir.open(file_name, 'w') as csv_file: + buffer.seek(0) + csv_file.write(buffer.read()) + + +def set_exported_object_tags( + content_key: ContentKey, + exported_tags: TagValuesByTaxonomyIdDict, +) -> None: + """ + Sets the tags for the given exported content object. + """ + content_key_str = str(content_key) + + # Clear all tags related with the content. + oel_tagging.delete_object_tags(content_key_str) + + for taxonomy_export_id, tags_values in exported_tags.items(): + if not tags_values: + continue + + taxonomy = oel_tagging.get_taxonomy_by_export_id(str(taxonomy_export_id)) + oel_tagging.tag_object( + object_id=content_key_str, + taxonomy=taxonomy, + tags=tags_values, + create_invalid=True, + taxonomy_export_id=str(taxonomy_export_id), + ) + CONTENT_OBJECT_TAGS_CHANGED.send_event( + time=datetime.datetime.now(datetime.timezone.utc), + content_object=ContentObjectData(object_id=content_key_str) + ) + + +def import_course_tags_from_csv(csv_path, course_id) -> None: + """ + Import tags from a csv file generated on export. + """ + # Open csv file and extract the tags + with open(csv_path, 'r') as csv_file: + csv_reader = csv.DictReader(csv_file) + tags_in_blocks = list(csv_reader) + + def get_exported_tags(block) -> TagValuesByTaxonomyIdDict: + """ + Returns a map with taxonomy export_id and tags for this block. + """ + result = {} + for key, value in block.items(): + if key in ['Type', 'Name', 'ID'] or not value: + continue + result[key] = value.split(TAGS_CSV_SEPARATOR) + return result + + course_key = CourseKey.from_string(str(course_id)) + + for block in tags_in_blocks: + exported_tags = get_exported_tags(block) + block_type = block.get('Type', '') + block_id = block.get('ID', '') + + if not block_type or not block_id: + raise ValueError(f"Invalid format of csv in: '{block}'.") + + if block_type == 'course': + set_exported_object_tags(course_key, exported_tags) + else: + block_key = course_key.make_usage_key(block_type, block_id) + set_exported_object_tags(block_key, exported_tags) + + +def copy_object_tags( + source_content_key: ContentKey, + dest_content_key: ContentKey, +) -> None: + """ + Copies the permitted object tags on source_object_id to dest_object_id. + + If an source object tag is not available for use on the dest_object_id, it will not be copied. + """ + all_object_tags, taxonomies = get_all_object_tags( + content_key=source_content_key, + prefetch_orgs=True, + ) + source_object_tags = all_object_tags.get(str(source_content_key), {}) + + for taxonomy_id, taxonomy in taxonomies.items(): + tags = source_object_tags.get(taxonomy_id, []) + tag_object( + object_id=str(dest_content_key), + taxonomy=taxonomy, + tags=tags, + ) + + +def tag_object( + object_id: str, + taxonomy: Taxonomy, + tags: list[str], + object_tag_class: type[ObjectTag] = ObjectTag, +) -> None: + """ + Replaces the existing ObjectTag entries for the given taxonomy + object_id + with the given list of tags, if the taxonomy can be used by the given object_id. + + This is a wrapper around oel_tagging.tag_object that adds emitting the `CONTENT_OBJECT_TAGS_CHANGED` event + when tagging an object. + + tags: A list of the values of the tags from this taxonomy to apply. + + object_tag_class: Optional. Use a proxy subclass of ObjectTag for additional + validation. (e.g. only allow tagging certain types of objects.) + + Raised Tag.DoesNotExist if the proposed tags are invalid for this taxonomy. + Preserves existing (valid) tags, adds new (valid) tags, and removes omitted + (or invalid) tags. + """ + content_key = get_content_key_from_string(object_id) + context_key = get_context_key_from_key(content_key) + + if check_taxonomy_context_key_org(taxonomy, context_key): + oel_tagging.tag_object( + object_id=object_id, + taxonomy=taxonomy, + tags=tags, + ) + CONTENT_OBJECT_TAGS_CHANGED.send_event( + time=datetime.datetime.now(datetime.timezone.utc), + content_object=ContentObjectData(object_id=object_id) + ) + + +# Expose the oel_tagging APIs + +add_tag_to_taxonomy = oel_tagging.add_tag_to_taxonomy +update_tag_in_taxonomy = oel_tagging.update_tag_in_taxonomy +delete_tags_from_taxonomy = oel_tagging.delete_tags_from_taxonomy +get_taxonomy = oel_tagging.get_taxonomy +get_taxonomy_by_export_id = oel_tagging.get_taxonomy_by_export_id +get_taxonomies = oel_tagging.get_taxonomies +get_tags = oel_tagging.get_tags +get_object_tag_counts = oel_tagging.get_object_tag_counts +delete_object_tags = oel_tagging.delete_object_tags +resync_object_tags = oel_tagging.resync_object_tags +get_object_tags = oel_tagging.get_object_tags +add_tag_to_taxonomy = oel_tagging.add_tag_to_taxonomy diff --git a/openedx/core/djangoapps/content_tagging/apps.py b/openedx/core/djangoapps/content_tagging/apps.py new file mode 100644 index 000000000000..de52da36780b --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/apps.py @@ -0,0 +1,16 @@ +""" +Define the content tagging Django App. +""" + +from django.apps import AppConfig + + +class ContentTaggingConfig(AppConfig): + """App config for the content tagging feature""" + + default_auto_field = "django.db.models.BigAutoField" + name = "openedx.core.djangoapps.content_tagging" + + def ready(self): + # Connect signal handlers + from . import handlers # pylint: disable=unused-import diff --git a/openedx/core/djangoapps/content_tagging/auth.py b/openedx/core/djangoapps/content_tagging/auth.py new file mode 100644 index 000000000000..73040111ab53 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/auth.py @@ -0,0 +1,14 @@ +""" +Functions to validate the access in content tagging actions +""" + + +from openedx_tagging.core.tagging import rules as oel_tagging_rules + + +def has_view_object_tags_access(user, object_id): + return user.has_perm( + "oel_tagging.view_objecttag", + # The obj arg expects a model, but we are passing an object + oel_tagging_rules.ObjectTagPermissionItem(taxonomy=None, object_id=object_id), # type: ignore[arg-type] + ) diff --git a/openedx/core/djangoapps/content_tagging/handlers.py b/openedx/core/djangoapps/content_tagging/handlers.py new file mode 100644 index 000000000000..74b4dd5b3331 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/handlers.py @@ -0,0 +1,146 @@ +""" +Automatic tagging of content +""" + +import crum +import logging + +from django.dispatch import receiver +from openedx_events.content_authoring.data import ( + CourseData, + DuplicatedXBlockData, + XBlockData, + LibraryBlockData, +) +from openedx_events.content_authoring.signals import ( + COURSE_CREATED, + XBLOCK_CREATED, + XBLOCK_DELETED, + XBLOCK_UPDATED, + XBLOCK_DUPLICATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_DELETED, +) + +from .api import copy_object_tags +from .tasks import ( + delete_course_tags, + delete_xblock_tags, + update_course_tags, + update_xblock_tags, + update_library_block_tags, + delete_library_block_tags, +) +from .toggles import CONTENT_TAGGING_AUTO + +log = logging.getLogger(__name__) + + +@receiver(COURSE_CREATED) +def auto_tag_course(**kwargs): + """ + Automatically tag course based on their metadata + """ + course_data = kwargs.get("course", None) + if not course_data or not isinstance(course_data, CourseData): + log.error("Received null or incorrect data for event") + return + + if not CONTENT_TAGGING_AUTO.is_enabled(course_data.course_key): + return + + update_course_tags.delay(str(course_data.course_key)) + + +@receiver(XBLOCK_CREATED) +@receiver(XBLOCK_UPDATED) +def auto_tag_xblock(**kwargs): + """ + Automatically tag XBlock based on their metadata + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + if not CONTENT_TAGGING_AUTO.is_enabled(xblock_info.usage_key.course_key): + return + + if xblock_info.block_type == "course": + # Course update is handled by XBlock of course type + update_course_tags.delay(str(xblock_info.usage_key.course_key)) + + update_xblock_tags.delay(str(xblock_info.usage_key)) + + +@receiver(XBLOCK_DELETED) +def delete_tag_xblock(**kwargs): + """ + Automatically delete XBlock auto tags. + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + if not CONTENT_TAGGING_AUTO.is_enabled(xblock_info.usage_key.course_key): + return + + if xblock_info.block_type == "course": + # Course deletion is handled by XBlock of course type + delete_course_tags.delay(str(xblock_info.usage_key.course_key)) + + delete_xblock_tags.delay(str(xblock_info.usage_key)) + + +@receiver(LIBRARY_BLOCK_CREATED) +@receiver(LIBRARY_BLOCK_UPDATED) +def auto_tag_library_block(**kwargs): + """ + Automatically tag Library Blocks based on metadata + """ + if not CONTENT_TAGGING_AUTO.is_enabled(): + return + + library_block_data = kwargs.get("library_block", None) + if not library_block_data or not isinstance(library_block_data, LibraryBlockData): + log.error("Received null or incorrect data for event") + return + + current_request = crum.get_current_request() + update_library_block_tags.delay( + str(library_block_data.usage_key), current_request.LANGUAGE_CODE + ) + + +@receiver(LIBRARY_BLOCK_DELETED) +def delete_tag_library_block(**kwargs): + """ + Delete tags associated with a Library XBlock whenever the block is deleted. + """ + library_block_data = kwargs.get("library_block", None) + if not library_block_data or not isinstance(library_block_data, LibraryBlockData): + log.error("Received null or incorrect data for event") + return + + try: + delete_library_block_tags(str(library_block_data.usage_key)) + except Exception as err: # pylint: disable=broad-except + log.error(f"Failed to delete library block tags: {err}") + + +@receiver(XBLOCK_DUPLICATED) +def duplicate_tags(**kwargs): + """ + Duplicates tags associated with an XBlock whenever the block is duplicated to a new location. + """ + xblock_data = kwargs.get("xblock_info", None) + if not xblock_data or not isinstance(xblock_data, DuplicatedXBlockData): + log.error("Received null or incorrect data for event") + return + + copy_object_tags( + xblock_data.source_usage_key, + xblock_data.usage_key, + ) diff --git a/openedx/core/djangoapps/content_tagging/helpers/__init__.py b/openedx/core/djangoapps/content_tagging/helpers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/helpers/objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/helpers/objecttag_export_helpers.py new file mode 100644 index 000000000000..d367d08cd5a2 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/helpers/objecttag_export_helpers.py @@ -0,0 +1,173 @@ +""" +This module contains helper functions to build a object tree with object tags. +""" + +from __future__ import annotations + +from typing import Any, Callable, Iterator, Union + +from attrs import define +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2 +from xblock.core import XBlock + +import openedx.core.djangoapps.content_libraries.api as library_api +from xmodule.modulestore.django import modulestore + +from ..types import TagValuesByObjectIdDict, TagValuesByTaxonomyIdDict + + +@define +class TaggedContent: + """ + A tagged content, with its tags and children. + """ + display_name: str + block_id: str + category: str + object_tags: TagValuesByTaxonomyIdDict + 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 _get_course_tagged_object_and_children( + course_key: CourseKey, object_tag_cache: TagValuesByObjectIdDict +) -> tuple[TaggedContent, list[XBlock]]: + """ + Returns a TaggedContent with course metadata with its tags, and its children. + """ + store = modulestore() + + course = store.get_course(course_key) + if course is None: + raise ValueError(f"Course not found: {course_key}") + + course_id = str(course_key) + + tagged_course = TaggedContent( + display_name=course.display_name_with_default, + block_id=course_id, + category=course.category, + object_tags=object_tag_cache.get(course_id, {}), + children=None, + ) + + return tagged_course, course.children if course.has_children else [] + + +def _get_library_tagged_object_and_children( + library_key: LibraryLocatorV2, object_tag_cache: TagValuesByObjectIdDict +) -> tuple[TaggedContent, list[library_api.LibraryXBlockMetadata]]: + """ + Returns a TaggedContent with library metadata with its tags, and its children. + """ + library = library_api.get_library(library_key) + if library is None: + raise ValueError(f"Library not found: {library_key}") + + library_id = str(library_key) + + tagged_library = TaggedContent( + display_name=library.title, + block_id=library_id, + category='library', + object_tags=object_tag_cache.get(library_id, {}), + children=None, + ) + + children = library_api.get_library_blocks(library_key) + + return tagged_library, children + + +def _get_xblock_tagged_object_and_children( + usage_key: UsageKey, object_tag_cache: TagValuesByObjectIdDict +) -> tuple[TaggedContent, list[XBlock]]: + """ + Returns a TaggedContent with xblock metadata with its tags, and its children. + """ + store = modulestore() + block = store.get_item(usage_key) + block_id = str(usage_key) + tagged_block = TaggedContent( + display_name=block.display_name_with_default, + block_id=block_id, + category=block.category, + object_tags=object_tag_cache.get(block_id, {}), + children=None, + ) + + return tagged_block, block.children if block.has_children else [] + + +def _get_library_block_tagged_object( + library_block: library_api.LibraryXBlockMetadata, object_tag_cache: TagValuesByObjectIdDict +) -> tuple[TaggedContent, None]: + """ + Returns a TaggedContent with library content block metadata and its tags, + and 'None' as children. + """ + block_id = str(library_block.usage_key) + tagged_library_block = TaggedContent( + display_name=library_block.display_name, + block_id=block_id, + category=library_block.usage_key.block_type, + object_tags=object_tag_cache.get(block_id, {}), + children=None, + ) + + return tagged_library_block, None + + +def build_object_tree_with_objecttags( + content_key: LibraryLocatorV2 | CourseKey, + object_tag_cache: TagValuesByObjectIdDict, +) -> TaggedContent: + """ + Returns the object with the tags associated with it. + """ + get_tagged_children: Union[ + # _get_course_tagged_object_and_children type + Callable[[library_api.LibraryXBlockMetadata, dict[str, dict[int, list[Any]]]], tuple[TaggedContent, None]], + # _get_library_block_tagged_object type + Callable[[UsageKey, dict[str, dict[int, list[Any]]]], tuple[TaggedContent, list[Any]]] + ] + if isinstance(content_key, CourseKey): + tagged_content, children = _get_course_tagged_object_and_children( + content_key, object_tag_cache + ) + get_tagged_children = _get_xblock_tagged_object_and_children + elif isinstance(content_key, LibraryLocatorV2): + tagged_content, children = _get_library_tagged_object_and_children( + content_key, object_tag_cache + ) + get_tagged_children = _get_library_block_tagged_object + else: + raise ValueError(f"Invalid content_key: {type(content_key)} -> {content_key}") + + blocks: list[tuple[TaggedContent, list | None]] = [(tagged_content, children)] + + while blocks: + tagged_block, block_children = blocks.pop() + tagged_block.children = [] + + if not block_children: + continue + + for child in block_children: + tagged_child, child_children = get_tagged_children(child, object_tag_cache) + tagged_block.children.append(tagged_child) + blocks.append((tagged_child, child_children)) + + return tagged_content diff --git a/openedx/core/djangoapps/content_tagging/migrations/0001_initial.py b/openedx/core/djangoapps/content_tagging/migrations/0001_initial.py new file mode 100644 index 000000000000..7d74d7ab73a0 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 3.2.20 on 2023-07-25 06:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("oel_tagging", "0002_auto_20230718_2026"), + ("organizations", "0003_historicalorganizationcourse"), + ] + + operations = [ + migrations.CreateModel( + name="ContentObjectTag", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.objecttag",), + ), + migrations.CreateModel( + name="ContentTaxonomy", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.taxonomy",), + ), + migrations.CreateModel( + name="TaxonomyOrg", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rel_type", + models.CharField( + choices=[("OWN", "owner")], default="OWN", max_length=3 + ), + ), + ( + "org", + models.ForeignKey( + default=None, + help_text="Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="organizations.organization", + ), + ), + ( + "taxonomy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="oel_tagging.taxonomy", + ), + ), + ], + ), + migrations.AddIndex( + model_name="taxonomyorg", + index=models.Index( + fields=["taxonomy", "rel_type"], name="content_tag_taxonom_b04dd1_idx" + ), + ), + migrations.AddIndex( + model_name="taxonomyorg", + index=models.Index( + fields=["taxonomy", "rel_type", "org"], + name="content_tag_taxonom_70d60b_idx", + ), + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py new file mode 100644 index 000000000000..fa00df307c64 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.21 on 2023-10-09 23:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [ + ('content_tagging', '0001_initial'), + ('content_tagging', '0002_system_defined_taxonomies'), + ('content_tagging', '0003_system_defined_fixture'), + ('content_tagging', '0004_system_defined_org'), + ('content_tagging', '0005_auto_20230830_1517'), + ('content_tagging', '0006_simplify_models'), + ] + + initial = True + + dependencies = [ + ("oel_tagging", "0001_squashed"), + ('organizations', '0003_historicalorganizationcourse'), + ] + + operations = [ + migrations.CreateModel( + name='ContentObjectTag', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.objecttag',), + ), + migrations.CreateModel( + name='TaxonomyOrg', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rel_type', models.CharField(choices=[('OWN', 'owner')], default='OWN', max_length=3)), + ('org', models.ForeignKey(default=None, help_text='Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.', null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.organization')), + ('taxonomy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_tagging.taxonomy')), + ], + ), + migrations.AddIndex( + model_name='taxonomyorg', + index=models.Index(fields=['taxonomy', 'rel_type'], name='content_tag_taxonom_b04dd1_idx'), + ), + migrations.AddIndex( + model_name='taxonomyorg', + index=models.Index(fields=['taxonomy', 'rel_type', 'org'], name='content_tag_taxonom_70d60b_idx'), + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py new file mode 100644 index 000000000000..14a3e6ace128 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2.20 on 2023-07-31 21:07 + +from django.db import migrations +import openedx.core.djangoapps.content_tagging.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_tagging', '0005_language_taxonomy'), + ('content_tagging', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ContentAuthorTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.usersystemdefinedtaxonomy', ), + ), + migrations.CreateModel( + name='ContentLanguageTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.languagetaxonomy', ), + ), + migrations.CreateModel( + name='ContentOrganizationTaxonomy', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.modelsystemdefinedtaxonomy', ), + ), + migrations.CreateModel( + name='OrganizationModelObjectTag', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.modelobjecttag',), + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py new file mode 100644 index 000000000000..ff855482e1fc --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2.20 on 2023-07-11 22:57 + +from django.db import migrations + + +def load_system_defined_taxonomies(apps, schema_editor): + """ + Creates system defined taxonomies + """ + + # Create system defined taxonomy instances + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + author_taxonomy = Taxonomy( + pk=-2, + name="Content Authors", + description="Allows tags for any user ID created on the instance.", + enabled=True, + required=True, + allow_multiple=False, + allow_free_text=False, + visible_to_authors=False, + ) + ContentAuthorTaxonomy = apps.get_model("content_tagging", "ContentAuthorTaxonomy") + author_taxonomy.taxonomy_class = ContentAuthorTaxonomy + author_taxonomy.save() + + org_taxonomy = Taxonomy( + pk=-3, + name="Organizations", + description="Allows tags for any organization ID created on the instance.", + enabled=True, + required=True, + allow_multiple=False, + allow_free_text=False, + visible_to_authors=False, + ) + ContentOrganizationTaxonomy = apps.get_model("content_tagging", "ContentOrganizationTaxonomy") + org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy + org_taxonomy.save() + + +def revert_system_defined_taxonomies(apps, schema_editor): + """ + Deletes all system defined taxonomies + """ + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + Taxonomy.objects.get(id=-2).delete() + Taxonomy.objects.get(id=-3).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("content_tagging", "0002_system_defined_taxonomies"), + ] + + operations = [ + migrations.RunPython(load_system_defined_taxonomies, revert_system_defined_taxonomies), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py new file mode 100644 index 000000000000..a60ef381cd54 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py @@ -0,0 +1,50 @@ +from django.db import migrations + + +def load_system_defined_org_taxonomies(apps, _schema_editor): + """ + Associates the system defined taxonomy Language (id=-1) to all orgs and + removes the ContentOrganizationTaxonomy (id=-3) from the database + """ + # Disabled for now as the way that this taxonomy is created has changed. + # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + # TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) + + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + Taxonomy.objects.get(id=-3).delete() + + + + +def revert_system_defined_org_taxonomies(apps, _schema_editor): + """ + Deletes association of system defined taxonomy Language (id=-1) to all orgs and + creates the ContentOrganizationTaxonomy (id=-3) in the database + """ + # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + # TaxonomyOrg.objects.get(id=-1).delete() + + Taxonomy = apps.get_model("oel_tagging", "Taxonomy") + org_taxonomy = Taxonomy( + pk=-3, + name="Organizations", + description="Allows tags for any organization ID created on the instance.", + enabled=True, + required=True, + allow_multiple=False, + allow_free_text=False, + visible_to_authors=False, + ) + ContentOrganizationTaxonomy = apps.get_model("content_tagging", "ContentOrganizationTaxonomy") + org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy + org_taxonomy.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("content_tagging", "0003_system_defined_fixture"), + ] + + operations = [ + migrations.RunPython(load_system_defined_org_taxonomies, revert_system_defined_org_taxonomies), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0005_auto_20230830_1517.py b/openedx/core/djangoapps/content_tagging/migrations/0005_auto_20230830_1517.py new file mode 100644 index 000000000000..6252594e8bff --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0005_auto_20230830_1517.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2023-08-30 15:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_tagging', '0004_system_defined_org'), + ] + + operations = [ + migrations.DeleteModel( + name='ContentOrganizationTaxonomy', + ), + migrations.DeleteModel( + name='OrganizationModelObjectTag', + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py new file mode 100644 index 000000000000..7e8eb99ee71c --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.21 on 2023-09-29 23:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_tagging', '0005_auto_20230830_1517'), + ] + + operations = [ + migrations.DeleteModel( + name='ContentAuthorTaxonomy', + ), + migrations.DeleteModel( + name='ContentLanguageTaxonomy', + ), + migrations.DeleteModel( + name='ContentTaxonomy', + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py new file mode 100644 index 000000000000..0a48016ca2b4 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py @@ -0,0 +1,28 @@ +from django.db import migrations + + +def mark_language_taxonomy_as_all_orgs(apps, _schema_editor): + """ + Associates the system defined taxonomy Language (id=-1) to all orgs. + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.update_or_create(taxonomy_id=-1, defaults={"org": None}) + + +def revert_mark_language_taxonomy_as_all_orgs(apps, _schema_editor): + """ + Deletes association of system defined taxonomy Language (id=-1) to all orgs. + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.get(taxonomy_id=-1, org=None).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('content_tagging', '0001_squashed'), + ("oel_tagging", "0012_language_taxonomy"), + ] + + operations = [ + migrations.RunPython(mark_language_taxonomy_as_all_orgs, revert_mark_language_taxonomy_as_all_orgs), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0008_remove_content_object_tag.py b/openedx/core/djangoapps/content_tagging/migrations/0008_remove_content_object_tag.py new file mode 100644 index 000000000000..ecaeec7f8c8b --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0008_remove_content_object_tag.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2.23 on 2024-01-30 21:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_tagging', '0007_system_defined_org_2'), + ] + + operations = [ + migrations.DeleteModel( + name='ContentObjectTag', + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/__init__.py b/openedx/core/djangoapps/content_tagging/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/models/__init__.py b/openedx/core/djangoapps/content_tagging/models/__init__.py new file mode 100644 index 000000000000..4a25224b4bae --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/models/__init__.py @@ -0,0 +1,6 @@ +""" +Content Tagging and System defined models +""" +from .base import ( + TaxonomyOrg, +) diff --git a/openedx/core/djangoapps/content_tagging/models/base.py b/openedx/core/djangoapps/content_tagging/models/base.py new file mode 100644 index 000000000000..8a232d3a7bf4 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/models/base.py @@ -0,0 +1,84 @@ +""" +Content Tagging models +""" +from __future__ import annotations + +from django.db import models +from django.db.models import Q, QuerySet +from django.utils.translation import gettext as _ +from openedx_tagging.core.tagging.models import Taxonomy +from organizations.models import Organization + + +class TaxonomyOrg(models.Model): + """ + Represents the many-to-many relationship between Taxonomies and Organizations. + + We keep this as a separate class from ContentTaxonomy so that class can remain a proxy for Taxonomy, keeping the + data models and usage simple. + """ + + class RelType(models.TextChoices): + OWNER = "OWN", _("owner") + + taxonomy = models.ForeignKey(Taxonomy, on_delete=models.CASCADE) + org = models.ForeignKey( + Organization, + null=True, + default=None, + on_delete=models.CASCADE, + help_text=_( + "Organization that is related to this taxonomy." + "If None, then this taxonomy is related to all organizations." + ), + ) + rel_type = models.CharField( + max_length=3, + choices=RelType.choices, + default=RelType.OWNER, + ) + + class Meta: + indexes = [ + models.Index(fields=["taxonomy", "rel_type"]), + models.Index(fields=["taxonomy", "rel_type", "org"]), + ] + + @classmethod + def get_relationships( + cls, taxonomy: Taxonomy, rel_type: RelType, org_short_name: str | None = None + ) -> QuerySet: + """ + Returns the relationships of the given rel_type and taxonomy where: + * the relationship is available for all organizations, OR + * (if provided) the relationship is available to the org with the given org_short_name + """ + # A relationship with org=None means all Organizations + org_filter = Q(org=None) + if org_short_name is not None: + org_filter |= Q(org__short_name=org_short_name) + return cls.objects.filter( + taxonomy=taxonomy, + rel_type=rel_type, + ).filter(org_filter) + + @classmethod + def get_organizations( + cls, taxonomy: Taxonomy, rel_type=RelType.OWNER, + ) -> tuple[bool, list[Organization]]: + """ + Returns a tuple containing: + * bool: flag indicating whether "all organizations" have the given relationship to the taxonomy + * orgs: list of Organizations which have the given relationship to the taxonomy + """ + is_all_org = False + orgs = [] + # Iterate over the taxonomyorgs instead of filtering to take advantage of prefetched data. + for taxonomy_org in taxonomy.taxonomyorg_set.all(): + if taxonomy_org.rel_type == rel_type: + if taxonomy_org.org is None: + is_all_org = True + else: + orgs.append(taxonomy_org.org) + + return (is_all_org, orgs) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/__init__.py b/openedx/core/djangoapps/content_tagging/rest_api/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/rest_api/urls.py b/openedx/core/djangoapps/content_tagging/rest_api/urls.py new file mode 100644 index 000000000000..d7f012bb7ba1 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rest_api/urls.py @@ -0,0 +1,9 @@ +""" +Taxonomies API URLs. +""" + +from django.urls import path, include + +from .v1 import urls as v1_urls + +urlpatterns = [path("v1/", include(v1_urls))] diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/__init__.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py new file mode 100644 index 000000000000..e4fa403fa526 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py @@ -0,0 +1,90 @@ +""" +API Filters for content tagging org +""" + +from django.db.models import Exists, OuterRef, Q +from rest_framework.filters import BaseFilterBackend + +import openedx_tagging.core.tagging.rules as oel_tagging + +from ...rules import get_admin_orgs, get_user_orgs +from ...models import TaxonomyOrg + + +class UserOrgFilterBackend(BaseFilterBackend): + """ + Filter taxonomies based on user's orgs roles + + Taxonomy admin can see all taxonomies + Org staff can see all taxonomies from their orgs + Content creators and instructors can see enabled taxonomies avaliable to their orgs + """ + + def filter_queryset(self, request, queryset, _): + if oel_tagging.is_taxonomy_admin(request.user): + return queryset + + user_admin_orgs = get_admin_orgs(request.user) + user_orgs = get_user_orgs(request.user) # Orgs that the user is a content creator or instructor + + if len(user_orgs) == 0 and len(user_admin_orgs) == 0: + return queryset.none() + + return queryset.filter( + # Get enabled taxonomies available to all orgs, or from orgs that the user is + # a content creator or instructor + Q( + Exists( + TaxonomyOrg.objects + .filter( + taxonomy=OuterRef("pk"), + rel_type=TaxonomyOrg.RelType.OWNER, + ) + .filter( + Q(org=None) | + Q(org__in=user_orgs) + ) + ), + enabled=True, + ) | + # Get all taxonomies from orgs that the user is OrgStaff + Q( + Exists( + TaxonomyOrg.objects + .filter(taxonomy=OuterRef("pk"), rel_type=TaxonomyOrg.RelType.OWNER) + .filter(org__in=user_admin_orgs) + ) + ) + ) + + +class ObjectTagTaxonomyOrgFilterBackend(BaseFilterBackend): + """ + Filter for ObjectTagViewSet to only show taxonomies that the user can view. + """ + + def filter_queryset(self, request, queryset, _): + if oel_tagging.is_taxonomy_admin(request.user): + return queryset.prefetch_related('taxonomy__taxonomyorg_set') + + user_admin_orgs = get_admin_orgs(request.user) + user_orgs = get_user_orgs(request.user) + user_or_admin_orgs = list(set(user_orgs) | set(user_admin_orgs)) + + return queryset.filter(taxonomy__enabled=True).filter( + # Get ObjectTags from taxonomies available to all orgs, or from orgs that the user is + # a OrgStaff, content creator or instructor + Q( + Exists( + TaxonomyOrg.objects + .filter( + taxonomy=OuterRef("taxonomy_id"), + rel_type=TaxonomyOrg.RelType.OWNER, + ) + .filter( + Q(org=None) | + Q(org__in=user_or_admin_orgs) + ) + ) + ) + ).prefetch_related('taxonomy__taxonomyorg_set') diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py new file mode 100644 index 000000000000..8bd26230855a --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py @@ -0,0 +1,96 @@ +""" +API Serializers for content tagging org +""" + +from __future__ import annotations + +from rest_framework import serializers, fields + +from openedx_tagging.core.tagging.rest_api.v1.serializers import ( + TaxonomyListQueryParamsSerializer, + TaxonomySerializer, +) + +from organizations.models import Organization + +from ...models import TaxonomyOrg + + +class TaxonomyOrgListQueryParamsSerializer(TaxonomyListQueryParamsSerializer): + """ + Serializer for the query params for the GET view + """ + + org: fields.Field = serializers.CharField( + required=False, + ) + unassigned: fields.Field = serializers.BooleanField(required=False) + + def validate(self, attrs: dict) -> dict: + """ + Validate the serializer data + """ + if "org" in attrs and "unassigned" in attrs: + raise serializers.ValidationError( + "'org' and 'unassigned' params cannot be both defined" + ) + + return attrs + + +class TaxonomyUpdateOrgBodySerializer(serializers.Serializer): + """ + Serializer for the body params for the update orgs action + """ + + orgs: fields.Field = serializers.SlugRelatedField( + many=True, + slug_field="short_name", + queryset=Organization.objects.all(), + required=False, + ) + + all_orgs: fields.Field = serializers.BooleanField(required=False) + + def validate(self, attrs: dict) -> dict: + """ + Validate the serializer data + """ + if bool(attrs.get("orgs") is not None) == bool(attrs.get("all_orgs")): + raise serializers.ValidationError( + "You must specify either orgs or all_orgs, but not both." + ) + + return attrs + + +class TaxonomyOrgSerializer(TaxonomySerializer): + """ + Serializer for Taxonomy objects inclusing the associated orgs + """ + + orgs = serializers.SerializerMethodField() + all_orgs = serializers.SerializerMethodField() + + def get_orgs(self, obj) -> list[str]: + """ + Return the list of orgs for the taxonomy. + """ + return [ + taxonomy_org.org.short_name for taxonomy_org in obj.taxonomyorg_set.all() + if taxonomy_org.org and taxonomy_org.rel_type == TaxonomyOrg.RelType.OWNER + ] + + def get_all_orgs(self, obj) -> bool: + """ + Return True if the taxonomy is associated with all orgs. + """ + for taxonomy_org in obj.taxonomyorg_set.all(): + if taxonomy_org.org_id is None and taxonomy_org.rel_type == TaxonomyOrg.RelType.OWNER: + return True + return False + + class Meta: + model = TaxonomySerializer.Meta.model + fields = TaxonomySerializer.Meta.fields + ["orgs", "all_orgs"] + read_only_fields = ["orgs", "all_orgs"] diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/__init__.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py new file mode 100644 index 000000000000..880578242afd --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -0,0 +1,2410 @@ +""" +Tests tagging rest api views +""" + +from __future__ import annotations + +import abc +import json +from io import BytesIO +from unittest.mock import MagicMock +from urllib.parse import parse_qs, urlparse + +import ddt +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from openedx_tagging.core.tagging.models import Tag, Taxonomy +from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy +from openedx_tagging.core.tagging.rest_api.v1.serializers import TaxonomySerializer +from organizations.models import Organization +from rest_framework import status +from rest_framework.test import APITestCase + +from common.djangoapps.student.auth import add_users, update_org_role +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + OrgContentCreatorRole, + OrgInstructorRole, + OrgLibraryUserRole, + OrgStaffRole +) +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content_libraries.api import AccessLevel, create_library, set_library_user_permissions +from openedx.core.djangoapps.content_tagging import api as tagging_api +from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg +from openedx.core.djangoapps.content_tagging.utils import rules_cache +from openedx.core.djangolib.testing.utils import skip_unless_cms +from openedx.core.lib import blockstore_api + +from ....tests.test_objecttag_export_helpers import TaggedCourseMixin + +User = get_user_model() + +TAXONOMY_ORG_LIST_URL = "/api/content_tagging/v1/taxonomies/" +TAXONOMY_ORG_DETAIL_URL = "/api/content_tagging/v1/taxonomies/{pk}/" +TAXONOMY_ORG_UPDATE_ORG_URL = "/api/content_tagging/v1/taxonomies/{pk}/orgs/" +OBJECT_TAG_UPDATE_URL = "/api/content_tagging/v1/object_tags/{object_id}/" +OBJECT_TAGS_EXPORT_URL = "/api/content_tagging/v1/object_tags/{object_id}/export/" +OBJECT_TAGS_URL = "/api/content_tagging/v1/object_tags/{object_id}/" +TAXONOMY_TEMPLATE_URL = "/api/content_tagging/v1/taxonomies/import/{filename}" +TAXONOMY_CREATE_IMPORT_URL = "/api/content_tagging/v1/taxonomies/import/" +TAXONOMY_TAGS_IMPORT_URL = "/api/content_tagging/v1/taxonomies/{pk}/tags/import/" +TAXONOMY_TAGS_URL = "/api/content_tagging/v1/taxonomies/{pk}/tags/" + + +def check_taxonomy( + data, + pk, + name, + description=None, + enabled=True, + allow_multiple=True, + allow_free_text=False, + system_defined=False, + visible_to_authors=True, + export_id=None, + **_ +): + """ + Check the given data against the expected values. + """ + assert data["id"] == pk + assert data["name"] == name + assert data["description"] == description + assert data["enabled"] == enabled + assert data["allow_multiple"] == allow_multiple + assert data["allow_free_text"] == allow_free_text + assert data["system_defined"] == system_defined + assert data["visible_to_authors"] == visible_to_authors + assert data["export_id"] == export_id + + +class TestTaxonomyObjectsMixin: + """ + Sets up data for testing Content Taxonomies. + """ + def _setUp_orgs(self): + """ + Create orgs for testing + """ + self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") + self.orgB = Organization.objects.create(name="Organization B", short_name="orgB") + self.orgX = Organization.objects.create(name="Organization X", short_name="orgX") + + def _setUp_courses(self): + """ + Create courses for testing + """ + self.courseA = CourseLocator("orgA", "101", "test") + self.courseB = CourseLocator("orgB", "101", "test") + + def _setUp_library(self): + """ + Create library for testing + """ + self.collection = blockstore_api.create_collection("Test library collection") + self.content_libraryA = create_library( + collection_uuid=self.collection.uuid, + org=self.orgA, + slug="lib_a", + title="Library Org A", + description="This is a library from Org A", + ) + self.libraryA = str(self.content_libraryA.key) + + def _setUp_users(self): + """ + Create users for testing + """ + self.user = User.objects.create( + username="user", + email="user@example.com", + ) + self.staff = User.objects.create( + username="staff", + email="staff@example.com", + is_staff=True, + ) + self.superuser = User.objects.create( + username="superuser", + email="superuser@example.com", + is_superuser=True, + ) + + self.staffA = User.objects.create( + username="staffA", + email="staffA@example.com", + ) + update_org_role(self.staff, OrgStaffRole, self.staffA, [self.orgA.short_name]) + + self.staffB = User.objects.create( + username="staffB", + email="staffB@example.com", + ) + update_org_role(self.staff, OrgStaffRole, self.staffB, [self.orgB.short_name]) + + self.content_creatorA = User.objects.create( + username="content_creatorA", + email="content_creatorA@example.com", + ) + update_org_role(self.staff, OrgContentCreatorRole, self.content_creatorA, [self.orgA.short_name]) + + self.instructorA = User.objects.create( + username="instructorA", + email="instructorA@example.com", + ) + update_org_role(self.staff, OrgInstructorRole, self.instructorA, [self.orgA.short_name]) + + self.library_staffA = User.objects.create( + username="library_staffA", + email="library_staffA@example.com", + ) + update_org_role(self.staff, OrgLibraryUserRole, self.library_staffA, [self.orgA.short_name]) + + self.course_instructorA = User.objects.create( + username="course_instructorA", + email="course_instructorA@example.com", + ) + add_users(self.staff, CourseInstructorRole(self.courseA), self.course_instructorA) + + self.course_staffA = User.objects.create( + username="course_staffA", + email="course_staffA@example.com", + ) + add_users(self.staff, CourseStaffRole(self.courseA), self.course_staffA) + + self.library_userA = User.objects.create( + username="library_userA", + email="library_userA@example.com", + ) + set_library_user_permissions( + self.content_libraryA.key, + self.library_userA, + AccessLevel.READ_LEVEL + ) + + def _setUp_taxonomies(self): + """ + Create taxonomies for testing + """ + # Orphaned taxonomy + self.ot1 = tagging_api.create_taxonomy(name="ot1", enabled=True) + self.ot2 = tagging_api.create_taxonomy(name="ot2", enabled=False) + + # System defined taxonomy + self.st1 = tagging_api.create_taxonomy(name="st1", enabled=True) + self.st1.taxonomy_class = SystemDefinedTaxonomy + self.st1.save() + TaxonomyOrg.objects.create( + taxonomy=self.st1, + rel_type=TaxonomyOrg.RelType.OWNER, + org=None, + ) + self.st2 = tagging_api.create_taxonomy(name="st2", enabled=False) + self.st2.taxonomy_class = SystemDefinedTaxonomy + self.st2.save() + TaxonomyOrg.objects.create( + taxonomy=self.st2, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + # Global taxonomy, which contains tags + self.t1 = tagging_api.create_taxonomy(name="t1", enabled=True) + TaxonomyOrg.objects.create( + taxonomy=self.t1, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + self.t2 = tagging_api.create_taxonomy(name="t2", enabled=False) + TaxonomyOrg.objects.create( + taxonomy=self.t2, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + root1 = Tag.objects.create(taxonomy=self.t1, value="ALPHABET") + Tag.objects.create(taxonomy=self.t1, value="android", parent=root1) + Tag.objects.create(taxonomy=self.t1, value="abacus", parent=root1) + Tag.objects.create(taxonomy=self.t1, value="azure", parent=root1) + Tag.objects.create(taxonomy=self.t1, value="aardvark", parent=root1) + Tag.objects.create(taxonomy=self.t1, value="anvil", parent=root1) + + # OrgA taxonomy + self.tA1 = tagging_api.create_taxonomy(name="tA1", enabled=True) + TaxonomyOrg.objects.create( + taxonomy=self.tA1, + org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER,) + self.tA2 = tagging_api.create_taxonomy(name="tA2", enabled=False) + TaxonomyOrg.objects.create( + taxonomy=self.tA2, + org=self.orgA, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + # OrgB taxonomy + self.tB1 = tagging_api.create_taxonomy(name="tB1", enabled=True) + TaxonomyOrg.objects.create( + taxonomy=self.tB1, + org=self.orgB, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + self.tB2 = tagging_api.create_taxonomy(name="tB2", enabled=False) + TaxonomyOrg.objects.create( + taxonomy=self.tB2, + org=self.orgB, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + # OrgA and OrgB taxonomy + self.tBA1 = tagging_api.create_taxonomy(name="tBA1", enabled=True) + TaxonomyOrg.objects.create( + taxonomy=self.tBA1, + org=self.orgA, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + TaxonomyOrg.objects.create( + taxonomy=self.tBA1, + org=self.orgB, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + self.tBA2 = tagging_api.create_taxonomy(name="tBA2", enabled=False) + TaxonomyOrg.objects.create( + taxonomy=self.tBA2, + org=self.orgA, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + TaxonomyOrg.objects.create( + taxonomy=self.tBA2, + org=self.orgB, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + def setUp(self): + + super().setUp() + + self._setUp_orgs() + self._setUp_courses() + self._setUp_library() + self._setUp_users() + self._setUp_taxonomies() + + # Clear the rules cache in between test runs to keep query counts consistent. + rules_cache.clear() + + +@skip_unless_cms +@ddt.ddt +class TestTaxonomyListCreateViewSet(TestTaxonomyObjectsMixin, APITestCase): + """ + Test cases for TaxonomyViewSet for list and create actions + """ + + def _test_list_taxonomy( + self, + user_attr: str, + expected_taxonomies: list[str], + enabled_parameter: bool | None = None, + org_parameter: str | None = None, + unassigned_parameter: bool | None = None, + page_size: int | None = None, + ) -> None: + """ + Helper function to call the list endpoint and check the response + """ + url = TAXONOMY_ORG_LIST_URL + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + # Set parameters cleaning empty values + query_params = {k: v for k, v in { + "enabled": enabled_parameter, + "org": org_parameter, + "unassigned": unassigned_parameter, + "page_size": page_size, + }.items() if v is not None} + + response = self.client.get(url, query_params, format="json") + + assert response.status_code == status.HTTP_200_OK + self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies)) + + def test_list_taxonomy_staff(self) -> None: + """ + Tests that staff users see all taxonomies + """ + # page_size=10, and so "tBA1" and "tBA2" appear on the second page + expected_taxonomies = ["ot1", "ot2", "st1", "st2", "t1", "t2", "tA1", "tA2", "tB1", "tB2"] + self._test_list_taxonomy( + user_attr="staff", + expected_taxonomies=expected_taxonomies, + page_size=10, + ) + + @ddt.data( + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_list_taxonomy_orgA(self, user_attr: str) -> None: + """ + Tests that non staff users from orgA can see only enabled taxonomies from orgA and global taxonomies + """ + expected_taxonomies = ["st1", "t1", "tA1", "tBA1"] + self._test_list_taxonomy( + user_attr=user_attr, + enabled_parameter=True, + expected_taxonomies=expected_taxonomies, + ) + + @ddt.data( + (True, ["ot1", "st1", "t1", "tA1", "tB1", "tBA1"]), + (False, ["ot2", "st2", "t2", "tA2", "tB2", "tBA2"]), + ) + @ddt.unpack + def test_list_taxonomy_enabled_filter(self, enabled_parameter: bool, expected_taxonomies: list[str]) -> None: + """ + Tests that the enabled filter works as expected + """ + self._test_list_taxonomy( + user_attr="staff", + enabled_parameter=enabled_parameter, + expected_taxonomies=expected_taxonomies + ) + + @ddt.data( + ("orgA", ["st1", "st2", "t1", "t2", "tA1", "tA2", "tBA1", "tBA2"]), + ("orgB", ["st1", "st2", "t1", "t2", "tB1", "tB2", "tBA1", "tBA2"]), + ("orgX", ["st1", "st2", "t1", "t2"]), + # Non-existent orgs are ignored + ("invalidOrg", ["st1", "st2", "t1", "t2"]), + ) + @ddt.unpack + def test_list_taxonomy_org_filter(self, org_parameter: str, expected_taxonomies: list[str]) -> None: + """ + Tests that the org filter works as expected + """ + self._test_list_taxonomy( + user_attr="staff", + org_parameter=org_parameter, + expected_taxonomies=expected_taxonomies, + ) + + def test_list_unassigned_taxonomies(self): + """ + Test that passing in "unassigned" query param returns Taxonomies that + are unassigned. i.e. does not belong to any org + """ + self._test_list_taxonomy( + user_attr="staff", + expected_taxonomies=["ot1", "ot2"], + unassigned_parameter=True, + ) + + def test_list_unassigned_and_org_filter_invalid(self) -> None: + """ + Test that passing "org" and "unassigned" query params should throw an error + """ + url = TAXONOMY_ORG_LIST_URL + + self.client.force_authenticate(user=self.user) + + query_params = {"org": "orgA", "unassigned": "true"} + + response = self.client.get(url, query_params, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @ddt.data( + ("user", (), None), + ("staffA", ["tA2", "tBA1", "tBA2"], None), + ("staff", ["st2", "t1", "t2"], "3"), + ) + @ddt.unpack + def test_list_taxonomy_pagination( + self, user_attr: str, expected_taxonomies: list[str], expected_next_page: str | None + ) -> None: + """ + Tests that the pagination works as expected + """ + url = TAXONOMY_ORG_LIST_URL + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + query_params = {"page_size": 3, "page": 2} + + response = self.client.get(url, query_params, format="json") + + assert response.status_code == status.HTTP_200_OK if len(expected_taxonomies) > 0 else status.HTTP_404_NOT_FOUND + if status.is_success(response.status_code): + self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies)) + parsed_url = urlparse(response.data["next"]) + + next_page = parse_qs(parsed_url.query).get("page", [None])[0] + assert next_page == expected_next_page + + def test_list_invalid_page(self) -> None: + """ + Tests that using an invalid page will raise NOT_FOUND + """ + url = TAXONOMY_ORG_LIST_URL + + self.client.force_authenticate(user=self.user) + + query_params = {"page": 123123} + + response = self.client.get(url, query_params, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @ddt.data( + (None, status.HTTP_401_UNAUTHORIZED), + ("user", status.HTTP_403_FORBIDDEN), + ("content_creatorA", status.HTTP_403_FORBIDDEN), + ("instructorA", status.HTTP_403_FORBIDDEN), + ("library_staffA", status.HTTP_403_FORBIDDEN), + ("course_instructorA", status.HTTP_403_FORBIDDEN), + ("course_staffA", status.HTTP_403_FORBIDDEN), + ("library_userA", status.HTTP_403_FORBIDDEN), + ("staffA", status.HTTP_201_CREATED), + ("staff", status.HTTP_201_CREATED), + ) + @ddt.unpack + def test_create_taxonomy(self, user_attr: str, expected_status: int) -> None: + """ + Tests that only Taxonomy admins and org level admins can create taxonomies + """ + url = TAXONOMY_ORG_LIST_URL + + create_data = { + "name": "taxonomy_data", + "description": "This is a description", + "enabled": True, + "allow_multiple": True, + "export_id": "taxonomy_data", + } + + if user_attr: + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.post(url, create_data, format="json") + assert response.status_code == expected_status + + # If we were able to create the taxonomy, check if it was created + if status.is_success(expected_status): + check_taxonomy(response.data, response.data["id"], **create_data) + url = TAXONOMY_ORG_DETAIL_URL.format(pk=response.data["id"]) + + response = self.client.get(url) + check_taxonomy(response.data, response.data["id"], **create_data) + + # Also checks if the taxonomy was associated with the org + if user_attr == "staffA": + assert response.data["orgs"] == [self.orgA.short_name] + + @ddt.data( + ('staff', 11), + ("content_creatorA", 16), + ("library_staffA", 16), + ("library_userA", 16), + ("instructorA", 16), + ("course_instructorA", 16), + ("course_staffA", 16), + ) + @ddt.unpack + def test_list_taxonomy_query_count(self, user_attr: str, expected_queries: int): + """ + Test how many queries are used when retrieving taxonomies and permissions + """ + url = TAXONOMY_ORG_LIST_URL + f'?org={self.orgA.short_name}&enabled=true' + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + with self.assertNumQueries(expected_queries): + response = self.client.get(url) + + assert response.status_code == 200 + assert response.data["can_add_taxonomy"] == user.is_staff + assert len(response.data["results"]) == 4 + for taxonomy in response.data["results"]: + if taxonomy["system_defined"]: + assert not taxonomy["can_change_taxonomy"] + assert not taxonomy["can_delete_taxonomy"] + assert taxonomy["can_tag_object"] + else: + assert taxonomy["can_change_taxonomy"] == user.is_staff + assert taxonomy["can_delete_taxonomy"] == user.is_staff + assert taxonomy["can_tag_object"] + + +@ddt.ddt +class TestTaxonomyDetailExportMixin(TestTaxonomyObjectsMixin): + """ + Test cases to be used with detail and export actions + """ + + @abc.abstractmethod + def _test_api_call(self, **_kwargs) -> None: + """ + Helper function to call the detail/export endpoint and check the response + """ + + @ddt.data( + "user", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_detail_taxonomy_all_org_enabled(self, user_attr: str) -> None: + """ + Tests that everyone can see enabled global taxonomies + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="t1", + expected_status=status.HTTP_200_OK, + reason="Everyone should see enabled global taxonomies", + ) + + @ddt.data( + ("content_creatorA", "tA1", "User with OrgContentCreatorRole(orgA) should see an enabled taxonomy from orgA"), + ("content_creatorA", "tBA1", "User with OrgContentCreatorRole(orgA) should see an enabled taxonomy from orgA"), + ("content_creatorA", "t1", "User with OrgContentCreatorRole(orgA) should see an enabled global taxonomy"), + ("instructorA", "tA1", "User with OrgInstructorRole(orgA) should see an enabled taxonomy from orgA"), + ("instructorA", "tBA1", "User with OrgInstructorRole(orgA) should see an enabled taxonomy from orgA"), + ("instructorA", "t1", "User with OrgInstructorRole(orgA) should see an enabled global taxonomy"), + ("library_staffA", "tA1", "User with OrgLibraryUserRole(orgA) should see an enabled taxonomy from orgA"), + ("library_staffA", "tBA1", "User with OrgLibraryUserRole(orgA) should see an enabled taxonomy from orgA"), + ("library_staffA", "t1", "User with OrgInstructorRole(orgA) should see an enabled global taxonomy"), + ( + "course_instructorA", + "tA1", + "User with CourseInstructorRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_instructorA", + "tBA1", + "User with CourseInstructorRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_instructorA", + "t1", + "User with CourseInstructorRole in a course from orgA should see an enabled global taxonomy" + ), + ( + "course_staffA", + "tA1", + "User with CourseStaffRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_staffA", + "tBA1", + "User with CourseStaffRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_staffA", + "t1", + "User with CourseStaffRole in a course from orgA should see an enabled global taxonomy" + ), + ( + "library_userA", + "tA1", + "User with permission on a library from orgA should see an enabled taxonomy from orgA" + ), + ( + "library_userA", + "tBA1", + "User with permission on a library from orgA should see an enabled taxonomy from orgA" + ), + ( + "library_userA", + "t1", + "User with permission on a library from orgA should see an enabled global taxonomy" + ), + ) + @ddt.unpack + def test_detail_taxonomy_org_user_see_enabled(self, user_attr: str, taxonomy_attr: str, reason: str) -> None: + """ + Tests that org users (content creators and instructors) can see enabled global taxonomies and taxonomies + from their orgs + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason=reason, + ) + + @ddt.data( + "tA2", + "tBA2", + ) + def test_detail_taxonomy_org_admin_see_disabled(self, taxonomy_attr: str) -> None: + """ + Tests that org admins can see disabled taxonomies from their orgs + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason="User with OrgContentCreatorRole(orgA) should see a disabled taxonomy from orgA", + ) + + @ddt.data( + "st2", + "t2", + ) + def test_detail_taxonomy_org_admin_dont_see_disabled_global(self, taxonomy_attr: str) -> None: + """ + Tests that org admins can't see disabled global taxonomies + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_404_NOT_FOUND, + reason="User with OrgContentCreatorRole(orgA) shouldn't see a disabled global taxonomy", + ) + + @ddt.data( + ("content_creatorA", "t2", "User with OrgContentCreatorRole(orgA) shouldn't see a disabled global taxonomy"), + ("instructorA", "tA2", "User with OrgInstructorRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("instructorA", "tBA2", "User with OrgInstructorRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("instructorA", "t2", "User with OrgInstructorRole(orgA) shouldn't see a disabled global taxonomy"), + ("library_staffA", "tA2", "User with OrgLibraryUserRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("library_staffA", "tBA2", "User with OrgLibraryUserRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("library_staffA", "t2", "User with OrgInstructorRole(orgA) shouldn't see a disabled global taxonomy"), + ( + "course_instructorA", + "tA2", + "User with CourseInstructorRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_instructorA", + "tBA2", + "User with CourseInstructorRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_instructorA", + "t2", + "User with CourseInstructorRole in a course from orgA shouldn't see a disabled global taxonomy" + ), + ( + "course_staffA", + "tA2", + "User with CourseStaffRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_staffA", + "tBA2", + "User with CourseStaffRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_staffA", + "t2", + "User with CourseStaffRole in a course from orgA should't see a disabled global taxonomy" + ), + ( + "library_userA", + "tA2", + "User with permission on a library from orgA shouldn't see an disabled taxonomy from orgA" + ), + ( + "library_userA", + "tBA2", + "User with permission on a library from orgA shouldn't see an disabled taxonomy from orgA" + ), + ( + "library_userA", + "t2", + "User with permission on a library from orgA shouldn't see an disabled global taxonomy" + ), + ) + @ddt.unpack + def test_detail_taxonomy_org_user_dont_see_disabled(self, user_attr: str, taxonomy_attr: str, reason: str) -> None: + """ + Tests that org users (content creators and instructors) can't see disabled global taxonomies and taxonomies + from their orgs + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_404_NOT_FOUND, + reason=reason, + ) + + @ddt.data( + ("staff", "ot1", "Staff should see an enabled no org taxonomy"), + ("staff", "ot2", "Staff should see a disabled no org taxonomy"), + ) + @ddt.unpack + def test_detail_taxonomy_staff_see_no_org(self, user_attr: str, taxonomy_attr: str, reason: str) -> None: + """ + Tests that staff can see taxonomies with no org + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason=reason, + ) + + @ddt.data( + "staffA", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA" + ) + def test_detail_taxonomy_other_dont_see_no_org(self, user_attr: str) -> None: + """ + Tests that org users can't see taxonomies with no org + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="ot1", + expected_status=status.HTTP_404_NOT_FOUND, + reason="Only taxonomy admins should see taxonomies with no org", + ) + + @ddt.data( + "staffA", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA" + ) + def test_detail_taxonomy_dont_see_other_org(self, user_attr: str) -> None: + """ + Tests that org users can't see taxonomies from other orgs + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="tB1", + expected_status=status.HTTP_404_NOT_FOUND, + reason="Users shouldn't see taxonomies from other orgs", + ) + + @ddt.data( + "ot1", + "ot2", + "st1", + "st2", + "t1", + "t2", + "tA1", + "tA2", + "tB1", + "tB2", + "tBA1", + "tBA2", + ) + def test_detail_taxonomy_staff_see_all(self, taxonomy_attr: str) -> None: + """ + Tests that staff can see all taxonomies + """ + self._test_api_call( + user_attr="staff", + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason="Staff should see all taxonomies", + ) + + +@skip_unless_cms +class TestTaxonomyDetailViewSet(TestTaxonomyDetailExportMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with detail action + """ + + def _test_api_call(self, **kwargs) -> None: + """ + Helper function to call the retrieve endpoint and check the response + """ + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.get(url) + assert response.status_code == expected_status, reason + + if status.is_success(expected_status): + request = MagicMock() + request.user = user + context = {"request": request} + check_taxonomy( + response.data, + taxonomy.pk, + **(TaxonomySerializer(taxonomy.cast(), context=context)).data, + ) + + +@skip_unless_cms +class TestTaxonomyExportViewSet(TestTaxonomyDetailExportMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with export action + """ + + def _test_api_call(self, **kwargs) -> None: + """ + Helper function to call the export endpoint and check the response + """ + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.get(url) + assert response.status_code == expected_status, reason + assert len(response.data) > 0 + + +@ddt.ddt +class TestTaxonomyChangeMixin(TestTaxonomyObjectsMixin): + """ + Test cases to be used with update, patch and delete actions + """ + + @abc.abstractmethod + def _test_api_call(self, **_kwargs) -> None: + """ + Helper function to call the update/patch/delete endpoint and check the response + """ + + @ddt.data( + "ot1", + "ot2", + "st1", + "st2", + "t1", + "t2", + "tA1", + "tA2", + "tB1", + "tB2", + "tBA1", + "tBA2", + ) + def test_regular_user_cant_edit_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that regular users can't edit taxonomies + """ + self._test_api_call( + user_attr="user", + taxonomy_attr=taxonomy_attr, + expected_status=[status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND], + reason="Regular users shouldn't be able to edit taxonomies", + ) + + @ddt.data( + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_org_user_cant_edit_org_taxonomies(self, user_attr: str) -> None: + """ + Tests that content creators and instructors from orgA can't edit taxonomies from orgA + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="tA1", + expected_status=[status.HTTP_403_FORBIDDEN], + reason="Content creators and instructors shouldn't be able to edit taxonomies", + ) + + @ddt.data( + "tA1", + "tA2", + "tBA1", + "tBA2", + ) + def test_org_staff_can_edit_org_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that org staff can edit taxonomies from their orgs + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + # Check both status: 200 for update and 204 for delete + expected_status=[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT], + reason="Org staff should be able to edit taxonomies from their orgs", + ) + + @ddt.data( + "tB1", + "tB2", + ) + def test_org_staff_cant_edit_other_org_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that org staff can't edit taxonomies from other orgs + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + expected_status=[status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND], + reason="Org staff shouldn't be able to edit taxonomies from other orgs", + ) + + @ddt.data( + "ot1", + "ot2", + "t1", + "t2", + "tA1", + "tA2", + "tB1", + "tB2", + "tBA1", + "tBA2", + + ) + def test_staff_can_edit_almost_all_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that staff can edit all but system defined taxonomies + """ + self._test_api_call( + user_attr="staff", + taxonomy_attr=taxonomy_attr, + # Check both status: 200 for update and 204 for delete + expected_status=[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT], + reason="Staff should be able to edit all but system defined taxonomies", + ) + + @ddt.data( + "st1", + "st2", + ) + def test_staff_cant_edit_system_defined_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that staff can't edit system defined taxonomies + """ + self._test_api_call( + user_attr="staff", + taxonomy_attr=taxonomy_attr, + # Check both status: 200 for update and 204 for delete + expected_status=[status.HTTP_403_FORBIDDEN], + reason="Staff shouldn't be able to edit system defined ", + ) + + +@skip_unless_cms +class TestTaxonomyUpdateViewSet(TestTaxonomyChangeMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with PUT method + """ + + def _test_api_call(self, **kwargs) -> None: + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.put(url, {"name": "new name"}, format="json") + assert response.status_code in expected_status, reason + + # If we were able to update the taxonomy, check if the name changed + if status.is_success(response.status_code): + response = self.client.get(url) + check_taxonomy( + response.data, + response.data["id"], + **{ + "name": "new name", + "description": taxonomy.description, + "enabled": taxonomy.enabled, + "export_id": taxonomy.export_id, + }, + ) + + +@skip_unless_cms +class TestTaxonomyPatchViewSet(TestTaxonomyChangeMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with PATCH method + """ + + def _test_api_call(self, **kwargs) -> None: + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.patch(url, {"name": "new name"}, format="json") + assert response.status_code in expected_status, reason + + # If we were able to patch the taxonomy, check if the name changed + if status.is_success(response.status_code): + response = self.client.get(url) + check_taxonomy( + response.data, + response.data["id"], + **{ + "name": "new name", + "description": taxonomy.description, + "enabled": taxonomy.enabled, + "export_id": taxonomy.export_id, + }, + ) + + +@skip_unless_cms +class TestTaxonomyDeleteViewSet(TestTaxonomyChangeMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with DELETE method + """ + + def _test_api_call(self, **kwargs) -> None: + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.delete(url) + assert response.status_code in expected_status, reason + + # If we were able to delete the taxonomy, check that it's really gone + if status.is_success(response.status_code): + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@skip_unless_cms +@ddt.ddt +class TestTaxonomyUpdateOrg(TestTaxonomyObjectsMixin, APITestCase): + """ + Test cases for updating orgs from taxonomies + """ + + def test_update_org(self) -> None: + """ + Tests that taxonomy admin can add/remove orgs from a taxonomy + """ + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tA1.pk) + self.client.force_authenticate(user=self.staff) + + response = self.client.put(url, {"orgs": [self.orgB.short_name, self.orgX.short_name]}, format="json") + assert response.status_code == status.HTTP_200_OK + + # Check that the orgs were updated + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + response = self.client.get(url) + assert response.data["orgs"] == [self.orgB.short_name, self.orgX.short_name] + assert not response.data["all_orgs"] + + def test_update_all_org(self) -> None: + """ + Tests that taxonomy admin can associate a taxonomy to all orgs + """ + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tA1.pk) + self.client.force_authenticate(user=self.staff) + + response = self.client.put(url, {"all_orgs": True}, format="json") + assert response.status_code == status.HTTP_200_OK + + # Check that the orgs were updated + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + response = self.client.get(url) + assert response.data["orgs"] == [] + assert response.data["all_orgs"] + + def test_update_no_org(self) -> None: + """ + Tests that taxonomy admin can associate a taxonomy no orgs + """ + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tA1.pk) + self.client.force_authenticate(user=self.staff) + + response = self.client.put(url, {"orgs": []}, format="json") + + assert response.status_code == status.HTTP_200_OK + + # Check that the orgs were updated + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + response = self.client.get(url) + assert response.data["orgs"] == [] + assert not response.data["all_orgs"] + + @ddt.data( + (True, ["orgX"], "Using both all_orgs and orgs parameters should throw error"), + (False, None, "Using neither all_orgs or orgs parameter should throw error"), + (None, None, "Using neither all_orgs or orgs parameter should throw error"), + (False, 'InvalidOrg', "Passing an invalid org should throw error"), + ) + @ddt.unpack + def test_update_org_invalid_inputs(self, all_orgs: bool, orgs: list[str], reason: str) -> None: + """ + Tests if passing both or none of all_orgs and orgs parameters throws error + """ + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tA1.pk) + self.client.force_authenticate(user=self.staff) + + # Set body cleaning empty values + body = {k: v for k, v in {"all_orgs": all_orgs, "orgs": orgs}.items() if v is not None} + response = self.client.put(url, body, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST, reason + + # Check that the orgs didn't change + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + response = self.client.get(url) + assert response.data["orgs"] == [self.orgA.short_name] + + def test_update_org_system_defined(self) -> None: + """ + Tests that is not possible to change the orgs associated with a system defined taxonomy + """ + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.st1.pk) + self.client.force_authenticate(user=self.staff) + + response = self.client.put(url, {"orgs": [self.orgA.short_name]}, format="json") + assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_400_BAD_REQUEST] + + # Check that the orgs didn't change + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.st1.pk) + response = self.client.get(url) + assert response.data["orgs"] == [] + assert response.data["all_orgs"] + + @ddt.data( + "staffA", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_update_org_no_perm(self, user_attr: str) -> None: + """ + Tests that only taxonomy admins can associate orgs to taxonomies + """ + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tA1.pk) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.put(url, {"orgs": []}, format="json") + assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND] + + # Check that the orgs didn't change + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data["orgs"] == [self.orgA.short_name] + + def test_update_org_check_permissions_orgA(self) -> None: + """ + Tests that adding an org to a taxonomy allow org level admins to edit it + """ + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staffA) + + response = self.client.put(url, {"name": "new name"}, format="json") + + # User staffA can't update metadata from a taxonomy from orgB + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staff) + + # Add the taxonomy tB1 to orgA + response = self.client.put(url, {"orgs": [self.orgA.short_name]}, format="json") + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staffA) + + response = self.client.put(url, {"name": "new name"}, format="json") + + # Now staffA can change the metadata from a tB1 because it's associated with orgA + assert response.status_code == status.HTTP_200_OK + + def test_update_org_check_permissions_all_orgs(self) -> None: + """ + Tests that adding an org to all orgs only let taxonomy global admins to edit it + """ + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + self.client.force_authenticate(user=self.staffA) + + response = self.client.put(url, {"name": "new name"}, format="json") + + # User staffA can update metadata from a taxonomy from orgA + assert response.status_code == status.HTTP_200_OK + + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staff) + + # Add the taxonomy tA1 to all orgs + response = self.client.put(url, {"all_orgs": True}, format="json") + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staffA) + + response = self.client.put(url, {"name": "new name"}, format="json") + + # Now staffA can't change the metadata from a tA1 because only global taxonomy admins can edit all orgs + # taxonomies + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_update_org_check_permissions_no_orgs(self) -> None: + """ + Tests that remove all orgs from a taxonomy only let taxonomy global admins to edit it + """ + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tA1.pk) + self.client.force_authenticate(user=self.staffA) + + response = self.client.put(url, {"name": "new name"}, format="json") + + # User staffA can update metadata from a taxonomy from orgA + assert response.status_code == status.HTTP_200_OK + + url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staff) + + # Remove all orgs from tA1 + response = self.client.put(url, {"orgs": []}, format="json") + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.tB1.pk) + self.client.force_authenticate(user=self.staffA) + + response = self.client.put(url, {"name": "new name"}, format="json") + + # Now staffA can't change the metadata from a tA1 because only global taxonomy admins can edit no orgs + # taxonomies + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestObjectTagMixin(TestTaxonomyObjectsMixin): + """ + Sets up data for testing ObjectTags. + """ + def setUp(self): + """ + Setup the test cases + """ + super().setUp() + self.xblockA = BlockUsageLocator( + course_key=self.courseA, + block_type='problem', + block_id='block_id' + ) + self.xblockB = BlockUsageLocator( + course_key=self.courseB, + block_type='problem', + block_id='block_id' + ) + + self.multiple_taxonomy = tagging_api.create_taxonomy( + name="Multiple Taxonomy", + allow_multiple=True, + ) + self.single_value_taxonomy = tagging_api.create_taxonomy( + name="Required Taxonomy", + allow_multiple=False, + ) + for i in range(20): + # Valid ObjectTags + Tag.objects.create(taxonomy=self.tA1, value=f"Tag {i}") + Tag.objects.create(taxonomy=self.tA2, value=f"Tag {i}") + Tag.objects.create(taxonomy=self.multiple_taxonomy, value=f"Tag {i}") + Tag.objects.create(taxonomy=self.single_value_taxonomy, value=f"Tag {i}") + + self.open_taxonomy = tagging_api.create_taxonomy( + name="Enabled Free-Text Taxonomy", + allow_free_text=True, + ) + + # Add org permissions to taxonomy + TaxonomyOrg.objects.create( + taxonomy=self.multiple_taxonomy, + org=self.orgA, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + TaxonomyOrg.objects.create( + taxonomy=self.single_value_taxonomy, + org=self.orgA, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + TaxonomyOrg.objects.create( + taxonomy=self.open_taxonomy, + org=self.orgA, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + add_users(self.staff, CourseStaffRole(self.courseA), self.staffA) + + +@skip_unless_cms +@ddt.ddt +class TestObjectTagViewSet(TestObjectTagMixin, APITestCase): + """ + Testing various cases for the ObjectTagView. + """ + + def _call_put_request(self, object_id, taxonomy_id, tags): + url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id) + return self.client.put(url, {"tagsData": [{ + "taxonomy": taxonomy_id, + "tags": tags, + }]}, format="json") + + @ddt.data( + # staffA and staff are staff in courseA and can tag using enabled taxonomies + ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("user", "tA1", [], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), + ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ) + @ddt.unpack + def test_tag_course(self, user_attr, taxonomy_attr, tag_values, expected_status): + """ + Tests that only staff and org level users can tag courses + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.courseA, taxonomy.pk, tag_values) + + assert response.status_code == expected_status + if status.is_success(expected_status): + tags_by_taxonomy = response.data[str(self.courseA)]["taxonomies"] + if tag_values: + response_taxonomy = tags_by_taxonomy[0] + assert response_taxonomy["name"] == taxonomy.name + response_tags = response_taxonomy["tags"] + assert [t["value"] for t in response_tags] == tag_values + else: + assert tags_by_taxonomy == [] # No tags are set from any taxonomy + + # Check that re-fetching the tags returns what we set + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.courseA) + new_response = self.client.get(url, format="json") + assert status.is_success(new_response.status_code) + assert new_response.data == response.data + + @ddt.data( + "staffA", + "staff", + ) + def test_tag_course_disabled_taxonomy(self, user_attr): + """ + Nobody can use disable taxonomies to tag objects + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + disabled_taxonomy = self.tA2 + assert disabled_taxonomy.enabled is False + + response = self._call_put_request(self.courseA, disabled_taxonomy.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + ("staffA", "tA1"), + ("staff", "tA1"), + ("staffA", "multiple_taxonomy"), + ("staff", "multiple_taxonomy"), + ) + @ddt.unpack + def test_tag_course_invalid(self, user_attr, taxonomy_attr): + """ + Tests that nobody can add invalid tags to a course using a closed taxonomy + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.courseA, taxonomy.pk, ["invalid"]) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @ddt.data( + # staffA and staff are staff in courseA (owner of xblockA) and can tag using any taxonomies + ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ) + @ddt.unpack + def test_tag_xblock(self, user_attr, taxonomy_attr, tag_values, expected_status): + """ + Tests that only staff and org level users can tag xblocks + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.xblockA, taxonomy.pk, tag_values) + + assert response.status_code == expected_status + if status.is_success(expected_status): + tags_by_taxonomy = response.data[str(self.xblockA)]["taxonomies"] + if tag_values: + response_taxonomy = tags_by_taxonomy[0] + assert response_taxonomy["name"] == taxonomy.name + response_tags = response_taxonomy["tags"] + assert [t["value"] for t in response_tags] == tag_values + else: + assert tags_by_taxonomy == [] # No tags are set from any taxonomy + + # Check that re-fetching the tags returns what we set + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.xblockA) + new_response = self.client.get(url, format="json") + assert status.is_success(new_response.status_code) + assert new_response.data == response.data + + @ddt.data( + "staffA", + "staff", + ) + def test_tag_xblock_disabled_taxonomy(self, user_attr): + """ + Tests that nobody can use disabled taxonomies to tag xblocks + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + disabled_taxonomy = self.tA2 + assert disabled_taxonomy.enabled is False + + response = self._call_put_request(self.xblockA, disabled_taxonomy.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + ("staffA", "tA1"), + ("staff", "tA1"), + ("staffA", "multiple_taxonomy"), + ("staff", "multiple_taxonomy"), + ) + @ddt.unpack + def test_tag_xblock_invalid(self, user_attr, taxonomy_attr): + """ + Tests that staff can't add invalid tags to a xblock using a closed taxonomy + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.xblockA, taxonomy.pk, ["invalid"]) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @ddt.data( + # staffA and staff are staff in libraryA and can tag using enabled taxonomies + ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("user", "tA1", [], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), + ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ) + @ddt.unpack + def test_tag_library(self, user_attr, taxonomy_attr, tag_values, expected_status): + """ + Tests that only staff and org level users can tag libraries + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.libraryA, taxonomy.pk, tag_values) + + assert response.status_code == expected_status + if status.is_success(expected_status): + tags_by_taxonomy = response.data[str(self.libraryA)]["taxonomies"] + if tag_values: + response_taxonomy = tags_by_taxonomy[0] + assert response_taxonomy["name"] == taxonomy.name + response_tags = response_taxonomy["tags"] + assert [t["value"] for t in response_tags] == tag_values + else: + assert tags_by_taxonomy == [] # No tags are set from any taxonomy + + # Check that re-fetching the tags returns what we set + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.libraryA) + new_response = self.client.get(url, format="json") + assert status.is_success(new_response.status_code) + assert new_response.data == response.data + + @ddt.data( + "staffA", + "staff", + ) + def test_tag_library_disabled_taxonomy(self, user_attr): + """ + Nobody can use disabled taxonomies to tag objects + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + disabled_taxonomy = self.tA2 + assert disabled_taxonomy.enabled is False + + response = self._call_put_request(self.libraryA, disabled_taxonomy.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + ("staffA", "tA1"), + ("staff", "tA1"), + ("staffA", "multiple_taxonomy"), + ("staff", "multiple_taxonomy"), + ) + @ddt.unpack + def test_tag_library_invalid(self, user_attr, taxonomy_attr): + """ + Tests that nobody can add invalid tags to a library using a closed taxonomy + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.libraryA, taxonomy.pk, ["invalid"]) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @ddt.data( + ("superuser", status.HTTP_200_OK), + ("staff", status.HTTP_403_FORBIDDEN), + ("staffA", status.HTTP_403_FORBIDDEN), + ("staffB", status.HTTP_403_FORBIDDEN), + ) + @ddt.unpack + def test_tag_cross_org(self, user_attr, expected_status): + """ + Tests that only superusers may add a taxonomy from orgA to an object from orgB + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self._call_put_request(self.courseB, self.tA1.pk, ["Tag 1"]) + + assert response.status_code == expected_status + + @ddt.data( + ("superuser", status.HTTP_200_OK), + ("staff", status.HTTP_403_FORBIDDEN), + ("staffA", status.HTTP_403_FORBIDDEN), + ("staffB", status.HTTP_403_FORBIDDEN), + ) + @ddt.unpack + def test_tag_no_org(self, user_attr, expected_status): + """ + Tests that only superusers may add a no-org taxonomy to an object + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self._call_put_request(self.courseA, self.ot1.pk, []) + + assert response.status_code == expected_status + + @ddt.data( + "courseB", + "xblockB", + ) + def test_tag_no_permission(self, objectid_attr): + """ + Test that a user without access to courseB can't apply tags to it + """ + self.client.force_authenticate(user=self.staffA) + object_id = getattr(self, objectid_attr) + + response = self._call_put_request(object_id, self.tA1.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + "courseB", + "xblockB", + ) + def test_tag_unauthorized(self, objectid_attr): + """ + Test that a user without access to courseB can't apply tags to it + """ + object_id = getattr(self, objectid_attr) + + response = self._call_put_request(object_id, self.tA1.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_tag_invalid_object(self): + """ + Test that we cannot tag an object that is not a CouseKey, LibraryLocatorV2 or UsageKey + """ + self.client.force_authenticate(user=self.staff) + + response = self._call_put_request('invalid_key', self.tA1.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_get_tags(self): + """ + Test that we can get tags for an object + """ + self.client.force_authenticate(user=self.staffA) + taxonomy = self.multiple_taxonomy + tag_values = ["Tag 1", "Tag 2"] + + # Tag an object + response1 = self._call_put_request(self.courseA, taxonomy.pk, tag_values) + assert status.is_success(response1.status_code) + + # Fetch this object's tags for a single taxonomy + expected_tags = [{ + 'name': 'Multiple Taxonomy', + 'export_id': '13-multiple-taxonomy', + 'taxonomy_id': taxonomy.pk, + 'can_tag_object': True, + 'tags': [ + {'value': 'Tag 1', 'lineage': ['Tag 1'], 'can_delete_objecttag': True}, + {'value': 'Tag 2', 'lineage': ['Tag 2'], 'can_delete_objecttag': True}, + ], + }] + + # Fetch tags for a single taxonomy + get_url = OBJECT_TAGS_URL.format(object_id=self.courseA) + get_url += f"?taxonomy={taxonomy.pk}" + response2 = self.client.get(get_url, format="json") + assert status.is_success(response2.status_code) + assert response2.data[str(self.courseA)]["taxonomies"] == expected_tags + + # Fetch all of this object's tags, for all taxonomies + get_all_url = OBJECT_TAGS_URL.format(object_id=self.courseA) + response3 = self.client.get(get_all_url, format="json") + assert status.is_success(response3.status_code) + assert response3.data[str(self.courseA)]["taxonomies"] == expected_tags + + @ddt.data( + ('staff', 'courseA', 8), + ('staff', 'libraryA', 8), + ("content_creatorA", 'courseA', 11, False), + ("content_creatorA", 'libraryA', 11, False), + ("library_staffA", 'libraryA', 11, False), # Library users can only view objecttags, not change them? + ("library_userA", 'libraryA', 11, False), + ("instructorA", 'courseA', 11), + ("course_instructorA", 'courseA', 11), + ("course_staffA", 'courseA', 11), + ) + @ddt.unpack + def test_object_tags_query_count( + self, + user_attr: str, + object_attr: str, + expected_queries: int, + expected_perm: bool = True): + """ + Test how many queries are used when retrieving object tags and permissions + """ + object_key = getattr(self, object_attr) + object_id = str(object_key) + tagging_api.tag_object(object_id=object_id, taxonomy=self.t1, tags=["anvil", "android"]) + expected_tags = [ + {"value": "android", "lineage": ["ALPHABET", "android"], "can_delete_objecttag": expected_perm}, + {"value": "anvil", "lineage": ["ALPHABET", "anvil"], "can_delete_objecttag": expected_perm}, + ] + url = OBJECT_TAGS_URL.format(object_id=object_id) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + with self.assertNumQueries(expected_queries): + response = self.client.get(url) + + assert response.status_code == 200 + assert len(response.data[object_id]["taxonomies"]) == 1 + assert response.data[object_id]["taxonomies"][0]["can_tag_object"] == expected_perm + assert response.data[object_id]["taxonomies"][0]["tags"] == expected_tags + + +@skip_unless_cms +@ddt.ddt +class TestContentObjectChildrenExportView(TaggedCourseMixin, APITestCase): # type: ignore[misc] + """ + Tests exporting course children with tags + """ + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.staff = UserFactory.create( + username="staff", + email="staff@example.com", + is_staff=True, + ) + + self.staffA = UserFactory.create( + username="staffA", + email="userA@example.com", + ) + update_org_role(self.staff, OrgStaffRole, self.staffA, [self.orgA.short_name]) + + @ddt.data( + "staff", + "staffA", + ) + def test_export_course(self, user_attr) -> None: + url = OBJECT_TAGS_EXPORT_URL.format(object_id=str(self.course.id)) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.headers['Content-Type'] == 'text/csv' + + zip_content = BytesIO(b"".join(response.streaming_content)).getvalue() # type: ignore[attr-defined] + assert zip_content == self.expected_csv.encode() + + def test_export_course_anoymous_forbidden(self) -> None: + url = OBJECT_TAGS_EXPORT_URL.format(object_id=str(self.course.id)) + response = self.client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_export_course_user_forbidden(self) -> None: + url = OBJECT_TAGS_EXPORT_URL.format(object_id=str(self.course.id)) + self.client.force_authenticate(user=self.user) + response = self.client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_export_course_invalid_id(self) -> None: + url = OBJECT_TAGS_EXPORT_URL.format(object_id="invalid") + self.client.force_authenticate(user=self.staff) + response = self.client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@skip_unless_cms +@ddt.ddt +class TestDownloadTemplateView(APITestCase): + """ + Tests the taxonomy template downloads. + """ + @ddt.data( + ("template.csv", "text/csv"), + ("template.json", "application/json"), + ) + @ddt.unpack + def test_download(self, filename, content_type) -> None: + url = TAXONOMY_TEMPLATE_URL.format(filename=filename) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.headers['Content-Type'] == content_type + assert response.headers['Content-Disposition'] == f'attachment; filename="{filename}"' + assert int(response.headers['Content-Length']) > 0 + + def test_download_not_found(self) -> None: + url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt") + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_download_method_not_allowed(self) -> None: + url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt") + response = self.client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +class ImportTaxonomyMixin(TestTaxonomyObjectsMixin): + """ + Mixin to test importing taxonomies. + """ + def _get_file(self, tags: list, file_format: str) -> SimpleUploadedFile: + """ + Returns a file for the given format. + """ + if file_format == "csv": + csv_data = "id,value" + for tag in tags: + csv_data += f"\n{tag['id']},{tag['value']}" + return SimpleUploadedFile("taxonomy.csv", csv_data.encode(), content_type="text/csv") + else: # json + json_data = {"tags": tags} + return SimpleUploadedFile("taxonomy.json", json.dumps(json_data).encode(), content_type="application/json") + + +@skip_unless_cms +@ddt.ddt +class TestCreateImportView(ImportTaxonomyMixin, APITestCase): + """ + Tests the create/import taxonomy action. + """ + @ddt.data( + "csv", + "json", + ) + def test_import_global_admin(self, file_format: str) -> None: + """ + Tests importing a valid taxonomy file with a global admin. + """ + url = TAXONOMY_CREATE_IMPORT_URL + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + {"id": "tag_2", "value": "Tag 2"}, + {"id": "tag_3", "value": "Tag 3"}, + {"id": "tag_4", "value": "Tag 4"}, + ] + file = self._get_file(new_tags, file_format) + + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_201_CREATED + + # Check if the taxonomy was created + taxonomy = response.data + assert taxonomy["name"] == "Imported Taxonomy name" + assert taxonomy["description"] == "Imported Taxonomy description" + assert taxonomy["export_id"] == "imported_taxonomy" + + # Check if the tags were created + url = TAXONOMY_TAGS_URL.format(pk=taxonomy["id"]) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(new_tags) + for i, tag in enumerate(tags): + assert tag["value"] == new_tags[i]["value"] + + # Check if the taxonomy was no association with orgs + assert len(taxonomy["orgs"]) == 0 + + @ddt.data( + "csv", + "json", + ) + def test_import_orgA_admin(self, file_format: str) -> None: + """ + Tests importing a valid taxonomy file with a orgA admin. + """ + url = TAXONOMY_CREATE_IMPORT_URL + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + {"id": "tag_2", "value": "Tag 2"}, + {"id": "tag_3", "value": "Tag 3"}, + {"id": "tag_4", "value": "Tag 4"}, + ] + file = self._get_file(new_tags, file_format) + + self.client.force_authenticate(user=self.staffA) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_201_CREATED + + # Check if the taxonomy was created + taxonomy = response.data + assert taxonomy["name"] == "Imported Taxonomy name" + assert taxonomy["description"] == "Imported Taxonomy description" + assert taxonomy["export_id"] == "imported_taxonomy" + + # Check if the tags were created + url = TAXONOMY_TAGS_URL.format(pk=taxonomy["id"]) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(new_tags) + for i, tag in enumerate(tags): + assert tag["value"] == new_tags[i]["value"] + + # Check if the taxonomy was associated with the orgA + assert len(taxonomy["orgs"]) == 1 + assert taxonomy["orgs"][0] == self.orgA.short_name + + def test_import_no_file(self) -> None: + """ + Tests importing a taxonomy without a file. + """ + url = TAXONOMY_CREATE_IMPORT_URL + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + }, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["file"][0] == "No file was submitted." + + # Check if the taxonomy was not created + assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + + @ddt.data( + "csv", + "json", + ) + def test_import_no_name(self, file_format) -> None: + """ + Tests importing a taxonomy without specifing a name. + """ + url = TAXONOMY_CREATE_IMPORT_URL + file = SimpleUploadedFile(f"taxonomy.{file_format}", b"invalid file content") + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["taxonomy_name"][0] == "This field is required." + + # Check if the taxonomy was not created + assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + + @ddt.data( + "csv", + "json", + ) + def test_import_no_export_id(self, file_format) -> None: + url = TAXONOMY_CREATE_IMPORT_URL + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + ] + file = self._get_file(new_tags, file_format) + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy", + "taxonomy_description": "Imported Taxonomy description", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_201_CREATED + + taxonomy = response.data + taxonomy_id = taxonomy["id"] + assert taxonomy["name"] == "Imported Taxonomy" + assert taxonomy["description"] == "Imported Taxonomy description" + assert taxonomy["export_id"] == f"{taxonomy_id}-imported-taxonomy" + + # Check if the taxonomy was not created + assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + + def test_import_invalid_format(self) -> None: + """ + Tests importing a taxonomy with an invalid file format. + """ + url = TAXONOMY_CREATE_IMPORT_URL + file = SimpleUploadedFile("taxonomy.invalid", b"invalid file content") + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy_id", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["file"][0] == "File type not supported: invalid" + + # Check if the taxonomy was not created + assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + + @ddt.data( + "csv", + "json", + ) + def test_import_invalid_content(self, file_format) -> None: + """ + Tests importing a taxonomy with an invalid file content. + """ + url = TAXONOMY_CREATE_IMPORT_URL + file = SimpleUploadedFile(f"taxonomy.{file_format}", b"invalid file content") + self.client.force_authenticate(user=self.staff) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert f"Invalid '.{file_format}' format:" in response.data + + # Check if the taxonomy was not created + assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + + def test_import_no_perm(self) -> None: + """ + Tests importing a taxonomy using a user without permission. + """ + url = TAXONOMY_CREATE_IMPORT_URL + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + {"id": "tag_2", "value": "Tag 2"}, + {"id": "tag_3", "value": "Tag 3"}, + {"id": "tag_4", "value": "Tag 4"}, + ] + file = self._get_file(new_tags, "json") + + self.client.force_authenticate(user=self.user) + response = self.client.post( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Check if the taxonomy was not created + assert not Taxonomy.objects.filter(name="Imported Taxonomy name").exists() + + +@skip_unless_cms +@ddt.ddt +class TestImportTagsView(ImportTaxonomyMixin, APITestCase): + """ + Tests the taxonomy import tags action. + """ + def setUp(self): + ImportTaxonomyMixin.setUp(self) + + self.taxonomy = tagging_api.create_taxonomy( + name="Test import taxonomy", + ) + tag_1 = Tag.objects.create( + taxonomy=self.taxonomy, + external_id="old_tag_1", + value="Old tag 1", + ) + tag_2 = Tag.objects.create( + taxonomy=self.taxonomy, + external_id="old_tag_2", + value="Old tag 2", + ) + self.old_tags = [tag_1, tag_2] + + @ddt.data( + "csv", + "json", + ) + def test_import(self, file_format: str) -> None: + """ + Tests importing a valid taxonomy file. + """ + url = TAXONOMY_TAGS_IMPORT_URL.format(pk=self.taxonomy.id) + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + {"id": "tag_2", "value": "Tag 2"}, + {"id": "tag_3", "value": "Tag 3"}, + {"id": "tag_4", "value": "Tag 4"}, + ] + file = self._get_file(new_tags, file_format) + + self.client.force_authenticate(user=self.staff) + response = self.client.put( + url, + {"file": file}, + format="multipart" + ) + assert response.status_code == status.HTTP_200_OK + + # Check if the tags were created + url = TAXONOMY_TAGS_URL.format(pk=self.taxonomy.id) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(new_tags) + for i, tag in enumerate(tags): + assert tag["value"] == new_tags[i]["value"] + + def test_import_no_file(self) -> None: + """ + Tests importing a taxonomy without a file. + """ + url = TAXONOMY_TAGS_IMPORT_URL.format(pk=self.taxonomy.id) + self.client.force_authenticate(user=self.staff) + response = self.client.put( + url, + {}, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["file"][0] == "No file was submitted." + + # Check if the taxonomy was not changed + url = TAXONOMY_TAGS_URL.format(pk=self.taxonomy.id) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(self.old_tags) + for i, tag in enumerate(tags): + assert tag["value"] == self.old_tags[i].value + + def test_import_invalid_format(self) -> None: + """ + Tests importing a taxonomy with an invalid file format. + """ + url = TAXONOMY_TAGS_IMPORT_URL.format(pk=self.taxonomy.id) + file = SimpleUploadedFile("taxonomy.invalid", b"invalid file content") + self.client.force_authenticate(user=self.staff) + response = self.client.put( + url, + {"file": file}, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["file"][0] == "File type not supported: invalid" + + # Check if the taxonomy was not changed + url = TAXONOMY_TAGS_URL.format(pk=self.taxonomy.id) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(self.old_tags) + for i, tag in enumerate(tags): + assert tag["value"] == self.old_tags[i].value + + @ddt.data( + "csv", + "json", + ) + def test_import_invalid_content(self, file_format) -> None: + """ + Tests importing a taxonomy with an invalid file content. + """ + url = TAXONOMY_TAGS_IMPORT_URL.format(pk=self.taxonomy.id) + file = SimpleUploadedFile(f"taxonomy.{file_format}", b"invalid file content") + self.client.force_authenticate(user=self.staff) + response = self.client.put( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomy_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert f"Invalid '.{file_format}' format:" in response.data + + # Check if the taxonomy was not changed + url = TAXONOMY_TAGS_URL.format(pk=self.taxonomy.id) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(self.old_tags) + for i, tag in enumerate(tags): + assert tag["value"] == self.old_tags[i].value + + @ddt.data( + "csv", + "json", + ) + def test_import_free_text(self, file_format) -> None: + """ + Tests importing a taxonomy with an invalid file content. + """ + self.taxonomy.allow_free_text = True + self.taxonomy.save() + url = TAXONOMY_TAGS_IMPORT_URL.format(pk=self.taxonomy.id) + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + {"id": "tag_2", "value": "Tag 2"}, + {"id": "tag_3", "value": "Tag 3"}, + {"id": "tag_4", "value": "Tag 4"}, + ] + file = self._get_file(new_tags, file_format) + + self.client.force_authenticate(user=self.staff) + response = self.client.put( + url, + {"file": file}, + format="multipart" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data == f"Invalid taxonomy ({self.taxonomy.id}): You cannot import a free-form taxonomy." + + # Check if the taxonomy has no tags, since it is free text + url = TAXONOMY_TAGS_URL.format(pk=self.taxonomy.id) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == 0 + + def test_import_no_perm(self) -> None: + """ + Tests importing a taxonomy using a user without permission. + """ + url = TAXONOMY_TAGS_IMPORT_URL.format(pk=self.taxonomy.id) + new_tags = [ + {"id": "tag_1", "value": "Tag 1"}, + {"id": "tag_2", "value": "Tag 2"}, + {"id": "tag_3", "value": "Tag 3"}, + {"id": "tag_4", "value": "Tag 4"}, + ] + file = self._get_file(new_tags, "json") + + self.client.force_authenticate(user=self.user) + response = self.client.put( + url, + { + "taxonomy_name": "Imported Taxonomy name", + "taxonomy_description": "Imported Taxonomy description", + "taxonomt_export_id": "imported_taxonomy", + "file": file, + }, + format="multipart" + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Check if the taxonomy was not changed + url = TAXONOMY_TAGS_URL.format(pk=self.taxonomy.id) + self.client.force_authenticate(user=self.staff) + response = self.client.get(url) + tags = response.data["results"] + assert len(tags) == len(self.old_tags) + for i, tag in enumerate(tags): + assert tag["value"] == self.old_tags[i].value + + +@skip_unless_cms +@ddt.ddt +class TestTaxonomyTagsViewSet(TestTaxonomyObjectsMixin, APITestCase): + """ + Test cases for TaxonomyTagsViewSet retrive action. + """ + @ddt.data( + ('staff', 11), + ("content_creatorA", 13), + ("library_staffA", 13), + ("library_userA", 13), + ("instructorA", 13), + ("course_instructorA", 13), + ("course_staffA", 13), + ) + @ddt.unpack + def test_taxonomy_tags_query_count(self, user_attr: str, expected_queries: int): + """ + Test how many queries are used when retrieving small taxonomies+tags and permissions + """ + url = f"{TAXONOMY_TAGS_URL}?search_term=an&parent_tag=ALPHABET".format(pk=self.t1.id) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + with self.assertNumQueries(expected_queries): + response = self.client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["can_add_tag"] == user.is_staff + assert len(response.data["results"]) == 2 + for taxonomy in response.data["results"]: + assert taxonomy["can_change_tag"] == user.is_staff + assert taxonomy["can_delete_tag"] == user.is_staff diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py new file mode 100644 index 000000000000..50fec093c1fb --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py @@ -0,0 +1,34 @@ +""" +Taxonomies API v1 URLs. +""" + +from django.urls.conf import include, path +from openedx_tagging.core.tagging.rest_api.v1 import views as oel_tagging_views +from openedx_tagging.core.tagging.rest_api.v1 import views_import as oel_tagging_views_import +from openedx_tagging.core.tagging.rest_api.v1.views import ObjectTagCountsView +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register("taxonomies", views.TaxonomyOrgView, basename="taxonomy") +router.register("object_tags", views.ObjectTagOrgView, basename="object_tag") +router.register("object_tag_counts", ObjectTagCountsView, basename="object_tag_counts") + +urlpatterns = [ + path( + "taxonomies//tags/", + oel_tagging_views.TaxonomyTagsView.as_view(), + name="taxonomy-tags", + ), + path( + "taxonomies/import/template.", + oel_tagging_views_import.TemplateView.as_view(), + name="taxonomy-import-template", + ), + path( + "object_tags//export/", + views.ObjectTagExportView.as_view(), + ), + path('', include(router.urls)) +] diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py new file mode 100644 index 000000000000..71b210b9e561 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -0,0 +1,198 @@ +""" +Tagging Org API Views +""" +from __future__ import annotations + +from django.db.models import Count +from django.http import StreamingHttpResponse +from openedx_tagging.core.tagging import rules as oel_tagging_rules +from openedx_tagging.core.tagging.rest_api.v1.views import ObjectTagView, TaxonomyView +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from openedx_events.content_authoring.data import ContentObjectData +from openedx_events.content_authoring.signals import CONTENT_OBJECT_TAGS_CHANGED + +from ...auth import has_view_object_tags_access +from ...api import ( + create_taxonomy, + generate_csv_rows, + get_taxonomies, + get_taxonomies_for_org, + get_taxonomy, + get_unassigned_taxonomies, + set_taxonomy_orgs +) +from ...rules import get_admin_orgs +from .filters import ObjectTagTaxonomyOrgFilterBackend, UserOrgFilterBackend +from .serializers import TaxonomyOrgListQueryParamsSerializer, TaxonomyOrgSerializer, TaxonomyUpdateOrgBodySerializer + + +class TaxonomyOrgView(TaxonomyView): + """ + View to list, create, retrieve, update, delete, export or import Taxonomies. + This view extends the TaxonomyView to add Organization filters. + + Refer to TaxonomyView docstring for usage details. + + **Additional List Query Parameters** + * org (optional) - Filter by organization. + + **List Example Requests** + GET api/content_tagging/v1/taxonomies?org=orgA - Get all taxonomies for organization A + GET api/content_tagging/v1/taxonomies?org=orgA&enabled=true - Get all enabled taxonomies for organization A + + **List Query Returns** + * 200 - Success + * 400 - Invalid query parameter + * 403 - Permission denied + """ + + filter_backends = [UserOrgFilterBackend] + serializer_class = TaxonomyOrgSerializer + + def get_queryset(self): + """ + Return a list of taxonomies. + + Returns all taxonomies by default. + If you want the disabled taxonomies, pass enabled=False. + If you want the enabled taxonomies, pass enabled=True. + """ + query_params = TaxonomyOrgListQueryParamsSerializer(data=self.request.query_params.dict()) + query_params.is_valid(raise_exception=True) + enabled = query_params.validated_data.get("enabled", None) + org = query_params.validated_data.get("org", None) + + # If org filtering was requested, then use it, even if the org is invalid/None + if "org" in query_params.validated_data: + queryset = get_taxonomies_for_org(enabled, org) + elif "unassigned" in query_params.validated_data: + queryset = get_unassigned_taxonomies(enabled) + else: + queryset = get_taxonomies(enabled) + + # Prefetch taxonomyorgs so we can check permissions + queryset = queryset.prefetch_related("taxonomyorg_set__org") + + # Annotate with tags_count to avoid selecting all the tags + queryset = queryset.annotate(tags_count=Count("tag", distinct=True)) + + return queryset + + def perform_create(self, serializer): + """ + Create a new taxonomy. + """ + user_admin_orgs = get_admin_orgs(self.request.user) + serializer.instance = create_taxonomy(**serializer.validated_data, orgs=user_admin_orgs) + + @action(detail=False, url_path="import", methods=["post"]) + def create_import(self, request: Request, **kwargs) -> Response: # type: ignore + """ + Creates a new taxonomy with the given orgs and imports the tags from the uploaded file. + """ + response = super().create_import(request=request, **kwargs) # type: ignore + + # If creation was successful, set the orgs for the new taxonomy + if status.is_success(response.status_code): + # ToDo: This code is temporary + # In the future, the orgs parameter will be defined in the request body from the frontend + # See: https://github.com/openedx/modular-learning/issues/116 + if oel_tagging_rules.is_taxonomy_admin(request.user): + orgs = None + else: + orgs = get_admin_orgs(request.user) + + taxonomy = get_taxonomy(response.data["id"]) + assert taxonomy + set_taxonomy_orgs(taxonomy, all_orgs=False, orgs=orgs) + + serializer = self.get_serializer(taxonomy) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return response + + @action(detail=True, methods=["put"]) + def orgs(self, request, **_kwargs) -> Response: + """ + Update the orgs associated with taxonomies. + """ + taxonomy = self.get_object() + perm = "oel_tagging.update_orgs" + if not request.user.has_perm(perm, taxonomy): + raise PermissionDenied("You do not have permission to update the orgs associated with this taxonomy.") + body = TaxonomyUpdateOrgBodySerializer( + data=request.data, + ) + body.is_valid(raise_exception=True) + orgs = body.validated_data.get("orgs") + all_orgs: bool = body.validated_data.get("all_orgs", False) + + set_taxonomy_orgs(taxonomy=taxonomy, all_orgs=all_orgs, orgs=orgs) + + return Response() + + +class ObjectTagOrgView(ObjectTagView): + """ + View to create and retrieve ObjectTags for a provided Object ID (object_id). + This view extends the ObjectTagView to add Organization filters for the results, + and fires events when the tags are updated. + + Refer to ObjectTagView docstring for usage details. + """ + filter_backends = [ObjectTagTaxonomyOrgFilterBackend] + + def update(self, request, *args, **kwargs) -> Response: + """ + Extend the update method to fire CONTENT_OBJECT_TAGS_CHANGED event + """ + response = super().update(request, *args, **kwargs) + if response.status_code == 200: + object_id = kwargs.get('object_id') + CONTENT_OBJECT_TAGS_CHANGED.send_event( + content_object=ContentObjectData(object_id=object_id) + ) + return response + + +class ObjectTagExportView(APIView): + """" + View to export a CSV with all children and tags for a given course/context. + """ + def get(self, request: Request, **kwargs) -> StreamingHttpResponse: + """ + Export a CSV with all children and tags for a given course/context. + """ + + class Echo(object): + """ + Class that implements just the write method of the file-like interface, + used for the streaming response. + """ + def write(self, value): + return value + + object_id: str = kwargs.get('context_id', None) + pseudo_buffer = Echo() + + if not has_view_object_tags_access(self.request.user, object_id): + raise PermissionDenied( + "You do not have permission to view object tags for this object_id." + ) + + try: + return StreamingHttpResponse( + streaming_content=generate_csv_rows( + object_id, + pseudo_buffer, + ), + content_type="text/csv", + headers={'Content-Disposition': f'attachment; filename="{object_id}_tags.csv"'}, + ) + except ValueError as e: + raise ValidationError from e diff --git a/openedx/core/djangoapps/content_tagging/rules.py b/openedx/core/djangoapps/content_tagging/rules.py new file mode 100644 index 000000000000..265194860159 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/rules.py @@ -0,0 +1,338 @@ +"""Django rules-based permissions for tagging""" + +from __future__ import annotations + +from typing import Union + +import django.contrib.auth.models +import openedx_tagging.core.tagging.rules as oel_tagging +import rules +from organizations.models import Organization + +from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from common.djangoapps.student.role_helpers import get_course_roles, get_role_cache +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + OrgContentCreatorRole, + OrgInstructorRole, + OrgLibraryUserRole, + OrgStaffRole +) + +from .models import TaxonomyOrg +from .utils import check_taxonomy_context_key_org, get_context_key_from_key_string, rules_cache + + +UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser] + + +def is_org_admin(user: UserType, orgs: list[Organization] | None = None) -> bool: + """ + Return True if the given user is an admin for any of the given orgs. + """ + return len(get_admin_orgs(user, orgs)) > 0 + + +def is_org_user(user: UserType, orgs: list[Organization]) -> bool: + """ + Return True if the given user is a member of any of the given orgs. + """ + return len(get_user_orgs(user, orgs)) > 0 + + +def get_admin_orgs(user: UserType, orgs: list[Organization] | None = None) -> list[Organization]: + """ + Returns a list of orgs that the given user is an org-level staff, from the given list of orgs. + + If no orgs are provided, check all orgs + """ + org_list = rules_cache.get_orgs() if orgs is None else orgs + return [ + org for org in org_list if OrgStaffRole(org=org.short_name).has_user(user) + ] + + +def _get_content_creator_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Returns a list of orgs that the given user is an org-level library user or instructor, from the given list of orgs. + """ + return [ + org for org in orgs if ( + OrgLibraryUserRole(org=org.short_name).has_user(user) or + OrgInstructorRole(org=org.short_name).has_user(user) or + OrgContentCreatorRole(org=org.short_name).has_user(user) + ) + ] + + +def _get_course_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Returns a list of orgs for courses where the given user is staff or instructor, from the given list of orgs. + + Note: The user does not have org-level access to these orgs, only course-level access. So when checking ObjectTag + permissions, ensure that the user has staff/instructor access to the course/library with that object_id. + """ + if not orgs: + return [] + + def user_has_role_ignore_course_id(user, role_name, org_name) -> bool: + """ + Returns True if the given user has the given role for the given org, OR for any courses in this org. + """ + # We use the user's RoleCache here to avoid re-querying. + roles_cache = get_role_cache(user) + course_roles = get_course_roles(user) + return any( + access_role.role in roles_cache.get_roles(role_name) and + access_role.org == org_name + for access_role in course_roles + ) + + return [ + org for org in orgs if ( + user_has_role_ignore_course_id(user, CourseStaffRole.ROLE, org.short_name) or + user_has_role_ignore_course_id(user, CourseInstructorRole.ROLE, org.short_name) + ) + ] + + +def _get_library_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Returns a list of orgs (from the given list of orgs) that are associated with libraries that the given user has + explicitly been granted access to. + + Note: If no libraries exist for the given orgs, then no orgs will be returned, even though the user may be permitted + to access future libraries created in these orgs. + Nor does this mean the user may access all libraries in this org: library permissions are granted per library. + """ + library_orgs = rules_cache.get_library_orgs(user, [org.short_name for org in orgs]) + return list(set(library_orgs).intersection(orgs)) + + +def get_user_orgs(user: UserType, orgs: list[Organization] | None = None) -> list[Organization]: + """ + Return a list of orgs that the given user is a member of (instructor or content creator), + from the given list of orgs. + """ + org_list = rules_cache.get_orgs() if orgs is None else orgs + content_creator_orgs = _get_content_creator_orgs(user, org_list) + course_user_orgs = _get_course_user_orgs(user, org_list) + library_user_orgs = _get_library_user_orgs(user, org_list) + user_orgs = list(set(content_creator_orgs) | set(course_user_orgs) | set(library_user_orgs)) + + return user_orgs + + +@rules.predicate +def can_create_taxonomy(user: UserType) -> bool: + """ + Returns True if the given user can create a taxonomy. + + Taxonomy admins and org-level staff can create taxonomies. + """ + # Taxonomy admins can view any taxonomy + if oel_tagging.is_taxonomy_admin(user): + return True + + # Org-level staff can create taxonomies associated with one of their orgs. + if is_org_admin(user): + return True + + return False + + +@rules.predicate +def can_view_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: + """ + Returns True if the given user can view the given taxonomy. + + Taxonomy admins can view any taxonomy. + Org-level staff can view any taxonomy that is associated with one of their orgs. + Org-level course creators and instructors can view any enabled taxonomy that is owned by one of their orgs. + """ + # The following code allows METHOD permission (GET) in the viewset for everyone + if not taxonomy: + return True + + taxonomy = taxonomy.cast() + + # Taxonomy admins can view any taxonomy + if oel_tagging.is_taxonomy_admin(user): + return True + + is_all_org, taxonomy_orgs = TaxonomyOrg.get_organizations(taxonomy) + + # Enabled all-org taxonomies can be viewed by any registered user + if is_all_org: + return taxonomy.enabled + + # Org-level staff can view any taxonomy that is associated with one of their orgs. + if is_org_admin(user, taxonomy_orgs): + return True + + # Org-level course creators and instructors can view any enabled taxonomy that is owned by one of their orgs. + if is_org_user(user, taxonomy_orgs): + return taxonomy.enabled + + return False + + +@rules.predicate +def can_change_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: + """ + Returns True if the given user can edit the given taxonomy. + + System definied taxonomies cannot be edited + Taxonomy admins can edit any non system defined taxonomies + Only taxonomy admins can edit all org taxonomies + Org-level staff can edit any taxonomy that is associated with one of their orgs. + """ + # The following code allows METHOD permission (PUT, PATCH) in the viewset for everyone + if not taxonomy: + return True + + taxonomy = taxonomy.cast() + + # System definied taxonomies cannot be edited + if taxonomy.system_defined: + return False + + # Taxonomy admins can edit any non system defined taxonomies + if oel_tagging.is_taxonomy_admin(user): + return True + + is_all_org, taxonomy_orgs = TaxonomyOrg.get_organizations(taxonomy) + + # Only taxonomy admins can edit all org taxonomies + if is_all_org: + return False + + # Org-level staff can edit any taxonomy that is associated with one of their orgs. + if is_org_admin(user, taxonomy_orgs): + return True + + return False + + +@rules.predicate +def can_change_object_tag_objectid(user: UserType, object_id: str) -> bool: + """ + Everyone that has permission to edit the object should be able to tag it. + """ + if not object_id: + return True + + try: + context_key = get_context_key_from_key_string(object_id) + assert context_key.org + except (ValueError, AssertionError): + return False + + if has_studio_write_access(user, context_key): + return True + + object_org = rules_cache.get_orgs([context_key.org]) + return bool(object_org) and is_org_admin(user, object_org) + + +@rules.predicate +def can_view_object_tag_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: + """ + Only enabled taxonomy and users with permission to view this taxonomy can view object tags + from that taxonomy. + + This rule is different from can_view_taxonomy because it checks if the taxonomy is enabled. + """ + # Note: in the REST API, where we're dealing with multiple taxonomies at once, permissions + # are also enforced by ObjectTagTaxonomyOrgFilterBackend. + return not taxonomy or (taxonomy.cast().enabled and can_view_taxonomy(user, taxonomy)) + + +@rules.predicate +def can_view_object_tag_objectid(user: UserType, object_id: str) -> bool: + """ + Everyone that has permission to view the object should be able to view its tags. + """ + if not object_id: + raise ValueError("object_id must be provided") + + if not user.is_authenticated: + return False + + try: + context_key = get_context_key_from_key_string(object_id) + assert context_key.org + except (ValueError, AssertionError): + return False + + if has_studio_read_access(user, context_key): + return True + + object_org = rules_cache.get_orgs([context_key.org]) + return bool(object_org) and (is_org_admin(user, object_org) or is_org_user(user, object_org)) + + +@rules.predicate +def can_change_object_tag( + user: UserType, perm_obj: oel_tagging.ObjectTagPermissionItem | None = None +) -> bool: + """ + Returns True if the given user may change object tags with the given taxonomy + object_id. + + Adds additional checks to ensure the taxonomy is available for use with the object_id's org. + """ + if oel_tagging.can_change_object_tag(user, perm_obj): + if perm_obj and perm_obj.taxonomy and perm_obj.object_id: + try: + context_key = get_context_key_from_key_string(perm_obj.object_id) + except ValueError: + return False # pragma: no cover + + return check_taxonomy_context_key_org(perm_obj.taxonomy, context_key) + + return True + return False + + +@rules.predicate +def can_change_taxonomy_tag(user: UserType, tag: oel_tagging.Tag | None = None) -> bool: + """ + Even taxonomy admins cannot add tags to system taxonomies (their tags are system-defined), or free-text taxonomies + (these don't have predefined tags). + """ + taxonomy = tag.taxonomy if tag else None + if taxonomy: + taxonomy = taxonomy.cast() + return oel_tagging.is_taxonomy_admin(user) and ( + not tag + or not taxonomy + or (bool(taxonomy) and not taxonomy.allow_free_text and not taxonomy.system_defined) + ) + + +# Taxonomy +rules.set_perm("oel_tagging.add_taxonomy", can_create_taxonomy) +rules.set_perm("oel_tagging.change_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.delete_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.view_taxonomy", can_view_taxonomy) +rules.add_perm("oel_tagging.update_orgs", oel_tagging.is_taxonomy_admin) + +# Tag +rules.set_perm("oel_tagging.add_tag", can_change_taxonomy_tag) +rules.set_perm("oel_tagging.change_tag", can_change_taxonomy_tag) +rules.set_perm("oel_tagging.delete_tag", can_change_taxonomy_tag) +rules.set_perm("oel_tagging.view_tag", rules.always_allow) + +# ObjectTag +rules.set_perm("oel_tagging.add_objecttag", can_change_object_tag) +rules.set_perm("oel_tagging.change_objecttag", can_change_object_tag) +rules.set_perm("oel_tagging.delete_objecttag", can_change_object_tag) +rules.set_perm("oel_tagging.can_tag_object", can_change_object_tag) + +# This perms are used in the tagging rest api from openedx_tagging that is exposed in the CMS. They are overridden here +# to include Organization and objects permissions. +rules.set_perm("oel_tagging.view_objecttag_taxonomy", can_view_object_tag_taxonomy) +rules.set_perm("oel_tagging.view_objecttag_objectid", can_view_object_tag_objectid) +rules.set_perm("oel_tagging.change_objecttag_taxonomy", can_view_object_tag_taxonomy) +rules.set_perm("oel_tagging.change_objecttag_objectid", can_change_object_tag_objectid) diff --git a/openedx/core/djangoapps/content_tagging/tasks.py b/openedx/core/djangoapps/content_tagging/tasks.py new file mode 100644 index 000000000000..bd90aad6eaba --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/tasks.py @@ -0,0 +1,204 @@ +""" +Defines asynchronous celery task for auto-tagging content +""" +from __future__ import annotations + +import logging + +from celery import shared_task +from celery_utils.logged_task import LoggedTask +from django.conf import settings +from django.contrib.auth import get_user_model +from edx_django_utils.monitoring import set_code_owner_attribute +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from openedx_tagging.core.tagging.models import Taxonomy + +from xmodule.modulestore.django import modulestore + +from . import api +from .types import ContentKey + +LANGUAGE_TAXONOMY_ID = -1 + +log = logging.getLogger(__name__) +User = get_user_model() + + +def _set_initial_language_tag(content_key: ContentKey, lang_code: str) -> None: + """ + Create a tag for the language taxonomy in the content_object if it doesn't exist. + + lang_code is the two-letter language code, optionally with country suffix. + + If the language is not configured in the plataform or the language tag doesn't exist, + use the default language of the platform. + """ + lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID).cast() + + if lang_code and not api.get_object_tags(object_id=str(content_key), taxonomy_id=lang_taxonomy.id): + try: + lang_tag = lang_taxonomy.tag_for_external_id(lang_code) + except api.oel_tagging.TagDoesNotExist: + default_lang_code = settings.LANGUAGE_CODE + logging.warning( + "Language not configured in the plataform: %s. Using default language: %s", + lang_code, + default_lang_code, + ) + lang_tag = lang_taxonomy.tag_for_external_id(default_lang_code) + api.tag_object( + object_id=str(content_key), + taxonomy=lang_taxonomy, + tags=[lang_tag.value], + ) + + +def _delete_tags(content_object: ContentKey) -> None: + api.delete_object_tags(str(content_object)) + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def update_course_tags(course_key_str: str) -> bool: + """ + Updates the automatically-managed tags for a course + (whenever a course is created or updated) + + Params: + course_key_str (str): identifier of the Course + """ + try: + course_key = CourseKey.from_string(course_key_str) + + log.info("Updating tags for Course with id: %s", course_key) + + course = modulestore().get_course(course_key) + if course: + lang_code = course.language + _set_initial_language_tag(course_key, lang_code) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error updating tags for Course with id: %s. %s", course_key, e) + return False + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def delete_course_tags(course_key_str: str) -> bool: + """ + Delete the tags for a Course (when the course itself has been deleted). + + Params: + course_key_str (str): identifier of the Course + """ + try: + course_key = CourseKey.from_string(course_key_str) + + log.info("Deleting tags for Course with id: %s", course_key) + + _delete_tags(course_key) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error deleting tags for Course with id: %s. %s", course_key, e) + return False + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def update_xblock_tags(usage_key_str: str) -> bool: + """ + Updates the automatically-managed tags for a XBlock + (whenever an XBlock is created/updated). + + Params: + usage_key_str (str): identifier of the XBlock + """ + try: + usage_key = UsageKey.from_string(usage_key_str) + + log.info("Updating tags for XBlock with id: %s", usage_key) + + if usage_key.course_key.is_course: + course = modulestore().get_course(usage_key.course_key) + if course is None: + return True + lang_code = course.language + else: + return True + + _set_initial_language_tag(usage_key, lang_code) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error updating tags for XBlock with id: %s. %s", usage_key, e) + return False + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def delete_xblock_tags(usage_key_str: str) -> bool: + """ + Delete the tags for a XBlock (when the XBlock itself is deleted). + + Params: + usage_key_str (str): identifier of the XBlock + """ + try: + usage_key = UsageKey.from_string(usage_key_str) + + log.info("Deleting tags for XBlock with id: %s", usage_key) + + _delete_tags(usage_key) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error deleting tags for XBlock with id: %s. %s", usage_key, e) + return False + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def update_library_block_tags(usage_key_str: str, language_code: str) -> bool: + """ + Updates the automatically-managed tags for a content library block + whenever it is created/updated + + Params: + usage_key_str (str): identifier of the Library Block + langauge_code (str): the preferred language code of the user + """ + try: + usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) + + log.info("Updating tags for Library Block with id: %s", usage_key) + + _set_initial_language_tag(usage_key, language_code) + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error updating tags for XBlock with id: %s. %s", usage_key, e) + return False + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def delete_library_block_tags(usage_key_str: str) -> bool: + """ + Delete the tags for a Library Block (when the Library Block itself is deleted). + + Params: + usage_key_str (str): identifier of the Library Block + """ + try: + usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) + + log.info("Deleting tags for Library Block with id: %s", usage_key) + + _delete_tags(usage_key) + + return True + except Exception as e: # pylint: disable=broad-except + log.error("Error deleting tags for Library Block with id: %s. %s", usage_key, e) + return False diff --git a/openedx/core/djangoapps/content_tagging/tests/__init__.py b/openedx/core/djangoapps/content_tagging/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py new file mode 100644 index 000000000000..1bc80b73727a --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py @@ -0,0 +1,482 @@ +"""Tests for the Tagging models""" +import io +import os +import tempfile +import ddt +from django.test.testcases import TestCase +from fs.osfs import OSFS +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_tagging.core.tagging.models import ObjectTag +from organizations.models import Organization +from .test_objecttag_export_helpers import TestGetAllObjectTagsMixin, TaggedCourseMixin + +from .. import api +from ..utils import rules_cache + + +class TestTaxonomyMixin: + """ + Sets up data for testing Content Taxonomies. + """ + + def setUp(self): + super().setUp() + self.org1 = Organization.objects.create(name="OpenedX", short_name="OeX") + self.org2 = Organization.objects.create(name="Axim", short_name="Ax") + # Taxonomies + self.taxonomy_disabled = api.create_taxonomy( + name="Learning Objectives", + # We will disable this taxonomy below, after we have used it to tag some objects. + # Note: "disabled" taxonomies are not a supported nor user-exposed feature at the moment, so it's not + # actually that important to test them. + ) + api.set_taxonomy_orgs(self.taxonomy_disabled, orgs=[self.org1, self.org2]) + + self.taxonomy_all_orgs = api.create_taxonomy(name="Content Types") + api.set_taxonomy_orgs(self.taxonomy_all_orgs, all_orgs=True) + + self.taxonomy_both_orgs = api.create_taxonomy(name="OpenedX/Axim Content Types") + api.set_taxonomy_orgs(self.taxonomy_both_orgs, orgs=[self.org1, self.org2]) + + self.taxonomy_one_org = api.create_taxonomy(name="OpenedX Content Types") + api.set_taxonomy_orgs(self.taxonomy_one_org, orgs=[self.org1]) + + self.taxonomy_no_orgs = api.create_taxonomy(name="No orgs") + + # Tags + self.tag_disabled = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_disabled, + tag="learning", + ) + self.tag_all_orgs = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_all_orgs, + tag="learning", + ) + self.tag_both_orgs = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_both_orgs, + tag="learning", + ) + self.tag_one_org = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_one_org, + tag="learning", + ) + self.tag_no_orgs = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_no_orgs, + tag="learning", + ) + # ObjectTags + api.tag_object( + object_id="course-v1:OeX+DemoX+Demo_Course", + taxonomy=self.taxonomy_all_orgs, + tags=[self.tag_all_orgs.value], + ) + self.all_orgs_course_tag = api.get_object_tags( + object_id="course-v1:OeX+DemoX+Demo_Course", + )[0] + api.tag_object( + object_id="block-v1:Ax+DemoX+Demo_Course+type@vertical+block@abcde", + taxonomy=self.taxonomy_all_orgs, + tags=[self.tag_all_orgs.value], + ) + self.all_orgs_block_tag = api.get_object_tags( + object_id="block-v1:Ax+DemoX+Demo_Course+type@vertical+block@abcde", + )[0] + + # Force apply these tags: Ax and OeX are not an allowed org for these taxonomies + api.oel_tagging.tag_object( + object_id="course-v1:Ax+DemoX+Demo_Course", + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.value], + ) + self.both_orgs_course_tag = api.get_object_tags( + object_id="course-v1:Ax+DemoX+Demo_Course", + )[0] + api.oel_tagging.tag_object( + object_id="block-v1:OeX+DemoX+Demo_Course+type@video+block@abcde", + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.value], + ) + self.both_orgs_block_tag = api.get_object_tags( + object_id="block-v1:OeX+DemoX+Demo_Course+type@video+block@abcde", + )[0] + api.oel_tagging.tag_object( + object_id="block-v1:OeX+DemoX+Demo_Course+type@html+block@abcde", + taxonomy=self.taxonomy_one_org, + tags=[self.tag_one_org.value], + ) + self.one_org_block_tag = api.get_object_tags( + object_id="block-v1:OeX+DemoX+Demo_Course+type@html+block@abcde", + )[0] + api.oel_tagging.tag_object( + object_id="course-v1:Ax+DemoX+Demo_Course", + taxonomy=self.taxonomy_disabled, + tags=[self.tag_disabled.value], + ) + self.disabled_course_tag = api.get_object_tags( + object_id="course-v1:Ax+DemoX+Demo_Course", + )[0] + self.taxonomy_disabled.enabled = False + self.taxonomy_disabled.save() + self.disabled_course_tag.refresh_from_db() # Update its cached .taxonomy + + # Clear the rules cache in between test runs + rules_cache.clear() + + +@ddt.ddt +class TestAPITaxonomy(TestTaxonomyMixin, TestCase): + """ + Tests the Content Taxonomy APIs. + """ + + def test_get_taxonomies_enabled_subclasses(self): + with self.assertNumQueries(1): + taxonomies = list(taxonomy.cast() for taxonomy in api.get_taxonomies()) + assert taxonomies == [ + self.taxonomy_all_orgs, + self.taxonomy_no_orgs, + self.taxonomy_one_org, + self.taxonomy_both_orgs, + ] + + @ddt.data( + # All orgs + (None, True, ["taxonomy_all_orgs"]), + (None, False, []), + (None, None, ["taxonomy_all_orgs"]), + # Org 1 + ("org1", True, ["taxonomy_all_orgs", "taxonomy_one_org", "taxonomy_both_orgs"]), + ("org1", False, ["taxonomy_disabled"]), + ( + "org1", + None, + [ + "taxonomy_all_orgs", + "taxonomy_disabled", + "taxonomy_one_org", + "taxonomy_both_orgs", + ], + ), + # Org 2 + ("org2", True, ["taxonomy_all_orgs", "taxonomy_both_orgs"]), + ("org2", False, ["taxonomy_disabled"]), + ( + "org2", + None, + ["taxonomy_all_orgs", "taxonomy_disabled", "taxonomy_both_orgs"], + ), + ) + @ddt.unpack + def test_get_taxonomies_for_org(self, org_attr, enabled, expected): + org_owner = getattr(self, org_attr).short_name if org_attr else None + taxonomies = list( + taxonomy.cast() + for taxonomy in api.get_taxonomies_for_org( + org_short_name=org_owner, enabled=enabled + ) + ) + assert taxonomies == [ + getattr(self, taxonomy_attr) for taxonomy_attr in expected + ] + + def test_get_unassigned_taxonomies(self): + expected = ["taxonomy_no_orgs"] + taxonomies = list(api.get_unassigned_taxonomies()) + assert taxonomies == [ + getattr(self, taxonomy_attr) for taxonomy_attr in expected + ] + + @ddt.data( + ("taxonomy_all_orgs", "all_orgs_course_tag"), + ("taxonomy_all_orgs", "all_orgs_block_tag"), + ("taxonomy_both_orgs", "both_orgs_course_tag"), + ("taxonomy_both_orgs", "both_orgs_block_tag"), + ("taxonomy_one_org", "one_org_block_tag"), + ) + @ddt.unpack + def test_get_content_tags_valid_for_org( + self, + taxonomy_attr, + object_tag_attr, + ): + taxonomy_id = getattr(self, taxonomy_attr).id + object_tag = getattr(self, object_tag_attr) + with self.assertNumQueries(1): + valid_tags = list( + api.get_object_tags( + object_id=object_tag.object_id, + taxonomy_id=taxonomy_id, + ) + ) + assert len(valid_tags) == 1 + assert valid_tags[0].id == object_tag.id + + @ddt.data( + ("taxonomy_all_orgs", "all_orgs_course_tag"), + ("taxonomy_all_orgs", "all_orgs_block_tag"), + ("taxonomy_both_orgs", "both_orgs_course_tag"), + ("taxonomy_both_orgs", "both_orgs_block_tag"), + ("taxonomy_one_org", "one_org_block_tag"), + ) + @ddt.unpack + def test_get_content_tags( + self, + taxonomy_attr, + object_tag_attr, + ): + taxonomy_id = getattr(self, taxonomy_attr).id + object_tag = getattr(self, object_tag_attr) + with self.assertNumQueries(1): + valid_tags = list( + api.get_object_tags( + object_id=object_tag.object_id, + taxonomy_id=taxonomy_id, + ) + ) + assert len(valid_tags) == 1 + assert valid_tags[0].id == object_tag.id + + def test_get_tags(self): + result = list(api.get_tags(self.taxonomy_all_orgs)) + assert len(result) == 1 + assert result[0]["value"] == self.tag_all_orgs.value + assert result[0]["_id"] == self.tag_all_orgs.id + assert result[0]["parent_value"] is None + assert result[0]["depth"] == 0 + + +class TestAPIObjectTags(TestGetAllObjectTagsMixin, TestCase): + """ + Tests object tag API functions. + """ + + def test_get_course_object_tags(self): + """ + Test the get_all_object_tags function using a course + """ + with self.assertNumQueries(1): + object_tags, taxonomies = api.get_all_object_tags( + CourseKey.from_string("course-v1:orgA+test_course+test_run") + ) + + assert object_tags == self.expected_course_objecttags + assert taxonomies == { + self.taxonomy_1.id: self.taxonomy_1, + self.taxonomy_2.id: self.taxonomy_2, + } + + def test_get_course_object_tags_with_add_tags(self): + """ + This test checks for an issue in get_all_object_tags: + If new tags are added to those already added previously, + the previous tags are lost. + This happens because the new tags will overwrite the old ones + in the result. + """ + # Tag in a new taxonomy + ObjectTag.objects.create( + object_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1", + taxonomy=self.taxonomy_1, + tag=self.tag_1_1, + ) + # Tag in a already tagged taxonomy + ObjectTag.objects.create( + object_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1", + taxonomy=self.taxonomy_2, + tag=self.tag_2_1, + ) + + with self.assertNumQueries(1): + object_tags, taxonomies = api.get_all_object_tags( + CourseKey.from_string("course-v1:orgA+test_course+test_run") + ) + + vertical1_tags = api.get_object_tags( + "block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1", + taxonomy_id=self.taxonomy_1.id, + ) + vertical2_tags = api.get_object_tags( + "block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1", + taxonomy_id=self.taxonomy_2.id, + ) + + # Add new object tags to the expected result + self.expected_course_objecttags["block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1"] = { + self.taxonomy_1.id: [tag.value for tag in vertical1_tags], + self.taxonomy_2.id: [tag.value for tag in vertical2_tags], + } + + assert object_tags == self.expected_course_objecttags + assert taxonomies == { + self.taxonomy_1.id: self.taxonomy_1, + self.taxonomy_2.id: self.taxonomy_2, + } + + def test_get_library_object_tags(self): + """ + Test the get_all_object_tags function using a library + """ + with self.assertNumQueries(1): + object_tags, taxonomies = api.get_all_object_tags( + LibraryLocatorV2.from_string(f"lib:orgA:lib_{self.block_suffix}") + ) + + assert object_tags == self.expected_library_objecttags + assert taxonomies == { + self.taxonomy_1.id: self.taxonomy_1, + self.taxonomy_2.id: self.taxonomy_2, + } + + def _test_copy_object_tags(self, src_key, dst_key, expected_tags): + """ + Test copying object tags to a new object. + """ + # Destination block doesn't have any tags yet + with self.assertNumQueries(1): + assert not list(api.get_object_tags(object_id=str(dst_key))) + + # Copy tags from the source block + api.copy_object_tags(src_key, dst_key) + + with self.assertNumQueries(1): + dst_tags = list(api.get_object_tags(object_id=str(dst_key))) + + # Check that the destination tags match the expected list (name + value only; object_id will differ) + with self.assertNumQueries(0): + assert len(dst_tags) == len(expected_tags) + for idx, src_tag in enumerate(expected_tags): + dst_tag = dst_tags[idx] + assert src_tag.export_id == dst_tag.export_id + assert src_tag.value == dst_tag.value + + def test_copy_object_tags(self): + """ + Test copying object tags to a new object. + """ + src_key = UsageKey.from_string("block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential") + dst_key = UsageKey.from_string("block-v1:orgB+test_course+test_run+type@sequential+block@test_sequential") + expected_tags = list(self.sequential_tags1) + list(self.sequential_tags2) + with self.assertNumQueries(30): # TODO why so high? + self._test_copy_object_tags(src_key, dst_key, expected_tags) + + def test_copy_cross_org_tags(self): + """ + Test copying object tags to a new object in a different org. + Ensure only the permitted tags are copied. + """ + src_key = UsageKey.from_string("block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential") + dst_key = UsageKey.from_string("block-v1:orgB+test_course+test_run+type@sequential+block@test_sequential") + + # Add another tag from an orgA-specific taxonomy + api.tag_object( + object_id=str(src_key), + taxonomy=self.taxonomy_3, + tags=["Tag 3.1"], + ) + + # Destination block should have all of the source block's tags, except for the orgA-specific one. + expected_tags = list(self.sequential_tags1) + list(self.sequential_tags2) + with self.assertNumQueries(31): # TODO why so high? + self._test_copy_object_tags(src_key, dst_key, expected_tags) + + +class TestExportImportTags(TaggedCourseMixin): + """ + Tests for export/import functions + """ + def _create_csv_file(self, content): + """ + Create a csv file and returns the path and name + """ + file_dir_name = tempfile.mkdtemp() + file_name = f'{file_dir_name}/tags.csv' + with open(file_name, 'w') as csv_file: + csv_file.write(content) + return file_name + + def test_generate_csv_rows(self) -> None: + buffer = io.StringIO() + list(api.generate_csv_rows(str(self.course.id), buffer)) + buffer.seek(0) + csv_content = buffer.getvalue() + + assert csv_content == self.expected_csv + + def test_export_tags_in_csv_file(self) -> None: + file_dir_name = tempfile.mkdtemp() + file_dir = OSFS(file_dir_name) + file_name = 'tags.csv' + + api.export_tags_in_csv_file(str(self.course.id), file_dir, file_name) + + file_path = os.path.join(file_dir_name, file_name) + + self.assertTrue(os.path.exists(file_path)) + + with open(file_path, 'r') as f: + content = f.read() + + cleaned_content = content.replace('\r\n', '\n') + cleaned_expected_csv = self.expected_csv.replace('\r\n', '\n') + self.assertEqual(cleaned_content, cleaned_expected_csv) + + def test_import_tags_invalid_format(self) -> None: + csv_path = self._create_csv_file('invalid format, Invalid\r\ntest1, test2') + with self.assertRaises(ValueError) as exc: + api.import_course_tags_from_csv(csv_path, self.course.id) + assert "Invalid format of csv in" in str(exc.exception) + + def test_import_tags_valid_taxonomy_and_tags(self) -> None: + csv_path = self._create_csv_file( + '"Name","Type","ID","1-taxonomy-1","2-taxonomy-2"\r\n' + '"Test Course","course","course-v1:orgA+test_course+test_run","Tag 1.1",""\r\n' + ) + api.import_course_tags_from_csv(csv_path, self.course.id) + object_tags = list(api.get_object_tags(self.course.id)) + assert len(object_tags) == 1 + + object_tag = object_tags[0] + assert object_tag.tag == self.tag_1_1 + assert object_tag.taxonomy == self.taxonomy_1 + + def test_import_tags_invalid_tag(self) -> None: + csv_path = self._create_csv_file( + '"Name","Type","ID","1-taxonomy-1","2-taxonomy-2"\r\n' + '"Test Course","course","course-v1:orgA+test_course+test_run","Tag 1.11",""\r\n' + ) + api.import_course_tags_from_csv(csv_path, self.course.id) + object_tags = list(api.get_object_tags(self.course.id)) + assert len(object_tags) == 0 + + object_tags = list(api.get_object_tags( + self.course.id, + include_deleted=True, + )) + assert len(object_tags) == 1 + + object_tag = object_tags[0] + assert object_tag.tag is None + assert object_tag.value == 'Tag 1.11' + assert object_tag.taxonomy == self.taxonomy_1 + + def test_import_tags_invalid_taxonomy(self) -> None: + csv_path = self._create_csv_file( + '"Name","Type","ID","1-taxonomy-1-1"\r\n' + '"Test Course","course","course-v1:orgA+test_course+test_run","Tag 1.11"\r\n' + ) + api.import_course_tags_from_csv(csv_path, self.course.id) + object_tags = list(api.get_object_tags(self.course.id)) + assert len(object_tags) == 0 + + object_tags = list(api.get_object_tags( + self.course.id, + include_deleted=True, + )) + assert len(object_tags) == 1 + + object_tag = object_tags[0] + assert object_tag.tag is None + assert object_tag.value == 'Tag 1.11' + assert object_tag.taxonomy is None + assert object_tag.export_id == '1-taxonomy-1-1' diff --git a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py new file mode 100644 index 000000000000..1193dcf0583a --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py @@ -0,0 +1,466 @@ +""" +Test the objecttag_export_helpers module +""" +import time +from unittest.mock import patch + +from openedx.core.djangoapps.content_libraries import api as library_api +from openedx.core.djangoapps.content_libraries.tests.base import BlockstoreAppTestMixin +from openedx.core.lib.blockstore_api import create_collection +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + +from .. import api +from ..helpers.objecttag_export_helpers import TaggedContent, build_object_tree_with_objecttags, iterate_with_level +from openedx_tagging.core.tagging.models import ObjectTag +from organizations.models import Organization + + +class TestGetAllObjectTagsMixin: + """ + Set up data to test get_all_object_tags functions + """ + + def setUp(self): + super().setUp() + + self.taxonomy_1 = api.create_taxonomy(name="Taxonomy 1") + api.set_taxonomy_orgs(self.taxonomy_1, all_orgs=True) + self.tag_1_1 = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_1, + tag="Tag 1.1", + ) + self.tag_1_2 = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_1, + tag="Tag 1.2", + ) + + self.taxonomy_2 = api.create_taxonomy(name="Taxonomy 2") + api.set_taxonomy_orgs(self.taxonomy_2, all_orgs=True) + + self.tag_2_1 = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_2, + tag="Tag 2.1", + ) + self.tag_2_2 = api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_2, + tag="Tag 2.2", + ) + + api.tag_object( + object_id="course-v1:orgA+test_course+test_run", + taxonomy=self.taxonomy_1, + tags=['Tag 1.1'], + ) + self.course_tags = api.get_object_tags("course-v1:orgA+test_course+test_run") + + self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") + self.orgB = Organization.objects.create(name="Organization B", short_name="orgB") + self.taxonomy_3 = api.create_taxonomy(name="Taxonomy 3", orgs=[self.orgA]) + api.add_tag_to_taxonomy( + taxonomy=self.taxonomy_3, + tag="Tag 3.1", + ) + + # Tag blocks + api.tag_object( + object_id="block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential", + taxonomy=self.taxonomy_1, + tags=['Tag 1.1', 'Tag 1.2'], + ) + self.sequential_tags1 = api.get_object_tags( + "block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential", + taxonomy_id=self.taxonomy_1.id, + + ) + api.tag_object( + object_id="block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential", + taxonomy=self.taxonomy_2, + tags=['Tag 2.1'], + ) + self.sequential_tags2 = api.get_object_tags( + "block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential", + taxonomy_id=self.taxonomy_2.id, + ) + api.tag_object( + object_id="block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1", + taxonomy=self.taxonomy_2, + tags=['Tag 2.2'], + ) + self.vertical1_tags = api.get_object_tags( + "block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1" + ) + api.tag_object( + object_id="block-v1:orgA+test_course+test_run+type@html+block@test_html", + taxonomy=self.taxonomy_2, + tags=['Tag 2.1'], + ) + self.html_tags = api.get_object_tags("block-v1:orgA+test_course+test_run+type@html+block@test_html") + + # Create "deleted" object tags, which will be omitted from the results. + for object_id in ( + "course-v1:orgA+test_course+test_run", + "block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential", + "block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1", + "block-v1:orgA+test_course+test_run+type@html+block@test_html", + ): + ObjectTag.objects.create( + object_id=str(object_id), + taxonomy=None, + tag=None, + _value="deleted tag", + _export_id="deleted_taxonomy", + ) + + self.expected_course_objecttags = { + "course-v1:orgA+test_course+test_run": { + self.taxonomy_1.id: [tag.value for tag in self.course_tags], + }, + "block-v1:orgA+test_course+test_run+type@sequential+block@test_sequential": { + self.taxonomy_1.id: [tag.value for tag in self.sequential_tags1], + self.taxonomy_2.id: [tag.value for tag in self.sequential_tags2], + }, + "block-v1:orgA+test_course+test_run+type@vertical+block@test_vertical1": { + self.taxonomy_2.id: [tag.value for tag in self.vertical1_tags], + }, + "block-v1:orgA+test_course+test_run+type@html+block@test_html": { + self.taxonomy_2.id: [tag.value for tag in self.html_tags], + }, + } + + # Library tags and library contents need a unique block_id that is persisted along test runs + self.block_suffix = str(round(time.time() * 1000)) + + api.tag_object( + object_id=f"lib:orgA:lib_{self.block_suffix}", + taxonomy=self.taxonomy_2, + tags=['Tag 2.1'], + ) + self.library_tags = api.get_object_tags(f"lib:orgA:lib_{self.block_suffix}") + + api.tag_object( + object_id=f"lb:orgA:lib_{self.block_suffix}:problem:problem1_{self.block_suffix}", + taxonomy=self.taxonomy_1, + tags=['Tag 1.1'], + ) + self.problem1_tags = api.get_object_tags( + f"lb:orgA:lib_{self.block_suffix}:problem:problem1_{self.block_suffix}" + ) + + api.tag_object( + object_id=f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}", + taxonomy=self.taxonomy_1, + tags=['Tag 1.2'], + ) + self.library_html_tags1 = api.get_object_tags( + object_id=f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}", + taxonomy_id=self.taxonomy_1.id, + ) + + api.tag_object( + object_id=f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}", + taxonomy=self.taxonomy_2, + tags=['Tag 2.2'], + ) + self.library_html_tags2 = api.get_object_tags( + object_id=f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}", + taxonomy_id=self.taxonomy_2.id, + ) + + # Create "deleted" object tags, which will be omitted from the results. + for object_id in ( + f"lib:orgA:lib_{self.block_suffix}", + f"lb:orgA:lib_{self.block_suffix}:problem:problem1_{self.block_suffix}", + f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}", + ): + ObjectTag.objects.create( + object_id=object_id, + taxonomy=None, + tag=None, + _value="deleted tag", + _export_id="deleted_taxonomy", + ) + + self.expected_library_objecttags = { + f"lib:orgA:lib_{self.block_suffix}": { + self.taxonomy_2.id: [tag.value for tag in self.library_tags], + }, + f"lb:orgA:lib_{self.block_suffix}:problem:problem1_{self.block_suffix}": { + self.taxonomy_1.id: [tag.value for tag in self.problem1_tags], + }, + f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}": { + self.taxonomy_1.id: [tag.value for tag in self.library_html_tags1], + self.taxonomy_2.id: [tag.value for tag in self.library_html_tags2], + }, + } + + +class TaggedCourseMixin(TestGetAllObjectTagsMixin, BlockstoreAppTestMixin, ModuleStoreTestCase): # type: ignore[misc] + """ + Mixin with a course structure and taxonomies + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + CREATE_USER = False + + def setUp(self): + 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_course_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: [tag.value for tag in 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: [tag.value for tag in self.sequential_tags1], + self.taxonomy_2.id: [tag.value for tag in self.sequential_tags2], + }, + ) + + assert self.expected_course_tagged_xblock.children is not None # type guard + self.expected_course_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_course_tagged_xblock.children is not None # type guard + self.expected_course_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: [tag.value for tag in 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) + + BlockFactory.create( + parent=vertical2, + category="html", + display_name="test html", + ) + tagged_html = 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: [tag.value for tag in self.html_tags], + }, + ) + assert untagged_vertical2.children is not None # type guard + untagged_vertical2.children.append(tagged_html) + + self.all_course_object_tags, _ = api.get_all_object_tags(self.course.id) + self.expected_course_tagged_content_list = [ + (self.expected_course_tagged_xblock, 0), + (tagged_sequential, 1), + (tagged_vertical, 2), + (untagged_vertical2, 2), + (tagged_html, 3), + (untagged_sequential, 1), + (untagged_vertical, 2), + ] + + collection = create_collection("Test library collection") + + # Create a library + self.library = library_api.create_library( + collection.uuid, + self.orgA, + f"lib_{self.block_suffix}", + "Test Library", + ) + self.expected_library_tagged_xblock = TaggedContent( + display_name="Test Library", + block_id=f"lib:orgA:lib_{self.block_suffix}", + category="library", + children=[], + object_tags={ + self.taxonomy_2.id: [tag.value for tag in self.library_tags], + }, + ) + + library_api.create_library_block( + self.library.key, + "problem", + f"problem1_{self.block_suffix}", + ) + tagged_problem = TaggedContent( + display_name="Blank Advanced Problem", + block_id=f"lb:orgA:lib_{self.block_suffix}:problem:problem1_{self.block_suffix}", + category="problem", + children=[], + object_tags={ + self.taxonomy_1.id: [tag.value for tag in self.problem1_tags], + }, + ) + + library_api.create_library_block( + self.library.key, + "problem", + f"problem2_{self.block_suffix}", + ) + untagged_problem = TaggedContent( + display_name="Blank Advanced Problem", + block_id=f"lb:orgA:lib_{self.block_suffix}:problem:problem2_{self.block_suffix}", + category="problem", + children=[], + object_tags={}, + ) + + library_api.create_library_block( + self.library.key, + "html", + f"html_{self.block_suffix}", + ) + tagged_library_html = TaggedContent( + display_name="Text", + block_id=f"lb:orgA:lib_{self.block_suffix}:html:html_{self.block_suffix}", + category="html", + children=[], + object_tags={ + self.taxonomy_1.id: [tag.value for tag in self.library_html_tags1], + self.taxonomy_2.id: [tag.value for tag in self.library_html_tags2], + }, + ) + + assert self.expected_library_tagged_xblock.children is not None # type guard + # The children are sorted by add order + self.expected_library_tagged_xblock.children.append(tagged_library_html) + self.expected_library_tagged_xblock.children.append(tagged_problem) + self.expected_library_tagged_xblock.children.append(untagged_problem) + + self.all_library_object_tags, _ = api.get_all_object_tags(self.library.key) + self.expected_library_tagged_content_list = [ + (self.expected_library_tagged_xblock, 0), + (tagged_library_html, 1), + (tagged_problem, 1), + (untagged_problem, 1), + ] + + self.expected_csv = ( + '"Name","Type","ID","1-taxonomy-1","2-taxonomy-2"\r\n' + '"Test Course","course","course-v1:orgA+test_course+test_run","Tag 1.1",""\r\n' + '" test sequential","sequential","test_sequential","Tag 1.1; Tag 1.2","Tag 2.1"\r\n' + '" test vertical1","vertical","test_vertical1","","Tag 2.2"\r\n' + '" test vertical2","vertical","test_vertical2","",""\r\n' + '" test html","html","test_html","","Tag 2.1"\r\n' + '" untagged sequential","sequential","untagged_sequential","",""\r\n' + '" untagged vertical","vertical","untagged_vertical","",""\r\n' + ) + + +class TestContentTagChildrenExport(TaggedCourseMixin): # type: ignore[misc] + """ + Test helper functions for exporting tagged content + """ + def test_build_course_object_tree(self) -> None: + """ + Test if we can export a course + """ + with self.assertNumQueries(3): + tagged_course = build_object_tree_with_objecttags(self.course.id, self.all_course_object_tags) + + assert tagged_course == self.expected_course_tagged_xblock + + def test_build_library_object_tree(self) -> None: + """ + Test if we can export a library + """ + with self.assertNumQueries(11): + tagged_library = build_object_tree_with_objecttags(self.library.key, self.all_library_object_tags) + + assert tagged_library == self.expected_library_tagged_xblock + + def test_course_iterate_with_level(self) -> None: + """ + Test if we can iterate over the tagged course in the correct order + """ + tagged_content_list = list(iterate_with_level(self.expected_course_tagged_xblock)) + assert tagged_content_list == self.expected_course_tagged_content_list + + def test_library_iterate_with_level(self) -> None: + """ + Test if we can iterate over the tagged library in the correct order + """ + tagged_content_list = list(iterate_with_level(self.expected_library_tagged_xblock)) + assert tagged_content_list == self.expected_library_tagged_content_list diff --git a/openedx/core/djangoapps/content_tagging/tests/test_rules.py b/openedx/core/djangoapps/content_tagging/tests/test_rules.py new file mode 100644 index 000000000000..d53256f510a4 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/tests/test_rules.py @@ -0,0 +1,610 @@ +"""Tests content_tagging rules-based permissions""" + +import ddt +from django.contrib.auth import get_user_model +from django.test import TestCase +from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from openedx_tagging.core.tagging.models import ( + Tag, + UserSystemDefinedTaxonomy, +) +from openedx_tagging.core.tagging.rules import ObjectTagPermissionItem + +from common.djangoapps.student.auth import add_users, update_org_role +from common.djangoapps.student.roles import CourseStaffRole, OrgStaffRole + +from .. import api +from .test_api import TestTaxonomyMixin + +User = get_user_model() + + +@ddt.ddt +class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): + """ + Tests that the expected rules have been applied to the Taxonomy models. + + We set ENABLE_CREATOR_GROUP for these tests, otherwise all users have course creator access for all orgs. + """ + + def setUp(self): + super().setUp() + self.superuser = User.objects.create( + username="superuser", + email="superuser@example.com", + is_superuser=True, + ) + self.staff = User.objects.create( + username="staff", + email="staff@example.com", + is_staff=True, + ) + + # Normal user: grant course creator access to both org1 and org2 + self.user_both_orgs = User.objects.create( + username="user_both_orgs", + email="staff+both@example.com", + ) + update_org_role( + self.staff, + OrgStaffRole, + self.user_both_orgs, + [self.org1.short_name, self.org2.short_name], + ) + + # Normal user: grant course creator access to org2 + self.user_org2 = User.objects.create( + username="user_org2", + email="staff+org2@example.com", + ) + update_org_role( + self.staff, OrgStaffRole, self.user_org2, [self.org2.short_name] + ) + + # Normal user: no course creator access + self.learner = User.objects.create( + username="learner", + email="learner@example.com", + ) + + self.course1 = CourseLocator(self.org1.short_name, "DemoX", "Demo_Course") + self.course2 = CourseLocator(self.org2.short_name, "DemoX", "Demo_Course") + self.courseC = CourseLocator("orgC", "DemoX", "Demo_Course") + + self.xblock1 = BlockUsageLocator( + course_key=self.course1, + block_type='problem', + block_id='block_id' + ) + self.xblock2 = BlockUsageLocator( + course_key=self.course2, + block_type='problem', + block_id='block_id' + ) + self.xblockC = BlockUsageLocator( + course_key=self.courseC, + block_type='problem', + block_id='block_id' + ) + + add_users(self.staff, CourseStaffRole(self.course1), self.user_both_orgs) + add_users(self.staff, CourseStaffRole(self.course2), self.user_both_orgs) + add_users(self.staff, CourseStaffRole(self.course2), self.user_org2) + add_users(self.staff, CourseStaffRole(self.course2), self.user_org2) + + self.tax_all_course1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_all_orgs, + object_id=str(self.course1), + ) + self.tax_all_course2 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_all_orgs, + object_id=str(self.course2), + ) + self.tax_all_xblock1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_all_orgs, + object_id=str(self.xblock1), + ) + self.tax_all_xblock2 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_all_orgs, + object_id=str(self.xblock2), + ) + + self.tax_both_course1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_both_orgs, + object_id=str(self.course1), + ) + self.tax_both_course2 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_both_orgs, + object_id=str(self.course2), + ) + self.tax_both_xblock1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_both_orgs, + object_id=str(self.xblock1), + ) + self.tax_both_xblock2 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_both_orgs, + object_id=str(self.xblock2), + ) + + self.tax1_course1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_one_org, + object_id=str(self.course1), + ) + self.tax1_xblock1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_one_org, + object_id=str(self.xblock1), + ) + + self.tax_no_org_course1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_no_orgs, + object_id=str(self.course1), + ) + + self.tax_no_org_xblock1 = ObjectTagPermissionItem( + taxonomy=self.taxonomy_no_orgs, + object_id=str(self.xblock1), + ) + + self.disabled_course2_tag_perm = ObjectTagPermissionItem( + taxonomy=self.taxonomy_disabled, + object_id=str(self.course2), + ) + + self.all_org_perms = ( + self.tax_all_course1, + self.tax_all_course2, + self.tax_all_xblock1, + self.tax_all_xblock2, + self.tax_both_course1, + self.tax_both_course2, + self.tax_both_xblock1, + self.tax_both_xblock2, + ) + + def _expected_users_have_perm( + self, perm, obj, learner_perm=False, learner_obj=False, user_org2=True + ): + """ + Checks that all users have the given permission on the given object. + + If learners_too, then the learner user should have it too. + """ + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm) + assert self.superuser.has_perm(perm, obj) + assert self.staff.has_perm(perm) + assert self.staff.has_perm(perm, obj) + + # Org content creators are bound by a taxonomy's org restrictions + assert self.user_both_orgs.has_perm(perm) == learner_perm + assert self.user_both_orgs.has_perm(perm, obj) + assert self.user_org2.has_perm(perm) == learner_perm + # user_org2 does not have course creator access for org 1 + assert self.user_org2.has_perm(perm, obj) == user_org2 + + # Learners can't do much but view + assert self.learner.has_perm(perm) == learner_perm + assert self.learner.has_perm(perm, obj) == learner_obj + + # Taxonomy + def test_taxonomy_base_add_permissions(self): + """ + Test that staff, superuser and org admins can call POST on taxonomies. + """ + perm = "oel_tagging.add_taxonomy" + assert self.superuser.has_perm(perm) + assert self.staff.has_perm(perm) + assert self.user_both_orgs.has_perm(perm) + assert self.user_org2.has_perm(perm) + assert not self.learner.has_perm(perm) + + @ddt.data( + "oel_tagging.change_taxonomy", + "oel_tagging.delete_taxonomy", + ) + def test_taxonomy_base_edit_permissions(self, perm): + """ + Test that everyone can call PUT, PATCH and DELETE on taxonomies. + """ + assert self.superuser.has_perm(perm) + assert self.staff.has_perm(perm) + assert self.user_both_orgs.has_perm(perm) + assert self.user_org2.has_perm(perm) + assert self.learner.has_perm(perm) + + @ddt.data( + "oel_tagging.view_taxonomy", + ) + def test_taxonomy_base_view_permissions(self, perm): + """ + Test that everyone can call GET on taxonomies. + """ + assert self.superuser.has_perm(perm) + assert self.staff.has_perm(perm) + assert self.user_both_orgs.has_perm(perm) + assert self.user_org2.has_perm(perm) + assert self.learner.has_perm(perm) + + @ddt.data( + ("oel_tagging.change_taxonomy", "taxonomy_disabled"), + ("oel_tagging.change_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_one_org"), + ("oel_tagging.delete_taxonomy", "taxonomy_disabled"), + ("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_one_org"), + ) + @ddt.unpack + def test_change_taxonomy(self, perm, taxonomy_attr): + """ + Test that only instance level and org level admins can edit/delete taxonomies from their orgs. + """ + taxonomy = getattr(self, taxonomy_attr) + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org") + assert not self.learner.has_perm(perm, taxonomy) + + @ddt.data( + ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"), + ) + @ddt.unpack + def test_change_taxonomy_all_no_org(self, perm, taxonomy_attr): + """ + Test that only Staff & Superuser can edit/delete taxonomies from all or no org. + """ + taxonomy = getattr(self, taxonomy_attr) + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + + @ddt.data( + "oel_tagging.change_taxonomy", + "oel_tagging.delete_taxonomy", + ) + def test_system_taxonomy(self, perm): + """ + Test that even taxonomy administrators cannot edit/delete system taxonomies. + """ + system_taxonomy = api.create_taxonomy( + name="System Languages", + ) + system_taxonomy.taxonomy_class = UserSystemDefinedTaxonomy + system_taxonomy = system_taxonomy.cast() + assert self.superuser.has_perm(perm, system_taxonomy) + assert not self.staff.has_perm(perm, system_taxonomy) + assert not self.user_both_orgs.has_perm(perm, system_taxonomy) + assert not self.user_org2.has_perm(perm, system_taxonomy) + assert not self.learner.has_perm(perm, system_taxonomy) + + def test_view_taxonomy_no_orgs(self): + """ + Test that only Staff & Superuser can view taxonomies with no orgs. + """ + taxonomy = self.taxonomy_no_orgs + taxonomy.enabled = True + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + + @ddt.data( + "taxonomy_both_orgs", + "taxonomy_one_org", + ) + def test_view_taxonomy_enabled(self, taxonomy_attr): + """ + Test that anyone can view enabled taxonomies from their org. + """ + taxonomy = getattr(self, taxonomy_attr) + taxonomy.enabled = True + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org") + assert not self.learner.has_perm(perm, taxonomy) + + def test_view_taxonomy_enabled_all_orgs(self): + """ + Test that anyone can view enabled global taxonomies. + """ + taxonomy = self.taxonomy_all_orgs + taxonomy.enabled = True + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) + assert self.learner.has_perm(perm, taxonomy) + + @ddt.data( + "taxonomy_both_orgs", + "taxonomy_one_org", + ) + def test_view_taxonomy_disabled(self, taxonomy_attr): + """ + Test that only instance level and org level admins can view disabled taxonomies. + """ + taxonomy = getattr(self, taxonomy_attr) + taxonomy.enabled = False + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org") + assert not self.learner.has_perm(perm, taxonomy) + + def test_view_taxonomy_all_orgs_disabled(self): + """ + Test that only instance level admins can view disabled all org taxonomies. + """ + taxonomy = self.taxonomy_all_orgs + taxonomy.enabled = False + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + + def test_view_taxonomy_disabled_no_org(self): + """ + Test that only Staff & Superuser can view disabled taxonomies with no orgs. + """ + taxonomy = self.taxonomy_no_orgs + taxonomy.enabled = False + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + + # Tag + + @ddt.data( + "oel_tagging.add_tag", + "oel_tagging.change_tag", + "oel_tagging.delete_tag", + ) + def test_tag_base_edit_permissions(self, perm): + """ + Test that only Staff & Superuser can call add/edit/delete tags. + """ + assert self.superuser.has_perm(perm) + assert self.staff.has_perm(perm) + assert not self.user_both_orgs.has_perm(perm) + assert not self.user_org2.has_perm(perm) + assert not self.learner.has_perm(perm) + + def test_tag_base_view_permissions(self): + """ + Test that everyone can call view tag. + """ + perm = "oel_tagging.view_tag" + assert self.superuser.has_perm(perm) + assert self.staff.has_perm(perm) + assert self.user_both_orgs.has_perm(perm) + assert self.user_org2.has_perm(perm) + assert self.learner.has_perm(perm) + + @ddt.data( + ("oel_tagging.change_tag", "tag_all_orgs"), + ("oel_tagging.change_tag", "tag_disabled"), + ("oel_tagging.change_tag", "tag_both_orgs"), + ("oel_tagging.change_tag", "tag_one_org"), + ("oel_tagging.change_tag", "tag_no_orgs"), + ("oel_tagging.delete_tag", "tag_all_orgs"), + ("oel_tagging.delete_tag", "tag_disabled"), + ("oel_tagging.delete_tag", "tag_both_orgs"), + ("oel_tagging.delete_tag", "tag_one_org"), + ("oel_tagging.delete_tag", "tag_no_orgs"), + ) + @ddt.unpack + def test_change_tag(self, perm, tag_attr): + """ + Test that only Staff & Superuser can edit/delete taxonomies. + """ + tag = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, tag) + assert self.staff.has_perm(perm, tag) + assert not self.user_both_orgs.has_perm(perm, tag) + assert not self.user_org2.has_perm(perm, tag) + assert not self.learner.has_perm(perm, tag) + + @ddt.data( + "oel_tagging.change_tag", + "oel_tagging.delete_tag", + ) + def test_system_taxonomy_tag(self, perm): + """ + Test that even taxonomy administrators cannot edit/delete tags on system taxonomies. + """ + system_taxonomy = api.create_taxonomy( + name="System Languages", + ) + system_taxonomy.taxonomy_class = UserSystemDefinedTaxonomy + system_taxonomy = system_taxonomy.cast() + tag_system_taxonomy = Tag.objects.create( + taxonomy=system_taxonomy, + value="en", + ) + + assert self.superuser.has_perm(perm, tag_system_taxonomy) + assert not self.staff.has_perm(perm, tag_system_taxonomy) + assert not self.user_both_orgs.has_perm(perm, tag_system_taxonomy) + assert not self.user_org2.has_perm(perm, tag_system_taxonomy) + assert not self.learner.has_perm(perm, tag_system_taxonomy) + + @ddt.data( + "oel_tagging.change_tag", + "oel_tagging.delete_tag", + ) + def test_free_text_taxonomy_tag(self, perm): + """ + Test that even taxonomy administrators cannot edit/delete tags on free text taxonomies. + """ + free_text_taxonomy = api.create_taxonomy( + name="Free text", + allow_free_text=True, + ) + + tag_free_text_taxonomy = Tag.objects.create( + taxonomy=free_text_taxonomy, + value="value1", + ) + + assert self.superuser.has_perm(perm, tag_free_text_taxonomy) + assert not self.staff.has_perm(perm, tag_free_text_taxonomy) + assert not self.user_both_orgs.has_perm(perm, tag_free_text_taxonomy) + assert not self.user_org2.has_perm(perm, tag_free_text_taxonomy) + assert not self.learner.has_perm(perm, tag_free_text_taxonomy) + + @ddt.data( + "oel_tagging.change_tag", + "oel_tagging.delete_tag", + ) + def test_tag_no_taxonomy(self, perm): + """Taxonomy administrators can modify any Tag, even those with no Taxonnmy.""" + tag = Tag() + + # Global Taxonomy Admins can do pretty much anything + assert self.superuser.has_perm(perm, tag) + assert self.staff.has_perm(perm, tag) + + # Everyone else can't do anything + assert not self.user_both_orgs.has_perm(perm, tag) + assert not self.user_org2.has_perm(perm, tag) + assert not self.learner.has_perm(perm, tag) + + @ddt.data( + "tag_all_orgs", + "tag_both_orgs", + "tag_one_org", + "tag_disabled", + "tag_no_orgs", + ) + def test_view_tag(self, tag_attr): + """Anyone can view any Tag""" + tag = getattr(self, tag_attr) + self._expected_users_have_perm( + "oel_tagging.view_tag", tag, learner_perm=True, learner_obj=True + ) + + # ObjectTag + + @ddt.data( + ("oel_tagging.add_objecttag", "disabled_course2_tag_perm"), + ("oel_tagging.change_objecttag", "disabled_course2_tag_perm"), + ("oel_tagging.delete_objecttag", "disabled_course2_tag_perm"), + ) + @ddt.unpack + def test_object_tag_disabled_taxonomy(self, perm, tag_attr): + """ + Only superuser create/edit an ObjectTag using a disabled Taxonomy + """ + object_tag_perm = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, object_tag_perm) + assert not self.staff.has_perm(perm, object_tag_perm) + assert not self.user_both_orgs.has_perm(perm, object_tag_perm) + assert not self.user_org2.has_perm(perm, object_tag_perm) + assert not self.learner.has_perm(perm, object_tag_perm) + + @ddt.data( + ("oel_tagging.add_objecttag", "tax_no_org_course1"), + ("oel_tagging.add_objecttag", "tax_no_org_xblock1"), + ("oel_tagging.change_objecttag", "tax_no_org_course1"), + ("oel_tagging.change_objecttag", "tax_no_org_xblock1"), + ("oel_tagging.delete_objecttag", "tax_no_org_xblock1"), + ("oel_tagging.delete_objecttag", "tax_no_org_course1"), + ) + @ddt.unpack + def test_object_tag_no_orgs(self, perm, tag_attr): + """Only superusers can create/edit an ObjectTag with a no-org Taxonomy""" + object_tag = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, object_tag) + assert not self.staff.has_perm(perm, object_tag) + assert not self.user_both_orgs.has_perm(perm, object_tag) + assert not self.user_org2.has_perm(perm, object_tag) + assert not self.learner.has_perm(perm, object_tag) + + @ddt.data( + "oel_tagging.add_objecttag", + "oel_tagging.change_objecttag", + "oel_tagging.delete_objecttag", + "oel_tagging.can_tag_object", + ) + def test_change_object_tag_all_orgs(self, perm): + """ + Taxonomy administrators and org authors can create/edit an ObjectTag using taxonomies in their org, + but only on objects they have write access to. + """ + for perm_item in self.all_org_perms: + assert self.superuser.has_perm(perm, perm_item) + assert self.staff.has_perm(perm, perm_item) + assert self.user_both_orgs.has_perm(perm, perm_item) + assert self.user_org2.has_perm(perm, perm_item) == (self.org2.short_name in perm_item.object_id) + assert not self.learner.has_perm(perm, perm_item) + + @ddt.data( + ("oel_tagging.add_objecttag", "tax1_course1"), + ("oel_tagging.add_objecttag", "tax1_xblock1"), + ("oel_tagging.change_objecttag", "tax1_course1"), + ("oel_tagging.change_objecttag", "tax1_xblock1"), + ("oel_tagging.delete_objecttag", "tax1_course1"), + ("oel_tagging.delete_objecttag", "tax1_xblock1"), + ) + @ddt.unpack + def test_change_object_tag_org1(self, perm, tag_attr): + """Taxonomy administrators can create/edit an ObjectTag on taxonomies in their org.""" + perm_item = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, perm_item) + assert self.staff.has_perm(perm, perm_item) + assert self.user_both_orgs.has_perm(perm, perm_item) + assert not self.user_org2.has_perm(perm, perm_item) + assert not self.learner.has_perm(perm, perm_item) + + @ddt.data( + "tax_all_course1", + "tax_all_course2", + "tax_all_xblock1", + "tax_all_xblock2", + "tax_both_course1", + "tax_both_course2", + "tax_both_xblock1", + "tax_both_xblock2", + ) + def test_view_object_tag(self, tag_attr): + """Content authors can view ObjectTags associated with enabled taxonomies in their org.""" + perm = "oel_tagging.view_objecttag" + perm_item = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, perm_item) + assert self.staff.has_perm(perm, perm_item) + assert self.user_both_orgs.has_perm(perm, perm_item) + assert self.user_org2.has_perm(perm, perm_item) == tag_attr.endswith("2") + assert not self.learner.has_perm(perm, perm_item) + + def test_view_object_tag_diabled(self): + """ + Nobody can view a ObjectTag from a disabled taxonomy + """ + perm = "oel_tagging.view_objecttag" + assert self.superuser.has_perm(perm, self.disabled_course_tag) + assert not self.staff.has_perm(perm, self.disabled_course_tag) + assert not self.user_both_orgs.has_perm(perm, self.disabled_course_tag) + assert not self.user_org2.has_perm(perm, self.disabled_course_tag) + assert not self.learner.has_perm(perm, self.disabled_course_tag) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py new file mode 100644 index 000000000000..6601eb627e9d --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py @@ -0,0 +1,311 @@ +""" +Test for auto-tagging content +""" +from __future__ import annotations + +from unittest.mock import patch + +from django.test import override_settings, LiveServerTestCase +from django.http import HttpRequest +from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_tagging.core.tagging.models import Tag, Taxonomy +from organizations.models import Organization + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase +from openedx.core.lib.blockstore_api import create_collection +from openedx.core.djangoapps.content_libraries.api import create_library, create_library_block, delete_library_block +from openedx.core.lib.blockstore_api.tests.base import BlockstoreAppTestMixin + +from .. import api +from ..models.base import TaxonomyOrg +from ..toggles import CONTENT_TAGGING_AUTO +from ..types import ContentKey + +LANGUAGE_TAXONOMY_ID = -1 + + +class LanguageTaxonomyTestMixin: + """ + Mixin for test cases that expect the Language System Taxonomy to exist. + """ + + def setUp(self): + """ + When pytest runs, it creates the database by inspecting models, not by + running migrations. So data created by our migrations is not present. + In particular, the Language Taxonomy is not present. So this mixin will + create the taxonomy, simulating the effect of the following migrations: + 1. openedx_tagging.core.tagging.migrations.0012_language_taxonomy + 2. content_tagging.migrations.0007_system_defined_org_2 + 3. openedx_tagging.core.tagging.migrations.0015_taxonomy_export_id + """ + super().setUp() + Taxonomy.objects.get_or_create(id=-1, defaults={ + "name": "Languages", + "description": "Languages that are enabled on this system.", + "enabled": True, + "allow_multiple": False, + "allow_free_text": False, + "visible_to_authors": True, + "export_id": "-1_languages", + "_taxonomy_class": "openedx_tagging.core.tagging.models.system_defined.LanguageTaxonomy", + }) + TaxonomyOrg.objects.get_or_create(taxonomy_id=-1, defaults={"org": None}) + + +@skip_unless_cms # Auto-tagging is only available in the CMS +@override_waffle_flag(CONTENT_TAGGING_AUTO, active=True) +class TestAutoTagging( # type: ignore[misc] + LanguageTaxonomyTestMixin, + ModuleStoreTestCase, + BlockstoreAppTestMixin, + LiveServerTestCase +): + """ + Test if the Course and XBlock tags are automatically created + """ + + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + def _check_tag(self, object_key: ContentKey, taxonomy_id: int, value: str | None): + """ + Check if the ObjectTag exists for the given object_id and taxonomy_id + + If value is None, check if the ObjectTag does not exists + """ + object_tags = list(api.get_object_tags(str(object_key), taxonomy_id=taxonomy_id)) + object_tag = object_tags[0] if len(object_tags) == 1 else None + if len(object_tags) > 1: + raise ValueError("Found too many object tags") + if value is None: + assert not object_tag, f"Expected no tag for taxonomy_id={taxonomy_id}, " \ + f"but one found with value={object_tag.value}" + else: + assert object_tag, f"Tag for taxonomy_id={taxonomy_id} with value={value} with expected, but none found" + assert object_tag.value == value, f"Tag value mismatch {object_tag.value} != {value}" + + return True + + def setUp(self): + super().setUp() + # Create user + self.user = UserFactory.create() + self.user_id = self.user.id + + self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") + self.patcher = patch("openedx.core.djangoapps.content_tagging.tasks.modulestore", return_value=self.store) + self.addCleanup(self.patcher.stop) + self.patcher.start() + + def test_create_course(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pl"}, + ) + + # Check if the tags are created in the Course + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Polski") + + @override_settings(LANGUAGE_CODE='pt-br') + def test_create_course_invalid_language(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "11"}, + ) + + # Check if the tags are created in the Course is the system default + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Português (Brasil)") + + @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_DICT={'pt': 'Portuguese'}, LANGUAGE_CODE='pt') + def test_create_course_unsuported_language(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "en"}, + ) + + # Check if the tags are created in the Course is the system default + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + + @override_settings(LANGUAGE_CODE='pt') + def test_create_course_no_tag_default_language(self): + # Remove Portuguese tag + Tag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID, value="Portuguese").delete() + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "11"}, + ) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + def test_update_course(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt-br"}, + ) + + # Simulates user manually changing a tag + lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) + api.tag_object( + object_id=str(course.id), + taxonomy=lang_taxonomy, + tags=["Español (España)"] + ) + + # Update course language + course.language = "en" + self.store.update_item(course, self.user_id) + + # Does not automatically update the tag + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Español (España)") + + def test_create_delete_xblock(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt-br"}, + ) + + # Create XBlocks + sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") + vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") + + usage_key_str = str(vertical.location) + + # Check if the tags are created in the XBlock + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Português (Brasil)") + + # Delete the XBlock + self.store.delete_item(vertical.location, self.user_id) + + # Check if the tags are deleted + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + + @override_waffle_flag(CONTENT_TAGGING_AUTO, active=False) + def test_waffle_disabled_create_update_course(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + # Update course language + course.language = "en" + self.store.update_item(course, self.user_id) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + @override_waffle_flag(CONTENT_TAGGING_AUTO, active=False) + def test_waffle_disabled_create_delete_xblock(self): + # Create course + course = self.store.create_course( + self.orgA.short_name, + "test_course", + "test_run", + self.user_id, + fields={"language": "pt"}, + ) + + # Create XBlocks + sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") + vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") + + usage_key_str = str(vertical.location) + + # No tags created + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, None) + + # Delete the XBlock + self.store.delete_item(vertical.location, self.user_id) + + # Still no tags + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + + def test_create_delete_library_block(self): + # Create collection and library + collection = create_collection("Test library collection") + library = create_library( + collection_uuid=collection.uuid, + org=self.orgA, + slug="lib_a", + title="Library Org A", + description="This is a library from Org A", + ) + + fake_request = HttpRequest() + fake_request.LANGUAGE_CODE = "pt-br" + with patch('crum.get_current_request', return_value=fake_request): + # Create Library Block + library_block = create_library_block(library.key, "problem", "Problem1") + + usage_key_str = str(library_block.usage_key) + + # Check if the tags are created in the Library Block with the user's preferred language + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)') + + # Delete the XBlock + delete_library_block(library_block.usage_key) + + # Check if the tags are deleted + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + + @override_waffle_flag(CONTENT_TAGGING_AUTO, active=False) + def test_waffle_disabled_create_delete_library_block(self): + # Create collection and library + collection = create_collection("Test library collection 2") + library = create_library( + collection_uuid=collection.uuid, + org=self.orgA, + slug="lib_a2", + title="Library Org A 2", + description="This is a library from Org A 2", + ) + + fake_request = HttpRequest() + fake_request.LANGUAGE_CODE = "pt-br" + with patch('crum.get_current_request', return_value=fake_request): + # Create Library Block + library_block = create_library_block(library.key, "problem", "Problem2") + + usage_key_str = str(library_block.usage_key) + + # No tags created + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + + # Delete the XBlock + delete_library_block(library_block.usage_key) + + # Still no tags + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) diff --git a/openedx/core/djangoapps/content_tagging/toggles.py b/openedx/core/djangoapps/content_tagging/toggles.py new file mode 100644 index 000000000000..30a21cf77e51 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/toggles.py @@ -0,0 +1,17 @@ + +""" +Toggles for content tagging +""" + +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +# .. toggle_name: content_tagging.auto +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: Setting this enables automatic tagging of content +# .. toggle_type: feature_flag +# .. toggle_category: admin +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-08-30 +# .. toggle_tickets: https://github.com/openedx/modular-learning/issues/79 +CONTENT_TAGGING_AUTO = CourseWaffleFlag('content_tagging.auto', __name__) diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py new file mode 100644 index 000000000000..64fa0d58f000 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -0,0 +1,17 @@ +""" +Types used by content tagging API and implementation +""" +from __future__ import annotations + +from typing import Dict, List, Union + +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_tagging.core.tagging.models import Taxonomy + +ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey] +ContextKey = Union[LibraryLocatorV2, CourseKey] + +TagValuesByTaxonomyIdDict = Dict[int, List[str]] +TagValuesByObjectIdDict = Dict[str, TagValuesByTaxonomyIdDict] +TaxonomyDict = Dict[int, Taxonomy] diff --git a/openedx/core/djangoapps/content_tagging/urls.py b/openedx/core/djangoapps/content_tagging/urls.py new file mode 100644 index 000000000000..b81c01e1b048 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/urls.py @@ -0,0 +1,10 @@ +""" +Content Tagging URLs +""" +from django.urls import path, include + +from .rest_api import urls + +urlpatterns = [ + path('', include(urls)), +] diff --git a/openedx/core/djangoapps/content_tagging/utils.py b/openedx/core/djangoapps/content_tagging/utils.py new file mode 100644 index 000000000000..dee369109bb5 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/utils.py @@ -0,0 +1,138 @@ +""" +Utils functions for tagging +""" +from __future__ import annotations + +from edx_django_utils.cache import RequestCache +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_tagging.core.tagging.models import Taxonomy +from organizations.models import Organization + +from .models import TaxonomyOrg +from .types import ContentKey, ContextKey + + +def get_content_key_from_string(key_str: str) -> ContentKey: + """ + Get content key from string + """ + try: + return CourseKey.from_string(key_str) + except InvalidKeyError: + try: + return LibraryLocatorV2.from_string(key_str) + except InvalidKeyError: + try: + return UsageKey.from_string(key_str) + except InvalidKeyError as usage_key_error: + raise ValueError("object_id must be a CourseKey, LibraryLocatorV2 or a UsageKey") from usage_key_error + + +def get_context_key_from_key(content_key: ContentKey) -> ContextKey: + """ + Returns the context key from a given content key. + """ + # If the content key is a CourseKey or a LibraryLocatorV2, return it + if isinstance(content_key, (CourseKey, LibraryLocatorV2)): + return content_key + + # If the content key is a UsageKey, return the context key + context_key = content_key.context_key + + if isinstance(context_key, (CourseKey, LibraryLocatorV2)): + return context_key + + raise ValueError("context must be a CourseKey or a LibraryLocatorV2") + + +def get_context_key_from_key_string(key_str: str) -> ContextKey: + """ + Get context key from an key string + """ + content_key = get_content_key_from_string(key_str) + return get_context_key_from_key(content_key) + + +def check_taxonomy_context_key_org(taxonomy: Taxonomy, context_key: ContextKey) -> bool: + """ + Returns True if the given taxonomy can tag a object with the given context_key. + """ + if not context_key.org: + return False + + is_all_org, taxonomy_orgs = TaxonomyOrg.get_organizations(taxonomy) + + if is_all_org: + return True + + # Ensure the object_id's org is among the allowed taxonomy orgs + object_org = rules_cache.get_orgs([context_key.org]) + return bool(object_org) and object_org[0] in taxonomy_orgs + + +class TaggingRulesCache: + """ + Caches data required for computing rules for the duration of the request. + """ + + def __init__(self): + """ + Initializes the request cache. + """ + self.request_cache = RequestCache('openedx.core.djangoapps.content_tagging.utils') + + def clear(self): + """ + Clears the rules cache. + """ + self.request_cache.clear() + + def get_orgs(self, org_names: list[str] | None = None) -> list[Organization]: + """ + Returns the Organizations with the given name(s), or all Organizations if no names given. + + Organization instances are cached for the duration of the request. + """ + cache_key = 'all_orgs' + all_orgs = self.request_cache.data.get(cache_key) + if all_orgs is None: + all_orgs = { + org.short_name: org + for org in Organization.objects.all() + } + self.request_cache.set(cache_key, all_orgs) + + if org_names: + return [ + all_orgs[org_name] for org_name in org_names if org_name in all_orgs + ] + + return all_orgs.values() + + def get_library_orgs(self, user, org_names: list[str]) -> list[Organization]: + """ + Returns the Organizations that are associated with libraries that the given user has explicitly been granted + access to. + + These library orgs are cached for the duration of the request. + """ + # Import the content_libraries api here to avoid circular imports. + from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user + + cache_key = f'library_orgs:{user.id}' + library_orgs = self.request_cache.data.get(cache_key) + if library_orgs is None: + library_orgs = { + library.org.short_name: library.org + for library in get_libraries_for_user(user).select_related('org').only('org') + } + self.request_cache.set(cache_key, library_orgs) + + return [ + library_orgs[org_name] for org_name in org_names if org_name in library_orgs + ] + + +rules_cache = TaggingRulesCache() diff --git a/xmodule/modulestore/tests/test_mixed_modulestore.py b/xmodule/modulestore/tests/test_mixed_modulestore.py index 7e35217a1064..a7b31d2b29ae 100644 --- a/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -538,13 +538,15 @@ def test_get_items_include_orphans(self, default_ms, expected_items_in_tree, orp assert len(items_in_tree) == expected_items_in_tree # draft: + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: get draft, get ancestors up to course (2-6), compute inheritance # sends: update problem and then each ancestor up to course (edit info) # split: - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: definitions (calculator field), structures # sends: 2 sends to update index & structure (note, it would also be definition if a content field changed) - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 6, 5), (ModuleStoreEnum.Type.split, 3, 2, 2)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 6, 5), (ModuleStoreEnum.Type.split, 4, 2, 2)) @ddt.unpack def test_update_item(self, default_ms, num_mysql, max_find, max_send): """ @@ -1069,15 +1071,17 @@ def test_has_changes_missing_child(self, default_ms, default_branch): assert self.store.has_changes(parent) # Draft + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # Find: find parents (definition.children query), get parent, get course (fill in run?), # find parents of the parent (course), get inheritance items, # get item (to delete subtree), get inheritance again. # Sends: delete item, update parent # Split - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # Find: active_versions, 2 structures (published & draft), definition (unnecessary) # Sends: updated draft and published structures and active_versions - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 6, 2), (ModuleStoreEnum.Type.split, 4, 2, 3)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 6, 2), (ModuleStoreEnum.Type.split, 5, 2, 3)) @ddt.unpack def test_delete_item(self, default_ms, num_mysql, max_find, max_send): """ @@ -1099,14 +1103,16 @@ def test_delete_item(self, default_ms, num_mysql, max_find, max_send): self.store.get_item(self.writable_chapter_location, revision=ModuleStoreEnum.RevisionOption.published_only) # Draft: + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: find parent (definition.children), count versions of item, get parent, count grandparents, # inheritance items, draft item, draft child, inheritance # sends: delete draft vertical and update parent # Split: - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: draft and published structures, definition (unnecessary) # sends: update published (why?), draft, and active_versions - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 8, 2), (ModuleStoreEnum.Type.split, 4, 3, 3)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 8, 2), (ModuleStoreEnum.Type.split, 5, 3, 3)) @ddt.unpack def test_delete_private_vertical(self, default_ms, num_mysql, max_find, max_send): """ @@ -1154,13 +1160,15 @@ def test_delete_private_vertical(self, default_ms, num_mysql, max_find, max_send assert vert_loc not in course.children # Draft: + # mysql: check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: find parent (definition.children) 2x, find draft item, get inheritance items # send: one delete query for specific item # Split: - # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record + # mysql: SplitModulestoreCourseIndex - select 2x (by course_id, by objectid), update, update historical record, + # check CONTENT_TAGGING_AUTO CourseWaffleFlag # find: structure (cached) # send: update structure and active_versions - @ddt.data((ModuleStoreEnum.Type.mongo, 0, 3, 1), (ModuleStoreEnum.Type.split, 4, 1, 2)) + @ddt.data((ModuleStoreEnum.Type.mongo, 1, 3, 1), (ModuleStoreEnum.Type.split, 5, 1, 2)) @ddt.unpack def test_delete_draft_vertical(self, default_ms, num_mysql, max_find, max_send): """