This commit is contained in:
j3d1 2025-09-26 20:00:29 +02:00
parent 4627f0aca2
commit 7c91661be2
14 changed files with 1208 additions and 220 deletions

View file

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

View file

@ -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)

View file

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

View file

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

View file

@ -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})"

View file

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

View file

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

View file

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

View file

@ -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 {

View file

@ -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}}, {

View file

@ -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 + '/')

View file

@ -0,0 +1,501 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<!-- Workflow Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
</li>
<li class="breadcrumb-item active" aria-current="page">{{ workflowInstance?.workflow_type || 'Loading...' }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">{{ workflowInstance?.workflow_type || 'Workflow Detail' }}</h1>
</div>
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary" @click="$router.go(-1)">
<b-icon-arrow-left class="me-1"></b-icon-arrow-left>
Back
</button>
<button v-if="canAbort" class="btn btn-outline-danger" @click="abortWorkflow" :disabled="loading">
<b-icon-x-circle class="me-1"></b-icon-x-circle>
Abort
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading && !workflowInstance" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading workflow details...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-danger">
<b-icon-exclamation-triangle class="me-2"></b-icon-exclamation-triangle>
{{ error }}
</div>
<!-- Workflow Content -->
<div v-else-if="workflowInstance" class="row">
<!-- Workflow Progress Sidebar -->
<div class="col-lg-3 mb-4">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">Progress Overview</h6>
</div>
<div class="card-body">
<!-- Overall Progress -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Overall Progress</small>
<small class="fw-bold">{{ workflowInstance.progress_percentage }}%</small>
</div>
<div class="progress mb-2" style="height: 8px;">
<div class="progress-bar"
:style="{ width: workflowInstance.progress_percentage + '%' }"
:class="getProgressBarClass(workflowInstance.state)">
</div>
</div>
<span :class="getStatusBadgeClass(workflowInstance.state)">
{{ workflowInstance.status_display || workflowInstance.state }}
</span>
</div>
<!-- Step List -->
<div class="workflow-steps">
<h6 class="mb-3">Steps</h6>
<div v-for="step in stepDefinitions" :key="step.step" class="step-item mb-3">
<div class="d-flex align-items-center">
<div class="step-indicator me-2"
:class="getStepClass(step.step)">
<span v-if="isStepCompleted(step.step)">
<b-icon-check class="text-white"></b-icon-check>
</span>
<span v-else-if="isStepCurrent(step.step)">
{{ step.step }}
</span>
<span v-else class="text-muted">
{{ step.step }}
</span>
</div>
<div class="flex-grow-1">
<div class="fw-bold small"
:class="{ 'text-primary': isStepCurrent(step.step) }">
{{ step.name }}
</div>
<div class="text-muted small">{{ step.description }}</div>
</div>
</div>
</div>
</div>
<!-- Workflow Info -->
<div class="border-top pt-3 mt-3">
<div class="mb-2">
<small class="text-muted">Started:</small>
<div class="fw-bold">{{ formatDateTime(workflowInstance.started_at) }}</div>
</div>
<div v-if="workflowInstance.estimated_completion" class="mb-2">
<small class="text-muted">Estimated completion:</small>
<div class="fw-bold">{{ formatDateTime(workflowInstance.estimated_completion) }}</div>
</div>
<div v-if="workflowDefinition">
<small class="text-muted">Category:</small>
<div class="fw-bold">{{ workflowDefinition.category }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="col-lg-9">
<!-- Current Step Content -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">
{{ currentStepDefinition?.name || `Step ${currentStep}` }}
</h5>
<small class="text-muted">{{ currentStepDefinition?.description }}</small>
</div>
<div class="step-navigation">
<button class="btn btn-sm btn-outline-secondary me-1"
@click="handlePrevStep"
:disabled="!canNavigatePrev">
<b-icon-chevron-left></b-icon-chevron-left>
</button>
<span class="mx-2 small">{{ currentStepIndex + 1 }} / {{ totalSteps }}</span>
<button class="btn btn-sm btn-outline-secondary"
@click="handleNextStep"
:disabled="!canNavigateNext">
<b-icon-chevron-right></b-icon-chevron-right>
</button>
</div>
</div>
<div class="card-body">
<!-- Step-specific content based on workflow type and current step -->
<component
v-if="stepComponent"
:is="stepComponent"
:workflow-instance="workflowInstance"
:step="currentStep"
:payload="workflowInstance.payload"
@update="handleStepUpdate"
@next="handleNextStep"
@prev="handlePrevStep"
/>
<!-- Default step content if no specific component -->
<div v-else class="text-center py-5">
<b-icon-gear class="text-muted mb-3" style="font-size: 3rem;"></b-icon-gear>
<h5 class="text-muted">{{ currentStepDefinition?.name || 'Step Content' }}</h5>
<p class="text-muted">{{ currentStepDefinition?.description || 'This step is in progress.' }}</p>
<!-- Step Navigation Buttons -->
<div class="mt-4">
<button v-if="canNavigatePrev"
class="btn btn-outline-secondary me-2"
@click="handlePrevStep">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<button v-if="canNavigateNext"
class="btn btn-primary"
@click="handleNextStep">
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button v-else-if="currentStepIndex === stepDefinitions.length - 1"
class="btn btn-success"
@click="completeWorkflow">
<b-icon-check-circle class="me-1"></b-icon-check-circle>
Complete Workflow
</button>
</div>
</div>
<!-- Debug Info (only in development) -->
<div v-if="$isDevelopment" class="mt-4 border-top pt-3">
<details>
<summary class="text-muted small">Debug Info</summary>
<pre class="small mt-2">{{ JSON.stringify(workflowInstance, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
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: 'WorkflowDetail',
components: {
...BIcons,
BaseLayout
},
props: {
id: {
type: [String, Number],
required: true
},
step: {
type: String,
default: "initial"
}
},
data() {
return {
loading: false,
error: null,
workflowInstance: null,
refreshInterval: null
}
},
computed: {
...mapState(['active_workflows']),
currentStep() {
return this.step || "initial";
},
workflowDefinition() {
if (!this.workflowInstance?.workflow_type) return null;
return workflowRegistry.get(this.workflowInstance.workflow_type);
},
stepDefinitions() {
return this.workflowDefinition?.getStepDefinitions() || [];
},
totalSteps() {
return this.stepDefinitions.length || this.workflowInstance?.total_steps || 1;
},
currentStepDefinition() {
return this.stepDefinitions.find(step => step.step === this.currentStep);
},
canAbort() {
return this.workflowInstance?.state === 'running';
},
stepComponent() {
// Return step-specific component if it exists
// This allows for custom UI per workflow type and step
const workflowType = this.workflowInstance?.workflow_type;
if (workflowType) {
// Try to load a step-specific component
// e.g., FotoFirstImportStep1, BulkImportStep2, etc.
const componentName = `${workflowType}Step${this.currentStep}`;
// This would require registering step components
return null; // For now, use default content
}
return null;
},
currentStepIndex() {
// Find the index of the current step in the stepDefinitions array
return this.stepDefinitions.findIndex(step => step.step === this.currentStep);
},
canNavigateNext() {
// Check if we can navigate to the next step
return this.currentStepIndex >= 0 && this.currentStepIndex < this.stepDefinitions.length - 1;
},
canNavigatePrev() {
// Check if we can navigate to the previous step
return this.currentStepIndex > 0;
}
},
watch: {
step(newStep) {
// Update route when step changes - no need for parseInt since both are strings
if (newStep !== this.currentStep) {
this.$router.push({
name: 'workflow-detail',
params: { id: this.id, step: newStep }
});
}
}
},
async mounted() {
await this.loadWorkflowInstance();
},
methods: {
...mapActions([
'fetchActiveWorkflows',
'updateWorkflow',
'deleteWorkflow'
]),
async loadWorkflowInstance() {
try {
this.loading = true;
this.error = null;
await this.fetchActiveWorkflows();
// Try to get from store first
this.workflowInstance = this.active_workflows?.find(w => w.id == this.id);
if (!this.workflowInstance) {
throw new Error('Workflow not found');
}
} catch (error) {
console.error('Error loading workflow:', error);
this.error = error.message || 'Failed to load workflow details';
} finally {
this.loading = false;
}
},
navigateToStep(step) {
const stepStr = String(step);
// For navigation, we need to validate the step exists in step definitions
const stepExists = this.stepDefinitions.some(step => step.step === stepStr);
if (stepExists || (step >= 1 && step <= this.totalSteps)) {
this.$router.push({
name: 'workflow-detail',
params: { id: this.id, step: stepStr }
});
}
},
handleNextStep() {
// Find the next step in stepDefinitions
const currentIndex = this.stepDefinitions.findIndex(step => step.step === this.currentStep);
if (currentIndex >= 0 && currentIndex < this.stepDefinitions.length - 1) {
const nextStep = this.stepDefinitions[currentIndex + 1].step;
this.navigateToStep(nextStep);
}
},
handlePrevStep() {
// Find the previous step in stepDefinitions
const currentIndex = this.stepDefinitions.findIndex(step => step.step === this.currentStep);
if (currentIndex > 0) {
const prevStep = this.stepDefinitions[currentIndex - 1].step;
this.navigateToStep(prevStep);
}
},
async handleStepUpdate(payload) {
try {
// Update the workflow instance with new payload data
const updatedWorkflow = {
...this.workflowInstance,
payload: {
...this.workflowInstance.payload,
...payload
}
};
await this.updateWorkflow(updatedWorkflow);
this.workflowInstance = updatedWorkflow;
} catch (error) {
console.error('Error updating workflow step:', error);
this.error = 'Failed to update workflow step';
}
},
async completeWorkflow() {
try {
this.loading = true;
const updatedWorkflow = {
...this.workflowInstance,
state: 'completed'
};
await this.updateWorkflow(updatedWorkflow);
this.$router.push('/workflows');
} catch (error) {
console.error('Error completing workflow:', error);
this.error = 'Failed to complete workflow';
} finally {
this.loading = false;
}
},
async abortWorkflow() {
try {
this.loading = true;
await this.deleteWorkflow(this.workflowInstance.id);
this.$router.push('/workflows');
} catch (error) {
console.error('Error aborting workflow:', error);
this.error = 'Failed to abort workflow';
} finally {
this.loading = false;
}
},
isStepCompleted(stepNumber) {
// Check if a step is completed based on current step
const currentStepNum = parseInt(this.currentStep) || 1;
return parseInt(stepNumber) < currentStepNum;
},
isStepCurrent(stepNumber) {
// Check if a step is the current active step
return String(stepNumber) === String(this.currentStep);
},
getStepClass(stepNumber) {
if (this.isStepCompleted(stepNumber)) {
return 'step-indicator-completed bg-success';
} else if (this.isStepCurrent(stepNumber)) {
return 'step-indicator-current bg-primary text-white';
} else {
return 'step-indicator-pending bg-light border';
}
},
getProgressBarClass(state) {
const classMap = {
'running': 'bg-primary',
'completed': 'bg-success',
'failed': 'bg-danger',
'aborted': 'bg-warning'
};
return classMap[state] || 'bg-secondary';
},
getStatusBadgeClass(state) {
const classMap = {
'running': 'badge bg-primary',
'completed': 'badge bg-success',
'failed': 'badge bg-danger',
'aborted': 'badge bg-warning text-dark'
};
return classMap[state] || 'badge bg-secondary';
},
formatDateTime(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
}
}
</script>
<style scoped>
.workflow-steps {
counter-reset: step-counter;
}
.step-item {
position: relative;
padding-left: 2rem;
}
.step-indicator {
position: absolute;
left: 0;
top: 0.5rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #fff;
}
.step-indicator-completed {
background-color: #28a745;
}
.step-indicator-current {
background-color: #007bff;
}
.step-indicator-pending {
background-color: #6c757d;
}
.step-navigation {
min-width: 120px;
}
</style>

View file

@ -40,7 +40,7 @@
<div class="progress" style="height: 8px;">
<div class="progress-bar"
:style="{ width: workflow.progress_percentage + '%' }"
:class="getProgressBarClass(workflow.status)">
:class="getProgressBarClass(workflow.state)">
</div>
</div>
<small class="text-muted">{{ workflow.progress_percentage }}%</small>
@ -52,21 +52,9 @@
:disabled="loading">
<b-icon-eye></b-icon-eye>
</button>
<button v-if="workflow.status === 'running'"
class="btn btn-sm btn-outline-warning me-1"
@click="pauseWorkflowInstance(workflow)"
:disabled="loading">
<b-icon-pause></b-icon-pause>
</button>
<button v-if="workflow.status === 'paused'"
class="btn btn-sm btn-outline-success me-1"
@click="resumeWorkflowInstance(workflow)"
:disabled="loading">
<b-icon-play></b-icon-play>
</button>
<button class="btn btn-sm btn-outline-danger"
@click="cancelWorkflowInstance(workflow)"
:disabled="workflow.status === 'completed' || workflow.status === 'cancelled' || loading">
@click="abortWorkflowInstance(workflow)"
:disabled="loading">
<b-icon-x-circle></b-icon-x-circle>
</button>
</td>
@ -93,10 +81,16 @@
<div class="card h-100 workflow-card">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center mb-3">
<template v-for="(icon, index) in workflow.icons" :key="icon">
<div class="workflow-icon me-3">
<component :is="workflow.icon" class="text-primary" style="font-size: 1.5rem;"></component>
<component :is="icon" class="text-primary" style="font-size: 1.5rem;"></component>
</div>
<div>
<!-- Add arrow between icons, but not after the last one -->
<div v-if="index < workflow.icons.length - 1" class="workflow-arrow me-3">
<b-icon-arrow-right class="text-muted" style="font-size: 1rem;"></b-icon-arrow-right>
</div>
</template>
<div class="workflow-header">
<h6 class="card-title mb-1">{{ workflow.name }}</h6>
<small class="text-muted">{{ workflow.category }}</small>
</div>
@ -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();
await this.createWorkflow(workflowData);
console.log('Workflow started successfully:', workflow.name);
const newWorkflow = await this.createWorkflow(workflowData);
console.log('Workflow started successfully:', newWorkflow);
// Refresh active workflows to show the new one
await this.loadActiveWorkflows();
// 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
}
});
} 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);
},
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;
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 resumeWorkflowInstance(workflow) {
async abortWorkflowInstance(workflow) {
if (confirm(`Are you sure you want to abort "${workflow.workflow_type}"?`)) {
try {
this.loading = true;
this.error = null;
await this.resumeWorkflow(workflow.id);
await this.deleteWorkflow(workflow.id);
await this.loadActiveWorkflows();
console.log('Workflow resumed successfully:', workflow.workflow_type);
console.log('Workflow aborted 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}"?`)) {
try {
this.loading = true;
this.error = null;
await this.cancelWorkflow(workflow.id);
await this.loadActiveWorkflows();
console.log('Workflow cancelled 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;

421
frontend/src/workflows.js Normal file
View file

@ -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<string>; // 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;