wahnsinn vibe
This commit is contained in:
0
backend/apps/__init__.py
Normal file
0
backend/apps/__init__.py
Normal file
72
backend/apps/ai_admin/__init__.py
Normal file
72
backend/apps/ai_admin/__init__.py
Normal 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()
|
||||
6
backend/apps/ai_admin/manifest.yaml
Normal file
6
backend/apps/ai_admin/manifest.yaml
Normal 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: []
|
||||
113
backend/apps/ai_admin/planner.py
Normal file
113
backend/apps/ai_admin/planner.py
Normal 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
|
||||
227
backend/apps/ai_admin/tool_defs.py
Normal file
227
backend/apps/ai_admin/tool_defs.py
Normal 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)
|
||||
69
backend/apps/ai_core/__init__.py
Normal file
69
backend/apps/ai_core/__init__.py
Normal 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)
|
||||
158
backend/apps/ai_core/indexer.py
Normal file
158
backend/apps/ai_core/indexer.py
Normal 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)
|
||||
6
backend/apps/ai_core/manifest.yaml
Normal file
6
backend/apps/ai_core/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: ai_core
|
||||
version: 0.1.0
|
||||
depends_on: [core, catalog]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [LLMProvider, ToolRegistry]
|
||||
34
backend/apps/ai_core/models.py
Normal file
34
backend/apps/ai_core/models.py
Normal 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())
|
||||
69
backend/apps/ai_core/ollama_client.py
Normal file
69
backend/apps/ai_core/ollama_client.py
Normal 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
|
||||
6
backend/apps/ai_core/reindex.py
Normal file
6
backend/apps/ai_core/reindex.py
Normal 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}")
|
||||
56
backend/apps/ai_core/tools.py
Normal file
56
backend/apps/ai_core/tools.py
Normal 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, "")]
|
||||
111
backend/apps/ai_shop/__init__.py
Normal file
111
backend/apps/ai_shop/__init__.py
Normal 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]}
|
||||
6
backend/apps/ai_shop/manifest.yaml
Normal file
6
backend/apps/ai_shop/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: ai_shop
|
||||
version: 0.1.0
|
||||
depends_on: [core, catalog, ai_core]
|
||||
conflicts_with: []
|
||||
required: false
|
||||
provides: []
|
||||
155
backend/apps/auth/__init__.py
Normal file
155
backend/apps/auth/__init__.py
Normal 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}
|
||||
6
backend/apps/auth/manifest.yaml
Normal file
6
backend/apps/auth/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: auth
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [UserService]
|
||||
18
backend/apps/auth/models.py
Normal file
18
backend/apps/auth/models.py
Normal 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())
|
||||
140
backend/apps/cart/__init__.py
Normal file
140
backend/apps/cart/__init__.py
Normal 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)
|
||||
6
backend/apps/cart/manifest.yaml
Normal file
6
backend/apps/cart/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: cart
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [CartService]
|
||||
30
backend/apps/cart/models.py
Normal file
30
backend/apps/cart/models.py
Normal 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")
|
||||
242
backend/apps/catalog/__init__.py
Normal file
242
backend/apps/catalog/__init__.py
Normal 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,
|
||||
)
|
||||
6
backend/apps/catalog/manifest.yaml
Normal file
6
backend/apps/catalog/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: catalog
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [ProductService, CategoryService]
|
||||
39
backend/apps/catalog/models.py
Normal file
39
backend/apps/catalog/models.py
Normal 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")
|
||||
87
backend/apps/catalog/projector.py
Normal file
87
backend/apps/catalog/projector.py
Normal 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)
|
||||
106
backend/apps/checkout/__init__.py
Normal file
106
backend/apps/checkout/__init__.py
Normal 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}
|
||||
6
backend/apps/checkout/manifest.yaml
Normal file
6
backend/apps/checkout/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: checkout
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog, cart, payment]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: []
|
||||
8
backend/apps/hello/__init__.py
Normal file
8
backend/apps/hello/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def ping():
|
||||
return {"msg": "hello from dummy app"}
|
||||
6
backend/apps/hello/manifest.yaml
Normal file
6
backend/apps/hello/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: hello
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: false
|
||||
provides: []
|
||||
51
backend/apps/mail/__init__.py
Normal file
51
backend/apps/mail/__init__.py
Normal 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())
|
||||
6
backend/apps/mail/manifest.yaml
Normal file
6
backend/apps/mail/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: mail
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [MailService]
|
||||
175
backend/apps/orders/__init__.py
Normal file
175
backend/apps/orders/__init__.py
Normal 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)
|
||||
6
backend/apps/orders/manifest.yaml
Normal file
6
backend/apps/orders/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: orders
|
||||
version: 0.1.0
|
||||
depends_on: [core, auth, catalog, mail]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [OrderService]
|
||||
34
backend/apps/orders/models.py
Normal file
34
backend/apps/orders/models.py
Normal 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())
|
||||
34
backend/apps/payment/__init__.py
Normal file
34
backend/apps/payment/__init__.py
Normal 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"},
|
||||
]
|
||||
6
backend/apps/payment/manifest.yaml
Normal file
6
backend/apps/payment/manifest.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: payment
|
||||
version: 0.1.0
|
||||
depends_on: [core]
|
||||
conflicts_with: []
|
||||
required: true
|
||||
provides: [PaymentProvider]
|
||||
Reference in New Issue
Block a user