wahnsinn vibe

This commit is contained in:
Marek Lenczewski
2026-04-16 19:42:06 +02:00
parent 9c5da44f64
commit e3e88cc58e
127 changed files with 9456 additions and 3 deletions

0
backend/apps/__init__.py Normal file
View File

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.security import require_admin
from apps.ai_core.models import AIAuditLog
from apps.ai_core.tools import get_tool, validate_args
from .planner import build_plan
from .tool_defs import register_all
router = APIRouter(dependencies=[Depends(require_admin)])
class PlanIn(BaseModel):
prompt: str
class ExecuteCardIn(BaseModel):
tool: str
args: dict = {}
class ExecuteIn(BaseModel):
cards: list[ExecuteCardIn]
@router.post("/plan")
def plan_endpoint(body: PlanIn):
if not body.prompt.strip():
raise HTTPException(400, "Empty prompt")
cards = build_plan(body.prompt)
return {"cards": cards}
@router.post("/execute")
def execute_endpoint(
body: ExecuteIn,
claims: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
user_id = int(claims["sub"])
results = []
for card in body.cards:
spec = get_tool(card.tool)
if not spec:
results.append({"tool": card.tool, "ok": False, "error": "unknown tool"})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"error": "unknown tool"}, ok=False))
db.commit()
continue
missing = validate_args(spec, card.args)
if missing:
results.append({"tool": card.tool, "ok": False, "error": f"missing: {missing}"})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"missing": missing}, ok=False))
db.commit()
continue
try:
res = spec.handler(card.args, db)
results.append({"tool": card.tool, "ok": True, "result": res})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result=res, ok=True))
db.commit()
except Exception as e: # noqa: BLE001
results.append({"tool": card.tool, "ok": False, "error": str(e)})
db.add(AIAuditLog(user_id=user_id, tool=card.tool, args=card.args, result={"error": str(e)}, ok=False))
db.commit()
return {"results": results}
def on_load() -> None:
register_all()

View File

@@ -0,0 +1,6 @@
name: ai_admin
version: 0.1.0
depends_on: [core, auth, catalog, orders, ai_core]
conflicts_with: []
required: false
provides: []

View File

@@ -0,0 +1,113 @@
"""Build a structured action plan from a natural-language prompt (or JSON bulk)."""
from __future__ import annotations
import json
from core.db import SessionLocal
from apps.ai_core.ollama_client import get_llm
from apps.ai_core.tools import describe_for_prompt, get_tool, validate_args
from apps.catalog.models import Category, Product
SYSTEM_PROMPT = """You are an admin assistant for an e-commerce shop.
You help the operator perform tasks by producing a STRUCTURED PLAN of tool calls.
You MUST NEVER execute anything. You only propose cards that the operator confirms.
Output format (STRICT):
Reply with ONLY a JSON object of the shape {"cards": [ ... ]}.
Each card: {"tool": "<tool-name>", "args": {...}, "missing": [], "preview": "German summary", "notes": ""}.
Rules:
- Only use tools from the provided TOOL CATALOG. If no tool applies, return {"cards": []}.
- NEVER emit a single card that aggregates multiple items.
If the user provides JSON with multiple items (bulk) → produce ONE card per item.
If the user asks to change something about ALL or MULTIPLE existing products/categories
→ produce ONE card per matching item from the SHOP STATE snapshot, with its exact id.
Examples:
"setze alle preise auf 1" on 3 products → 3 cards, each {"tool":"catalog.product.update","args":{"id":<real id>,"price":1},...}
NOT one card with id=null or "all".
- Numbers must be numbers in JSON (not strings). Omit optional fields instead of sending null.
- Stay concise in "preview".
"""
def _shop_state_snapshot() -> dict:
"""Compact snapshot of current shop state for the planner. Keep it small."""
db = SessionLocal()
try:
products = [
{"id": p.id, "sku": p.sku, "name_de": (p.name or {}).get("de", ""), "price": float(p.price)}
for p in db.query(Product).order_by(Product.id).all()
]
categories = [
{"id": c.id, "slug": c.slug, "name_de": (c.name or {}).get("de", "")}
for c in db.query(Category).order_by(Category.id).all()
]
return {"products": products, "categories": categories}
finally:
db.close()
def build_plan(user_prompt: str) -> list[dict]:
tools = describe_for_prompt(role="admin")
state = _shop_state_snapshot()
user_msg = (
f"TOOL CATALOG (JSON):\n{json.dumps(tools, ensure_ascii=False)}\n\n"
f"SHOP STATE (current products & categories):\n{json.dumps(state, ensure_ascii=False)}\n\n"
f"USER REQUEST:\n{user_prompt}\n\n"
"Reply with ONLY the JSON object described in the rules."
)
try:
result = get_llm().chat_json(SYSTEM_PROMPT, user_msg)
except Exception as e: # noqa: BLE001
return [
{
"tool": "_error",
"args": {},
"missing": [],
"preview": f"Planer-Fehler: {e}",
"notes": "LLM antwortete nicht verwertbar. Prompt umformulieren.",
}
]
if isinstance(result, list):
cards = result
elif isinstance(result, dict) and isinstance(result.get("cards"), list):
cards = result["cards"]
else:
cards = []
# Validate and annotate
clean: list[dict] = []
for card in cards:
if not isinstance(card, dict):
continue
name = card.get("tool", "")
args = card.get("args") or {}
spec = get_tool(name)
if spec:
# Trust only server-side required-field validation, not LLM-supplied missing
missing = validate_args(spec, args)
clean.append(
{
"tool": name,
"args": args,
"missing": missing,
"preview": card.get("preview", ""),
"notes": card.get("notes", ""),
"schema": spec.args_schema,
}
)
else:
clean.append(
{
"tool": name,
"args": args,
"missing": list(card.get("missing") or []),
"preview": card.get("preview", f"Unbekanntes Tool: {name}"),
"notes": "tool not in catalog",
"schema": {"type": "object", "properties": {}},
}
)
return clean

View File

