feat: Cloud-Fundament - Auth, Persistenz und Mandanten-Trennung
- SQLite-Store (app/store.py) hinter Store-Interface: Nutzer + Sessions persistent - Bearer-Token-Auth (app/auth.py); Nutzerverwaltung via Admin-Key (POST /api/admin/users) - GET /api/me, PUT /api/me/prefs (dauerhafte Nutzer-Praeferenzen) - chat/speak/transcribe/sessions auth-geschuetzt; Mandanten-Trennung (fremde Session -> 403) - Route-Aufloesung: Defaults < Profil < ENV < Nutzer-Prefs < Session < Request - SessionManager (in-memory) durch Store ersetzt - AUTH_ENABLED-Schalter (prod an, dev/Tests aus); DB_PATH/ADMIN_API_KEY - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, deploy-env); data/ gitignored - Tests: 29 gruen (Auth, Mandanten, Persistenz, Routing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
293ed257db
commit
e0e69fdf15
22 changed files with 625 additions and 57 deletions
14
app/api/admin.py
Normal file
14
app/api/admin.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth import require_admin
|
||||
from app.dependencies import get_store
|
||||
from app.schemas import UserCreate, UserCreated
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||
async def create_user(payload: UserCreate):
|
||||
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
||||
user, token = get_store().create_user(payload.display_name)
|
||||
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import RoutingError
|
||||
from app.auth import require_user
|
||||
from app.store import User, SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
build_orchestrator,
|
||||
|
|
@ -36,6 +38,7 @@ async def chat(
|
|||
default=None,
|
||||
description="Optional session id to apply a stored route",
|
||||
),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
overrides = {
|
||||
"input_endpoint": payload.input_endpoint,
|
||||
|
|
@ -45,12 +48,14 @@ async def chat(
|
|||
"llm_provider": payload.llm_provider,
|
||||
"tts_provider": payload.tts_provider,
|
||||
}
|
||||
route = resolve_route(session_id, overrides)
|
||||
voice = payload.voice or settings.openrouter_tts_voice
|
||||
|
||||
try:
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
output = await resolve_output_endpoint(route)
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except RoutingError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
|
|
|||
21
app/api/me.py
Normal file
21
app/api/me.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth import require_user
|
||||
from app.dependencies import get_store
|
||||
from app.schemas import UserPrefs
|
||||
from app.store import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user: User = Depends(require_user)):
|
||||
return {"user_id": user.id, "display_name": user.display_name, "prefs": user.prefs}
|
||||
|
||||
|
||||
@router.put("/me/prefs")
|
||||
async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
|
||||
"""Setzt die dauerhaften Routing-Praeferenzen des Nutzers (nur gesetzte Felder)."""
|
||||
prefs = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
updated = get_store().set_user_prefs(user.id, prefs)
|
||||
return {"user_id": updated.id, "prefs": updated.prefs}
|
||||
|
|
@ -1,10 +1,21 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.schemas import SessionRouteRequest
|
||||
from app.dependencies import session_manager
|
||||
from app.auth import require_user
|
||||
from app.dependencies import get_store
|
||||
from app.store import User, SessionOwnershipError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/route")
|
||||
async def set_session_route(session_id: str, payload: SessionRouteRequest):
|
||||
session = session_manager.update(session_id, payload.model_dump())
|
||||
return {"session_id": session_id, "route": session}
|
||||
async def set_session_route(
|
||||
session_id: str,
|
||||
payload: SessionRouteRequest,
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
try:
|
||||
session = get_store().update_session(session_id, user.id, payload.model_dump())
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return {"session_id": session_id, "route": session.data}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import RoutingError
|
||||
from app.auth import require_user
|
||||
from app.store import User, SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
build_orchestrator,
|
||||
|
|
@ -22,18 +24,21 @@ async def speak(
|
|||
default=None,
|
||||
description="Optional session id to apply a stored route",
|
||||
),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
overrides = {
|
||||
"output_endpoint": payload.output_endpoint,
|
||||
"language": payload.language,
|
||||
"tts_provider": payload.tts_provider,
|
||||
}
|
||||
route = resolve_route(session_id, overrides)
|
||||
voice = payload.voice or settings.openrouter_tts_voice
|
||||
|
||||
try:
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
output = await resolve_output_endpoint(route)
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except RoutingError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
|
||||
from app.errors import RoutingError
|
||||
from app.auth import require_user
|
||||
from app.store import User, SessionOwnershipError
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
build_orchestrator,
|
||||
|
|
@ -20,17 +22,20 @@ async def transcribe(
|
|||
default=None,
|
||||
description="Optional session id to apply a stored route",
|
||||
),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
overrides = {
|
||||
"input_endpoint": input_endpoint,
|
||||
"language": language,
|
||||
"stt_provider": stt_provider,
|
||||
}
|
||||
route = resolve_route(session_id, overrides)
|
||||
|
||||
try:
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
source = await resolve_input_endpoint(route)
|
||||
except SessionOwnershipError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except RoutingError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
|
|
|||
36
app/auth.py
Normal file
36
app/auth.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from fastapi import Header, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_store
|
||||
from app.store import User
|
||||
|
||||
|
||||
def require_user(authorization: str | None = Header(default=None)) -> User:
|
||||
"""FastAPI-Dependency: liefert den authentifizierten Nutzer.
|
||||
|
||||
Bei AUTH_ENABLED=false gilt ein anonymer Standardnutzer (dev/Test). Sonst ist
|
||||
ein gueltiges Bearer-Token noetig (sonst 401).
|
||||
"""
|
||||
store = get_store()
|
||||
if not settings.auth_enabled:
|
||||
return store.ensure_anonymous_user()
|
||||
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="Bearer token required")
|
||||
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
user = store.get_user_by_token(token)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(x_admin_key: str | None = Header(default=None)) -> None:
|
||||
"""Schuetzt die Nutzerverwaltung ueber ADMIN_API_KEY (aus der Umgebung)."""
|
||||
expected = settings.admin_api_key.strip()
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Admin API not configured (ADMIN_API_KEY unset)"
|
||||
)
|
||||
if not x_admin_key or x_admin_key.strip() != expected:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
|
@ -120,6 +120,9 @@ class Settings(BaseSettings):
|
|||
local_llm_base_url: str = "http://127.0.0.1:11434/v1"
|
||||
local_llm_api_key: str = "dummy"
|
||||
local_llm_model: str = "llama3.1"
|
||||
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
|
||||
admin_api_key: str = ""
|
||||
auth_enabled: bool = True
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
class SessionManager:
|
||||
def __init__(self):
|
||||
self._sessions = {}
|
||||
|
||||
def get(self, session_id: str) -> dict:
|
||||
return self._sessions.setdefault(session_id, {})
|
||||
|
||||
def update(self, session_id: str, values: dict) -> dict:
|
||||
session = self.get(session_id)
|
||||
session.update({k: v for k, v in values.items() if v is not None})
|
||||
return session
|
||||
|
|
@ -23,9 +23,20 @@ from app.pipeline.input_cleaner import InputCleaner
|
|||
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
|
||||
from app.pipeline.tts_normalizer import TTSNormalizer
|
||||
from app.core.orchestrator import Orchestrator
|
||||
from app.core.session_manager import SessionManager
|
||||
from app.store import SQLiteStore, Store, User
|
||||
|
||||
session_manager = SessionManager()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistenz-Store: Modul-Singleton (SQLite). Spaetere Backends implementieren
|
||||
# dasselbe Store-Interface, ohne die App zu aendern.
|
||||
# ---------------------------------------------------------------------------
|
||||
_store: Store | None = None
|
||||
|
||||
|
||||
def get_store() -> Store:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = SQLiteStore(settings.db_path)
|
||||
return _store
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
|
||||
|
|
@ -136,17 +147,35 @@ class ResolvedRoute:
|
|||
}
|
||||
|
||||
|
||||
def get_session_route(session_id: str | None) -> dict:
|
||||
"""Liefert die gespeicherte Route einer Session (leeres dict ohne session_id)."""
|
||||
return session_manager.get(session_id) if session_id else {}
|
||||
def get_session_route(session_id: str | None, user: User | None = None) -> dict:
|
||||
"""Liefert die gespeicherte Route einer Session des Nutzers (leeres dict sonst).
|
||||
|
||||
Gehoert die Session einem anderen Nutzer, wird SessionOwnershipError ausgeloest.
|
||||
"""
|
||||
if not session_id:
|
||||
return {}
|
||||
session = get_store().get_session(session_id)
|
||||
if session is None:
|
||||
return {}
|
||||
if user is not None and session.user_id != user.id:
|
||||
from app.store import SessionOwnershipError
|
||||
|
||||
raise SessionOwnershipError(
|
||||
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
||||
)
|
||||
return session.data
|
||||
|
||||
|
||||
def resolve_route(
|
||||
user: User | None = None,
|
||||
session_id: str | None = None,
|
||||
overrides: dict | None = None,
|
||||
cfg: Settings = settings,
|
||||
) -> ResolvedRoute:
|
||||
"""Loest die effektive Route aus Defaults, Session und Request-Overrides auf."""
|
||||
"""Loest die effektive Route auf.
|
||||
|
||||
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
|
||||
"""
|
||||
resolved = {
|
||||
"input_endpoint": cfg.default_input_endpoint,
|
||||
"output_endpoint": cfg.default_output_endpoint,
|
||||
|
|
@ -156,10 +185,11 @@ def resolve_route(
|
|||
"language": cfg.default_language,
|
||||
}
|
||||
|
||||
session_route = get_session_route(session_id)
|
||||
user_prefs = user.prefs if user is not None else {}
|
||||
session_route = get_session_route(session_id, user)
|
||||
request_overrides = overrides or {}
|
||||
|
||||
for layer in (session_route, request_overrides):
|
||||
for layer in (user_prefs, session_route, request_overrides):
|
||||
for key in ROUTE_KEYS:
|
||||
value = layer.get(key)
|
||||
if value is not None:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from app.api.speak import router as speak_router
|
|||
from app.api.devices import router as devices_router
|
||||
from app.api.sessions import router as sessions_router
|
||||
from app.api.config import router as config_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.me import router as me_router
|
||||
|
||||
app = FastAPI(title="Voice Assistant Gateway")
|
||||
app.include_router(health_router)
|
||||
|
|
@ -15,3 +17,5 @@ app.include_router(speak_router, prefix="/api")
|
|||
app.include_router(devices_router, prefix="/api")
|
||||
app.include_router(sessions_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
app.include_router(admin_router, prefix="/api")
|
||||
app.include_router(me_router, prefix="/api")
|
||||
|
|
|
|||
|
|
@ -69,3 +69,22 @@ class RouteInfo(BaseModel):
|
|||
tts_provider: str
|
||||
language: str
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
display_name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class UserCreated(BaseModel):
|
||||
user_id: str
|
||||
display_name: str
|
||||
token: str # nur bei Erstellung sichtbar
|
||||
|
||||
|
||||
class UserPrefs(BaseModel):
|
||||
input_endpoint: str | None = None
|
||||
output_endpoint: str | None = None
|
||||
stt_provider: str | None = None
|
||||
llm_provider: str | None = None
|
||||
tts_provider: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
|
|
|||
197
app/store.py
Normal file
197
app/store.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""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 = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
user_id: str
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
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: ...
|
||||
|
||||
@abstractmethod
|
||||
def ensure_anonymous_user(self) -> 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."""
|
||||
|
||||
|
||||
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
|
||||
);
|
||||
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
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# ----- Nutzer -----------------------------------------------------------
|
||||
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||
return User(
|
||||
id=row["id"],
|
||||
display_name=row["display_name"],
|
||||
prefs=json.loads(row["prefs_json"] or "{}"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# ----- 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue