DELETE /api/admin/users/{id}/stats (usage/web_search_usage/cost_usage) und
.../memories (alle Erinnerungen) — require_admin, audit-geloggt; Konto, Verlauf
und (jeweils) die andere Datenart bleiben. Store-Methoden delete_user_stats/
delete_user_memories + zwei UI-Buttons je Nutzerkarte.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
740 lines
30 KiB
Python
740 lines
30 KiB
Python
"""Persistenzschicht: Nutzer und Sessions.
|
|
|
|
Ein abstraktes Store-Interface mit SQLite-Default (stdlib). Spaetere Backends
|
|
(Postgres/Redis) koennen dasselbe Interface implementieren, ohne die App zu aendern.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import hashlib
|
|
import secrets
|
|
import sqlite3
|
|
import uuid
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ANONYMOUS_USER_ID = "anonymous"
|
|
|
|
|
|
def hash_token(raw_token: str) -> str:
|
|
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
@dataclass
|
|
class User:
|
|
id: str
|
|
display_name: str
|
|
prefs: dict = field(default_factory=dict)
|
|
created_at: str = ""
|
|
external_id: str | None = None # SSO-/Proxy-Identitaet (Forward-Auth)
|
|
is_admin: bool = False # transient, aus ADMIN_USERS abgeleitet
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
id: str
|
|
user_id: str
|
|
data: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class Memory:
|
|
id: int
|
|
content: str
|
|
created_at: str = ""
|
|
|
|
|
|
class SessionOwnershipError(Exception):
|
|
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403)."""
|
|
|
|
|
|
class Store(ABC):
|
|
@abstractmethod
|
|
def create_user(self, display_name: str) -> tuple[User, str]:
|
|
"""Legt einen Nutzer an und liefert (User, Klartext-Token). Token nur hier sichtbar."""
|
|
|
|
@abstractmethod
|
|
def get_user_by_token(self, raw_token: str) -> User | None: ...
|
|
|
|
@abstractmethod
|
|
def get_user(self, user_id: str) -> User | None: ...
|
|
|
|
@abstractmethod
|
|
def set_user_prefs(self, user_id: str, prefs: dict) -> User: ...
|
|
|
|
def set_user_admin(self, user_id: str, is_admin: bool) -> User | None: ...
|
|
|
|
@abstractmethod
|
|
def ensure_anonymous_user(self) -> User: ...
|
|
|
|
@abstractmethod
|
|
def list_users(self) -> list[User]: ...
|
|
|
|
@abstractmethod
|
|
def get_user_by_external_id(self, external_id: str) -> User | None: ...
|
|
|
|
@abstractmethod
|
|
def get_or_create_user_by_external_id(
|
|
self, external_id: str, display_name: str | None = None
|
|
) -> User: ...
|
|
|
|
@abstractmethod
|
|
def get_session(self, session_id: str) -> Session | None: ...
|
|
|
|
@abstractmethod
|
|
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
|
"""Erstellt/aktualisiert eine Session des Nutzers. Fremde Session -> SessionOwnershipError."""
|
|
|
|
@abstractmethod
|
|
def append_message(self, session_id: str, user_id: str, role: str, content: str) -> None:
|
|
"""Haengt eine Nachricht an die Session an. Fremde Session -> SessionOwnershipError."""
|
|
|
|
@abstractmethod
|
|
def get_recent_messages(self, session_id: str, limit: int) -> list[dict]:
|
|
"""Liefert die letzten `limit` Nachrichten chronologisch ([{'role','content'}, ...])."""
|
|
|
|
@abstractmethod
|
|
def add_memory(self, user_id: str, content: str) -> Memory:
|
|
"""Speichert eine dauerhafte Erinnerung (Fakt/Vorliebe) zum Nutzer."""
|
|
|
|
@abstractmethod
|
|
def get_memories(self, user_id: str) -> list[Memory]:
|
|
"""Liefert alle Erinnerungen des Nutzers (chronologisch)."""
|
|
|
|
@abstractmethod
|
|
def delete_memory(self, user_id: str, memory_id: int) -> bool:
|
|
"""Loescht eine Erinnerung des Nutzers. True, wenn etwas geloescht wurde."""
|
|
|
|
@abstractmethod
|
|
def get_request_count(self, user_id: str, day: str | None = None) -> int:
|
|
"""Anzahl der Anfragen des Nutzers am angegebenen Tag (Default: heute, UTC)."""
|
|
|
|
@abstractmethod
|
|
def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int:
|
|
"""Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl."""
|
|
|
|
@abstractmethod
|
|
def web_search_count(self, user_id: str, day: str | None = None) -> int:
|
|
"""Anzahl Web-Suchen des Nutzers am Tag (Default: heute, UTC)."""
|
|
|
|
@abstractmethod
|
|
def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int:
|
|
"""Zaehlt n Web-Suchen dazu und liefert die neue Tages-Zahl."""
|
|
|
|
@abstractmethod
|
|
def add_cost_usage(self, user_id: str, category: str, usd: float, day: str | None = None) -> None:
|
|
"""Bucht Kosten (USD) auf Nutzer/Tag/Kategorie."""
|
|
|
|
@abstractmethod
|
|
def cost_for_day(self, user_id: str, day: str | None = None) -> float:
|
|
"""Summe der Kosten (USD) des Nutzers am Tag (Default: heute, UTC)."""
|
|
|
|
@abstractmethod
|
|
def delete_user(self, user_id: str) -> bool:
|
|
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
|
|
Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden.
|
|
Liefert True, wenn der Nutzer existierte und geloescht wurde."""
|
|
|
|
@abstractmethod
|
|
def delete_user_history(self, user_id: str) -> int:
|
|
"""Loescht NUR den Gespraechsverlauf (Sessions + Nachrichten) eines Nutzers;
|
|
Konto, Erinnerungen und Nutzungsdaten bleiben. Liefert die Zahl geloeschter Nachrichten."""
|
|
|
|
@abstractmethod
|
|
def delete_user_stats(self, user_id: str) -> int:
|
|
"""Loescht die Statistik-/Nutzungsdaten (usage, web_search_usage, cost_usage)
|
|
eines Nutzers. Konto, Verlauf und Erinnerungen bleiben."""
|
|
|
|
@abstractmethod
|
|
def delete_user_memories(self, user_id: str) -> int:
|
|
"""Loescht alle Erinnerungen eines Nutzers. Liefert die Zahl geloeschter Erinnerungen."""
|
|
|
|
@abstractmethod
|
|
def reset_token(self, user_id: str) -> tuple[User, str] | None:
|
|
"""Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig.
|
|
Liefert (User, Klartext-Token) oder None, wenn der Nutzer nicht existiert."""
|
|
|
|
@abstractmethod
|
|
def update_display_name(self, user_id: str, display_name: str) -> User | None:
|
|
"""Aktualisiert den Anzeigenamen. None wenn nicht gefunden."""
|
|
|
|
@abstractmethod
|
|
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
|
|
"""Protokolliert ein erkanntes Notfall-Signal (sensibel!)."""
|
|
|
|
@abstractmethod
|
|
def list_sessions_for_user(self, user_id: str) -> list[dict]: ...
|
|
|
|
@abstractmethod
|
|
def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]: ...
|
|
|
|
@abstractmethod
|
|
def list_emergency_events(self, limit: int = 50) -> list[dict]: ...
|
|
|
|
@abstractmethod
|
|
def last_manual_emergency_at(self, user_id: str) -> str | None: ...
|
|
|
|
@abstractmethod
|
|
def get_usage_for_user(self, user_id: str) -> list[dict]: ...
|
|
|
|
@abstractmethod
|
|
def get_all_usage(self) -> list[dict]: ...
|
|
|
|
@abstractmethod
|
|
def get_config_overrides(self) -> dict[str, str]: ...
|
|
|
|
@abstractmethod
|
|
def set_config_override(self, key: str, value: str) -> None: ...
|
|
|
|
@abstractmethod
|
|
def delete_config_override(self, key: str) -> bool: ...
|
|
|
|
|
|
class SQLiteStore(Store):
|
|
def __init__(self, db_path: str):
|
|
self.db_path = db_path
|
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_schema()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
def _init_schema(self) -> None:
|
|
with self._connect() as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
display_name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
prefs_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
external_id TEXT,
|
|
is_admin INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
data_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_session
|
|
ON messages(session_id, id);
|
|
CREATE TABLE IF NOT EXISTS memories (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_memories_user
|
|
ON memories(user_id, id);
|
|
CREATE TABLE IF NOT EXISTS usage (
|
|
user_id TEXT NOT NULL,
|
|
day TEXT NOT NULL,
|
|
requests INTEGER NOT NULL DEFAULT 0,
|
|
units INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (user_id, day)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS web_search_usage (
|
|
user_id TEXT NOT NULL,
|
|
day TEXT NOT NULL,
|
|
count INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (user_id, day)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS cost_usage (
|
|
user_id TEXT NOT NULL,
|
|
day TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
amount_usd REAL NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (user_id, day, category)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS emergency_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
snippet TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config_overrides (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
|
|
cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
|
|
if "external_id" not in cols:
|
|
conn.execute("ALTER TABLE users ADD COLUMN external_id TEXT")
|
|
if "is_admin" not in cols:
|
|
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
|
# NULLs gelten in SQLite als verschieden -> Alt-Nutzer ohne external_id ok.
|
|
conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external"
|
|
" ON users(external_id)"
|
|
)
|
|
|
|
# ----- Nutzer -----------------------------------------------------------
|
|
def _row_to_user(self, row: sqlite3.Row) -> User:
|
|
keys = row.keys()
|
|
return User(
|
|
id=row["id"],
|
|
display_name=row["display_name"],
|
|
prefs=json.loads(row["prefs_json"] or "{}"),
|
|
created_at=row["created_at"],
|
|
external_id=row["external_id"] if "external_id" in keys else None,
|
|
is_admin=bool(row["is_admin"]) if "is_admin" in keys else False,
|
|
)
|
|
|
|
def create_user(self, display_name: str) -> tuple[User, str]:
|
|
raw_token = secrets.token_urlsafe(32)
|
|
user = User(id=uuid.uuid4().hex, display_name=display_name, prefs={}, created_at=_now())
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
|
" VALUES (?, ?, ?, ?, ?)",
|
|
(user.id, user.display_name, hash_token(raw_token), "{}", user.created_at),
|
|
)
|
|
return user, raw_token
|
|
|
|
def get_user_by_token(self, raw_token: str) -> User | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE token_hash = ?", (hash_token(raw_token),)
|
|
).fetchone()
|
|
return self._row_to_user(row) if row else None
|
|
|
|
def get_user(self, user_id: str) -> User | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
return self._row_to_user(row) if row else None
|
|
|
|
def set_user_prefs(self, user_id: str, prefs: dict) -> User:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET prefs_json = ? WHERE id = ?",
|
|
(json.dumps(prefs), user_id),
|
|
)
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if row is None:
|
|
raise KeyError(f"Unbekannter Nutzer: {user_id}")
|
|
return self._row_to_user(row)
|
|
|
|
def ensure_anonymous_user(self) -> User:
|
|
existing = self.get_user(ANONYMOUS_USER_ID)
|
|
if existing:
|
|
return existing
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
|
" VALUES (?, ?, ?, ?, ?)",
|
|
(ANONYMOUS_USER_ID, "Anonymous", f"anon-{ANONYMOUS_USER_ID}", "{}", _now()),
|
|
)
|
|
return self.get_user(ANONYMOUS_USER_ID)
|
|
|
|
def list_users(self) -> list[User]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM users WHERE id != ? ORDER BY created_at",
|
|
(ANONYMOUS_USER_ID,),
|
|
).fetchall()
|
|
return [self._row_to_user(row) for row in rows]
|
|
|
|
def get_user_by_external_id(self, external_id: str) -> User | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE external_id = ?", (external_id,)
|
|
).fetchone()
|
|
return self._row_to_user(row) if row else None
|
|
|
|
def get_or_create_user_by_external_id(
|
|
self, external_id: str, display_name: str | None = None
|
|
) -> User:
|
|
"""Findet den Nutzer zur SSO-/Proxy-Identitaet oder legt ihn an (Forward-Auth)."""
|
|
existing = self.get_user_by_external_id(external_id)
|
|
if existing:
|
|
return existing
|
|
user = User(
|
|
id=uuid.uuid4().hex,
|
|
display_name=display_name or external_id,
|
|
prefs={},
|
|
created_at=_now(),
|
|
external_id=external_id,
|
|
)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at,"
|
|
" external_id) VALUES (?, ?, ?, ?, ?, ?)",
|
|
# token_hash ist NOT NULL UNIQUE -> synthetischer, kollisionsfreier Platzhalter
|
|
# (SSO-Nutzer authentifizieren sich nicht ueber ein Token).
|
|
(user.id, user.display_name, f"ext:{external_id}", "{}",
|
|
user.created_at, external_id),
|
|
)
|
|
return user
|
|
|
|
# ----- Sessions ---------------------------------------------------------
|
|
def get_session(self, session_id: str) -> Session | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return Session(id=row["id"], user_id=row["user_id"], data=json.loads(row["data_json"] or "{}"))
|
|
|
|
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
|
existing = self.get_session(session_id)
|
|
if existing and existing.user_id != user_id:
|
|
raise SessionOwnershipError(
|
|
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
|
)
|
|
|
|
data = dict(existing.data) if existing else {}
|
|
data.update({k: v for k, v in values.items() if v is not None})
|
|
payload = json.dumps(data)
|
|
now = _now()
|
|
|
|
with self._connect() as conn:
|
|
if existing:
|
|
conn.execute(
|
|
"UPDATE sessions SET data_json = ?, updated_at = ? WHERE id = ?",
|
|
(payload, now, session_id),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
|
|
" VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, user_id, payload, now, now),
|
|
)
|
|
return Session(id=session_id, user_id=user_id, data=data)
|
|
|
|
# ----- Nachrichten / Gespraechsverlauf ----------------------------------
|
|
def append_message(self, session_id: str, user_id: str, role: str, content: str) -> None:
|
|
existing = self.get_session(session_id)
|
|
if existing and existing.user_id != user_id:
|
|
raise SessionOwnershipError(
|
|
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
|
)
|
|
now = _now()
|
|
with self._connect() as conn:
|
|
if not existing:
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
|
|
" VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, user_id, "{}", now, now),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, created_at)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
(session_id, role, content, now),
|
|
)
|
|
|
|
def get_recent_messages(self, session_id: str, limit: int) -> list[dict]:
|
|
if limit <= 0:
|
|
return []
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT role, content FROM messages WHERE session_id = ?"
|
|
" ORDER BY id DESC LIMIT ?",
|
|
(session_id, limit),
|
|
).fetchall()
|
|
return [{"role": row["role"], "content": row["content"]} for row in reversed(rows)]
|
|
|
|
# ----- Langzeit-Erinnerungen --------------------------------------------
|
|
def add_memory(self, user_id: str, content: str) -> Memory:
|
|
now = _now()
|
|
with self._connect() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO memories (user_id, content, created_at) VALUES (?, ?, ?)",
|
|
(user_id, content, now),
|
|
)
|
|
memory_id = cur.lastrowid
|
|
return Memory(id=memory_id, content=content, created_at=now)
|
|
|
|
def get_memories(self, user_id: str) -> list[Memory]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, content, created_at FROM memories WHERE user_id = ? ORDER BY id",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [
|
|
Memory(id=row["id"], content=row["content"], created_at=row["created_at"])
|
|
for row in rows
|
|
]
|
|
|
|
def delete_memory(self, user_id: str, memory_id: int) -> bool:
|
|
with self._connect() as conn:
|
|
cur = conn.execute(
|
|
"DELETE FROM memories WHERE id = ? AND user_id = ?",
|
|
(memory_id, user_id),
|
|
)
|
|
return cur.rowcount > 0
|
|
|
|
# ----- Nutzung / Quota --------------------------------------------------
|
|
@staticmethod
|
|
def _today() -> str:
|
|
return datetime.now(timezone.utc).date().isoformat()
|
|
|
|
def get_request_count(self, user_id: str, day: str | None = None) -> int:
|
|
day = day or self._today()
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT requests FROM usage WHERE user_id = ? AND day = ?",
|
|
(user_id, day),
|
|
).fetchone()
|
|
return int(row["requests"]) if row else 0
|
|
|
|
def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int:
|
|
day = day or self._today()
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO usage (user_id, day, requests, units) VALUES (?, ?, 1, ?)"
|
|
" ON CONFLICT(user_id, day) DO UPDATE SET"
|
|
" requests = requests + 1, units = units + excluded.units",
|
|
(user_id, day, units),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT requests FROM usage WHERE user_id = ? AND day = ?",
|
|
(user_id, day),
|
|
).fetchone()
|
|
return int(row["requests"])
|
|
|
|
def web_search_count(self, user_id: str, day: str | None = None) -> int:
|
|
day = day or self._today()
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT count FROM web_search_usage WHERE user_id = ? AND day = ?",
|
|
(user_id, day),
|
|
).fetchone()
|
|
return int(row["count"]) if row else 0
|
|
|
|
def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int:
|
|
day = day or self._today()
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO web_search_usage (user_id, day, count) VALUES (?, ?, ?)"
|
|
" ON CONFLICT(user_id, day) DO UPDATE SET count = count + excluded.count",
|
|
(user_id, day, int(n)),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT count FROM web_search_usage WHERE user_id = ? AND day = ?",
|
|
(user_id, day),
|
|
).fetchone()
|
|
return int(row["count"])
|
|
|
|
def add_cost_usage(self, user_id: str, category: str, usd: float,
|
|
day: str | None = None) -> None:
|
|
if not usd or usd <= 0:
|
|
return
|
|
day = day or self._today()
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO cost_usage (user_id, day, category, amount_usd) VALUES (?, ?, ?, ?)"
|
|
" ON CONFLICT(user_id, day, category) DO UPDATE SET"
|
|
" amount_usd = amount_usd + excluded.amount_usd",
|
|
(user_id, day, category, float(usd)),
|
|
)
|
|
|
|
def cost_for_day(self, user_id: str, day: str | None = None) -> float:
|
|
day = day or self._today()
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT COALESCE(SUM(amount_usd), 0) AS s FROM cost_usage"
|
|
" WHERE user_id = ? AND day = ?",
|
|
(user_id, day),
|
|
).fetchone()
|
|
return float(row["s"]) if row else 0.0
|
|
|
|
def delete_user(self, user_id: str) -> bool:
|
|
if user_id == ANONYMOUS_USER_ID:
|
|
raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.")
|
|
with self._connect() as conn:
|
|
if not conn.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone():
|
|
return False
|
|
conn.execute(
|
|
"DELETE FROM messages WHERE session_id IN"
|
|
" (SELECT id FROM sessions WHERE user_id = ?)",
|
|
(user_id,),
|
|
)
|
|
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
|
conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
|
|
conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,))
|
|
conn.execute("DELETE FROM web_search_usage WHERE user_id = ?", (user_id,))
|
|
conn.execute("DELETE FROM cost_usage WHERE user_id = ?", (user_id,))
|
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
return True
|
|
|
|
def delete_user_history(self, user_id: str) -> int:
|
|
with self._connect() as conn:
|
|
cur = conn.execute(
|
|
"DELETE FROM messages WHERE session_id IN"
|
|
" (SELECT id FROM sessions WHERE user_id = ?)",
|
|
(user_id,),
|
|
)
|
|
n = cur.rowcount
|
|
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
|
return n
|
|
|
|
def delete_user_stats(self, user_id: str) -> int:
|
|
with self._connect() as conn:
|
|
n = 0
|
|
for tbl in ("usage", "web_search_usage", "cost_usage"):
|
|
cur = conn.execute(f"DELETE FROM {tbl} WHERE user_id = ?", (user_id,))
|
|
n += cur.rowcount
|
|
return n
|
|
|
|
def delete_user_memories(self, user_id: str) -> int:
|
|
with self._connect() as conn:
|
|
cur = conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
|
|
return cur.rowcount
|
|
|
|
def reset_token(self, user_id: str) -> tuple[User, str] | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
raw_token = secrets.token_urlsafe(32)
|
|
conn.execute(
|
|
"UPDATE users SET token_hash = ? WHERE id = ?",
|
|
(hash_token(raw_token), user_id),
|
|
)
|
|
user = self._row_to_user(conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone())
|
|
return user, raw_token
|
|
|
|
def update_display_name(self, user_id: str, display_name: str) -> User | None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET display_name = ? WHERE id = ?",
|
|
(display_name, user_id),
|
|
)
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
return self._row_to_user(row) if row else None
|
|
|
|
def set_user_admin(self, user_id: str, is_admin: bool) -> User | None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET is_admin = ? WHERE id = ?",
|
|
(1 if is_admin else 0, user_id),
|
|
)
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
return self._row_to_user(row) if row else None
|
|
|
|
# ----- Notfall-Protokoll ------------------------------------------------
|
|
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO emergency_events (user_id, category, snippet, created_at)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
(user_id, category, snippet, _now()),
|
|
)
|
|
|
|
# ----- Admin-Abfragen ---------------------------------------------------
|
|
def list_sessions_for_user(self, user_id: str) -> list[dict]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT s.id, s.created_at, s.updated_at,"
|
|
" (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS msg_count"
|
|
" FROM sessions s WHERE s.user_id = ?"
|
|
" ORDER BY s.updated_at DESC LIMIT 50",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_messages_for_session(self, session_id: str, limit: int = 200) -> list[dict]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT role, content, created_at FROM messages"
|
|
" WHERE session_id = ? ORDER BY id LIMIT ?",
|
|
(session_id, limit),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def list_emergency_events(self, limit: int = 50) -> list[dict]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT e.id, e.user_id, e.category, e.snippet, e.created_at,"
|
|
" COALESCE(u.display_name, e.user_id) AS display_name"
|
|
" FROM emergency_events e LEFT JOIN users u ON u.id = e.user_id"
|
|
" ORDER BY e.id DESC LIMIT ?",
|
|
(limit,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def last_manual_emergency_at(self, user_id: str) -> str | None:
|
|
"""ISO-Zeitstempel des letzten vom Nutzer ausgelösten Notrufs (oder None)."""
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT created_at FROM emergency_events"
|
|
" WHERE user_id = ? AND category = 'manual'"
|
|
" ORDER BY id DESC LIMIT 1",
|
|
(user_id,),
|
|
).fetchone()
|
|
return row["created_at"] if row else None
|
|
|
|
def get_usage_for_user(self, user_id: str) -> list[dict]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT day, requests, units FROM usage"
|
|
" WHERE user_id = ? ORDER BY day DESC LIMIT 30",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_all_usage(self) -> list[dict]:
|
|
month = datetime.now(timezone.utc).strftime("%Y-%m") + "-%"
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name,"
|
|
" SUM(u.requests) AS total_requests, SUM(u.units) AS total_units,"
|
|
" MAX(u.day) AS last_active,"
|
|
" COALESCE((SELECT SUM(w.count) FROM web_search_usage w"
|
|
" WHERE w.user_id = u.user_id), 0) AS total_web_searches,"
|
|
" COALESCE((SELECT SUM(c.amount_usd) FROM cost_usage c"
|
|
" WHERE c.user_id = u.user_id AND c.day LIKE ?), 0) AS month_cost_usd,"
|
|
" COALESCE((SELECT SUM(ce.amount_usd) FROM cost_usage ce"
|
|
" WHERE ce.user_id = u.user_id AND ce.day LIKE ?"
|
|
" AND ce.category IN ('tts','alert','llm_local')), 0) AS month_cost_estimated_usd,"
|
|
" COALESCE((SELECT SUM(uu.requests) FROM usage uu"
|
|
" WHERE uu.user_id = u.user_id AND uu.day LIKE ?), 0) AS month_requests"
|
|
" FROM usage u LEFT JOIN users usr ON usr.id = u.user_id"
|
|
" GROUP BY u.user_id ORDER BY total_requests DESC",
|
|
(month, month, month),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_config_overrides(self) -> dict[str, str]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute("SELECT key, value FROM config_overrides").fetchall()
|
|
return {r["key"]: r["value"] for r in rows}
|
|
|
|
def set_config_override(self, key: str, value: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO config_overrides(key, value, updated_at) VALUES(?,?,?)"
|
|
" ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
|
(key, value, _now()),
|
|
)
|
|
|
|
def delete_config_override(self, key: str) -> bool:
|
|
with self._connect() as conn:
|
|
cur = conn.execute("DELETE FROM config_overrides WHERE key=?", (key,))
|
|
return cur.rowcount > 0
|