diff --git a/backend/files/serializers.py b/backend/files/serializers.py index e4bc82e..2310ab5 100644 --- a/backend/files/serializers.py +++ b/backend/files/serializers.py @@ -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] diff --git a/backend/toolshed/api/files.py b/backend/toolshed/api/files.py index 675e8e3..cb1dd38 100644 --- a/backend/toolshed/api/files.py +++ b/backend/toolshed/api/files.py @@ -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//', delete_file), path('item_files//', item_files), path('item_files///', delete_item_file), path('staged_files//', staged_files), diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 4f0c8d8..15b7eac 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -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[^/]+)', InventoryItemViewSet, basename='inventory_items') diff --git a/backend/toolshed/tests/test_files.py b/backend/toolshed/tests/test_files.py index 8b0f698..a215878 100644 --- a/backend/toolshed/tests/test_files.py +++ b/backend/toolshed/tests/test_files.py @@ -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) diff --git a/frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue b/frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue index a5bd6f3..cb0d3c3 100644 --- a/frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue +++ b/frontend/src/components/workflow/workflows/BulkItemImportWorkflow.vue @@ -7,6 +7,8 @@

Upload your CSV or Excel file containing item data for bulk import.

+
{{ uploadError }}
+
@@ -321,6 +323,7 @@ export default { processingFile: false, processingStatus: '', isDragOver: false, + uploadError: null, fileAnalysis: null, detectedColumns: [], parsedRows: [], @@ -385,8 +388,9 @@ export default { }, async processFile(file) { + this.uploadError = null; if (!this.isValidFileType(file)) { - alert('Please upload a CSV or Excel file (.csv, .xlsx, .xls)'); + this.uploadError = 'Please upload a CSV or Excel file (.csv, .xlsx, .xls)'; return; } @@ -406,7 +410,7 @@ export default { this.updateStep1Payload(); } catch (error) { console.error('Error processing file:', error); - alert('Error processing file. Please try again.'); + this.uploadError = 'Error processing file. Please try again.'; this.removeFile(); } finally { this.processingFile = false; @@ -565,10 +569,11 @@ export default { proceedFromStep1() { if (!this.canProceedFromStep1) { - alert('Please upload a file and map the required Name column before proceeding.'); + this.uploadError = 'Please upload a file and map the required Name column before proceeding.'; return; } + this.uploadError = null; this.updateStep1Payload(); this.$emit('next'); }, diff --git a/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue b/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue index 20466e5..1998a36 100644 --- a/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue +++ b/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue @@ -76,169 +76,43 @@ @click="proceedFromStep1" :disabled="photos.length === 0" > - Next: Process Images + Next: Data Presets
- +
-

Image Processing

-

Processing and optimizing your captured images...

+

Data Presets

+

+ Set default values for the whole batch. Each item starts out with these values in Item + Details Entry and can still be adjusted individually. +

- -
-
+
+
-
-
Processing Progress
- {{ processedCount }}/{{ totalPhotos }} -
- -
-
-
- -
-
-
- Processing: {{ currentlyProcessing }} -
-
- - All images processed successfully! -
-
-
-
-
- - -
-
-
-
Processing Options
-
-
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
-
-
- - -
-
Processed Images
-
-
-
- -
-
- {{ image.name }} - - - -
-
- - {{ formatFileSize(image.originalSize) }} → - {{ formatFileSize(image.processedSize) }} - - - {{ - Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100) - }}% reduced - -
-
-
+ +
- -
- - -
+
@@ -246,14 +120,16 @@

Item Details Entry

-

Enter details for each photographed item to complete the inventory import.

+

Enter the remaining details for each photographed item.

+ +
Item Progress
- {{ currentItemIndex + 1 }} of {{ totalItems }} + {{ overallPosition }} of {{ totalItems }}
+ +
+
+
Finalizing import...
+
+ -
+
- Current item +
{{ currentItem.name }}
@@ -285,113 +169,25 @@
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
-
- - -
- -
- + +
{{ itemErrors.name }}
-
-
- -
- $ - -
-
-
- -
- $ - -
-
+
+ +
@@ -401,7 +197,7 @@
-
+
- @@ -426,7 +222,7 @@ -
+ +
@@ -467,263 +256,12 @@
-
+
- -
-
- - -
-
-

Import Completion

-

Review and finalize your imported items.

-
- - -
-
-
-
-
-

{{ finalTotalItems }}

-

Items Imported

-
-
-
-
-
-
-

{{ categorizedItems }}

-

With Categories

-
-
-
-
-
-
-

{{ itemsWithLocation }}

-

With Locations

-
-
-
-
-
-
-

${{ totalValue }}

-

Total Value

-
-
-
-
-
- - -
-
-
-
Items by Category
-
-
-
-
-
- {{ category || 'Uncategorized' }} - {{ count }} -
-
-
-
- No items to categorize -
-
-
-
- - -
-
-
-
Import Options
-
-
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
-
-
- - -
-
-
-
Imported Items
-
- - -
-
-
- -
-
-
- -
-
{{ item.details.name }}
-

- {{ item.details.category || 'No category' }}

-
- Qty: {{ item.details.quantity }} - ${{ item.details.estimated_value }} -
-
-
-
-
- - -
- - - - - - - - - - - - - - - - - - - - - -
ImageNameCategoryQuantityLocationValue
- - {{ item.details.name }} - {{ item.details.category }} - - - {{ item.details.quantity }} {{ item.details.unit }}{{ item.details.location || '-' }} - ${{ item.details.estimated_value }} - - -
-
-
-
-
- - -
-
-
- -
Ready to Complete Import
-

- All {{ finalTotalItems }} items have been processed and are ready to be added to your - inventory. - This action cannot be undone. -

-
- - -
-
-
+
@@ -743,6 +281,45 @@ import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue"; import CameraFileSource from "@/components/inputs/CameraFileSource.vue"; import FsFileSource from "@/components/inputs/FsFileSource.vue"; import WebcamFileSource from "@/components/inputs/WebcamFileSource.vue"; +import FieldInput from "@/components/workflow/workflows/FotoFirstFieldInput.vue"; +import FotoFirstCompletedItemCard from "@/components/workflow/workflows/FotoFirstCompletedItemCard.vue"; + +// Mirrors the fields InventoryNew.vue collects when creating a single item, minus `name` and +// `files` (name stays per-item/required, files are the photo already staged in step 1). +const PRESET_FIELD_DEFS = [ + {key: 'description', label: 'Description', type: 'textarea'}, + {key: 'tags', label: 'Tags', type: 'tags'}, + {key: 'properties', label: 'Properties', type: 'properties'}, + {key: 'owner_group', label: 'Owner', type: 'owner_group'}, + {key: 'owned_quantity', label: 'Quantity', type: 'number'}, + {key: 'availability_policy', label: 'Availability Policy', type: 'availability_policy'}, + {key: 'storage_location', label: 'Storage Location', type: 'storage_location'} +]; + +function defaultValueFor(key) { + switch (key) { + case 'tags': + case 'properties': + return []; + case 'owner_group': + case 'storage_location': + return null; + case 'owned_quantity': + return 1; + case 'availability_policy': + return 'private'; + default: + return ''; + } +} + +function defaultPresetValues() { + const values = {}; + PRESET_FIELD_DEFS.forEach(field => { + values[field.key] = defaultValueFor(field.key); + }); + return values; +} export default { name: 'FotoFirstBulkImportWorkflow', @@ -755,17 +332,13 @@ export default { estimatedDuration: '10-60 minutes', stepDefinitions: [ {step: '1', name: 'Photo Capture', description: 'Capture or upload item photos'}, - {step: '2', name: 'Image Processing', description: 'Process and optimize images'}, - {step: '3', name: 'Item Details Entry', description: 'Enter details for each photographed item'}, - {step: '4', name: 'Import Completion', description: 'Finalize and save imported items'} + {step: '2', name: 'Data Presets', description: 'Set properties shared by the whole batch'}, + {step: '3', name: 'Item Details Entry', description: 'Enter remaining details for each photographed item'} ], getInitialPayload() { return { - processing_options: { - auto_rotate: true, - compress: true, - max_width: 1920, - max_height: 1080 + data_presets: { + values: {} } }; } @@ -776,6 +349,8 @@ export default { DragDropFileSource, CameraFileSource, FsFileSource, + FieldInput, + FotoFirstCompletedItemCard, ...BIcons }, props: { @@ -797,98 +372,60 @@ export default { // Step 1: photo capture photos: [], - // Step 2: image processing - processing: false, - processedCount: 0, - currentlyProcessing: null, - processedImages: [], - processingOptions: { - auto_rotate: true, - compress: true, - max_width: 1920, - max_height: 1080 - }, + // Step 2: data presets + presetFieldDefs: PRESET_FIELD_DEFS, + presetValues: defaultPresetValues(), // Step 3: item details entry currentItemIndex: 0, - currentItemDetails: this.getDefaultItemDetails(), + // Can't call this.getDefaultItemDetails() here - it reads this.presetValues, which + // Vue hasn't assigned yet while still evaluating data()'s own return object. + currentItemDetails: {name: '', ...defaultPresetValues()}, + itemErrors: {name: ''}, + actionError: '', completedItems: [], showCompleted: false, - - // Step 4: completion - importing: false, - viewMode: 'grid', - importOptions: { - generate_qr_codes: true, - send_notification: true, - create_report: true, - auto_backup: false - } + saving: false, + importing: false } }, computed: { - ...mapState(['user']), - // Step 1/2 - totalPhotos() { - return this.photos.length; + ...mapState(['user', 'availability_policies', 'storage_locations', 'groups', 'groupMemberships']), + ownerGroups() { + const hostedHandles = new Set(this.groups.map(group => group.handle)); + const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle)); + return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle)); }, - progressPercentage() { - return this.totalPhotos > 0 ? (this.processedCount / this.totalPhotos) * 100 : 0; - }, - isComplete() { - return this.processedCount === this.totalPhotos && this.totalPhotos > 0; - }, - - // Step 3 availableItems() { - return this.processedImages.length > 0 ? this.processedImages : this.photos; + return this.photos; }, totalItems() { - return this.availableItems.length; + return this.availableItems.length + this.completedItems.length; }, currentItem() { return this.availableItems[this.currentItemIndex] || null; }, + overallPosition() { + return this.completedItems.length + this.currentItemIndex + 1; + }, itemProgressPercentage() { return this.totalItems > 0 ? (this.completedItems.length / this.totalItems) * 100 : 0; - }, - - // Step 4 - finalTotalItems() { - return this.completedItems.length; - }, - categorizedItems() { - return this.completedItems.filter(item => item.details.category).length; - }, - itemsWithLocation() { - return this.completedItems.filter(item => item.details.location).length; - }, - totalValue() { - return this.completedItems.reduce((sum, item) => { - return sum + (item.details.estimated_value || 0); - }, 0).toFixed(2); - }, - categoryBreakdown() { - const breakdown = {}; - this.completedItems.forEach(item => { - const category = item.details.category || 'uncategorized'; - breakdown[category] = (breakdown[category] || 0) + 1; - }); - return breakdown; } }, - mounted() { + async mounted() { this.loadFromPayload(); - }, - beforeUnmount() { - this.processedImages.forEach(image => { - if (image.processedUrl && image.processedUrl.startsWith('blob:')) { - URL.revokeObjectURL(image.processedUrl); - } - }); + await Promise.all([ + this.fetchInfo(), + this.fetchStorageLocations(), + this.fetchGroups(), + this.fetchGroupMemberships(), + this.fetchTags(), + this.fetchProperties() + ]); }, methods: { - ...mapActions(['stageFile', 'unstageFile']), + ...mapActions(['stageFile', 'unstageFile', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups', + 'fetchGroupMemberships', 'fetchTags', 'fetchProperties', 'createInventoryItem', 'commitStagedFile']), loadFromPayload() { // `photos` is seeded from workflowInstance.staged_files, not payload. See docs/implementation.md#staged-photos-are-the-durable-state. this.photos = (this.workflowInstance.staged_files || []).map(hash => ({ @@ -900,17 +437,12 @@ export default { uploaded: true, timestamp: null })); - if (this.payload.processing_options) { - this.processingOptions = {...this.processingOptions, ...this.payload.processing_options}; - } - if (this.payload.processed_images) { - this.processedImages = [...this.payload.processed_images]; - this.processedCount = this.processedImages.length; + if (this.payload.data_presets) { + this.presetValues = {...defaultPresetValues(), ...this.payload.data_presets.values}; } if (this.payload.completed_items) this.completedItems = [...this.payload.completed_items]; if (this.payload.current_item_details) this.currentItemDetails = {...this.payload.current_item_details}; if (this.payload.current_item_index !== undefined) this.currentItemIndex = this.payload.current_item_index; - if (this.payload.import_options) this.importOptions = {...this.importOptions, ...this.payload.import_options}; }, // --- Step 1: Photo capture --- @@ -978,188 +510,95 @@ export default { this.$emit('next'); }, - // --- Step 2: Image processing --- - async startProcessing() { - if (this.photos.length === 0) { - alert('No photos to process. Please go back and add photos first.'); - return; - } - - this.processing = true; - this.processedCount = 0; - this.processedImages = []; - - try { - for (let i = 0; i < this.photos.length; i++) { - const photo = this.photos[i]; - this.currentlyProcessing = photo.name; - - const processedImage = await this.processImage(photo); - this.processedImages.push(processedImage); - this.processedCount++; - - // Small delay to show progress - await new Promise(resolve => setTimeout(resolve, 500)); - } - - this.currentlyProcessing = null; - this.updateProcessingPayload(); - } catch (error) { - console.error('Error processing images:', error); - alert('Error processing images. Please try again.'); - } finally { - this.processing = false; - } - }, - - async processImage(photo) { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - // Calculate new dimensions - let {width, height} = this.calculateDimensions( - img.width, - img.height, - this.processingOptions.max_width, - this.processingOptions.max_height - ); - - canvas.width = width; - canvas.height = height; - - // Draw and compress - ctx.drawImage(img, 0, 0, width, height); - - canvas.toBlob(blob => { - const processedImage = { - name: photo.name, - originalSize: photo.size, - processedSize: blob.size, - processedUrl: URL.createObjectURL(blob), - processedFile: blob, - timestamp: new Date().toISOString() - }; - resolve(processedImage); - }, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95); - }; - img.src = photo.dataUrl; - }); - }, - - calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) { - let width = originalWidth; - let height = originalHeight; - - if (width > maxWidth) { - height = (height * maxWidth) / width; - width = maxWidth; - } - - if (height > maxHeight) { - width = (width * maxHeight) / height; - height = maxHeight; - } - - return {width: Math.round(width), height: Math.round(height)}; - }, - - formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - }, - - updateProcessingPayload() { + // --- Step 2: Data presets --- + proceedFromPresets() { + this.currentItemDetails = this.getDefaultItemDetails(); this.$emit('update', { - processing_options: this.processingOptions, - processed_images: this.processedImages + data_presets: {values: this.presetValues} }); - }, - - proceedFromStep2() { - this.updateProcessingPayload(); this.$emit('next'); }, // --- Step 3: Item details entry --- getDefaultItemDetails() { - return { - name: '', - category: '', - quantity: 1, - unit: 'piece', - condition: 'good', - description: '', - location: '', - purchase_price: null, - estimated_value: null - }; + const base = {name: ''}; + PRESET_FIELD_DEFS.forEach(field => { + const value = this.presetValues[field.key]; + // tag-field/property-field mutate their array in place, so each item needs its own + // copy - otherwise editing one item's tags would silently edit every other item's too. + base[field.key] = Array.isArray(value) ? [...value] : value; + }); + return base; }, - saveCurrentItem() { + async saveCurrentItem() { + this.itemErrors = {name: ''}; + this.actionError = ''; if (!this.currentItemDetails.name.trim()) { - alert('Please enter an item name before saving.'); + this.itemErrors = {name: 'Please enter an item name before saving.'}; return; } - const itemData = { - image: this.currentItem, - details: {...this.currentItemDetails}, - saved_at: new Date().toISOString() - }; + this.saving = true; + try { + const photo = this.currentItem; + const created = await this.createItem(photo, this.currentItemDetails); + // `id`/`owner`/`owner_group` are the only fields that identify the created + // InventoryItem - never cache its name/tags/photo/etc. here, since the item can be + // edited elsewhere (or by the user themselves) before this workflow finishes, and + // a cached copy would silently go stale. + this.completedItems.push({id: created.id, owner: created.owner, owner_group: created.owner_group}); + // createItem() already committed the photo's file to the item, which detaches it + // from this workflow's staged files server-side - drop it here too so this array + // stays in sync, and so it can never be revisited as if still pending. + this.photos = this.photos.filter(p => p.hash !== photo.hash); + if (this.currentItemIndex >= this.photos.length) { + this.currentItemIndex = Math.max(0, this.photos.length - 1); + } + this.currentItemDetails = this.getDefaultItemDetails(); - const existingIndex = this.completedItems.findIndex(item => - item.image === this.currentItem - ); - - if (existingIndex >= 0) { - this.completedItems.splice(existingIndex, 1, itemData); - } else { - this.completedItems.push(itemData); + if (this.photos.length > 0) { + // Only PATCH here when items remain - if that was the last one, finishImport() + // below sends its own (already up to date) consolidated PATCH, and firing both + // back-to-back races two writes against dev's SQLite ("database is locked"). + this.updateItemsPayload(); + } else { + this.finishImport(); + } + } catch (error) { + console.error('Failed to create item:', this.currentItemDetails.name, error); + this.actionError = `Failed to save "${this.currentItemDetails.name}". Please try again.`; + } finally { + this.saving = false; } - - this.nextItem(); - this.updateItemsPayload(); }, skipCurrentItem() { - this.nextItem(); + this.itemErrors = {name: ''}; + this.actionError = ''; + if (this.currentItemIndex < this.photos.length - 1) { + this.currentItemIndex++; + this.currentItemDetails = this.getDefaultItemDetails(); + } else { + this.finishImport(); + } }, nextItem() { - if (this.currentItemIndex < this.totalItems - 1) { + this.itemErrors = {name: ''}; + this.actionError = ''; + if (this.currentItemIndex < this.photos.length - 1) { this.currentItemIndex++; this.currentItemDetails = this.getDefaultItemDetails(); } }, previousItem() { + this.itemErrors = {name: ''}; + this.actionError = ''; if (this.currentItemIndex > 0) { this.currentItemIndex--; - - const existingItem = this.completedItems.find(item => - item.image === this.currentItem - ); - - if (existingItem) { - this.currentItemDetails = {...existingItem.details}; - } else { - this.currentItemDetails = this.getDefaultItemDetails(); - } - } - }, - - editItem(index) { - const item = this.completedItems[index]; - const itemIndex = this.availableItems.findIndex(img => img === item.image); - if (itemIndex >= 0) { - this.currentItemIndex = itemIndex; - this.currentItemDetails = {...item.details}; + this.currentItemDetails = this.getDefaultItemDetails(); } }, @@ -1171,70 +610,62 @@ export default { }); }, - proceedFromStep3() { - if (this.completedItems.length === 0) { - alert('Please complete at least one item before proceeding.'); - return; + // --- Creates the real InventoryItem the moment its details are saved --- + async createItem(photo, details) { + const created = await this.createInventoryItem({ + name: details.name, + description: details.description, + tags_input: details.tags, + properties: details.properties, + owned_quantity: details.owned_quantity, + availability_policy: details.availability_policy, + storage_location: details.storage_location, + owner_group: details.owner_group + }); + if (photo?.hash) { + await this.commitStagedFile({item_id: created.id, file_hash: photo.hash}); + // Detach the file from *this* workflow specifically now that it belongs to the + // item - scoped by workflow id, unlike a blanket clear, since files are content- + // addressed and the same hash could be staged on other workflows too. + await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash}); } - - this.updateItemsPayload(); - this.$emit('next'); + return created; }, - // --- Step 4: Completion --- - async completeImport() { - if (this.finalTotalItems === 0) { - alert('No items to import. Please go back and add items.'); + // --- Finalization once the last item has been saved/skipped --- + async finishImport() { + if (this.completedItems.length === 0) { + this.actionError = 'No items were saved. Please go back and save at least one item.'; return; } - const confirmed = confirm( - `Are you sure you want to import ${this.finalTotalItems} items? This action cannot be undone.` - ); - - if (!confirmed) return; - + this.importing = true; try { - this.importing = true; - - this.updateFinalPayload(); - - // Simulate import process - await new Promise(resolve => setTimeout(resolve, 2000)); - this.$emit('update', { + completed_items: this.completedItems, + current_item_details: this.currentItemDetails, + current_item_index: this.currentItemIndex, import_completed: true, completion_timestamp: new Date().toISOString() }); + // The parent's 'update' and 'complete' handlers each PATCH the same workflow row; + // firing them back-to-back can hit two overlapping writes against dev's SQLite, + // which only allows one writer at a time ("database is locked"). Give the first + // PATCH a moment to land before triggering the second. + await new Promise(resolve => setTimeout(resolve, 800)); + this.$emit('complete'); - } catch (error) { - console.error('Error completing import:', error); - alert('Error completing import. Please try again.'); } finally { this.importing = false; } - }, - - updateFinalPayload() { - this.$emit('update', { - import_options: this.importOptions, - final_summary: { - total_items: this.finalTotalItems, - categorized_items: this.categorizedItems, - items_with_location: this.itemsWithLocation, - total_value: parseFloat(this.totalValue), - category_breakdown: this.categoryBreakdown - } - }); } } } \ No newline at end of file + diff --git a/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow2.vue b/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow2.vue deleted file mode 100644 index 08b2bc2..0000000 --- a/frontend/src/components/workflow/workflows/FotoFirstBulkImportWorkflow2.vue +++ /dev/null @@ -1,1341 +0,0 @@ - - - - - - diff --git a/frontend/src/router.js b/frontend/src/router.js index f3c9e79..90340db 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -184,7 +184,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { }, {path: '/workflows', component: Workflows, meta: {requiresAuth: true}, -}, {path: '/workflows/:id/:phase?', +}, {path: '/workflows/:id/:step?', name: 'workflow-detail', component: WorkflowDetail, meta: {requiresAuth: true}, diff --git a/frontend/src/store.js b/frontend/src/store.js index 4e8141d..1c0bde8 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -426,9 +426,9 @@ export default createStore({ ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) : await dispatch('getHomeServers') const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/' - const ret = await servers.delete(getters.signAuth, path) - dispatch('fetchInventoryItems') - return ret + // No self-refetch here, same as createInventoryItem/updateInventoryItem - callers + // refetch whichever owner-scoped list (own/group/friend) is actually on screen. + return await servers.delete(getters.signAuth, path) }, async fetchSearchResults({state, commit, dispatch, getters}, {query}) { const servers = await dispatch('getAllKnownServers') diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue index 15a0e35..5e47f82 100644 --- a/frontend/src/views/Files.vue +++ b/frontend/src/views/Files.vue @@ -8,9 +8,9 @@
Images
- - - + + @@ -58,6 +58,10 @@ export default { }, whithout_images(files) { return files.filter(file => !file.mime_type.startsWith("image/")); + }, + // See docs/implementation.md#thumbnail-lookup-by-hash. + thumbnailPathForHash(hash, size = 256) { + return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`; } }, mounted() { diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 4fa1274..56b1b70 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -3,6 +3,10 @@

Settings

+
{{ deleteError }}
+
+ Your account has been permanently deleted. Signing you out... +
@@ -56,6 +60,12 @@ import {mapActions, mapGetters, mapMutations} from "vuex"; export default { name: 'Settings', components: {BaseLayout}, + data() { + return { + deleteError: null, + deleteSuccess: false + } + }, computed: { ...mapGetters(['signAuth']) }, @@ -63,6 +73,7 @@ export default { ...mapActions(['getHomeServers']), ...mapMutations(['logout']), async deleteAccount() { + this.deleteError = null; if (!confirm('Are you sure you want to permanently delete your account? ' + 'All your data will be deleted and you will not be able to log back in. This cannot be undone.')) { return; @@ -72,14 +83,16 @@ export default { const response = await servers.delete(this.signAuth, '/api/v1/account/'); if (!response || !response.ok) { const errorBody = response ? await response.json().catch(() => ({})) : {}; - alert('Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error')); + this.deleteError = 'Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error'); return; } - alert('Your account has been permanently deleted.'); - this.logout(); + this.deleteSuccess = true; + // logout() redirects to /login right away - give the user a moment to read the + // confirmation above before that navigation happens. + setTimeout(() => this.logout(), 2000); } catch (error) { console.error('Account deletion failed', error); - alert('Account deletion failed: ' + error); + this.deleteError = 'Account deletion failed: ' + error; } }, } diff --git a/frontend/src/views/settings/Data.vue b/frontend/src/views/settings/Data.vue index e42f328..aac829f 100644 --- a/frontend/src/views/settings/Data.vue +++ b/frontend/src/views/settings/Data.vue @@ -43,6 +43,8 @@
Delete all your data including photos, videos, comments, profile information and more +
{{ deleteError }}
+
{{ deleteSuccess }}
@@ -64,6 +66,8 @@

Import data from backup or other instances +
{{ importError }}
+
{{ importSuccess }}
@@ -82,7 +86,11 @@ export default { components: {}, data: () => ({ selectedFile: null, - localUserIdentityRecord: null + localUserIdentityRecord: null, + deleteError: null, + deleteSuccess: null, + importError: null, + importSuccess: null }), computed: { ...mapGetters(['signAuth']) @@ -96,6 +104,8 @@ export default { } }, async deleteData() { + this.deleteError = null; + this.deleteSuccess = null; if (!confirm('Are you sure you want to permanently delete all your data (inventory, locations, ' + 'settings, friends and files)? Your account itself will stay - this cannot be undone.')) { return; @@ -105,19 +115,19 @@ export default { const response = await servers.delete(this.signAuth, '/api/v1/account_data/'); if (!response || !response.ok) { const errorBody = response ? await response.json().catch(() => ({})) : {}; - alert('Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error')); + this.deleteError = 'Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error'); return; } const summary = await response.json().catch(() => ({})); - alert('All your data has been deleted: ' + + this.deleteSuccess = 'All your data has been deleted: ' + `${summary.inventory_items || 0} inventory items, ` + `${summary.locations || 0} locations, ` + `${summary.settings || 0} settings, ` + `${summary.friends || 0} friends, ` + - `${summary.files || 0} files.`); + `${summary.files || 0} files.`; } catch (error) { console.error('Data deletion failed', error); - alert('Data deletion failed: ' + error); + this.deleteError = 'Data deletion failed: ' + error; } }, exportKey() { @@ -141,8 +151,10 @@ export default { }); }, async importData() { + this.importError = null; + this.importSuccess = null; if (!this.selectedFile) { - alert('Please select a file to import'); + this.importError = 'Please select a file to import'; return; } try { @@ -150,16 +162,16 @@ export default { const servers = await this.getHomeServers(); const summary = await servers.post(this.signAuth, '/api/v1/import/', {zip: base64}); if (summary && summary.detail) { - alert('Data import failed: ' + summary.detail); + this.importError = 'Data import failed: ' + summary.detail; return; } - alert('Data imported successfully: ' + + this.importSuccess = 'Data imported successfully: ' + `${summary.profile ? 'profile, ' : ''}` + `${summary.settings || 0} settings, ` + `${summary.inventory_items || 0} inventory items, ` + `${summary.friends || 0} friends, ` + `${summary.locations || 0} locations, ` + - `${summary.files || 0} files.`); + `${summary.files || 0} files.`; this.selectedFile = null; const fileInput = document.getElementById('inputFile'); if (fileInput) { @@ -167,7 +179,7 @@ export default { } } catch (error) { console.error('Data import failed', error); - alert('Data import failed: ' + error); + this.importError = 'Data import failed: ' + error; } }, async exportData() {