toolshed/backend/toolshed/api/inventory.py
2026-08-26 22:18:55 +02:00

256 lines
11 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 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/<handle>/<id> or
/inventory/shared/<handle>/<id>. 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/<str:handle>/<int:id>/', get_shared_item, name='shared_inventory_item'),
]