bifroest/app/demo/shop_db.py
2026-10-27 08:34:17 +01:00

226 lines
11 KiB
Python

"""Beispieldatenbank für die Demo „Bestellservice“: Kunden, Artikel, Bestellungen, Positionen, Retouren.
Liegt als eigene Datenbank im PostgreSQL von Bifröst (`<datenbank>_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}")