diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9951adf..342173f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,6 +14,7 @@ services: --authentication-policy=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + --default-time-zone=+00:00 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s diff --git a/docker-compose.yml b/docker-compose.yml index b985128..33ef3e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: --authentication-policy=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + --default-time-zone=+00:00 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s diff --git a/src/backend/api/middleware/api_key.py b/src/backend/api/middleware/api_key.py index fb48cb3..b6701fc 100644 --- a/src/backend/api/middleware/api_key.py +++ b/src/backend/api/middleware/api_key.py @@ -9,7 +9,8 @@ from src.backend.database import get_db from src.backend.config import settings from src.backend.models.orm.api_key import ApiKey from src.backend.models.orm.user import User -from src.backend.services.auth_service import hash_api_key, utcnow +from src.backend.services.auth_service import hash_api_key +from src.backend.utils.tempo import utcnow _RENEW_INTERVAL = timedelta(minutes=5) diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 5a6432f..73d8314 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -2,6 +2,8 @@ import csv import io from datetime import datetime +from src.backend.models.api.tipi import UtcNaiveInput +from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse @@ -232,8 +234,8 @@ async def get_measurements( measured_by: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), production_run_id: int | None = Query(None, gt=0), page: int = Query(1, ge=1), @@ -290,8 +292,8 @@ async def export_measurements_csv( measured_by: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -372,7 +374,7 @@ async def export_measurements_csv( neutralise_csv_cell(m.lot_number or ""), neutralise_csv_cell(m.serial_number or ""), m.input_method, - m.measured_at.isoformat(), + iso_utc(m.measured_at), m.synced_to_csv, ]) @@ -382,6 +384,6 @@ async def export_measurements_csv( iter([output.getvalue()]), media_type="text/csv", headers={ - "Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + "Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv" }, ) diff --git a/src/backend/api/routers/reports.py b/src/backend/api/routers/reports.py index fb7ccd0..201c3e5 100644 --- a/src/backend/api/routers/reports.py +++ b/src/backend/api/routers/reports.py @@ -1,5 +1,5 @@ """Reports router — PDF report generation endpoints for Metrologist.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcNaiveInput from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import Response @@ -18,8 +18,8 @@ async def download_spc_report( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), @@ -52,8 +52,8 @@ async def download_measurement_report( recipe_id: int = Query(...), subtask_id: int | None = Query(None), version_id: int | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), diff --git a/src/backend/api/routers/setup.py b/src/backend/api/routers/setup.py index 5ab3f77..b9c4aaa 100644 --- a/src/backend/api/routers/setup.py +++ b/src/backend/api/routers/setup.py @@ -6,6 +6,7 @@ NOT protected by API Key auth (standalone access). import hmac import random from datetime import datetime, timedelta +from src.backend.utils.tempo import utcnow from pathlib import Path from alembic import command @@ -555,7 +556,7 @@ async def seed_demo_data(body: PasswordBody): tec_user = created_users[2] # tec1 lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] - base_date = datetime.now() + base_date = utcnow() all_subtasks = subtasks_1 + subtasks_2 measurements_created = 0 diff --git a/src/backend/api/routers/statistics.py b/src/backend/api/routers/statistics.py index 651c4ef..b121ab7 100644 --- a/src/backend/api/routers/statistics.py +++ b/src/backend/api/routers/statistics.py @@ -1,5 +1,6 @@ """Statistics router - SPC endpoints for Metrologist dashboard.""" from datetime import datetime +from src.backend.models.api.tipi import UtcNaiveInput from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, select @@ -100,8 +101,8 @@ async def get_summary( recipe_id: int = Query(...), version_id: int | None = Query(None), subtask_id: int | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), @@ -122,8 +123,8 @@ async def get_capability( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), @@ -145,8 +146,8 @@ async def get_control_chart( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), @@ -172,8 +173,8 @@ async def get_histogram( recipe_id: int = Query(...), subtask_id: int = Query(...), version_id: int | None = Query(None), - date_from: datetime | None = Query(None), - date_to: datetime | None = Query(None), + date_from: UtcNaiveInput = Query(None), + date_to: UtcNaiveInput = Query(None), operator_id: int | None = Query(None), lot_number: str | None = Query(None), serial_number: str | None = Query(None), diff --git a/src/backend/config.py b/src/backend/config.py index 48ea38a..ae3f84c 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -28,6 +28,9 @@ class Settings(BaseSettings): db_user: str = "tmflow" db_password: str = _DEFAULT_DB_PASSWORD + # Presentation time zone (DB is UTC, #42) + display_timezone: str = "Europe/Rome" + # Server server_host: str = "0.0.0.0" server_port: int = 8000 diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index f578463..00a5f2e 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -1,5 +1,5 @@ """Pydantic schemas for Measurement operations.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel): # Who let this value stand, when it was out of tolerance. Null on a value in # tolerance - and on one that is not and is still waiting for an answer. supervisor_id: Optional[int] = None - authorised_at: Optional[datetime] = None - measured_at: datetime + authorised_at: Optional[UtcDateTime] = None + measured_at: UtcDateTime synced_to_csv: bool @@ -97,7 +97,7 @@ class CycleMeasurementResponse(BaseModel): subtask_id: int value: float pass_fail: str - measured_at: datetime + measured_at: UtcDateTime class CurrentCycleResponse(BaseModel): @@ -114,8 +114,8 @@ class MeasurementQuery(BaseModel): measured_by: Optional[int] = None lot_number: Optional[str] = None serial_number: Optional[str] = None - date_from: Optional[datetime] = None - date_to: Optional[datetime] = None + date_from: UtcNaiveInput = None + date_to: UtcNaiveInput = None pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$") page: int = Field(1, ge=1) per_page: int = Field(50, ge=1, le=500) diff --git a/src/backend/models/api/production.py b/src/backend/models/api/production.py index a8da51e..f382ddb 100644 --- a/src/backend/models/api/production.py +++ b/src/backend/models/api/production.py @@ -1,5 +1,5 @@ """Pydantic schemas for production runs and their events.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel): supervisor_id: Optional[int] task_id: Optional[int] = None note: Optional[str] - created_at: datetime + created_at: UtcDateTime class ProductionRunResponse(BaseModel): @@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel): serial_number: Optional[str] status: str measurement_interval_minutes: Optional[int] - next_measurement_at: Optional[datetime] + next_measurement_at: Optional[UtcDateTime] cycle_count: int - started_at: datetime - paused_at: Optional[datetime] - closed_at: Optional[datetime] + started_at: UtcDateTime + paused_at: Optional[UtcDateTime] + closed_at: Optional[UtcDateTime] closed_by: Optional[int] statistics_path: Optional[str] = None @@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel): # overdue is a fact the operator must see, not deduce. seconds_to_next_measurement: Optional[int] = None overdue: bool = False - server_time: Optional[datetime] = None + server_time: Optional[UtcDateTime] = None # The measurement tasks of this run's version, in execution order. The client # needs them to bring the operator back to the measurement when the interval diff --git a/src/backend/models/api/recipe.py b/src/backend/models/api/recipe.py index f46d504..07a7888 100644 --- a/src/backend/models/api/recipe.py +++ b/src/backend/models/api/recipe.py @@ -1,5 +1,5 @@ """Pydantic schemas for Recipe and RecipeVersion operations.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime from typing import Optional, TYPE_CHECKING from pydantic import BaseModel, ConfigDict, Field @@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel): version_number: int is_current: bool created_by: int - created_at: datetime + created_at: UtcDateTime change_notes: Optional[str] = None tasks: list["TaskResponse"] = [] @@ -72,7 +72,7 @@ class RecipeResponse(BaseModel): requires_serial: bool = False allow_manual_input: bool = False created_by: int - created_at: datetime + created_at: UtcDateTime active: bool current_version: Optional[RecipeVersionResponse] = None diff --git a/src/backend/models/api/station.py b/src/backend/models/api/station.py index 23afe9d..9e15bf4 100644 --- a/src/backend/models/api/station.py +++ b/src/backend/models/api/station.py @@ -1,5 +1,5 @@ """Pydantic schemas for Station and StationRecipeAssignment.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -29,7 +29,7 @@ class StationResponse(BaseModel): notes: Optional[str] active: bool created_by: int - created_at: datetime + created_at: UtcDateTime class StationRecipeAssignmentCreate(BaseModel): @@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel): station_id: int recipe_id: int assigned_by: int - assigned_at: datetime + assigned_at: UtcDateTime class RecipeSummary(BaseModel): diff --git a/src/backend/models/api/statistics.py b/src/backend/models/api/statistics.py index 1bf11f9..6319afa 100644 --- a/src/backend/models/api/statistics.py +++ b/src/backend/models/api/statistics.py @@ -1,5 +1,5 @@ """Pydantic schemas for SPC statistics responses.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput from typing import Optional from pydantic import BaseModel, Field @@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel): recipe_id: int version_id: Optional[int] = None subtask_id: Optional[int] = None - date_from: Optional[datetime] = None - date_to: Optional[datetime] = None + date_from: UtcNaiveInput = None + date_to: UtcNaiveInput = None operator_id: Optional[int] = None lot_number: Optional[str] = None serial_number: Optional[str] = None @@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel): class ControlChartData(BaseModel): """Data for X-bar/R control chart.""" values: list[float] - timestamps: list[datetime] + timestamps: list[UtcDateTime] mean: float ucl: float # Upper Control Limit lcl: float # Lower Control Limit @@ -60,7 +60,7 @@ class CapabilityData(BaseModel): class TrendData(BaseModel): """Temporal trend data for capability indices.""" - dates: list[datetime] + dates: list[UtcDateTime] cpk_values: list[Optional[float]] ppk_values: list[Optional[float]] n_per_period: list[int] diff --git a/src/backend/models/api/tipi.py b/src/backend/models/api/tipi.py new file mode 100644 index 0000000..cf3189d --- /dev/null +++ b/src/backend/models/api/tipi.py @@ -0,0 +1,14 @@ +"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire.""" +from datetime import datetime +from typing import Annotated, Optional + +from pydantic import AfterValidator, PlainSerializer + +from src.backend.utils.tempo import as_utc_naive, to_utc_aware + +UtcDateTime = Annotated[ + datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str) +] + +# Input: aware values are converted to UTC naive, naive is taken as UTC. +UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)] diff --git a/src/backend/models/api/user.py b/src/backend/models/api/user.py index 7c6e71b..eb022ac 100644 --- a/src/backend/models/api/user.py +++ b/src/backend/models/api/user.py @@ -1,5 +1,5 @@ """Pydantic schemas for User operations.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -48,8 +48,8 @@ class UserResponse(BaseModel): language_pref: str theme_pref: str active: bool - created_at: datetime - last_login: Optional[datetime] = None + created_at: UtcDateTime + last_login: Optional[UtcDateTime] = None class UserPasswordChange(BaseModel): diff --git a/src/backend/models/api/vision.py b/src/backend/models/api/vision.py index 4a8f466..56b58b7 100644 --- a/src/backend/models/api/vision.py +++ b/src/backend/models/api/vision.py @@ -1,5 +1,5 @@ """Pydantic schemas for the vision execution response.""" -from datetime import datetime +from src.backend.models.api.tipi import UtcDateTime from pydantic import BaseModel, ConfigDict @@ -28,4 +28,4 @@ class VisionReferenceImageResponse(BaseModel): note: str | None = None station_id: int | None = None device_code: str | None = None - acquired_at: datetime + acquired_at: UtcDateTime diff --git a/src/backend/services/auth_service.py b/src/backend/services/auth_service.py index 8d9c5ae..3da2328 100644 --- a/src/backend/services/auth_service.py +++ b/src/backend/services/auth_service.py @@ -1,7 +1,8 @@ """Authentication service - password hashing, API key management.""" import hashlib import secrets -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta +from src.backend.utils.tempo import utcnow import bcrypt from fastapi import HTTPException, status @@ -78,11 +79,6 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) - return supervisor -def utcnow() -> datetime: - """Naive UTC now (the whole DB moves to naive UTC, #42).""" - return datetime.now(timezone.utc).replace(tzinfo=None) - - def hash_api_key(key: str) -> str: """SHA-256 hex digest of an API key; this is all the DB ever stores.""" return hashlib.sha256(key.encode("utf-8")).hexdigest() diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index f94db53..456249b 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -1,6 +1,7 @@ """Measurement service - pass/fail calculation, data storage.""" import math from datetime import datetime +from src.backend.utils.tempo import utcnow from decimal import Decimal from typing import TYPE_CHECKING @@ -343,7 +344,7 @@ async def authorise_measurement( ) -> Measurement: """Let an out-of-tolerance value stand, on the supervisor's name.""" measurement.supervisor_id = supervisor.id - measurement.authorised_at = datetime.now() + measurement.authorised_at = utcnow() await db.flush() await db.refresh(measurement) return measurement diff --git a/src/backend/services/production_export_service.py b/src/backend/services/production_export_service.py index c7e979a..4d7acdb 100644 --- a/src/backend/services/production_export_service.py +++ b/src/backend/services/production_export_service.py @@ -14,6 +14,7 @@ import csv import io import logging from datetime import datetime +from src.backend.utils.tempo import iso_utc, to_local, utcnow from pathlib import Path from sqlalchemy import select, update @@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str: measurement.input_method, "" if measurement.input_duration_ms is None else measurement.input_duration_ms, measurement.measured_by, - measurement.measured_at.isoformat() if measurement.measured_at else "", + iso_utc(measurement.measured_at), measurement.supervisor_id or "", - measurement.authorised_at.isoformat() if measurement.authorised_at else "", + iso_utc(measurement.authorised_at), ]) return output.getvalue() @@ -191,7 +192,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N directory = Path(settings.upload_path) / EXPORT_SUBDIR directory.mkdir(parents=True, exist_ok=True) - stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S") + stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S") filename = f"production_{run.id}_{stamp}.csv" # A large run's file can take a moment to write: off the event loop, so # closing one run does not stall every other request being served. diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py index 2611351..512e3bf 100644 --- a/src/backend/services/production_service.py +++ b/src/backend/services/production_service.py @@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the timestamp is only there for the audit trail. """ from datetime import datetime, timedelta +from src.backend.utils.tempo import utcnow from typing import Optional from fastapi import HTTPException, status @@ -34,10 +35,10 @@ from src.backend.services import ( def _now() -> datetime: """Single source of time for runs. - Naive local time, matching what the database writes for ``func.now()`` defaults, + Naive UTC, matching what the database writes for ``func.now()`` defaults, so a run's started_at and its deadline are comparable. """ - return datetime.now() + return utcnow() # --------------------------------------------------------------------------- diff --git a/src/backend/services/report_service.py b/src/backend/services/report_service.py index b89927a..a5ded88 100644 --- a/src/backend/services/report_service.py +++ b/src/backend/services/report_service.py @@ -2,6 +2,7 @@ import asyncio import base64 from datetime import datetime +from src.backend.utils.tempo import to_local, utcnow from pathlib import Path import plotly.graph_objects as go @@ -342,7 +343,7 @@ async def generate_spc_report( control_chart_svg=control_chart_svg, histogram_svg=histogram_svg, filters_desc=filters_desc, - generated_at=datetime.now(), + generated_at=to_local(utcnow()), n_measurements=len(measurements), ) @@ -395,7 +396,7 @@ async def generate_measurement_report( "value": f"{float(m.value):.4f}", "unit": st_info.get("unit", "mm"), "pass_fail": m.pass_fail, - "measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"), + "measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"), "operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"), "lot_number": m.lot_number or "—", "serial_number": m.serial_number or "—", @@ -430,7 +431,7 @@ async def generate_measurement_report( rows=rows, summary=summary, filters_desc=filters_desc, - generated_at=datetime.now(), + generated_at=to_local(utcnow()), n_measurements=len(measurements), ) diff --git a/src/backend/tests/test_tempo.py b/src/backend/tests/test_tempo.py new file mode 100644 index 0000000..98b5eb5 --- /dev/null +++ b/src/backend/tests/test_tempo.py @@ -0,0 +1,108 @@ +"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42).""" +from datetime import datetime, timezone +from unittest.mock import patch + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from src.backend.models.orm.user import User +from src.backend.services import report_service +from src.backend.tests.conftest import auth_headers, create_test_recipe +from src.backend.utils.tempo import as_utc_naive, to_local, utcnow + + +def test_utcnow_naive_and_close_to_utc(): + now = utcnow() + assert now.tzinfo is None + delta = datetime.now(timezone.utc).replace(tzinfo=None) - now + assert abs(delta.total_seconds()) < 5 + + +def test_as_utc_naive(): + parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00") + assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0) + assert as_utc_naive(parsed).tzinfo is None + naive = datetime(2026, 10, 6, 8, 0) + assert as_utc_naive(naive) is naive + assert as_utc_naive(None) is None + + +def test_to_local_is_rome(): + assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200" + assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100" + + +async def _make_measurement(client, user, recipe_id) -> None: + tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json() + await client.post( + "/api/measurements/", + headers=auth_headers(user), + json={ + "subtask_id": tasks[0]["subtasks"][0]["id"], + "version_id": tasks[0]["version_id"], + "value": 10.0, + }, + ) + + +async def test_api_datetimes_carry_utc_offset_and_filter_equivalence( + client: AsyncClient, + measurement_tec_user: User, + metrologist_user: User, + db_session: AsyncSession, +): + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + await _make_measurement(client, measurement_tec_user, recipe.id) + + resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user)) + item = resp.json()["items"][0] + assert item["measured_at"].endswith("+00:00") + + # Same instant written with +02:00 and as UTC: identical results. + future_utc = "2099-01-01T08:00:00+00:00" + future_rome = "2099-01-01T10:00:00+02:00" + past_utc = "2000-01-01T08:00:00+00:00" + past_rome = "2000-01-01T10:00:00+02:00" + for frm, to in ((past_utc, future_utc), (past_rome, future_rome)): + r = await client.get( + "/api/measurements/", + headers=auth_headers(metrologist_user), + params={"date_from": frm, "date_to": to}, + ) + assert r.status_code == 200 + assert r.json()["total"] == 1 + + # Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC, + # written as +02:00 (now+3h local wall clock), must exclude it. + ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc) + from datetime import timedelta + ahead_utc = (ahead + timedelta(hours=1)).isoformat() + ahead_rome = (ahead + timedelta(hours=1)).astimezone( + timezone(timedelta(hours=2)) + ).isoformat() + totals = [] + for frm in (ahead_utc, ahead_rome): + r = await client.get( + "/api/measurements/", + headers=auth_headers(metrologist_user), + params={"date_from": frm}, + ) + totals.append(r.json()["total"]) + assert totals == [0, 0] + + +async def test_report_generated_at_is_local_time( + db_session: AsyncSession, metrologist_user: User +): + recipe = await create_test_recipe(db_session, metrologist_user.id) + captured: dict[str, str] = {} + + def fake_render(html: str) -> bytes: + captured["html"] = html + return b"%PDF" + + with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \ + patch.object(report_service, "_render_pdf", side_effect=fake_render): + await report_service.generate_measurement_report(db_session, recipe.id) + + assert "Generated 2026-10-06 10:00" in captured["html"] diff --git a/src/backend/utils/__init__.py b/src/backend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/utils/tempo.py b/src/backend/utils/tempo.py new file mode 100644 index 0000000..5141be0 --- /dev/null +++ b/src/backend/utils/tempo.py @@ -0,0 +1,34 @@ +"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42).""" +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from src.backend.config import settings + + +def utcnow() -> datetime: + """Naive UTC now (the whole DB moves to naive UTC, #42).""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def as_utc_naive(dt: datetime | None) -> datetime | None: + """Aware -> UTC naive; naive is taken as UTC already; None stays None.""" + if dt is None or dt.tzinfo is None: + return dt + return dt.astimezone(timezone.utc).replace(tzinfo=None) + + +def to_utc_aware(dt: datetime) -> datetime: + """Naive (UTC) or aware datetime -> aware UTC.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def iso_utc(dt: datetime | None) -> str: + """ISO 8601 with explicit +00:00; "" for None (CSV cells).""" + return to_utc_aware(dt).isoformat() if dt else "" + + +def to_local(dt: datetime) -> datetime: + """Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone.""" + return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))