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

39
backend/alembic.ini Normal file
View File

@@ -0,0 +1,39 @@
[alembic]
script_location = core/migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

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]

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

37
backend/core/config.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
APP_ENV: str = "dev"
DATABASE_URL: str = "postgresql+psycopg://shop:shop@localhost:5432/shop"
REDIS_URL: str = "redis://localhost:6379/0"
MEILI_URL: str = "http://localhost:7700"
MEILI_KEY: str = "shop-dev-master-key"
OLLAMA_URL: str = "http://localhost:11434"
OLLAMA_CHAT_MODEL: str = "llama3.1"
OLLAMA_EMBED_MODEL: str = "nomic-embed-text"
OLLAMA_EMBED_DIM: int = 768
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 1025
MAIL_FROM: str = "shop@example.com"
JWT_SECRET: str = "change-me"
JWT_ACCESS_MINUTES: int = 15
JWT_REFRESH_DAYS: int = 30
UPLOAD_DIR: str = "./uploads"
PUBLIC_BASE_URL: str = "http://localhost:8000"
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:5174"
@property
def cors_list(self) -> list[str]:
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
settings = Settings()

21
backend/core/db.py Normal file
View File

@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from core.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

18
backend/core/di.py Normal file
View File

@@ -0,0 +1,18 @@
"""Minimal service registry. Apps call `register_service(name, instance)` at startup."""
from typing import Any
_services: dict[str, Any] = {}
def register_service(name: str, instance: Any) -> None:
_services[name] = instance
def get_service(name: str) -> Any:
if name not in _services:
raise KeyError(f"Service not registered: {name}")
return _services[name]
def has_service(name: str) -> bool:
return name in _services

