From 796fef81beedd48c84f2c0cac5656e16c700cbdf Mon Sep 17 00:00:00 2001 From: jedi Date: Thu, 23 Jul 2026 04:41:04 +0200 Subject: [PATCH] stash --- backend/authentication/api.py | 34 +- .../0002_toolsheduser_profile_picture.py | 20 + backend/authentication/models.py | 2 + backend/authentication/tests/test_auth.py | 47 ++ backend/files/media_urls.py | 8 +- backend/files/tests.py | 32 +- ...08_alter_inventoryitem_storage_location.py | 19 + backend/toolshed/models.py | 2 +- backend/toolshed/tests/test_locations.py | 62 ++- deploy/dev/instance_a/domains.json | 16 - deploy/dev/instance_a/nginx-a.dev.conf | 32 +- deploy/docker-compose.override.yml | 4 + docs/development.md | 6 + .../src/components/AuthenticatedAvatar.vue | 0 .../src/components/AuthenticatedImage.vue | 105 +++- frontend/src/components/UserDropdown.vue | 19 +- .../src/components/inputs/FsFileSource.vue | 18 +- .../src/components/inputs/PreferenceInput.vue | 168 ++++++ .../components/workflow/ComponentRegistry.js | 110 ++++ .../src/components/workflow/ExampleUsage.js | 193 +++++++ frontend/src/components/workflow/README.md | 146 ++++++ .../workflow/steps/BulkImportStep1.vue | 480 ++++++++++++++++++ .../workflow/steps/FotoFirstStep1.vue | 274 ++++++++++ .../workflow/steps/FotoFirstStep2.vue | 360 +++++++++++++ .../workflow/steps/FotoFirstStep3.vue | 419 +++++++++++++++ .../workflow/steps/FotoFirstStep4.vue | 376 ++++++++++++++ frontend/src/router.js | 3 + frontend/src/store.js | 151 ++++++ frontend/src/views/Inventory.vue | 9 +- frontend/src/views/InventoryDetail.vue | 9 +- frontend/src/views/InventoryEdit.vue | 6 +- frontend/src/views/InventoryNew.vue | 10 +- frontend/src/views/Profile.vue | 55 +- frontend/src/views/Settings.vue | 3 + frontend/src/views/WorkflowDetail.vue | 33 +- frontend/src/views/settings/Account.vue | 47 +- frontend/src/views/settings/Preferences.vue | 234 +++++++++ 37 files changed, 3404 insertions(+), 108 deletions(-) create mode 100644 backend/authentication/migrations/0002_toolsheduser_profile_picture.py create mode 100644 backend/toolshed/migrations/0008_alter_inventoryitem_storage_location.py create mode 100644 frontend/src/components/AuthenticatedAvatar.vue create mode 100644 frontend/src/components/inputs/PreferenceInput.vue create mode 100644 frontend/src/components/workflow/ComponentRegistry.js create mode 100644 frontend/src/components/workflow/ExampleUsage.js create mode 100644 frontend/src/components/workflow/README.md create mode 100644 frontend/src/components/workflow/steps/BulkImportStep1.vue create mode 100644 frontend/src/components/workflow/steps/FotoFirstStep1.vue create mode 100644 frontend/src/components/workflow/steps/FotoFirstStep2.vue create mode 100644 frontend/src/components/workflow/steps/FotoFirstStep3.vue create mode 100644 frontend/src/components/workflow/steps/FotoFirstStep4.vue create mode 100644 frontend/src/views/settings/Preferences.vue diff --git a/backend/authentication/api.py b/backend/authentication/api.py index eefcdd4..ea5874f 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -11,6 +11,8 @@ from rest_framework.response import Response from authentication.models import ToolshedUser from authentication.signature_auth import SignatureAuthenticationLocal +from files.models import File +from files.serializers import FileSerializer from hostadmin.models import Domain router = routers.SimpleRouter() @@ -53,15 +55,43 @@ class UserViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, IsAdminUser] -@api_view(['GET']) +@api_view(['GET', 'PATCH']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def getUserInfo(request): user = request.user + if request.method == 'PATCH': + old_file = user.profile_picture + if 'profile_picture' in request.data: + profile_picture = request.data.get('profile_picture') + if profile_picture is None: + user.profile_picture = None + elif type(profile_picture) == dict: + serializer = FileSerializer(data=profile_picture) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + user.profile_picture = serializer.save() + else: + return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400) + elif 'profile_picture_id' in request.data: + profile_picture_id = request.data.get('profile_picture_id') + if profile_picture_id is None: + user.profile_picture = None + else: + try: + user.profile_picture = File.objects.get(id=profile_picture_id) + except File.DoesNotExist: + 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: + old_file.delete() + return Response({ 'username': user.username, 'domain': user.domain, - 'email': user.email + 'email': user.email, + 'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None, }) diff --git a/backend/authentication/migrations/0002_toolsheduser_profile_picture.py b/backend/authentication/migrations/0002_toolsheduser_profile_picture.py new file mode 100644 index 0000000..8078aac --- /dev/null +++ b/backend/authentication/migrations/0002_toolsheduser_profile_picture.py @@ -0,0 +1,20 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0001_initial'), + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='toolsheduser', + name='profile_picture', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='profile_picture_users', to='files.file'), + ), + ] + diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 5ef3890..598c0cf 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser): domain = models.CharField(max_length=255, default='localhost') private_key = models.CharField(max_length=255) public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user') + profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True, + related_name='profile_picture_users') objects = ToolshedUserManager() class Meta: diff --git a/backend/authentication/tests/test_auth.py b/backend/authentication/tests/test_auth.py index 0a3caf1..1abdcaf 100644 --- a/backend/authentication/tests/test_auth.py +++ b/backend/authentication/tests/test_auth.py @@ -1,4 +1,5 @@ import json +import base64 from django.test import Client, RequestFactory from nacl.encoding import HexEncoder @@ -6,6 +7,7 @@ from nacl.signing import SigningKey from authentication.models import ToolshedUser, KnownIdentity from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase +from files.models import File class AuthorizationTestCase(ToolshedTestCase): @@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase): self.assertEqual(reply.json()['username'], 'testuser1') self.assertEqual(reply.json()['domain'], 'example.com') self.assertEqual(reply.json()['email'], 'test1@abc.de') + self.assertIsNone(reply.json()['profile_picture']) def test_user_info2(self): target = "/auth/user/" @@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase): self.assertEqual(reply.status_code, 200) self.assertEqual(reply.json()['username'], 'testuser1') self.assertEqual(reply.json()['domain'], 'example.com') + self.assertIsNone(reply.json()['profile_picture']) + + def test_user_info_patch_profile_picture(self): + content = base64.b64encode(b'user-profile-image').decode('utf-8') + reply = self.client.patch('/auth/user/', self.f['local_user1'], { + 'profile_picture': { + 'data': content, + 'mime_type': 'image/png' + } + }) + self.assertEqual(reply.status_code, 200) + self.assertTrue(reply.json()['profile_picture']) + self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png') + self.assertEqual(File.objects.count(), 1) + self.f['local_user1'].refresh_from_db() + self.assertIsNotNone(self.f['local_user1'].profile_picture) + + def test_user_info_patch_profile_picture_clear(self): + encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8') + test_file = File.objects.create(mime_type='image/png', data=encoded_content) + self.f['local_user1'].profile_picture = test_file + self.f['local_user1'].save() + + reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None}) + self.assertEqual(reply.status_code, 200) + self.assertIsNone(reply.json()['profile_picture']) + self.f['local_user1'].refresh_from_db() + self.assertIsNone(self.f['local_user1'].profile_picture) + self.assertFalse(File.objects.filter(id=test_file.id).exists()) + + def test_user_info_patch_profile_picture_invalid(self): + reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'}) + self.assertEqual(reply.status_code, 400) + + def test_user_info_patch_profile_picture_id(self): + encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8') + test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content) + reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id}) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.json()['profile_picture']['id'], test_file.id) + + def test_user_info_patch_profile_picture_id_not_found(self): + reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999}) + self.assertEqual(reply.status_code, 400) def test_user_info_fail(self): reply = self.anonymous_client.get('/auth/user/') diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index b87ab00..d708ae0 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -1,5 +1,7 @@ from django.http import HttpResponse from django.urls import path +from django.db.models import Q +from django.conf import settings from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.decorators import api_view, permission_classes, authentication_classes @@ -7,7 +9,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from authentication.signature_auth import SignatureAuthentication -from backend import settings from files.models import File @@ -17,7 +18,10 @@ from files.models import File @authentication_classes([SignatureAuthentication]) def media_urls(request, hash_path): try: - file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get( + file = File.objects.filter( + Q(connected_items__owner__in=request.user.friends_or_self()) | + Q(profile_picture_users__in=request.user.friends_or_self()) + ).distinct().get( file=hash_path) if settings.SERVE_X_ACCEL_REDIRECT: diff --git a/backend/files/tests.py b/backend/files/tests.py index 86be80b..2d68e88 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -1,7 +1,7 @@ from django.core.files.base import ContentFile from django.core.files.storage import DefaultStorage from django.db import IntegrityError, transaction -from django.test import Client +from django.test import Client, override_settings from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin from toolshed.tests import InventoryTestMixin from nacl.hash import sha256 @@ -165,3 +165,33 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh self.f['ext_user1']) self.assertEqual(reply.status_code, 404) self.assertTrue('X-Accel-Redirect' not in reply.headers) + + @override_settings(SERVE_X_ACCEL_REDIRECT=True) + def test_profile_picture_url(self): + self.f['local_user1'].profile_picture = self.f['test_file3'] + self.f['local_user1'].save() + + reply = client.get( + f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}", + self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + + @override_settings(SERVE_X_ACCEL_REDIRECT=True) + def test_profile_picture_url_friend(self): + self.f['local_user1'].profile_picture = self.f['test_file3'] + self.f['local_user1'].save() + + reply = client.get( + f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}", + self.f['local_user2']) + self.assertEqual(reply.status_code, 200) + + def test_profile_picture_url_not_friend(self): + self.f['local_user1'].profile_picture = self.f['test_file3'] + self.f['local_user1'].save() + + reply = client.get( + f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}", + self.f['ext_user1']) + self.assertEqual(reply.status_code, 404) + diff --git a/backend/toolshed/migrations/0008_alter_inventoryitem_storage_location.py b/backend/toolshed/migrations/0008_alter_inventoryitem_storage_location.py new file mode 100644 index 0000000..4c089a4 --- /dev/null +++ b/backend/toolshed/migrations/0008_alter_inventoryitem_storage_location.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.2 on 2026-07-23 02:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('toolshed', '0007_workflowinstance'), + ] + + operations = [ + migrations.AlterField( + model_name='inventoryitem', + name='storage_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='toolshed.storagelocation'), + ), + ] diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 2f80b2b..a2a8d7d 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -90,7 +90,7 @@ class InventoryItem(SoftDeleteModel): tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items') properties = models.ManyToManyField(Property, through='ItemProperty') files = models.ManyToManyField(File, related_name='connected_items') - storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True, + storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True, related_name='inventory_items') def clean(self): diff --git a/backend/toolshed/tests/test_locations.py b/backend/toolshed/tests/test_locations.py index d792857..5f9eef9 100644 --- a/backend/toolshed/tests/test_locations.py +++ b/backend/toolshed/tests/test_locations.py @@ -1,6 +1,6 @@ from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase from files.tests import FilesTestMixin -from toolshed.models import InventoryItem, Category +from toolshed.models import InventoryItem, Category, StorageLocation from toolshed.tests import InventoryTestMixin, LocationTestMixin client = SignatureAuthClient() @@ -69,3 +69,63 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin, self.assertEqual(reply.json()[3]['description'], None) self.assertEqual(reply.json()[3]['category'], 'cat1') self.assertEqual(reply.json()[3]['path'], 'loc1/loc4') + + def test_post_new_location(self): + reply = client.post('/api/storage_locations/', self.f['local_user1'], { + 'name': 'loc5', + 'description': 'a new location', + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(StorageLocation.objects.count(), 5) + location = StorageLocation.objects.get(name='loc5') + self.assertEqual(location.description, 'a new location') + self.assertEqual(location.owner, self.f['local_user1']) + self.assertEqual(location.parent, None) + self.assertEqual(reply.json()['path'], 'loc5') + + def test_post_new_nested_location(self): + reply = client.post('/api/storage_locations/', self.f['local_user1'], { + 'name': 'loc5', + 'parent': self.f['loc3'].id, + }) + self.assertEqual(reply.status_code, 201) + location = StorageLocation.objects.get(name='loc5') + self.assertEqual(location.parent, self.f['loc3']) + self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5') + + def test_patch_location(self): + reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], { + 'name': 'loc2-renamed', + 'parent': self.f['loc1'].id, + }) + self.assertEqual(reply.status_code, 200) + location = StorageLocation.objects.get(id=self.f['loc2'].id) + self.assertEqual(location.name, 'loc2-renamed') + self.assertEqual(location.parent, self.f['loc1']) + self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed') + + def test_delete_location(self): + reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1']) + self.assertEqual(reply.status_code, 204) + self.assertEqual(StorageLocation.objects.count(), 3) + self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0) + + def test_delete_location_with_items_sets_null(self): + item = InventoryItem.objects.create( + owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3']) + reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1']) + self.assertEqual(reply.status_code, 204) + item.refresh_from_db() + self.assertIsNone(item.storage_location) + self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1) + + def test_locations_are_owner_scoped(self): + reply = client.get('/api/storage_locations/', self.f['local_user2']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 0) + + def test_cannot_delete_other_users_location(self): + reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2']) + self.assertEqual(reply.status_code, 404) + self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1) + diff --git a/deploy/dev/instance_a/domains.json b/deploy/dev/instance_a/domains.json index 711ab9d..ad8f349 100644 --- a/deploy/dev/instance_a/domains.json +++ b/deploy/dev/instance_a/domains.json @@ -1,19 +1,3 @@ [ -[ - { - "speed": 1, - "position": 1 - }, - { - "speed": 1, - "position": 1 - }, - { - "speed": 1, - "position": 1 - } -] - -arr[1].speed "a.localhost" ] diff --git a/deploy/dev/instance_a/nginx-a.dev.conf b/deploy/dev/instance_a/nginx-a.dev.conf index 5dfbbe5..c661fb3 100644 --- a/deploy/dev/instance_a/nginx-a.dev.conf +++ b/deploy/dev/instance_a/nginx-a.dev.conf @@ -14,7 +14,7 @@ http { } upstream dns { - server dns:8053; + server toolshed-dns:8053; } server { @@ -106,17 +106,35 @@ http { # DoH server server { listen 5353 ssl; - server_name localhost; + server_name localhost 127.0.0.3; ssl_certificate /etc/nginx/nginx.crt; ssl_certificate_key /etc/nginx/nginx.key; - location /dns-query { - proxy_pass http://dns; - # allow any origin - add_header 'Access-Control-Allow-Origin' '*'; - add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS'; + # Ensure CORS headers are present even when nginx generates 5xx responses. + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always; + add_header 'Access-Control-Expose-Headers' 'Content-Type' always; + error_page 500 502 503 504 = @doh_error; + location /dns-query { + if ($request_method = OPTIONS) { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always; + add_header 'Access-Control-Max-Age' 86400 always; + add_header 'Content-Length' 0; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + return 204; + } + + proxy_pass http://dns; + } + + location @doh_error { + default_type text/plain; + return 502 'DoH upstream unavailable'; } } } diff --git a/deploy/docker-compose.override.yml b/deploy/docker-compose.override.yml index 6ed6978..b3fc9d3 100644 --- a/deploy/docker-compose.override.yml +++ b/deploy/docker-compose.override.yml @@ -78,3 +78,7 @@ services: - ./dev/zone.json:/dns/zone.json expose: - 8053 + networks: + default: + aliases: + - toolshed-dns diff --git a/docs/development.md b/docs/development.md index 3fcd229..b4e1dcd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -97,6 +97,12 @@ Start the fullstack application: docker-compose -f deploy/docker-compose.override.yml up --build ``` +Run backend tests in Docker: + +``` bash +docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test" +``` + This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend. The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH server is used to direct the frontend to the correct backend instance. diff --git a/frontend/src/components/AuthenticatedAvatar.vue b/frontend/src/components/AuthenticatedAvatar.vue new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/AuthenticatedImage.vue b/frontend/src/components/AuthenticatedImage.vue index 4c504a0..60e4c38 100644 --- a/frontend/src/components/AuthenticatedImage.vue +++ b/frontend/src/components/AuthenticatedImage.vue @@ -1,15 +1,8 @@ - - \ No newline at end of file diff --git a/frontend/src/components/UserDropdown.vue b/frontend/src/components/UserDropdown.vue index 0f6280b..60ba182 100644 --- a/frontend/src/components/UserDropdown.vue +++ b/frontend/src/components/UserDropdown.vue @@ -6,8 +6,8 @@ - + @@ -35,14 +35,17 @@ diff --git a/frontend/src/components/inputs/FsFileSource.vue b/frontend/src/components/inputs/FsFileSource.vue index 29bff9a..442e126 100644 --- a/frontend/src/components/inputs/FsFileSource.vue +++ b/frontend/src/components/inputs/FsFileSource.vue @@ -1,9 +1,9 @@ @@ -19,9 +19,17 @@ export default { ...BIcons }, emits: ["input"], + data() { + return { + inputId: `files-${this._uid}` + }; + }, methods: { loadFiles() { - const files = document.getElementById("files").files; + const files = this.$refs.fileInput?.files; + if (!files || files.length === 0) { + return; + } const jobs = [...files].map((file) => { return new Promise((resolve, reject) => { var reader = new FileReader(); @@ -52,6 +60,10 @@ export default { }); Promise.all(jobs).then((files) => { this.$emit("input", files) + // Allow selecting the same file again to trigger change. + if (this.$refs.fileInput) { + this.$refs.fileInput.value = ''; + } }) } }, diff --git a/frontend/src/components/inputs/PreferenceInput.vue b/frontend/src/components/inputs/PreferenceInput.vue new file mode 100644 index 0000000..ee85035 --- /dev/null +++ b/frontend/src/components/inputs/PreferenceInput.vue @@ -0,0 +1,168 @@ + + + + diff --git a/frontend/src/components/workflow/ComponentRegistry.js b/frontend/src/components/workflow/ComponentRegistry.js new file mode 100644 index 0000000..f938458 --- /dev/null +++ b/frontend/src/components/workflow/ComponentRegistry.js @@ -0,0 +1,110 @@ +/** + * Workflow Step Component Registry + * + * This module handles the dynamic loading and registration of workflow step components. + * It provides a centralized way to map workflow types and steps to their corresponding Vue components. + */ + +// Import all step components +import FotoFirstStep1 from './steps/FotoFirstStep1.vue'; +import FotoFirstStep2 from './steps/FotoFirstStep2.vue'; +import FotoFirstStep3 from './steps/FotoFirstStep3.vue'; +import FotoFirstStep4 from './steps/FotoFirstStep4.vue'; +import BulkImportStep1 from './steps/BulkImportStep1.vue'; + +/** + * Component registry mapping workflow types and steps to components + * Format: 'workflowType-step' -> Component + */ +const componentRegistry = { + // Foto First Import Workflow components + 'foto-first-bulk-import-1': FotoFirstStep1, + 'foto-first-bulk-import-2': FotoFirstStep2, + 'foto-first-bulk-import-3': FotoFirstStep3, + 'foto-first-bulk-import-4': FotoFirstStep4, + + // Bulk Item Import Workflow components + 'import-items-1': BulkImportStep1, + // Additional steps can be added as needed + // 'import-items-2': BulkImportStep2, + // 'import-items-3': BulkImportStep3, + // ... etc +}; + +/** + * Get a step component for a given workflow type and step + * @param {string} workflowType - The workflow type identifier + * @param {string|number} step - The step identifier + * @returns {Object|null} Vue component or null if not found + */ +export function getStepComponent(workflowType, step) { + const componentKey = `${workflowType}-${step}`; + return componentRegistry[componentKey] || null; +} + +/** + * Register a new step component + * @param {string} workflowType - The workflow type identifier + * @param {string|number} step - The step identifier + * @param {Object} component - The Vue component + */ +export function registerStepComponent(workflowType, step, component) { + const componentKey = `${workflowType}-${step}`; + componentRegistry[componentKey] = component; +} + +/** + * Get all registered components for a workflow type + * @param {string} workflowType - The workflow type identifier + * @returns {Object} Object with step numbers as keys and components as values + */ +export function getWorkflowComponents(workflowType) { + const workflowComponents = {}; + + Object.keys(componentRegistry).forEach(key => { + if (key.startsWith(`${workflowType}-`)) { + const step = key.replace(`${workflowType}-`, ''); + workflowComponents[step] = componentRegistry[key]; + } + }); + + return workflowComponents; +} + +/** + * Check if a step component exists for a workflow type and step + * @param {string} workflowType - The workflow type identifier + * @param {string|number} step - The step identifier + * @returns {boolean} True if component exists, false otherwise + */ +export function hasStepComponent(workflowType, step) { + const componentKey = `${workflowType}-${step}`; + return componentKey in componentRegistry; +} + +/** + * Get all registered workflow types + * @returns {Array} Array of workflow type identifiers + */ +export function getRegisteredWorkflowTypes() { + const workflowTypes = new Set(); + + Object.keys(componentRegistry).forEach(key => { + const parts = key.split('-'); + if (parts.length >= 2) { + // Reconstruct workflow type (everything except the last part which is the step) + const workflowType = parts.slice(0, -1).join('-'); + workflowTypes.add(workflowType); + } + }); + + return Array.from(workflowTypes); +} + +export default { + getStepComponent, + registerStepComponent, + getWorkflowComponents, + hasStepComponent, + getRegisteredWorkflowTypes +}; diff --git a/frontend/src/components/workflow/ExampleUsage.js b/frontend/src/components/workflow/ExampleUsage.js new file mode 100644 index 0000000..dc22fc7 --- /dev/null +++ b/frontend/src/components/workflow/ExampleUsage.js @@ -0,0 +1,193 @@ +/** + * Workflow Step Component Example Usage + * + * This file demonstrates how to use the workflow step component dispatching system + * and provides examples for developers who want to create new workflow steps. + */ + +import { + getStepComponent, + registerStepComponent, + hasStepComponent, + getWorkflowComponents, + getRegisteredWorkflowTypes +} from './ComponentRegistry.js'; + +/** + * Example: Creating and registering a new workflow step component + */ + +// 1. Create your step component (example) +const ExampleWorkflowStep1 = { + name: 'ExampleWorkflowStep1', + props: { + workflowInstance: { type: Object, required: true }, + step: { type: String, required: true }, + payload: { type: Object, default: () => ({}) } + }, + template: ` +
+

Example Workflow - Step 1

+

This is a custom workflow step component.

+ +
+ ` +}; + +// 2. Register the component +registerStepComponent('example-workflow', '1', ExampleWorkflowStep1); + +/** + * Example: Using the component registry programmatically + */ +export function demonstrateComponentRegistry() { + console.log('=== Workflow Component Registry Demo ==='); + + // Check if a component exists + console.log('Has foto-first step 1:', hasStepComponent('foto-first-bulk-import', '1')); + console.log('Has non-existent step:', hasStepComponent('non-existent', '999')); + + // Get a specific component + const step1Component = getStepComponent('foto-first-bulk-import', '1'); + console.log('Retrieved component:', step1Component?.name); + + // Get all components for a workflow + const fotoFirstComponents = getWorkflowComponents('foto-first-bulk-import'); + console.log('Foto First components:', Object.keys(fotoFirstComponents)); + + // Get all registered workflow types + const workflowTypes = getRegisteredWorkflowTypes(); + console.log('Registered workflow types:', workflowTypes); + + return { + availableWorkflows: workflowTypes, + fotoFirstSteps: Object.keys(fotoFirstComponents), + totalComponents: workflowTypes.reduce((total, type) => { + return total + Object.keys(getWorkflowComponents(type)).length; + }, 0) + }; +} + +/** + * Example: Dynamic component loading in a Vue component + */ +export const WorkflowStepLoader = { + name: 'WorkflowStepLoader', + props: { + workflowType: { type: String, required: true }, + currentStep: { type: [String, Number], required: true }, + workflowInstance: { type: Object, required: true }, + payload: { type: Object, default: () => ({}) } + }, + computed: { + stepComponent() { + return getStepComponent(this.workflowType, this.currentStep); + }, + hasStepComponent() { + return hasStepComponent(this.workflowType, this.currentStep); + } + }, + template: ` +
+ + + + +
+
{{ workflowType }} - Step {{ currentStep }}
+

