From 3b494dfa37f49f1162c13e926c837bd557a81705 Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 16 Aug 2026 19:00:05 +0200 Subject: [PATCH] stash --- backend/authentication/api.py | 3 +- backend/files/media_urls.py | 3 +- backend/toolshed/api/files.py | 70 ++++++++++++++++++- backend/toolshed/api/inventory.py | 6 ++ .../0011_workflowinstance_staged_files.py | 19 +++++ backend/toolshed/models.py | 3 +- backend/toolshed/offlinedata.py | 7 +- backend/toolshed/serializers.py | 11 ++- 8 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 backend/toolshed/migrations/0011_workflowinstance_staged_files.py diff --git a/backend/authentication/api.py b/backend/authentication/api.py index f5c4542..46de434 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -130,7 +130,8 @@ def getUserInfo(request): return Response({'profile_picture_id': 'File does not exist.'}, status=400) user.save() - if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0: + if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \ + and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0: old_file.delete() return Response({ diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index 92566ed..e753725 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -26,7 +26,8 @@ def media_urls(request, hash_path): try: file = File.objects.filter( Q(connected_items__owner__in=request.user.friends_or_self()) | - Q(profile_picture_users__in=request.user.friends_or_self()) + Q(profile_picture_users__in=request.user.friends_or_self()) | + Q(staged_by_workflows__owner__in=request.user.user.all()) ).distinct().get( file=hash_path) diff --git a/backend/toolshed/api/files.py b/backend/toolshed/api/files.py index b56e4dc..d0d94b5 100644 --- a/backend/toolshed/api/files.py +++ b/backend/toolshed/api/files.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from authentication.signature_auth import SignatureAuthenticationLocal from files.models import File from files.serializers import FileSerializer -from toolshed.models import InventoryItem +from toolshed.models import InventoryItem, WorkflowInstance @api_view(['GET']) @@ -30,6 +30,16 @@ def get_item_files(request, item_id): def post_item_file(request, item_id): try: item = InventoryItem.objects.get(id=item_id, owner=request.user) + if 'file_hash' in request.data: + # Attach a file the caller already staged on one of their own workflows, identified + # by its content hash (which the client already computed before ever uploading it), + # instead of re-uploading bytes that are already stored server-side. + try: + file = File.objects.get(hash=request.data['file_hash'], staged_by_workflows__owner=request.user) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + item.files.add(file) + return Response(FileSerializer(file).data, status=status.HTTP_201_CREATED) serializer = FileSerializer(data=request.data) if serializer.is_valid(): file = serializer.save() @@ -40,6 +50,31 @@ def post_item_file(request, item_id): return Response(status=status.HTTP_404_NOT_FOUND) +def get_staged_files(request, workflow_id): + try: + workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) + # Staged files are private working state the client already holds in full (name, size, + # mime_type, base64 data) from the moment it read them off disk/camera - the only thing + # it can't already know is whether/under what hash the upload was persisted, so that's + # all this returns, unlike the fuller FileSerializer representation item_files uses. + return Response(list(workflow.staged_files.values_list('hash', flat=True))) + except WorkflowInstance.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +def post_staged_file(request, workflow_id): + try: + workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) + serializer = FileSerializer(data=request.data) + if serializer.is_valid(): + file = serializer.save() + workflow.staged_files.add(file) + return Response({'hash': file.hash}, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except WorkflowInstance.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + @api_view(['POST', 'GET']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) @@ -58,7 +93,8 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/ item = InventoryItem.objects.get(id=item_id, owner=request.user) file = item.files.get(id=file_id) item.files.remove(file_id) - if file.connected_items.count() == 0: + if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ + and file.staged_by_workflows.count() == 0: file.delete() return Response(status=status.HTTP_204_NO_CONTENT) except InventoryItem.DoesNotExist: @@ -67,8 +103,38 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/ return Response(status=status.HTTP_404_NOT_FOUND) +@api_view(['POST', 'GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([SignatureAuthenticationLocal]) +def staged_files(request, workflow_id, format=None): # /staged_files/ + if request.method == 'GET': + return get_staged_files(request, workflow_id) + elif request.method == 'POST': + return post_staged_file(request, workflow_id) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([SignatureAuthenticationLocal]) +def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged_files/ + try: + workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) + file = workflow.staged_files.get(hash=file_hash) + workflow.staged_files.remove(file) + if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ + and file.staged_by_workflows.count() == 0: + file.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except WorkflowInstance.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + urlpatterns = [ path('files/', list_all_files), path('item_files//', item_files), path('item_files///', delete_item_file), + path('staged_files//', staged_files), + path('staged_files///', delete_staged_file), ] diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 5846e03..64f3cd6 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from authentication.models import ToolshedUser, KnownIdentity from authentication.signature_auth import SignatureAuthentication +from files.models import File from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer @@ -120,7 +121,12 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet): 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.delete() router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items') diff --git a/backend/toolshed/migrations/0011_workflowinstance_staged_files.py b/backend/toolshed/migrations/0011_workflowinstance_staged_files.py new file mode 100644 index 0000000..628f0b2 --- /dev/null +++ b/backend/toolshed/migrations/0011_workflowinstance_staged_files.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.2 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0001_initial'), + ('toolshed', '0010_rename_name_workflowinstance_slug'), + ] + + operations = [ + migrations.AddField( + model_name='workflowinstance', + name='staged_files', + field=models.ManyToManyField(blank=True, related_name='staged_by_workflows', to='files.file'), + ), + ] diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index e89a501..bd34747 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -139,10 +139,11 @@ class WorkflowInstance(models.Model): state = models.CharField(max_length=255) payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend. owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows') + staged_files = models.ManyToManyField(File, related_name='staged_by_workflows', blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"{self.name} ({self.state})" + return f"{self.slug} ({self.state})" diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 9a6c5f5..f0704cb 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -284,14 +284,15 @@ def delete_user_account(user): def _delete_orphaned_files(file_ids): """Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. - A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture) - references it anymore. Returns the number of files deleted. + A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no + WorkflowInstance (staged file) references it anymore. Returns the number of files deleted. """ from files.models import File deleted = 0 for file_obj in File.objects.filter(id__in=file_ids): - if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists(): + if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists() \ + or file_obj.staged_by_workflows.exists(): continue file_obj.file.delete(save=False) file_obj.delete() diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 777801c..1f09692 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -203,9 +203,16 @@ class InventoryItemSerializer(serializers.ModelSerializer): class WorkflowInstanceSerializer(serializers.ModelSerializer): owner = serializers.StringRelatedField(read_only=True) + # The client already holds the full file (name, size, mime_type, base64 data) for anything it + # staged itself - hash is the only thing it can't already know, so that's all this exposes, + # unlike InventoryItemSerializer.files which needs the fuller FileSerializer representation. + staged_files = serializers.SerializerMethodField() class Meta: model = WorkflowInstance - fields = ['id', 'slug', 'state', 'payload', 'owner', 'created_at', 'updated_at'] - read_only_fields = ['owner', 'created_at', 'updated_at'] + fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at'] + read_only_fields = ['owner', 'staged_files', 'created_at', 'updated_at'] + + def get_staged_files(self, obj): + return list(obj.staged_files.values_list('hash', flat=True))