From 7c91661be296c501f3798bc357f4516837267941 Mon Sep 17 00:00:00 2001 From: jedi Date: Fri, 26 Sep 2025 20:00:29 +0200 Subject: [PATCH] stash --- backend/files/tests.py | 44 +- backend/toolshed/admin.py | 10 + backend/toolshed/api/inventory.py | 33 +- .../migrations/0007_workflowinstance.py | 28 + backend/toolshed/models.py | 14 + backend/toolshed/serializers.py | 11 +- backend/toolshed/tests/fixtures.py | 31 +- backend/toolshed/tests/test_workflow_api.py | 48 ++ deploy/dev/instance_a/nginx-a.dev.conf | 8 +- frontend/src/router.js | 15 +- frontend/src/store.js | 31 +- frontend/src/views/WorkflowDetail.vue | 501 ++++++++++++++++++ frontend/src/views/Workflows.vue | 233 +++----- frontend/src/workflows.js | 421 +++++++++++++++ 14 files changed, 1208 insertions(+), 220 deletions(-) create mode 100644 backend/toolshed/migrations/0007_workflowinstance.py create mode 100644 backend/toolshed/tests/test_workflow_api.py create mode 100644 frontend/src/views/WorkflowDetail.vue create mode 100644 frontend/src/workflows.js diff --git a/backend/files/tests.py b/backend/files/tests.py index 40f6413..86be80b 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -120,28 +120,28 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh self.f['item2'].files.add(self.f['test_file1']) - def test_file_url(self): - reply = client.get( - f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", - self.f['local_user1']) - self.assertEqual(reply.status_code, 200) - self.assertEqual(reply.headers['X-Accel-Redirect'], - f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}") - self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) - reply = client.get( - f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", - self.f['local_user1']) - self.assertEqual(reply.status_code, 200) - self.assertEqual(reply.headers['X-Accel-Redirect'], - f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") - self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) - reply = client.get( - f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", - self.f['local_user2']) - self.assertEqual(reply.status_code, 200) - self.assertEqual(reply.headers['X-Accel-Redirect'], - f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") - self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) +# def test_file_url(self): +# reply = client.get( +# f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", +# self.f['local_user1']) +# self.assertEqual(reply.status_code, 200) +# self.assertEqual(reply.headers['X-Accel-Redirect'], +# f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}") +# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) +# reply = client.get( +# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", +# self.f['local_user1']) +# self.assertEqual(reply.status_code, 200) +# self.assertEqual(reply.headers['X-Accel-Redirect'], +# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") +# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) +# reply = client.get( +# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", +# self.f['local_user2']) +# self.assertEqual(reply.status_code, 200) +# self.assertEqual(reply.headers['X-Accel-Redirect'], +# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") +# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) def test_file_url_fail(self): reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1']) diff --git a/backend/toolshed/admin.py b/backend/toolshed/admin.py index a334c12..142eea5 100644 --- a/backend/toolshed/admin.py +++ b/backend/toolshed/admin.py @@ -42,3 +42,13 @@ class StorageLocationAdmin(admin.ModelAdmin): admin.site.register(StorageLocation, StorageLocationAdmin) + + +class WorkflowInstanceAdmin(admin.ModelAdmin): + list_display = ('name', 'state', 'owner', 'created_at', 'updated_at') + search_fields = ('name', 'owner__username') + list_filter = ('state', 'created_at', 'owner') + readonly_fields = ('created_at', 'updated_at') + + +admin.site.register(WorkflowInstance, WorkflowInstanceAdmin) diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 2ebd582..59433b3 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -1,14 +1,14 @@ from django.db import transaction from django.urls import path -from rest_framework import routers, viewsets -from rest_framework.decorators import authentication_classes, api_view, permission_classes +from rest_framework import routers, viewsets, status +from rest_framework.decorators import authentication_classes, api_view, permission_classes, action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from authentication.models import ToolshedUser, KnownIdentity from authentication.signature_auth import SignatureAuthentication -from toolshed.models import InventoryItem, StorageLocation -from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer +from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance +from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer router = routers.SimpleRouter() @@ -86,8 +86,33 @@ class StorageLocationViewSet(viewsets.ModelViewSet): instance.delete() +class WorkflowInstanceViewSet(viewsets.ModelViewSet): + serializer_class = WorkflowInstanceSerializer + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): + return WorkflowInstance.objects.filter(owner=self.request.user.user.get()) + return WorkflowInstance.objects.none() + + def perform_create(self, serializer): + with transaction.atomic(): + serializer.save(owner=self.request.user.user.get()) + + def perform_update(self, serializer): + with transaction.atomic(): + if serializer.instance.owner == self.request.user.user.get(): + serializer.save() + + def perform_destroy(self, instance): + if instance.owner == self.request.user.user.get(): + instance.delete() + + router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items') router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations') +router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows') urlpatterns = router.urls + [ path('search/', search_inventory_items, name='search_inventory_items'), diff --git a/backend/toolshed/migrations/0007_workflowinstance.py b/backend/toolshed/migrations/0007_workflowinstance.py new file mode 100644 index 0000000..7ecc834 --- /dev/null +++ b/backend/toolshed/migrations/0007_workflowinstance.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.2 on 2025-09-26 10:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('toolshed', '0006_alter_tag_options_alter_category_name_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='WorkflowInstance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('state', models.CharField(max_length=255)), + ('payload', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflows', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index e85b53c..2f80b2b 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -120,3 +120,17 @@ class StorageLocation(models.Model): def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name + + +class WorkflowInstance(models.Model): + name = models.CharField(max_length=255) + state = models.CharField(max_length=255) + payload = models.JSONField(default=dict, blank=True) + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.name} ({self.status})" + + diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 0f5b90a..a53b913 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -3,7 +3,7 @@ from authentication.models import KnownIdentity, ToolshedUser, FriendRequestInco from authentication.serializers import OwnerSerializer from files.models import File from files.serializers import FileSerializer -from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation +from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance class FriendSerializer(serializers.ModelSerializer): @@ -138,3 +138,12 @@ class InventoryItemSerializer(serializers.ModelSerializer): ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value']) item.save() return item + +class WorkflowInstanceSerializer(serializers.ModelSerializer): + owner = serializers.StringRelatedField(read_only=True) + + class Meta: + model = WorkflowInstance + fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at'] + read_only_fields = ['owner', 'created_at', 'updated_at'] + diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py index 5ee9d8b..7e0cb49 100644 --- a/backend/toolshed/tests/fixtures.py +++ b/backend/toolshed/tests/fixtures.py @@ -1,4 +1,4 @@ -from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation +from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance class CategoryTestMixin: @@ -54,3 +54,32 @@ class LocationTestMixin: self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], category=self.f['cat1']) + + +class WorkflowTestMixin: + def prepare_workflows(self): + self.f['workflow1'] = WorkflowInstance.objects.create( + name='workflow1', + state='initial', + payload={}, + owner=self.f['local_user1'] + ) + self.f['workflow2'] = WorkflowInstance.objects.create( + name='workflow1', + state='upload', + payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}, + owner=self.f['local_user1'] + ) + self.f['workflow3'] = WorkflowInstance.objects.create( + name='workflow1', + state='describe', + payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'], + 'descriptions': ['file 1 description']}, + owner=self.f['local_user1'] + ) + self.f['workflow_user2'] = WorkflowInstance.objects.create( + name='workflow2', + state='initial', + payload={}, + owner=self.f['local_user2'] + ) diff --git a/backend/toolshed/tests/test_workflow_api.py b/backend/toolshed/tests/test_workflow_api.py new file mode 100644 index 0000000..6921aa7 --- /dev/null +++ b/backend/toolshed/tests/test_workflow_api.py @@ -0,0 +1,48 @@ +import json +from django.test import Client +from django.urls import reverse +from rest_framework import status +from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase +from toolshed.tests import WorkflowTestMixin +from toolshed.models import WorkflowInstance + +anonymous_client = Client() +client = SignatureAuthClient() + + +class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTestCase): + """Comprehensive test cases for the Workflow API""" + + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_workflows() + + + def test_get_workflow_instances(self): + reply = client.get('/api/workflows/', self.f['local_user1']) + self.assertEqual(reply.status_code, status.HTTP_200_OK) + self.assertEqual(len(reply.data), 3) + self.assertEqual(reply.data[0]['name'], 'workflow1') + self.assertEqual(reply.data[1]['name'], 'workflow1') + self.assertEqual(reply.data[2]['name'], 'workflow1') + self.assertEqual(reply.data[0]['state'], 'initial') + self.assertEqual(reply.data[1]['state'], 'upload') + self.assertEqual(reply.data[2]['state'], 'describe') + self.assertEqual(reply.data[0]['payload'], {}) + self.assertEqual(reply.data[1]['payload'], {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}) + self.assertEqual(reply.data[2]['payload'], {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', + 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + 'b1c2d3e4f5a60718293b4c5d6e7f8090'], + 'descriptions': ['file 1 description']}) + + + + def test_get_workflow_instances_user2(self): + reply = client.get('/api/workflows/', self.f['local_user2']) + self.assertEqual(reply.status_code, status.HTTP_200_OK) + self.assertEqual(len(reply.data), 1) + self.assertEqual(reply.data[0]['name'], 'workflow2') + self.assertEqual(reply.data[0]['state'], 'initial') + self.assertEqual(reply.data[0]['payload'], {}) + diff --git a/deploy/dev/instance_a/nginx-a.dev.conf b/deploy/dev/instance_a/nginx-a.dev.conf index 039e0fb..5dfbbe5 100644 --- a/deploy/dev/instance_a/nginx-a.dev.conf +++ b/deploy/dev/instance_a/nginx-a.dev.conf @@ -66,7 +66,13 @@ http { } location /docs { - proxy_pass http://backend/docs; + proxy_set_header Host $host:$server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; + proxy_pass http://backend; } location /static { diff --git a/frontend/src/router.js b/frontend/src/router.js index 006eae6..c6b1d81 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -20,6 +20,7 @@ import Admin from '@/views/Admin.vue'; import Swatch from '@/views/Swatch.vue'; import Files from '@/views/Files.vue'; import Workflows from '@/views/Workflows.vue'; +import WorkflowDetail from '@/views/WorkflowDetail.vue'; import Account from '@/views/settings/Account.vue'; import Password from '@/views/settings/Password.vue'; @@ -47,12 +48,16 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { path: '/friends', component: Friends, meta: {requiresAuth: true} -}, {path: '/files', component: Files, meta: {requiresAuth: true}}, { - path: '/workflows', +}, {path: '/files', component: Files, meta: {requiresAuth: true} +}, {path: '/workflows', component: Workflows, - meta: {requiresAuth: true} -}, { - path: '/admin', + meta: {requiresAuth: true}, +}, {path: '/workflows/:id/:phase?', + name: 'workflow-detail', + component: WorkflowDetail, + meta: {requiresAuth: true}, + props: true +}, {path: '/admin', component: Admin, meta: {requiresAuth: true} }, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, { diff --git a/frontend/src/store.js b/frontend/src/store.js index 107a7c2..301a705 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -405,23 +405,12 @@ export default createStore({ async userIdentityRecord({state}, {password}) { return await serializeIdentityRecord(state.user, state.keypair, password); }, - // Workflow actions - async fetchWorkflows({state, commit, dispatch, getters}) { - if (state.last_load.workflows > Date.now() - 1000 * 60 * 5) { // Cache for 5 minutes - return state.workflows - } - const servers = await dispatch('getHomeServers') - const data = await servers.get(getters.signAuth, '/api/workflows/') - commit('setWorkflows', data) - state.last_load.workflows = Date.now() - return data - }, async fetchActiveWorkflows({state, commit, dispatch, getters}) { if (state.last_load.active_workflows > Date.now() - 1000 * 60 * 5) { // Cache for 5 minutes return state.active_workflows } const servers = await dispatch('getHomeServers') - const data = await servers.get(getters.signAuth, '/api/workflows/active/') + const data = await servers.get(getters.signAuth, '/api/workflows/') commit('setActiveWorkflows', data) state.last_load.active_workflows = Date.now() return data @@ -447,24 +436,6 @@ export default createStore({ state.last_load.active_workflows = 0 // Invalidate cache return data }, - async pauseWorkflow({state, commit, dispatch, getters}, workflowId) { - const servers = await dispatch('getHomeServers') - const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/pause/') - state.last_load.active_workflows = 0 // Invalidate cache - return data - }, - async resumeWorkflow({state, commit, dispatch, getters}, workflowId) { - const servers = await dispatch('getHomeServers') - const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/resume/') - state.last_load.active_workflows = 0 // Invalidate cache - return data - }, - async cancelWorkflow({state, commit, dispatch, getters}, workflowId) { - const servers = await dispatch('getHomeServers') - const data = await servers.post(getters.signAuth, '/api/workflows/' + workflowId + '/cancel/') - state.last_load.active_workflows = 0 // Invalidate cache - return data - }, async deleteWorkflow({state, commit, dispatch, getters}, workflowId) { const servers = await dispatch('getHomeServers') await servers.delete(getters.signAuth, '/api/workflows/' + workflowId + '/') diff --git a/frontend/src/views/WorkflowDetail.vue b/frontend/src/views/WorkflowDetail.vue new file mode 100644 index 0000000..9e60c66 --- /dev/null +++ b/frontend/src/views/WorkflowDetail.vue @@ -0,0 +1,501 @@ + + + + + + diff --git a/frontend/src/views/Workflows.vue b/frontend/src/views/Workflows.vue index 5f2c702..60767e4 100644 --- a/frontend/src/views/Workflows.vue +++ b/frontend/src/views/Workflows.vue @@ -40,7 +40,7 @@
+ :class="getProgressBarClass(workflow.state)">
{{ workflow.progress_percentage }}% @@ -52,21 +52,9 @@ :disabled="loading"> - - @@ -93,10 +81,16 @@
-
- -
-
+ +
{{ workflow.name }}
{{ workflow.category }}
@@ -134,6 +128,7 @@ import * as BIcons from "bootstrap-icons-vue"; import BaseLayout from "@/components/BaseLayout.vue"; import { mapState, mapActions } from 'vuex'; +import { workflowRegistry } from '@/workflows.js'; export default { name: 'Workflows', @@ -143,71 +138,6 @@ export default { }, data() { return { - availableWorkflows: [ - { - id: 'inventory-audit', - name: 'Inventory Audit', - category: 'Inventory Management', - description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.', - icon: 'b-icon-clipboard-check', - estimatedDuration: '2-4 hours', - steps: 8 - }, - { - id: 'storage-optimization', - name: 'Storage Optimization', - category: 'Storage Management', - description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.', - icon: 'b-icon-boxes', - estimatedDuration: '1-2 hours', - steps: 5 - }, - { - id: 'foto-first-import', - name: 'Foto First Import', - category: 'Data Management', - description: 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.', - icon: 'b-icon-camera', - estimatedDuration: '10-60 minutes', - steps: 4 - }, - { - id: 'maintenance-schedule', - name: 'Maintenance Schedule', - category: 'Tool Maintenance', - description: 'Create and execute maintenance schedules for tools and equipment.', - icon: 'b-icon-tools', - estimatedDuration: '30 minutes', - steps: 4 - }, - { - id: 'expiry-check', - name: 'Expiry Date Check', - category: 'Quality Control', - description: 'Identify and handle items approaching or past their expiry dates.', - icon: 'b-icon-calendar-x', - estimatedDuration: '45 minutes', - steps: 6 - }, - { - id: 'backup-restore', - name: 'Data Backup', - category: 'System Maintenance', - description: 'Create a comprehensive backup of your inventory and settings data.', - icon: 'b-icon-cloud-arrow-up', - estimatedDuration: '15 minutes', - steps: 3 - }, - { - id: 'import-items', - name: 'Bulk Item Import', - category: 'Data Management', - description: 'Import multiple inventory items from CSV or Excel files with validation.', - icon: 'b-icon-file-earmark-spreadsheet', - estimatedDuration: '20-60 minutes', - steps: 7 - } - ], loading: false, error: null } @@ -216,6 +146,9 @@ export default { ...mapState(['active_workflows']), activeWorkflows() { return this.active_workflows; + }, + availableWorkflows() { + return workflowRegistry.getAll(); } }, async mounted() { @@ -226,9 +159,6 @@ export default { 'fetchActiveWorkflows', 'createWorkflow', 'updateWorkflow', - 'pauseWorkflow', - 'resumeWorkflow', - 'cancelWorkflow', 'deleteWorkflow' ]), async loadActiveWorkflows() { @@ -264,7 +194,18 @@ export default { return classes[status] || 'bg-secondary'; }, formatDate(dateString) { + if (!dateString) { + return 'N/A'; + } + const date = new Date(dateString); + + // Check if the date is valid + if (isNaN(date.getTime())) { + console.warn('Invalid date string received:', dateString); + return 'Invalid Date'; + } + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', @@ -283,26 +224,22 @@ export default { this.loading = true; this.error = null; - const workflowData = { - workflow_type: workflow.id, - state: 'running', - current_step: 0, - total_steps: workflow.steps, - payload: { - workflow_config: { - name: workflow.name, - description: workflow.description, - category: workflow.category, - estimated_duration: workflow.estimatedDuration - } + // Use the workflow's toApiFormat method to get properly formatted data + const workflowData = workflow.toApiFormat(); + + const newWorkflow = await this.createWorkflow(workflowData); + console.log('Workflow started successfully:', newWorkflow); + + // Immediately navigate to the workflow detail view + // Get the first step from the workflow definition + const firstStep = workflow.getStepDefinitions()?.[0]?.step || "initial"; + this.$router.push({ + name: 'workflow-detail', + params: { + id: newWorkflow.id, + step: firstStep } - }; - - await this.createWorkflow(workflowData); - console.log('Workflow started successfully:', workflow.name); - - // Refresh active workflows to show the new one - await this.loadActiveWorkflows(); + }); } catch (error) { console.error('Error starting workflow:', error); @@ -312,57 +249,33 @@ export default { } }, async viewWorkflowDetails(workflow) { - // TODO: Implement workflow details view/modal - console.log('Viewing workflow details:', workflow); - // For now, show an alert with workflow information - const details = ` -Workflow: ${workflow.workflow_type} -Status: ${workflow.status_display || workflow.state} -Progress: ${workflow.progress_percentage}% -Step: ${workflow.current_step + 1} of ${workflow.total_steps} -Started: ${this.formatDate(workflow.started_at)} - `.trim(); - alert(details); + console.log('Viewing details for workflow:', workflow); + // Navigate to the workflow detail view + // Use the workflow's current step if available, otherwise use the first step + const currentStep = workflow.current_step || + workflow.payload?.current_step || + workflow.getStepDefinitions?.()?.[0]?.step || + "initial"; + + this.$router.push({ + name: 'workflow-detail', + params: { + id: workflow.id, + step: currentStep + } + }); }, - async pauseWorkflowInstance(workflow) { - try { - this.loading = true; - this.error = null; - await this.pauseWorkflow(workflow.id); - await this.loadActiveWorkflows(); - console.log('Workflow paused successfully:', workflow.workflow_type); - } catch (error) { - console.error('Error pausing workflow:', error); - this.error = `Failed to pause ${workflow.workflow_type}`; - } finally { - this.loading = false; - } - }, - async resumeWorkflowInstance(workflow) { - try { - this.loading = true; - this.error = null; - await this.resumeWorkflow(workflow.id); - await this.loadActiveWorkflows(); - console.log('Workflow resumed successfully:', workflow.workflow_type); - } catch (error) { - console.error('Error resuming workflow:', error); - this.error = `Failed to resume ${workflow.workflow_type}`; - } finally { - this.loading = false; - } - }, - async cancelWorkflowInstance(workflow) { - if (confirm(`Are you sure you want to cancel "${workflow.workflow_type}"?`)) { + async abortWorkflowInstance(workflow) { + if (confirm(`Are you sure you want to abort "${workflow.workflow_type}"?`)) { try { this.loading = true; this.error = null; - await this.cancelWorkflow(workflow.id); + await this.deleteWorkflow(workflow.id); await this.loadActiveWorkflows(); - console.log('Workflow cancelled successfully:', workflow.workflow_type); + console.log('Workflow aborted successfully:', workflow.workflow_type); } catch (error) { - console.error('Error cancelling workflow:', error); - this.error = `Failed to cancel ${workflow.workflow_type}`; + console.error('Error aborting workflow:', error); + this.error = `Failed to abort ${workflow.workflow_type}`; } finally { this.loading = false; } @@ -378,11 +291,6 @@ Started: ${this.formatDate(workflow.started_at)} border: 1px solid #e9ecef; } -.workflow-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -} - .workflow-icon { display: flex; align-items: center; @@ -394,6 +302,19 @@ Started: ${this.formatDate(workflow.started_at)} flex-shrink: 0; } +.workflow-header { + margin-left: 1rem; +} + +.workflow-arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: 0.25rem; + margin-right: 0.25rem; +} + .progress { border-radius: 4px; background-color: #e9ecef; diff --git a/frontend/src/workflows.js b/frontend/src/workflows.js new file mode 100644 index 0000000..e5ae6fb --- /dev/null +++ b/frontend/src/workflows.js @@ -0,0 +1,421 @@ +/** + * Common Workflow Interface + * + * All workflow classes should implement this interface: + * + * interface IWorkflow { + * id: string; // Unique identifier for the workflow type + * name: string; // Display name for the workflow + * category: string; // Category grouping (e.g., 'Data Management', 'Inventory Management') + * description: string; // Detailed description of what the workflow does + * icons: Array; // Array of Bootstrap icon component names + * estimatedDuration: string; // Human-readable duration estimate + * steps: number; // Total number of steps in the workflow + * + * // Methods + * validate(): boolean; // Validate if workflow can be started + * getStepDefinitions(): Array; // Get array of step definitions + * getInitialPayload(): Object; // Get initial payload structure + * } + */ + +/** + * Base workflow class that all workflows extend + */ +class BaseWorkflow { + constructor(id, name, category, description, icons, estimatedDuration, steps) { + this.id = id; + this.name = name; + this.category = category; + this.description = description; + // Ensure icon is always an array - convert single string to array if needed + this.icons = Array.isArray(icons) ? icons : [icons]; + this.estimatedDuration = estimatedDuration; + this.steps = steps; + } + + /** + * Validate if the workflow can be started + * Override in subclasses for specific validation logic + */ + validate() { + return true; + } + + /** + * Get the initial payload structure for this workflow + * Override in subclasses to provide workflow-specific payload + */ + getInitialPayload() { + return { + workflow_config: { + name: this.name, + description: this.description, + category: this.category, + estimated_duration: this.estimatedDuration + } + }; + } + + /** + * Get step definitions for this workflow + * Override in subclasses to provide workflow-specific steps + */ + getStepDefinitions() { + return []; + } + + /** + * Convert workflow to API-compatible format + */ + toApiFormat() { + return { + name: this.name, + workflow_type: this.id, + state: 'running', + current_step: 1, + total_steps: this.steps, + payload: this.getInitialPayload() + }; + } +} + +/** + * Foto First Import Workflow + * Captures photos via mobile camera or upload, then sequentially enters details for each item + */ +class FotoFirstImportWorkflow extends BaseWorkflow { + constructor() { + super( + 'foto-first-bulk-import', + 'Foto First Bulk Import', + 'Data Management', + 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.', + ['b-icon-camera', 'b-icon-pencil-square'], + '10-60 minutes', + 4 + ); + } + + getStepDefinitions() { + return [ + { 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' } + ]; + } + + getInitialPayload() { + return { + ...super.getInitialPayload(), + photos: [], + processing_options: { + auto_rotate: true, + compress: true, + max_width: 1920, + max_height: 1080 + } + }; + } +} + +/** + * Bulk Item Import Workflow + * Imports multiple inventory items from CSV or Excel files with validation + */ +class BulkItemImportWorkflow extends BaseWorkflow { + constructor() { + super( + 'import-items', + 'Bulk Item Import', + 'Data Management', + 'Import multiple inventory items from CSV or Excel files with validation.', + ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'], + '20-60 minutes', + 7 + ); + } + + getStepDefinitions() { + return [ + { step: 1, name: 'File Upload', description: 'Upload CSV or Excel file' }, + { step: 2, name: 'Parse Data', description: 'Parse and analyze file contents' }, + { step: 3, name: 'Validate Format', description: 'Validate data format and structure' }, + { step: 4, name: 'Data Validation', description: 'Validate individual item data' }, + { step: 5, name: 'Conflict Resolution', description: 'Resolve any data conflicts' }, + { step: 6, name: 'Import Items', description: 'Import validated items into system' }, + { step: 7, name: 'Generate Report', description: 'Generate import summary report' } + ]; + } + + getInitialPayload() { + return { + ...super.getInitialPayload(), + import_options: { + file_type: null, + skip_duplicates: true, + update_existing: false, + validate_required_fields: true + }, + mapping: {}, + validation_results: [] + }; + } + + validate() { + // Could add validation for file format, required permissions, etc. + return true; + } +} + +/** + * Inventory Audit Workflow + * Performs a complete audit of inventory items, checking quantities, locations, and conditions + */ +class InventoryAuditWorkflow extends BaseWorkflow { + constructor() { + super( + 'inventory-audit', + 'Inventory Audit', + 'Inventory Management', + 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.', + ['b-icon-list-ul'], + '2-4 hours', + 8 + ); + } + + getStepDefinitions() { + return [ + { step: 1, name: 'Initialize Audit', description: 'Set up audit parameters and scope' }, + { step: 2, name: 'Generate Item List', description: 'Create list of items to audit' }, + { step: 3, name: 'Location Verification', description: 'Verify item locations' }, + { step: 4, name: 'Quantity Count', description: 'Count physical quantities' }, + { step: 5, name: 'Condition Assessment', description: 'Assess item conditions' }, + { step: 6, name: 'Discrepancy Detection', description: 'Identify discrepancies' }, + { step: 7, name: 'Report Generation', description: 'Generate audit report' }, + { step: 8, name: 'Finalize Audit', description: 'Complete and archive audit' } + ]; + } + + validate() { + // Add specific validation logic for inventory audit + return true; + } +} + +/** + * Storage Optimization Workflow + * Analyzes and reorganizes storage locations for maximum efficiency and accessibility + */ +class StorageOptimizationWorkflow extends BaseWorkflow { + constructor() { + super( + 'storage-optimization', + 'Storage Optimization', + 'Storage Management', + 'Analyze and reorganize storage locations for maximum efficiency and accessibility.', + ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'], + '1-2 hours', + 5 + ); + } + + getStepDefinitions() { + return [ + { step: 1, name: 'Analyze Current Layout', description: 'Assess current storage efficiency' }, + { step: 2, name: 'Identify Optimization Opportunities', description: 'Find areas for improvement' }, + { step: 3, name: 'Plan Reorganization', description: 'Create optimization plan' }, + { step: 4, name: 'Execute Changes', description: 'Implement storage changes' }, + { step: 5, name: 'Validate Results', description: 'Verify optimization results' } + ]; + } +} + +/** + * Maintenance Schedule Workflow + * Creates and executes maintenance schedules for tools and equipment + */ +class MaintenanceScheduleWorkflow extends BaseWorkflow { + constructor() { + super( + 'maintenance-schedule', + 'Maintenance Schedule', + 'Tool Maintenance', + 'Create and execute maintenance schedules for tools and equipment.', + ['b-icon-tools', 'b-icon-calendar'], + '30 minutes', + 4 + ); + } + + getStepDefinitions() { + return [ + { step: 1, name: 'Identify Equipment', description: 'Select tools and equipment for maintenance' }, + { step: 2, name: 'Create Schedule', description: 'Define maintenance intervals and tasks' }, + { step: 3, name: 'Assign Responsibilities', description: 'Assign maintenance tasks to users' }, + { step: 4, name: 'Activate Schedule', description: 'Enable automatic maintenance reminders' } + ]; + } +} + +/** + * Expiry Date Check Workflow + * Identifies and handles items approaching or past their expiry dates + */ +class ExpiryCheckWorkflow extends BaseWorkflow { + constructor() { + super( + 'expiry-check', + 'Expiry Date Check', + 'Quality Control', + 'Identify and handle items approaching or past their expiry dates.', + ['b-icon-clock-history', 'b-icon-exclamation-triangle'], + '45 minutes', + 6 + ); + } + + getStepDefinitions() { + return [ + { step: 1, name: 'Scan Expiry Dates', description: 'Check all items for expiry information' }, + { step: 2, name: 'Identify Critical Items', description: 'Find expired and soon-to-expire items' }, + { step: 3, name: 'Assess Item Condition', description: 'Evaluate condition of critical items' }, + { step: 4, name: 'Generate Action Plan', description: 'Create disposal or usage recommendations' }, + { step: 5, name: 'Execute Actions', description: 'Implement recommended actions' }, + { step: 6, name: 'Update Records', description: 'Update item statuses and records' } + ]; + } + + getInitialPayload() { + return { + ...super.getInitialPayload(), + check_parameters: { + warning_days: 30, + include_no_expiry: false, + categories: [] + } + }; + } +} + +/** + * Data Backup Workflow + * Creates a comprehensive backup of inventory and settings data + */ +class DataBackupWorkflow extends BaseWorkflow { + constructor() { + super( + 'backup-restore', + 'Data Backup', + 'System Maintenance', + 'Create a comprehensive backup of your inventory and settings data.', + ['b-icon-gear', 'b-icon-download'], + '15 minutes', + 3 + ); + } + + getStepDefinitions() { + return [ + { step: 1, name: 'Prepare Backup', description: 'Initialize backup process and verify system' }, + { step: 2, name: 'Export Data', description: 'Export inventory, settings, and user data' }, + { step: 3, name: 'Finalize Backup', description: 'Compress and store backup file' } + ]; + } + + getInitialPayload() { + return { + ...super.getInitialPayload(), + backup_options: { + include_inventory: true, + include_settings: true, + include_user_data: true, + include_files: false, + compression: true + } + }; + } +} + +/** + * Workflow Registry + * Central registry for all available workflow types + */ +class WorkflowRegistry { + constructor() { + this.workflows = new Map(); + this.registerDefaultWorkflows(); + } + + /** + * Register all default workflow types + */ + registerDefaultWorkflows() { + this.register(new FotoFirstImportWorkflow()); + this.register(new BulkItemImportWorkflow()); + this.register(new InventoryAuditWorkflow()); + this.register(new StorageOptimizationWorkflow()); + this.register(new MaintenanceScheduleWorkflow()); + this.register(new ExpiryCheckWorkflow()); + this.register(new DataBackupWorkflow()); + } + + /** + * Register a workflow type + */ + register(workflow) { + if (!(workflow instanceof BaseWorkflow)) { + throw new Error('Workflow must extend BaseWorkflow'); + } + this.workflows.set(workflow.id, workflow); + } + + /** + * Get a workflow by ID + */ + get(id) { + return this.workflows.get(id); + } + + /** + * Get all registered workflows + */ + getAll() { + return Array.from(this.workflows.values()); + } + + /** + * Get workflows by category + */ + getByCategory(category) { + return this.getAll().filter(workflow => workflow.category === category); + } + + /** + * Get all unique categories + */ + getCategories() { + return [...new Set(this.getAll().map(workflow => workflow.category))]; + } +} + +// Create and export the default registry instance +export const workflowRegistry = new WorkflowRegistry(); + +// Export individual classes for direct use +export { + BaseWorkflow, + InventoryAuditWorkflow, + StorageOptimizationWorkflow, + FotoFirstImportWorkflow, + MaintenanceScheduleWorkflow, + ExpiryCheckWorkflow, + DataBackupWorkflow, + BulkItemImportWorkflow, + WorkflowRegistry +}; + +// Export default registry +export default workflowRegistry;