from django.db import transaction from django.urls import path from rest_framework import routers, viewsets, status from rest_framework.decorators import authentication_classes, api_view, permission_classes, action from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from authentication.models import ToolshedUser, KnownIdentity, Group from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw from files.models import File from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer router = routers.SimpleRouter() def inventory_items(identity): try: user = identity.user.get() if user: for item in user.inventory_items.all(): yield item except ToolshedUser.DoesNotExist: pass for friend in identity.friends.all(): friend_user = friend.user.first() if friend_user: for item in friend_user.inventory_items.all(): if item.availability_policy != 'private': yield item class InventoryItemViewSet(viewsets.ModelViewSet): serializer_class = InventoryItemSerializer authentication_classes = [SignatureAuthentication] permission_classes = [IsAuthenticated] # Detail routes address an item by its owner-scoped id, not the internal row id - the # router still names the URL capture group 'pk', so keep that as lookup_url_kwarg and just # change which model field it's matched against. get_queryset() below is always already # scoped to the requester's own items/groups, so this can't cross into another owner's ids. lookup_field = 'id' lookup_url_kwarg = 'pk' def get_queryset(self): # A KnownIdentity acting purely as a group member (e.g. a remote member on a group # hosted on this backend) never has a local ToolshedUser account here - group-owned # items must stay reachable for such an identity, only personal ("owner=...") items # require .user.exists(). if type(self.request.user) != KnownIdentity: return InventoryItem.objects.none() identity = self.request.user group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all()) if self.action != 'list': # retrieve/update/destroy: anything the caller may act on - their own items, or any # group they're currently a member of. The narrower per-group listing below is only # for the list action, so the main Inventory page stays scoped to personal items. if identity.user.exists(): return InventoryItem.objects.filter(owner=identity.user.get()) | group_items return group_items group_id = self.request.query_params.get('group') if group_id: try: group = Group.objects.get(pk=group_id) except Group.DoesNotExist: return InventoryItem.objects.none() if not group.is_member(identity): return InventoryItem.objects.none() return InventoryItem.objects.filter(owner_group=group) if identity.user.exists(): return InventoryItem.objects.filter(owner=identity.user.get()) return InventoryItem.objects.none() def perform_create(self, serializer): group_id = self.request.data.get('owner_group') with transaction.atomic(): if group_id: try: group = Group.objects.get(pk=group_id) except Group.DoesNotExist: raise NotFound('No such group') if not group.is_member(self.request.user): raise PermissionDenied('Not a member of this group') serializer.save(owner=None, owner_group=group).clean() else: serializer.save(owner=self.request.user.user.get()).clean() @staticmethod def _is_authorized(request, instance): if instance.owner_id: return request.user.user.filter(pk=instance.owner_id).exists() return instance.owner_group.is_member(request.user) def perform_update(self, serializer): with transaction.atomic(): if self._is_authorized(self.request, serializer.instance): serializer.save().clean() def perform_destroy(self, instance): if self._is_authorized(self.request, instance): instance.delete() def matches_query(item, query): query = query.lower() if query in item.name.lower(): return True if item.description and query in item.description.lower(): return True if any(query in tag.name.lower() for tag in item.tags.all()): return True return False @api_view(['GET']) @authentication_classes([SignatureAuthentication]) @permission_classes([IsAuthenticated]) def search_inventory_items(request): query = request.query_params.get('query') if query: matching_items = [item for item in inventory_items(request.user) if matches_query(item, query)] return Response(InventoryItemSerializer(matching_items, many=True).data) return Response({'error': 'No query provided.'}, status=400) @api_view(['GET']) @authentication_classes([SignatureAuthentication]) @permission_classes([IsAuthenticated]) def get_shared_item(request, handle, id): """Fetch a single item by its owner's handle (username@domain) and local id, e.g. for the /i// item URL (see docs/design-in-progress/items-labels.md) or the /inventory/shared// in-app view. Unlike InventoryItemViewSet, which only ever returns the requester's own items, this looks the item up by owner instead of by requester, so it's the only endpoint that can serve a friend's item - subject to the same friends-or-self and availability_policy checks getUserProfile/_accessible_files already use elsewhere.""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: return Response(status=400) try: owner = ToolshedUser.objects.get(username=username, domain=domain) except ToolshedUser.DoesNotExist: return Response(status=404) if owner not in request.user.friends_or_self(): return Response(status=403) try: item = owner.inventory_items.get(id=id) except InventoryItem.DoesNotExist: return Response(status=404) is_owner = request.user.user.filter(pk=owner.pk).exists() if item.availability_policy == 'private' and not is_owner: return Response(status=403) return Response(InventoryItemSerializer(item).data) class StorageLocationViewSet(viewsets.ModelViewSet): serializer_class = StorageLocationSerializer authentication_classes = [SignatureAuthentication] permission_classes = [IsAuthenticated] def get_queryset(self): if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): return StorageLocation.objects.filter(owner=self.request.user.user.get()) return StorageLocation.objects.none() def perform_create(self, serializer): with transaction.atomic(): serializer.save(owner=self.request.user.user.get()) def perform_update(self, serializer): with transaction.atomic(): if serializer.instance.owner == self.request.user.user.get(): serializer.save() def perform_destroy(self, instance): if instance.owner == self.request.user.user.get(): instance.delete() class WorkflowInstanceViewSet(viewsets.ModelViewSet): serializer_class = WorkflowInstanceSerializer authentication_classes = [SignatureAuthentication] permission_classes = [IsAuthenticated] def get_queryset(self): if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): return WorkflowInstance.objects.filter(owner=self.request.user.user.get()) return WorkflowInstance.objects.none() def perform_create(self, serializer): with transaction.atomic(): serializer.save(owner=self.request.user.user.get()) def perform_update(self, serializer): with transaction.atomic(): if serializer.instance.owner == self.request.user.user.get(): serializer.save() def perform_destroy(self, instance): if instance.owner == self.request.user.user.get(): staged_file_ids = list(instance.staged_files.values_list('id', flat=True)) instance.delete() for file in File.objects.filter(id__in=staged_file_ids): if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ and file.staged_by_workflows.count() == 0: file.file.delete(save=False) file.delete() router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items') router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations') router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows') urlpatterns = router.urls + [ path('search/', search_inventory_items, name='search_inventory_items'), path('inventory_items///', get_shared_item, name='shared_inventory_item'), ]