feat: FASE 7 - Polish & Testing (security, i18n, test suite, docs)
Security hardening: CORS lockdown, rate limiting middleware con sliding window e eviction IP stale, security headers (CSP, HSTS, X-Frame-Options), session cookie hardening, filename sanitization upload. i18n completion: internazionalizzati barcode.js e csv-export.js con bridge window.BARCODE_I18N/CSV_I18N, aggiornati .po IT/EN con 27 nuove stringhe. Tablet UX: touch target 44px per dispositivi coarse pointer. Test suite: 101 test totali (76 server + 25 client), copertura completa di tutti i router API, autenticazione, ruoli, CRUD, SPC, file upload, security integration. Infrastruttura SQLite async in-memory con fixtures. Fix critici: MissingGreenlet in recipe_service (selectinload eager), route ordering tasks.py, auth_service bcrypt diretto, Measurement.id Integer per SQLite. Documentazione: API.md (riferimento completo 40+ endpoint), DEPLOYMENT.md (guida produzione con Docker/Nginx/SSL), USER_GUIDE.md (manuale utente per ruolo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# TieMeasureFlow i18n Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
TieMeasureFlow supports complete internationalization (i18n) with:
|
||||
- **Server-side**: Flask-Babel for Python/Jinja2 templates
|
||||
- **Client-side**: alpinejs-i18n for JavaScript/Alpine.js
|
||||
- **Supported languages**: Italian (IT - default) and English (EN)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/
|
||||
├── translations/ # Flask-Babel translations
|
||||
│ ├── babel.cfg # Extraction config
|
||||
│ ├── it/LC_MESSAGES/
|
||||
│ │ ├── messages.po # Italian strings
|
||||
│ │ └── messages.mo # Compiled binary (generated)
|
||||
│ └── en/LC_MESSAGES/
|
||||
│ ├── messages.po # English translations
|
||||
│ └── messages.mo # Compiled binary (generated)
|
||||
├── static/js/locales/ # Alpine.js i18n
|
||||
│ ├── it.json # Italian client-side strings
|
||||
│ └── en.json # English client-side strings
|
||||
└── compile_translations.py # Utility to compile .po → .mo
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Language Selection
|
||||
|
||||
1. **User session**: Language stored in `session["language"]` (persistent)
|
||||
2. **Browser fallback**: Uses `Accept-Language` header if no session
|
||||
3. **Default**: Italian (IT)
|
||||
|
||||
### Server-Side (Flask-Babel)
|
||||
|
||||
**In Python code:**
|
||||
```python
|
||||
from flask_babel import gettext as _
|
||||
|
||||
flash(_("Profilo aggiornato con successo"))
|
||||
```
|
||||
|
||||
**In Jinja2 templates:**
|
||||
```jinja2
|
||||
<h1>{{ _('Accedi al sistema') }}</h1>
|
||||
|
||||
{# With variables #}
|
||||
<p>{{ _('Benvenuto, %(name)s!', name=user.display_name) }}</p>
|
||||
```
|
||||
|
||||
### Client-Side (Alpine.js i18n)
|
||||
|
||||
**Load i18n in base template:**
|
||||
```html
|
||||
<script src="/static/js/lib/alpinejs-i18n.min.js"></script>
|
||||
<script>
|
||||
// Initialize i18n with current language
|
||||
Alpine.plugin(AlpineI18n);
|
||||
Alpine.store('i18n', {
|
||||
locale: '{{ current_language }}',
|
||||
fallbackLocale: 'it',
|
||||
messages: {}
|
||||
});
|
||||
|
||||
// Load locale files
|
||||
fetch('/static/js/locales/{{ current_language }}.json')
|
||||
.then(r => r.json())
|
||||
.then(data => Alpine.store('i18n').messages['{{ current_language }}'] = data);
|
||||
</script>
|
||||
```
|
||||
|
||||
**Use in templates:**
|
||||
```html
|
||||
<button @click="submit" x-text="$t('numpad.submit')"></button>
|
||||
|
||||
<p x-text="$t('auth.welcome', {name: userName})"></p>
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Add New Strings
|
||||
|
||||
**Python/Templates:**
|
||||
1. Wrap strings with `_()` or `{{ _() }}`
|
||||
2. Extract strings: `pybabel extract -F translations/babel.cfg -o translations/messages.pot .`
|
||||
3. Update catalogs: `pybabel update -i translations/messages.pot -d translations`
|
||||
4. Edit `translations/*/LC_MESSAGES/messages.po` files
|
||||
5. Compile: `python compile_translations.py`
|
||||
|
||||
**JavaScript/Alpine:**
|
||||
1. Add keys to `static/js/locales/it.json` (Italian strings)
|
||||
2. Add translations to `static/js/locales/en.json`
|
||||
3. No compilation needed (loaded at runtime)
|
||||
|
||||
### 2. Compile Translations
|
||||
|
||||
After editing .po files:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
This generates `.mo` binary files that Flask-Babel uses at runtime.
|
||||
|
||||
### 3. Language Switching
|
||||
|
||||
**Endpoint**: `GET /set-language/<lang>`
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<a href="{{ url_for('set_language', lang='en') }}">English</a>
|
||||
<a href="{{ url_for('set_language', lang='it') }}">Italiano</a>
|
||||
```
|
||||
|
||||
**With Alpine.js**:
|
||||
```html
|
||||
<button @click="$store.i18n.locale = 'en'; window.location.href='/set-language/en'">
|
||||
English
|
||||
</button>
|
||||
```
|
||||
|
||||
## Translation Keys
|
||||
|
||||
### Server-Side (messages.po)
|
||||
|
||||
56 strings covering:
|
||||
- **Login**: "Accedi al sistema", "Credenziali non valide", etc.
|
||||
- **Navbar**: "Misure", "Ricette", "Statistiche", etc.
|
||||
- **Profile**: "Profilo Utente", "Salva Modifiche", etc.
|
||||
- **Flash messages**: "Benvenuto, %(name)s!", "Logout effettuato", etc.
|
||||
- **Common UI**: "Caricamento...", "Salva", "Annulla", "Conferma", etc.
|
||||
- **Measurements**: "Conforme", "Non Conforme", etc.
|
||||
|
||||
### Client-Side (locales/*.json)
|
||||
|
||||
Structured keys:
|
||||
```json
|
||||
{
|
||||
"numpad": { "title", "clear", "submit", "decimal", "close" },
|
||||
"measurement": { "pass", "warning", "fail", "value", "next" },
|
||||
"common": { "loading", "save", "cancel", "confirm", "delete", "edit", ... },
|
||||
"theme": { "light", "dark", "toggle" },
|
||||
"language": { "switch", "it", "en" },
|
||||
"auth": { "login", "username", "password", "signIn", ... },
|
||||
"nav": { "measurements", "recipes", "statistics", ... },
|
||||
"profile": { "title", "displayName", "language", "theme", ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
**config.py** must define:
|
||||
```python
|
||||
BABEL_DEFAULT_LOCALE = 'it'
|
||||
BABEL_TRANSLATION_DIRECTORIES = 'translations'
|
||||
LANGUAGES = {
|
||||
'it': 'Italiano',
|
||||
'en': 'English'
|
||||
}
|
||||
```
|
||||
|
||||
**app.py** provides:
|
||||
- `get_locale()`: Language selector function
|
||||
- `/set-language/<lang>`: Language switch endpoint
|
||||
- `inject_globals()`: Exposes `current_language` and `languages` to templates
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use Italian as msgid**: Italian strings are the canonical keys
|
||||
2. **Keep client/server keys synced**: Use similar naming across .po and .json
|
||||
3. **Test both languages**: Switch languages and verify all pages
|
||||
4. **Recompile after editing**: Run `compile_translations.py` after changing .po files
|
||||
5. **Use variables for dynamic content**: `_('Welcome, %(name)s!', name=user)` or `$t('auth.welcome', {name})`
|
||||
6. **Avoid hardcoded strings**: Always use translation functions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Translations not appearing:**
|
||||
- Verify `.mo` files exist: `find translations -name "*.mo"`
|
||||
- Check session language: `session["language"]` or browser console
|
||||
- Recompile: `python compile_translations.py`
|
||||
- Restart Flask server
|
||||
|
||||
**Client-side i18n not working:**
|
||||
- Check browser console for fetch errors
|
||||
- Verify JSON files exist: `static/js/locales/{it,en}.json`
|
||||
- Check Alpine.js i18n plugin is loaded
|
||||
- Verify `Alpine.store('i18n')` is initialized
|
||||
|
||||
**Missing translations show as keys:**
|
||||
- Add missing keys to .po or .json files
|
||||
- Recompile .po files if server-side
|
||||
- Reload page if client-side
|
||||
|
||||
## Commands Reference
|
||||
|
||||
```bash
|
||||
# Extract translatable strings from code
|
||||
pybabel extract -F translations/babel.cfg -o translations/messages.pot .
|
||||
|
||||
# Initialize new language (first time)
|
||||
pybabel init -i translations/messages.pot -d translations -l en
|
||||
|
||||
# Update existing catalogs with new strings
|
||||
pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
# Compile .po to .mo (required after editing)
|
||||
python compile_translations.py
|
||||
|
||||
# Check translation statistics
|
||||
msgfmt --statistics translations/it/LC_MESSAGES/messages.po
|
||||
msgfmt --statistics translations/en/LC_MESSAGES/messages.po
|
||||
```
|
||||
|
||||
## Future Expansion
|
||||
|
||||
To add a new language (e.g., German):
|
||||
|
||||
1. **Server-side**:
|
||||
```bash
|
||||
pybabel init -i translations/messages.pot -d translations -l de
|
||||
# Edit translations/de/LC_MESSAGES/messages.po
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
2. **Client-side**:
|
||||
- Create `static/js/locales/de.json`
|
||||
- Add to config: `LANGUAGES = {..., 'de': 'Deutsch'}`
|
||||
|
||||
3. **Update loader**: Modify base template to support new locale
|
||||
|
||||
## Resources
|
||||
|
||||
- Flask-Babel: https://python-babel.github.io/flask-babel/
|
||||
- Babel: http://babel.pocoo.org/
|
||||
- Alpine.js i18n: https://github.com/alpine-collective/alpine-i18n
|
||||
- PO file format: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
|
||||
@@ -10,6 +10,7 @@ class Config:
|
||||
|
||||
# Flask
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key")
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
@@ -17,6 +18,9 @@ class Config:
|
||||
# Session
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS)
|
||||
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
|
||||
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
|
||||
|
||||
# Babel i18n
|
||||
BABEL_DEFAULT_LOCALE = "it"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
@@ -9,3 +9,7 @@ urllib3>=2.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
coverage>=7.0.0
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Tablet Touch Targets (44px minimum) */
|
||||
@layer components {
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
button,
|
||||
.btn,
|
||||
[role="button"],
|
||||
a.inline-flex {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Flash message dismiss button */
|
||||
.fixed button[x-data] {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,3 +372,31 @@ textarea:focus-visible {
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Tablet Touch Targets (44px minimum per WCAG 2.5.5)
|
||||
============================================================ */
|
||||
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
button,
|
||||
.btn,
|
||||
[role="button"],
|
||||
a.inline-flex {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Flash message dismiss button */
|
||||
.fixed button[x-data] {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
function barcodeScanner() {
|
||||
const _t = (key) => (window.BARCODE_I18N && window.BARCODE_I18N[key]) || key;
|
||||
|
||||
return {
|
||||
scanning: false,
|
||||
result: null,
|
||||
@@ -19,7 +21,7 @@ function barcodeScanner() {
|
||||
|
||||
// Check library availability
|
||||
if (!window.Html5Qrcode) {
|
||||
this.error = 'Scanner library not loaded';
|
||||
this.error = _t('scanner_lib_not_loaded');
|
||||
this.scanning = false;
|
||||
return;
|
||||
}
|
||||
@@ -29,7 +31,7 @@ function barcodeScanner() {
|
||||
const devices = await Html5Qrcode.getCameras();
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
this.error = 'Nessuna fotocamera disponibile';
|
||||
this.error = _t('no_camera_available');
|
||||
this.scanning = false;
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +74,7 @@ function barcodeScanner() {
|
||||
|
||||
} catch (err) {
|
||||
console.error('Scanner initialization error:', err);
|
||||
this.error = 'Impossibile accedere alla fotocamera';
|
||||
this.error = _t('camera_access_error');
|
||||
this.scanning = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
function csvExport() {
|
||||
const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key;
|
||||
|
||||
return {
|
||||
// Locale-specific settings
|
||||
delimiter: ';', // Italian Excel standard
|
||||
@@ -50,20 +52,20 @@ function csvExport() {
|
||||
buildMeasurementCSV(measurements) {
|
||||
const headers = [
|
||||
'ID',
|
||||
'Subtask ID',
|
||||
'Nome Sottotask',
|
||||
'Valore Misurato',
|
||||
'Unità',
|
||||
'Valore Nominale',
|
||||
'Tolleranza +',
|
||||
'Tolleranza -',
|
||||
'Scarto',
|
||||
'Esito',
|
||||
'Numero Lotto',
|
||||
'Numero Seriale',
|
||||
'Metodo Input',
|
||||
'Data Misurazione',
|
||||
'Operatore'
|
||||
_t('subtask_id'),
|
||||
_t('subtask_name'),
|
||||
_t('measured_value'),
|
||||
_t('unit'),
|
||||
_t('nominal_value'),
|
||||
_t('tolerance_plus'),
|
||||
_t('tolerance_minus'),
|
||||
_t('deviation'),
|
||||
_t('result'),
|
||||
_t('lot_number'),
|
||||
_t('serial_number'),
|
||||
_t('input_method'),
|
||||
_t('measurement_date'),
|
||||
_t('operator')
|
||||
];
|
||||
|
||||
let csv = headers.join(this.delimiter) + '\n';
|
||||
@@ -97,28 +99,28 @@ function csvExport() {
|
||||
*/
|
||||
buildTaskSummaryCSV(task) {
|
||||
// Header section
|
||||
let csv = 'RIEPILOGO ESECUZIONE TASK\n\n';
|
||||
let csv = _t('task_summary_title') + '\n\n';
|
||||
|
||||
csv += `Task ID${this.delimiter}${task.id}\n`;
|
||||
csv += `Nome Task${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
|
||||
csv += `Ricetta${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
|
||||
csv += `Operatore${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
|
||||
csv += `Data Inizio${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
|
||||
csv += `Data Fine${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
|
||||
csv += `Stato${this.delimiter}${task.status || ''}\n`;
|
||||
csv += `Lotto${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
|
||||
csv += `Seriale${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
|
||||
csv += `${_t('task_id')}${this.delimiter}${task.id}\n`;
|
||||
csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
|
||||
csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
|
||||
csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
|
||||
csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
|
||||
csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
|
||||
csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`;
|
||||
csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
|
||||
csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
|
||||
|
||||
// Statistics
|
||||
csv += `\nSTATISTICHE\n`;
|
||||
csv += `\n${_t('statistics')}\n`;
|
||||
const stats = this.calculateStats(task.measurements);
|
||||
csv += `Totale Misure${this.delimiter}${stats.total}\n`;
|
||||
csv += `Passate${this.delimiter}${stats.passed}\n`;
|
||||
csv += `Fallite${this.delimiter}${stats.failed}\n`;
|
||||
csv += `Percentuale Successo${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
|
||||
csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`;
|
||||
csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`;
|
||||
csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`;
|
||||
csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
|
||||
|
||||
// Measurements detail
|
||||
csv += `\nDETTAGLIO MISURE\n`;
|
||||
csv += `\n${_t('measurement_details')}\n`;
|
||||
csv += this.buildMeasurementCSV(task.measurements);
|
||||
|
||||
return csv;
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<span class="text-sm font-medium flex-1">{{ message }}</span>
|
||||
|
||||
<!-- Dismiss -->
|
||||
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity">
|
||||
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity touch-target">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
|
||||
@@ -148,4 +148,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.BARCODE_I18N = {
|
||||
scanner_lib_not_loaded: "{{ _('Libreria scanner non caricata') }}",
|
||||
no_camera_available: "{{ _('Nessuna fotocamera disponibile') }}",
|
||||
camera_access_error: "{{ _('Impossibile accedere alla fotocamera') }}"
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -262,6 +262,39 @@
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/csv-export.js') }}"></script>
|
||||
<script>
|
||||
window.CSV_I18N = {
|
||||
subtask_id: "{{ _('Subtask ID') }}",
|
||||
subtask_name: "{{ _('Nome Sottotask') }}",
|
||||
measured_value: "{{ _('Valore Misurato') }}",
|
||||
unit: "{{ _('Unita') }}",
|
||||
nominal_value: "{{ _('Valore Nominale') }}",
|
||||
tolerance_plus: "{{ _('Tolleranza +') }}",
|
||||
tolerance_minus: "{{ _('Tolleranza -') }}",
|
||||
deviation: "{{ _('Scarto') }}",
|
||||
result: "{{ _('Esito') }}",
|
||||
lot_number: "{{ _('Numero Lotto') }}",
|
||||
serial_number: "{{ _('Numero Seriale') }}",
|
||||
input_method: "{{ _('Metodo Input') }}",
|
||||
measurement_date: "{{ _('Data Misurazione') }}",
|
||||
operator: "{{ _('Operatore') }}",
|
||||
task_summary_title: "{{ _('RIEPILOGO ESECUZIONE TASK') }}",
|
||||
task_id: "{{ _('Task ID') }}",
|
||||
task_name: "{{ _('Nome Task') }}",
|
||||
recipe: "{{ _('Ricetta') }}",
|
||||
start_date: "{{ _('Data Inizio') }}",
|
||||
end_date: "{{ _('Data Fine') }}",
|
||||
status: "{{ _('Stato') }}",
|
||||
lot: "{{ _('Lotto') }}",
|
||||
serial: "{{ _('Seriale') }}",
|
||||
statistics: "{{ _('STATISTICHE') }}",
|
||||
total_measurements: "{{ _('Totale Misure') }}",
|
||||
passed: "{{ _('Passate') }}",
|
||||
failed: "{{ _('Fallite') }}",
|
||||
pass_rate: "{{ _('Percentuale Successo') }}",
|
||||
measurement_details: "{{ _('DETTAGLIO MISURE') }}"
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// Pass data to csv-export.js
|
||||
window.measurementData = {
|
||||
recipeName: {{ recipe.name|tojson }},
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Shared test fixtures for Flask client tests.
|
||||
|
||||
Creates a Flask test client and mocks the API client (requests to FastAPI server).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the client package is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app import create_app
|
||||
|
||||
# All blueprint modules that import api_client at module level.
|
||||
# We must patch at the *use-site* (blueprint namespace) so the local name
|
||||
# ``api_client`` in each module points to the mock, not the real singleton.
|
||||
_API_CLIENT_PATCH_TARGETS = [
|
||||
"blueprints.auth.api_client",
|
||||
"blueprints.maker.api_client",
|
||||
"blueprints.measure.api_client",
|
||||
"blueprints.statistics.api_client",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app():
|
||||
"""Create a Flask application configured for testing."""
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
app.config["WTF_CSRF_ENABLED"] = False
|
||||
app.config["SECRET_KEY"] = "test-secret-key"
|
||||
|
||||
# Provide default template variables that are normally set inside
|
||||
# specific Jinja2 blocks and therefore not visible to sibling blocks.
|
||||
@app.context_processor
|
||||
def _inject_template_defaults():
|
||||
return {
|
||||
"versions": [],
|
||||
"current_version": None,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(flask_app):
|
||||
"""Yield a Flask test client."""
|
||||
with flask_app.test_client() as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_client(flask_app):
|
||||
"""Yield a Flask test client with a logged-in session."""
|
||||
with flask_app.test_client() as test_client:
|
||||
with test_client.session_transaction() as sess:
|
||||
sess["api_key"] = "test-api-key-12345"
|
||||
sess["user"] = {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"display_name": "Test User",
|
||||
"roles": ["Maker", "MeasurementTec", "Metrologist"],
|
||||
"is_admin": True,
|
||||
"language_pref": "en",
|
||||
"theme_pref": "light",
|
||||
"active": True,
|
||||
}
|
||||
sess["user_id"] = 1
|
||||
sess["language"] = "en"
|
||||
sess["theme"] = "light"
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client():
|
||||
"""Patch the api_client singleton used in blueprints.
|
||||
|
||||
Patches every blueprint module that does
|
||||
``from services.api_client import api_client`` so the local name
|
||||
in each module resolves to the same MagicMock instance.
|
||||
|
||||
Yields the mocked APIClient instance so tests can configure responses:
|
||||
|
||||
mock_api_client.get.return_value = {"items": [...], "total": 1}
|
||||
mock_api_client.post.return_value = {"user": {...}, "api_key": "..."}
|
||||
"""
|
||||
mock = MagicMock()
|
||||
# Default: all methods return empty success dict
|
||||
mock.get.return_value = {}
|
||||
mock.post.return_value = {}
|
||||
mock.put.return_value = {}
|
||||
mock.delete.return_value = {}
|
||||
|
||||
# Stack patches for every blueprint that imports api_client
|
||||
patchers = [patch(target, mock) for target in _API_CLIENT_PATCH_TARGETS]
|
||||
for p in patchers:
|
||||
p.start()
|
||||
|
||||
yield mock
|
||||
|
||||
for p in patchers:
|
||||
p.stop()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for auth blueprint (client/blueprints/auth.py).
|
||||
|
||||
Covers login GET/POST, logout, unauthenticated redirect, and language/theme switch.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestLoginPage:
|
||||
"""GET /auth/login tests."""
|
||||
|
||||
def test_login_page_renders(self, client):
|
||||
"""Login page renders successfully with 200 status."""
|
||||
resp = client.get("/auth/login")
|
||||
assert resp.status_code == 200
|
||||
assert b"login" in resp.data.lower() or b"Login" in resp.data
|
||||
|
||||
def test_login_page_redirects_when_already_logged_in(self, logged_in_client):
|
||||
"""Already authenticated user is redirected away from login."""
|
||||
resp = logged_in_client.get("/auth/login")
|
||||
assert resp.status_code == 302
|
||||
|
||||
|
||||
class TestLoginPost:
|
||||
"""POST /auth/login tests."""
|
||||
|
||||
def test_login_post_success_redirects(self, client, mock_api_client):
|
||||
"""Successful login POST sets session and redirects."""
|
||||
mock_api_client.post.return_value = {
|
||||
"api_key": "new-api-key-abc",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "testuser",
|
||||
"display_name": "Test User",
|
||||
"roles": ["MeasurementTec"],
|
||||
"is_admin": False,
|
||||
"language_pref": "en",
|
||||
"theme_pref": "light",
|
||||
"active": True,
|
||||
},
|
||||
}
|
||||
# Mock settings call made after login
|
||||
mock_api_client.get.return_value = {}
|
||||
|
||||
resp = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "testuser", "password": "pass123"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
mock_api_client.post.assert_called_once()
|
||||
|
||||
# Verify session was populated
|
||||
with client.session_transaction() as sess:
|
||||
assert sess.get("api_key") == "new-api-key-abc"
|
||||
assert sess["user"]["username"] == "testuser"
|
||||
|
||||
def test_login_post_error_shows_error(self, client, mock_api_client):
|
||||
"""Failed login returns the login page with error flash."""
|
||||
mock_api_client.post.return_value = {
|
||||
"error": True,
|
||||
"detail": "Credenziali non valide",
|
||||
}
|
||||
|
||||
resp = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "bad", "password": "wrong"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Error message should appear in the response (flashed)
|
||||
assert b"Credenziali non valide" in resp.data or b"login" in resp.data.lower()
|
||||
|
||||
def test_login_post_empty_fields(self, client, mock_api_client):
|
||||
"""Login with empty username/password shows validation error."""
|
||||
resp = client.post(
|
||||
"/auth/login",
|
||||
data={"username": "", "password": ""},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Should render login again (not crash)
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""GET/POST /auth/logout tests."""
|
||||
|
||||
def test_logout_clears_session(self, logged_in_client, mock_api_client):
|
||||
"""Logout clears session and redirects to login."""
|
||||
mock_api_client.post.return_value = {}
|
||||
|
||||
resp = logged_in_client.get("/auth/logout", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
# Session should be cleared
|
||||
with logged_in_client.session_transaction() as sess:
|
||||
assert "api_key" not in sess
|
||||
assert "user" not in sess
|
||||
|
||||
|
||||
class TestUnauthenticatedRedirect:
|
||||
"""Protected routes redirect unauthenticated users."""
|
||||
|
||||
def test_profile_requires_login(self, client):
|
||||
"""Profile page redirects to login when not authenticated."""
|
||||
resp = client.get("/auth/profile", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestLanguageSwitch:
|
||||
"""Language and theme switching via app routes."""
|
||||
|
||||
def test_set_language_stores_in_session(self, client):
|
||||
"""Setting language updates session and redirects back."""
|
||||
resp = client.get("/set-language/en", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert sess.get("language") == "en"
|
||||
|
||||
def test_set_language_invalid_ignored(self, client):
|
||||
"""Setting an unsupported language does not crash and still redirects."""
|
||||
resp = client.get("/set-language/xx", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
# Invalid language should NOT be stored
|
||||
assert sess.get("language") != "xx"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for maker blueprint (client/blueprints/maker.py).
|
||||
|
||||
Covers recipe list, recipe creation page, role enforcement, and recipe edit.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestRecipeList:
|
||||
"""GET /maker/recipes tests."""
|
||||
|
||||
def test_recipe_list_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe list page renders for Maker role."""
|
||||
mock_api_client.get.return_value = {
|
||||
"items": [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
],
|
||||
"total": 1,
|
||||
"pages": 1,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/maker/recipes")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_recipe_list_requires_login(self, client):
|
||||
"""Unauthenticated user is redirected to login."""
|
||||
resp = client.get("/maker/recipes", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestRecipeNew:
|
||||
"""GET /maker/recipes/new tests."""
|
||||
|
||||
def test_recipe_new_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe creation page renders for Maker role."""
|
||||
resp = logged_in_client.get("/maker/recipes/new")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestRoleRequired:
|
||||
"""Maker role enforcement tests."""
|
||||
|
||||
def test_requires_maker_role(self, flask_app):
|
||||
"""User without Maker role gets 403 on maker endpoints."""
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["api_key"] = "test-key"
|
||||
sess["user"] = {
|
||||
"id": 2,
|
||||
"username": "tec_only",
|
||||
"display_name": "Tec Only",
|
||||
"roles": ["MeasurementTec"],
|
||||
"is_admin": False,
|
||||
"language_pref": "en",
|
||||
"theme_pref": "light",
|
||||
"active": True,
|
||||
}
|
||||
sess["user_id"] = 2
|
||||
sess["language"] = "en"
|
||||
sess["theme"] = "light"
|
||||
|
||||
resp = c.get("/maker/recipes")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestRecipeEdit:
|
||||
"""GET /maker/recipes/<id>/edit tests."""
|
||||
|
||||
def test_recipe_edit_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe edit page renders with recipe data."""
|
||||
# First call: recipe detail, second call: versions list
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
[{"version_number": 1, "is_current": True}],
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/maker/recipes/1/edit")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_recipe_edit_not_found(self, logged_in_client, mock_api_client):
|
||||
"""Recipe edit with API error redirects to recipe list."""
|
||||
mock_api_client.get.return_value = {
|
||||
"error": True,
|
||||
"detail": "Not found",
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/maker/recipes" in resp.headers["Location"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for measure blueprint (client/blueprints/measure.py).
|
||||
|
||||
Covers recipe selection, task list, login requirement, and measurement submission.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSelectRecipe:
|
||||
"""GET /measure/select tests."""
|
||||
|
||||
def test_select_recipe_renders(self, logged_in_client, mock_api_client):
|
||||
"""Recipe selection page renders for MeasurementTec role."""
|
||||
mock_api_client.get.return_value = {
|
||||
"items": [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
],
|
||||
"total": 1,
|
||||
"pages": 1,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_select_recipe_requires_login(self, client):
|
||||
"""Unauthenticated user is redirected to login."""
|
||||
resp = client.get("/measure/select", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestTaskList:
|
||||
"""GET /measure/tasks/<recipe_id> tests."""
|
||||
|
||||
def test_task_list_renders(self, logged_in_client, mock_api_client):
|
||||
"""Task list page renders with recipe and task data."""
|
||||
# First call: recipe details (dict), second call: tasks list.
|
||||
# The route calls tasks_resp.get("error") so the mock must return
|
||||
# a dict (not a bare list) to avoid AttributeError.
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
|
||||
{
|
||||
"items": [
|
||||
{"id": 1, "title": "Task 1", "order_index": 0},
|
||||
{"id": 2, "title": "Task 2", "order_index": 1},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/tasks/1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestSaveMeasurement:
|
||||
"""POST /measure/save-measurement tests."""
|
||||
|
||||
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
|
||||
"""Save measurement forwards data to API and returns 201."""
|
||||
mock_api_client.post.return_value = {
|
||||
"id": 1,
|
||||
"subtask_id": 10,
|
||||
"task_id": 5,
|
||||
"value": 9.95,
|
||||
"pass_fail": "pass",
|
||||
}
|
||||
|
||||
resp = logged_in_client.post(
|
||||
"/measure/save-measurement",
|
||||
json={
|
||||
"subtask_id": 10,
|
||||
"task_id": 5,
|
||||
"value": 9.95,
|
||||
"pass_fail": "pass",
|
||||
"deviation": 0.05,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["id"] == 1
|
||||
assert data["pass_fail"] == "pass"
|
||||
|
||||
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
|
||||
"""Missing required fields return 400."""
|
||||
resp = logged_in_client.post(
|
||||
"/measure/save-measurement",
|
||||
json={"subtask_id": 10}, # missing task_id and value
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
data = resp.get_json()
|
||||
assert data["error"] is True
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for statistics blueprint (client/blueprints/statistics.py).
|
||||
|
||||
Covers dashboard rendering, role enforcement, and SPC/summary API proxies.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
"""GET /statistics/dashboard tests."""
|
||||
|
||||
def test_dashboard_renders(self, logged_in_client, mock_api_client):
|
||||
"""Dashboard renders for Metrologist role."""
|
||||
mock_api_client.get.return_value = {
|
||||
"items": [
|
||||
{"id": 1, "code": "REC-001", "name": "Recipe A"},
|
||||
],
|
||||
"total": 1,
|
||||
"pages": 1,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get("/statistics/dashboard")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_dashboard_requires_login(self, client):
|
||||
"""Unauthenticated user is redirected to login."""
|
||||
resp = client.get("/statistics/dashboard", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/auth/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
class TestRoleRequired:
|
||||
"""Metrologist role enforcement tests."""
|
||||
|
||||
def test_requires_metrologist_role(self, flask_app):
|
||||
"""User without Metrologist role gets 403 on statistics endpoints."""
|
||||
with flask_app.test_client() as c:
|
||||
with c.session_transaction() as sess:
|
||||
sess["api_key"] = "test-key"
|
||||
sess["user"] = {
|
||||
"id": 3,
|
||||
"username": "maker_only",
|
||||
"display_name": "Maker Only",
|
||||
"roles": ["Maker"],
|
||||
"is_admin": False,
|
||||
"language_pref": "en",
|
||||
"theme_pref": "light",
|
||||
"active": True,
|
||||
}
|
||||
sess["user_id"] = 3
|
||||
sess["language"] = "en"
|
||||
sess["theme"] = "light"
|
||||
|
||||
resp = c.get("/statistics/dashboard")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestSPCProxy:
|
||||
"""SPC data proxy endpoint tests."""
|
||||
|
||||
def test_summary_proxy(self, logged_in_client, mock_api_client):
|
||||
"""Summary API proxy returns JSON from backend."""
|
||||
mock_api_client.get.return_value = {
|
||||
"total": 100,
|
||||
"pass_count": 90,
|
||||
"fail_count": 5,
|
||||
"warning_count": 5,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get(
|
||||
"/statistics/api/summary?recipe_id=1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["total"] == 100
|
||||
|
||||
def test_capability_proxy(self, logged_in_client, mock_api_client):
|
||||
"""Capability API proxy returns JSON from backend."""
|
||||
mock_api_client.get.return_value = {
|
||||
"cp": 1.33,
|
||||
"cpk": 1.20,
|
||||
}
|
||||
|
||||
resp = logged_in_client.get(
|
||||
"/statistics/api/capability?recipe_id=1&subtask_id=1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "cp" in data
|
||||
@@ -1048,3 +1048,77 @@ msgstr "Measurements Report"
|
||||
|
||||
msgid "Errore nella generazione del report"
|
||||
msgstr "Error generating report"
|
||||
|
||||
# Barcode Scanner i18n
|
||||
msgid "Libreria scanner non caricata"
|
||||
msgstr "Scanner library not loaded"
|
||||
|
||||
msgid "Nessuna fotocamera disponibile"
|
||||
msgstr "No camera available"
|
||||
|
||||
msgid "Impossibile accedere alla fotocamera"
|
||||
msgstr "Unable to access camera"
|
||||
|
||||
# CSV Export i18n
|
||||
msgid "Subtask ID"
|
||||
msgstr "Subtask ID"
|
||||
|
||||
msgid "Nome Sottotask"
|
||||
msgstr "Subtask Name"
|
||||
|
||||
msgid "Valore Misurato"
|
||||
msgstr "Measured Value"
|
||||
|
||||
msgid "Valore Nominale"
|
||||
msgstr "Nominal Value"
|
||||
|
||||
msgid "Tolleranza +"
|
||||
msgstr "Tolerance +"
|
||||
|
||||
msgid "Tolleranza -"
|
||||
msgstr "Tolerance -"
|
||||
|
||||
msgid "Scarto"
|
||||
msgstr "Deviation"
|
||||
|
||||
msgid "Metodo Input"
|
||||
msgstr "Input Method"
|
||||
|
||||
msgid "Data Misurazione"
|
||||
msgstr "Measurement Date"
|
||||
|
||||
msgid "RIEPILOGO ESECUZIONE TASK"
|
||||
msgstr "TASK EXECUTION SUMMARY"
|
||||
|
||||
msgid "Task ID"
|
||||
msgstr "Task ID"
|
||||
|
||||
msgid "Nome Task"
|
||||
msgstr "Task Name"
|
||||
|
||||
msgid "Data Inizio"
|
||||
msgstr "Start Date"
|
||||
|
||||
msgid "Data Fine"
|
||||
msgstr "End Date"
|
||||
|
||||
msgid "Stato"
|
||||
msgstr "Status"
|
||||
|
||||
msgid "STATISTICHE"
|
||||
msgstr "STATISTICS"
|
||||
|
||||
msgid "Totale Misure"
|
||||
msgstr "Total Measurements"
|
||||
|
||||
msgid "Passate"
|
||||
msgstr "Passed"
|
||||
|
||||
msgid "Fallite"
|
||||
msgstr "Failed"
|
||||
|
||||
msgid "Percentuale Successo"
|
||||
msgstr "Pass Rate"
|
||||
|
||||
msgid "DETTAGLIO MISURE"
|
||||
msgstr "MEASUREMENT DETAILS"
|
||||
|
||||
@@ -1048,3 +1048,77 @@ msgstr "Report Misurazioni"
|
||||
|
||||
msgid "Errore nella generazione del report"
|
||||
msgstr "Errore nella generazione del report"
|
||||
|
||||
# Barcode Scanner i18n
|
||||
msgid "Libreria scanner non caricata"
|
||||
msgstr "Libreria scanner non caricata"
|
||||
|
||||
msgid "Nessuna fotocamera disponibile"
|
||||
msgstr "Nessuna fotocamera disponibile"
|
||||
|
||||
msgid "Impossibile accedere alla fotocamera"
|
||||
msgstr "Impossibile accedere alla fotocamera"
|
||||
|
||||
# CSV Export i18n
|
||||
msgid "Subtask ID"
|
||||
msgstr "Subtask ID"
|
||||
|
||||
msgid "Nome Sottotask"
|
||||
msgstr "Nome Sottotask"
|
||||
|
||||
msgid "Valore Misurato"
|
||||
msgstr "Valore Misurato"
|
||||
|
||||
msgid "Valore Nominale"
|
||||
msgstr "Valore Nominale"
|
||||
|
||||
msgid "Tolleranza +"
|
||||
msgstr "Tolleranza +"
|
||||
|
||||
msgid "Tolleranza -"
|
||||
msgstr "Tolleranza -"
|
||||
|
||||
msgid "Scarto"
|
||||
msgstr "Scarto"
|
||||
|
||||
msgid "Metodo Input"
|
||||
msgstr "Metodo Input"
|
||||
|
||||
msgid "Data Misurazione"
|
||||
msgstr "Data Misurazione"
|
||||
|
||||
msgid "RIEPILOGO ESECUZIONE TASK"
|
||||
msgstr "RIEPILOGO ESECUZIONE TASK"
|
||||
|
||||
msgid "Task ID"
|
||||
msgstr "Task ID"
|
||||
|
||||
msgid "Nome Task"
|
||||
msgstr "Nome Task"
|
||||
|
||||
msgid "Data Inizio"
|
||||
msgstr "Data Inizio"
|
||||
|
||||
msgid "Data Fine"
|
||||
msgstr "Data Fine"
|
||||
|
||||
msgid "Stato"
|
||||
msgstr "Stato"
|
||||
|
||||
msgid "STATISTICHE"
|
||||
msgstr "STATISTICHE"
|
||||
|
||||
msgid "Totale Misure"
|
||||
msgstr "Totale Misure"
|
||||
|
||||
msgid "Passate"
|
||||
msgstr "Passate"
|
||||
|
||||
msgid "Fallite"
|
||||
msgstr "Fallite"
|
||||
|
||||
msgid "Percentuale Successo"
|
||||
msgstr "Percentuale Successo"
|
||||
|
||||
msgid "DETTAGLIO MISURE"
|
||||
msgstr "DETTAGLIO MISURE"
|
||||
|
||||
Reference in New Issue
Block a user