Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
Open edX Learning ("Learning Core").
"""
__version__ = "0.1.5"
__version__ = "0.1.6"
35 changes: 27 additions & 8 deletions openedx_tagging/core/tagging/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,11 +385,30 @@ 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

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

Expand All @@ -399,17 +418,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 @@ -429,7 +448,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
16 changes: 16 additions & 0 deletions openedx_tagging/core/tagging/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,19 @@ class Meta:
"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)
83 changes: 73 additions & 10 deletions openedx_tagging/core/tagging/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
"""
from django.db import models
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
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_object_tags, get_taxonomies, get_taxonomy
from ...api import create_taxonomy, get_object_tags, get_taxonomies, get_taxonomy, tag_object
from ...models import Taxonomy
from ...rules import ChangeObjectTagPermissionItem
from .permissions import ObjectTagObjectPermissions, TaxonomyObjectPermissions
from .serializers import (
ObjectTagListQueryParamsSerializer,
ObjectTagSerializer,
ObjectTagUpdateBodySerializer,
ObjectTagUpdateQueryParamsSerializer,
TaxonomyListQueryParamsSerializer,
TaxonomySerializer,
)
Expand Down Expand Up @@ -167,10 +169,9 @@ def perform_create(self, serializer) -> None:
serializer.instance = create_taxonomy(**serializer.validated_data)


class ObjectTagView(ReadOnlyModelViewSet):
class ObjectTagView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, GenericViewSet):
"""
View to retrieve paginated ObjectTags for an Object, given its Object ID.
(What tags does this object have?)
View to retrieve paginated ObjectTags for a provided Object ID (object_id).

**Retrieve Parameters**
* object_id (required): - The Object ID to retrieve ObjectTags for.
Expand All @@ -195,7 +196,14 @@ class ObjectTagView(ReadOnlyModelViewSet):
* 403 - Permission denied
* 405 - Method not allowed

**Update Parameters**
* object_id (required): - The Object ID to add 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**
* 200 - Success
* 403 - Permission denied
* 405 - Method not allowed

Expand Down Expand Up @@ -224,8 +232,8 @@ def get_queryset(self) -> models.QuerySet:

def retrieve(self, request, object_id=None):
"""
Retrieve ObjectTags that belong to a given Object given its
object_id and return paginated results.
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
Expand All @@ -238,3 +246,58 @@ def retrieve(self, request, object_id=None):
paginated_object_tags = self.paginate_queryset(object_tags)
serializer = ObjectTagSerializer(paginated_object_tags, many=True)
return self.get_paginated_response(serializer.data)

def update(self, request, object_id, partial=False):
"""
Update ObjectTags that belong to a given object_id and
return the list of these ObjecTags paginated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The response isn't really paginated is it? I don't think we need pagination.

Also, do we have a limit on how many tags can be applied to one object? If not, I'd like to add a limit in. Something like 100 so that we can avoid any need for pagination in the object tag APIs. That can come in a separate task though, no need to do in this PR. Just let me know what the current status is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It calls the retrieve method, which is paginated.

We don't have a limit on tags per object.

I think the idea here is to return the default GET format to make the refresh on the frontend easier and save 1 extra call.

Personally, I prefer to return no data in the PUT/POST requests and let the frontend handle the logic to make another GET call to get refreshed data. We can fine-tune this when we make the frontend.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@rpenido We will need to have a limit on tags per object. I created openedx/modular-learning#99 but it's not ready yet as I'm waiting for the product folks to confirm what the actual limit is. When we implement that ticket we can probably remove pagination from the GET API.

I personally usually like to make the POST/PUT API return the same thing as the GET API. It saves the frontend from making an extra request.


Pass a list of Tag ids or Tag values to be applied to an 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"

perm_obj = ChangeObjectTagPermissionItem(
taxonomy=taxonomy,
object_id=object_id,
)

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

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)
56 changes: 46 additions & 10 deletions openedx_tagging/core/tagging/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
import django.contrib.auth.models
# typing support in rules depends on https://github.com/dfunckt/django-rules/pull/177
import rules # type: ignore[import]
from attrs import define

from .models import ObjectTag, Tag, Taxonomy
from .models import Tag, Taxonomy

UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser]

Expand All @@ -19,6 +20,16 @@
is_taxonomy_admin: Callable[[UserType], bool] = rules.is_staff


@define
class ChangeObjectTagPermissionItem:
"""
Pair of taxonomy and object_id used for permission checking.
"""

taxonomy: Taxonomy
object_id: str


@rules.predicate
def can_view_taxonomy(user: UserType, taxonomy: Taxonomy | None = None) -> bool:
"""
Expand Down Expand Up @@ -53,18 +64,39 @@ def can_change_tag(user: UserType, tag: Tag | None = None) -> bool:


@rules.predicate
def can_change_object_tag(user: UserType, object_tag: ObjectTag | None = None) -> bool:
def can_change_object_tag_objectid(_user: UserType, _object_id: str) -> bool:
"""
Taxonomy admins can create or modify object tags on enabled taxonomies.
Nobody can create or modify object tags without checking the permission for the tagged object.

This rule should be defined in other apps for proper permission checking.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We have some options here:

  1. return True for this app (I think it is ok to tag everything here: we are tagging str)
  2. return False and change the tests accordingly (everything will return 403 and will barely cover the implemented code in the rest_api)
  3. return False and change the tests accordingly (everything will return 403) AND duplicate the tests patching this permission (forcing True) to actually test the workflow.

In the actual context, 1 is the faster/easier path. For 2 and 3, I must fix the test_rules.py and test_views.py. Let me know what you think!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In edx-platform, we'll be adding a rule that defines how "content objects" are tagged.

So in the tests, can't we add a new rule just for tests that defines how "Foo objects" are tagged? And then confirms that the rule is enforced by trying to tag some Foo objects?

In other words, the tests should work in a similar way to how the platform code is going to work. I would prefer no patching and I want to see the actual functionality being tested so I don't like 2 or 3.

Please review openedx/modular-learning#98 , I created this ticket for next sprint to clearly define the missed requirements here around permissions. If you think these tests will take a while, we can just return True for now and make sure the permissions are properly tested when we implement that ticket but I'd prefer to make sure that the base functionality in openedx-learning is implemented correctly now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So in the tests, can't we add a new rule just for tests that defines how "Foo objects" are tagged? And then confirms that the rule is enforced by trying to tag some Foo objects?

Awesome idea @bradenmacdonald!

@rpenido rpenido Sep 1, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@bradenmacdonald I would like to add some tests to check if we can't tag anything but "abc". Just give me a minute before merge!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Phew! It had a copy/paste error. Tested and fixed 85b320a

I think we are good to go now @bradenmacdonald!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like a build error. No rush, just let me know when it's green and ready :)

@rpenido rpenido Sep 1, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think we are good to go now @bradenmacdonald!

Too soon.

openedx_tagging/core/tagging/rules.py:92: error: Argument 2 to "has_perm" of "PermissionsMixin" has incompatible type "str"; expected "Optional[Union[Model, AnonymousUser]]"  [arg-type]
openedx_tagging/core/tagging/rules.py:92: error: Argument 2 to "has_perm" of "AnonymousUser" has incompatible type "str"; expected "Optional[Union[Model, AnonymousUser]]"  [arg-type]

Where mypi gets these types from?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@rpenido According to the django docs for has_perm it seems like the second object is usually an object, but here we're passing in a string (the object ID). So I think the type check is mostly correct. BUT here we don't have objects, we have object IDs, and the API doesn't actually care how you define the permissions checks. I think defining them to use the object_id is quite reasonable. So just add a comment explaining that and put #type: ignore[arg-type] at the end of the line to suppress the warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you @bradenmacdonald! We are good now!²

"""
taxonomy = (
object_tag.taxonomy.cast() if (object_tag and object_tag.taxonomy) 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)
return False


@rules.predicate
def can_change_object_tag(user: UserType, perm_obj: ChangeObjectTagPermissionItem | None = None) -> bool:
"""
Checks if the user has permissions to create or modify tags on the given taxonomy and object_id.
"""

# The following code allows METHOD permission (PUT) in the viewset for everyone
if perm_obj is None:
return True

# Checks the permission for the taxonomy
taxonomy_perm = user.has_perm("oel_tagging.change_objecttag_taxonomy", perm_obj.taxonomy)
if not taxonomy_perm:
return False

# Checks the permission for the object_id
objectid_perm = user.has_perm(
"oel_tagging.change_objecttag_objectid",
# The obj arg expects an object, but we are passing a string
perm_obj.object_id, # type: ignore[arg-type]
)

return objectid_perm


# Taxonomy
rules.add_perm("oel_tagging.add_taxonomy", can_change_taxonomy)
Expand All @@ -81,5 +113,9 @@ def can_change_object_tag(user: UserType, object_tag: ObjectTag | None = None) -
# ObjectTag
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.delete_objecttag", can_change_object_tag)
rules.add_perm("oel_tagging.view_objecttag", rules.always_allow)

# Users can tag objects using tags from any taxonomy that they have permission to view
rules.add_perm("oel_tagging.change_objecttag_taxonomy", can_view_taxonomy)
rules.add_perm("oel_tagging.change_objecttag_objectid", can_change_object_tag_objectid)
Loading