No custom component found for this step.

+
+ + +
+
+
+ ` +}; + +/** + * Development utilities for workflow components + */ +export const WorkflowDevUtils = { + /** + * List all available workflow steps + */ + listAllSteps() { + const workflowTypes = getRegisteredWorkflowTypes(); + const allSteps = {}; + + workflowTypes.forEach(type => { + allSteps[type] = Object.keys(getWorkflowComponents(type)); + }); + + return allSteps; + }, + + /** + * Validate workflow step coverage + */ + validateWorkflowCoverage(workflowDefinitions) { + const results = {}; + + Object.entries(workflowDefinitions).forEach(([type, definition]) => { + const requiredSteps = definition.getStepDefinitions().map(s => s.step.toString()); + const availableSteps = Object.keys(getWorkflowComponents(type)); + + results[type] = { + required: requiredSteps, + available: availableSteps, + missing: requiredSteps.filter(step => !availableSteps.includes(step)), + coverage: (availableSteps.length / requiredSteps.length) * 100 + }; + }); + + return results; + }, + + /** + * Generate component registry report + */ + generateReport() { + const workflowTypes = getRegisteredWorkflowTypes(); + const report = { + totalWorkflowTypes: workflowTypes.length, + totalComponents: 0, + workflows: {} + }; + + workflowTypes.forEach(type => { + const components = getWorkflowComponents(type); + const stepCount = Object.keys(components).length; + + report.totalComponents += stepCount; + report.workflows[type] = { + steps: stepCount, + stepNumbers: Object.keys(components).sort((a, b) => parseInt(a) - parseInt(b)) + }; + }); + + return report; + } +}; + +export default { + demonstrateComponentRegistry, + WorkflowStepLoader, + WorkflowDevUtils +}; diff --git a/frontend/src/components/workflow/README.md b/frontend/src/components/workflow/README.md new file mode 100644 index 0000000..b91a72d --- /dev/null +++ b/frontend/src/components/workflow/README.md @@ -0,0 +1,146 @@ +# Workflow Step Component Dispatching System + +## Overview + +This system enables dynamic dispatching of Vue.js components based on workflow type and current step in the WorkflowDetail view. It provides a flexible, extensible architecture for creating custom step-specific user interfaces for different workflow types. + +## Architecture + +### 1. Component Registry (`/components/workflow/ComponentRegistry.js`) + +The central registry that maps workflow types and steps to their corresponding Vue components using the format: `workflowType-step` → Component. + +**Key Functions:** +- `getStepComponent(workflowType, step)` - Retrieves a step component +- `registerStepComponent(workflowType, step, component)` - Registers new components +- `hasStepComponent(workflowType, step)` - Checks component existence +- `getWorkflowComponents(workflowType)` - Gets all components for a workflow +- `getRegisteredWorkflowTypes()` - Lists all registered workflow types + +### 2. Step Components (`/components/workflow/steps/`) + +Individual Vue components that handle specific workflow steps: + +#### Foto First Import Workflow +- **FotoFirstStep1.vue** - Photo capture/upload with camera and file upload support +- **FotoFirstStep2.vue** - Image processing with compression and optimization +- **FotoFirstStep3.vue** - Item details entry with form-based data collection +- **FotoFirstStep4.vue** - Import completion with summary and finalization + +#### Bulk Import Workflow +- **BulkImportStep1.vue** - File upload with CSV/Excel support and column mapping + +### 3. Dynamic Component Loading in WorkflowDetail.vue + +The `stepComponent` computed property now uses the registry: + +```javascript +stepComponent() { + const workflowType = this.workflowInstance?.workflow_type; + if (workflowType && this.currentStep) { + return getStepComponent(workflowType, this.currentStep); + } + return null; +} +``` + +## How Content Dispatching Works + +### 1. **Workflow Active State Management** +- Components connect to Vuex store's `active_workflows` state +- `loadWorkflowInstance()` fetches and finds specific workflow instances +- State determines which workflow type and step are active + +### 2. **Dynamic Component Resolution** +- System looks up components using workflow type + step combination +- Registry returns the appropriate Vue component or null +- Vue's `` renders the resolved component + +### 3. **Component Communication** +- Step components receive props: `workflowInstance`, `step`, `payload` +- Components emit events: `@update`, `@next`, `@prev`, `@complete` +- Parent WorkflowDetail handles state updates and navigation + +### 4. **Workflow Active Classes** +- `isStepCurrent(stepNumber)` - Identifies active step +- `isStepCompleted(stepNumber)` - Tracks completed steps +- `getStepClass(stepNumber)` - Applies appropriate CSS classes: + - `step-indicator-completed bg-success` - Completed steps + - `step-indicator-current bg-primary text-white` - Current step + - `step-indicator-pending bg-light border` - Pending steps + +## Component Features + +### FotoFirstStep1 (Photo Capture) +- **Camera Integration**: Uses `navigator.mediaDevices.getUserMedia()` +- **File Upload**: Drag-and-drop and file selection +- **Image Preview**: Real-time photo gallery +- **Data Persistence**: Photos saved to workflow payload + +### FotoFirstStep2 (Image Processing) +- **Batch Processing**: Processes multiple images sequentially +- **Image Optimization**: Compression and resizing +- **Progress Tracking**: Visual progress indicators +- **Processing Options**: Configurable compression and dimensions + +### FotoFirstStep3 (Item Details) +- **Item-by-Item Entry**: Navigate through captured photos +- **Comprehensive Forms**: Name, category, quantity, location, pricing +- **Progress Tracking**: Shows completion status +- **Data Validation**: Required field validation +- **Edit Support**: Ability to modify previously entered items + +### FotoFirstStep4 (Completion) +- **Import Summary**: Statistics and breakdowns +- **Data Review**: Grid and list view of items +- **Final Options**: QR codes, notifications, reports +- **Completion Workflow**: Final import execution + +### BulkImportStep1 (File Upload) +- **File Type Support**: CSV and Excel files +- **Drag-and-Drop**: Modern file upload interface +- **Column Mapping**: Automatic and manual field mapping +- **Data Preview**: Shows first 5 rows of data +- **Template Download**: Provides sample CSV template +- **File Analysis**: Validates data structure and content + +## Extensibility + +### Adding New Workflow Types +1. Create step components in `/components/workflow/steps/` +2. Import components in `ComponentRegistry.js` +3. Add mappings to `componentRegistry` object +4. Define workflow in `workflows.js` + +### Adding New Steps +1. Create component: `WorkflowTypeStepN.vue` +2. Import and register in ComponentRegistry +3. Update workflow step definitions +4. Component automatically dispatched when step is reached + +## Benefits + +1. **Modularity**: Each step is an independent, reusable component +2. **Flexibility**: Easy to create workflow-specific UI experiences +3. **Maintainability**: Clear separation of concerns +4. **Extensibility**: Simple process to add new workflows and steps +5. **Type Safety**: Registry provides centralized component management +6. **Performance**: Components only loaded when needed +7. **Consistency**: Standardized props and events across all step components + +## Usage Example + +```javascript +// Register a new step component +registerStepComponent('custom-workflow', '1', CustomStep1Component); + +// Check if component exists +if (hasStepComponent('foto-first-bulk-import', '1')) { + // Component is available +} + +// Get all components for a workflow +const components = getWorkflowComponents('import-items'); +``` + +This system provides a robust foundation for building complex, multi-step workflows with rich, interactive user interfaces while maintaining clean separation between workflow logic and presentation components. diff --git a/frontend/src/components/workflow/steps/BulkImportStep1.vue b/frontend/src/components/workflow/steps/BulkImportStep1.vue new file mode 100644 index 0000000..75a9722 --- /dev/null +++ b/frontend/src/components/workflow/steps/BulkImportStep1.vue @@ -0,0 +1,480 @@ + + + + + diff --git a/frontend/src/components/workflow/steps/FotoFirstStep1.vue b/frontend/src/components/workflow/steps/FotoFirstStep1.vue new file mode 100644 index 0000000..10d5157 --- /dev/null +++ b/frontend/src/components/workflow/steps/FotoFirstStep1.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/components/workflow/steps/FotoFirstStep2.vue b/frontend/src/components/workflow/steps/FotoFirstStep2.vue new file mode 100644 index 0000000..5557242 --- /dev/null +++ b/frontend/src/components/workflow/steps/FotoFirstStep2.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/frontend/src/components/workflow/steps/FotoFirstStep3.vue b/frontend/src/components/workflow/steps/FotoFirstStep3.vue new file mode 100644 index 0000000..6ac4bbd --- /dev/null +++ b/frontend/src/components/workflow/steps/FotoFirstStep3.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/frontend/src/components/workflow/steps/FotoFirstStep4.vue b/frontend/src/components/workflow/steps/FotoFirstStep4.vue new file mode 100644 index 0000000..e9887c4 --- /dev/null +++ b/frontend/src/components/workflow/steps/FotoFirstStep4.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index c6b1d81..b63d124 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -28,6 +28,7 @@ import Privacy from '@/views/settings/Privacy.vue'; import Email from '@/views/settings/Email.vue'; import Notifications from '@/views/settings/Notifications.vue'; import Data from '@/views/settings/Data.vue'; +import Preferences from '@/views/settings/Preferences.vue'; const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { @@ -86,6 +87,8 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { path: 'notifications/', name: 'notifications', component: Notifications, meta: {requiresAuth: true} }, { path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true} + }, { + path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true} }] }, { path: '/storage-location', diff --git a/frontend/src/store.js b/frontend/src/store.js index 301a705..09c1fed 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -7,12 +7,65 @@ import {parseIdentityRecord, serializeIdentityRecord} from "@/identity"; //import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import persistentStatePlugin from "@/../extras/persistent-state-plugin"; +const defaultPreferenceDefinitions = [ + { + key: 'ui.compact_mode', + type: 'boolean', + default: false, + label: 'Compact mode', + description: 'Show denser item rows and reduce spacing in lists.' + }, + { + key: 'ui.default_search_scope', + type: 'enum', + options: ['inventory', 'friends', 'all'], + default: 'inventory', + label: 'Default search scope', + description: 'Choose where the global search starts.' + }, + { + key: 'notifications.desktop_enabled', + type: 'boolean', + default: true, + label: 'Desktop notifications', + description: 'Enable in-browser notifications for important updates.' + }, + { + key: 'files.max_upload_mb', + type: 'integer', + default: 25, + label: 'Default upload size limit (MB)', + description: 'Used as a prefill hint in upload dialogs.' + }, + { + key: 'ui.experimental_flags', + type: 'json', + default: {}, + label: 'Experimental flags', + description: 'Optional JSON toggles for feature previews.' + }, +] + +const parseStoredPreferences = (storageKey) => { + try { + const raw = localStorage.getItem(storageKey) + if (!raw) { + return {} + } + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (_error) { + return {} + } +} + export default createStore({ state: { local_loaded: false, last_load: {}, user: null, + user_profile: null, token: null, keypair: null, remember: false, @@ -31,6 +84,10 @@ export default createStore({ domains: [], storage_locations: [], active_workflows: [], + preferenceDefinitions: [], + accountPreferences: {}, + devicePreferences: {}, + preferencesLoaded: false, }, mutations: { setInventoryItems(state, {url, items}) { @@ -69,11 +126,42 @@ export default createStore({ setActiveWorkflows(state, workflows) { state.active_workflows = workflows; }, + setPreferenceDefinitions(state, definitions) { + state.preferenceDefinitions = definitions; + }, + setAccountPreferences(state, preferences) { + state.accountPreferences = preferences; + }, + setDevicePreferences(state, preferences) { + state.devicePreferences = preferences; + }, + setAccountPreference(state, {key, value}) { + state.accountPreferences = {...state.accountPreferences, [key]: value}; + }, + setDevicePreference(state, {key, value}) { + state.devicePreferences = {...state.devicePreferences, [key]: value}; + }, + deleteAccountPreference(state, key) { + const prefs = {...state.accountPreferences}; + delete prefs[key]; + state.accountPreferences = prefs; + }, + deleteDevicePreference(state, key) { + const prefs = {...state.devicePreferences}; + delete prefs[key]; + state.devicePreferences = prefs; + }, + setPreferencesLoaded(state, loaded) { + state.preferencesLoaded = loaded; + }, setUser(state, user) { state.user = user; if (state.remember) localStorage.setItem('user', user); }, + setUserProfile(state, profile) { + state.user_profile = profile; + }, setToken(state, token) { state.token = token; if (state.remember) @@ -110,6 +198,7 @@ export default createStore({ }, logout(state) { state.user = null; + state.user_profile = null; state.token = null; state.keypair = null; localStorage.removeItem('user'); @@ -136,6 +225,33 @@ export default createStore({ } }, actions: { + async loadUserPreferences({state, commit}) { + const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous') + const deviceKey = 'toolshed.preferences.device' + + commit('setPreferenceDefinitions', defaultPreferenceDefinitions) + commit('setAccountPreferences', parseStoredPreferences(accountKey)) + commit('setDevicePreferences', parseStoredPreferences(deviceKey)) + commit('setPreferencesLoaded', true) + }, + async setAccountPreference({state, commit}, {key, value}) { + commit('setAccountPreference', {key, value}) + const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous') + localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences)) + }, + async resetAccountPreference({state, commit}, key) { + commit('deleteAccountPreference', key) + const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous') + localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences)) + }, + async setDevicePreference({state, commit}, {key, value}) { + commit('setDevicePreference', {key, value}) + localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) + }, + async resetDevicePreference({state, commit}, key) { + commit('deleteDevicePreference', key) + localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) + }, async login({commit, dispatch, state, getters}, {username, password, remember}) { commit('setRemember', remember); const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) @@ -146,11 +262,33 @@ export default createStore({ commit('setKey', data.key); const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) commit('setHomeServers', s) + await dispatch('fetchUserProfile', {force: true}) return true; } else { return false; } }, + async fetchUserProfile({state, commit, dispatch, getters}, {force = false} = {}) { + if (!force && state.user_profile && state.last_load.user_profile > Date.now() - 1000 * 60) { + return state.user_profile + } + const servers = await dispatch('getHomeServers') + const data = await servers.get(getters.signAuth, '/auth/user/') + commit('setUserProfile', data) + state.last_load.user_profile = Date.now() + return data + }, + async updateUserProfilePicture({state, commit, dispatch, getters}, {file = null} = {}) { + const servers = await dispatch('getHomeServers') + const payload = file ? {profile_picture: {data: file.data, mime_type: file.mime_type}} : {profile_picture: null} + const data = await servers.patch(getters.signAuth, '/auth/user/', payload) + commit('setUserProfile', data) + state.last_load.user_profile = Date.now() + if (data.profile_picture) { + state.last_load.files = 0 + } + return data + }, async lookupServer({state}, {username}) { const domain = username.split('@')[1] const request = '_toolshed-server._tcp.' + domain + '.' @@ -510,5 +648,18 @@ export default createStore({ time: Date.now() - 1000 * 60 * 60 * 24 }] }, + getPreference: (state) => (key, fallbackDefault = null) => { + if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) { + return state.devicePreferences[key] + } + if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) { + return state.accountPreferences[key] + } + const definition = state.preferenceDefinitions.find((pref) => pref.key === key) + if (definition) { + return definition.default + } + return fallbackDefault + }, } }) \ No newline at end of file diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index d077c14..38df1f8 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -109,16 +109,21 @@ export default { }, computed: { ...mapGetters(["inventory_items", "loaded_items"]), - ...mapState(["user"]), + ...mapState(["user", "storage_locations"]), }, methods: { - ...mapActions(["fetchInventoryItems", "deleteInventoryItem"]), + ...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]), only_images(files) { return files.filter(file => file.mime_type.startsWith("image/")); }, + locationPath(item) { + const loc = this.storage_locations.find(loc => loc.id === item.storage_location) + return loc ? loc.path : null + }, }, async mounted() { await this.fetchInventoryItems() + await this.fetchStorageLocations() } } diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 7aebe4a..2d66059 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -57,7 +57,7 @@ diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue index 79edb96..0f5b0a9 100644 --- a/frontend/src/views/InventoryEdit.vue +++ b/frontend/src/views/InventoryEdit.vue @@ -43,7 +43,7 @@ - + v-model="item.storage_location"> +