diff --git a/src/database.py b/src/database.py index db6b1a8..a22891b 100644 --- a/src/database.py +++ b/src/database.py @@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS incidents ( type TEXT DEFAULT 'adhoc', refresh_mode TEXT DEFAULT 'manual', refresh_interval INTEGER DEFAULT 15, + refresh_start_time TEXT, retention_days INTEGER DEFAULT 0, visibility TEXT DEFAULT 'public', summary TEXT, @@ -362,6 +363,12 @@ async def init_db(): await db.commit() logger.info("Migration: tenant_id zu incidents hinzugefuegt") + if "refresh_start_time" not in columns: + await db.execute("ALTER TABLE incidents ADD COLUMN refresh_start_time TEXT") + await db.execute("UPDATE incidents SET refresh_start_time = '07:00' WHERE refresh_mode = 'auto'") + await db.commit() + logger.info("Migration: refresh_start_time zu incidents hinzugefuegt (bestehende Auto-Lagen auf 07:00)") + # Migration: Token-Spalten fuer refresh_log cursor = await db.execute("PRAGMA table_info(refresh_log)") rl_columns = [row[1] for row in await cursor.fetchall()] diff --git a/src/main.py b/src/main.py index cc76ddc..f930a07 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 +from datetime import datetime, timedelta from typing import Dict from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response @@ -107,11 +107,11 @@ scheduler = AsyncIOScheduler() async def check_auto_refresh(): - """Prüft welche Lagen einen Auto-Refresh brauchen.""" + """Prüft welche Lagen einen Auto-Refresh brauchen (Slot-basiert).""" db = await get_db() try: cursor = await db.execute( - "SELECT id, refresh_interval FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'" + "SELECT id, refresh_interval, refresh_start_time FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'" ) incidents = await cursor.fetchall() @@ -120,18 +120,72 @@ async def check_auto_refresh(): for incident in incidents: incident_id = incident["id"] interval = incident["refresh_interval"] + start_time_str = incident["refresh_start_time"] - # Letzten abgeschlossenen Refresh prüfen (egal ob auto oder manual) + # Letzten abgeschlossenen oder laufenden Refresh pruefen cursor = await db.execute( - "SELECT started_at FROM refresh_log WHERE incident_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1", + "SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running') ORDER BY id DESC LIMIT 1", (incident_id,), ) last_refresh = await cursor.fetchone() + # Laufenden Refresh ueberspringen + if last_refresh and last_refresh["status"] == "running": + logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)") + continue + should_refresh = False + if not last_refresh: + # Noch nie gelaufen -> sofort starten should_refresh = True + logger.info(f"Auto-Refresh Lage {incident_id}: erster Refresh") + elif start_time_str: + # Slot-basierte Logik: Naechsten faelligen Slot berechnen + try: + start_h, start_m = map(int, start_time_str.split(":")) + except (ValueError, AttributeError): + logger.warning(f"Auto-Refresh Lage {incident_id}: ungueltiges Startzeit-Format '{start_time_str}'") + continue + + last_time = datetime.fromisoformat(last_refresh["started_at"]) + if last_time.tzinfo is None: + last_time = last_time.replace(tzinfo=TIMEZONE) + else: + last_time = last_time.astimezone(TIMEZONE) + + # Anker: heute um start_time + anchor_today = now.replace(hour=start_h, minute=start_m, second=0, microsecond=0) + interval_td = timedelta(minutes=interval) + + if interval >= 1440: + # Taeglicher oder laengerer Rhythmus + days_interval = interval // 1440 + # Letzter Slot der <= now ist + current_slot = anchor_today + if current_slot > now: + current_slot -= timedelta(days=days_interval) + # Sicherheitsschleife: weiter zurueck falls noetig + while current_slot > now: + current_slot -= timedelta(days=days_interval) + else: + # Untertaegig: Slots ab Anker im Intervall-Takt + # Anker zurueck bis vor last_refresh + ref_anchor = anchor_today + while ref_anchor > last_time: + ref_anchor -= interval_td + # Von dort vorwaerts bis zum letzten Slot <= now + current_slot = ref_anchor + while current_slot + interval_td <= now: + current_slot += interval_td + + if current_slot > last_time: + should_refresh = True + logger.info(f"Auto-Refresh Lage {incident_id}: Slot {current_slot.strftime('%H:%M')} faellig (letzter Refresh: {last_time.strftime('%Y-%m-%d %H:%M')})") + else: + logger.debug(f"Auto-Refresh Lage {incident_id}: kein faelliger Slot (letzter: {current_slot.strftime('%H:%M')})") else: + # Fallback: altes Intervall-Verhalten (kein start_time gesetzt) last_time = datetime.fromisoformat(last_refresh["started_at"]) if last_time.tzinfo is None: last_time = last_time.replace(tzinfo=TIMEZONE) @@ -145,15 +199,6 @@ async def check_auto_refresh(): 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 - running_cursor = await db.execute( - "SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running' LIMIT 1", - (incident_id,), - ) - 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") except Exception as e: diff --git a/src/models.py b/src/models.py index d162f11..176fb86 100644 --- a/src/models.py +++ b/src/models.py @@ -49,6 +49,7 @@ class IncidentCreate(BaseModel): type: str = Field(default="adhoc", pattern="^(adhoc|research)$") refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$") refresh_interval: int = Field(default=15, ge=10, le=10080) + refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") retention_days: int = Field(default=0, ge=0, le=999) international_sources: bool = True include_telegram: bool = False @@ -62,6 +63,7 @@ class IncidentUpdate(BaseModel): status: Optional[str] = Field(default=None, pattern="^(active|archived)$") refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$") refresh_interval: Optional[int] = Field(default=None, ge=10, le=10080) + refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") retention_days: Optional[int] = Field(default=None, ge=0, le=999) international_sources: Optional[bool] = None include_telegram: Optional[bool] = None @@ -82,6 +84,7 @@ class IncidentResponse(BaseModel): status: str refresh_mode: str refresh_interval: int + refresh_start_time: Optional[str] = None retention_days: int visibility: str = "public" summary: Optional[str] diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 9803acc..2353321 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"]) INCIDENT_UPDATE_COLUMNS = { "title", "description", "type", "status", "refresh_mode", - "refresh_interval", "retention_days", "international_sources", "include_telegram", "visibility", + "refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "visibility", } @@ -107,15 +107,16 @@ async def create_incident( 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, include_telegram, visibility, + refresh_start_time, retention_days, international_sources, include_telegram, visibility, tenant_id, created_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( data.title, data.description, data.type, data.refresh_mode, data.refresh_interval, + data.refresh_start_time, data.retention_days, 1 if data.international_sources else 0, 1 if data.include_telegram else 0, diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 3acf07a..410ae8a 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -398,6 +398,10 @@ +