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 resolve_group_by_handle(handle): """handle is "name@domain" (no leading '#') - the same format/parser group.py's GroupDetail uses, so a group reference parses identically everywhere it appears (URL path, ?group= query param, or an owner_group payload field), rather than some spots taking a handle and others a bare pk.""" try: name, domain = split_userhandle_or_throw(handle) except ValueError: return None try: return Group.objects.get(name=name, domain=domain) except Group.DoesNotExist: return None 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. See # docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids. lookup_field = 'id' lookup_url_kwarg = 'pk' def get_queryset(self): # A pure group-member KnownIdentity may have no local ToolshedUser account; only # personal items require .user.exists(). See # docs/implementation.md#group-member-identities-without-local-accounts. 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: any item the caller may act on, own or group. See # docs/implementation.md#inventory-queryset-scope-by-action. if identity.user.exists(): return InventoryItem.objects.filter(owner=identity.user.get()) | group_items return group_items group_handle = self.request.query_params.get('group') if group_handle: group = resolve_group_by_handle(group_handle) if not group or 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_handle = self.request.data.get('owner_group') with transaction.atomic(): if group_handle: group = resolve_group_by_handle(group_handle) if not group: 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 and local id, for /i// or /inventory/shared//. See docs/implementation.md#get-shared-item-looks-up-by-owner.""" 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] # Detail routes address a location by its owner-scoped id, not the internal row id. See # docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids. lookup_field = 'id' lookup_url_kwarg = 'pk' def get_queryset(self): # Mirrors InventoryItemViewSet.get_queryset() - see its own comments for why the # list/detail scopes differ and why group membership alone (no linked ToolshedUser # required) is enough for the group branch. if type(self.request.user) != KnownIdentity: return StorageLocation.objects.none() identity = self.request.user group_locations = StorageLocation.objects.filter(owner_group__in=identity.member_of_groups.all()) if self.action != 'list': if identity.user.exists(): return StorageLocation.objects.filter(owner=identity.user.get()) | group_locations return group_locations group_handle = self.request.query_params.get('group') if group_handle: group = resolve_group_by_handle(group_handle) if not group or not group.is_member(identity): return StorageLocation.objects.none() return StorageLocation.objects.filter(owner_group=group) if identity.user.exists(): return StorageLocation.objects.filter(owner=identity.user.get()) return StorageLocation.objects.none() def perform_create(self, serializer): group_handle = self.request.data.get('owner_group') with transaction.atomic(): if group_handle: group = resolve_group_by_handle(group_handle) if not group: 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() 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'), ]