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 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-07 02:56:51 +01:00
Ursprung a8e9f34ff8
Commit a69352575d
6 geänderte Dateien mit 45 neuen und 35 gelöschten Zeilen

Datei anzeigen

@@ -3,7 +3,7 @@ import asyncio
import json import json
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime
from config import TIMEZONE from config import TIMEZONE
from typing import Optional from typing import Optional
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
@@ -206,7 +206,7 @@ async def _create_notifications_for_incident(
if not notifications: if not notifications:
return 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: if visibility == "public" and tenant_id:
cursor = await db.execute( cursor = await db.execute(
@@ -465,7 +465,7 @@ class AgentOrchestrator:
await db.execute( await db.execute(
"""UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen', """UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen',
completed_at = ? WHERE incident_id = ? AND status = 'running'""", 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() await db.commit()
except Exception as e: except Exception as e:
@@ -481,7 +481,7 @@ class AgentOrchestrator:
await db.execute( await db.execute(
"""UPDATE refresh_log SET status = 'error', error_message = ?, """UPDATE refresh_log SET status = 'error', error_message = ?,
completed_at = ? WHERE incident_id = ? AND status = 'running'""", 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() await db.commit()
except Exception as e: except Exception as e:
@@ -542,12 +542,12 @@ class AgentOrchestrator:
await db.execute( await db.execute(
"""UPDATE refresh_log SET status = 'error', error_message = 'Retry gestartet', """UPDATE refresh_log SET status = 'error', error_message = 'Retry gestartet',
completed_at = ? WHERE incident_id = ? AND status = 'running'""", 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() await db.commit()
# Refresh-Log starten # 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( cursor = await db.execute(
"INSERT INTO refresh_log (incident_id, started_at, status, trigger_type, retry_count, tenant_id) VALUES (?, ?, 'running', ?, ?, ?)", "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), (incident_id, now, trigger_type, retry_count, tenant_id),
@@ -1008,7 +1008,7 @@ class AgentOrchestrator:
cache_creation_tokens = ?, cache_read_tokens = ?, cache_creation_tokens = ?, cache_read_tokens = ?,
total_cost_usd = ?, api_calls = ? total_cost_usd = ?, api_calls = ?
WHERE id = ?""", 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.input_tokens, usage_acc.output_tokens,
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens, usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
round(usage_acc.total_cost_usd, 7), usage_acc.call_count, log_id), round(usage_acc.total_cost_usd, 7), usage_acc.call_count, log_id),

Datei anzeigen

@@ -1,7 +1,7 @@
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy.""" """JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
import secrets import secrets
import string import string
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from jose import jwt, JWTError from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -23,7 +23,7 @@ def create_token(
org_slug: str = None, org_slug: str = None,
) -> str: ) -> str:
"""JWT-Token erstellen mit Tenant-Kontext.""" """JWT-Token erstellen mit Tenant-Kontext."""
now = datetime.now(timezone.utc) now = datetime.now(TIMEZONE)
expire = now + timedelta(hours=JWT_EXPIRE_HOURS) expire = now + timedelta(hours=JWT_EXPIRE_HOURS)
payload = { payload = {
"sub": str(user_id), "sub": str(user_id),

Datei anzeigen

@@ -5,7 +5,7 @@ import logging
import os import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime
from typing import Dict from typing import Dict
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response
@@ -93,15 +93,15 @@ async def check_auto_refresh():
) )
incidents = await cursor.fetchall() incidents = await cursor.fetchall()
now = datetime.now(timezone.utc) now = datetime.now(TIMEZONE)
for incident in incidents: for incident in incidents:
incident_id = incident["id"] incident_id = incident["id"]
interval = incident["refresh_interval"] 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( 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,), (incident_id,),
) )
last_refresh = await cursor.fetchone() last_refresh = await cursor.fetchone()
@@ -110,21 +110,27 @@ async def check_auto_refresh():
if not last_refresh: if not last_refresh:
should_refresh = True should_refresh = True
else: else:
last_time = datetime.fromisoformat(last_refresh["completed_at"]) last_time = datetime.fromisoformat(last_refresh["started_at"])
if last_time.tzinfo is None: 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 elapsed = (now - last_time).total_seconds() / 60
if elapsed >= interval: if elapsed >= interval:
should_refresh = True 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: if should_refresh:
# Prüfen ob bereits ein laufender Refresh existiert # 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", "SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running' LIMIT 1",
(incident_id,), (incident_id,),
) )
if await cursor.fetchone(): if await running_cursor.fetchone():
continue # Laufender Refresh — überspringen logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)")
continue
await orchestrator.enqueue_refresh(incident_id, trigger_type="auto") await orchestrator.enqueue_refresh(incident_id, trigger_type="auto")
@@ -143,11 +149,13 @@ async def cleanup_expired():
) )
incidents = await cursor.fetchall() incidents = await cursor.fetchall()
now = datetime.now(timezone.utc) now = datetime.now(TIMEZONE)
for incident in incidents: for incident in incidents:
created = datetime.fromisoformat(incident["created_at"]) created = datetime.fromisoformat(incident["created_at"])
if created.tzinfo is None: 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 age_days = (now - created).days
if age_days >= incident["retention_days"]: if age_days >= incident["retention_days"]:
await db.execute( await db.execute(
@@ -164,12 +172,14 @@ async def cleanup_expired():
for orphan in orphans: for orphan in orphans:
started = datetime.fromisoformat(orphan["started_at"]) started = datetime.fromisoformat(orphan["started_at"])
if started.tzinfo is None: 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 age_minutes = (now - started).total_seconds() / 60
if age_minutes >= 15: if age_minutes >= 15:
await db.execute( await db.execute(
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?", "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)") 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: try:
result = await db.execute( result = await db.execute(
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = 'Verwaist (Neustart, automatisch bereinigt)' WHERE status = 'running'", "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: if result.rowcount > 0:
await db.commit() await db.commit()

Datei anzeigen

@@ -1,6 +1,6 @@
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung.""" """Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from models import ( from models import (
MagicLinkRequest, MagicLinkRequest,
@@ -78,7 +78,7 @@ async def request_magic_link(
# Token + Code generieren # Token + Code generieren
token = generate_magic_token() token = generate_magic_token()
code = generate_magic_code() 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 # Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
await db.execute( await db.execute(
@@ -124,10 +124,10 @@ async def verify_magic_link(
raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link") raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link")
# Ablauf pruefen # Ablauf pruefen
now = datetime.now(timezone.utc) now = datetime.now(TIMEZONE)
expires = datetime.fromisoformat(ml["expires_at"]) expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None: if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc) expires = expires.replace(tzinfo=TIMEZONE)
if now > expires: if now > expires:
raise HTTPException(status_code=400, detail="Link abgelaufen. Bitte neuen Link anfordern.") 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") raise HTTPException(status_code=400, detail="Ungueltiger Code")
# Ablauf pruefen # Ablauf pruefen
now = datetime.now(timezone.utc) now = datetime.now(TIMEZONE)
expires = datetime.fromisoformat(ml["expires_at"]) expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None: if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc) expires = expires.replace(tzinfo=TIMEZONE)
if now > expires: if now > expires:
raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.") raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.")

Datei anzeigen

@@ -5,7 +5,7 @@ from models import IncidentCreate, IncidentUpdate, IncidentResponse, Subscriptio
from auth import get_current_user from auth import get_current_user
from middleware.license_check import require_writable_license from middleware.license_check import require_writable_license
from database import db_dependency, get_db from database import db_dependency, get_db
from datetime import datetime, timezone from datetime import datetime
from config import TIMEZONE from config import TIMEZONE
import asyncio import asyncio
import aiosqlite import aiosqlite
@@ -102,7 +102,7 @@ async def create_incident(
): ):
"""Neue Lage anlegen.""" """Neue Lage anlegen."""
tenant_id = current_user.get("tenant_id") 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( cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, """INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
retention_days, international_sources, visibility, retention_days, international_sources, visibility,
@@ -184,7 +184,7 @@ async def update_incident(
if not updates: if not updates:
return await _enrich_incident(db, row) 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) set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [incident_id] values = list(updates.values()) + [incident_id]

Datei anzeigen

@@ -1,6 +1,6 @@
"""Lizenz-Verwaltung und -Pruefung.""" """Lizenz-Verwaltung und -Pruefung."""
import logging import logging
from datetime import datetime, timezone from datetime import datetime
from config import TIMEZONE from config import TIMEZONE
import aiosqlite 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"} return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"}
# Ablauf pruefen # Ablauf pruefen
now = datetime.now(timezone.utc) now = datetime.now(TIMEZONE)
valid_until = license_row["valid_until"] valid_until = license_row["valid_until"]
if valid_until is not None: if valid_until is not None:
try: try:
expiry = datetime.fromisoformat(valid_until) expiry = datetime.fromisoformat(valid_until)
if expiry.tzinfo is None: if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc) expiry = expiry.replace(tzinfo=TIMEZONE)
if now > expiry: if now > expiry:
return { return {
"valid": False, "valid": False,