Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787575807d |
@@ -2,7 +2,8 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
|
||||
@@ -154,6 +155,26 @@ def create_app() -> Flask:
|
||||
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
||||
return Markup(bolded.replace("\n", "<br>"))
|
||||
|
||||
@app.template_filter("local_dt")
|
||||
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
|
||||
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
|
||||
|
||||
A value without timezone is taken as UTC. None or empty gives "-".
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return "-"
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return value
|
||||
if not isinstance(value, datetime):
|
||||
return str(value)
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
|
||||
return value.astimezone(tz).strftime(fmt)
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
|
||||
@@ -56,6 +56,8 @@ class Config:
|
||||
# Babel i18n
|
||||
BABEL_DEFAULT_LOCALE = "it"
|
||||
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
||||
# Il DB e le API parlano UTC; si converte solo alla presentazione.
|
||||
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
|
||||
LANGUAGES = {"it": "Italiano", "en": "English"}
|
||||
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ function csvExport() {
|
||||
*/
|
||||
generateFilename(prefix) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
||||
|
||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
||||
<tr>
|
||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
||||
{{ m.measured_at|local_dt }}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);" class="text-sm">
|
||||
{{ m.task_info.title if m.task_info else '-' }}
|
||||
|
||||
@@ -312,8 +312,8 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
try {
|
||||
// Parallel fetch of all endpoints
|
||||
@@ -417,8 +417,8 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
const url = '/statistics/api/report-' + type + '?' + params.toString();
|
||||
const resp = await fetch(url);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_dt(flask_app):
|
||||
return flask_app.jinja_env.filters["local_dt"]
|
||||
|
||||
|
||||
def test_summer_time(local_dt):
|
||||
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
|
||||
|
||||
|
||||
def test_winter_time(local_dt):
|
||||
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
|
||||
|
||||
|
||||
def test_naive_is_utc(local_dt):
|
||||
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
|
||||
|
||||
|
||||
def test_datetime_object_and_format(local_dt):
|
||||
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
|
||||
assert local_dt(dt, "%H:%M") == "10:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("empty", [None, ""])
|
||||
def test_empty(local_dt, empty):
|
||||
assert local_dt(empty) == "-"
|
||||
@@ -407,6 +407,21 @@ class TestTaskComplete:
|
||||
assert resp.status_code == 200
|
||||
assert b"productionClock(" in resp.data
|
||||
|
||||
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
|
||||
"""#42: the API sends UTC; the summary shows Europe/Rome."""
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
|
||||
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
|
||||
"unit": "mm"}]}],
|
||||
None,
|
||||
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
|
||||
"measured_at": "2026-10-06T08:00:00+00:00"}]},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
|
||||
|
||||
def test_measurements_filtered_by_lot_serial_and_operator(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user