From b26d0d1b8767b5ecafc958980a3661f333b97551 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 10 Jul 2026 10:50:51 -0400 Subject: [PATCH] Upload floor-map blueprints (light/dark) instead of typing a path The Floor Map settings only had text fields for the blueprint image path with no way to upload one. Add a real upload. - POST /settings/map-blueprint (multipart: file + theme) saves the image to the instance maps dir and points map_blueprint_ at the served URL; GET /settings/map-blueprint/ serves it (public - kiosks read the map). - Settings > Floor Map: a file picker + thumbnail next to each theme's field (still accepts a manual path/URL too). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/index.js | 6 ++ .../src/views/settings/SystemSettings.vue | 41 ++++++++++++-- shopdb/core/api/settings.py | 55 ++++++++++++++++++- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index a8d7826..d0de2dd 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -793,6 +793,12 @@ export const settingsApi = { }, create(data) { return api.post('/settings', data) + }, + uploadMapBlueprint(theme, file) { + const form = new FormData() + form.append('theme', theme) + form.append('file', file) + return api.post('/settings/map-blueprint', form, { headers: { 'Content-Type': 'multipart/form-data' } }) } } diff --git a/frontend/src/views/settings/SystemSettings.vue b/frontend/src/views/settings/SystemSettings.vue index c117ba1..7cd84a3 100644 --- a/frontend/src/views/settings/SystemSettings.vue +++ b/frontend/src/views/settings/SystemSettings.vue @@ -358,7 +358,7 @@
@@ -788,6 +796,7 @@ const computerTypes = ref([]) // ComputerType names for the dropdown const loading = ref(true) const saving = ref(false) +const mapUploading = ref(false) const testingEmail = ref(false) const error = ref('') const success = ref('') @@ -908,6 +917,27 @@ async function changePcTypeMapping(pxetype, computertype) { } } +async function uploadBlueprint(theme, event) { + const file = event.target.files[0] + if (!file) return + mapUploading.value = true + error.value = '' + success.value = '' + try { + const { data } = await settingsApi.uploadMapBlueprint(theme, file) + const url = data?.data?.value + if (theme === 'light') settings.map_blueprint_light = url + else settings.map_blueprint_dark = url + success.value = 'Blueprint uploaded' + setTimeout(() => { success.value = '' }, 2000) + } catch (e) { + error.value = apiError(e, 'Upload failed') + } finally { + mapUploading.value = false + event.target.value = '' + } +} + async function toggleSetting(key) { const newValue = !settings[key] await saveSetting(key, newValue) @@ -1319,4 +1349,7 @@ onMounted(loadSettings) .identifier-matrix .identifier-name { color: var(--text); } +.map-upload-row { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.4rem; } +.map-thumb { height: 40px; border: 1px solid var(--border); border-radius: 4px; background: #fff; } +.map-thumb-dark { background: #222; } diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 7744852..a81bf20 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -1,7 +1,10 @@ """Settings API routes.""" -from flask import Blueprint, request +import os + +from flask import Blueprint, request, current_app, send_from_directory from flask_jwt_extended import jwt_required +from werkzeug.utils import secure_filename from shopdb.extensions import db, cache from shopdb.core.models import Setting, AuditLog @@ -11,6 +14,14 @@ from shopdb.utils.authz import require_permission, require_role settings_bp = Blueprint('settings', __name__) +# Floor-map blueprint uploads live in the instance dir and are served publicly +# (the kiosk dashboards read them without auth). +MAP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} + + +def _map_dir(): + return os.path.join(current_app.instance_path, 'maps') + # Cache key for settings SETTINGS_CACHE_KEY = 'system_settings' SETTINGS_CACHE_TTL = 300 # 5 minutes @@ -74,6 +85,48 @@ def invalidate_settings_cache(): cache.delete(SETTINGS_CACHE_KEY) +@settings_bp.route('/map-blueprint', methods=['POST']) +@jwt_required() +@require_role('admin') +def upload_map_blueprint(): + """Upload a floor-map blueprint image and point the setting at it. + + multipart/form-data: file=, theme=light|dark. Saves to the instance + maps dir and sets map_blueprint_ to the served URL. + """ + theme = (request.form.get('theme') or '').strip().lower() + if theme not in ('light', 'dark'): + return error_response(ErrorCodes.VALIDATION_ERROR, 'theme must be light or dark') + upload = request.files.get('file') + if not upload or not upload.filename: + return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') + ext = os.path.splitext(upload.filename)[1].lower() + if ext not in MAP_IMAGE_EXTENSIONS: + return error_response(ErrorCodes.VALIDATION_ERROR, + f'Unsupported image type {ext}') + + os.makedirs(_map_dir(), exist_ok=True) + filename = secure_filename(f'blueprint-{theme}{ext}') + upload.save(os.path.join(_map_dir(), filename)) + + url = f'/api/settings/map-blueprint/{filename}' + key = f'map_blueprint_{theme}' + setting = Setting.query.filter_by(key=key).first() + if setting: + setting.value = url + else: + db.session.add(Setting(key=key, value=url, valuetype='string', category='map')) + db.session.commit() + invalidate_settings_cache() + return success_response({'key': key, 'value': url}, message='Blueprint uploaded') + + +@settings_bp.route('/map-blueprint/', methods=['GET']) +def serve_map_blueprint(filename): + """Serve an uploaded blueprint image (public - kiosks read it).""" + return send_from_directory(_map_dir(), filename) + + @settings_bp.route('', methods=['GET']) @jwt_required(optional=True) def list_settings():