diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py
index a2a8d7d..ea038a7 100644
--- a/backend/toolshed/models.py
+++ b/backend/toolshed/models.py
@@ -125,12 +125,12 @@ class StorageLocation(models.Model):
class WorkflowInstance(models.Model):
name = models.CharField(max_length=255)
state = models.CharField(max_length=255)
- payload = models.JSONField(default=dict, blank=True)
+ payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
- return f"{self.name} ({self.status})"
+ return f"{self.name} ({self.state})"
diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py
index 7e0cb49..79b573d 100644
--- a/backend/toolshed/tests/fixtures.py
+++ b/backend/toolshed/tests/fixtures.py
@@ -1,4 +1,5 @@
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
+import json
class CategoryTestMixin:
@@ -58,28 +59,29 @@ class LocationTestMixin:
class WorkflowTestMixin:
def prepare_workflows(self):
+ # `payload` is an opaque, frontend-serialized JSON string on the backend.
self.f['workflow1'] = WorkflowInstance.objects.create(
name='workflow1',
state='initial',
- payload={},
+ payload=json.dumps({}),
owner=self.f['local_user1']
)
self.f['workflow2'] = WorkflowInstance.objects.create(
name='workflow1',
state='upload',
- payload={'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']},
+ payload=json.dumps({'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']},
+ payload=json.dumps({'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={},
+ payload=json.dumps({}),
owner=self.f['local_user2']
)
diff --git a/backend/toolshed/tests/test_workflow_api.py b/backend/toolshed/tests/test_workflow_api.py
index 6921aa7..98489df 100644
--- a/backend/toolshed/tests/test_workflow_api.py
+++ b/backend/toolshed/tests/test_workflow_api.py
@@ -29,9 +29,9 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
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',
+ self.assertEqual(json.loads(reply.data[0]['payload']), {})
+ self.assertEqual(json.loads(reply.data[1]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
+ self.assertEqual(json.loads(reply.data[2]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']})
@@ -44,5 +44,5 @@ class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTest
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'], {})
+ self.assertEqual(json.loads(reply.data[0]['payload']), {})
diff --git a/frontend/src/components/workflow/ComponentRegistry.js b/frontend/src/components/workflow/ComponentRegistry.js
index f938458..48a83ca 100644
--- a/frontend/src/components/workflow/ComponentRegistry.js
+++ b/frontend/src/components/workflow/ComponentRegistry.js
@@ -11,6 +11,7 @@ import FotoFirstStep2 from './steps/FotoFirstStep2.vue';
import FotoFirstStep3 from './steps/FotoFirstStep3.vue';
import FotoFirstStep4 from './steps/FotoFirstStep4.vue';
import BulkImportStep1 from './steps/BulkImportStep1.vue';
+import BulkImportStep6 from './steps/BulkImportStep6.vue';
/**
* Component registry mapping workflow types and steps to components
@@ -25,6 +26,7 @@ const componentRegistry = {
// Bulk Item Import Workflow components
'import-items-1': BulkImportStep1,
+ 'import-items-6': BulkImportStep6,
// Additional steps can be added as needed
// 'import-items-2': BulkImportStep2,
// 'import-items-3': BulkImportStep3,
diff --git a/frontend/src/components/workflow/README.md b/frontend/src/components/workflow/README.md
index b91a72d..50969e1 100644
--- a/frontend/src/components/workflow/README.md
+++ b/frontend/src/components/workflow/README.md
@@ -36,7 +36,8 @@ The `stepComponent` computed property now uses the registry:
```javascript
stepComponent() {
- const workflowType = this.workflowInstance?.workflow_type;
+ // `name` on the WorkflowInstance doubles as the workflow type identifier
+ const workflowType = this.workflowInstance?.name;
if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep);
}
diff --git a/frontend/src/components/workflow/steps/BulkImportStep1.vue b/frontend/src/components/workflow/steps/BulkImportStep1.vue
index 75a9722..54ede66 100644
--- a/frontend/src/components/workflow/steps/BulkImportStep1.vue
+++ b/frontend/src/components/workflow/steps/BulkImportStep1.vue
@@ -218,6 +218,7 @@ export default {
isDragOver: false,
fileAnalysis: null,
detectedColumns: [],
+ parsedRows: [],
previewData: [],
columnMapping: {},
requiredFields: [
@@ -239,11 +240,11 @@ export default {
},
mounted() {
// Load existing data if resuming
- if (this.payload.uploaded_file) {
- this.uploadedFile = this.payload.uploaded_file;
+ if (this.payload.file_name) {
this.uploadTime = this.payload.upload_time;
this.fileAnalysis = this.payload.file_analysis;
this.detectedColumns = this.payload.detected_columns || [];
+ this.parsedRows = this.payload.parsed_rows || [];
this.previewData = this.payload.preview_data || [];
this.columnMapping = this.payload.column_mapping || {};
}
@@ -296,55 +297,66 @@ export default {
},
async analyzeFile(file) {
- // This is a simplified version - in reality, you'd use a library like Papa Parse for CSV
- // or SheetJS for Excel files
-
+ // Parsing happens entirely client-side. The backend never sees the
+ // raw file - only the structured `items` list produced here, sent
+ // later as generic payload/request body.
if (file.name.endsWith('.csv')) {
const text = await file.text();
- const lines = text.split('\n').filter(line => line.trim());
-
- if (lines.length > 0) {
- // Parse CSV header
- const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
- this.detectedColumns = headers;
-
- // Parse preview data
- this.previewData = lines.slice(1, 6).map(line => {
- const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
- const row = {};
- headers.forEach((header, index) => {
- row[header] = values[index] || '';
- });
- return row;
- });
-
- this.fileAnalysis = {
- totalRows: lines.length - 1, // Exclude header
- totalColumns: headers.length,
- validRows: lines.length - 1, // Simplified - assume all valid for demo
- errorRows: 0
- };
- }
+ this.parsedRows = this.parseCsv(text);
} else {
- // For Excel files, you'd use a library like SheetJS
- // This is a mock implementation
+ // For Excel files, you'd use a library like SheetJS to parse
+ // client-side. This is a mock implementation.
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
- this.previewData = [
+ this.parsedRows = [
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
];
- this.fileAnalysis = {
- totalRows: 100,
- totalColumns: 5,
- validRows: 98,
- errorRows: 2
- };
}
+ this.previewData = this.parsedRows.slice(0, 5);
+ this.fileAnalysis = {
+ totalRows: this.parsedRows.length,
+ totalColumns: this.detectedColumns.length,
+ validRows: this.parsedRows.length,
+ errorRows: 0
+ };
+
// Auto-map columns based on common names
this.autoMapColumns();
},
+ // Simple CSV parser: no external dependency, entirely client-side.
+ // Returns an array of row objects keyed by the detected header columns.
+ parseCsv(text) {
+ const lines = text.split('\n').filter(line => line.trim());
+ if (lines.length === 0) {
+ this.detectedColumns = [];
+ return [];
+ }
+ const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
+ this.detectedColumns = headers;
+ return lines.slice(1).map(line => {
+ const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
+ const row = {};
+ headers.forEach((header, index) => {
+ row[header] = values[index] || '';
+ });
+ return row;
+ });
+ },
+
+ // Convert the fully parsed rows + column mapping into the generic item
+ // dicts consumed by the "Import Items" step, which creates each one via
+ // the standard generic InventoryItem create endpoint.
+ buildItemsFromMapping() {
+ return this.parsedRows.map(row => ({
+ name: this.columnMapping.name ? row[this.columnMapping.name] : '',
+ description: this.columnMapping.description ? row[this.columnMapping.description] : '',
+ category: this.columnMapping.category ? row[this.columnMapping.category] : '',
+ quantity: this.columnMapping.quantity ? row[this.columnMapping.quantity] : undefined,
+ })).filter(item => item.name);
+ },
+
autoMapColumns() {
const mapping = {};
@@ -401,6 +413,7 @@ export default {
this.uploadTime = null;
this.fileAnalysis = null;
this.detectedColumns = [];
+ this.parsedRows = [];
this.previewData = [];
this.columnMapping = {};
this.updatePayload();
@@ -429,12 +442,14 @@ export default {
updatePayload() {
this.$emit('update', {
- uploaded_file: this.uploadedFile,
+ file_name: this.uploadedFile?.name || null,
upload_time: this.uploadTime,
file_analysis: this.fileAnalysis,
detected_columns: this.detectedColumns,
+ parsed_rows: this.parsedRows,
preview_data: this.previewData,
- column_mapping: this.columnMapping
+ column_mapping: this.columnMapping,
+ items: this.buildItemsFromMapping()
});
},
diff --git a/frontend/src/components/workflow/steps/BulkImportStep6.vue b/frontend/src/components/workflow/steps/BulkImportStep6.vue
new file mode 100644
index 0000000..ff67fa6
--- /dev/null
+++ b/frontend/src/components/workflow/steps/BulkImportStep6.vue
@@ -0,0 +1,124 @@
+
+
+ Create inventory items from the rows parsed in the File Upload step.
+ The file itself was never sent to the server - only these structured
+ item rows are.
+
+ {{ items.length }} item(s) ready to import.
+
+ {{ results.created_count }} created
+
+ {{ results.errors.length }} issue(s)
+
+ Import Items
+ Import complete
+
+
+