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_<theme> at the served URL; GET /settings/map-blueprint/<file> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -793,6 +793,12 @@ export const settingsApi = {
|
|||||||
},
|
},
|
||||||
create(data) {
|
create(data) {
|
||||||
return api.post('/settings', 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' } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label>
|
<label>
|
||||||
<span>Blueprint image URL (light theme)</span>
|
<span>Blueprint image (light theme)</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="settings.map_blueprint_light"
|
v-model="settings.map_blueprint_light"
|
||||||
@@ -366,13 +366,17 @@
|
|||||||
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
|
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
>
|
||||||
<small class="input-hint">Path or URL to the light-theme floor plan</small>
|
<div class="map-upload-row">
|
||||||
|
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||||
|
<img v-if="settings.map_blueprint_light" :src="settings.map_blueprint_light" class="map-thumb" alt="light blueprint" />
|
||||||
|
</div>
|
||||||
|
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label>
|
<label>
|
||||||
<span>Blueprint image URL (dark theme)</span>
|
<span>Blueprint image (dark theme)</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="settings.map_blueprint_dark"
|
v-model="settings.map_blueprint_dark"
|
||||||
@@ -380,7 +384,11 @@
|
|||||||
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
|
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
>
|
||||||
<small class="input-hint">Path or URL to the dark-theme floor plan</small>
|
<div class="map-upload-row">
|
||||||
|
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||||
|
<img v-if="settings.map_blueprint_dark" :src="settings.map_blueprint_dark" class="map-thumb map-thumb-dark" alt="dark blueprint" />
|
||||||
|
</div>
|
||||||
|
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -788,6 +796,7 @@ const computerTypes = ref([]) // ComputerType names for the dropdown
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const mapUploading = ref(false)
|
||||||
const testingEmail = ref(false)
|
const testingEmail = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const success = 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) {
|
async function toggleSetting(key) {
|
||||||
const newValue = !settings[key]
|
const newValue = !settings[key]
|
||||||
await saveSetting(key, newValue)
|
await saveSetting(key, newValue)
|
||||||
@@ -1319,4 +1349,7 @@ onMounted(loadSettings)
|
|||||||
.identifier-matrix .identifier-name {
|
.identifier-matrix .identifier-name {
|
||||||
color: var(--text);
|
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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""Settings API routes."""
|
"""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 flask_jwt_extended import jwt_required
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from shopdb.extensions import db, cache
|
from shopdb.extensions import db, cache
|
||||||
from shopdb.core.models import Setting, AuditLog
|
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__)
|
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
|
# Cache key for settings
|
||||||
SETTINGS_CACHE_KEY = 'system_settings'
|
SETTINGS_CACHE_KEY = 'system_settings'
|
||||||
SETTINGS_CACHE_TTL = 300 # 5 minutes
|
SETTINGS_CACHE_TTL = 300 # 5 minutes
|
||||||
@@ -74,6 +85,48 @@ def invalidate_settings_cache():
|
|||||||
cache.delete(SETTINGS_CACHE_KEY)
|
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=<image>, theme=light|dark. Saves to the instance
|
||||||
|
maps dir and sets map_blueprint_<theme> 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/<path:filename>', 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'])
|
@settings_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def list_settings():
|
def list_settings():
|
||||||
|
|||||||
Reference in New Issue
Block a user