"""Beispieldatenbank für die Demo „Bestellservice“: Kunden, Artikel, Bestellungen, Positionen, Retouren. Liegt als eigene Datenbank im PostgreSQL von Bifröst (`_demo_shop`) mit einer nur lesenden Rolle. Angebunden über den PostgreSQL-Treiber (DB-Dienst aus `scripts/dev.sh`); läuft der nicht, überspringt die Demo diesen Teil mit einem Hinweis. """ import os import random from datetime import date, timedelta import httpx from sqlalchemy import create_engine, select, text from sqlalchemy.engine import make_url from app.core.config import Settings from app.core.crypto import Vault from app.core.database import Database # Wie in scripts/dev.sh; anders über Umgebungsvariablen DBMCP_URL = os.environ.get("AIW_DEMO_DBMCP_URL", "http://localhost:8791/mcp") DBMCP_KEY = os.environ.get("AIW_DEMO_DBMCP_KEY", "dev-schluessel-12345678") READER, READER_PASSWORD = "demo_leser", "demo-leser-nur-lesen" CONNECTION = "shop" EXAMPLES = [ "Welche Kunden haben 2026 am meisten retourniert, und aus welchen Gründen? (Shop-Datenbank)", "Wie hat sich der Umsatz von Gewerbe- und Privatkunden je Quartal entwickelt? Bitte als Diagramm.", ] SCHEMA = """ DROP TABLE IF EXISTS retouren, positionen, bestellungen, artikel, kunden; CREATE TABLE kunden ( kunde_id integer PRIMARY KEY, name text NOT NULL, segment text NOT NULL CHECK (segment IN ('Privat', 'Gewerbe')), stadt text NOT NULL, region text NOT NULL, kunde_seit date NOT NULL ); COMMENT ON TABLE kunden IS 'Kundinnen und Kunden des Bestellservice'; CREATE TABLE artikel ( artikel_id integer PRIMARY KEY, name text NOT NULL, kategorie text NOT NULL, preis_eur numeric(10, 2) NOT NULL ); CREATE TABLE bestellungen ( bestell_id integer PRIMARY KEY, kunde_id integer NOT NULL REFERENCES kunden, datum date NOT NULL, kanal text NOT NULL, rabatt_prozent integer NOT NULL DEFAULT 0 ); COMMENT ON COLUMN bestellungen.rabatt_prozent IS 'Rabatt auf den Warenwert der Bestellung'; CREATE TABLE positionen ( bestell_id integer NOT NULL REFERENCES bestellungen, artikel_id integer NOT NULL REFERENCES artikel, menge integer NOT NULL, einzelpreis_eur numeric(10, 2) NOT NULL, PRIMARY KEY (bestell_id, artikel_id) ); CREATE TABLE retouren ( retoure_id integer PRIMARY KEY, bestell_id integer NOT NULL REFERENCES bestellungen, artikel_id integer NOT NULL REFERENCES artikel, datum date NOT NULL, grund text NOT NULL ); """ ARTICLES = [("Kaffeemaschine", "Geräte", 249.00), ("Wasserkocher", "Geräte", 39.90), ("Mühle", "Geräte", 89.90), ("Milchaufschäumer", "Zubehör", 54.90), ("Kaffeebohnen 1 kg", "Verbrauch", 18.90), ("Entkalker", "Verbrauch", 7.90), ("Tassen-Set", "Zubehör", 29.90), ("Wasserfilter", "Verbrauch", 12.90)] CITIES = {"Nord": ["Hamburg", "Bremen", "Kiel"], "Süd": ["München", "Stuttgart", "Nürnberg"], "West": ["Köln", "Düsseldorf", "Dortmund"], "Ost": ["Berlin", "Leipzig", "Dresden"]} FIRST = ["Lena", "Jonas", "Mia", "Felix", "Emma", "Paul", "Hanna", "Lukas", "Sophie", "Noah", "Lea", "Elias"] LAST = ["Berger", "Krüger", "Hoffmann", "Wagner", "Becker", "Schulz", "Lange", "Vogel", "Seidel", "Brandt"] FIRMS = ["Büro", "Kanzlei", "Praxis", "Café", "Agentur", "Werkstatt", "Hotel", "Bäckerei"] REASONS = ["Defekt bei Lieferung", "Falscher Artikel", "Gefällt nicht", "Lieferung zu spät", "Doppelt bestellt"] def rows() -> dict[str, list[tuple]]: """Reproduzierbare Daten 2025/2026; Gewerbekunden bestellen größer, einige Kunden retournieren auffällig oft.""" rng = random.Random(14) customers = [] for n in range(1, 301): region = rng.choice(list(CITIES)) business = rng.random() < 0.3 name = (f"{rng.choice(FIRMS)} {rng.choice(LAST)}" if business else f"{rng.choice(FIRST)} {rng.choice(LAST)}") customers.append((n, name, "Gewerbe" if business else "Privat", rng.choice(CITIES[region]), region, date(2023, 1, 1) + timedelta(days=rng.randrange(0, 900)))) articles = [(i + 1, name, kind, price) for i, (name, kind, price) in enumerate(ARTICLES)] frequent = {c[0] for c in rng.sample(customers, 8)} # retournieren oft orders, items, returns = [], [], [] start = date(2025, 1, 1) for order_id in range(1, 2501): customer = rng.choice(customers) day = start + timedelta(days=rng.randrange(0, 663)) business = customer[2] == "Gewerbe" chosen = rng.sample(articles, rng.randint(1, 3)) value = 0.0 for article in chosen: quantity = rng.choice([1, 1, 2, 3]) * (rng.choice([2, 5, 10]) if business else 1) items.append((order_id, article[0], quantity, article[3])) value += quantity * article[3] chance = 0.35 if customer[0] in frequent else 0.04 if rng.random() < chance: returns.append((len(returns) + 1, order_id, article[0], day + timedelta(days=rng.randint(3, 20)), rng.choice(REASONS[:2] if customer[0] in frequent else REASONS))) orders.append((order_id, customer[0], day, rng.choice(["Online", "Online", "Filiale", "Telefon"]), 10 if value >= 500 else 0)) return {"kunden": customers, "artikel": articles, "bestellungen": orders, "positionen": items, "retouren": returns} def database_name(settings: Settings) -> str: return f"{make_url(settings.database_url).database}_demo_shop" def build(settings: Settings) -> str: """Datenbank, Rolle und Daten anlegen (neu befüllt bei jedem Lauf). Liefert den Datenbanknamen.""" url, name = make_url(settings.database_url), database_name(settings) admin = create_engine(url.set(database="postgres"), isolation_level="AUTOCOMMIT") with admin.connect() as c: if not c.scalar(text("SELECT 1 FROM pg_database WHERE datname = :n"), {"n": name}): c.execute(text(f'CREATE DATABASE "{name}"')) if not c.scalar(text("SELECT 1 FROM pg_roles WHERE rolname = :r"), {"r": READER}): c.execute(text(f"CREATE ROLE {READER} LOGIN PASSWORD '{READER_PASSWORD}'")) admin.dispose() engine = create_engine(url.set(database=name)) data = rows() with engine.begin() as c: c.execute(text(SCHEMA)) for table, values in data.items(): columns = len(values[0]) placeholders = ", ".join(f":p{i}" for i in range(columns)) c.execute(text(f"INSERT INTO {table} VALUES ({placeholders})"), [{f"p{i}": v for i, v in enumerate(row)} for row in values]) c.execute(text(f'GRANT CONNECT ON DATABASE "{name}" TO {READER}')) c.execute(text(f"GRANT USAGE ON SCHEMA public TO {READER}")) c.execute(text(f"GRANT SELECT ON ALL TABLES IN SCHEMA public TO {READER}")) engine.dispose() return name def reachable() -> bool: try: return httpx.get(DBMCP_URL.removesuffix("/mcp") + "/ready", timeout=2).status_code == 200 except httpx.HTTPError: return False def driver(db: Database, settings: Settings, vault: Vault, principal) -> int | None: """PostgreSQL-Treiber mit der URL des DB-Dienstes: vorhandenen nutzen, sonst registrieren; Tools abrufen und die lesenden einstufen (wie ein Plattform-Admin unter „Datenbank-Treiber“).""" from app.mcp import service as mcp from app.mcp.models import McpServer from app.tools import service as tools from app.tools.models import ToolDefinition with db.session() as session: found = session.scalar(select(McpServer).where(McpServer.workspace_id.is_(None), McpServer.role == mcp.DATABASE, McpServer.url == DBMCP_URL)) if found is None: name = "postgres" if session.scalar(select(McpServer).where(McpServer.name == "postgres")) is None \ else "postgres_demo" found = mcp.save(session, principal, settings, vault, None, name=name, label="PostgreSQL", url=DBMCP_URL, token=DBMCP_KEY, role=mcp.DATABASE) print(f" Datenbank-Treiber PostgreSQL registriert ({DBMCP_URL}).") driver_id = found.id with db.session() as session: error, _ = mcp.discover(session, principal, settings, vault, driver_id) if error: print(f" Datenbank übersprungen: Tools des Treibers nicht abrufbar ({error}).") return None with db.session() as session: kind = mcp.kind(session.get(McpServer, driver_id).name) tools.sync(session) for definition in session.scalars(select(ToolDefinition).where(ToolDefinition.kind == kind)): if not definition.classified and definition.suggested_risk == "read": tools.classify(session, principal, definition.name, "read", tools.ALLOW) return driver_id def setup(db: Database, settings: Settings, user_id: int, workspace_id: int) -> None: """Beispieldatenbank anlegen und im Workspace verbinden (auch in einem schon vorhandenen Demo-Workspace).""" from app.identity.service import principal_for from app.mcp import databases from app.mcp.models import McpServer from app.workspaces import service as workspaces from app.workspaces.models import Workspace if not reachable(): print(f" Datenbank übersprungen: kein DB-Dienst unter {DBMCP_URL} (läuft mit scripts/dev.sh).") return # Nur für die Demo: lokaler DB-Dienst und lokale Datenbank (scripts/dev.sh erlaubt beides ebenso) settings = settings.model_copy(update={"mcp_allow_local": True, "db_allow_local": True}) vault = Vault(settings.secret_key) with db.session() as session: principal = principal_for(session, user_id) existing = session.scalar(select(McpServer).where(McpServer.workspace_id == workspace_id, McpServer.name == CONNECTION)) name = build(settings) if existing is not None: print(f" Datenbank {name}: Daten neu geladen, Verbindung „Shop-Datenbank“ besteht schon.") return driver_id = driver(db, settings, vault, principal) if driver_id is None: return url = make_url(settings.database_url) with db.session() as session: server = databases.create(session, principal, settings, vault, workspace_id, driver_id=driver_id, name=CONNECTION, label="Shop-Datenbank", host=url.host or "127.0.0.1", port=str(url.port or 5432), database=name, user=READER, password=READER_PASSWORD, sslmode="disable", exposure_confirmed=True) databases.test(session, settings, vault, server, user_id) status, message = server.status, server.status_message workspace = session.get(Workspace, workspace_id) examples = workspaces.examples(workspace) added = [e for e in EXAMPLES if e not in examples] if added: workspaces.set_examples(session, principal, workspace_id, "\n".join((examples + added)[:workspaces.MAX_EXAMPLES])) counts = {table: len(values) for table, values in rows().items()} print(f" Datenbank {name} ({counts['kunden']} Kunden, {counts['bestellungen']} Bestellungen, " f"{counts['retouren']} Retouren) als „Shop-Datenbank“: {'verbunden' if status == 'ok' else message}")