wahnsinn vibe
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user