perf: reduce number of queries for content tagging endpoints (#34200)

This commit is contained in:
Jillian
2024-02-17 06:45:26 +10:30
committed by GitHub
parent 6353bb2e8e
commit b6366b67b3
15 changed files with 292 additions and 175 deletions

View File

@@ -87,7 +87,7 @@ def set_taxonomy_orgs(
def get_taxonomies_for_org(
enabled=True,
org_owner: Organization | None = None,
org_short_name: str | None = None,
) -> QuerySet:
"""
Generates a list of the enabled Taxonomies available for the given org, sorted by name.
@@ -100,7 +100,6 @@ def get_taxonomies_for_org(
If you want the disabled Taxonomies, pass enabled=False.
If you want all Taxonomies (both enabled and disabled), pass enabled=None.
"""
org_short_name = org_owner.short_name if org_owner else None
return oel_tagging.get_taxonomies(enabled=enabled).filter(
Exists(
TaxonomyOrg.get_relationships(

View File

@@ -64,16 +64,21 @@ class TaxonomyOrg(models.Model):
@classmethod
def get_organizations(
cls, taxonomy: Taxonomy, rel_type: RelType
) -> list[Organization]:
cls, taxonomy: Taxonomy, rel_type=RelType.OWNER,
) -> tuple[bool, list[Organization]]:
"""
Returns the list of Organizations which have the given relationship to the taxonomy.
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
"""
rels = cls.objects.filter(
taxonomy=taxonomy,
rel_type=rel_type,
)
# A relationship with org=None means all Organizations
if rels.filter(org=None).exists():
return list(Organization.objects.all())
return [rel.org for rel in rels]
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)

View File

@@ -6,7 +6,6 @@ from django.db.models import Exists, OuterRef, Q
from rest_framework.filters import BaseFilterBackend
import openedx_tagging.core.tagging.rules as oel_tagging
from organizations.models import Organization
from ...rules import get_admin_orgs, get_user_orgs
from ...models import TaxonomyOrg
@@ -25,9 +24,8 @@ class UserOrgFilterBackend(BaseFilterBackend):
if oel_tagging.is_taxonomy_admin(request.user):
return queryset
orgs = list(Organization.objects.all())
user_admin_orgs = get_admin_orgs(request.user, orgs)
user_orgs = get_user_orgs(request.user, orgs) # Orgs that the user is a content creator or instructor
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()
@@ -67,11 +65,10 @@ class ObjectTagTaxonomyOrgFilterBackend(BaseFilterBackend):
def filter_queryset(self, request, queryset, _):
if oel_tagging.is_taxonomy_admin(request.user):
return queryset
return queryset.prefetch_related('taxonomy__taxonomyorg_set')
orgs = list(Organization.objects.all())
user_admin_orgs = get_admin_orgs(request.user, orgs)
user_orgs = get_user_orgs(request.user, orgs)
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(
@@ -90,4 +87,4 @@ class ObjectTagTaxonomyOrgFilterBackend(BaseFilterBackend):
)
)
)
)
).prefetch_related('taxonomy__taxonomyorg_set')

View File