62
backend/core/events.py Normal file
View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, Session, mapped_column
from core.db import Base, SessionLocal
EventHandler = Callable[[str, dict[str, Any], Session], None]
class EventLog(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
type: Mapped[str] = mapped_column(String(128), index=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class EventBus:
def __init__(self) -> None:
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
self._wildcards: list[tuple[str, EventHandler]] = []
def subscribe(self, event_type: str, handler: EventHandler) -> None:
"""Subscribe to an event type. Supports 'namespace.*' wildcards."""
if event_type.endswith(".*"):
self._wildcards.append((event_type[:-2], handler))
else:
self._handlers[event_type].append(handler)
def publish(self, event_type: str, payload: dict[str, Any], db: Session | None = None) -> None:
"""Publish event. Persists to events table and calls all handlers synchronously."""
own_db = db is None
if own_db:
db = SessionLocal()
try:
db.add(EventLog(type=event_type, payload=payload))
db.commit()
for h in self._handlers.get(event_type, []):
try:
h(event_type, payload, db)
except Exception as e: # noqa: BLE001
print(f"[event-handler-error] {event_type}: {e}")
for prefix, h in self._wildcards:
if event_type.startswith(prefix + ".") or event_type == prefix:
try:
h(event_type, payload, db)
except Exception as e: # noqa: BLE001
print(f"[event-handler-error] {event_type}: {e}")
finally:
if own_db:
db.close()
event_bus = EventBus()

16
backend/core/i18n.py Normal file
View File

@@ -0,0 +1,16 @@
"""Tiny i18n helper for DE/EN content stored as {'de': ..., 'en': ...} dicts."""
from typing import Any
DEFAULT_LOCALE = "de"
SUPPORTED = ("de", "en")
def pick(field: dict[str, Any] | None, locale: str = DEFAULT_LOCALE) -> str:
if not field:
return ""
return field.get(locale) or field.get(DEFAULT_LOCALE) or next(iter(field.values()), "")
def normalize(field: dict[str, Any] | None) -> dict[str, str]:
field = field or {}
return {lang: str(field.get(lang, "")) for lang in SUPPORTED}

99
backend/core/loader.py Normal file
View File

@@ -0,0 +1,99 @@
"""App-Loader: discovers apps/<name>/manifest.yaml, resolves deps, imports and registers."""
from __future__ import annotations
import importlib
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from fastapi import APIRouter, FastAPI
APPS_DIR = Path(__file__).resolve().parent.parent / "apps"
@dataclass
class AppManifest:
name: str
version: str = "0.1.0"
depends_on: list[str] = field(default_factory=list)
conflicts_with: list[str] = field(default_factory=list)
required: bool = False
provides: list[str] = field(default_factory=list)
def discover_manifests() -> list[AppManifest]:
manifests: list[AppManifest] = []
if not APPS_DIR.exists():
return manifests
for entry in sorted(APPS_DIR.iterdir()):
mf_path = entry / "manifest.yaml"
if not mf_path.exists():
continue
data = yaml.safe_load(mf_path.read_text()) or {}
manifests.append(
AppManifest(
name=data["name"],
version=data.get("version", "0.1.0"),
depends_on=list(data.get("depends_on", [])),
conflicts_with=list(data.get("conflicts_with", [])),
required=bool(data.get("required", False)),
provides=list(data.get("provides", [])),
)
)
return manifests
def _topo_sort(manifests: list[AppManifest]) -> list[AppManifest]:
by_name = {m.name: m for m in manifests}
visited: set[str] = set()
temp: set[str] = set()
order: list[AppManifest] = []
def visit(name: str) -> None:
if name in visited:
return
if name in temp:
raise RuntimeError(f"Circular app dependency at {name}")
m = by_name.get(name)
if not m:
return # dependency on core or non-app (e.g. 'core')
temp.add(name)
for d in m.depends_on:
if d == "core":
continue
if d not in by_name:
raise RuntimeError(f"App {name} depends on missing app: {d}")
visit(d)
temp.discard(name)
visited.add(name)
order.append(m)
for m in manifests:
visit(m.name)
return order
def _check_conflicts(manifests: list[AppManifest]) -> None:
names = {m.name for m in manifests}
for m in manifests:
for c in m.conflicts_with:
if c in names:
raise RuntimeError(f"App conflict: {m.name} conflicts with {c}")
def load_apps(app: FastAPI) -> list[AppManifest]:
manifests = discover_manifests()
_check_conflicts(manifests)
ordered = _topo_sort(manifests)
loaded: list[AppManifest] = []
for m in ordered:
module_name = f"apps.{m.name}"
mod = importlib.import_module(module_name)
# Optional lifecycle hook
if hasattr(mod, "on_load") and callable(mod.on_load):
mod.on_load()
if hasattr(mod, "router") and isinstance(mod.router, APIRouter):
app.include_router(mod.router, prefix=f"/api/{m.name}", tags=[m.name])
loaded.append(m)
print(f"[app-loader] loaded {m.name} v{m.version}")
return loaded

89
backend/core/main.py Normal file
View File

@@ -0,0 +1,89 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import Session
from core.config import settings
from core.db import get_db
from core.loader import load_apps
from core.redis_client import redis_client
from core.security import require_admin
from core.settings_service import get_setting_cached, set_setting
@asynccontextmanager
async def lifespan(app: FastAPI):
loaded = load_apps(app)
app.state.loaded_apps = loaded
yield
app = FastAPI(title="Shopsystem API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static uploads
upload_dir = Path(settings.UPLOAD_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(upload_dir)), name="uploads")
@app.get("/health")
def health(db: Session = Depends(get_db)):
db_ok = False
redis_ok = False
try:
db.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
try:
redis_ok = bool(redis_client.ping())
except Exception:
pass
return {
"ok": db_ok and redis_ok,
"env": settings.APP_ENV,
"db": db_ok,
"redis": redis_ok,
"apps": [m.name for m in getattr(app.state, "loaded_apps", [])],
}
# Core router — settings management (admin)
core_router = APIRouter()
@core_router.get("/settings/{key}")
def read_setting(key: str):
"""Public read — Redis-backed."""
v = get_setting_cached(key)
if v is None:
raise HTTPException(404, f"Setting {key} not found")
return {"key": key, "value": v}
@core_router.put("/settings/{key}")
def write_setting(
key: str,
body: dict = Body(...),
_: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
if "value" not in body:
raise HTTPException(400, "Missing 'value' in body")
set_setting(db, key, body["value"])
return {"key": key, "value": body["value"]}
app.include_router(core_router, prefix="/api/core", tags=["core"])

View File

@@ -0,0 +1,63 @@
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
from core.config import settings
from core.db import Base
# Import all model modules so Base.metadata contains everything ----
# Core
from core import events # noqa: F401
from core import settings_service # noqa: F401
# Apps — import each app's models if present
import importlib
APPS_DIR = Path(__file__).resolve().parent.parent.parent / "apps"
if APPS_DIR.exists():
for entry in sorted(APPS_DIR.iterdir()):
models_py = entry / "models.py"
if models_py.exists():
importlib.import_module(f"apps.{entry.name}.models")
# -------------------------------------------------------------------
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,47 @@
"""core init: events + settings + pgvector
Revision ID: 0001_core
Revises:
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001_core"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# pgvector extension (used by ai_core later, set up early for cleanliness)
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("type", sa.String(128), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_events_type", "events", ["type"])
op.create_table(
"settings",
sa.Column("key", sa.String(128), primary_key=True),
sa.Column("value", sa.JSON(), nullable=False),
)
def downgrade() -> None:
op.drop_table("settings")
op.drop_index("ix_events_type", table_name="events")
op.drop_table("events")

View File

@@ -0,0 +1,40 @@
"""auth: users
Revision ID: 0002_auth
Revises: 0001_core
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_auth"
down_revision: str | None = "0001_core"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role", sa.String(32), nullable=False, server_default="customer"),
sa.Column("name", sa.String(128), nullable=False, server_default=""),
sa.Column("locale", sa.String(8), nullable=False, server_default="de"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")

View File

@@ -0,0 +1,59 @@
"""catalog: categories + products
Revision ID: 0003_catalog
Revises: 0002_auth
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003_catalog"
down_revision: str | None = "0002_auth"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"categories",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("slug", sa.String(128), nullable=False, unique=True),
sa.Column("name", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.ForeignKeyConstraint(["parent_id"], ["categories.id"], ondelete="SET NULL"),
)
op.create_index("ix_categories_slug", "categories", ["slug"], unique=True)
op.create_table(
"products",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("sku", sa.String(64), nullable=False, unique=True),
sa.Column("name", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("description", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("price", sa.Numeric(10, 2), nullable=False),
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
sa.Column("stock", sa.Integer(), nullable=False, server_default="0"),
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("image_url", sa.String(500), nullable=False, server_default=""),
sa.Column("category_id", sa.Integer(), nullable=True),
sa.Column("attributes", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["category_id"], ["categories.id"], ondelete="SET NULL"),
)
op.create_index("ix_products_sku", "products", ["sku"], unique=True)
def downgrade() -> None:
op.drop_index("ix_products_sku", table_name="products")
op.drop_table("products")
op.drop_index("ix_categories_slug", table_name="categories")
op.drop_table("categories")

View File

@@ -0,0 +1,46 @@
"""cart: carts + cart_items
Revision ID: 0004_cart
Revises: 0003_catalog
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004_cart"
down_revision: str | None = "0003_catalog"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"carts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=False, unique=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_table(
"cart_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("cart_id", sa.Integer(), nullable=False),
sa.Column("product_id", sa.Integer(), nullable=False),
sa.Column("qty", sa.Integer(), nullable=False, server_default="1"),
sa.ForeignKeyConstraint(["cart_id"], ["carts.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
sa.UniqueConstraint("cart_id", "product_id", name="uq_cart_product"),
)
def downgrade() -> None:
op.drop_table("cart_items")
op.drop_table("carts")

View File

@@ -0,0 +1,56 @@
"""orders: orders + order_status_history
Revision ID: 0005_orders
Revises: 0004_cart
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005_orders"
down_revision: str | None = "0004_cart"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"orders",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="paid"),
sa.Column("total", sa.Numeric(10, 2), nullable=False),
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
sa.Column("address", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("payment", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("items", sa.JSON(), nullable=False, server_default="[]"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
)
op.create_table(
"order_status_history",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("note", sa.String(500), nullable=False, server_default=""),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
)
def downgrade() -> None:
op.drop_table("order_status_history")
op.drop_table("orders")

View File

@@ -0,0 +1,62 @@
"""ai_core: ai_documents + ai_audit
Revision ID: 0006_ai_core
Revises: 0005_orders
Create Date: 2026-04-16
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
from core.config import settings
revision: str = "0006_ai_core"
down_revision: str | None = "0005_orders"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"ai_documents",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("source_type", sa.String(64), nullable=False),
sa.Column("source_id", sa.String(64), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("embedding", Vector(settings.OLLAMA_EMBED_DIM), nullable=False),
sa.Column("meta", sa.JSON(), nullable=False, server_default="{}"),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_ai_documents_source_type", "ai_documents", ["source_type"])
op.create_index("ix_ai_documents_source_id", "ai_documents", ["source_id"])
op.create_table(
"ai_audit",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("tool", sa.String(128), nullable=False),
sa.Column("args", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("result", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("ok", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("ai_audit")
op.drop_index("ix_ai_documents_source_id", table_name="ai_documents")
op.drop_index("ix_ai_documents_source_type", table_name="ai_documents")
op.drop_table("ai_documents")

View File

@@ -0,0 +1,5 @@
import redis
from core.config import settings
redis_client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)

93
backend/core/security.py Normal file
View File

@@ -0,0 +1,93 @@
from datetime import UTC, datetime, timedelta
from typing import Any
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from core.config import settings
from core.db import get_db
_ph = PasswordHasher()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def hash_password(pw: str) -> str:
return _ph.hash(pw)
def verify_password(pw: str, hashed: str) -> bool:
try:
return _ph.verify(hashed, pw)
except VerifyMismatchError:
return False
def _make_token(sub: str, role: str, delta: timedelta, token_type: str) -> str:
exp = datetime.now(UTC) + delta
payload = {"sub": sub, "role": role, "type": token_type, "exp": exp}
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def make_access_token(user_id: int, role: str) -> str:
return _make_token(str(user_id), role, timedelta(minutes=settings.JWT_ACCESS_MINUTES), "access")
def make_refresh_token(user_id: int, role: str) -> str:
return _make_token(str(user_id), role, timedelta(days=settings.JWT_REFRESH_DAYS), "refresh")
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
except JWTError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") from e
def current_user_claims(token: str | None = Depends(oauth2_scheme)) -> dict[str, Any]:
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token")
claims = decode_token(token)
if claims.get("type") != "access":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong token type")
return claims
def require_admin(claims: dict = Depends(current_user_claims)) -> dict:
if claims.get("role") != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin role required")
return claims
def optional_user(token: str | None = Depends(oauth2_scheme)) -> dict | None:
if not token:
return None
try:
claims = decode_token(token)
return claims if claims.get("type") == "access" else None
except HTTPException:
return None
def get_current_user_id(claims: dict = Depends(current_user_claims)) -> int:
return int(claims["sub"])
# Re-export for DI-free apps
__all__ = [
"hash_password",
"verify_password",
"make_access_token",
"make_refresh_token",
"decode_token",
"current_user_claims",
"require_admin",
"optional_user",
"get_current_user_id",
"oauth2_scheme",
"get_db",
"Session",
]

265
backend/core/seed.py Normal file
View File

@@ -0,0 +1,265 @@
"""Seed DB with admin + demo customer + categories + 12 clothing products."""
from __future__ import annotations
from core.db import SessionLocal
from core.events import event_bus
from core.security import hash_password
from core.settings_service import set_setting
from apps.auth.models import User
from apps.catalog.models import Category, Product
from apps.catalog.projector import rebuild_all
CATEGORIES = [
{"slug": "oberteile", "name": {"de": "Oberteile", "en": "Tops"}, "sort_order": 1},
{"slug": "hosen", "name": {"de": "Hosen", "en": "Pants"}, "sort_order": 2},
{"slug": "schuhe", "name": {"de": "Schuhe", "en": "Shoes"}, "sort_order": 3},
{"slug": "accessoires", "name": {"de": "Accessoires", "en": "Accessories"}, "sort_order": 4},
]
def _placeholder(color_hex: str, label: str) -> str:
# Simple SVG data URL — zero external dependencies
from urllib.parse import quote
svg = (
f"<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'>"
f"<rect width='400' height='400' fill='#{color_hex}'/>"
f"<text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' "
f"font-family='Arial' font-size='26' fill='#ffffff'>{label}</text>"
f"</svg>"
)
return "data:image/svg+xml;utf8," + quote(svg)
PRODUCTS = [
{
"sku": "PULLI-GRUEN-M",
"name": {"de": "Grüner Kuschelpulli", "en": "Green Cozy Sweater"},
"description": {
"de": "Weicher grüner Pullover aus Bio-Baumwolle, ideal für kühle Tage.",
"en": "Soft green sweater made of organic cotton, perfect for chilly days.",
},
"price": 49.90,
"stock": 20,
"cat": "oberteile",
"color": "green",
"img": ("2e7d32", "Grüner Pulli"),
},
{
"sku": "PULLI-BLAU-L",
"name": {"de": "Blauer Rundhalspullover", "en": "Blue Crewneck Sweater"},
"description": {
"de": "Klassischer blauer Pullover mit Rundhalsausschnitt.",
"en": "Classic blue crewneck sweater.",
},
"price": 42.00,
"stock": 15,
"cat": "oberteile",
"color": "blue",
"img": ("1565c0", "Blauer Pulli"),
},
{
"sku": "TSHIRT-WHITE-M",
"name": {"de": "Weißes Basic T-Shirt", "en": "White Basic T-Shirt"},
"description": {
"de": "Leichtes Basic-Shirt aus 100% Bio-Baumwolle.",
"en": "Light basic shirt made of 100% organic cotton.",
},
"price": 14.90,
"stock": 80,
"cat": "oberteile",
"color": "white",
"img": ("f5f5f5", "Weißes Shirt"),
},
{
"sku": "TSHIRT-BLACK-L",
"name": {"de": "Schwarzes T-Shirt", "en": "Black T-Shirt"},
"description": {
"de": "Klassisches schwarzes Shirt, regular fit.",
"en": "Classic black shirt, regular fit.",
},
"price": 15.90,
"stock": 80,
"cat": "oberteile",
"color": "black",
"img": ("212121", "Schwarzes Shirt"),
},
{
"sku": "JACKE-OUTDOOR-M",
"name": {"de": "Warme Outdoor-Jacke", "en": "Warm Outdoor Jacket"},
"description": {
"de": "Wasserabweisende, wärmende Jacke zum Wandern und Radeln im Herbst.",
"en": "Water-repellent, warm jacket for hiking and cycling in autumn.",
},
"price": 129.00,
"stock": 10,
"cat": "oberteile",
"color": "olive",
"img": ("556b2f", "Outdoor-Jacke"),
},
{
"sku": "JEANS-BLAU-32",
"name": {"de": "Blaue Jeans Straight", "en": "Blue Straight Jeans"},
"description": {
"de": "Gerade geschnittene klassische Jeans in Dunkelblau.",
"en": "Straight-cut classic jeans in dark blue.",
},
"price": 69.00,
"stock": 40,
"cat": "hosen",
"color": "blue",
"img": ("0d47a1", "Blaue Jeans"),
},
{
"sku": "JEANS-SCHWARZ-34",
"name": {"de": "Schwarze Slim Jeans", "en": "Black Slim Jeans"},
"description": {
"de": "Slim-Fit-Jeans in tiefem Schwarz, leicht elastisch.",
"en": "Slim-fit jeans in deep black, slightly stretchy.",
},
"price": 74.00,
"stock": 25,
"cat": "hosen",
"color": "black",
"img": ("1b1b1b", "Schwarze Jeans"),
},
{
"sku": "HOSE-WANDER-L",
"name": {"de": "Wanderhose robust", "en": "Robust Hiking Pants"},
"description": {
"de": "Robuste Wanderhose für alle Jahreszeiten, wasserabweisend.",
"en": "Robust hiking pants for all seasons, water-repellent.",
},
"price": 89.00,
"stock": 18,
"cat": "hosen",
"color": "khaki",
"img": ("6b8e23", "Wanderhose"),
},
{
"sku": "SNEAKER-WEISS-42",
"name": {"de": "Weiße Sneaker", "en": "White Sneakers"},
"description": {
"de": "Zeitlose weiße Sneaker für den Alltag.",
"en": "Timeless white sneakers for everyday use.",
},
"price": 79.00,
"stock": 30,
"cat": "schuhe",
"color": "white",
"img": ("eeeeee", "Weiße Sneaker"),
},
{
"sku": "WANDERSCHUH-43",
"name": {"de": "Wanderschuhe warm", "en": "Warm Hiking Boots"},
"description": {
"de": "Warme Wanderschuhe mit guter Dämpfung und rutschfester Sohle.",
"en": "Warm hiking boots with great cushioning and slip-resistant sole.",
},
"price": 149.00,
"stock": 12,
"cat": "schuhe",
"color": "brown",
"img": ("6d4c41", "Wanderschuhe"),
},
{
"sku": "MUETZE-WOLLE",
"name": {"de": "Wollmütze grün", "en": "Green Wool Beanie"},
"description": {
"de": "Warme grüne Wollmütze für kalte Tage.",
"en": "Warm green wool beanie for cold days.",
},
"price": 19.90,
"stock": 50,
"cat": "accessoires",
"color": "green",
"img": ("388e3c", "Mütze"),
},
{
"sku": "SCHAL-GRAU",
"name": {"de": "Grauer Schal", "en": "Grey Scarf"},
"description": {
"de": "Weicher grauer Schal aus Merinowolle.",
"en": "Soft grey scarf made of merino wool.",
},
"price": 29.00,
"stock": 35,
"cat": "accessoires",
"color": "grey",
"img": ("757575", "Grauer Schal"),
},
]
def seed() -> None:
db = SessionLocal()
try:
# Shop name setting
if not db.query(User).filter_by(email="admin@example.com").first():
set_setting(db, "core.shop_name", "Demo Shop")
# Users
if not db.query(User).filter_by(email="admin@example.com").first():
admin = User(
email="admin@example.com",
password_hash=hash_password("admin123"),
role="admin",
name="Admin",
locale="de",
)
db.add(admin)
if not db.query(User).filter_by(email="kunde@example.com").first():
customer = User(
email="kunde@example.com",
password_hash=hash_password("kunde123"),
role="customer",
name="Demo Kunde",
locale="de",
)
db.add(customer)
db.commit()
# Categories
cats_by_slug: dict[str, Category] = {}
for c in CATEGORIES:
row = db.query(Category).filter_by(slug=c["slug"]).first()
if not row:
row = Category(slug=c["slug"], name=c["name"], sort_order=c["sort_order"])
db.add(row)
db.commit()
db.refresh(row)
event_bus.publish("category.created", {"id": row.id}, db=db)
cats_by_slug[c["slug"]] = row
# Products
for pd in PRODUCTS:
if db.query(Product).filter_by(sku=pd["sku"]).first():
continue
image = _placeholder(*pd["img"])
p = Product(
sku=pd["sku"],
name=pd["name"],
description=pd["description"],
price=pd["price"],
currency="EUR",
stock=pd["stock"],
active=True,
image_url=image,
category_id=cats_by_slug[pd["cat"]].id,
attributes={"color": pd["color"]},
)
db.add(p)
db.commit()
db.refresh(p)
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
# Rebuild Redis cache (idempotent)
rebuild_all(db)
print("Seed complete.")
finally:
db.close()
if __name__ == "__main__":
seed()

View File

@@ -0,0 +1,43 @@
"""Key-Value settings store with Redis projection."""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import JSON, String
from sqlalchemy.orm import Mapped, Session, mapped_column
from core.db import Base
from core.events import event_bus
from core.redis_client import redis_client
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[dict] = mapped_column(JSON)
def get_setting(db: Session, key: str, default: Any = None) -> Any:
row = db.get(Setting, key)
return row.value.get("v") if row else default
def set_setting(db: Session, key: str, value: Any) -> None:
row = db.get(Setting, key)
if row:
row.value = {"v": value}
else:
row = Setting(key=key, value={"v": value})
db.add(row)
db.commit()
redis_client.set(f"setting:{key}", json.dumps(value))
event_bus.publish("core.settings_updated", {"key": key, "value": value}, db=db)
def get_setting_cached(key: str, default: Any = None) -> Any:
raw = redis_client.get(f"setting:{key}")
if raw is None:
return default
return json.loads(raw)

32
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[project]
name = "shop-backend"
version = "0.1.0"
description = "Shopsystem Core + Apps"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy>=2.0",
"alembic>=1.13",
"psycopg[binary]>=3.2",
"redis>=5.0",
"pydantic>=2.9",
"pydantic-settings>=2.5",
"python-jose[cryptography]>=3.3",
"argon2-cffi>=23.1",
"httpx>=0.27",
"pgvector>=0.3",
"pyyaml>=6.0",
"jinja2>=3.1",
"aiosmtplib>=3.0",
"python-multipart>=0.0.9",
"punq>=0.7",
"email-validator>=2.2",
]
[tool.uv]
package = false
[tool.ruff]
line-length = 100
target-version = "py311"

1343
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff