This commit is contained in:
j3d1 2026-08-16 19:00:05 +02:00
parent 82a27ce2a8
commit 3b494dfa37
8 changed files with 112 additions and 10 deletions

View file

@ -130,7 +130,8 @@ def getUserInfo(request):
return Response({'profile_picture_id': 'File does not exist.'}, status=400) return Response({'profile_picture_id': 'File does not exist.'}, status=400)
user.save() 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() old_file.delete()
return Response({ return Response({

View file

@ -26,7 +26,8 @@ def media_urls(request, hash_path):
try: try:
file = File.objects.filter( file = File.objects.filter(
Q(connected_items__owner__in=request.user.friends_or_self()) | 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( ).distinct().get(
file=hash_path) file=hash_path)

View file

@ -7,7 +7,7 @@ from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthenticationLocal from authentication.signature_auth import SignatureAuthenticationLocal
from files.models import File from files.models import File
from files.serializers import FileSerializer from files.serializers import FileSerializer
from toolshed.models import InventoryItem from toolshed.models import InventoryItem, WorkflowInstance
@api_view(['GET']) @api_view(['GET'])
@ -30,6 +30,16 @@ def get_item_files(request, item_id):
def post_item_file(request, item_id): def post_item_file(request, item_id):
try: try:
item = InventoryItem.objects.get(id=item_id, owner=request.user) 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) serializer = FileSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
file = serializer.save() file = serializer.save()
@ -40,6 +50,31 @@ def post_item_file(request, item_id):
return Response(status=status.HTTP_404_NOT_FOUND) 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']) @api_view(['POST', 'GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal]) @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) item = InventoryItem.objects.get(id=item_id, owner=request.user)
file = item.files.get(id=file_id) file = item.files.get(id=file_id)
item.files.remove(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() file.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except InventoryItem.DoesNotExist: 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) 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 = [ urlpatterns = [
path('files/', list_all_files), path('files/', list_all_files),
path('item_files/<int:item_id>/', item_files), path('item_files/<int:item_id>/', item_files),
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file), path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
path('staged_files/<int:workflow_id>/', staged_files),
path('staged_files/<int:workflow_id>/<str:file_hash>/', delete_staged_file),
] ]

View file

@ -7,6 +7,7 @@ from rest_framework.response import Response
from authentication.models import ToolshedUser, KnownIdentity from authentication.models import ToolshedUser, KnownIdentity
from authentication.signature_auth import SignatureAuthentication from authentication.signature_auth import SignatureAuthentication
from files.models import File
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
@ -120,7 +121,12 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get(): if instance.owner == self.request.user.user.get():
staged_file_ids = list(instance.staged_files.values_list('id', flat=True))
instance.delete() 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') router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')

View file

@ -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'),
),
]

View file

@ -139,10 +139,11 @@ class WorkflowInstance(models.Model):
state = models.CharField(max_length=255) state = models.CharField(max_length=255)
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend. 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') 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.state})" return f"{self.slug} ({self.state})"

View file

@ -284,14 +284,15 @@ def delete_user_account(user):
def _delete_orphaned_files(file_ids): def _delete_orphaned_files(file_ids):
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. """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) A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no
references it anymore. Returns the number of files deleted. WorkflowInstance (staged file) references it anymore. Returns the number of files deleted.
""" """
from files.models import File from files.models import File
deleted = 0 deleted = 0
for file_obj in File.objects.filter(id__in=file_ids): 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 continue
file_obj.file.delete(save=False) file_obj.file.delete(save=False)
file_obj.delete() file_obj.delete()

View file

@ -203,9 +203,16 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class WorkflowInstanceSerializer(serializers.ModelSerializer): class WorkflowInstanceSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True) 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: class Meta:
model = WorkflowInstance model = WorkflowInstance
fields = ['id', 'slug', 'state', 'payload', 'owner', 'created_at', 'updated_at'] fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at']
read_only_fields = ['owner', '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))