@@ -4,7 +4,6 @@ API Serializers for content tagging org
from __future__ import annotations
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers, fields
from openedx_tagging.core.tagging.rest_api.v1.serializers import (
@@ -14,26 +13,7 @@ from openedx_tagging.core.tagging.rest_api.v1.serializers import (
from organizations.models import Organization
class OptionalSlugRelatedField(serializers.SlugRelatedField):
"""
Modifies the DRF serializer SlugRelatedField.
Non-existent slug values are represented internally as an empty queryset, instead of throwing a validation error.
"""
def to_internal_value(self, data):
"""
Returns the object related to the given slug value, or an empty queryset if not found.
"""
queryset = self.get_queryset()
try:
return queryset.get(**{self.slug_field: data})
except ObjectDoesNotExist:
return queryset.none()
except (TypeError, ValueError):
self.fail('invalid')
from ...models import TaxonomyOrg
class TaxonomyOrgListQueryParamsSerializer(TaxonomyListQueryParamsSerializer):
@@ -41,13 +21,22 @@ class TaxonomyOrgListQueryParamsSerializer(TaxonomyListQueryParamsSerializer):
Serializer for the query params for the GET view
"""
org: fields.Field = OptionalSlugRelatedField(
slug_field="short_name",
queryset=Organization.objects.all(),
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):
"""
@@ -86,14 +75,20 @@ class TaxonomyOrgSerializer(TaxonomySerializer):
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]
"""
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.
"""
return obj.taxonomyorg_set.filter(org__isnull=True).exists()
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

View File

@@ -126,6 +126,11 @@ class TestTaxonomyObjectsMixin:
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",
@@ -498,27 +503,37 @@ class TestTaxonomyListCreateViewSet(TestTaxonomyObjectsMixin, APITestCase):
if user_attr == "staffA":
assert response.data["orgs"] == [self.orgA.short_name]
def test_list_taxonomy_query_count(self):
@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'
self.client.force_authenticate(user=self.staff)
with self.assertNumQueries(16): # TODO Why so many queries?
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"]
assert len(response.data["results"]) == 2
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"]
assert taxonomy["can_delete_taxonomy"]
assert taxonomy["can_change_taxonomy"] == user.is_staff
assert taxonomy["can_delete_taxonomy"] == user.is_staff
assert taxonomy["can_tag_object"]
@@ -753,7 +768,7 @@ class TestTaxonomyDetailExportMixin(TestTaxonomyObjectsMixin):
user_attr=user_attr,
taxonomy_attr="ot1",
expected_status=status.HTTP_404_NOT_FOUND,
reason="Only staff should see taxonomies with no org",
reason="Only taxonomy admins should see taxonomies with no org",
)
@ddt.data(
@@ -1232,11 +1247,12 @@ class TestTaxonomyUpdateOrg(TestTaxonomyObjectsMixin, APITestCase):
self.client.force_authenticate(user=user)
response = self.client.put(url, {"orgs": []}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
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:
@@ -1642,14 +1658,15 @@ class TestObjectTagViewSet(TestObjectTagMixin, APITestCase):
assert response.status_code == status.HTTP_400_BAD_REQUEST
@ddt.data(
("staff", status.HTTP_200_OK),
("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 global admins can add a taxonomy from orgA to an object from orgB
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)
@@ -1661,14 +1678,15 @@ class TestObjectTagViewSet(TestObjectTagMixin, APITestCase):
assert response.status_code == expected_status
@ddt.data(
("staff", status.HTTP_200_OK),
("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 global admins can add a no-org taxonomy to an object
Tests that only superusers may add a no-org taxonomy to an object
"""
user = getattr(self, user_attr)
self.client.force_authenticate(user=user)
@@ -1760,26 +1778,43 @@ class TestObjectTagViewSet(TestObjectTagMixin, APITestCase):
assert status.is_success(response3.status_code)
assert response3.data[str(self.courseA)]["taxonomies"] == expected_tags
def test_object_tags_query_count(self):
@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 = self.courseA
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": True},
{"value": "anvil", "lineage": ["ALPHABET", "anvil"], "can_delete_objecttag": True},
{"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)
self.client.force_authenticate(user=self.staff)
with self.assertNumQueries(7): # TODO Why so many queries?
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"]
assert response.data[object_id]["taxonomies"][0]["can_tag_object"] == expected_perm
assert response.data[object_id]["taxonomies"][0]["tags"] == expected_tags
@@ -2364,19 +2399,30 @@ class TestTaxonomyTagsViewSet(TestTaxonomyObjectsMixin, APITestCase):
"""
Test cases for TaxonomyTagsViewSet retrive action.
"""
def test_taxonomy_tags_query_count(self):
@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)
self.client.force_authenticate(user=self.staff)
with self.assertNumQueries(13): # TODO Why so many queries?
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"]
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"]
assert taxonomy["can_delete_tag"]
assert taxonomy["can_change_tag"] == user.is_staff
assert taxonomy["can_delete_tag"] == user.is_staff

View File

@@ -6,6 +6,7 @@ from __future__ import annotations
import csv
from typing import Iterator
from django.db.models import Count
from django.http import StreamingHttpResponse
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
@@ -67,13 +68,8 @@ class TaxonomyOrgView(TaxonomyView):
query_params = TaxonomyOrgListQueryParamsSerializer(data=self.request.query_params.dict())
query_params.is_valid(raise_exception=True)
enabled = query_params.validated_data.get("enabled", None)
unassigned = query_params.validated_data.get("unassigned", None)
org = query_params.validated_data.get("org", None)
# Raise an error if both "org" and "unassigned" query params were provided
if "org" in query_params.validated_data and "unassigned" in query_params.validated_data:
raise ValidationError("'org' and 'unassigned' params cannot be both defined")
# 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)
@@ -82,7 +78,13 @@ class TaxonomyOrgView(TaxonomyView):
else:
queryset = get_taxonomies(enabled)
return queryset.prefetch_related("taxonomyorg_set")
# 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):
"""

View File

@@ -7,11 +7,9 @@ from typing import Union
import django.contrib.auth.models
import openedx_tagging.core.tagging.rules as oel_tagging
import rules
from django.db.models import Q
from organizations.models import Organization
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from common.djangoapps.student.models import CourseAccessRole
from common.djangoapps.student.roles import (
CourseInstructorRole,
CourseStaffRole,
@@ -20,11 +18,12 @@ from common.djangoapps.student.roles import (
OrgLibraryUserRole,
OrgStaffRole
)
from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user
from .models import TaxonomyOrg
from .utils import get_context_key_from_key_string
from .utils import get_context_key_from_key_string, TaggingRulesCache
rules_cache = TaggingRulesCache()
UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser]
@@ -32,7 +31,6 @@ 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
@@ -45,19 +43,19 @@ def is_org_user(user: UserType, orgs: list[Organization]) -> bool:
def get_admin_orgs(user: UserType, orgs: list[Organization] | None = None) -> list[Organization]:
"""
Returns a list of orgs that the given user is an admin, from the given list of orgs.
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 = Organization.objects.all() if orgs is None else 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]:
def _get_content_creator_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
"""
Returns a list of orgs that the given user is a content creator, the given list of orgs.
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 (
@@ -68,43 +66,69 @@ def get_content_creator_orgs(user: UserType, orgs: list[Organization]) -> list[O
]
def get_instructor_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
def _get_course_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
"""
Returns a list of orgs that the given user is an instructor, from the given list of orgs.
"""
instructor_roles = CourseAccessRole.objects.filter(
org__in=(org.short_name for org in orgs),
user=user,
role__in=(CourseStaffRole.ROLE, CourseInstructorRole.ROLE),
)
instructor_orgs = [role.org for role in instructor_roles]
return [org for org in orgs if org.short_name in instructor_orgs]
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 RolesCache here to avoid re-querying.
# This cache gets populated the first time the user's permissions are checked (i.e when
# _get_content_creator_orgs is called).
# pylint: disable=protected-access
roles_cache = user._roles
assert roles_cache
return any(
access_role.role in roles_cache.get_roles(role_name) and
access_role.org == org_name
for access_role in roles_cache._roles
)
def get_library_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
"""
Returns a list of orgs that the given user has explicity permission, from the given list of orgs.
"""
return [
org for org in orgs if (
len(get_libraries_for_user(user, org=org.short_name)) > 0
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_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
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.
"""
content_creator_orgs = get_content_creator_orgs(user, orgs)
instructor_orgs = get_instructor_orgs(user, orgs)
library_user_orgs = get_library_user_orgs(user, orgs)
user_orgs = list(set(content_creator_orgs) | set(instructor_orgs) | set(library_user_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.
@@ -141,21 +165,12 @@ def can_view_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
if oel_tagging.is_taxonomy_admin(user):
return True
is_all_org = TaxonomyOrg.objects.filter(
taxonomy=taxonomy,
org=None,
rel_type=TaxonomyOrg.RelType.OWNER,
).exists()
is_all_org, taxonomy_orgs = TaxonomyOrg.get_organizations(taxonomy)
# Enabled all-org taxonomies can be viewed by any registred user
# Enabled all-org taxonomies can be viewed by any registered user
if is_all_org:
return taxonomy.enabled
taxonomy_orgs = TaxonomyOrg.get_organizations(
taxonomy=taxonomy,
rel_type=TaxonomyOrg.RelType.OWNER,
)
# Org-level staff can view any taxonomy that is associated with one of their orgs.
if is_org_admin(user, taxonomy_orgs):
return True
@@ -191,21 +206,12 @@ def can_change_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
if oel_tagging.is_taxonomy_admin(user):
return True
is_all_org = TaxonomyOrg.objects.filter(
taxonomy=taxonomy,
org=None,
rel_type=TaxonomyOrg.RelType.OWNER,
).exists()
is_all_org, taxonomy_orgs = TaxonomyOrg.get_organizations(taxonomy)
# Only taxonomy admins can edit all org taxonomies
if is_all_org:
return False
taxonomy_orgs = TaxonomyOrg.get_organizations(
taxonomy=taxonomy,
rel_type=TaxonomyOrg.RelType.OWNER,
)
# Org-level staff can edit any taxonomy that is associated with one of their orgs.
if is_org_admin(user, taxonomy_orgs):
return True
@@ -223,10 +229,15 @@ def can_change_object_tag_objectid(user: UserType, object_id: str) -> bool:
try:
context_key = get_context_key_from_key_string(object_id)
except ValueError:
assert context_key.org
except (ValueError, AssertionError):
return False
return has_studio_write_access(user, context_key)
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
@@ -245,17 +256,25 @@ def can_view_object_tag_taxonomy(user: UserType, taxonomy: oel_tagging.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 tag it.
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")
try:
context_key = get_context_key_from_key_string(object_id)
except ValueError:
if not user.is_authenticated:
return False
return has_studio_read_access(user, context_key)
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
@@ -263,32 +282,28 @@ def can_change_object_tag(
user: UserType, perm_obj: oel_tagging.ObjectTagPermissionItem | None = None
) -> bool:
"""
Checks if the user has permissions to create or modify tags on the given taxonomy and object_id.
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 not oel_tagging.can_change_object_tag(user, perm_obj):
return False
if oel_tagging.can_change_object_tag(user, perm_obj):
if perm_obj and perm_obj.taxonomy and perm_obj.object_id:
# can_change_object_tag_objectid already checked that object_id is valid and has an org,
# so these statements will not fail. But we need to assert to keep the type checker happy.
try:
context_key = get_context_key_from_key_string(perm_obj.object_id)
assert context_key.org
except (ValueError, AssertionError):
return False # pragma: no cover
is_all_org, taxonomy_orgs = TaxonomyOrg.get_organizations(perm_obj.taxonomy)
if not is_all_org:
# 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
# The following code allows METHOD permission (PUT) in the viewset for everyone
if perm_obj is None:
return True
# TaxonomySerializer use this rule passing object_id = "" to check if the user
# can use the taxonomy
if perm_obj.object_id == "":
return True
# Also skip taxonomy check if the taxonomy is not set
if not perm_obj.taxonomy:
return True
# Taxonomy admins can tag any object using any taxonomy
if oel_tagging.is_taxonomy_admin(user):
return True
context_key = get_context_key_from_key_string(perm_obj.object_id)
org_short_name = context_key.org
return perm_obj.taxonomy.taxonomyorg_set.filter(Q(org__short_name=org_short_name) | Q(org=None)).exists()
return False
@rules.predicate
@@ -324,7 +339,6 @@ rules.set_perm("oel_tagging.view_tag", rules.always_allow)
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.view_objecttag", oel_tagging.can_view_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

View File

@@ -157,11 +157,11 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase):
)
@ddt.unpack
def test_get_taxonomies_for_org(self, org_attr, enabled, expected):
org_owner = getattr(self, org_attr) if org_attr else None
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_owner=org_owner, enabled=enabled
org_short_name=org_owner, enabled=enabled
)
)
assert taxonomies == [

View File

@@ -537,7 +537,7 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
"""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 self.staff.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)
@@ -546,10 +546,11 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
"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 can create/edit an ObjectTag using taxonomies in their org,
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:
@@ -588,7 +589,7 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
"tax_both_xblock2",
)
def test_view_object_tag(self, tag_attr):
"""Anyone can view any ObjectTag"""
"""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)

View File

@@ -3,9 +3,13 @@ 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 organizations.models import Organization
from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user
from .types import ContentKey
@@ -42,3 +46,57 @@ def get_context_key_from_key_string(key_str: str) -> CourseKey | LibraryLocatorV
return context_key
raise ValueError("context must be a CourseKey or a LibraryLocatorV2")
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.rules')
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.
"""
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
]

View File

@@ -108,7 +108,7 @@ libsass==0.10.0
click==8.1.6
# pinning this version to avoid updates while the library is being developed
openedx-learning==0.5.1
openedx-learning==0.6.2
# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.
openai<=0.28.1

View File

@@ -781,7 +781,7 @@ openedx-filters==1.6.0
# via
# -r requirements/edx/kernel.in
# lti-consumer-xblock
openedx-learning==0.5.1
openedx-learning==0.6.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in

View File

@@ -1309,7 +1309,7 @@ openedx-filters==1.6.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# lti-consumer-xblock
openedx-learning==0.5.1
openedx-learning==0.6.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt

View File

@@ -921,7 +921,7 @@ openedx-filters==1.6.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
openedx-learning==0.5.1
openedx-learning==0.6.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt

View File

@@ -979,7 +979,7 @@ openedx-filters==1.6.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
openedx-learning==0.5.1
openedx-learning==0.6.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt