This commit is contained in:
j3d1 2026-08-31 13:02:06 +02:00
parent d606de8773
commit 2218cc3543
12 changed files with 393 additions and 2204 deletions

View file

@ -14,7 +14,7 @@ class FileSerializer(serializers.Serializer):
def to_representation(self, instance):
return {'id': instance.id, 'name': instance.file.url, 'size': instance.file.size,
'mime_type': instance.mime_type}
'mime_type': instance.mime_type, 'hash': instance.hash}
def create(self, validated_data):
return File.objects.get_or_create(**validated_data)[0]

View file

@ -55,8 +55,8 @@ def post_item_file(request, item_id):
if not request.user.user.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
try:
file = File.objects.get(hash=request.data['file_hash'],
staged_by_workflows__owner=request.user.user.get())
file = File.objects.filter(hash=request.data['file_hash'],
staged_by_workflows__owner=request.user.user.get()).distinct().get()
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
item.files.add(file)
@ -101,6 +101,31 @@ def item_files(request, item_id, format=None):
return post_item_file(request, item_id)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def delete_file(request, file_id, format=None):
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
# Only detach from items this identity is actually authorized to act on - a file is
# content-addressed and can be shared by other users'/groups' items via the same hash,
# so it must never be removed from connections this request has no authority over.
authorized_items = file.connected_items.filter(
Q(owner=request.user) | Q(owner_group__in=request.user.public_identity.member_of_groups.all())
)
if not authorized_items.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
for item in authorized_items:
item.files.remove(file)
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()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
@ -151,6 +176,7 @@ def delete_staged_file(request, workflow_id, file_hash, format=None):
urlpatterns = [
path('files/', list_all_files),
path('files/<int:file_id>/', delete_file),
path('item_files/<int:item_id>/', item_files),
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
path('staged_files/<int:workflow_id>/', staged_files),

View file

@ -49,15 +49,21 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
if owner_group:
if not owner_group.is_member(identity):
return InventoryItem.objects.none()
return InventoryItem.objects.filter(owner_group=owner_group)
if owner_user:
queryset = InventoryItem.objects.filter(owner_group=owner_group)
elif owner_user:
if owner_user not in identity.friends_or_self():
return InventoryItem.objects.none()
queryset = InventoryItem.objects.filter(owner=owner_user)
if not identity.user.filter(pk=owner_user.pk).exists():
queryset = queryset.exclude(availability_policy='private')
return queryset
return InventoryItem.objects.none()
else:
return InventoryItem.objects.none()
# InventoryItemSerializer touches owner/owner_group/category/storage_location (FKs) and
# tags/files/itemproperty_set (M2M/reverse-FK) for every row - without this, listing N
# items costs ~5N extra queries (each a full network round trip once every other API
# call also goes through the federation ServerSet layer, not just local Django).
return queryset.select_related('owner', 'owner_group', 'category', 'storage_location').prefetch_related(
'tags', 'files', 'itemproperty_set__property')
def perform_create(self, serializer):
try:
@ -209,6 +215,18 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
file.file.delete(save=False)
file.delete()
@action(detail=True, methods=['post'])
def update_step(self, request, pk=None):
# get_queryset already scopes to the request user's own workflows, so get_object 404s for anyone else's.
instance = self.get_object()
with transaction.atomic():
if 'current_step' in request.data:
instance.current_step = request.data['current_step']
if 'payload' in request.data:
instance.payload = request.data['payload']
instance.save()
return Response(self.get_serializer(instance).data)
router = routers.SimpleRouter()
router.register(r'inventory_items/(?P<handle>[^/]+)', InventoryItemViewSet, basename='inventory_items')

View file

@ -1,5 +1,5 @@
from django.test import Client
from authentication.models import Group
from authentication.models import Group, ToolshedUser
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import File, InventoryItem
@ -140,6 +140,49 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
self.assertEqual(File.objects.count(), 3)
self.assertEqual(self.f['item1'].files.count(), 2)
def test_delete_file_top_level(self):
response = client.delete(f"/api/v1/files/{self.f['test_file2'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(File.objects.count(), 2)
self.assertEqual(self.f['item1'].files.count(), 1)
def test_delete_file_top_level_removes_from_all_owned_items(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(File.objects.count(), 2)
self.assertEqual(self.f['item1'].files.count(), 1)
self.assertEqual(self.f['item2'].files.count(), 0)
def test_delete_file_top_level_not_found(self):
response = client.delete(f"/api/v1/files/99999/", self.f['local_user1'])
self.assertEqual(response.status_code, 404)
self.assertEqual(File.objects.count(), 3)
def test_delete_file_top_level_not_owner(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user2'])
self.assertEqual(response.status_code, 404)
self.assertEqual(File.objects.count(), 3)
self.assertEqual(self.f['item1'].files.count(), 2)
def test_delete_file_top_level_anonymous(self):
response = anonymous_client.delete(f"/api/v1/files/{self.f['test_file1'].id}/")
self.assertEqual(response.status_code, 403)
self.assertEqual(File.objects.count(), 3)
def test_delete_file_top_level_only_detaches_own_items_when_shared_by_hash(self):
other_item = InventoryItem.create_for_owner(
owner=self.f['local_user2'], owned_quantity=1, name='other-user-item',
availability_policy='private')
other_item.files.add(self.f['test_file1'])
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['item1'].files.count(), 1)
self.assertEqual(self.f['item2'].files.count(), 0)
self.assertEqual(other_item.files.count(), 1)
self.assertEqual(File.objects.filter(id=self.f['test_file1'].id).count(), 1)
def test_get_inventory(self):
reply = client.get('/api/v1/inventory_items/{}/'.format(self.f['local_user1']), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
@ -215,6 +258,18 @@ class GroupOwnedFileApiTestCase(UserTestMixin, GroupTestMixin, FilesTestMixin, T
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)
def test_other_member_can_delete_file_top_level(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user2'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['group_item'].files.count(), 0)
def test_non_member_cannot_delete_file_top_level(self):
outsider = ToolshedUser.objects.create_user('testuser3', 'test3@abc.de', 'testpassword4',
domain=self.f['example_com'].name)
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", outsider)
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)
def test_item_files_when_id_collides_across_two_groups(self):
group2 = Group.objects.create(name='group2', domain=self.f['example_com'].name)
group2.members.add(self.f['local_user2'].public_identity)