Compare commits
5 commits
6fcdd1eefa
...
622e63ea8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 622e63ea8f | |||
| 0f51f6e33f | |||
| 3b494dfa37 | |||
| 82a27ce2a8 | |||
| c0f70004eb |
45 changed files with 3222 additions and 519 deletions
|
|
@ -1,13 +0,0 @@
|
||||||
FROM python:alpine
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache gcc musl-dev python3-dev
|
|
||||||
COPY requirements.txt /app
|
|
||||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
|
||||||
COPY . /app
|
|
||||||
RUN python configure.py
|
|
||||||
RUN python manage.py collectstatic --noinput
|
|
||||||
CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure
|
|
||||||
# TODO serve static files with nginx and remove --insecure
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,7 +130,9 @@ def getUserInfo(request):
|
||||||
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
|
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0:
|
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \
|
||||||
|
and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0:
|
||||||
|
old_file.file.delete(save=False)
|
||||||
old_file.delete()
|
old_file.delete()
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-08-09 13:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0003_accountpreference'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='accountpreference',
|
||||||
|
name='id',
|
||||||
|
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.http import http_date
|
||||||
|
from django.utils.timezone import now
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from PIL import Image
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
@ -11,6 +20,29 @@ from rest_framework.response import Response
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
|
THUMBNAIL_SIZES = (32, 64, 256)
|
||||||
|
|
||||||
|
|
||||||
|
def _accessible_files(request):
|
||||||
|
# Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see
|
||||||
|
# what - a file is visible if the requester is friends-or-self with whatever currently
|
||||||
|
# references it (an inventory item, a profile picture) or it's their own staged photo.
|
||||||
|
return File.objects.filter(
|
||||||
|
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
||||||
|
Q(profile_picture_users__in=request.user.friends_or_self()) |
|
||||||
|
Q(staged_by_workflows__owner__in=request.user.user.all())
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_headers(etag):
|
||||||
|
# Content is addressed by its own hash and can never change under a given URL, so caches
|
||||||
|
# (and the conditional-GET checks in both views below) can treat it as immutable forever.
|
||||||
|
return {
|
||||||
|
'ETag': etag,
|
||||||
|
'Cache-Control': 'max-age=31536000, private, immutable',
|
||||||
|
'Expires': http_date((now() + timedelta(days=365)).timestamp()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@swagger_auto_schema(method='GET', auto_schema=None)
|
@swagger_auto_schema(method='GET', auto_schema=None)
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
|
|
@ -23,29 +55,112 @@ def media_urls(request, hash_path):
|
||||||
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it
|
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it
|
||||||
# follows the X-Accel-Redirect and serves the file itself, so the CORS header for that
|
# follows the X-Accel-Redirect and serves the file itself, so the CORS header for that
|
||||||
# case has to be configured in nginx's `location /redirect_media/` block instead.
|
# case has to be configured in nginx's `location /redirect_media/` block instead.
|
||||||
|
#
|
||||||
|
# Looked up by the derived storage path (not by raw hash) because FileSerializer.name
|
||||||
|
# (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g.
|
||||||
|
# AuthenticatedImage's `src` - already returns this path via Django's FileField.url, and
|
||||||
|
# the existing test suite (files/tests.py MediaUrlTestCase) exercises it this way too.
|
||||||
try:
|
try:
|
||||||
file = File.objects.filter(
|
file = _accessible_files(request).get(file=hash_path)
|
||||||
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
|
||||||
Q(profile_picture_users__in=request.user.friends_or_self())
|
# The access-control lookup above must happen before this check - otherwise a bare
|
||||||
).distinct().get(
|
# hash + If-None-Match would let anyone probe "does a file with this hash exist" for
|
||||||
file=hash_path)
|
# files they can't actually see.
|
||||||
|
if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
|
||||||
|
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
|
||||||
|
|
||||||
|
cache_headers = _cache_headers(file.hash)
|
||||||
|
|
||||||
if settings.SERVE_X_ACCEL_REDIRECT:
|
if settings.SERVE_X_ACCEL_REDIRECT:
|
||||||
return HttpResponse(status=status.HTTP_200_OK,
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
content_type=file.mime_type,
|
content_type=file.mime_type,
|
||||||
headers={
|
headers={
|
||||||
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
'X-Accel-Redirect': f'/redirect_media/{hash_path}',
|
||||||
}) # TODO Expires and Cache-Control
|
**cache_headers,
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
|
# Read via the FieldFile itself (works against whatever storage backend is
|
||||||
|
# actually configured) rather than assuming file.file.path is a real filesystem
|
||||||
|
# path - the test suite swaps in an in-memory backend where that isn't true.
|
||||||
|
with file.file.open('rb') as fh:
|
||||||
|
content = fh.read()
|
||||||
return HttpResponse(status=status.HTTP_200_OK,
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
content_type=file.mime_type,
|
content_type=file.mime_type,
|
||||||
content=open(file.file.path, 'rb').read())
|
headers=cache_headers,
|
||||||
|
content=content)
|
||||||
|
|
||||||
|
except File.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
def _thumbnail_rel_path(file_hash, size):
|
||||||
|
# Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails/<size>/`
|
||||||
|
# subtree - reachable through the same nginx `/redirect_media/` alias as originals when
|
||||||
|
# served from real disk, no separate nginx location needed.
|
||||||
|
return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6],
|
||||||
|
file_hash[6:] + '.jpg')
|
||||||
|
|
||||||
|
|
||||||
|
@swagger_auto_schema(method='GET', auto_schema=None)
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthentication])
|
||||||
|
def thumbnail_urls(request, size, hash_path):
|
||||||
|
if size not in THUMBNAIL_SIZES:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file = _accessible_files(request).get(file=hash_path)
|
||||||
|
|
||||||
|
etag = f'{file.hash}_{size}'
|
||||||
|
if request.META.get('HTTP_IF_NONE_MATCH') == etag:
|
||||||
|
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
|
||||||
|
|
||||||
|
# Read/write through the default storage backend, same as File.file itself, rather
|
||||||
|
# than a hand-rolled filesystem path - correct regardless of storage backend (real
|
||||||
|
# disk in production, in-memory under the test runner) and keeps the cache in the
|
||||||
|
# same place originals live.
|
||||||
|
rel_path = _thumbnail_rel_path(file.hash, size)
|
||||||
|
if not default_storage.exists(rel_path):
|
||||||
|
# Thumbnails are always re-encoded as JPEG regardless of the original format -
|
||||||
|
# smaller and simpler than preserving e.g. PNG transparency at this scale.
|
||||||
|
with file.file.open('rb') as fh:
|
||||||
|
image = Image.open(fh)
|
||||||
|
image.thumbnail((size, size))
|
||||||
|
# Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha,
|
||||||
|
# palette-with-transparency, RGBA) store meaningless color/luminance data under
|
||||||
|
# fully transparent pixels (often zeroed out, i.e. black). Converting straight
|
||||||
|
# to RGB reveals that instead of "nothing there"; compositing onto an opaque
|
||||||
|
# background first shows what the image is actually supposed to look like.
|
||||||
|
rgba = image.convert('RGBA')
|
||||||
|
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
|
||||||
|
flattened.paste(rgba, mask=rgba.getchannel('A'))
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
flattened.save(buffer, 'JPEG', quality=90)
|
||||||
|
default_storage.save(rel_path, ContentFile(buffer.getvalue()))
|
||||||
|
|
||||||
|
cache_headers = _cache_headers(etag)
|
||||||
|
|
||||||
|
if settings.SERVE_X_ACCEL_REDIRECT:
|
||||||
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
|
content_type='image/jpeg',
|
||||||
|
headers={
|
||||||
|
'X-Accel-Redirect': f'/redirect_media/{rel_path}',
|
||||||
|
**cache_headers,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
with default_storage.open(rel_path, 'rb') as fh:
|
||||||
|
content = fh.read()
|
||||||
|
return HttpResponse(status=status.HTTP_200_OK,
|
||||||
|
content_type='image/jpeg',
|
||||||
|
headers=cache_headers,
|
||||||
|
content=content)
|
||||||
|
|
||||||
except File.DoesNotExist:
|
except File.DoesNotExist:
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('<int:size>/<path:hash_path>/', thumbnail_urls),
|
||||||
path('<path:hash_path>', media_urls),
|
path('<path:hash_path>', media_urls),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
|
|
||||||
|
|
@ -40,6 +43,18 @@ class FileManager(models.Manager):
|
||||||
else:
|
else:
|
||||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
||||||
if not self.filter(hash=kwargs['hash']).exists():
|
if not self.filter(hash=kwargs['hash']).exists():
|
||||||
|
# The upload path is derived entirely from the hash (hash_upload, above), and hash
|
||||||
|
# is DB-unique - so if no File row owns this hash yet, anything already sitting at
|
||||||
|
# its computed path is necessarily a stale orphan (e.g. left behind by a bug in
|
||||||
|
# cleanup code that deleted a File row without removing its stored bytes, or a
|
||||||
|
# crashed upload). Clear it before saving instead of letting Django's storage layer
|
||||||
|
# invent an alternate filename to avoid the "collision" - a suffixed name would
|
||||||
|
# silently break every part of the app that derives this file's URL purely from its
|
||||||
|
# hash (media serving, thumbnail generation, item/avatar attachment), and no future
|
||||||
|
# caller could ever discover it again.
|
||||||
|
expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '')
|
||||||
|
if default_storage.exists(expected_path):
|
||||||
|
default_storage.delete(expected_path)
|
||||||
return super().create(**kwargs)
|
return super().create(**kwargs)
|
||||||
else:
|
else:
|
||||||
raise IntegrityError('File with this hash already exists')
|
raise IntegrityError('File with this hash already exists')
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import DefaultStorage
|
from django.core.files.storage import DefaultStorage, default_storage
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.test import Client, override_settings
|
from django.test import Client, override_settings
|
||||||
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
||||||
from toolshed.tests import InventoryTestMixin
|
from toolshed.tests import InventoryTestMixin
|
||||||
from nacl.hash import sha256
|
from nacl.hash import sha256
|
||||||
from nacl.encoding import HexEncoder
|
from nacl.encoding import HexEncoder
|
||||||
|
from PIL import Image
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from files.media_urls import THUMBNAIL_SIZES
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
anonymous_client = Client()
|
anonymous_client = Client()
|
||||||
|
|
@ -105,6 +112,23 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase):
|
||||||
self.assertEqual(File.objects.count(), 3)
|
self.assertEqual(File.objects.count(), 3)
|
||||||
self.assertEqual(countdir(DefaultStorage(), ''), 3)
|
self.assertEqual(countdir(DefaultStorage(), ''), 3)
|
||||||
|
|
||||||
|
def test_file_upload_reclaims_stale_orphan_at_canonical_path(self):
|
||||||
|
# Reproduces a real incident: a File row gets deleted without its underlying stored
|
||||||
|
# bytes being removed (e.g. a bug in some cleanup call site), leaving an orphan sitting
|
||||||
|
# at the exact path hash_upload() would compute for that content. A later upload of the
|
||||||
|
# same content must land back on that canonical path - not get silently suffixed by
|
||||||
|
# Django's default collision-avoidance, which would make it unreachable to every part
|
||||||
|
# of the app that derives a file's URL purely from its hash.
|
||||||
|
expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}"
|
||||||
|
default_storage.save(expected_path, ContentFile(self.f['test_content4']))
|
||||||
|
self.assertTrue(default_storage.exists(expected_path))
|
||||||
|
self.assertFalse(File.objects.filter(hash=self.f['hash4']).exists())
|
||||||
|
|
||||||
|
file = File.objects.create(mime_type='text/plain', data=self.f['encoded_content4'])
|
||||||
|
|
||||||
|
self.assertEqual(file.file.name, expected_path)
|
||||||
|
self.assertEqual(file.file.read(), self.f['test_content4'])
|
||||||
|
|
||||||
|
|
||||||
class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -120,28 +144,29 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
||||||
self.f['item2'].files.add(self.f['test_file1'])
|
self.f['item2'].files.add(self.f['test_file1'])
|
||||||
|
|
||||||
|
|
||||||
# def test_file_url(self):
|
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||||
# reply = client.get(
|
def test_file_url(self):
|
||||||
# f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
|
reply = client.get(
|
||||||
# self.f['local_user1'])
|
f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
|
||||||
# self.assertEqual(reply.status_code, 200)
|
self.f['local_user1'])
|
||||||
# self.assertEqual(reply.headers['X-Accel-Redirect'],
|
self.assertEqual(reply.status_code, 200)
|
||||||
# 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['X-Accel-Redirect'],
|
||||||
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
|
f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}")
|
||||||
# reply = client.get(
|
self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
|
||||||
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
reply = client.get(
|
||||||
# self.f['local_user1'])
|
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
||||||
# self.assertEqual(reply.status_code, 200)
|
self.f['local_user1'])
|
||||||
# self.assertEqual(reply.headers['X-Accel-Redirect'],
|
self.assertEqual(reply.status_code, 200)
|
||||||
# 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['X-Accel-Redirect'],
|
||||||
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
|
||||||
# reply = client.get(
|
self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
||||||
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
reply = client.get(
|
||||||
# self.f['local_user2'])
|
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
|
||||||
# self.assertEqual(reply.status_code, 200)
|
self.f['local_user2'])
|
||||||
# self.assertEqual(reply.headers['X-Accel-Redirect'],
|
self.assertEqual(reply.status_code, 200)
|
||||||
# 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['X-Accel-Redirect'],
|
||||||
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
|
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):
|
def test_file_url_fail(self):
|
||||||
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1'])
|
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1'])
|
||||||
|
|
@ -195,3 +220,138 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
||||||
self.f['ext_user1'])
|
self.f['ext_user1'])
|
||||||
self.assertEqual(reply.status_code, 404)
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_files()
|
||||||
|
self.prepare_users()
|
||||||
|
self.prepare_categories()
|
||||||
|
self.prepare_tags()
|
||||||
|
self.prepare_properties()
|
||||||
|
self.prepare_inventory()
|
||||||
|
|
||||||
|
# Each test method gets its own distinct image content (and therefore its own content
|
||||||
|
# hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within
|
||||||
|
# a run, so sharing one fixed image across methods risks one test's cached (or, in
|
||||||
|
# test_thumbnail_served_from_cache_on_second_request's case, deliberately corrupted)
|
||||||
|
# thumbnail leaking into another test's assertions.
|
||||||
|
seed = zlib.crc32(self._testMethodName.encode()) % 256
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG')
|
||||||
|
image_bytes = buffer.getvalue()
|
||||||
|
self.f['image_hash'] = sha256(image_bytes, encoder=HexEncoder).decode('utf-8')
|
||||||
|
self.f['image_file'] = File.objects.create(
|
||||||
|
mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8'))
|
||||||
|
self.f['item1'].files.add(self.f['image_file'])
|
||||||
|
|
||||||
|
def _thumb_url(self, size, image_hash=None):
|
||||||
|
h = image_hash or self.f['image_hash']
|
||||||
|
return f"/media/{size}/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}/"
|
||||||
|
|
||||||
|
def _thumb_rel_path(self, size):
|
||||||
|
h = self.f['image_hash']
|
||||||
|
return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg')
|
||||||
|
|
||||||
|
def test_thumbnail_sizes_available(self):
|
||||||
|
# Documents the fixed size allow-list this test suite exercises against - update both
|
||||||
|
# if files/media_urls.py's THUMBNAIL_SIZES ever changes.
|
||||||
|
self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256))
|
||||||
|
|
||||||
|
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
||||||
|
def test_thumbnail_generates_resized_jpeg(self):
|
||||||
|
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
|
||||||
|
|
||||||
|
reply = client.get(self._thumb_url(64), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertEqual(reply.headers['Content-Type'], 'image/jpeg')
|
||||||
|
|
||||||
|
generated = Image.open(io.BytesIO(reply.content))
|
||||||
|
self.assertEqual(generated.format, 'JPEG')
|
||||||
|
# Aspect-ratio-preserving fit within a 64x64 box, not a crop to exactly 64x64.
|
||||||
|
self.assertLessEqual(max(generated.size), 64)
|
||||||
|
self.assertAlmostEqual(generated.size[0] / generated.size[1], 800 / 600, places=2)
|
||||||
|
|
||||||
|
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
||||||
|
def test_thumbnail_flattens_transparency_instead_of_going_black(self):
|
||||||
|
# Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent
|
||||||
|
# region has zeroed-out luminance underneath, as many image tools produce. Converting
|
||||||
|
# straight to RGB (dropping alpha without compositing) reveals that zeroed data - the
|
||||||
|
# whole thumbnail comes out solid black even though the visible (opaque) content isn't.
|
||||||
|
half_transparent = Image.new('LA', (200, 200))
|
||||||
|
pixels = half_transparent.load()
|
||||||
|
for x in range(200):
|
||||||
|
for y in range(200):
|
||||||
|
if x < 100:
|
||||||
|
pixels[x, y] = (0, 0) # transparent, zeroed-out luminance underneath
|
||||||
|
else:
|
||||||
|
pixels[x, y] = (255, 255) # fully opaque, bright content
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
half_transparent.save(buffer, 'PNG')
|
||||||
|
image_bytes = buffer.getvalue()
|
||||||
|
image_hash = sha256(image_bytes, encoder=HexEncoder).decode('utf-8')
|
||||||
|
image_file = File.objects.create(
|
||||||
|
mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8'))
|
||||||
|
self.f['item1'].files.add(image_file)
|
||||||
|
|
||||||
|
reply = client.get(self._thumb_url(64, image_hash=image_hash), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
|
||||||
|
generated = Image.open(io.BytesIO(reply.content)).convert('L')
|
||||||
|
# The opaque (right) half must stay bright; a naive RGB conversion would blacken it too.
|
||||||
|
self.assertGreater(generated.getpixel((generated.width - 1, generated.height // 2)), 200)
|
||||||
|
self.assertNotEqual(generated.getextrema(), (0, 0))
|
||||||
|
|
||||||
|
@override_settings(SERVE_X_ACCEL_REDIRECT=False)
|
||||||
|
def test_thumbnail_served_from_cache_on_second_request(self):
|
||||||
|
client.get(self._thumb_url(64), self.f['local_user1'])
|
||||||
|
rel_path = self._thumb_rel_path(64)
|
||||||
|
with default_storage.open(rel_path, 'rb') as f:
|
||||||
|
cached_bytes = f.read()
|
||||||
|
|
||||||
|
# Overwrite the cached file with a marker so a correct implementation must serve this
|
||||||
|
# exact content back rather than regenerating it from the original.
|
||||||
|
default_storage.delete(rel_path)
|
||||||
|
default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER'))
|
||||||
|
|
||||||
|
reply = client.get(self._thumb_url(64), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
self.assertTrue(reply.content.endswith(b'MARKER'))
|
||||||
|
|
||||||
|
def test_thumbnail_invalid_size(self):
|
||||||
|
reply = client.get(self._thumb_url(100), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertFalse(default_storage.exists(self._thumb_rel_path(100)))
|
||||||
|
|
||||||
|
def test_thumbnail_not_found(self):
|
||||||
|
reply = client.get(self._thumb_url(64, image_hash='0' * 64), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
|
||||||
|
def test_thumbnail_anonymous(self):
|
||||||
|
reply = anonymous_client.get(self._thumb_url(64))
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
||||||
|
def test_thumbnail_not_friend(self):
|
||||||
|
# local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the
|
||||||
|
# denied case needs a stranger to that friendship instead.
|
||||||
|
reply = client.get(self._thumb_url(64), self.f['ext_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 404)
|
||||||
|
self.assertFalse(default_storage.exists(self._thumb_rel_path(64)))
|
||||||
|
|
||||||
|
def test_thumbnail_conditional_get(self):
|
||||||
|
reply = client.get(self._thumb_url(64), self.f['local_user1'])
|
||||||
|
etag = reply.headers['ETag']
|
||||||
|
self.assertEqual(etag, f"{self.f['image_hash']}_64")
|
||||||
|
|
||||||
|
reply = client.get(self._thumb_url(64), self.f['local_user1'], HTTP_IF_NONE_MATCH=etag)
|
||||||
|
self.assertEqual(reply.status_code, 304)
|
||||||
|
|
||||||
|
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||||
|
def test_thumbnail_x_accel_redirect(self):
|
||||||
|
reply = client.get(self._thumb_url(64), self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
h = self.f['image_hash']
|
||||||
|
self.assertEqual(reply.headers['X-Accel-Redirect'],
|
||||||
|
f"/redirect_media/thumbnails/64/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}.jpg")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ MarkupSafe==2.1.3
|
||||||
openapi-codec==1.3.2
|
openapi-codec==1.3.2
|
||||||
packaging==23.1
|
packaging==23.1
|
||||||
pycparser==2.21
|
pycparser==2.21
|
||||||
|
Pillow==10.4.0
|
||||||
PyNaCl==1.5.0
|
PyNaCl==1.5.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
pytz==2023.3
|
pytz==2023.3
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ admin.site.register(StorageLocation, StorageLocationAdmin)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowInstanceAdmin(admin.ModelAdmin):
|
class WorkflowInstanceAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'state', 'owner', 'created_at', 'updated_at')
|
list_display = ('slug', 'state', 'owner', 'created_at', 'updated_at')
|
||||||
search_fields = ('name', 'owner__username')
|
search_fields = ('slug', 'owner__username')
|
||||||
list_filter = ('state', 'created_at', 'owner')
|
list_filter = ('state', 'created_at', 'owner')
|
||||||
readonly_fields = ('created_at', 'updated_at')
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from rest_framework.response import Response
|
||||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from files.serializers import FileSerializer
|
from files.serializers import FileSerializer
|
||||||
from toolshed.models import InventoryItem
|
from toolshed.models import InventoryItem, WorkflowInstance
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
|
|
@ -30,6 +30,16 @@ def get_item_files(request, item_id):
|
||||||
def post_item_file(request, item_id):
|
def post_item_file(request, item_id):
|
||||||
try:
|
try:
|
||||||
item = InventoryItem.objects.get(id=item_id, owner=request.user)
|
item = InventoryItem.objects.get(id=item_id, owner=request.user)
|
||||||
|
if 'file_hash' in request.data:
|
||||||
|
# Attach a file the caller already staged on one of their own workflows, identified
|
||||||
|
# by its content hash (which the client already computed before ever uploading it),
|
||||||
|
# instead of re-uploading bytes that are already stored server-side.
|
||||||
|
try:
|
||||||
|
file = File.objects.get(hash=request.data['file_hash'], staged_by_workflows__owner=request.user)
|
||||||
|
except File.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
item.files.add(file)
|
||||||
|
return Response(FileSerializer(file).data, status=status.HTTP_201_CREATED)
|
||||||
serializer = FileSerializer(data=request.data)
|
serializer = FileSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
file = serializer.save()
|
file = serializer.save()
|
||||||
|
|
@ -40,6 +50,31 @@ def post_item_file(request, item_id):
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
def get_staged_files(request, workflow_id):
|
||||||
|
try:
|
||||||
|
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
||||||
|
# Hash alone identifies a staged file (client and server hash content the same way, and
|
||||||
|
# bytes are fetchable from a hash-derived storage path) - useful mainly for discovering
|
||||||
|
# what another session/device already staged on this workflow, unlike the fuller
|
||||||
|
# FileSerializer representation item_files uses.
|
||||||
|
return Response(list(workflow.staged_files.values_list('hash', flat=True)))
|
||||||
|
except WorkflowInstance.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
def post_staged_file(request, workflow_id):
|
||||||
|
try:
|
||||||
|
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
||||||
|
serializer = FileSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
file = serializer.save()
|
||||||
|
workflow.staged_files.add(file)
|
||||||
|
return Response({'hash': file.hash}, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except WorkflowInstance.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST', 'GET'])
|
@api_view(['POST', 'GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
@authentication_classes([SignatureAuthenticationLocal])
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
|
@ -58,7 +93,9 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/
|
||||||
item = InventoryItem.objects.get(id=item_id, owner=request.user)
|
item = InventoryItem.objects.get(id=item_id, owner=request.user)
|
||||||
file = item.files.get(id=file_id)
|
file = item.files.get(id=file_id)
|
||||||
item.files.remove(file_id)
|
item.files.remove(file_id)
|
||||||
if file.connected_items.count() == 0:
|
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
|
||||||
|
and file.staged_by_workflows.count() == 0:
|
||||||
|
file.file.delete(save=False)
|
||||||
file.delete()
|
file.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
except InventoryItem.DoesNotExist:
|
except InventoryItem.DoesNotExist:
|
||||||
|
|
@ -67,8 +104,39 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST', 'GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
def staged_files(request, workflow_id, format=None): # /staged_files/
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_staged_files(request, workflow_id)
|
||||||
|
elif request.method == 'POST':
|
||||||
|
return post_staged_file(request, workflow_id)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged_files/
|
||||||
|
try:
|
||||||
|
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
||||||
|
file = workflow.staged_files.get(hash=file_hash)
|
||||||
|
workflow.staged_files.remove(file)
|
||||||
|
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
|
||||||
|
and file.staged_by_workflows.count() == 0:
|
||||||
|
file.file.delete(save=False)
|
||||||
|
file.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
except WorkflowInstance.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
except File.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('files/', list_all_files),
|
path('files/', list_all_files),
|
||||||
path('item_files/<int:item_id>/', item_files),
|
path('item_files/<int:item_id>/', item_files),
|
||||||
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
|
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
|
||||||
|
path('staged_files/<int:workflow_id>/', staged_files),
|
||||||
|
path('staged_files/<int:workflow_id>/<str:file_hash>/', delete_staged_file),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from rest_framework.response import Response
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity
|
from authentication.models import ToolshedUser, KnownIdentity
|
||||||
from authentication.signature_auth import SignatureAuthentication
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from files.models import File
|
||||||
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
|
||||||
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
|
||||||
|
|
||||||
|
|
@ -120,7 +121,13 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
if instance.owner == self.request.user.user.get():
|
if instance.owner == self.request.user.user.get():
|
||||||
|
staged_file_ids = list(instance.staged_files.values_list('id', flat=True))
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
for file in File.objects.filter(id__in=staged_file_ids):
|
||||||
|
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
|
||||||
|
and file.staged_by_workflows.count() == 0:
|
||||||
|
file.file.delete(save=False)
|
||||||
|
file.delete()
|
||||||
|
|
||||||
|
|
||||||
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
|
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-08-09 13:08
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('toolshed', '0009_alter_workflowinstance_payload'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='workflowinstance',
|
||||||
|
old_name='name',
|
||||||
|
new_name='slug',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 4.2.2
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('files', '0001_initial'),
|
||||||
|
('toolshed', '0010_rename_name_workflowinstance_slug'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workflowinstance',
|
||||||
|
name='staged_files',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='staged_by_workflows', to='files.file'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -135,14 +135,15 @@ class StorageLocation(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class WorkflowInstance(models.Model):
|
class WorkflowInstance(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
slug = models.CharField(max_length=255)
|
||||||
state = models.CharField(max_length=255)
|
state = models.CharField(max_length=255)
|
||||||
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
|
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')
|
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
|
||||||
|
staged_files = models.ManyToManyField(File, related_name='staged_by_workflows', blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.state})"
|
return f"{self.slug} ({self.state})"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,14 +284,15 @@ def delete_user_account(user):
|
||||||
def _delete_orphaned_files(file_ids):
|
def _delete_orphaned_files(file_ids):
|
||||||
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
|
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
|
||||||
|
|
||||||
A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture)
|
A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no
|
||||||
references it anymore. Returns the number of files deleted.
|
WorkflowInstance (staged file) references it anymore. Returns the number of files deleted.
|
||||||
"""
|
"""
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
|
||||||
deleted = 0
|
deleted = 0
|
||||||
for file_obj in File.objects.filter(id__in=file_ids):
|
for file_obj in File.objects.filter(id__in=file_ids):
|
||||||
if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists():
|
if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists() \
|
||||||
|
or file_obj.staged_by_workflows.exists():
|
||||||
continue
|
continue
|
||||||
file_obj.file.delete(save=False)
|
file_obj.file.delete(save=False)
|
||||||
file_obj.delete()
|
file_obj.delete()
|
||||||
|
|
|
||||||
|
|
@ -203,9 +203,18 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
||||||
owner = serializers.StringRelatedField(read_only=True)
|
owner = serializers.StringRelatedField(read_only=True)
|
||||||
|
# Hash is enough to identify a staged file (the client computes the same SHA-256 the backend
|
||||||
|
# does, and can fetch bytes from a hash-derived storage path) - for anything staged by *this*
|
||||||
|
# session there's nothing more to say, and for a file staged elsewhere (another device/tab),
|
||||||
|
# hash is what lets this session recognize and fetch it. Unlike InventoryItemSerializer.files,
|
||||||
|
# no fuller FileSerializer representation is needed here.
|
||||||
|
staged_files = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkflowInstance
|
model = WorkflowInstance
|
||||||
fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at']
|
fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at']
|
||||||
read_only_fields = ['owner', 'created_at', 'updated_at']
|
read_only_fields = ['owner', 'staged_files', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_staged_files(self, obj):
|
||||||
|
return list(obj.staged_files.values_list('hash', flat=True))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,49 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
name: dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend-a:
|
backend-a:
|
||||||
build:
|
build:
|
||||||
context: ../backend/
|
context: ../../backend/
|
||||||
dockerfile: ../deploy/dev/Dockerfile.backend
|
dockerfile: ../deploy/dev/Dockerfile.backend
|
||||||
environment:
|
environment:
|
||||||
TOOLSHED_DB_PATH: /mnt/db.sqlite3
|
TOOLSHED_DB_PATH: /mnt/db.sqlite3
|
||||||
TOOLSHED_USERFILES_PATH: /mnt/userfiles
|
TOOLSHED_USERFILES_PATH: /mnt/userfiles
|
||||||
TOOLSHED_SETUP_PATH: /mnt/testdata.py
|
TOOLSHED_SETUP_PATH: /mnt/testdata.py
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend:/code
|
- ../../backend:/code
|
||||||
- ../deploy/dev/instance_a/a.env:/code/.env
|
- ./instance_a/a.env:/code/.env
|
||||||
- ../deploy/dev/instance_a/testdata.py:/mnt/testdata.py
|
- ./instance_a/testdata.py:/mnt/testdata.py
|
||||||
- ../deploy/dev/instance_a/a.sqlite3:/mnt/db.sqlite3
|
- ./instance_a/a.sqlite3:/mnt/db.sqlite3
|
||||||
- ../deploy/dev/instance_a/userfiles:/mnt/userfiles
|
- ./instance_a/userfiles:/mnt/userfiles
|
||||||
expose:
|
expose:
|
||||||
- 8000
|
- 8000
|
||||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||||
|
|
||||||
backend-b:
|
backend-b:
|
||||||
build:
|
build:
|
||||||
context: ../backend/
|
context: ../../backend/
|
||||||
dockerfile: ../deploy/dev/Dockerfile.backend
|
dockerfile: ../deploy/dev/Dockerfile.backend
|
||||||
environment:
|
environment:
|
||||||
TOOLSHED_DB_PATH: /mnt/db.sqlite3
|
TOOLSHED_DB_PATH: /mnt/db.sqlite3
|
||||||
TOOLSHED_USERFILES_PATH: /mnt/userfiles
|
TOOLSHED_USERFILES_PATH: /mnt/userfiles
|
||||||
TOOLSHED_SETUP_PATH: /mnt/testdata.py
|
TOOLSHED_SETUP_PATH: /mnt/testdata.py
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend:/code
|
- ../../backend:/code
|
||||||
- ../deploy/dev/instance_b/b.env:/code/.env
|
- ./instance_b/b.env:/code/.env
|
||||||
- ../deploy/dev/instance_b/testdata.py:/mnt/testdata.py
|
- ./instance_b/testdata.py:/mnt/testdata.py
|
||||||
- ../deploy/dev/instance_b/b.sqlite3:/mnt/db.sqlite3
|
- ./instance_b/b.sqlite3:/mnt/db.sqlite3
|
||||||
- ../deploy/dev/instance_b/userfiles:/mnt/userfiles
|
- ./instance_b/userfiles:/mnt/userfiles
|
||||||
expose:
|
expose:
|
||||||
- 8000
|
- 8000
|
||||||
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../frontend/
|
context: ../../frontend/
|
||||||
dockerfile: ../deploy/dev/Dockerfile.frontend
|
dockerfile: ../deploy/dev/Dockerfile.frontend
|
||||||
volumes:
|
volumes:
|
||||||
- ../frontend:/app
|
- ../../frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
expose:
|
expose:
|
||||||
- 5173
|
- 5173
|
||||||
|
|
@ -50,11 +51,11 @@ services:
|
||||||
|
|
||||||
wiki:
|
wiki:
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../../
|
||||||
dockerfile: deploy/dev/Dockerfile.wiki
|
dockerfile: deploy/dev/Dockerfile.wiki
|
||||||
volumes:
|
volumes:
|
||||||
- ../mkdocs.yml:/wiki/mkdocs.yml
|
- ../../mkdocs.yml:/wiki/mkdocs.yml
|
||||||
- ../docs:/wiki/docs
|
- ../../docs:/wiki/docs
|
||||||
expose:
|
expose:
|
||||||
- 8001
|
- 8001
|
||||||
command: mkdocs serve --dev-addr=0.0.0.0:8001
|
command: mkdocs serve --dev-addr=0.0.0.0:8001
|
||||||
|
|
@ -62,12 +63,12 @@ services:
|
||||||
proxy-a:
|
proxy-a:
|
||||||
build:
|
build:
|
||||||
context: ./
|
context: ./
|
||||||
dockerfile: dev/Dockerfile.proxy
|
dockerfile: Dockerfile.proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
|
- ./instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./dev/instance_a/dns.json:/var/www/dns.json:ro
|
- ./instance_a/dns.json:/var/www/dns.json:ro
|
||||||
- ./dev/instance_a/domains.json:/var/www/domains.json:ro
|
- ./instance_a/domains.json:/var/www/domains.json:ro
|
||||||
- ./dev/instance_a/userfiles:/var/www/userfiles:ro
|
- ./instance_a/userfiles:/var/www/userfiles:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
- "127.0.0.3:5353:5353"
|
- "127.0.0.3:5353:5353"
|
||||||
|
|
@ -75,19 +76,19 @@ services:
|
||||||
proxy-b:
|
proxy-b:
|
||||||
build:
|
build:
|
||||||
context: ./
|
context: ./
|
||||||
dockerfile: dev/Dockerfile.proxy
|
dockerfile: Dockerfile.proxy
|
||||||
volumes:
|
volumes:
|
||||||
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
- ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./dev/instance_b/userfiles:/var/www/userfiles:ro
|
- ./instance_b/userfiles:/var/www/userfiles:ro
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.2:8080:8080"
|
- "127.0.0.2:8080:8080"
|
||||||
|
|
||||||
dns:
|
dns:
|
||||||
build:
|
build:
|
||||||
context: ./dev/
|
context: ./
|
||||||
dockerfile: Dockerfile.dns
|
dockerfile: Dockerfile.dns
|
||||||
volumes:
|
volumes:
|
||||||
- ./dev/zone.json:/dns/zone.json
|
- ./zone.json:/dns/zone.json
|
||||||
expose:
|
expose:
|
||||||
- 8053
|
- 8053
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
events {}
|
events {}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
client_max_body_size 128M;
|
||||||
|
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server backend-a:8000;
|
server backend-a:8000;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
events {}
|
events {}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
client_max_body_size 128M;
|
||||||
|
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server backend-b:8000;
|
server backend-b:8000;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
deploy/prod/.gitignore
vendored
Normal file
2
deploy/prod/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
.secrets/
|
||||||
|
inventory.yml
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
FROM node:alpine as builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY ./package.json /app/package.json
|
|
||||||
COPY . /app
|
|
||||||
RUN npm install
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
|
||||||
FROM nginx:alpine as runner
|
|
||||||
RUN apk add --update npm
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
|
||||||
EXPOSE 80
|
|
||||||
28
deploy/prod/Dockerfile.backend
Normal file
28
deploy/prod/Dockerfile.backend
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Production image for the Django backend.
|
||||||
|
# Runs migrations then serves the app with gunicorn on port 8000.
|
||||||
|
# Static files are collected at build time into /app/staticfiles and
|
||||||
|
# served by the backend itself behind the host nginx reverse proxy.
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DJANGO_SETTINGS_MODULE=backend.settings
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 --workers 3"]
|
||||||
18
deploy/prod/Dockerfile.frontend
Normal file
18
deploy/prod/Dockerfile.frontend
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Build-only image for the Vue frontend.
|
||||||
|
# It is never run as a service: ansible builds this image once, runs it
|
||||||
|
# with the host output directory bind-mounted at /output, the container
|
||||||
|
# copies the compiled static build into it, and exits. Nginx on the host
|
||||||
|
# then serves that directory directly.
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY extras/ ./extras/
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM alpine AS export
|
||||||
|
COPY --from=build /app/dist /dist
|
||||||
|
VOLUME /output
|
||||||
|
CMD ["sh", "-c", "rm -rf /output/* && cp -a /dist/. /output/"]
|
||||||
17
deploy/prod/Dockerfile.wiki
Normal file
17
deploy/prod/Dockerfile.wiki
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Build-only image for the project wiki (mkdocs).
|
||||||
|
# It is never run as a service: ansible builds this image once, runs it
|
||||||
|
# with the host output directory bind-mounted at /output, the container
|
||||||
|
# copies the built static site into it, and exits. Nginx on the host
|
||||||
|
# then serves that directory directly, the same way it does the frontend.
|
||||||
|
|
||||||
|
FROM python:3.11-slim AS build
|
||||||
|
WORKDIR /wiki
|
||||||
|
RUN pip install --no-cache-dir mkdocs
|
||||||
|
COPY mkdocs.yml ./
|
||||||
|
COPY docs/ ./docs/
|
||||||
|
RUN mkdocs build
|
||||||
|
|
||||||
|
FROM alpine AS export
|
||||||
|
COPY --from=build /wiki/site /site
|
||||||
|
VOLUME /output
|
||||||
|
CMD ["sh", "-c", "rm -rf /output/* && cp -a /site/. /output/"]
|
||||||
205
deploy/prod/README.md
Normal file
205
deploy/prod/README.md
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
# Toolshed production deployment — manual steps
|
||||||
|
|
||||||
|
`playbook.yml` automates installing docker.io and nginx (plus certbot, and
|
||||||
|
obtaining/renewing a TLS certificate with it, on hosts that manage their own
|
||||||
|
— see `behind_tls_proxy` below), building the backend, frontend and wiki
|
||||||
|
images, exporting the frontend and wiki static builds for nginx to serve,
|
||||||
|
writing the small `/local/domains` and `/local/dns` fixture files the
|
||||||
|
frontend fetches directly (registration domain list and DoH resolver
|
||||||
|
preference — see `toolshed_register_domains`/`toolshed_doh_resolvers` in
|
||||||
|
`playbook.yml`), configuring nginx, and installing the `toolshed-backend`
|
||||||
|
systemd service. It does **not** set up the target server or DNS. Those are
|
||||||
|
manual, one-time steps and are covered here. Seeding the backend's shared
|
||||||
|
reference data is also a manual, one-time step — see
|
||||||
|
[First superuser & shared reference data](#5-first-superuser--shared-reference-data).
|
||||||
|
|
||||||
|
## 1. Server & firewall
|
||||||
|
|
||||||
|
- A Debian/Ubuntu host reachable over SSH.
|
||||||
|
- Copy `inventory.example.yml` to `inventory.yml` (git-ignored, since it
|
||||||
|
holds real hostnames/IPs) and fill in your host(s) — see
|
||||||
|
[Per-deployment configuration](#2-per-deployment-configuration).
|
||||||
|
- Inbound TCP 80 open in the firewall/security group. Also open 443 unless
|
||||||
|
`behind_tls_proxy: true` — and keep both open permanently, not just for the
|
||||||
|
initial deploy: certbot's renewal timer needs 80 for the ACME HTTP-01
|
||||||
|
challenge and 443 for HTTPS traffic for as long as this host is live.
|
||||||
|
|
||||||
|
## 2. Per-deployment configuration
|
||||||
|
|
||||||
|
Each entry under `hosts:` in `inventory.yml` is its own independent
|
||||||
|
deployment (its own repo checkout, database, domain, systemd service and
|
||||||
|
Django `SECRET_KEY` — nothing is shared between hosts). Set these as
|
||||||
|
host_vars directly on each host entry, not via `-e` on the command line,
|
||||||
|
so a single `inventory.yml` can hold several unrelated deployments safely:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
toolshed:
|
||||||
|
hosts:
|
||||||
|
my-server:
|
||||||
|
ansible_host: 203.0.113.10
|
||||||
|
ansible_user: deploy
|
||||||
|
toolshed_domain: toolshed.webdomain.tld
|
||||||
|
toolshed_handle_domain: yourtoolshed.tld # optional, see below
|
||||||
|
toolshed_repo_url: git@example.com:your-org/toolshed.git
|
||||||
|
behind_tls_proxy: false
|
||||||
|
```
|
||||||
|
|
||||||
|
- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django
|
||||||
|
`ALLOWED_HOSTS`, and the hostname you'll point a TLS cert at — e.g.
|
||||||
|
`toolshed.webdomain.tld`. Required, no default. This is not necessarily the
|
||||||
|
same as the **handle domain** your users log in with (the part after `@`
|
||||||
|
in `user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate.
|
||||||
|
- `toolshed_handle_domain` — the **handle domain**, only needed when it's
|
||||||
|
different from `toolshed_domain`. Omit it when the two are the same (it
|
||||||
|
then defaults to `toolshed_domain`). Set so nginx/Django accept requests
|
||||||
|
for either domain, whichever ends up as the `Host` header.
|
||||||
|
- `toolshed_repo_url` — the git remote the playbook checks out and builds
|
||||||
|
from. Required, no default.
|
||||||
|
- `toolshed_version` — the branch, tag or commit to check out and build.
|
||||||
|
Optional, defaults to `stable`.
|
||||||
|
- `behind_tls_proxy` — `true` if TLS for this host is already terminated by
|
||||||
|
something in front of it (e.g. an external reverse proxy or load
|
||||||
|
balancer) that forwards plain HTTP here; `false` if this nginx has to
|
||||||
|
terminate TLS itself. This controls two things:
|
||||||
|
- Whether nginx trusts an upstream `X-Forwarded-Proto` header or sets its
|
||||||
|
own — get this wrong and Django's `SECURE_PROXY_SSL_HEADER` check
|
||||||
|
(`backend/backend/settings.py`) will treat every request as insecure or,
|
||||||
|
flipped the other way, treat plain HTTP as secure.
|
||||||
|
- Whether the playbook manages TLS at all. When `false`, it automatically
|
||||||
|
obtains a Let's Encrypt certificate via certbot and switches nginx over
|
||||||
|
to it — nothing to do manually beyond DNS (below). certbot's own systemd
|
||||||
|
timer keeps renewing it afterwards, independent of the playbook.
|
||||||
|
- `toolshed_letsencrypt_email` — required whenever `behind_tls_proxy` is
|
||||||
|
`false`; the account email certbot registers the certificate under
|
||||||
|
(used only for renewal-failure notices). Ignored otherwise.
|
||||||
|
- `http_port` — optional, defaults to `80`. Only relevant when
|
||||||
|
`behind_tls_proxy: true` and whatever's in front of this host forwards to
|
||||||
|
a nonstandard port instead of 80.
|
||||||
|
- `doh_resolvers` — optional, defaults to `["1.1.1.1", "8.8.8.8"]` (the same
|
||||||
|
hardcoded fallback the frontend itself uses, see `frontend/src/dns.js`).
|
||||||
|
DNS-over-HTTPS resolvers the frontend uses to look up a handle domain's
|
||||||
|
`_toolshed-server._tcp` SRV record before it has a cached preference.
|
||||||
|
Written to `/local/dns` at deploy time; only worth overriding as a
|
||||||
|
host_var (or `-e doh_resolvers='["9.9.9.9"]'`) if you want this
|
||||||
|
deployment to prefer a specific resolver.
|
||||||
|
|
||||||
|
## 3. DNS
|
||||||
|
|
||||||
|
There are two distinct domains at play here, and it's easy to conflate them:
|
||||||
|
|
||||||
|
- **Web domain** — the machine's actual hostname: nginx `server_name`,
|
||||||
|
Django `ALLOWED_HOSTS`, your TLS cert, what's in `toolshed_domain`. This is
|
||||||
|
what an A/AAAA record has to resolve to the server's IP for.
|
||||||
|
- **Handle domain** — the part after the `@` in a username, e.g.
|
||||||
|
`user@yourtoolshed.tld`. Toolshed usernames don't encode a server address
|
||||||
|
directly; the frontend resolves the handle domain to a server via an SRV
|
||||||
|
record, `_toolshed-server._tcp.<handle domain>.` (see
|
||||||
|
`frontend/src/store.js`, `lookupServer`). What's in `toolshed_handle_domain`
|
||||||
|
(see [Per-deployment configuration](#2-per-deployment-configuration)) only
|
||||||
|
makes nginx/Django accept it as a `Host` header — publishing the actual SRV
|
||||||
|
record is still a separate, manual DNS step, covered below.
|
||||||
|
|
||||||
|
The SRV lookup happens for every login, not just federation with other
|
||||||
|
servers, so **every** deployment needs it published for its own handle
|
||||||
|
domain — even a standalone server that only ever serves itself.
|
||||||
|
|
||||||
|
These two domains can be **the same** or **completely different**, and
|
||||||
|
that's exactly the choice between an A record and an SRV record:
|
||||||
|
|
||||||
|
- **Same domain**: if `yourtoolshed.tld` is both the web domain and the
|
||||||
|
handle domain, it needs both an A record (so the domain itself resolves to
|
||||||
|
the server) and an SRV record that happens to point back at itself.
|
||||||
|
- **Different domains**: the handle domain only needs the SRV record — no A
|
||||||
|
record of its own — pointing at whatever web domain the server actually
|
||||||
|
lives at. This is useful when the handle you give out (short, brandable,
|
||||||
|
independent of hosting) shouldn't have to match wherever the box is
|
||||||
|
actually deployed (a subdomain of a shared hosting provider, an internal
|
||||||
|
service name, etc.).
|
||||||
|
|
||||||
|
**a) A/AAAA record — web domain → server IP:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dig <your-web-domain> A
|
||||||
|
```
|
||||||
|
|
||||||
|
**b) SRV record — handle domain → web domain + port.** Use port 443: the
|
||||||
|
federation protocol is HTTPS-only.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dig _toolshed-server._tcp.<your-handle-domain> SRV
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, with a handle domain of `yourtoolshed.tld` and a web domain of
|
||||||
|
`toolshed.webdomain.tld`:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ dig _toolshed-server._tcp.yourtoolshed.tld srv
|
||||||
|
_toolshed-server._tcp.yourtoolshed.tld. 300 IN SRV 10 10 443 toolshed.webdomain.tld.
|
||||||
|
|
||||||
|
$ dig toolshed.webdomain.tld A
|
||||||
|
toolshed.webdomain.tld. 300 IN A 203.0.113.10
|
||||||
|
```
|
||||||
|
|
||||||
|
If you instead want `yourtoolshed.tld` itself to be the web domain too, its
|
||||||
|
SRV record just points at itself (`... SRV 10 10 443 yourtoolshed.tld.`) and
|
||||||
|
it additionally needs its own A record.
|
||||||
|
|
||||||
|
## 4. Secrets
|
||||||
|
|
||||||
|
`toolshed_secret_key` is generated once per host by the playbook (via the
|
||||||
|
`password` lookup, keyed by the host's inventory name) and stored as
|
||||||
|
`.secrets/<inventory-hostname>_secret_key` on the *control* machine, not on
|
||||||
|
the target. Back these files up — losing one invalidates all sessions and
|
||||||
|
signed cookies for that deployment on its next redeploy. They're git-ignored
|
||||||
|
on purpose; never commit them.
|
||||||
|
|
||||||
|
## 5. First superuser & shared reference data
|
||||||
|
|
||||||
|
The production backend image only runs `migrate` and `collectstatic` at
|
||||||
|
startup (see `Dockerfile.backend`) — unlike the dev compose setup, it never
|
||||||
|
runs the interactive `configure.py`. Two things dev gets "for free" from that
|
||||||
|
script therefore need doing manually, once, after a host's backend container
|
||||||
|
is first up (run these on the target host itself, or prefix with
|
||||||
|
`ssh <that-host>`):
|
||||||
|
|
||||||
|
- **Superuser account:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -it toolshed-backend python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Shared reference data** (the standard categories/properties/tags
|
||||||
|
shipped in `backend/shared_data/*.json` — tools, electrical, screws, IT,
|
||||||
|
etc.): without this step a fresh deployment starts with none of them.
|
||||||
|
Run `configure.py` interactively (the `-it` flags matter — the script's
|
||||||
|
prompts only appear with a real tty) and answer "yes" when it asks to
|
||||||
|
import them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec -it toolshed-backend python configure.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The other prompts it asks first (create `.env`, create a database) are
|
||||||
|
harmless to answer "yes" to as well: the container already gets its real
|
||||||
|
`SECRET_KEY`/`ALLOWED_HOSTS`/db path from the environment (see
|
||||||
|
`backend.env` below), those checks just look for files at paths relative
|
||||||
|
to `/app` that don't exist in this container, and re-running `migrate`
|
||||||
|
against the real database is idempotent. You can say "no" to the
|
||||||
|
superuser prompt here if you already created one above.
|
||||||
|
|
||||||
|
## 6. Running the playbook
|
||||||
|
|
||||||
|
Always target one host at a time with `--limit` — running against the whole
|
||||||
|
`toolshed` group in one invocation would apply every host's own
|
||||||
|
`toolshed_domain`/`toolshed_repo_url` correctly (they're per-host vars, see
|
||||||
|
[Per-deployment configuration](#2-per-deployment-configuration)), but rolls
|
||||||
|
out all deployments back-to-back in one run, which is rarely what you want:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ansible-playbook -i inventory.yml playbook.yml --limit my-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run it to roll out a new version to that host. It deploys whatever
|
||||||
|
`toolshed_version` is set for that host (`stable` by default) — set the
|
||||||
|
host_var for a persistent change, or pass `-e toolshed_version=<branch/tag/commit>`
|
||||||
|
for a one-off deploy of something else.
|
||||||
42
deploy/prod/inventory.example.yml
Normal file
42
deploy/prod/inventory.example.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
# Copy this file to inventory.yml (git-ignored) and fill in your real
|
||||||
|
# hosts. Each entry under hosts: is an independent deployment - see the
|
||||||
|
# README's "Per-deployment configuration" section for what each var means.
|
||||||
|
|
||||||
|
toolshed:
|
||||||
|
hosts:
|
||||||
|
my-server:
|
||||||
|
ansible_host: 203.0.113.10
|
||||||
|
ansible_user: deploy
|
||||||
|
# toolshed_domain is the "web domain" - see the README's DNS section
|
||||||
|
# for how this relates to the separate "handle domain" your users
|
||||||
|
# log in with (user@yourtoolshed.tld).
|
||||||
|
toolshed_domain: toolshed.webdomain.tld
|
||||||
|
# Optional - only needed if the handle domain differs from the web
|
||||||
|
# domain above. Omit it entirely when they're the same.
|
||||||
|
toolshed_handle_domain: yourtoolshed.tld
|
||||||
|
toolshed_repo_url: git@example.com:your-org/toolshed.git
|
||||||
|
# Optional - branch, tag or commit to deploy. Defaults to "stable".
|
||||||
|
toolshed_version: stable
|
||||||
|
# true if something in front of this host already terminates TLS
|
||||||
|
# (reverse proxy/load balancer), false if this nginx must do it itself.
|
||||||
|
behind_tls_proxy: false
|
||||||
|
# Required whenever behind_tls_proxy is false: the playbook obtains
|
||||||
|
# its own Let's Encrypt certificate via certbot, which needs an
|
||||||
|
# account email for renewal notices.
|
||||||
|
toolshed_letsencrypt_email: admin@example.com
|
||||||
|
|
||||||
|
# A second, unrelated deployment behind an existing TLS-terminating
|
||||||
|
# proxy - remove this if you only run one instance. Here the handle
|
||||||
|
# domain and web domain are the same, so toolshed_handle_domain is
|
||||||
|
# simply omitted, and toolshed_letsencrypt_email isn't needed since
|
||||||
|
# this nginx never handles TLS itself.
|
||||||
|
my-other-server:
|
||||||
|
ansible_host: my-other-server.example.com
|
||||||
|
ansible_user: deploy
|
||||||
|
toolshed_domain: toolshed.example.com
|
||||||
|
toolshed_repo_url: git@example.com:your-org/toolshed.git
|
||||||
|
behind_tls_proxy: true
|
||||||
|
# Only needed if the proxy in front forwards to something other than
|
||||||
|
# port 80 on this host.
|
||||||
|
http_port: 8080
|
||||||
539
deploy/prod/playbook.yml
Normal file
539
deploy/prod/playbook.yml
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
---
|
||||||
|
# Production deploy for toolshed.
|
||||||
|
#
|
||||||
|
# - installs docker.io and nginx on the target (plus certbot, unless
|
||||||
|
# behind_tls_proxy is true)
|
||||||
|
# - checks out the source and builds the backend and frontend docker images
|
||||||
|
# - runs the frontend image once to export its static build, which nginx
|
||||||
|
# then serves directly (the frontend image is never run as a service)
|
||||||
|
# - configures nginx (inline template, no separate .conf file) and, unless
|
||||||
|
# behind_tls_proxy is true, obtains/renews a Let's Encrypt certificate via
|
||||||
|
# certbot and switches nginx over to it automatically - no manual TLS step
|
||||||
|
# - installs and manages a systemd service that runs the backend container
|
||||||
|
#
|
||||||
|
# Usage (each host is its own independent deployment - always target one
|
||||||
|
# at a time, never the whole "toolshed" group in one run):
|
||||||
|
# ansible-playbook -i inventory.yml playbook.yml --limit my-server
|
||||||
|
#
|
||||||
|
# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional),
|
||||||
|
# toolshed_version (optional, defaults to "stable"), behind_tls_proxy and
|
||||||
|
# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are
|
||||||
|
# per-deployment and must be set as host_vars in inventory.yml (copy
|
||||||
|
# inventory.example.yml) rather than here or via -e, so that each host in
|
||||||
|
# the "toolshed" group can point at its own repo/domain/branch. They're read
|
||||||
|
# with `mandatory`/`default()` below instead of being declared in play
|
||||||
|
# `vars:`, since play vars always take precedence over inventory host_vars
|
||||||
|
# and would otherwise silently override whatever is set per-host.
|
||||||
|
|
||||||
|
- name: Deploy toolshed
|
||||||
|
hosts: toolshed
|
||||||
|
become: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
toolshed_src_dir: /opt/toolshed/src
|
||||||
|
toolshed_data_dir: /opt/toolshed/data
|
||||||
|
toolshed_dist_dir: /var/www/toolshed
|
||||||
|
|
||||||
|
toolshed_backend_image: toolshed-backend
|
||||||
|
toolshed_frontend_image: toolshed-frontend-builder
|
||||||
|
toolshed_wiki_image: toolshed-wiki-builder
|
||||||
|
toolshed_backend_container: toolshed-backend
|
||||||
|
toolshed_backend_port: 8000
|
||||||
|
toolshed_wiki_dist_dir: /var/www/toolshed-wiki
|
||||||
|
toolshed_local_dir: /var/www/toolshed-local
|
||||||
|
# Domain(s) this server accepts registrations for (the "handle domain" -
|
||||||
|
# see the README's DNS section). Served as a static /local/domains
|
||||||
|
# fixture that the frontend's registration/pairing forms fetch to
|
||||||
|
# populate their domain dropdown (frontend/src/views/Register.vue,
|
||||||
|
# Pairing.vue) - without it that dropdown is just empty.
|
||||||
|
toolshed_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | unique }}"
|
||||||
|
# DoH resolvers the frontend falls back to for SRV lookups when it has
|
||||||
|
# no cached preference yet, served as a static /local/dns fixture. These
|
||||||
|
# match the frontend's own hardcoded fallback (frontend/src/dns.js), so
|
||||||
|
# this mostly makes the choice explicit and per-host overridable (e.g.
|
||||||
|
# -e doh_resolvers='["9.9.9.9"]') rather than changing behavior.
|
||||||
|
toolshed_doh_resolvers: "{{ doh_resolvers | default(['1.1.1.1', '8.8.8.8']) }}"
|
||||||
|
# Docker tags can't contain "/", but toolshed_version is a git ref and
|
||||||
|
# branch names like "jedi/proto/frontend" do - sanitize before using it
|
||||||
|
# as an image tag. The raw value is still used as-is for the actual git
|
||||||
|
# checkout, where slashes are fine.
|
||||||
|
toolshed_image_tag: "{{ (toolshed_version | default('stable')) | replace('/', '-') }}"
|
||||||
|
|
||||||
|
toolshed_debug: "False"
|
||||||
|
# Plain HTTP listen port. Only relevant behind an external proxy that
|
||||||
|
# forwards to something other than 80 (see http_port in inventory.yml);
|
||||||
|
# when this nginx terminates TLS itself, the public port is always 443.
|
||||||
|
toolshed_http_port: "{{ http_port | default(80) }}"
|
||||||
|
toolshed_letsencrypt_webroot: /var/www/letsencrypt
|
||||||
|
# Nginx sets its own X-Forwarded-Proto from $scheme when it terminates
|
||||||
|
# TLS itself. Behind an external TLS-terminating proxy, $scheme at this
|
||||||
|
# nginx is always "http" (the proxy already stripped TLS one hop
|
||||||
|
# earlier), so overwriting the header with $scheme would tell Django
|
||||||
|
# every request is insecure. In that case pass through the proxy's own
|
||||||
|
# header instead.
|
||||||
|
toolshed_x_forwarded_proto: >-
|
||||||
|
{{ '$http_x_forwarded_proto' if (behind_tls_proxy | default(false) | bool) else '$scheme' }}
|
||||||
|
# The web domain (toolshed_domain, mandatory) and the handle domain
|
||||||
|
# (toolshed_handle_domain, optional - defaults to the web domain when
|
||||||
|
# they're the same) both need to be accepted by nginx/Django, since
|
||||||
|
# either may show up as the Host header depending on how the admin set
|
||||||
|
# up DNS for this deployment. Deduplicated so setting them equal
|
||||||
|
# doesn't produce a repeated entry.
|
||||||
|
toolshed_hostnames: >-
|
||||||
|
{{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname),
|
||||||
|
toolshed_handle_domain | default(toolshed_domain)] | unique }}
|
||||||
|
# Generated once per host on the controller and reused on every
|
||||||
|
# subsequent run against that host, keyed by inventory_hostname so
|
||||||
|
# separate deployments never end up sharing a Django SECRET_KEY.
|
||||||
|
toolshed_secret_key: >-
|
||||||
|
{{ lookup('ansible.builtin.password',
|
||||||
|
playbook_dir ~ '/.secrets/' ~ inventory_hostname ~ '_secret_key length=64 chars=ascii_letters,digits') }}
|
||||||
|
|
||||||
|
# Rendered twice against the same var (see the tasks below): once before
|
||||||
|
# a certificate exists (serves the site plainly over toolshed_http_port,
|
||||||
|
# or over 80/plain-HTTP forever if behind_tls_proxy), and once after
|
||||||
|
# certbot has obtained one, at which point the plain HTTP vhost switches
|
||||||
|
# to a redirect and a 443 vhost with the real content appears. Whichever
|
||||||
|
# of those two states applies, toolshed_cert (a registered `stat` result,
|
||||||
|
# undefined/false until it's checked) decides which one renders - this
|
||||||
|
# is the "another nginx config" from a single inline template, driven by
|
||||||
|
# behind_tls_proxy and certificate state rather than a separate file.
|
||||||
|
toolshed_nginx_conf: |
|
||||||
|
upstream toolshed_backend {
|
||||||
|
server 127.0.0.1:{{ toolshed_backend_port }};
|
||||||
|
}
|
||||||
|
|
||||||
|
{% macro toolshed_locations() %}
|
||||||
|
location /api {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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 {{ toolshed_x_forwarded_proto }};
|
||||||
|
proxy_pass http://toolshed_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /auth {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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 {{ toolshed_x_forwarded_proto }};
|
||||||
|
proxy_pass http://toolshed_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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 {{ toolshed_x_forwarded_proto }};
|
||||||
|
proxy_pass http://toolshed_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /djangoadmin {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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 {{ toolshed_x_forwarded_proto }};
|
||||||
|
proxy_pass http://toolshed_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /docs {
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
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 {{ toolshed_x_forwarded_proto }};
|
||||||
|
proxy_pass http://toolshed_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
proxy_pass http://toolshed_backend/static;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /wiki/ {
|
||||||
|
alias {{ toolshed_wiki_dist_dir }}/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /wiki {
|
||||||
|
return 301 /wiki/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static fixtures the frontend fetches directly (registration
|
||||||
|
# domain list, DoH resolver preference) - see toolshed_register_domains
|
||||||
|
# and toolshed_doh_resolvers above.
|
||||||
|
location /local/ {
|
||||||
|
alias {{ toolshed_local_dir }}/;
|
||||||
|
try_files $uri.json =404;
|
||||||
|
add_header Content-Type application/json;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vue-router history mode: fall back to index.html for
|
||||||
|
# any path that isn't a real static file.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% if behind_tls_proxy | default(false) | bool %}
|
||||||
|
server {
|
||||||
|
listen {{ toolshed_http_port }};
|
||||||
|
listen [::]:{{ toolshed_http_port }};
|
||||||
|
server_name {{ toolshed_hostnames | join(' ') }};
|
||||||
|
|
||||||
|
client_max_body_size 128M;
|
||||||
|
root {{ toolshed_dist_dir }};
|
||||||
|
index index.html;
|
||||||
|
{{ toolshed_locations() }}
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
{% set tls_active = toolshed_cert.stat.exists | default(false) %}
|
||||||
|
server {
|
||||||
|
listen {{ toolshed_http_port }};
|
||||||
|
listen [::]:{{ toolshed_http_port }};
|
||||||
|
server_name {{ toolshed_hostnames | join(' ') }};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root {{ toolshed_letsencrypt_webroot }};
|
||||||
|
}
|
||||||
|
{% if tls_active %}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
client_max_body_size 128M;
|
||||||
|
root {{ toolshed_dist_dir }};
|
||||||
|
index index.html;
|
||||||
|
{{ toolshed_locations() }}
|
||||||
|
{% endif %}
|
||||||
|
}
|
||||||
|
{% if tls_active %}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name {{ toolshed_hostnames | join(' ') }};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 128M;
|
||||||
|
root {{ toolshed_dist_dir }};
|
||||||
|
index index.html;
|
||||||
|
{{ toolshed_locations() }}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Install docker.io and nginx
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- docker.io
|
||||||
|
- nginx
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: Install certbot
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: certbot
|
||||||
|
state: present
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Ensure docker is running and enabled
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Ensure nginx is running and enabled
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: nginx
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Checkout toolshed source
|
||||||
|
ansible.builtin.git:
|
||||||
|
repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}"
|
||||||
|
dest: "{{ toolshed_src_dir }}"
|
||||||
|
version: "{{ toolshed_version | default('stable') }}"
|
||||||
|
force: true
|
||||||
|
# frontend/extras is registered as a submodule but unused and its
|
||||||
|
# pinned commit isn't fetchable from upstream - don't let a broken
|
||||||
|
# submodule block the checkout.
|
||||||
|
recursive: false
|
||||||
|
|
||||||
|
- name: Create toolshed system user
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: toolshed
|
||||||
|
system: true
|
||||||
|
shell: /usr/sbin/nologin
|
||||||
|
home: "{{ toolshed_data_dir }}"
|
||||||
|
create_home: false
|
||||||
|
register: toolshed_user
|
||||||
|
|
||||||
|
- name: Create backend data directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: toolshed
|
||||||
|
group: toolshed
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- "{{ toolshed_data_dir }}"
|
||||||
|
- "{{ toolshed_data_dir }}/userfiles"
|
||||||
|
|
||||||
|
- name: Create frontend static output directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ toolshed_dist_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Write backend environment file
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ toolshed_data_dir }}/backend.env"
|
||||||
|
# Root-owned and unreadable by the toolshed user on purpose: this is
|
||||||
|
# read by the docker daemon (root) via --env-file at container
|
||||||
|
# start and injected directly as env vars, so the containerized app
|
||||||
|
# - which runs as the toolshed user, see the systemd unit below -
|
||||||
|
# never needs filesystem access to its own SECRET_KEY.
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
content: |
|
||||||
|
DEBUG={{ toolshed_debug }}
|
||||||
|
SECRET_KEY={{ toolshed_secret_key }}
|
||||||
|
ALLOWED_HOSTS={{ toolshed_hostnames | join(',') }}
|
||||||
|
SERVE_X_ACCEL_REDIRECT=False
|
||||||
|
TOOLSHED_DB_PATH=/data/db.sqlite3
|
||||||
|
TOOLSHED_USERFILES_PATH=/data/userfiles
|
||||||
|
notify: restart backend
|
||||||
|
|
||||||
|
- name: Build backend docker image
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >-
|
||||||
|
docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }}
|
||||||
|
-f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend
|
||||||
|
changed_when: true
|
||||||
|
notify: restart backend
|
||||||
|
|
||||||
|
- name: Tag backend image as latest
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker tag {{ toolshed_backend_image }}:{{ toolshed_image_tag }} {{ toolshed_backend_image }}:latest
|
||||||
|
changed_when: true
|
||||||
|
notify: restart backend
|
||||||
|
|
||||||
|
- name: Build frontend builder docker image
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >-
|
||||||
|
docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
|
||||||
|
-f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_src_dir }}/frontend
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Run frontend builder once to export the static build
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker run --rm -v {{ toolshed_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }}
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Fix ownership of exported frontend build
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ toolshed_dist_dir }}"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
recurse: true
|
||||||
|
|
||||||
|
- name: Create wiki static output directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ toolshed_wiki_dist_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Build wiki builder docker image
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >-
|
||||||
|
docker build -t {{ toolshed_wiki_image }}:{{ toolshed_image_tag }}
|
||||||
|
-f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.wiki {{ toolshed_src_dir }}
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Run wiki builder once to export the static site
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker run --rm -v {{ toolshed_wiki_dist_dir }}:/output {{ toolshed_wiki_image }}:{{ toolshed_image_tag }}
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Fix ownership of exported wiki build
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ toolshed_wiki_dist_dir }}"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
recurse: true
|
||||||
|
|
||||||
|
- name: Create local fixtures directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ toolshed_local_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Write registration domain list fixture
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ toolshed_local_dir }}/domains.json"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
content: "{{ toolshed_register_domains | to_nice_json }}"
|
||||||
|
|
||||||
|
- name: Write DoH resolver fixture
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ toolshed_local_dir }}/dns.json"
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0644"
|
||||||
|
content: "{{ toolshed_doh_resolvers | to_nice_json }}"
|
||||||
|
|
||||||
|
- name: Create ACME HTTP-01 challenge webroot
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ toolshed_letsencrypt_webroot }}"
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: "0755"
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Check for an existing Let's Encrypt certificate
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem"
|
||||||
|
register: toolshed_cert
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Configure nginx site for toolshed (bootstrap)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/nginx/sites-available/toolshed.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: "{{ toolshed_nginx_conf }}"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: Remove default nginx site
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/nginx/sites-enabled/default
|
||||||
|
state: absent
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: Enable toolshed nginx site
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: /etc/nginx/sites-available/toolshed.conf
|
||||||
|
dest: /etc/nginx/sites-enabled/toolshed.conf
|
||||||
|
state: link
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
# Certbot's webroot check (below) needs nginx already serving the
|
||||||
|
# bootstrap config from the tasks above, so force the reload now
|
||||||
|
# instead of waiting for the end of the play.
|
||||||
|
- name: Apply the bootstrap nginx config now
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
|
- name: Ensure the certbot renewal deploy-hook directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/letsencrypt/renewal-hooks/deploy
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Reload nginx after certbot renews a certificate
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
content: |
|
||||||
|
#!/bin/sh
|
||||||
|
systemctl reload nginx
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Obtain or renew the Let's Encrypt certificate
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >-
|
||||||
|
certbot certonly --webroot -w {{ toolshed_letsencrypt_webroot }}
|
||||||
|
-d {{ toolshed_hostnames | join(' -d ') }}
|
||||||
|
--non-interactive --agree-tos
|
||||||
|
-m {{ toolshed_letsencrypt_email | mandatory('toolshed_letsencrypt_email must be set as a host_var for ' ~ inventory_hostname ~ ' since behind_tls_proxy is false there') }}
|
||||||
|
register: toolshed_certbot
|
||||||
|
changed_when: "'Certificate not yet due for renewal' not in toolshed_certbot.stdout"
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Re-check the certificate now that certbot has run
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem"
|
||||||
|
register: toolshed_cert
|
||||||
|
when: not (behind_tls_proxy | default(false) | bool)
|
||||||
|
|
||||||
|
- name: Configure nginx site for toolshed (final)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/nginx/sites-available/toolshed.conf
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: "{{ toolshed_nginx_conf }}"
|
||||||
|
notify: reload nginx
|
||||||
|
|
||||||
|
- name: Install systemd unit for the backend container
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/toolshed-backend.service
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Toolshed backend (Django) container
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
TimeoutStartSec=0
|
||||||
|
Restart=always
|
||||||
|
ExecStartPre=-/usr/bin/docker stop {{ toolshed_backend_container }}
|
||||||
|
ExecStartPre=-/usr/bin/docker rm {{ toolshed_backend_container }}
|
||||||
|
ExecStart=/usr/bin/docker run --rm --name {{ toolshed_backend_container }} \
|
||||||
|
--user {{ toolshed_user.uid }}:{{ toolshed_user.group }} \
|
||||||
|
--env-file {{ toolshed_data_dir }}/backend.env \
|
||||||
|
-v {{ toolshed_data_dir }}:/data \
|
||||||
|
-p 127.0.0.1:{{ toolshed_backend_port }}:8000 \
|
||||||
|
{{ toolshed_backend_image }}:latest
|
||||||
|
ExecStop=/usr/bin/docker stop {{ toolshed_backend_container }}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
notify: restart backend
|
||||||
|
|
||||||
|
- name: Ensure toolshed-backend service is enabled and started
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: toolshed-backend
|
||||||
|
daemon_reload: true
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: validate nginx config
|
||||||
|
ansible.builtin.command: nginx -t
|
||||||
|
listen: reload nginx
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: reload nginx
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
|
listen: reload nginx
|
||||||
|
|
||||||
|
- name: restart backend
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: toolshed-backend
|
||||||
|
daemon_reload: true
|
||||||
|
state: restarted
|
||||||
|
listen: restart backend
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<Sidebar/>
|
<Sidebar/>
|
||||||
<div class="main">
|
<div class="main" id="page">
|
||||||
<nav class="navbar navbar-expand navbar-light navbar-bg">
|
<nav class="navbar navbar-expand navbar-light navbar-bg">
|
||||||
<a class="sidebar-toggle d-flex" @click="toggleSidebar">
|
<a class="sidebar-toggle d-flex" @click="toggleSidebar">
|
||||||
<i class="hamburger align-self-center"></i>
|
<i class="hamburger align-self-center"></i>
|
||||||
|
|
@ -55,6 +55,7 @@ export default {
|
||||||
toggleSidebar() {
|
toggleSidebar() {
|
||||||
closeAllDropdowns();
|
closeAllDropdowns();
|
||||||
document.getElementById("sidebar").classList.toggle("collapsed");
|
document.getElementById("sidebar").classList.toggle("collapsed");
|
||||||
|
document.getElementById("page").classList.toggle("expanded");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,20 @@ export default {
|
||||||
const jobs = [...files].map((file) => {
|
const jobs = [...files].map((file) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = async () => {
|
||||||
const buffer = reader.result;
|
const buffer = reader.result;
|
||||||
if (!(buffer instanceof ArrayBuffer)) {
|
if (!(buffer instanceof ArrayBuffer)) {
|
||||||
console.log(buffer)
|
console.log(buffer)
|
||||||
reject("Not an ArrayBuffer");
|
reject("Not an ArrayBuffer");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
// SHA-256 via Web Crypto - must match the backend's own content hash
|
||||||
|
// (files/models.py, hashlib.sha256) so a hash computed here can later be
|
||||||
|
// used to identify the same File row server-side without a mismatch.
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
var base64 = btoa(
|
var base64 = btoa(
|
||||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,20 @@ export default {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let reader = new FileReader();
|
let reader = new FileReader();
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
reader.onloadend = () => {
|
reader.onloadend = async () => {
|
||||||
const buffer = reader.result;
|
const buffer = reader.result;
|
||||||
if (!(buffer instanceof ArrayBuffer)) {
|
if (!(buffer instanceof ArrayBuffer)) {
|
||||||
console.log(buffer)
|
console.log(buffer)
|
||||||
reject("Not an ArrayBuffer");
|
reject("Not an ArrayBuffer");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
// SHA-256 via Web Crypto - must match the backend's own content hash
|
||||||
|
// (files/models.py, hashlib.sha256) so a hash computed here can later
|
||||||
|
// be used to identify the same File row server-side without a mismatch.
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
var base64 = btoa(
|
var base64 = btoa(
|
||||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,20 @@ export default {
|
||||||
const jobs = [...files].map((file) => {
|
const jobs = [...files].map((file) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = async () => {
|
||||||
const buffer = reader.result;
|
const buffer = reader.result;
|
||||||
if (!(buffer instanceof ArrayBuffer)) {
|
if (!(buffer instanceof ArrayBuffer)) {
|
||||||
console.log(buffer)
|
console.log(buffer)
|
||||||
reject("Not an ArrayBuffer");
|
reject("Not an ArrayBuffer");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const data = new Uint8Array(buffer);
|
const data = new Uint8Array(buffer);
|
||||||
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
// SHA-256 via Web Crypto - must match the backend's own content hash
|
||||||
|
// (files/models.py, hashlib.sha256) so a hash computed here can later be
|
||||||
|
// used to identify the same File row server-side without a mismatch.
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
var base64 = btoa(
|
var base64 = btoa(
|
||||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -171,11 +171,17 @@ export default {
|
||||||
this.dataImage = undefined;
|
this.dataImage = undefined;
|
||||||
this.open();
|
this.open();
|
||||||
},
|
},
|
||||||
save() {
|
async save() {
|
||||||
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
||||||
const data = this.dataImage.split(',')[1];
|
const data = this.dataImage.split(',')[1];
|
||||||
const raw_data = atob(data);
|
const raw_data = atob(data);
|
||||||
const hash = nacl.crypto_hash(raw_data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
// SHA-256 via Web Crypto - must match the backend's own content hash (files/models.py,
|
||||||
|
// hashlib.sha256) so a hash computed here can later be used to identify the same File
|
||||||
|
// row server-side without a mismatch.
|
||||||
|
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
const image = {
|
const image = {
|
||||||
name: hash.slice(0, 12) + ".jpg",
|
name: hash.slice(0, 12) + ".jpg",
|
||||||
size: raw_data.length,
|
size: raw_data.length,
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
|
||||||
export default {
|
export default {
|
||||||
name: 'BackupRestoreWorkflow',
|
name: 'BackupRestoreWorkflow',
|
||||||
meta: {
|
meta: {
|
||||||
id: 'backup-restore',
|
slug: 'backup-restore',
|
||||||
name: 'Data Backup',
|
title: 'Data Backup',
|
||||||
category: 'System Maintenance',
|
category: 'System Maintenance',
|
||||||
description: 'Create a comprehensive backup of your inventory and settings data.',
|
description: 'Create a comprehensive backup of your inventory and settings data.',
|
||||||
icons: ['b-icon-gear', 'b-icon-download'],
|
icons: ['b-icon-gear', 'b-icon-download'],
|
||||||
|
|
|
||||||
|
|
@ -279,8 +279,8 @@ export default {
|
||||||
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
|
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
|
||||||
// by the Workflows and WorkflowDetail views.
|
// by the Workflows and WorkflowDetail views.
|
||||||
meta: {
|
meta: {
|
||||||
id: 'import-items',
|
slug: 'import-items',
|
||||||
name: 'Bulk Item Import',
|
title: 'Bulk Item Import',
|
||||||
category: 'Data Management',
|
category: 'Data Management',
|
||||||
description: 'Import multiple inventory items from CSV or Excel files with validation.',
|
description: 'Import multiple inventory items from CSV or Excel files with validation.',
|
||||||
icons: ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'],
|
icons: ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'],
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
|
||||||
export default {
|
export default {
|
||||||
name: 'ExpiryCheckWorkflow',
|
name: 'ExpiryCheckWorkflow',
|
||||||
meta: {
|
meta: {
|
||||||
id: 'expiry-check',
|
slug: 'expiry-check',
|
||||||
name: 'Expiry Date Check',
|
title: 'Expiry Date Check',
|
||||||
category: 'Quality Control',
|
category: 'Quality Control',
|
||||||
description: 'Identify and handle items approaching or past their expiry dates.',
|
description: 'Identify and handle items approaching or past their expiry dates.',
|
||||||
icons: ['b-icon-clock-history', 'b-icon-exclamation-triangle'],
|
icons: ['b-icon-clock-history', 'b-icon-exclamation-triangle'],
|
||||||
|
|
|
||||||
|
|
@ -2,78 +2,36 @@
|
||||||
<div class="foto-first-workflow">
|
<div class="foto-first-workflow">
|
||||||
<!-- Step 1: Photo Capture -->
|
<!-- Step 1: Photo Capture -->
|
||||||
<div v-if="step === '1'" class="foto-first-step-1">
|
<div v-if="step === '1'" class="foto-first-step-1">
|
||||||
<div class="step-header mb-4">
|
|
||||||
<h4 class="mb-2">Photo Capture</h4>
|
|
||||||
<p class="text-muted">Capture or upload item photos to begin the import process.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="upload-area mb-4">
|
<div class="staging-area mb-4">
|
||||||
<div class="row">
|
<drag-drop-file-source @input="addStagedFiles">
|
||||||
<!-- Camera Capture -->
|
<div class="card">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="card-body text-center">
|
||||||
<div class="card h-100">
|
<b-icon-upload class="text-primary mb-2" style="font-size: 2.5rem;"></b-icon-upload>
|
||||||
<div class="card-body text-center">
|
<p class="text-muted small mb-3">Drag and drop photos here, or add them below</p>
|
||||||
<b-icon-camera class="text-primary mb-3" style="font-size: 3rem;"></b-icon-camera>
|
<div class="d-flex justify-content-center gap-2">
|
||||||
<h6>Camera Capture</h6>
|
<fs-file-source @input="addStagedFiles">
|
||||||
<p class="text-muted small">Use your device camera to capture photos</p>
|
<span class="btn btn-outline-success">
|
||||||
<button class="btn btn-primary" @click="startCamera" :disabled="loadingCamera">
|
<b-icon-upload class="me-1"></b-icon-upload>
|
||||||
<b-icon-camera class="me-1"></b-icon-camera>
|
Upload Files
|
||||||
Start Camera
|
</span>
|
||||||
</button>
|
</fs-file-source>
|
||||||
|
<camera-file-source @input="addStagedFiles">
|
||||||
|
<span class="btn btn-outline-primary">
|
||||||
|
<b-icon-camera class="me-1"></b-icon-camera>
|
||||||
|
Camera
|
||||||
|
</span>
|
||||||
|
</camera-file-source>
|
||||||
|
<webcam-file-source @input="addStagedFiles">
|
||||||
|
<span class="btn btn-outline-primary">
|
||||||
|
<b-icon-camera-video class="me-1"></b-icon-camera-video>
|
||||||
|
Webcam
|
||||||
|
</span>
|
||||||
|
</webcam-file-source>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</drag-drop-file-source>
|
||||||
<!-- File Upload -->
|
|
||||||
<div class="col-md-6 mb-3">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body text-center">
|
|
||||||
<b-icon-upload class="text-success mb-3" style="font-size: 3rem;"></b-icon-upload>
|
|
||||||
<h6>File Upload</h6>
|
|
||||||
<p class="text-muted small">Upload photos from your device</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref="fileInput"
|
|
||||||
multiple
|
|
||||||
accept="image/*"
|
|
||||||
@change="handleFileUpload"
|
|
||||||
class="d-none"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-success" @click="$refs.fileInput.click()" :disabled="loadingCamera">
|
|
||||||
<b-icon-upload class="me-1"></b-icon-upload>
|
|
||||||
Upload Photos
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Camera Preview -->
|
|
||||||
<div v-if="showCamera" class="camera-section mb-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h6 class="mb-0">Camera Preview</h6>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" @click="stopCamera">
|
|
||||||
<b-icon-x></b-icon-x>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="camera-container text-center">
|
|
||||||
<video ref="video" autoplay muted class="camera-preview mb-3"></video>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-primary me-2" @click="capturePhoto" :disabled="!cameraReady">
|
|
||||||
<b-icon-camera class="me-1"></b-icon-camera>
|
|
||||||
Capture Photo
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary" @click="stopCamera">
|
|
||||||
<b-icon-stop class="me-1"></b-icon-stop>
|
|
||||||
Stop Camera
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Photo Gallery -->
|
<!-- Photo Gallery -->
|
||||||
|
|
@ -88,7 +46,15 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img :src="photo.preview" class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
|
<div class="photo-thumb-wrap">
|
||||||
|
<transition name="photo-wipe">
|
||||||
|
<img v-if="photo.dataUrl && !photo.uploaded" key="local" :src="photo.dataUrl"
|
||||||
|
class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
|
||||||
|
<authenticated-image v-else key="remote" :src="thumbnailPathForHash(photo.hash)"
|
||||||
|
:owner="user" img-class="card-img-top photo-thumbnail"
|
||||||
|
:alt="`Photo ${index + 1}`"/>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<small class="text-muted">Photo {{ index + 1 }}</small>
|
<small class="text-muted">Photo {{ index + 1 }}</small>
|
||||||
|
|
@ -108,7 +74,7 @@
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="proceedFromStep1"
|
@click="proceedFromStep1"
|
||||||
:disabled="photos.length === 0 || loadingCamera"
|
:disabled="photos.length === 0"
|
||||||
>
|
>
|
||||||
Next: Process Images
|
Next: Process Images
|
||||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||||
|
|
@ -223,7 +189,8 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
<div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img :src="image.processedUrl" class="card-img-top processed-thumbnail" :alt="`Processed ${index + 1}`">
|
<img :src="image.processedUrl" class="card-img-top processed-thumbnail"
|
||||||
|
:alt="`Processed ${index + 1}`">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<small class="text-muted">{{ image.name }}</small>
|
<small class="text-muted">{{ image.name }}</small>
|
||||||
|
|
@ -233,10 +200,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="processing-info">
|
<div class="processing-info">
|
||||||
<small class="text-muted d-block">
|
<small class="text-muted d-block">
|
||||||
{{ formatFileSize(image.originalSize) }} → {{ formatFileSize(image.processedSize) }}
|
{{ formatFileSize(image.originalSize) }} →
|
||||||
|
{{ formatFileSize(image.processedSize) }}
|
||||||
</small>
|
</small>
|
||||||
<small class="text-success">
|
<small class="text-success">
|
||||||
{{ Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100) }}% reduced
|
{{
|
||||||
|
Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100)
|
||||||
|
}}% reduced
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -299,7 +269,8 @@
|
||||||
<!-- Image Preview -->
|
<!-- Image Preview -->
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image" alt="Current item">
|
<img :src="currentItem.processedUrl || currentItem.dataUrl" class="card-img-top item-image"
|
||||||
|
alt="Current item">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<small class="text-muted">{{ currentItem.name }}</small>
|
<small class="text-muted">{{ currentItem.name }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,9 +447,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showCompleted" class="card-body">
|
<div v-if="showCompleted" class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
<div v-for="(item, index) in completedItems" :key="index"
|
||||||
|
class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<img :src="item.image.processedUrl || item.image.preview" class="completed-item-thumb me-2" alt="Item">
|
<img :src="item.image.processedUrl || item.image.dataUrl"
|
||||||
|
class="completed-item-thumb me-2" alt="Item">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="fw-bold small">{{ item.details.name }}</div>
|
<div class="fw-bold small">{{ item.details.name }}</div>
|
||||||
<div class="text-muted small">{{ item.details.category || 'No category' }}</div>
|
<div class="text-muted small">{{ item.details.category || 'No category' }}</div>
|
||||||
|
|
@ -563,7 +536,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div v-if="Object.keys(categoryBreakdown).length > 0" class="row">
|
<div v-if="Object.keys(categoryBreakdown).length > 0" class="row">
|
||||||
<div v-for="(count, category) in categoryBreakdown" :key="category" class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
<div v-for="(count, category) in categoryBreakdown" :key="category"
|
||||||
|
class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-capitalize">{{ category || 'Uncategorized' }}</span>
|
<span class="text-capitalize">{{ category || 'Uncategorized' }}</span>
|
||||||
<span class="badge bg-secondary">{{ count }}</span>
|
<span class="badge bg-secondary">{{ count }}</span>
|
||||||
|
|
@ -663,15 +637,19 @@
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<div v-if="viewMode === 'grid'" class="row">
|
<div v-if="viewMode === 'grid'" class="row">
|
||||||
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
<div v-for="(item, index) in completedItems" :key="index"
|
||||||
|
class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<img :src="item.image.processedUrl || item.image.preview" class="card-img-top item-thumb" :alt="item.details.name">
|
<img :src="item.image.processedUrl || item.image.dataUrl"
|
||||||
|
class="card-img-top item-thumb" :alt="item.details.name">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<h6 class="card-title mb-1">{{ item.details.name }}</h6>
|
<h6 class="card-title mb-1">{{ item.details.name }}</h6>
|
||||||
<p class="card-text small text-muted mb-1">{{ item.details.category || 'No category' }}</p>
|
<p class="card-text small text-muted mb-1">
|
||||||
|
{{ item.details.category || 'No category' }}</p>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<small class="text-muted">Qty: {{ item.details.quantity }}</small>
|
<small class="text-muted">Qty: {{ item.details.quantity }}</small>
|
||||||
<small v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</small>
|
<small v-if="item.details.estimated_value"
|
||||||
|
class="text-success">${{ item.details.estimated_value }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -682,32 +660,35 @@
|
||||||
<div v-else class="table-responsive">
|
<div v-else class="table-responsive">
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Image</th>
|
<th>Image</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Quantity</th>
|
<th>Quantity</th>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(item, index) in completedItems" :key="index">
|
<tr v-for="(item, index) in completedItems" :key="index">
|
||||||
<td>
|
<td>
|
||||||
<img :src="item.image.processedUrl || item.image.preview" class="list-item-thumb" :alt="item.details.name">
|
<img :src="item.image.processedUrl || item.image.dataUrl"
|
||||||
</td>
|
class="list-item-thumb" :alt="item.details.name">
|
||||||
<td class="fw-bold">{{ item.details.name }}</td>
|
</td>
|
||||||
<td>
|
<td class="fw-bold">{{ item.details.name }}</td>
|
||||||
<span v-if="item.details.category" class="badge bg-light text-dark">{{ item.details.category }}</span>
|
<td>
|
||||||
<span v-else class="text-muted">-</span>
|
<span v-if="item.details.category"
|
||||||
</td>
|
class="badge bg-light text-dark">{{ item.details.category }}</span>
|
||||||
<td>{{ item.details.quantity }} {{ item.details.unit }}</td>
|
<span v-else class="text-muted">-</span>
|
||||||
<td>{{ item.details.location || '-' }}</td>
|
</td>
|
||||||
<td>
|
<td>{{ item.details.quantity }} {{ item.details.unit }}</td>
|
||||||
<span v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</span>
|
<td>{{ item.details.location || '-' }}</td>
|
||||||
<span v-else class="text-muted">-</span>
|
<td>
|
||||||
</td>
|
<span v-if="item.details.estimated_value"
|
||||||
</tr>
|
class="text-success">${{ item.details.estimated_value }}</span>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -722,7 +703,8 @@
|
||||||
<b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle>
|
<b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle>
|
||||||
<h5 class="mb-3">Ready to Complete Import</h5>
|
<h5 class="mb-3">Ready to Complete Import</h5>
|
||||||
<p class="text-muted mb-4">
|
<p class="text-muted mb-4">
|
||||||
All {{ finalTotalItems }} items have been processed and are ready to be added to your inventory.
|
All {{ finalTotalItems }} items have been processed and are ready to be added to your
|
||||||
|
inventory.
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex justify-content-center gap-3">
|
<div class="d-flex justify-content-center gap-3">
|
||||||
|
|
@ -755,38 +737,30 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import {mapActions, mapState} from "vuex";
|
||||||
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
||||||
|
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
||||||
|
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||||
|
import WebcamFileSource from "@/components/inputs/WebcamFileSource.vue";
|
||||||
|
|
||||||
/**
|
|
||||||
* Foto First Bulk Import Workflow
|
|
||||||
*
|
|
||||||
* This single component implements every step of the 'foto-first-bulk-import'
|
|
||||||
* workflow (photo capture, image processing, item detail entry and import
|
|
||||||
* completion). Keeping the whole workflow in one file avoids splitting
|
|
||||||
* closely related state (photos, processed images, completed items) across
|
|
||||||
* many small step components and their prop/emit boundaries.
|
|
||||||
*/
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FotoFirstBulkImportWorkflow',
|
name: 'FotoFirstBulkImportWorkflow',
|
||||||
// Metadata describing this workflow, co-located with its implementation
|
|
||||||
// so there is a single source of truth per workflow type. Consumed by
|
|
||||||
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
|
|
||||||
// by the Workflows and WorkflowDetail views.
|
|
||||||
meta: {
|
meta: {
|
||||||
id: 'foto-first-bulk-import',
|
slug: 'foto-first-bulk-import',
|
||||||
name: 'Foto First Bulk Import',
|
title: 'Foto First Bulk Import',
|
||||||
category: 'Data Management',
|
category: 'Data Management',
|
||||||
description: 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.',
|
description: 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.',
|
||||||
icons: ['b-icon-camera', 'b-icon-pencil-square'],
|
icons: ['b-icon-camera', 'b-icon-pencil-square'],
|
||||||
estimatedDuration: '10-60 minutes',
|
estimatedDuration: '10-60 minutes',
|
||||||
stepDefinitions: [
|
stepDefinitions: [
|
||||||
{ step: '1', name: 'Photo Capture', description: 'Capture or upload item photos' },
|
{step: '1', name: 'Photo Capture', description: 'Capture or upload item photos'},
|
||||||
{ step: '2', name: 'Image Processing', description: 'Process and optimize images' },
|
{step: '2', name: 'Image Processing', description: 'Process and optimize images'},
|
||||||
{ step: '3', name: 'Item Details Entry', description: 'Enter details for each photographed item' },
|
{step: '3', name: 'Item Details Entry', description: 'Enter details for each photographed item'},
|
||||||
{ step: '4', name: 'Import Completion', description: 'Finalize and save imported items' }
|
{step: '4', name: 'Import Completion', description: 'Finalize and save imported items'}
|
||||||
],
|
],
|
||||||
getInitialPayload() {
|
getInitialPayload() {
|
||||||
return {
|
return {
|
||||||
photos: [],
|
|
||||||
processing_options: {
|
processing_options: {
|
||||||
auto_rotate: true,
|
auto_rotate: true,
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|
@ -797,6 +771,11 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
WebcamFileSource,
|
||||||
|
AuthenticatedImage,
|
||||||
|
DragDropFileSource,
|
||||||
|
CameraFileSource,
|
||||||
|
FsFileSource,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -816,11 +795,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
// Step 1: photo capture
|
// Step 1: photo capture
|
||||||
loadingCamera: false,
|
|
||||||
showCamera: false,
|
|
||||||
cameraReady: false,
|
|
||||||
photos: [],
|
photos: [],
|
||||||
stream: null,
|
|
||||||
|
|
||||||
// Step 2: image processing
|
// Step 2: image processing
|
||||||
processing: false,
|
processing: false,
|
||||||
|
|
@ -852,6 +827,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
...mapState(['user']),
|
||||||
// Step 1/2
|
// Step 1/2
|
||||||
totalPhotos() {
|
totalPhotos() {
|
||||||
return this.photos.length;
|
return this.photos.length;
|
||||||
|
|
@ -905,7 +881,6 @@ export default {
|
||||||
this.loadFromPayload();
|
this.loadFromPayload();
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.stopCamera();
|
|
||||||
this.processedImages.forEach(image => {
|
this.processedImages.forEach(image => {
|
||||||
if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
|
if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(image.processedUrl);
|
URL.revokeObjectURL(image.processedUrl);
|
||||||
|
|
@ -913,108 +888,108 @@ export default {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
...mapActions(['stageFile', 'unstageFile']),
|
||||||
loadFromPayload() {
|
loadFromPayload() {
|
||||||
if (this.payload.photos) this.photos = [...this.payload.photos];
|
// `photos`' durable state is the WorkflowInstance.staged_files relation itself (kept
|
||||||
|
// in sync directly by stageFile()/unstageFile(), not by writing to payload) - so it's
|
||||||
|
// seeded from the prop, not from payload. Entries restored this way have no local
|
||||||
|
// bytes yet (this session never uploaded them), so `dataUrl` stays null - the gallery
|
||||||
|
// falls back to fetching a thumbnail by hash via AuthenticatedImage (see below).
|
||||||
|
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
|
||||||
|
hash,
|
||||||
|
name: null,
|
||||||
|
size: null,
|
||||||
|
mime_type: null,
|
||||||
|
dataUrl: null,
|
||||||
|
uploaded: true,
|
||||||
|
timestamp: null
|
||||||
|
}));
|
||||||
if (this.payload.processing_options) {
|
if (this.payload.processing_options) {
|
||||||
this.processingOptions = { ...this.processingOptions, ...this.payload.processing_options };
|
this.processingOptions = {...this.processingOptions, ...this.payload.processing_options};
|
||||||
}
|
}
|
||||||
if (this.payload.processed_images) {
|
if (this.payload.processed_images) {
|
||||||
this.processedImages = [...this.payload.processed_images];
|
this.processedImages = [...this.payload.processed_images];
|
||||||
this.processedCount = this.processedImages.length;
|
this.processedCount = this.processedImages.length;
|
||||||
}
|
}
|
||||||
if (this.payload.completed_items) this.completedItems = [...this.payload.completed_items];
|
if (this.payload.completed_items) this.completedItems = [...this.payload.completed_items];
|
||||||
if (this.payload.current_item_details) this.currentItemDetails = { ...this.payload.current_item_details };
|
if (this.payload.current_item_details) this.currentItemDetails = {...this.payload.current_item_details};
|
||||||
if (this.payload.current_item_index !== undefined) this.currentItemIndex = this.payload.current_item_index;
|
if (this.payload.current_item_index !== undefined) this.currentItemIndex = this.payload.current_item_index;
|
||||||
if (this.payload.import_options) this.importOptions = { ...this.importOptions, ...this.payload.import_options };
|
if (this.payload.import_options) this.importOptions = {...this.importOptions, ...this.payload.import_options};
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Step 1: Photo capture ---
|
// --- Step 1: Photo capture ---
|
||||||
async startCamera() {
|
thumbnailPathForHash(hash, size = 256) {
|
||||||
try {
|
// files/media_urls.py's thumbnail_urls generates (and disk-caches) a resized JPEG
|
||||||
this.loadingCamera = true;
|
// on first request - a gallery card only needs a small image, not the full-size
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
// original. Looked up by the derived storage path, mirroring hash_upload()
|
||||||
video: { facingMode: 'environment' }
|
// (files/models.py) - matches how FileSerializer.name already builds file URLs
|
||||||
});
|
// elsewhere in the app (e.g. AuthenticatedImage's `src` for item files).
|
||||||
this.$refs.video.srcObject = this.stream;
|
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||||
this.showCamera = true;
|
|
||||||
this.cameraReady = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error accessing camera:', error);
|
|
||||||
alert('Could not access camera. Please check permissions or use file upload instead.');
|
|
||||||
} finally {
|
|
||||||
this.loadingCamera = false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
stopCamera() {
|
async addStagedFiles(files) {
|
||||||
if (this.stream) {
|
const new_files = files.filter(file => !this.photos.find(photo => photo.hash === file.hash));
|
||||||
this.stream.getTracks().forEach(track => track.stop());
|
if (new_files.length === 0) return;
|
||||||
this.stream = null;
|
|
||||||
}
|
|
||||||
this.showCamera = false;
|
|
||||||
this.cameraReady = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
capturePhoto() {
|
const staged = new_files.map(file => ({
|
||||||
if (!this.cameraReady) return;
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
mime_type: file.mime_type,
|
||||||
|
hash: file.hash, // SHA-256, same algorithm the backend hashes File content with
|
||||||
|
data: file.data,
|
||||||
|
dataUrl: `data:${file.mime_type};base64,${file.data}`,
|
||||||
|
uploaded: false,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
this.photos.push(...staged);
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
// Persist each photo server-side right away, keyed to this workflow instance, so it
|
||||||
const video = this.$refs.video;
|
// survives a reload or a switch to another device. WorkflowInstance.staged_files is
|
||||||
canvas.width = video.videoWidth;
|
// the durable record of this - nothing about photos needs to go into payload too.
|
||||||
canvas.height = video.videoHeight;
|
await Promise.all(staged.map(async ({hash, data, mime_type}) => {
|
||||||
|
try {
|
||||||
const ctx = canvas.getContext('2d');
|
await this.stageFile({
|
||||||
ctx.drawImage(video, 0, 0);
|
lifetime_id: this.workflowInstance.id,
|
||||||
|
file: {data, mime_type}
|
||||||
canvas.toBlob(blob => {
|
});
|
||||||
const photo = {
|
// Once persisted, the gallery can show the server-fetched thumbnail instead
|
||||||
file: blob,
|
// of the local dataUrl (kept around for step 2's client-side processing).
|
||||||
preview: URL.createObjectURL(blob),
|
// Re-lookup by hash rather than mutating the closed-over `photo` object -
|
||||||
name: `camera-photo-${Date.now()}.jpg`,
|
// that reference predates this.photos.push() above, so it's the raw object,
|
||||||
timestamp: new Date().toISOString()
|
// not the reactive proxy Vue tracks; writing to it wouldn't trigger a
|
||||||
};
|
// re-render.
|
||||||
this.photos.push(photo);
|
const photo = this.photos.find(p => p.hash === hash);
|
||||||
this.updatePhotosPayload();
|
if (photo) photo.uploaded = true;
|
||||||
}, 'image/jpeg', 0.8);
|
} catch (error) {
|
||||||
},
|
console.error('Failed to stage photo:', error);
|
||||||
|
this.photos = this.photos.filter(p => p.hash !== hash);
|
||||||
handleFileUpload(event) {
|
|
||||||
const files = Array.from(event.target.files);
|
|
||||||
files.forEach(file => {
|
|
||||||
if (file.type.startsWith('image/')) {
|
|
||||||
const photo = {
|
|
||||||
file: file,
|
|
||||||
preview: URL.createObjectURL(file),
|
|
||||||
name: file.name,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
this.photos.push(photo);
|
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
this.updatePhotosPayload();
|
|
||||||
event.target.value = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
removePhoto(index) {
|
async removePhoto(index) {
|
||||||
URL.revokeObjectURL(this.photos[index].preview);
|
const [photo] = this.photos.splice(index, 1);
|
||||||
this.photos.splice(index, 1);
|
if (photo) {
|
||||||
this.updatePhotosPayload();
|
try {
|
||||||
},
|
await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash});
|
||||||
|
} catch (error) {
|
||||||
clearAllPhotos() {
|
console.error('Failed to unstage photo:', error);
|
||||||
if (confirm('Are you sure you want to remove all photos?')) {
|
}
|
||||||
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
|
|
||||||
this.photos = [];
|
|
||||||
this.updatePhotosPayload();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePhotosPayload() {
|
async clearAllPhotos() {
|
||||||
this.$emit('update', { photos: this.photos });
|
if (confirm('Are you sure you want to remove all photos?')) {
|
||||||
|
const removed = this.photos;
|
||||||
|
this.photos = [];
|
||||||
|
await Promise.all(removed.map(photo =>
|
||||||
|
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
||||||
|
.catch(error => console.error('Failed to unstage photo:', error))
|
||||||
|
));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
proceedFromStep1() {
|
proceedFromStep1() {
|
||||||
this.updatePhotosPayload();
|
|
||||||
this.$emit('next');
|
this.$emit('next');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1060,7 +1035,7 @@ export default {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
// Calculate new dimensions
|
// Calculate new dimensions
|
||||||
let { width, height } = this.calculateDimensions(
|
let {width, height} = this.calculateDimensions(
|
||||||
img.width,
|
img.width,
|
||||||
img.height,
|
img.height,
|
||||||
this.processingOptions.max_width,
|
this.processingOptions.max_width,
|
||||||
|
|
@ -1076,7 +1051,7 @@ export default {
|
||||||
canvas.toBlob(blob => {
|
canvas.toBlob(blob => {
|
||||||
const processedImage = {
|
const processedImage = {
|
||||||
name: photo.name,
|
name: photo.name,
|
||||||
originalSize: photo.file.size,
|
originalSize: photo.size,
|
||||||
processedSize: blob.size,
|
processedSize: blob.size,
|
||||||
processedUrl: URL.createObjectURL(blob),
|
processedUrl: URL.createObjectURL(blob),
|
||||||
processedFile: blob,
|
processedFile: blob,
|
||||||
|
|
@ -1085,7 +1060,7 @@ export default {
|
||||||
resolve(processedImage);
|
resolve(processedImage);
|
||||||
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
|
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
|
||||||
};
|
};
|
||||||
img.src = photo.preview;
|
img.src = photo.dataUrl;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1103,7 +1078,7 @@ export default {
|
||||||
height = maxHeight;
|
height = maxHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { width: Math.round(width), height: Math.round(height) };
|
return {width: Math.round(width), height: Math.round(height)};
|
||||||
},
|
},
|
||||||
|
|
||||||
formatFileSize(bytes) {
|
formatFileSize(bytes) {
|
||||||
|
|
@ -1149,7 +1124,7 @@ export default {
|
||||||
|
|
||||||
const itemData = {
|
const itemData = {
|
||||||
image: this.currentItem,
|
image: this.currentItem,
|
||||||
details: { ...this.currentItemDetails },
|
details: {...this.currentItemDetails},
|
||||||
saved_at: new Date().toISOString()
|
saved_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1187,7 +1162,7 @@ export default {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
this.currentItemDetails = { ...existingItem.details };
|
this.currentItemDetails = {...existingItem.details};
|
||||||
} else {
|
} else {
|
||||||
this.currentItemDetails = this.getDefaultItemDetails();
|
this.currentItemDetails = this.getDefaultItemDetails();
|
||||||
}
|
}
|
||||||
|
|
@ -1199,7 +1174,7 @@ export default {
|
||||||
const itemIndex = this.availableItems.findIndex(img => img === item.image);
|
const itemIndex = this.availableItems.findIndex(img => img === item.image);
|
||||||
if (itemIndex >= 0) {
|
if (itemIndex >= 0) {
|
||||||
this.currentItemIndex = itemIndex;
|
this.currentItemIndex = itemIndex;
|
||||||
this.currentItemDetails = { ...item.details };
|
this.currentItemDetails = {...item.details};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1273,30 +1248,59 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.camera-preview {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 400px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-thumbnail,
|
.photo-thumbnail,
|
||||||
.item-thumb {
|
.item-thumb {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area .card {
|
/* Stacks the local dataUrl preview and the server-fetched AuthenticatedImage on top of each
|
||||||
|
other during their crossfade, instead of one disappearing before the other lays out. */
|
||||||
|
.photo-thumb-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-thumb-wrap .photo-thumbnail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wipe-enter-active,
|
||||||
|
.photo-wipe-leave-active {
|
||||||
|
transition: clip-path 0.5s ease, opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wipe-enter-from {
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wipe-enter-to {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wipe-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wipe-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staging-area .card {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area .card:hover {
|
.staging-area .card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processed-thumbnail {
|
.processed-thumbnail {
|
||||||
height: 120px;
|
height: 120px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
@ -1349,4 +1353,3 @@ export default {
|
||||||
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
|
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
|
||||||
export default {
|
export default {
|
||||||
name: 'InventoryAuditWorkflow',
|
name: 'InventoryAuditWorkflow',
|
||||||
meta: {
|
meta: {
|
||||||
id: 'inventory-audit',
|
slug: 'inventory-audit',
|
||||||
name: 'Inventory Audit',
|
title: 'Inventory Audit',
|
||||||
category: 'Inventory Management',
|
category: 'Inventory Management',
|
||||||
description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.',
|
description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.',
|
||||||
icons: ['b-icon-list-ul'],
|
icons: ['b-icon-list-ul'],
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
|
||||||
export default {
|
export default {
|
||||||
name: 'MaintenanceScheduleWorkflow',
|
name: 'MaintenanceScheduleWorkflow',
|
||||||
meta: {
|
meta: {
|
||||||
id: 'maintenance-schedule',
|
slug: 'maintenance-schedule',
|
||||||
name: 'Maintenance Schedule',
|
title: 'Maintenance Schedule',
|
||||||
category: 'Tool Maintenance',
|
category: 'Tool Maintenance',
|
||||||
description: 'Create and execute maintenance schedules for tools and equipment.',
|
description: 'Create and execute maintenance schedules for tools and equipment.',
|
||||||
icons: ['b-icon-tools', 'b-icon-calendar'],
|
icons: ['b-icon-tools', 'b-icon-calendar'],
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
|
||||||
export default {
|
export default {
|
||||||
name: 'StorageOptimizationWorkflow',
|
name: 'StorageOptimizationWorkflow',
|
||||||
meta: {
|
meta: {
|
||||||
id: 'storage-optimization',
|
slug: 'storage-optimization',
|
||||||
name: 'Storage Optimization',
|
title: 'Storage Optimization',
|
||||||
category: 'Storage Management',
|
category: 'Storage Management',
|
||||||
description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.',
|
description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.',
|
||||||
icons: ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'],
|
icons: ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'],
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,40 @@ body {
|
||||||
background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg));
|
background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg));
|
||||||
border-bottom-width: 1px !important;
|
border-bottom-width: 1px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (min-width: map-get($grid-breakpoints, xl)) {
|
||||||
|
.main.expanded {
|
||||||
|
.col-xl-12-ex {
|
||||||
|
flex: 0 0 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-xl-9-ex {
|
||||||
|
flex: 0 0 75% !important;
|
||||||
|
max-width: 75% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-xl-block-ex {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: map-get($grid-breakpoints, lg)) {
|
||||||
|
.main.expanded {
|
||||||
|
.col-lg-12-ex {
|
||||||
|
flex: 0 0 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-lg-9-ex {
|
||||||
|
flex: 0 0 75% !important;
|
||||||
|
max-width: 75% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-lg-block-ex {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -488,6 +488,26 @@ export default createStore({
|
||||||
await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/')
|
await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/')
|
||||||
state.files = state.files.filter(file => file.id !== file_id)
|
state.files = state.files.filter(file => file.id !== file_id)
|
||||||
},
|
},
|
||||||
|
async stageFile({state, dispatch, getters}, {lifetime_id, file}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file)
|
||||||
|
if (data.hash) {
|
||||||
|
return data.hash
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/')
|
||||||
|
},
|
||||||
|
async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash})
|
||||||
|
if (data.name) {
|
||||||
|
data.owner = state.user
|
||||||
|
state.files.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
},
|
||||||
async fetchTags({state, commit, dispatch, getters}) {
|
async fetchTags({state, commit, dispatch, getters}) {
|
||||||
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
|
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
|
||||||
return state.tags
|
return state.tags
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,11 @@
|
||||||
<li class="breadcrumb-item">
|
<li class="breadcrumb-item">
|
||||||
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
|
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.name || workflowInstance?.name || 'Loading...' }}</li>
|
<li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.title || workflowInstance?.title || 'Loading...' }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 class="h3 mb-0">{{ workflowDefinition?.name || workflowInstance?.name || 'Workflow Detail' }}</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group breadcrumb" role="group">
|
||||||
<button class="btn btn-outline-secondary" @click="$router.go(-1)">
|
<button class="btn btn-outline-secondary" @click="$router.go(-1)">
|
||||||
<b-icon-arrow-left class="me-1"></b-icon-arrow-left>
|
<b-icon-arrow-left class="me-1"></b-icon-arrow-left>
|
||||||
Back
|
Back
|
||||||
|
|
@ -43,8 +42,86 @@
|
||||||
|
|
||||||
<!-- Workflow Content -->
|
<!-- Workflow Content -->
|
||||||
<div v-else-if="workflowInstance" class="row">
|
<div v-else-if="workflowInstance" class="row">
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="col-xl-9 col-lg-12 col-lg-9-ex">
|
||||||
|
<!-- 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="workflowComponent"
|
||||||
|
:is="workflowComponent"
|
||||||
|
:workflow-instance="workflowInstance"
|
||||||
|
:step="currentStep"
|
||||||
|
:payload="workflowInstance.payload"
|
||||||
|
@update="handleStepUpdate"
|
||||||
|
@next="handleNextStep"
|
||||||
|
@prev="handlePrevStep"
|
||||||
|
@complete="completeWorkflow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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="true" 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>
|
||||||
<!-- Workflow Progress Sidebar -->
|
<!-- Workflow Progress Sidebar -->
|
||||||
<div class="col-lg-3 mb-4">
|
<div class="col-lg-3 mb-4 d-none d-xl-block d-lg-block-ex">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h6 class="card-title mb-0">Progress Overview</h6>
|
<h6 class="card-title mb-0">Progress Overview</h6>
|
||||||
|
|
@ -113,85 +190,6 @@
|
||||||
</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="workflowComponent"
|
|
||||||
:is="workflowComponent"
|
|
||||||
:workflow-instance="workflowInstance"
|
|
||||||
:step="currentStep"
|
|
||||||
:payload="workflowInstance.payload"
|
|
||||||
@update="handleStepUpdate"
|
|
||||||
@next="handleNextStep"
|
|
||||||
@prev="handlePrevStep"
|
|
||||||
@complete="completeWorkflow"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -217,7 +215,7 @@ export default {
|
||||||
},
|
},
|
||||||
step: {
|
step: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "initial"
|
default: "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -232,13 +230,17 @@ export default {
|
||||||
...mapState(['active_workflows']),
|
...mapState(['active_workflows']),
|
||||||
|
|
||||||
currentStep() {
|
currentStep() {
|
||||||
return this.step || "initial";
|
return this.step || "1";
|
||||||
},
|
},
|
||||||
|
|
||||||
workflowDefinition() {
|
workflowDefinition() {
|
||||||
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
||||||
if (!this.workflowInstance?.name) return null;
|
if (!this.workflowInstance?.slug) return null;
|
||||||
return getWorkflow(this.workflowInstance.name);
|
return getWorkflow(this.workflowInstance.slug);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWorkflowDisplayName() {
|
||||||
|
return getWorkflow(this.workflowInstance.slug)?.title;
|
||||||
},
|
},
|
||||||
|
|
||||||
stepDefinitions() {
|
stepDefinitions() {
|
||||||
|
|
@ -261,7 +263,7 @@ export default {
|
||||||
// Return the single component implementing the whole workflow, if any,
|
// Return the single component implementing the whole workflow, if any,
|
||||||
// using the workflow component registry. The component itself decides
|
// using the workflow component registry. The component itself decides
|
||||||
// what to render based on the `step` prop it receives.
|
// what to render based on the `step` prop it receives.
|
||||||
const workflowType = this.workflowInstance?.name;
|
const workflowType = this.workflowInstance?.slug;
|
||||||
return workflowType ? getWorkflowComponent(workflowType) : null;
|
return workflowType ? getWorkflowComponent(workflowType) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -506,4 +508,8 @@ export default {
|
||||||
.step-navigation {
|
.step-navigation {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group.breadcrumb{
|
||||||
|
padding: calc(0.5rem - 1px) 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -16,49 +16,51 @@
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Workflow</th>
|
<th>Workflow</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Progress</th>
|
<th>Progress</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="workflow in activeWorkflows" :key="workflow.id">
|
<tr v-for="workflow in activeWorkflows" :key="workflow.id">
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ getWorkflowDisplayName(workflow) }}</strong>
|
<strong>{{ getWorkflowDisplayName(workflow) }}</strong>
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">{{ workflow.payload?.workflow_config?.category || 'System' }}</small>
|
<small class="text-muted">{{
|
||||||
</td>
|
workflow.payload?.workflow_config?.category || 'System'
|
||||||
<td>
|
}}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
<span :class="getStatusBadgeClass(workflow.state)">
|
<span :class="getStatusBadgeClass(workflow.state)">
|
||||||
{{ workflow.status_display || workflow.state }}
|
{{ workflow.status_display || workflow.state }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="progress" style="height: 8px;">
|
<div class="progress" style="height: 8px;">
|
||||||
<div class="progress-bar"
|
<div class="progress-bar"
|
||||||
:style="{ width: workflow.progress_percentage + '%' }"
|
:style="{ width: workflow.progress_percentage + '%' }"
|
||||||
:class="getProgressBarClass(workflow.state)">
|
:class="getProgressBarClass(workflow.state)">
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ workflow.progress_percentage }}%</small>
|
</div>
|
||||||
</td>
|
<small class="text-muted">{{ workflow.progress_percentage }}%</small>
|
||||||
<td>{{ formatDate(workflow.started_at) }}</td>
|
</td>
|
||||||
<td>
|
<td>{{ formatDate(workflow.started_at) }}</td>
|
||||||
<button class="btn btn-sm btn-outline-primary me-1"
|
<td>
|
||||||
@click="viewWorkflowDetails(workflow)"
|
<button class="btn btn-sm btn-outline-primary me-1"
|
||||||
:disabled="loading">
|
@click="viewWorkflowDetails(workflow)"
|
||||||
<b-icon-eye></b-icon-eye>
|
:disabled="loading">
|
||||||
</button>
|
<b-icon-eye></b-icon-eye>
|
||||||
<button class="btn btn-sm btn-outline-danger"
|
</button>
|
||||||
@click="abortWorkflowInstance(workflow)"
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
:disabled="loading">
|
@click="abortWorkflowInstance(workflow)"
|
||||||
<b-icon-x-circle></b-icon-x-circle>
|
:disabled="loading">
|
||||||
</button>
|
<b-icon-x-circle></b-icon-x-circle>
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,21 +79,25 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-for="workflow in availableWorkflows" :key="workflow.id" class="col-lg-4 col-md-6 mb-3">
|
<div v-for="workflow in availableWorkflows" :key="workflow.id"
|
||||||
|
class="col-lg-4 col-md-6 mb-3">
|
||||||
<div class="card h-100 workflow-card">
|
<div class="card h-100 workflow-card">
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<template v-for="(icon, index) in workflow.icons" :key="icon">
|
<template v-for="(icon, index) in workflow.icons" :key="icon">
|
||||||
<div class="workflow-icon me-3">
|
<div class="workflow-icon me-3">
|
||||||
<component :is="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 -->
|
<!-- Add arrow between icons, but not after the last one -->
|
||||||
<div v-if="index < workflow.icons.length - 1" class="workflow-arrow me-3">
|
<div v-if="index < workflow.icons.length - 1"
|
||||||
<b-icon-arrow-right class="text-muted" style="font-size: 1rem;"></b-icon-arrow-right>
|
class="workflow-arrow me-3">
|
||||||
|
<b-icon-arrow-right class="text-muted"
|
||||||
|
style="font-size: 1rem;"></b-icon-arrow-right>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="workflow-header">
|
<div class="workflow-header">
|
||||||
<h6 class="card-title mb-1">{{ workflow.name }}</h6>
|
<h6 class="card-title mb-1">{{ workflow.title }}</h6>
|
||||||
<small class="text-muted">{{ workflow.category }}</small>
|
<small class="text-muted">{{ workflow.category }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,10 +111,9 @@
|
||||||
<span class="badge bg-light text-dark">{{ workflow.steps }} steps</span>
|
<span class="badge bg-light text-dark">{{ workflow.steps }} steps</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary w-100"
|
<button class="btn btn-primary w-100"
|
||||||
@click="startWorkflow(workflow)"
|
@click="startWorkflow(workflow)">
|
||||||
:disabled="isWorkflowRunning(workflow.id)">
|
|
||||||
<b-icon-play-fill class="me-1"></b-icon-play-fill>
|
<b-icon-play-fill class="me-1"></b-icon-play-fill>
|
||||||
{{ isWorkflowRunning(workflow.id) ? 'Running...' : 'Start Workflow' }}
|
Start Workflow
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,8 +132,8 @@
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import { mapState, mapActions } from 'vuex';
|
import {mapState, mapActions} from 'vuex';
|
||||||
import { getAllWorkflows, getWorkflow, buildWorkflowApiPayload } from '@/workflows.js';
|
import {getAllWorkflows, getWorkflow, buildWorkflowApiPayload} from '@/workflows.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Workflows',
|
name: 'Workflows',
|
||||||
|
|
@ -213,15 +218,8 @@ export default {
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
isWorkflowRunning(workflowId) {
|
|
||||||
return this.activeWorkflows.some(active =>
|
|
||||||
active.name === workflowId &&
|
|
||||||
active.state === 'running'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getWorkflowDisplayName(workflow) {
|
getWorkflowDisplayName(workflow) {
|
||||||
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
return getWorkflow(workflow.slug)?.title;
|
||||||
return getWorkflow(workflow.name)?.name || workflow.name;
|
|
||||||
},
|
},
|
||||||
async startWorkflow(workflow) {
|
async startWorkflow(workflow) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -232,11 +230,10 @@ export default {
|
||||||
const workflowData = buildWorkflowApiPayload(workflow);
|
const workflowData = buildWorkflowApiPayload(workflow);
|
||||||
|
|
||||||
const newWorkflow = await this.createWorkflow(workflowData);
|
const newWorkflow = await this.createWorkflow(workflowData);
|
||||||
console.log('Workflow started successfully:', newWorkflow);
|
|
||||||
|
|
||||||
// Immediately navigate to the workflow detail view
|
// Immediately navigate to the workflow detail view
|
||||||
// Get the first step from the workflow definition
|
// Get the first step from the workflow definition
|
||||||
const firstStep = workflow.stepDefinitions?.[0]?.step || "initial";
|
const firstStep = workflow.stepDefinitions?.[0]?.step || 1;
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
name: 'workflow-detail',
|
name: 'workflow-detail',
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -257,9 +254,9 @@ export default {
|
||||||
// Navigate to the workflow detail view
|
// Navigate to the workflow detail view
|
||||||
// Use the workflow's current step if available, otherwise use the first step
|
// Use the workflow's current step if available, otherwise use the first step
|
||||||
const currentStep = workflow.current_step ||
|
const currentStep = workflow.current_step ||
|
||||||
workflow.payload?.current_step ||
|
workflow.payload?.current_step ||
|
||||||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
||||||
"initial";
|
"initial";
|
||||||
|
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
name: 'workflow-detail',
|
name: 'workflow-detail',
|
||||||
|
|
|
||||||
|
|
@ -24,23 +24,20 @@ import StorageOptimizationWorkflow from '@/components/workflow/workflows/Storage
|
||||||
import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue';
|
import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue';
|
||||||
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
|
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
|
||||||
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
|
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflows with a fully co-located component + metadata.
|
* Workflows with a fully co-located component + metadata.
|
||||||
*/
|
*/
|
||||||
const implementedWorkflows = [
|
const workflows = [
|
||||||
{ ...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow },
|
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
|
||||||
{ ...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow },
|
{...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow},
|
||||||
{ ...InventoryAuditWorkflow.meta, component: InventoryAuditWorkflow },
|
{...InventoryAuditWorkflow.meta, component: InventoryAuditWorkflow},
|
||||||
{ ...StorageOptimizationWorkflow.meta, component: StorageOptimizationWorkflow },
|
{...StorageOptimizationWorkflow.meta, component: StorageOptimizationWorkflow},
|
||||||
{ ...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow },
|
{...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow},
|
||||||
{ ...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow },
|
{...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow},
|
||||||
{ ...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow },
|
{...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
|
||||||
];
|
];
|
||||||
/**
|
|
||||||
* The full workflow catalog: every workflow type known to the frontend,
|
|
||||||
* whether it has a custom UI or not.
|
|
||||||
*/
|
|
||||||
const workflows = implementedWorkflows;
|
|
||||||
/**
|
/**
|
||||||
* Get every workflow in the catalog.
|
* Get every workflow in the catalog.
|
||||||
* @returns {Array<Object>}
|
* @returns {Array<Object>}
|
||||||
|
|
@ -48,14 +45,16 @@ const workflows = implementedWorkflows;
|
||||||
export function getAllWorkflows() {
|
export function getAllWorkflows() {
|
||||||
return workflows;
|
return workflows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single workflow definition by id.
|
* Get a single workflow definition by id.
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @returns {Object|undefined}
|
* @returns {Object|undefined}
|
||||||
*/
|
*/
|
||||||
export function getWorkflow(id) {
|
export function getWorkflow(slug) {
|
||||||
return workflows.find(workflow => workflow.id === id);
|
return workflows.find(workflow => workflow.slug === slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Vue component implementing a workflow's UI, if any.
|
* Get the Vue component implementing a workflow's UI, if any.
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
|
|
@ -64,6 +63,7 @@ export function getWorkflow(id) {
|
||||||
export function getWorkflowComponent(id) {
|
export function getWorkflowComponent(id) {
|
||||||
return getWorkflow(id)?.component || null;
|
return getWorkflow(id)?.component || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all workflows belonging to a category.
|
* Get all workflows belonging to a category.
|
||||||
* @param {string} category
|
* @param {string} category
|
||||||
|
|
@ -72,6 +72,7 @@ export function getWorkflowComponent(id) {
|
||||||
export function getWorkflowsByCategory(category) {
|
export function getWorkflowsByCategory(category) {
|
||||||
return workflows.filter(workflow => workflow.category === category);
|
return workflows.filter(workflow => workflow.category === category);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all unique categories present in the catalog.
|
* Get all unique categories present in the catalog.
|
||||||
* @returns {Array<string>}
|
* @returns {Array<string>}
|
||||||
|
|
@ -79,6 +80,7 @@ export function getWorkflowsByCategory(category) {
|
||||||
export function getWorkflowCategories() {
|
export function getWorkflowCategories() {
|
||||||
return [...new Set(workflows.map(workflow => workflow.category))];
|
return [...new Set(workflows.map(workflow => workflow.category))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the payload sent to the backend to start a new instance of a
|
* Build the payload sent to the backend to start a new instance of a
|
||||||
* workflow, merging the common `workflow_config` metadata block with the
|
* workflow, merging the common `workflow_config` metadata block with the
|
||||||
|
|
@ -89,31 +91,28 @@ export function getWorkflowCategories() {
|
||||||
export function buildWorkflowApiPayload(workflow) {
|
export function buildWorkflowApiPayload(workflow) {
|
||||||
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
|
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
|
||||||
return {
|
return {
|
||||||
name: workflow.name,
|
title: workflow.title,
|
||||||
workflow_type: workflow.id,
|
slug: workflow.slug,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
current_step: 1,
|
current_step: 1,
|
||||||
total_steps: workflow.stepDefinitions.length,
|
total_steps: workflow.stepDefinitions.length,
|
||||||
payload: {
|
payload: {
|
||||||
workflow_config: {
|
|
||||||
name: workflow.name,
|
|
||||||
description: workflow.description,
|
|
||||||
category: workflow.category,
|
|
||||||
estimated_duration: workflow.estimatedDuration
|
|
||||||
},
|
|
||||||
...ownPayload
|
...ownPayload
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The backend stores WorkflowInstance.payload as an opaque string - it never
|
* The backend stores WorkflowInstance.payload as an opaque string - it never
|
||||||
* parses or understands it as JSON. The frontend is fully responsible for
|
* parses or understands it as JSON. The frontend is fully responsible for
|
||||||
* serializing it before sending and deserializing it after receiving.
|
* serializing it before sending and deserializing it after receiving.
|
||||||
*/
|
*/
|
||||||
export function serializeWorkflowPayload(workflow) {
|
export function serializeWorkflowPayload(workflow) {
|
||||||
|
console.log(workflow);
|
||||||
if (!workflow || !('payload' in workflow)) return workflow;
|
if (!workflow || !('payload' in workflow)) return workflow;
|
||||||
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
|
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializeWorkflowPayload(workflow) {
|
export function deserializeWorkflowPayload(workflow) {
|
||||||
if (!workflow) return workflow;
|
if (!workflow) return workflow;
|
||||||
let payload = {};
|
let payload = {};
|
||||||
|
|
@ -124,6 +123,7 @@ export function deserializeWorkflowPayload(workflow) {
|
||||||
}
|
}
|
||||||
return {...workflow, payload};
|
return {...workflow, payload};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getAllWorkflows,
|
getAllWorkflows,
|
||||||
getWorkflow,
|
getWorkflow,
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ export default defineConfig({
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
//'Upgrade-Insecure-Requests': '1',
|
//'Upgrade-Insecure-Requests': '1',
|
||||||
'Content-Security-Policy': 'default-src \'self\';'
|
'Content-Security-Policy': 'default-src \'self\';'
|
||||||
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\';'
|
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\' \'unsafe-inline\';'
|
||||||
+ ' style-src \'self\' \'unsafe-inline\';'
|
+ ' style-src \'self\' \'unsafe-inline\';'
|
||||||
+ ' img-src \'self\' * data:;'
|
+ ' img-src \'self\' * data: blob:;'
|
||||||
+ ' connect-src * data:',
|
+ ' connect-src * data:',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue