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:
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
38
src/main.py
38
src/main.py
@@ -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()
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren