feat: timer intervallo misura su ricetta + auto-logout per inattività
- Recipe: nuovo campo measurement_interval_minutes (migration 003) con UI nel recipe editor - Auto-logout: impostazione di sistema configurabile da admin/settings, timer inattività JS con warning modale 60s prima del logout, tracking eventi utente throttled - Navbar: aggiunto link "Impostazioni" per admin (desktop + mobile) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,7 @@ def create_app() -> Flask:
|
||||
"current_language": get_locale(),
|
||||
"languages": Config.LANGUAGES,
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -43,6 +43,28 @@ def user_list():
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@admin_bp.route("/settings")
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings_page():
|
||||
"""System settings page."""
|
||||
resp = api_client.get("/api/settings")
|
||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||
return render_template("admin/settings.html", settings=settings)
|
||||
|
||||
|
||||
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_update_settings():
|
||||
"""Proxy: Update system settings."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.put("/api/settings", data=data)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/stations")
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
@@ -76,12 +76,18 @@ def login():
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica logo aziendale dalle impostazioni di sistema
|
||||
# Carica impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||
if auto_logout:
|
||||
try:
|
||||
session["auto_logout_minutes"] = int(auto_logout)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
x-data="systemSettings()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-header flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Sessione e Sicurezza') }}
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-body space-y-5">
|
||||
<!-- Auto-logout -->
|
||||
<div>
|
||||
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="auto-logout"
|
||||
type="number"
|
||||
x-model.number="autoLogoutMinutes"
|
||||
class="tmf-input w-32"
|
||||
min="1"
|
||||
max="480"
|
||||
placeholder="—">
|
||||
<button @click="autoLogoutMinutes = null"
|
||||
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
||||
x-show="autoLogoutMinutes">
|
||||
{{ _('Disabilita') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-footer flex items-center justify-between">
|
||||
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Salvato') }}
|
||||
</span>
|
||||
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button @click="save()"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Salva impostazioni') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function systemSettings() {
|
||||
return {
|
||||
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
||||
saving: false,
|
||||
saved: false,
|
||||
errorMessage: '',
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
this.saved = false;
|
||||
this.errorMessage = '';
|
||||
|
||||
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
var payload = {
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
|
||||
};
|
||||
|
||||
try {
|
||||
var resp = await fetch('/admin/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || data.error;
|
||||
} else {
|
||||
this.saved = true;
|
||||
setTimeout(() => { this.saved = false; }, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
|
||||
}
|
||||
this.saving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -115,6 +115,74 @@
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user and auto_logout_minutes %}
|
||||
<script>
|
||||
function inactivityTimer() {
|
||||
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
|
||||
var warningMs = 60 * 1000;
|
||||
return {
|
||||
lastActivity: Date.now(),
|
||||
showWarning: false,
|
||||
remainingSeconds: 60,
|
||||
_interval: null,
|
||||
init() {
|
||||
var self = this;
|
||||
this._interval = setInterval(function() { self._check(); }, 5000);
|
||||
var lastUpdate = 0;
|
||||
var handler = function() {
|
||||
var now = Date.now();
|
||||
if (now - lastUpdate > 3000) {
|
||||
lastUpdate = now;
|
||||
self.lastActivity = now;
|
||||
self.showWarning = false;
|
||||
}
|
||||
};
|
||||
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
|
||||
window.addEventListener(evt, handler, { passive: true });
|
||||
});
|
||||
},
|
||||
resetTimer() {
|
||||
this.lastActivity = Date.now();
|
||||
this.showWarning = false;
|
||||
},
|
||||
_check() {
|
||||
var elapsed = Date.now() - this.lastActivity;
|
||||
var remaining = timeoutMs - elapsed;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(this._interval);
|
||||
window.location.href = '{{ url_for("auth.logout") }}';
|
||||
} else if (remaining <= warningMs) {
|
||||
this.showWarning = true;
|
||||
this.remainingSeconds = Math.ceil(remaining / 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<div x-data="inactivityTimer()" class="contents">
|
||||
<template x-if="showWarning">
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
|
||||
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
||||
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
|
||||
</p>
|
||||
<button @click="resetTimer()" class="btn btn-primary w-full">
|
||||
{{ _('Continua a lavorare') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -102,6 +102,20 @@
|
||||
</svg>
|
||||
<span>{{ _('Stazioni') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.settings_page') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.settings_page' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span>{{ _('Impostazioni') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -299,6 +313,17 @@
|
||||
</svg>
|
||||
{{ _('Stazioni') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.settings_page') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{{ _('Impostazioni') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -231,6 +231,23 @@
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Intervallo di misura -->
|
||||
<div>
|
||||
<label class="tmf-label" for="recipe-interval">
|
||||
{{ _('Intervallo misura (minuti)') }}
|
||||
</label>
|
||||
<input id="recipe-interval"
|
||||
type="number"
|
||||
x-model.number="measurementIntervalMinutes"
|
||||
class="tmf-input"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="{{ _('Es. 30') }}">
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,6 +483,9 @@ function recipeEditor() {
|
||||
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||
|
||||
// ---- Measurement interval ----
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||
|
||||
// ---- File upload (preview image) ----
|
||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||
uploadingFile: false,
|
||||
@@ -487,6 +507,11 @@ function recipeEditor() {
|
||||
description: this.description.trim(),
|
||||
};
|
||||
|
||||
// Measurement interval
|
||||
if (this.measurementIntervalMinutes) {
|
||||
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||
}
|
||||
|
||||
// Include image_path for preview thumbnail
|
||||
if (this.currentFilePath) {
|
||||
payload.image_path = this.currentFilePath;
|
||||
|
||||
@@ -672,6 +672,51 @@ msgstr "Users"
|
||||
msgid "Stazioni"
|
||||
msgstr "Stations"
|
||||
|
||||
msgid "Impostazioni"
|
||||
msgstr "Settings"
|
||||
|
||||
msgid "Impostazioni di Sistema"
|
||||
msgstr "System Settings"
|
||||
|
||||
msgid "Configura i parametri generali del sistema"
|
||||
msgstr "Configure general system parameters"
|
||||
|
||||
msgid "Sessione e Sicurezza"
|
||||
msgstr "Session & Security"
|
||||
|
||||
msgid "Auto-logout per inattività (minuti)"
|
||||
msgstr "Auto-logout for inactivity (minutes)"
|
||||
|
||||
msgid "Disabilita"
|
||||
msgstr "Disable"
|
||||
|
||||
msgid "L'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare."
|
||||
msgstr "The operator will be automatically logged out after the specified inactivity period. Leave empty to disable."
|
||||
|
||||
msgid "Salva impostazioni"
|
||||
msgstr "Save settings"
|
||||
|
||||
msgid "Salvato"
|
||||
msgstr "Saved"
|
||||
|
||||
msgid "Sessione in scadenza"
|
||||
msgstr "Session expiring"
|
||||
|
||||
msgid "Sarai disconnesso tra"
|
||||
msgstr "You will be logged out in"
|
||||
|
||||
msgid "secondi per inattività."
|
||||
msgstr "seconds due to inactivity."
|
||||
|
||||
msgid "Continua a lavorare"
|
||||
msgstr "Continue working"
|
||||
|
||||
msgid "Intervallo misura (minuti)"
|
||||
msgstr "Measurement interval (minutes)"
|
||||
|
||||
msgid "Timer cicalino per ricordare la misurazione periodica"
|
||||
msgstr "Buzzer timer to remind periodic measurement"
|
||||
|
||||
#: templates/components/next_measurement.html:19
|
||||
msgid "Prossima misura"
|
||||
msgstr "Next measurement"
|
||||
|
||||
@@ -699,9 +699,53 @@ msgid "Utenti"
|
||||
msgstr "Utenti"
|
||||
|
||||
#: templates/components/navbar.html:103 templates/components/navbar.html:300
|
||||
#, fuzzy
|
||||
msgid "Stazioni"
|
||||
msgstr "Azioni"
|
||||
msgstr "Stazioni"
|
||||
|
||||
msgid "Impostazioni"
|
||||
msgstr "Impostazioni"
|
||||
|
||||
msgid "Impostazioni di Sistema"
|
||||
msgstr "Impostazioni di Sistema"
|
||||
|
||||
msgid "Configura i parametri generali del sistema"
|
||||
msgstr "Configura i parametri generali del sistema"
|
||||
|
||||
msgid "Sessione e Sicurezza"
|
||||
msgstr "Sessione e Sicurezza"
|
||||
|
||||
msgid "Auto-logout per inattività (minuti)"
|
||||
msgstr "Auto-logout per inattività (minuti)"
|
||||
|
||||
msgid "Disabilita"
|
||||
msgstr "Disabilita"
|
||||
|
||||
msgid "L'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare."
|
||||
msgstr "L'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare."
|
||||
|
||||
msgid "Salva impostazioni"
|
||||
msgstr "Salva impostazioni"
|
||||
|
||||
msgid "Salvato"
|
||||
msgstr "Salvato"
|
||||
|
||||
msgid "Sessione in scadenza"
|
||||
msgstr "Sessione in scadenza"
|
||||
|
||||
msgid "Sarai disconnesso tra"
|
||||
msgstr "Sarai disconnesso tra"
|
||||
|
||||
msgid "secondi per inattività."
|
||||
msgstr "secondi per inattività."
|
||||
|
||||
msgid "Continua a lavorare"
|
||||
msgstr "Continua a lavorare"
|
||||
|
||||
msgid "Intervallo misura (minuti)"
|
||||
msgstr "Intervallo misura (minuti)"
|
||||
|
||||
msgid "Timer cicalino per ricordare la misurazione periodica"
|
||||
msgstr "Timer cicalino per ricordare la misurazione periodica"
|
||||
|
||||
#: templates/components/next_measurement.html:19
|
||||
msgid "Prossima misura"
|
||||
|
||||
Reference in New Issue
Block a user