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 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),

Datei anzeigen

@@ -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),

Datei anzeigen

@@ -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()

Datei anzeigen

@@ -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.")

Datei anzeigen

@@ -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]

Datei anzeigen

@@ -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,