@@ -0,0 +1,227 @@
"""Tool definitions — Admin-facing actions the KI can plan.
Each tool: name, description, JSON Schema for args, and a handler that is only
ever called from the `execute` endpoint after the user confirmed the Card.
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from core.events import event_bus
from core.settings_service import set_setting
from apps.ai_core.tools import ToolSpec, register_tool
from apps.catalog.models import Category, Product
from apps.catalog.projector import project_category, project_product
# ---- settings.update ------------------------------------------------
def _handler_settings_update(args: dict, db: Session) -> dict:
key = args["key"]
value = args["value"]
set_setting(db, key, value)
return {"key": key, "value": value}
SETTINGS_UPDATE = ToolSpec(
name="settings.update",
description="Update a shop-wide setting (e.g. shop name, currency, support email).",
args_schema={
"type": "object",
"required": ["key", "value"],
"properties": {
"key": {
"type": "string",
"description": "Setting key, e.g. 'core.shop_name'.",
},
"value": {
"description": "New value (string / number / boolean).",
},
},
},
handler=_handler_settings_update,
examples=[
{"key": "core.shop_name", "value": "TEST123"},
{"key": "core.support_email", "value": "help@example.com"},
],
)
# ---- catalog.product.create ----------------------------------------
def _coalesce(value, default):
"""Return default if value is None or missing; otherwise the value."""
return default if value is None else value
def _handler_product_create(args: dict, db: Session) -> dict:
if db.query(Product).filter_by(sku=args["sku"]).first():
raise ValueError(f"SKU already exists: {args['sku']}")
name_de = _coalesce(args.get("name_de"), "")
name_en = _coalesce(args.get("name_en"), name_de)
desc_de = _coalesce(args.get("description_de"), "")
desc_en = _coalesce(args.get("description_en"), desc_de)
category_id = args.get("category_id")
if category_id in ("", 0):
category_id = None
p = Product(
sku=args["sku"],
name={"de": name_de, "en": name_en},
description={"de": desc_de, "en": desc_en},
price=float(args["price"]),
currency=_coalesce(args.get("currency"), "EUR") or "EUR",
stock=int(_coalesce(args.get("stock"), 0) or 0),
active=bool(_coalesce(args.get("active"), True)),
image_url=_coalesce(args.get("image_url"), "") or "",
category_id=category_id,
attributes=_coalesce(args.get("attributes"), {}) or {},
)
db.add(p)
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
return {"id": p.id, "sku": p.sku}
PRODUCT_CREATE = ToolSpec(
name="catalog.product.create",
description="Create a new product in the catalog.",
args_schema={
"type": "object",
"required": ["sku", "name_de", "price"],
"properties": {
"sku": {"type": "string"},
"name_de": {"type": "string"},
"name_en": {"type": "string"},
"description_de": {"type": "string"},
"description_en": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "default": "EUR"},
"stock": {"type": "integer", "default": 0},
"active": {"type": "boolean", "default": True},
"image_url": {"type": "string"},
"category_id": {"type": "integer"},
"attributes": {"type": "object"},
},
},
handler=_handler_product_create,
examples=[
{
"sku": "TS-GREEN-M",
"name_de": "Grünes T-Shirt",
"name_en": "Green T-Shirt",
"price": 19.90,
"stock": 42,
"attributes": {"color": "green", "size": "M"},
}
],
)
# ---- catalog.product.update ----------------------------------------
def _handler_product_update(args: dict, db: Session) -> dict:
pid = int(args["id"])
p = db.get(Product, pid)
if not p:
raise ValueError(f"Product {pid} not found")
if "name_de" in args or "name_en" in args:
p.name = {
"de": args.get("name_de", p.name.get("de", "")),
"en": args.get("name_en", p.name.get("en", "")),
}
if "description_de" in args or "description_en" in args:
p.description = {
"de": args.get("description_de", p.description.get("de", "")),
"en": args.get("description_en", p.description.get("en", "")),
}
for f in ("price", "currency", "stock", "active", "image_url", "category_id"):
if f in args:
setattr(p, f, args[f])
if "attributes" in args:
p.attributes = args["attributes"]
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.updated", {"id": p.id}, db=db)
return {"id": p.id, "sku": p.sku}
PRODUCT_UPDATE = ToolSpec(
name="catalog.product.update",
description="Update fields of an existing product.",
args_schema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"name_de": {"type": "string"},
"name_en": {"type": "string"},
"description_de": {"type": "string"},
"description_en": {"type": "string"},
"price": {"type": "number"},
"stock": {"type": "integer"},
"active": {"type": "boolean"},
"image_url": {"type": "string"},
"category_id": {"type": "integer"},
"attributes": {"type": "object"},
},
},
handler=_handler_product_update,
examples=[{"id": 5, "price": 24.90, "stock": 10}],
)
# ---- catalog.category.create --------------------------------------
def _handler_category_create(args: dict, db: Session) -> dict:
if db.query(Category).filter_by(slug=args["slug"]).first():
raise ValueError(f"Slug exists: {args['slug']}")
c = Category(
slug=args["slug"],
name={"de": args.get("name_de", ""), "en": args.get("name_en", args.get("name_de", ""))},
parent_id=args.get("parent_id"),
sort_order=int(args.get("sort_order", 0)),
)
db.add(c)
db.commit()
db.refresh(c)
project_category(db, c.id)
event_bus.publish("category.created", {"id": c.id}, db=db)
return {"id": c.id, "slug": c.slug}
CATEGORY_CREATE = ToolSpec(
name="catalog.category.create",
description="Create a new category.",
args_schema={
"type": "object",
"required": ["slug", "name_de"],
"properties": {
"slug": {"type": "string"},
"name_de": {"type": "string"},
"name_en": {"type": "string"},
"parent_id": {"type": "integer"},
"sort_order": {"type": "integer"},
},
},
handler=_handler_category_create,
examples=[{"slug": "accessoires", "name_de": "Accessoires", "name_en": "Accessories"}],
)
# ---- registration -------------------------------------------------
ALL_TOOLS = [SETTINGS_UPDATE, PRODUCT_CREATE, PRODUCT_UPDATE, CATEGORY_CREATE]
def register_all() -> None:
for t in ALL_TOOLS:
register_tool(t)

View File

@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from core.db import get_db
from core.di import register_service
from core.security import require_admin
from .indexer import reindex_all, subscribe_indexer
from .ollama_client import get_llm
from .tools import describe_for_prompt, list_tools
router = APIRouter()
class QueryIn(BaseModel):
query: str
source_type: str | None = None
limit: int = 10
@router.post("/query")
def query_rag(body: QueryIn, db: Session = Depends(get_db)):
if not body.query.strip():
raise HTTPException(400, "Empty query")
emb = get_llm().embed(body.query)
stmt = text(
"""
SELECT source_type, source_id, text, meta,
1 - (embedding <=> (:q)::vector) AS score
FROM ai_documents
{where}
ORDER BY embedding <=> (:q)::vector
LIMIT :lim
""".format(
where="WHERE source_type = :st" if body.source_type else ""
)
)
params: dict = {"q": emb, "lim": body.limit}
if body.source_type:
params["st"] = body.source_type
rows = db.execute(stmt, params).mappings().all()
return [
{
"source_type": r["source_type"],
"source_id": r["source_id"],
"text": r["text"],
"meta": r["meta"],
"score": float(r["score"]),
}
for r in rows
]
@router.post("/reindex", dependencies=[Depends(require_admin)])
def trigger_reindex():
return reindex_all()
@router.get("/tools")
def catalog(_: dict = Depends(require_admin)):
return describe_for_prompt()
def on_load() -> None:
subscribe_indexer()
register_service("LLMProvider", get_llm())
register_service("ToolRegistry", list_tools)

View File

@@ -0,0 +1,158 @@
"""RAG indexer: subscribes to product/category/setting events and (re)builds embeddings."""
from __future__ import annotations
from typing import Any
from sqlalchemy import delete
from sqlalchemy.orm import Session
from core.db import SessionLocal
from core.events import event_bus
from core.i18n import pick
from apps.catalog.models import Category, Product
from .models import AIDocument
from .ollama_client import get_llm
_COLOR_SYNONYMS = {
"green": "grün grüner grünes Grün green olive",
"blue": "blau blauer blaues Blau blue navy",
"black": "schwarz schwarzer schwarzes Schwarz black",
"white": "weiß weißer weißes Weiß white blank",
"olive": "oliv olivgrün olive green khaki",
"red": "rot roter rotes Rot red",
"khaki": "khaki beige oliv",
"brown": "braun brauner braunes Braun brown",
"grey": "grau grauer graues Grau grey gray",
}
def _product_text(p: Product, cat: Category | None) -> str:
parts = [
pick(p.name, "de"),
pick(p.name, "en"),
pick(p.description, "de"),
pick(p.description, "en"),
]
if cat:
parts.append(pick(cat.name, "de"))
parts.append(pick(cat.name, "en"))
if p.attributes:
for k, v in p.attributes.items():
parts.append(f"{k}: {v}")
if k == "color" and isinstance(v, str) and v in _COLOR_SYNONYMS:
parts.append(_COLOR_SYNONYMS[v])
return "\n".join([s for s in parts if s])
def _category_text(c: Category) -> str:
return "\n".join([pick(c.name, "de"), pick(c.name, "en")])
def _upsert(db: Session, source_type: str, source_id: str, text: str, meta: dict) -> None:
if not text.strip():
return
embedding = get_llm().embed(text)
existing = (
db.query(AIDocument)
.filter_by(source_type=source_type, source_id=source_id)
.first()
)
if existing:
existing.text = text
existing.embedding = embedding
existing.meta = meta
else:
db.add(
AIDocument(
source_type=source_type,
source_id=source_id,
text=text,
embedding=embedding,
meta=meta,
)
)
db.commit()
def _remove(db: Session, source_type: str, source_id: str) -> None:
db.execute(
delete(AIDocument).where(
AIDocument.source_type == source_type,
AIDocument.source_id == source_id,
)
)
db.commit()
def index_product(db: Session, product_id: int) -> None:
p = db.get(Product, product_id)
if not p:
_remove(db, "product", str(product_id))
return
cat = db.get(Category, p.category_id) if p.category_id else None
text = _product_text(p, cat)
meta = {"category_id": p.category_id, "price": float(p.price)}
_upsert(db, "product", str(product_id), text, meta)
def index_category(db: Session, category_id: int) -> None:
c = db.get(Category, category_id)
if not c:
_remove(db, "category", str(category_id))
return
_upsert(db, "category", str(category_id), _category_text(c), {})
def reindex_all() -> dict:
db = SessionLocal()
try:
db.execute(delete(AIDocument))
db.commit()
n_p = 0
for p in db.query(Product).filter(Product.active.is_(True)).all():
index_product(db, p.id)
n_p += 1
n_c = 0
for c in db.query(Category).all():
index_category(db, c.id)
n_c += 1
return {"products": n_p, "categories": n_c}
finally:
db.close()
# Event subscribers -----------------------------------------------------
def _on_product_event(event_type: str, payload: dict[str, Any], db: Session) -> None:
pid = payload.get("id")
if not pid:
return
if event_type == "product.deleted":
_remove(db, "product", str(pid))
else:
try:
index_product(db, pid)
except Exception as e: # noqa: BLE001
print(f"[ai-indexer] product {pid} failed: {e}")
def _on_category_event(event_type: str, payload: dict[str, Any], db: Session) -> None:
cid = payload.get("id")
if not cid:
return
if event_type == "category.deleted":
_remove(db, "category", str(cid))
else:
try:
index_category(db, cid)
except Exception as e: # noqa: BLE001
print(f"[ai-indexer] category {cid} failed: {e}")
def subscribe_indexer() -> None:
event_bus.subscribe("product.*", _on_product_event)
event_bus.subscribe("category.*", _on_category_event)

View File

@@ -0,0 +1,6 @@
name: ai_core
version: 0.1.0
depends_on: [core, catalog]
conflicts_with: []
required: true
provides: [LLMProvider, ToolRegistry]

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import JSON, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from core.config import settings
from core.db import Base
class AIDocument(Base):
__tablename__ = "ai_documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_type: Mapped[str] = mapped_column(String(64), index=True) # 'product', 'category', 'setting'
source_id: Mapped[str] = mapped_column(String(64), index=True)
text: Mapped[str] = mapped_column(Text)
embedding: Mapped[list[float]] = mapped_column(Vector(settings.OLLAMA_EMBED_DIM))
meta: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class AIAuditLog(Base):
__tablename__ = "ai_audit"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
tool: Mapped[str] = mapped_column(String(128))
args: Mapped[dict] = mapped_column(JSON, default=dict)
result: Mapped[dict] = mapped_column(JSON, default=dict)
ok: Mapped[bool] = mapped_column()
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,69 @@
"""Thin Ollama client. Interface is kept minimal so Ollama can be swapped later."""
from __future__ import annotations
import json
import httpx
from core.config import settings
class OllamaClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = base_url or settings.OLLAMA_URL
self._client = httpx.Client(base_url=self.base_url, timeout=600.0)
def embed(self, text: str, model: str | None = None) -> list[float]:
model = model or settings.OLLAMA_EMBED_MODEL
r = self._client.post("/api/embeddings", json={"model": model, "prompt": text})
r.raise_for_status()
data = r.json()
return data["embedding"]
def chat(
self,
system: str,
user: str,
model: str | None = None,
json_mode: bool = False,
) -> str:
model = model or settings.OLLAMA_CHAT_MODEL
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
payload = {"model": model, "messages": messages, "stream": False}
if json_mode:
payload["format"] = "json"
r = self._client.post("/api/chat", json=payload)
r.raise_for_status()
data = r.json()
return data.get("message", {}).get("content", "")
def chat_json(self, system: str, user: str, model: str | None = None) -> dict:
raw = self.chat(system, user, model=model, json_mode=True)
try:
return json.loads(raw)
except json.JSONDecodeError:
# Try to recover a JSON object/array from the output
start = raw.find("{")
alt_start = raw.find("[")
if alt_start != -1 and (start == -1 or alt_start < start):
start = alt_start
end = max(raw.rfind("}"), raw.rfind("]"))
if start >= 0 and end > start:
try:
return json.loads(raw[start : end + 1])
except json.JSONDecodeError:
pass
raise
_client: OllamaClient | None = None
def get_llm() -> OllamaClient:
global _client
if _client is None:
_client = OllamaClient()
return _client

View File

@@ -0,0 +1,6 @@
"""Standalone entrypoint: `uv run python -m apps.ai_core.reindex`"""
from .indexer import reindex_all
if __name__ == "__main__":
result = reindex_all()
print(f"Reindexed: {result}")

View File

@@ -0,0 +1,56 @@
"""Tool Registry: apps register callable tools + JSON Schema for the KI to use.
KI never runs tools directly — the registry is only a catalog for the planner,
and handlers are invoked by the `execute` endpoint after user confirmation.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.orm import Session
@dataclass
class ToolSpec:
name: str # e.g. "catalog.product.create"
description: str
args_schema: dict # JSON Schema
handler: Callable[[dict, Session], dict]
required_role: str = "admin" # only admin-exposed in AI admin chat
examples: list[dict] = field(default_factory=list)
_tools: dict[str, ToolSpec] = {}
def register_tool(spec: ToolSpec) -> None:
_tools[spec.name] = spec
def get_tool(name: str) -> ToolSpec | None:
return _tools.get(name)
def list_tools(role: str = "admin") -> list[ToolSpec]:
return [t for t in _tools.values() if t.required_role == role or role == "admin"]
def describe_for_prompt(role: str = "admin") -> list[dict[str, Any]]:
"""Return a JSON-serializable description of all tools for the LLM prompt."""
return [
{
"name": t.name,
"description": t.description,
"args_schema": t.args_schema,
"examples": t.examples,
}
for t in list_tools(role)
]
def validate_args(spec: ToolSpec, args: dict) -> list[str]:
"""Return list of missing required keys (basic check — not full JSON-schema)."""
required = spec.args_schema.get("required", [])
return [k for k in required if k not in args or args[k] in (None, "")]

