Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Rpenido/fal 3474 implement tagging rest api tag object #9

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.4"
__version__ = "0.1.6"
14 changes: 5 additions & 9 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,14 @@ def resync_object_tags(object_tags: QuerySet = None) -> int:


def get_object_tags(
object_id: str, taxonomy: Taxonomy = None, valid_only=True
) -> Iterator[ObjectTag]:
object_id: str, taxonomy_id: str = None
) -> QuerySet:
"""
Generates a list of object tags for a given object.
Returns a Queryset of object tags for a given object.

Pass taxonomy to limit the returned object_tags to a specific taxonomy.

Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too.
Invalid tags will (probably) be hidden from learners.
"""
taxonomy = get_taxonomy(taxonomy_id)
ObjectTagClass = taxonomy.object_tag_class if taxonomy else ObjectTag
tags = (
ObjectTagClass.objects.filter(
Expand All @@ -117,9 +115,7 @@ def get_object_tags(
if taxonomy:
tags = tags.filter(taxonomy=taxonomy)

for object_tag in tags:
if not valid_only or object_tag.is_valid():
yield object_tag
return tags


def delete_object_tags(object_id: str):
Expand Down
36 changes: 28 additions & 8 deletions openedx_tagging/core/tagging/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,31 @@ def tag_object(
"""
Replaces the existing ObjectTag entries for the current taxonomy + object_id with the given list of tags.
If self.allows_free_text, then the list should be a list of tag values.
Otherwise, it should be a list of existing Tag IDs.
Otherwise, it should be either a list of existing Tag Values or IDs.
Raised ValueError if the proposed tags are invalid for this taxonomy.
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags.
"""

def _find_object_tag_index(tag_ref, object_tags) -> int:
"""
Search for Tag in the given list of ObjectTags by tag_ref or value,
returning its index or -1 if not found.
"""
return next(
(
i
for i, object_tag in enumerate(object_tags)
if object_tag.tag_ref == tag_ref or object_tag.value == tag_ref
),
-1,
)


if not isinstance(tags, list):
raise ValueError(_(f"Tags must be a list, not {type(tags).__name__}."))

tags = list(dict.fromkeys(tags)) # Remove duplicates preserving order
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing duplicates to clean lists like ["Tag 1", "Tag 1"] instead of throwing an IntegrityError on save

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


if not self.allow_multiple and len(tags) > 1:
raise ValueError(_(f"Taxonomy ({self.id}) only allows one tag per object."))

Expand All @@ -395,17 +415,17 @@ def tag_object(
)

ObjectTagClass = self.object_tag_class
current_tags = {
tag.tag_ref: tag
for tag in ObjectTagClass.objects.filter(
current_tags = list(
ObjectTagClass.objects.filter(
taxonomy=self,
object_id=object_id,
)
}
)
updated_tags = []
for tag_ref in tags:
if tag_ref in current_tags:
object_tag = current_tags.pop(tag_ref)
object_tag_index = _find_object_tag_index(tag_ref, current_tags)
if object_tag_index >= 0:
object_tag = current_tags.pop(object_tag_index)
else:
object_tag = ObjectTagClass(
taxonomy=self,
Expand All @@ -425,7 +445,7 @@ def tag_object(
object_tag.save()

# ...and delete any omitted existing tags
for old_tag in current_tags.values():
for old_tag in current_tags:
old_tag.delete()

return updated_tags
Expand Down
14 changes: 13 additions & 1 deletion openedx_tagging/core/tagging/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Taxonomy permissions
Tagging permissions
"""

from rest_framework.permissions import DjangoObjectPermissions
Expand All @@ -15,3 +15,15 @@ class TaxonomyObjectPermissions(DjangoObjectPermissions):
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}


class ObjectTagObjectPermissions(DjangoObjectPermissions):
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": ["%(app_label)s.view_%(model_name)s"],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}
42 changes: 41 additions & 1 deletion openedx_tagging/core/tagging/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from rest_framework import serializers

from openedx_tagging.core.tagging.models import Taxonomy
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy


class TaxonomyListQueryParamsSerializer(serializers.Serializer):
Expand All @@ -29,3 +29,43 @@ class Meta:
"system_defined",
"visible_to_authors",
]


class ObjectTagListQueryParamsSerializer(serializers.Serializer):
"""
Serializer for the query params for the ObjectTag GET view
"""

taxonomy = serializers.PrimaryKeyRelatedField(queryset=Taxonomy.objects.all(), required=False)


class ObjectTagSerializer(serializers.ModelSerializer):
"""
Serializer for the ObjectTag model.
"""

class Meta:
model = ObjectTag
fields = [
"name",
"value",
"taxonomy_id",
"tag_ref",
"is_valid",
]


class ObjectTagUpdateBodySerializer(serializers.Serializer):
"""
Serializer of the body for the ObjectTag UPDATE view
"""

tags = serializers.ListField(child=serializers.CharField(), required=True)


class ObjectTagUpdateQueryParamsSerializer(serializers.Serializer):
"""
Serializer of the query params for the ObjectTag UPDATE view
"""

taxonomy = serializers.PrimaryKeyRelatedField(queryset=Taxonomy.objects.all(), required=True)
1 change: 1 addition & 0 deletions openedx_tagging/core/tagging/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@

router = DefaultRouter()
router.register("taxonomies", views.TaxonomyView, basename="taxonomy")
router.register("object_tags", views.ObjectTagView, basename="object_tag")

urlpatterns = [path("", include(router.urls))]
159 changes: 152 additions & 7 deletions openedx_tagging/core/tagging/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@
Tagging API Views
"""
from django.http import Http404
from rest_framework.viewsets import ModelViewSet
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
from rest_framework.viewsets import GenericViewSet, ModelViewSet

from ...api import (
create_taxonomy,
get_taxonomy,
get_taxonomies,
from ...api import create_taxonomy, get_object_tags, get_taxonomies, get_taxonomy, tag_object
from .permissions import ObjectTagObjectPermissions, TaxonomyObjectPermissions
from .serializers import (
ObjectTagListQueryParamsSerializer,
ObjectTagSerializer,
ObjectTagUpdateBodySerializer,
ObjectTagUpdateQueryParamsSerializer,
TaxonomyListQueryParamsSerializer,
TaxonomySerializer,
)
from .permissions import TaxonomyObjectPermissions
from .serializers import TaxonomyListQueryParamsSerializer, TaxonomySerializer


class TaxonomyView(ModelViewSet):
Expand Down Expand Up @@ -145,3 +151,142 @@ def perform_create(self, serializer):
Create a new taxonomy.
"""
serializer.instance = create_taxonomy(**serializer.validated_data)


class ObjectTagView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, GenericViewSet):
"""
View to retrieve paginated ObjectTags for a provided Object ID (object_id).

**Retrieve Parameters**
* object_id (required): - The Object ID to retrieve ObjectTags for.

**Retrieve Query Parameters**
* taxonomy (optional) - PK of taxonomy to filter ObjectTags for.
* page (optional) - Page number of paginated results.
* page_size (optional) - Number of results included in each page.

**Retrieve Example Requests**
GET api/tagging/v1/object_tags/:object_id
GET api/tagging/v1/object_tags/:object_id?taxonomy=1
GET api/tagging/v1/object_tags/:object_id?taxonomy=1&page=2
GET api/tagging/v1/object_tags/:object_id?taxonomy=1&page=2&page_size=10

**Retrieve Query Returns**
* 200 - Success
* 400 - Invalid query parameter
* 403 - Permission denied

**Create Query Returns**
* 403 - Permission denied
* 405 - Method not allowed

**Update Parameters**
* object_id (required): - The Object ID to retrieve ObjectTags for.

**Update Request Body**
* tags: List of tags to be applied to a object id. Must be a list of Tag ids or Tag values.


**Update Query Returns**
ToDo: update docstring
* 403 - Permission denied
* 405 - Method not allowed

**Delete Query Returns**
* 403 - Permission denied
* 405 - Method not allowed
"""

serializer_class = ObjectTagSerializer
permission_classes = [ObjectTagObjectPermissions]
lookup_field = "object_id"

def get_queryset(self):
"""
Return a queryset of object tags for a given object.

If a taxonomy is passed in, object tags are limited to that taxonomy.
"""
object_id = self.kwargs.get("object_id")
query_params = ObjectTagListQueryParamsSerializer(
data=self.request.query_params.dict()
)
query_params.is_valid(raise_exception=True)
taxonomy_id = query_params.data.get("taxonomy", None)
return get_object_tags(object_id, taxonomy_id)

def retrieve(self, request, object_id=None):
"""
Retrieve ObjectTags that belong to a given object_id and
return paginated results.

Note: We override `retrieve` here instead of `list` because we are
passing in the Object ID (object_id) in the path (as opposed to passing
it in as a query_param) to retrieve the related ObjectTags.
By default retrieve would expect an ObjectTag ID to be passed in the
path and returns a it as a single result however that is not
behavior we want.
"""
object_tags = self.get_queryset()
paginated_object_tags = self.paginate_queryset(object_tags)
serializer = ObjectTagSerializer(paginated_object_tags, many=True)
return self.get_paginated_response(serializer.data)

@swagger_auto_schema(
query_serializer=ObjectTagUpdateQueryParamsSerializer,
request_body=ObjectTagUpdateBodySerializer,
responses={
200: ObjectTagSerializer,
400: "Invalid request body",
403: "Permission denied",
},
)
def update(self, request, object_id=None, partial=False):
"""
Update ObjectTags that belong to a given object_id and
return the list of these ObjecTags paginated.

Pass a list of Tag ids or Tag values to be applied to a object id in the
body `tag` parameter. Passing an empty list will remove all tags from
the object id.

**Example Body Requests**

PUT api/tagging/v1/object_tags/:object_id

**Example Body Requests**
```json
{
"tags": [1, 2, 3]
},
{
"tags": ["Tag 1", "Tag 2"]
},
{
"tags": []
}
"""

if partial:
raise MethodNotAllowed("PATCH", detail="PATCH not allowed")

query_params = ObjectTagUpdateQueryParamsSerializer(data=request.query_params.dict())
query_params.is_valid(raise_exception=True)
taxonomy = query_params.validated_data.get("taxonomy", None)
taxonomy = taxonomy.cast()

perm = f"{taxonomy._meta.app_label}.change_objecttag"

if not request.user.has_perm(perm, taxonomy):
raise PermissionDenied("You do not have permission to change object tags for this taxonomy.")

body = ObjectTagUpdateBodySerializer(data=request.data)
body.is_valid(raise_exception=True)

tags = body.data.get("tags", [])
try:
tag_object(taxonomy, tags, object_id)
except ValueError as e:
raise ValidationError(e)

return self.retrieve(request, object_id)
28 changes: 15 additions & 13 deletions openedx_tagging/core/tagging/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,20 @@ def can_change_tag(user: User, tag: Tag = None) -> bool:


@rules.predicate
def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool:
def can_change_object_tag(user: User, taxonomy: Taxonomy = None) -> bool:
"""
Taxonomy admins can create or modify object tags on enabled taxonomies.
Everyone can potentially create/edit object tags (taxonomy=None). The object permission must be checked
to determine if the user can create/edit a object_tag for a specific taxonomy.

Everyone can create or modify object tags on enabled taxonomies.
Only taxonomy admins can create or modify object tags on disabled taxonomies.
"""
taxonomy = (
object_tag.taxonomy.cast() if (object_tag and object_tag.taxonomy_id) else None
)
object_tag = taxonomy.object_tag_class.cast(object_tag) if taxonomy else object_tag
return is_taxonomy_admin(user) and (
not object_tag or not taxonomy or (taxonomy and taxonomy.enabled)
)
if not taxonomy:
return True

taxonomy = taxonomy.cast()

return taxonomy.enabled or is_taxonomy_admin(user)

# Taxonomy
rules.add_perm("oel_tagging.add_taxonomy", can_change_taxonomy)
Expand All @@ -73,7 +75,7 @@ def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool:
rules.add_perm("oel_tagging.view_tag", rules.always_allow)

# ObjectTag
rules.add_perm("oel_tagging.add_object_tag", can_change_object_tag)
rules.add_perm("oel_tagging.change_object_tag", can_change_object_tag)
rules.add_perm("oel_tagging.delete_object_tag", is_taxonomy_admin)
rules.add_perm("oel_tagging.view_object_tag", rules.always_allow)
rules.add_perm("oel_tagging.add_objecttag", can_change_object_tag)
rules.add_perm("oel_tagging.change_objecttag", can_change_object_tag)
rules.add_perm("oel_tagging.delete_objecttag", is_taxonomy_admin)
rules.add_perm("oel_tagging.view_objecttag", rules.always_allow)