toolshed/backend/toolshed/api/inventory.py
2026-08-23 14:22:32 +02:00

218 lines
9.4 KiB
Python

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/<handle>/<id> item URL (see docs/design-in-progress/items-labels.md) or the
/inventory/shared/<handle>/<id> 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/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
]