View File

@@ -0,0 +1,111 @@
import json
import re
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from core.db import get_db
from core.i18n import pick
from core.redis_client import redis_client
from apps.ai_core.ollama_client import get_llm
router = APIRouter()
class SearchIn(BaseModel):
query: str
limit: int = 12
_STOP = {
"ich", "suche", "brauche", "will", "möchte", "eine", "einen", "ein",
"die", "der", "das", "und", "oder", "mit", "für", "zum", "zur",
"i", "am", "looking", "for", "want", "need", "a", "an", "the",
"of", "to", "with", "etwas", "some", "nach",
}
_SYN = {
"pulli": ["pullover", "sweater"],
"shirt": ["t-shirt", "tshirt"],
"hose": ["pants", "jeans"],
"warme": ["warm"],
"warm": ["warme"],
"grüner": ["grün", "green"],
"grüne": ["grün", "green"],
"grünes": ["grün", "green"],
"blauer": ["blau", "blue"],
"blaue": ["blau", "blue"],
"blaues": ["blau", "blue"],
"wandern": ["wander", "hiking"],
}
def _tokenize(s: str) -> list[str]:
tokens = [t.lower() for t in re.findall(r"[\wäöüß]+", s, flags=re.UNICODE)]
expanded: list[str] = []
for t in tokens:
if t in _STOP or len(t) < 2:
continue
expanded.append(t)
expanded.extend(_SYN.get(t, []))
return expanded
def _keyword_score(product: dict, tokens: list[str]) -> float:
if not tokens:
return 0.0
haystack = " ".join([
pick(product.get("name", {}), "de").lower(),
pick(product.get("name", {}), "en").lower(),
pick(product.get("description", {}), "de").lower(),
pick(product.get("description", {}), "en").lower(),
" ".join(str(v).lower() for v in (product.get("attributes") or {}).values()),
product.get("sku", "").lower(),
])
hits = sum(1 for t in tokens if t in haystack)
return hits / len(tokens)
@router.post("/search")
def ki_search(body: SearchIn, db: Session = Depends(get_db)):
"""Hybrid product search: embedding similarity + keyword boost."""
q = body.query.strip()
if not q:
return {"query": q, "products": []}
emb = get_llm().embed(q)
# Pull a larger candidate pool, then re-rank with keyword boost
pool_size = max(body.limit * 3, 20)
rows = db.execute(
text(
"""
SELECT source_id, 1 - (embedding <=> (:q)::vector) AS score
FROM ai_documents
WHERE source_type = 'product'
ORDER BY embedding <=> (:q)::vector
LIMIT :lim
"""
),
{"q": emb, "lim": pool_size},
).mappings().all()
tokens = _tokenize(q)
candidates: list[dict] = []
for r in rows:
raw = redis_client.get(f"product:{r['source_id']}")
if not raw:
continue
d = json.loads(raw)
emb_s = float(r["score"])
kw_s = _keyword_score(d, tokens)
# Combined score: 60% embedding, 40% keyword (but keyword zeroing-out boosts ordering)
d["_score"] = round(0.6 * emb_s + 0.4 * kw_s, 4)
d["_emb"] = round(emb_s, 4)
d["_kw"] = round(kw_s, 4)
candidates.append(d)
candidates.sort(key=lambda p: p["_score"], reverse=True)
return {"query": q, "products": candidates[: body.limit]}

View File

@@ -0,0 +1,6 @@
name: ai_shop
version: 0.1.0
depends_on: [core, catalog, ai_core]
conflicts_with: []
required: false
provides: []

View File

@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from core.db import get_db
from core.events import event_bus
from core.security import (
decode_token,
get_current_user_id,
hash_password,
make_access_token,
make_refresh_token,
verify_password,
)
from .models import User
router = APIRouter()
class RegisterIn(BaseModel):
email: EmailStr
password: str
name: str = ""
class LoginIn(BaseModel):
email: EmailStr
password: str
class TokenOut(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
role: str
user_id: int
class UserOut(BaseModel):
id: int
email: str
name: str
role: str
locale: str
class UpdateMeIn(BaseModel):
name: str | None = None
locale: str | None = None
class ChangePasswordIn(BaseModel):
old_password: str
new_password: str
@router.post("/register", response_model=TokenOut)
def register(body: RegisterIn, db: Session = Depends(get_db)):
if db.query(User).filter_by(email=body.email.lower()).first():
raise HTTPException(400, "Email already registered")
user = User(
email=body.email.lower(),
password_hash=hash_password(body.password),
name=body.name,
role="customer",
)
db.add(user)
db.commit()
db.refresh(user)
event_bus.publish(
"user.registered",
{"user_id": user.id, "email": user.email},
db=db,
)
return TokenOut(
access_token=make_access_token(user.id, user.role),
refresh_token=make_refresh_token(user.id, user.role),
role=user.role,
user_id=user.id,
)
@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)):
user = db.query(User).filter_by(email=body.email.lower()).first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Invalid credentials")
event_bus.publish("user.logged_in", {"user_id": user.id}, db=db)
return TokenOut(
access_token=make_access_token(user.id, user.role),
refresh_token=make_refresh_token(user.id, user.role),
role=user.role,
user_id=user.id,
)
class RefreshIn(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=TokenOut)
def refresh(body: RefreshIn, db: Session = Depends(get_db)):
claims = decode_token(body.refresh_token)
if claims.get("type") != "refresh":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not a refresh token")
user_id = int(claims["sub"])
user = db.get(User, user_id)
if not user:
raise HTTPException(401, "User gone")
return TokenOut(
access_token=make_access_token(user.id, user.role),
refresh_token=make_refresh_token(user.id, user.role),
role=user.role,
user_id=user.id,
)
@router.get("/me", response_model=UserOut)
def me(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
user = db.get(User, user_id)
if not user:
raise HTTPException(404, "User not found")
return UserOut(id=user.id, email=user.email, name=user.name, role=user.role, locale=user.locale)
@router.put("/me", response_model=UserOut)
def update_me(
body: UpdateMeIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(404, "User not found")
if body.name is not None:
user.name = body.name
if body.locale is not None:
user.locale = body.locale
db.commit()
return UserOut(id=user.id, email=user.email, name=user.name, role=user.role, locale=user.locale)
@router.post("/change-password")
def change_password(
body: ChangePasswordIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user or not verify_password(body.old_password, user.password_hash):
raise HTTPException(400, "Old password wrong")
user.password_hash = hash_password(body.new_password)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,6 @@
name: auth
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: true
provides: [UserService]

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from core.db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(32), default="customer") # 'customer' | 'admin'
name: Mapped[str] = mapped_column(String(128), default="")
locale: Mapped[str] = mapped_column(String(8), default="de")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,140 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.redis_client import redis_client
from core.security import get_current_user_id
from .models import Cart, CartItem
router = APIRouter()
class AddItemIn(BaseModel):
product_id: int
qty: int = 1
class UpdateItemIn(BaseModel):
qty: int
class CartItemOut(BaseModel):
product_id: int
qty: int
name: dict = {}
price: float = 0.0
image_url: str = ""
line_total: float = 0.0
class CartOut(BaseModel):
items: list[CartItemOut]
subtotal: float
def _get_or_create_cart(db: Session, user_id: int) -> Cart:
cart = db.query(Cart).filter_by(user_id=user_id).first()
if not cart:
cart = Cart(user_id=user_id)
db.add(cart)
db.commit()
db.refresh(cart)
return cart
def _cart_to_out(cart: Cart) -> CartOut:
items: list[CartItemOut] = []
subtotal = 0.0
for it in cart.items:
raw = redis_client.get(f"product:{it.product_id}")
if not raw:
continue
p = json.loads(raw)
line = round(float(p["price"]) * it.qty, 2)
subtotal += line
items.append(
CartItemOut(
product_id=it.product_id,
qty=it.qty,
name=p.get("name", {}),
price=float(p["price"]),
image_url=p.get("image_url", ""),
line_total=line,
)
)
return CartOut(items=items, subtotal=round(subtotal, 2))
@router.get("", response_model=CartOut)
def get_cart(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
return _cart_to_out(_get_or_create_cart(db, user_id))
@router.post("/items", response_model=CartOut)
def add_item(
body: AddItemIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
if body.qty < 1:
raise HTTPException(400, "qty must be >= 1")
if not redis_client.get(f"product:{body.product_id}"):
raise HTTPException(404, "Product not found or inactive")
cart = _get_or_create_cart(db, user_id)
existing = db.query(CartItem).filter_by(cart_id=cart.id, product_id=body.product_id).first()
if existing:
existing.qty += body.qty
else:
db.add(CartItem(cart_id=cart.id, product_id=body.product_id, qty=body.qty))
db.commit()
db.refresh(cart)
return _cart_to_out(cart)
@router.put("/items/{product_id}", response_model=CartOut)
def update_item(
product_id: int,
body: UpdateItemIn,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
cart = _get_or_create_cart(db, user_id)
item = db.query(CartItem).filter_by(cart_id=cart.id, product_id=product_id).first()
if not item:
raise HTTPException(404, "Not in cart")
if body.qty < 1:
db.delete(item)
else:
item.qty = body.qty
db.commit()
db.refresh(cart)
return _cart_to_out(cart)
@router.delete("/items/{product_id}", response_model=CartOut)
def remove_item(
product_id: int,
user_id: int = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
cart = _get_or_create_cart(db, user_id)
item = db.query(CartItem).filter_by(cart_id=cart.id, product_id=product_id).first()
if item:
db.delete(item)
db.commit()
db.refresh(cart)
return _cart_to_out(cart)
@router.delete("", response_model=CartOut)
def clear_cart(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
cart = _get_or_create_cart(db, user_id)
for it in list(cart.items):
db.delete(it)
db.commit()
db.refresh(cart)
return _cart_to_out(cart)

View File

@@ -0,0 +1,6 @@
name: cart
version: 0.1.0
depends_on: [core, auth, catalog]
conflicts_with: []
required: true
provides: [CartService]

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.db import Base
class Cart(Base):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
items: Mapped[list["CartItem"]] = relationship(
"CartItem", back_populates="cart", cascade="all, delete-orphan", lazy="joined"
)
class CartItem(Base):
__tablename__ = "cart_items"
__table_args__ = (UniqueConstraint("cart_id", "product_id", name="uq_cart_product"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id", ondelete="CASCADE"))
product_id: Mapped[int] = mapped_column(ForeignKey("products.id", ondelete="CASCADE"))
qty: Mapped[int] = mapped_column(Integer, default=1)
cart: Mapped["Cart"] = relationship("Cart", back_populates="items")

View File

@@ -0,0 +1,242 @@
import json
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from core.config import settings
from core.db import get_db
from core.events import event_bus
from core.redis_client import redis_client
from core.security import require_admin
from .models import Category, Product
from .projector import (
delete_category_from_cache,
delete_product_from_cache,
project_category,
project_product,
rebuild_all,
)
router = APIRouter()
class CategoryIn(BaseModel):
slug: str
name: dict = Field(default_factory=dict)
parent_id: int | None = None
sort_order: int = 0
class CategoryOut(CategoryIn):
id: int
class ProductIn(BaseModel):
sku: str
name: dict = Field(default_factory=dict)
description: dict = Field(default_factory=dict)
price: float
currency: str = "EUR"
stock: int = 0
active: bool = True
image_url: str = ""
category_id: int | None = None
attributes: dict = Field(default_factory=dict)
class ProductOut(ProductIn):
id: int
# ------- Public reads (Redis-backed) --------
@router.get("/products")
def list_products():
raw = redis_client.get("product:list")
ids: list[int] = json.loads(raw) if raw else []
if not ids:
return []
keys = [f"product:{i}" for i in ids]
values = redis_client.mget(keys)
return [json.loads(v) for v in values if v]
@router.get("/products/{product_id}")
def read_product(product_id: int):
raw = redis_client.get(f"product:{product_id}")
if not raw:
raise HTTPException(404, "Product not found")
return json.loads(raw)
@router.get("/categories")
def list_categories():
raw = redis_client.get("category:tree")
return json.loads(raw) if raw else []
@router.get("/categories/{category_id}/products")
def products_by_category(category_id: int):
# Linear scan acceptable for prototype
raw = redis_client.get("product:list")
ids: list[int] = json.loads(raw) if raw else []
if not ids:
return []
values = redis_client.mget([f"product:{i}" for i in ids])
out = []
for v in values:
if not v:
continue
d = json.loads(v)
if d.get("category_id") == category_id:
out.append(d)
return out
# ------- Admin writes --------
admin_router = APIRouter(dependencies=[Depends(require_admin)])
@admin_router.get("/products", response_model=list[ProductOut])
def admin_list_products(db: Session = Depends(get_db)):
rows = db.query(Product).order_by(Product.id.desc()).all()
return [_to_product_out(p) for p in rows]
@admin_router.post("/products", response_model=ProductOut)
def admin_create_product(body: ProductIn, db: Session = Depends(get_db)):
if db.query(Product).filter_by(sku=body.sku).first():
raise HTTPException(400, "SKU already exists")
p = Product(**body.model_dump())
db.add(p)
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
return _to_product_out(p)
@admin_router.put("/products/{pid}", response_model=ProductOut)
def admin_update_product(pid: int, body: ProductIn, db: Session = Depends(get_db)):
p = db.get(Product, pid)
if not p:
raise HTTPException(404, "Not found")
for k, v in body.model_dump().items():
setattr(p, k, v)
db.commit()
db.refresh(p)
project_product(db, p.id)
event_bus.publish("product.updated", {"id": p.id}, db=db)
return _to_product_out(p)
@admin_router.delete("/products/{pid}")
def admin_delete_product(pid: int, db: Session = Depends(get_db)):
p = db.get(Product, pid)
if not p:
raise HTTPException(404, "Not found")
db.delete(p)
db.commit()
delete_product_from_cache(pid, db)
event_bus.publish("product.deleted", {"id": pid}, db=db)
return {"ok": True}
@admin_router.get("/categories", response_model=list[CategoryOut])
def admin_list_categories(db: Session = Depends(get_db)):
rows = db.query(Category).order_by(Category.sort_order, Category.id).all()
return [_to_category_out(c) for c in rows]
@admin_router.post("/categories", response_model=CategoryOut)
def admin_create_category(body: CategoryIn, db: Session = Depends(get_db)):
if db.query(Category).filter_by(slug=body.slug).first():
raise HTTPException(400, "Slug already exists")
c = Category(**body.model_dump())
db.add(c)
db.commit()
db.refresh(c)
project_category(db, c.id)
event_bus.publish("category.created", {"id": c.id}, db=db)
return _to_category_out(c)
@admin_router.put("/categories/{cid}", response_model=CategoryOut)
def admin_update_category(cid: int, body: CategoryIn, db: Session = Depends(get_db)):
c = db.get(Category, cid)
if not c:
raise HTTPException(404, "Not found")
for k, v in body.model_dump().items():
setattr(c, k, v)
db.commit()
db.refresh(c)
project_category(db, c.id)
event_bus.publish("category.updated", {"id": c.id}, db=db)
return _to_category_out(c)
@admin_router.delete("/categories/{cid}")
def admin_delete_category(cid: int, db: Session = Depends(get_db)):
c = db.get(Category, cid)
if not c:
raise HTTPException(404, "Not found")
db.delete(c)
db.commit()
delete_category_from_cache(cid, db)
event_bus.publish("category.deleted", {"id": cid}, db=db)
return {"ok": True}
@admin_router.post("/upload")
async def upload_image(file: UploadFile = File(...)):
ext = Path(file.filename or "").suffix or ".bin"
name = f"{uuid.uuid4().hex}{ext}"
dest = Path(settings.UPLOAD_DIR) / name
dest.parent.mkdir(parents=True, exist_ok=True)
content = await file.read()
dest.write_bytes(content)
return {"url": f"{settings.PUBLIC_BASE_URL}/uploads/{name}"}
@admin_router.post("/rebuild-cache")
def admin_rebuild_cache(db: Session = Depends(get_db)):
rebuild_all(db)
return {"ok": True}
router.include_router(admin_router, prefix="/admin")
# ------- helpers -------
def _to_product_out(p: Product) -> ProductOut:
return ProductOut(
id=p.id,
sku=p.sku,
name=p.name or {},
description=p.description or {},
price=float(p.price),
currency=p.currency,
stock=p.stock,
active=p.active,
image_url=p.image_url,
category_id=p.category_id,
attributes=p.attributes or {},
)
def _to_category_out(c: Category) -> CategoryOut:
return CategoryOut(
id=c.id,
slug=c.slug,
name=c.name or {},
parent_id=c.parent_id,
sort_order=c.sort_order,
)

View File

@@ -0,0 +1,6 @@
name: catalog
version: 0.1.0
depends_on: [core, auth]
conflicts_with: []
required: true
provides: [ProductService, CategoryService]

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.db import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
name: Mapped[dict] = mapped_column(JSON, default=dict) # {'de': '...', 'en': '...'}
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sku: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[dict] = mapped_column(JSON, default=dict) # i18n
description: Mapped[dict] = mapped_column(JSON, default=dict) # i18n
price: Mapped[float] = mapped_column(Numeric(10, 2))
currency: Mapped[str] = mapped_column(String(3), default="EUR")
stock: Mapped[int] = mapped_column(Integer, default=0)
active: Mapped[bool] = mapped_column(Boolean, default=True)
image_url: Mapped[str] = mapped_column(String(500), default="")
category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
attributes: Mapped[dict] = mapped_column(JSON, default=dict) # color, size, ...
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
category: Mapped[Category | None] = relationship("Category", lazy="joined")

View File

@@ -0,0 +1,87 @@
"""Projects catalog changes into Redis for the Shop frontend to read."""
from __future__ import annotations
import json
from sqlalchemy.orm import Session
from core.redis_client import redis_client
from .models import Category, Product
def _product_to_dict(p: Product) -> dict:
return {
"id": p.id,
"sku": p.sku,
"name": p.name,
"description": p.description,
"price": float(p.price),
"currency": p.currency,
"stock": p.stock,
"active": p.active,
"image_url": p.image_url,
"category_id": p.category_id,
"attributes": p.attributes or {},
}
def _category_to_dict(c: Category) -> dict:
return {
"id": c.id,
"slug": c.slug,
"name": c.name,
"parent_id": c.parent_id,
"sort_order": c.sort_order,
}
def project_product(db: Session, product_id: int) -> None:
p = db.get(Product, product_id)
if not p or not p.active:
redis_client.delete(f"product:{product_id}")
_refresh_product_list(db)
return
redis_client.set(f"product:{product_id}", json.dumps(_product_to_dict(p)))
_refresh_product_list(db)
def delete_product_from_cache(product_id: int, db: Session) -> None:
redis_client.delete(f"product:{product_id}")
_refresh_product_list(db)
def _refresh_product_list(db: Session) -> None:
ids = [row[0] for row in db.query(Product.id).filter(Product.active.is_(True)).all()]
redis_client.set("product:list", json.dumps(ids))
def project_category(db: Session, category_id: int) -> None:
c = db.get(Category, category_id)
if not c:
redis_client.delete(f"category:{category_id}")
else:
redis_client.set(f"category:{category_id}", json.dumps(_category_to_dict(c)))
_refresh_category_tree(db)
def delete_category_from_cache(category_id: int, db: Session) -> None:
redis_client.delete(f"category:{category_id}")
_refresh_category_tree(db)
def _refresh_category_tree(db: Session) -> None:
cats = db.query(Category).order_by(Category.sort_order, Category.id).all()
data = [_category_to_dict(c) for c in cats]
redis_client.set("category:tree", json.dumps(data))
def rebuild_all(db: Session) -> None:
# Refresh every product/category key
for p in db.query(Product).all():
if p.active:
redis_client.set(f"product:{p.id}", json.dumps(_product_to_dict(p)))
for c in db.query(Category).all():
redis_client.set(f"category:{c.id}", json.dumps(_category_to_dict(c)))
_refresh_product_list(db)
_refresh_category_tree(db)

View File

@@ -0,0 +1,106 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.di import get_service
from core.events import event_bus
from core.redis_client import redis_client
from core.security import get_current_user_id
from apps.cart.models import Cart, CartItem
router = APIRouter()
class AddressIn(BaseModel):
name: str
street: str
zip: str
city: str
country: str = "DE"
class CheckoutIn(BaseModel):
address: AddressIn
payment_method: str = "dummy"
class CheckoutPreview(BaseModel):
items: list[dict]
subtotal: float
total: float
@router.post("/preview", response_model=CheckoutPreview)
def preview(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
cart = db.query(Cart).filter_by(user_id=user_id).first()
if not cart or not cart.items:
raise HTTPException(400, "Cart is empty")
items = []
subtotal = 0.0
for it in cart.items:
raw = redis_client.get(f"product:{it.product_id}")
if not raw:
raise HTTPException(400, f"Product {it.product_id} no longer available")
p = json.loads(raw)
line = round(float(p["price"]) * it.qty, 2)
subtotal += line
items.append({"product_id": it.product_id, "name": p["name"], "qty": it.qty, "price": float(p["price"]), "line_total": line})
return CheckoutPreview(items=items, subtotal=round(subtotal, 2), total=round(subtotal, 2))
@router.post("/confirm")
def confirm(body: CheckoutIn, user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
cart = db.query(Cart).filter_by(user_id=user_id).first()
if not cart or not cart.items:
raise HTTPException(400, "Cart is empty")
# Snapshot items
snapshot_items = []
subtotal = 0.0
for it in cart.items:
raw = redis_client.get(f"product:{it.product_id}")
if not raw:
raise HTTPException(400, f"Product {it.product_id} not available")
p = json.loads(raw)
line = round(float(p["price"]) * it.qty, 2)
subtotal += line
snapshot_items.append(
{
"product_id": it.product_id,
"sku": p.get("sku", ""),
"name": p.get("name", {}),
"price": float(p["price"]),
"qty": it.qty,
"line_total": line,
}
)
total = round(subtotal, 2)
payment = get_service("PaymentProvider").charge(total, "EUR", body.payment_method)
if payment["status"] != "paid":
raise HTTPException(402, "Payment failed")
event_payload = {
"user_id": user_id,
"items": snapshot_items,
"subtotal": round(subtotal, 2),
"total": total,
"currency": "EUR",
"address": body.address.model_dump(),
"payment": payment,
}
event_bus.publish("checkout.confirmed", event_payload, db=db)
# Clear cart
for it in list(cart.items):
db.delete(it)
db.commit()
# Find order id from events handler (orders-app) — pull via redis key or return event_payload
# Since the orders handler sets 'last_order_id' for this user in redis for convenience:
last = redis_client.get(f"user:{user_id}:last_order_id")
return {"ok": True, "order_id": int(last) if last else None, "payment": payment}

View File

@@ -0,0 +1,6 @@
name: checkout
version: 0.1.0
depends_on: [core, auth, catalog, cart, payment]
conflicts_with: []
required: true
provides: []

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
def ping():
return {"msg": "hello from dummy app"}

View File

@@ -0,0 +1,6 @@
name: hello
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: false
provides: []

View File

@@ -0,0 +1,51 @@
"""Mail sender via SMTP (Mailhog in dev)."""
from __future__ import annotations
import asyncio
from email.message import EmailMessage
import aiosmtplib
from fastapi import APIRouter
from core.config import settings
from core.di import register_service
router = APIRouter()
class MailService:
async def send(self, to: str, subject: str, body_html: str, body_text: str = "") -> None:
msg = EmailMessage()
msg["From"] = settings.MAIL_FROM
msg["To"] = to
msg["Subject"] = subject
if body_text:
msg.set_content(body_text)
msg.add_alternative(body_html, subtype="html")
else:
msg.set_content(body_html, subtype="html")
try:
await aiosmtplib.send(
msg,
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
start_tls=False,
)
except Exception as e: # noqa: BLE001
print(f"[mail] send error: {e}")
def send_sync(self, to: str, subject: str, body_html: str, body_text: str = "") -> None:
"""Blocking wrapper for event handlers."""
try:
asyncio.run(self.send(to, subject, body_html, body_text))
except RuntimeError:
# inside running loop (unlikely in sync event handlers, but just in case)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(self.send(to, subject, body_html, body_text))
finally:
loop.close()
def on_load() -> None:
register_service("MailService", MailService())

View File

@@ -0,0 +1,6 @@
name: mail
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: true
provides: [MailService]

View File

@@ -0,0 +1,175 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from jinja2 import Template
from pydantic import BaseModel
from sqlalchemy.orm import Session
from core.db import get_db
from core.di import get_service
from core.events import event_bus
from core.redis_client import redis_client
from core.security import get_current_user_id, require_admin
from core.settings_service import get_setting_cached
from apps.auth.models import User
from .models import Order, OrderStatusHistory
router = APIRouter()
_EMAIL_TEMPLATE_DE = """
<h1>Bestellbestätigung #{{ order.id }}</h1>
<p>Vielen Dank für deine Bestellung bei {{ shop_name }}!</p>
<h2>Artikel</h2>
<ul>
{% for it in order['items'] %}
<li>{{ it['name'].get('de', it['name'].get('en', it.get('sku',''))) }} — {{ it['qty'] }} × {{ '%.2f' % it['price'] }} {{ order['currency'] }}</li>
{% endfor %}
</ul>
<p><strong>Gesamt: {{ '%.2f' % order['total'] }} {{ order['currency'] }}</strong></p>
<p>Lieferadresse: {{ order['address']['name'] }}, {{ order['address']['street'] }}, {{ order['address']['zip'] }} {{ order['address']['city'] }}</p>
<p>Zahlungsreferenz: {{ order['payment']['transaction_id'] }}</p>
"""
def _render_mail(order_dict: dict, shop_name: str) -> str:
return Template(_EMAIL_TEMPLATE_DE).render(order=order_dict, shop_name=shop_name)
# Event handler for checkout.confirmed ------------------------------------
def _on_checkout_confirmed(event_type: str, payload: dict[str, Any], db: Session) -> None:
order = Order(
user_id=payload.get("user_id"),
status="paid",
total=payload["total"],
currency=payload.get("currency", "EUR"),
address=payload.get("address", {}),
payment=payload.get("payment", {}),
items=payload.get("items", []),
)
db.add(order)
db.flush()
db.add(OrderStatusHistory(order_id=order.id, status="paid", note="auto"))
db.commit()
# Convenience key for the synchronous checkout handler to return order_id
if payload.get("user_id"):
redis_client.set(f"user:{payload['user_id']}:last_order_id", str(order.id), ex=60)
# Send confirmation mail
user = db.get(User, order.user_id) if order.user_id else None
if user:
shop_name = get_setting_cached("core.shop_name", "Shop")
body = _render_mail(
{
"id": order.id,
"items": order.items,
"total": float(order.total),
"currency": order.currency,
"address": order.address,
"payment": order.payment,
},
shop_name,
)
try:
mail = get_service("MailService")
mail.send_sync(user.email, f"Bestellbestätigung #{order.id}", body)
except Exception as e: # noqa: BLE001
print(f"[orders] mail send failed: {e}")
event_bus.publish("order.created", {"id": order.id, "user_id": order.user_id}, db=db)
def on_load() -> None:
event_bus.subscribe("checkout.confirmed", _on_checkout_confirmed)
# API -------------------------------------------------------------------
class OrderOut(BaseModel):
id: int
user_id: int | None
status: str
total: float
currency: str
address: dict
payment: dict
items: list
created_at: str
class StatusUpdateIn(BaseModel):
status: str
note: str = ""
def _to_out(o: Order) -> OrderOut:
return OrderOut(
id=o.id,
user_id=o.user_id,
status=o.status,
total=float(o.total),
currency=o.currency,
address=o.address or {},
payment=o.payment or {},
items=o.items or [],
created_at=o.created_at.isoformat() if o.created_at else "",
)
# Admin sub-router is registered FIRST so /admin* doesn't get shadowed by /{order_id}
admin_router = APIRouter(dependencies=[Depends(require_admin)])
@admin_router.get("", response_model=list[OrderOut])
def admin_list_orders(db: Session = Depends(get_db)):
rows = db.query(Order).order_by(Order.created_at.desc()).all()
return [_to_out(o) for o in rows]
@admin_router.get("/{order_id}", response_model=OrderOut)
def admin_read_order(order_id: int, db: Session = Depends(get_db)):
o = db.get(Order, order_id)
if not o:
raise HTTPException(404, "Not found")
return _to_out(o)
@admin_router.put("/{order_id}/status", response_model=OrderOut)
def admin_update_status(order_id: int, body: StatusUpdateIn, db: Session = Depends(get_db)):
o = db.get(Order, order_id)
if not o:
raise HTTPException(404, "Not found")
o.status = body.status
db.add(OrderStatusHistory(order_id=order_id, status=body.status, note=body.note))
db.commit()
db.refresh(o)
event_bus.publish("order.status_changed", {"id": order_id, "status": body.status}, db=db)
return _to_out(o)
router.include_router(admin_router, prefix="/admin")
# Customer routes — defined AFTER admin include so '/admin' matches first
@router.get("", response_model=list[OrderOut])
def list_my_orders(user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
rows = (
db.query(Order)
.filter(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.all()
)
return [_to_out(o) for o in rows]
@router.get("/{order_id}", response_model=OrderOut)
def read_order(order_id: int, user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db)):
o = db.get(Order, order_id)
if not o or o.user_id != user_id:
raise HTTPException(404, "Order not found")
return _to_out(o)

View File

@@ -0,0 +1,6 @@
name: orders
version: 0.1.0
depends_on: [core, auth, catalog, mail]
conflicts_with: []
required: true
provides: [OrderService]

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.db import Base
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="paid")
total: Mapped[float] = mapped_column(Numeric(10, 2))
currency: Mapped[str] = mapped_column(String(3), default="EUR")
address: Mapped[dict] = mapped_column(JSON, default=dict)
payment: Mapped[dict] = mapped_column(JSON, default=dict)
items: Mapped[list[dict]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
status_history: Mapped[list["OrderStatusHistory"]] = relationship(
"OrderStatusHistory", cascade="all, delete-orphan", lazy="joined"
)
class OrderStatusHistory(Base):
__tablename__ = "order_status_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(32))
note: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,34 @@
"""Dummy payment provider — always approves."""
from __future__ import annotations
import uuid
from fastapi import APIRouter
from core.di import register_service
router = APIRouter()
class PaymentProvider:
name = "DummyPayment"
def charge(self, amount: float, currency: str, method: str = "dummy") -> dict:
return {
"status": "paid",
"transaction_id": f"DUM-{uuid.uuid4().hex[:12]}",
"amount": amount,
"currency": currency,
"method": method,
}
def on_load() -> None:
register_service("PaymentProvider", PaymentProvider())
@router.get("/methods")
def available_methods():
return [
{"id": "dummy", "label_de": "Testzahlung", "label_en": "Test payment"},
]

View File

@@ -0,0 +1,6 @@
name: payment
version: 0.1.0
depends_on: [core]
conflicts_with: []
required: true
provides: [PaymentProvider]