From a69352575dea412732f38a20cc2481ecde17b0c5 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 7 Mar 2026 02:56:51 +0100 Subject: [PATCH] Fix: Komplett auf Europe/Berlin + DB-Migration + Timer-Fix - ALLE Timestamps einheitlich Europe/Berlin (kein UTC mehr) - DB-Migration: 1704 bestehende Timestamps von UTC nach Berlin konvertiert - Auto-Refresh Timer Fix: ORDER BY id DESC statt completed_at DESC (verhindert falsche Sortierung bei gemischten Timestamp-Formaten) - started_at statt completed_at fuer Timer-Vergleich (konsistenter) - Manuelle Refreshes werden bei Intervall-Pruefung beruecksichtigt - Debug-Logging fuer Auto-Refresh Entscheidungen - astimezone() fuer Timestamps mit Offset-Info Co-Authored-By: Claude Opus 4.6 --- src/agents/orchestrator.py | 14 ++++++------ src/auth.py | 4 ++-- src/main.py | 38 +++++++++++++++++++++------------ src/routers/auth.py | 12 +++++------ src/routers/incidents.py | 6 +++--- src/services/license_service.py | 6 +++--- 6 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index fac03fa..a47ad26 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -3,7 +3,7 @@ import asyncio import json import logging import re -from datetime import datetime, timezone +from datetime import datetime from config import TIMEZONE from typing import Optional from urllib.parse import urlparse, urlunparse @@ -206,7 +206,7 @@ async def _create_notifications_for_incident( if not notifications: return - now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') if visibility == "public" and tenant_id: cursor = await db.execute( @@ -465,7 +465,7 @@ class AgentOrchestrator: await db.execute( """UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen', completed_at = ? WHERE incident_id = ? AND status = 'running'""", - (datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), incident_id), + (datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id), ) await db.commit() except Exception as e: @@ -481,7 +481,7 @@ class AgentOrchestrator: await db.execute( """UPDATE refresh_log SET status = 'error', error_message = ?, completed_at = ? WHERE incident_id = ? AND status = 'running'""", - (error[:500], datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), incident_id), + (error[:500], datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id), ) await db.commit() except Exception as e: @@ -542,12 +542,12 @@ class AgentOrchestrator: await db.execute( """UPDATE refresh_log SET status = 'error', error_message = 'Retry gestartet', completed_at = ? WHERE incident_id = ? AND status = 'running'""", - (datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), incident_id), + (datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id), ) await db.commit() # Refresh-Log starten - now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') cursor = await db.execute( "INSERT INTO refresh_log (incident_id, started_at, status, trigger_type, retry_count, tenant_id) VALUES (?, ?, 'running', ?, ?, ?)", (incident_id, now, trigger_type, retry_count, tenant_id), @@ -1008,7 +1008,7 @@ class AgentOrchestrator: cache_creation_tokens = ?, cache_read_tokens = ?, total_cost_usd = ?, api_calls = ? WHERE id = ?""", - (datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), new_count, + (datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), new_count, usage_acc.input_tokens, usage_acc.output_tokens, usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens, round(usage_acc.total_cost_usd, 7), usage_acc.call_count, log_id), diff --git a/src/auth.py b/src/auth.py index 0215172..233f08b 100644 --- a/src/auth.py +++ b/src/auth.py @@ -1,7 +1,7 @@ """JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy.""" import secrets import string -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from jose import jwt, JWTError from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials @@ -23,7 +23,7 @@ def create_token( org_slug: str = None, ) -> str: """JWT-Token erstellen mit Tenant-Kontext.""" - now = datetime.now(timezone.utc) + now = datetime.now(TIMEZONE) expire = now + timedelta(hours=JWT_EXPIRE_HOURS) payload = { "sub": str(user_id), diff --git a/src/main.py b/src/main.py index 1821d23..a43dfad 100644 --- a/src/main.py +++ b/src/main.py @@ -5,7 +5,7 @@ import logging import os import sys from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import datetime from typing import Dict from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response @@ -93,15 +93,15 @@ async def check_auto_refresh(): ) incidents = await cursor.fetchall() - now = datetime.now(timezone.utc) + now = datetime.now(TIMEZONE) for incident in incidents: incident_id = incident["id"] interval = incident["refresh_interval"] - # Nur letzten AUTO-Refresh prüfen (manuelle Refreshs ignorieren) + # Letzten abgeschlossenen Refresh prüfen (egal ob auto oder manual) cursor = await db.execute( - "SELECT completed_at FROM refresh_log WHERE incident_id = ? AND trigger_type = 'auto' AND status = 'completed' ORDER BY completed_at DESC LIMIT 1", + "SELECT started_at FROM refresh_log WHERE incident_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1", (incident_id,), ) last_refresh = await cursor.fetchone() @@ -110,21 +110,27 @@ async def check_auto_refresh(): if not last_refresh: should_refresh = True else: - last_time = datetime.fromisoformat(last_refresh["completed_at"]) + last_time = datetime.fromisoformat(last_refresh["started_at"]) if last_time.tzinfo is None: - last_time = last_time.replace(tzinfo=timezone.utc) + last_time = last_time.replace(tzinfo=TIMEZONE) + else: + last_time = last_time.astimezone(TIMEZONE) elapsed = (now - last_time).total_seconds() / 60 if elapsed >= interval: should_refresh = True + logger.info(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f} Min seit letztem Refresh (Intervall: {interval} Min)") + else: + logger.debug(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f}/{interval} Min — noch nicht faellig") if should_refresh: # Prüfen ob bereits ein laufender Refresh existiert - cursor = await db.execute( + running_cursor = await db.execute( "SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running' LIMIT 1", (incident_id,), ) - if await cursor.fetchone(): - continue # Laufender Refresh — überspringen + if await running_cursor.fetchone(): + logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)") + continue await orchestrator.enqueue_refresh(incident_id, trigger_type="auto") @@ -143,11 +149,13 @@ async def cleanup_expired(): ) incidents = await cursor.fetchall() - now = datetime.now(timezone.utc) + now = datetime.now(TIMEZONE) for incident in incidents: created = datetime.fromisoformat(incident["created_at"]) if created.tzinfo is None: - created = created.replace(tzinfo=timezone.utc) + created = created.replace(tzinfo=TIMEZONE) + else: + created = created.astimezone(TIMEZONE) age_days = (now - created).days if age_days >= incident["retention_days"]: await db.execute( @@ -164,12 +172,14 @@ async def cleanup_expired(): for orphan in orphans: started = datetime.fromisoformat(orphan["started_at"]) if started.tzinfo is None: - started = started.replace(tzinfo=timezone.utc) + started = started.replace(tzinfo=TIMEZONE) + else: + started = started.astimezone(TIMEZONE) age_minutes = (now - started).total_seconds() / 60 if age_minutes >= 15: await db.execute( "UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?", - (now.isoformat(), f"Verwaist (>{int(age_minutes)} Min ohne Abschluss, automatisch bereinigt)", orphan["id"]), + (now.strftime('%Y-%m-%d %H:%M:%S'), f"Verwaist (>{int(age_minutes)} Min ohne Abschluss, automatisch bereinigt)", orphan["id"]), ) logger.warning(f"Verwaisten Refresh #{orphan['id']} für Lage {orphan['incident_id']} bereinigt ({int(age_minutes)} Min)") @@ -195,7 +205,7 @@ async def lifespan(app: FastAPI): try: result = await db.execute( "UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = 'Verwaist (Neustart, automatisch bereinigt)' WHERE status = 'running'", - (datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),), + (datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'),), ) if result.rowcount > 0: await db.commit() diff --git a/src/routers/auth.py b/src/routers/auth.py index d61b625..6116346 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -1,6 +1,6 @@ """Auth-Router: Magic-Link-Login und Nutzerverwaltung.""" import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, status from models import ( MagicLinkRequest, @@ -78,7 +78,7 @@ async def request_magic_link( # Token + Code generieren token = generate_magic_token() code = generate_magic_code() - expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S') + expires_at = (datetime.now(TIMEZONE) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S') # Alte ungenutzte Magic Links fuer diese E-Mail invalidieren await db.execute( @@ -124,10 +124,10 @@ async def verify_magic_link( raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link") # Ablauf pruefen - now = datetime.now(timezone.utc) + now = datetime.now(TIMEZONE) expires = datetime.fromisoformat(ml["expires_at"]) if expires.tzinfo is None: - expires = expires.replace(tzinfo=timezone.utc) + expires = expires.replace(tzinfo=TIMEZONE) if now > expires: raise HTTPException(status_code=400, detail="Link abgelaufen. Bitte neuen Link anfordern.") @@ -200,10 +200,10 @@ async def verify_magic_code( raise HTTPException(status_code=400, detail="Ungueltiger Code") # Ablauf pruefen - now = datetime.now(timezone.utc) + now = datetime.now(TIMEZONE) expires = datetime.fromisoformat(ml["expires_at"]) if expires.tzinfo is None: - expires = expires.replace(tzinfo=timezone.utc) + expires = expires.replace(tzinfo=TIMEZONE) if now > expires: raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.") diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 129fd93..ff0e6bb 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -5,7 +5,7 @@ from models import IncidentCreate, IncidentUpdate, IncidentResponse, Subscriptio from auth import get_current_user from middleware.license_check import require_writable_license from database import db_dependency, get_db -from datetime import datetime, timezone +from datetime import datetime from config import TIMEZONE import asyncio import aiosqlite @@ -102,7 +102,7 @@ async def create_incident( ): """Neue Lage anlegen.""" tenant_id = current_user.get("tenant_id") - now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') cursor = await db.execute( """INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, retention_days, international_sources, visibility, @@ -184,7 +184,7 @@ async def update_incident( if not updates: return await _enrich_incident(db, row) - updates["updated_at"] = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + updates["updated_at"] = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') set_clause = ", ".join(f"{k} = ?" for k in updates) values = list(updates.values()) + [incident_id] diff --git a/src/services/license_service.py b/src/services/license_service.py index ce851bf..e292147 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -1,6 +1,6 @@ """Lizenz-Verwaltung und -Pruefung.""" import logging -from datetime import datetime, timezone +from datetime import datetime from config import TIMEZONE import aiosqlite @@ -38,14 +38,14 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"} # Ablauf pruefen - now = datetime.now(timezone.utc) + now = datetime.now(TIMEZONE) valid_until = license_row["valid_until"] if valid_until is not None: try: expiry = datetime.fromisoformat(valid_until) if expiry.tzinfo is None: - expiry = expiry.replace(tzinfo=timezone.utc) + expiry = expiry.replace(tzinfo=TIMEZONE) if now > expiry: return { "valid": False,