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

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)