wahnsinn vibe
This commit is contained in:
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
37
backend/core/config.py
Normal file
37
backend/core/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
APP_ENV: str = "dev"
|
||||
DATABASE_URL: str = "postgresql+psycopg://shop:shop@localhost:5432/shop"
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
MEILI_URL: str = "http://localhost:7700"
|
||||
MEILI_KEY: str = "shop-dev-master-key"
|
||||
|
||||
OLLAMA_URL: str = "http://localhost:11434"
|
||||
OLLAMA_CHAT_MODEL: str = "llama3.1"
|
||||
OLLAMA_EMBED_MODEL: str = "nomic-embed-text"
|
||||
OLLAMA_EMBED_DIM: int = 768
|
||||
|
||||
SMTP_HOST: str = "localhost"
|
||||
SMTP_PORT: int = 1025
|
||||
MAIL_FROM: str = "shop@example.com"
|
||||
|
||||
JWT_SECRET: str = "change-me"
|
||||
JWT_ACCESS_MINUTES: int = 15
|
||||
JWT_REFRESH_DAYS: int = 30
|
||||
|
||||
UPLOAD_DIR: str = "./uploads"
|
||||
PUBLIC_BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:5174"
|
||||
|
||||
@property
|
||||
def cors_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
21
backend/core/db.py
Normal file
21
backend/core/db.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
18
backend/core/di.py
Normal file
18
backend/core/di.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Minimal service registry. Apps call `register_service(name, instance)` at startup."""
|
||||
from typing import Any
|
||||
|
||||
_services: dict[str, Any] = {}
|
||||
|
||||
|
||||
def register_service(name: str, instance: Any) -> None:
|
||||
_services[name] = instance
|
||||
|
||||
|
||||
def get_service(name: str) -> Any:
|
||||
if name not in _services:
|
||||
raise KeyError(f"Service not registered: {name}")
|
||||
return _services[name]
|
||||
|
||||
|
||||
def has_service(name: str) -> bool:
|
||||
return name in _services
|
||||
62
backend/core/events.py
Normal file
62
backend/core/events.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from core.db import Base, SessionLocal
|
||||
|
||||
EventHandler = Callable[[str, dict[str, Any], Session], None]
|
||||
|
||||
|
||||
class EventLog(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
||||
self._wildcards: list[tuple[str, EventHandler]] = []
|
||||
|
||||
def subscribe(self, event_type: str, handler: EventHandler) -> None:
|
||||
"""Subscribe to an event type. Supports 'namespace.*' wildcards."""
|
||||
if event_type.endswith(".*"):
|
||||
self._wildcards.append((event_type[:-2], handler))
|
||||
else:
|
||||
self._handlers[event_type].append(handler)
|
||||
|
||||
def publish(self, event_type: str, payload: dict[str, Any], db: Session | None = None) -> None:
|
||||
"""Publish event. Persists to events table and calls all handlers synchronously."""
|
||||
own_db = db is None
|
||||
if own_db:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(EventLog(type=event_type, payload=payload))
|
||||
db.commit()
|
||||
|
||||
for h in self._handlers.get(event_type, []):
|
||||
try:
|
||||
h(event_type, payload, db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[event-handler-error] {event_type}: {e}")
|
||||
for prefix, h in self._wildcards:
|
||||
if event_type.startswith(prefix + ".") or event_type == prefix:
|
||||
try:
|
||||
h(event_type, payload, db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[event-handler-error] {event_type}: {e}")
|
||||
finally:
|
||||
if own_db:
|
||||
db.close()
|
||||
|
||||
|
||||
event_bus = EventBus()
|
||||
16
backend/core/i18n.py
Normal file
16
backend/core/i18n.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Tiny i18n helper for DE/EN content stored as {'de': ..., 'en': ...} dicts."""
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_LOCALE = "de"
|
||||
SUPPORTED = ("de", "en")
|
||||
|
||||
|
||||
def pick(field: dict[str, Any] | None, locale: str = DEFAULT_LOCALE) -> str:
|
||||
if not field:
|
||||
return ""
|
||||
return field.get(locale) or field.get(DEFAULT_LOCALE) or next(iter(field.values()), "")
|
||||
|
||||
|
||||
def normalize(field: dict[str, Any] | None) -> dict[str, str]:
|
||||
field = field or {}
|
||||
return {lang: str(field.get(lang, "")) for lang in SUPPORTED}
|
||||
99
backend/core/loader.py
Normal file
99
backend/core/loader.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""App-Loader: discovers apps/<name>/manifest.yaml, resolves deps, imports and registers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
APPS_DIR = Path(__file__).resolve().parent.parent / "apps"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppManifest:
|
||||
name: str
|
||||
version: str = "0.1.0"
|
||||
depends_on: list[str] = field(default_factory=list)
|
||||
conflicts_with: list[str] = field(default_factory=list)
|
||||
required: bool = False
|
||||
provides: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def discover_manifests() -> list[AppManifest]:
|
||||
manifests: list[AppManifest] = []
|
||||
if not APPS_DIR.exists():
|
||||
return manifests
|
||||
for entry in sorted(APPS_DIR.iterdir()):
|
||||
mf_path = entry / "manifest.yaml"
|
||||
if not mf_path.exists():
|
||||
continue
|
||||
data = yaml.safe_load(mf_path.read_text()) or {}
|
||||
manifests.append(
|
||||
AppManifest(
|
||||
name=data["name"],
|
||||
version=data.get("version", "0.1.0"),
|
||||
depends_on=list(data.get("depends_on", [])),
|
||||
conflicts_with=list(data.get("conflicts_with", [])),
|
||||
required=bool(data.get("required", False)),
|
||||
provides=list(data.get("provides", [])),
|
||||
)
|
||||
)
|
||||
return manifests
|
||||
|
||||
|
||||
def _topo_sort(manifests: list[AppManifest]) -> list[AppManifest]:
|
||||
by_name = {m.name: m for m in manifests}
|
||||
visited: set[str] = set()
|
||||
temp: set[str] = set()
|
||||
order: list[AppManifest] = []
|
||||
|
||||
def visit(name: str) -> None:
|
||||
if name in visited:
|
||||
return
|
||||
if name in temp:
|
||||
raise RuntimeError(f"Circular app dependency at {name}")
|
||||
m = by_name.get(name)
|
||||
if not m:
|
||||
return # dependency on core or non-app (e.g. 'core')
|
||||
temp.add(name)
|
||||
for d in m.depends_on:
|
||||
if d == "core":
|
||||
continue
|
||||
if d not in by_name:
|
||||
raise RuntimeError(f"App {name} depends on missing app: {d}")
|
||||
visit(d)
|
||||
temp.discard(name)
|
||||
visited.add(name)
|
||||
order.append(m)
|
||||
|
||||
for m in manifests:
|
||||
visit(m.name)
|
||||
return order
|
||||
|
||||
|
||||
def _check_conflicts(manifests: list[AppManifest]) -> None:
|
||||
names = {m.name for m in manifests}
|
||||
for m in manifests:
|
||||
for c in m.conflicts_with:
|
||||
if c in names:
|
||||
raise RuntimeError(f"App conflict: {m.name} conflicts with {c}")
|
||||
|
||||
|
||||
def load_apps(app: FastAPI) -> list[AppManifest]:
|
||||
manifests = discover_manifests()
|
||||
_check_conflicts(manifests)
|
||||
ordered = _topo_sort(manifests)
|
||||
loaded: list[AppManifest] = []
|
||||
for m in ordered:
|
||||
module_name = f"apps.{m.name}"
|
||||
mod = importlib.import_module(module_name)
|
||||
# Optional lifecycle hook
|
||||
if hasattr(mod, "on_load") and callable(mod.on_load):
|
||||
mod.on_load()
|
||||
if hasattr(mod, "router") and isinstance(mod.router, APIRouter):
|
||||
app.include_router(mod.router, prefix=f"/api/{m.name}", tags=[m.name])
|
||||
loaded.append(m)
|
||||
print(f"[app-loader] loaded {m.name} v{m.version}")
|
||||
return loaded
|
||||
89
backend/core/main.py
Normal file
89
backend/core/main.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.db import get_db
|
||||
from core.loader import load_apps
|
||||
from core.redis_client import redis_client
|
||||
from core.security import require_admin
|
||||
from core.settings_service import get_setting_cached, set_setting
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
loaded = load_apps(app)
|
||||
app.state.loaded_apps = loaded
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Shopsystem API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Static uploads
|
||||
upload_dir = Path(settings.UPLOAD_DIR)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(upload_dir)), name="uploads")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health(db: Session = Depends(get_db)):
|
||||
db_ok = False
|
||||
redis_ok = False
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
redis_ok = bool(redis_client.ping())
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": db_ok and redis_ok,
|
||||
"env": settings.APP_ENV,
|
||||
"db": db_ok,
|
||||
"redis": redis_ok,
|
||||
"apps": [m.name for m in getattr(app.state, "loaded_apps", [])],
|
||||
}
|
||||
|
||||
|
||||
# Core router — settings management (admin)
|
||||
core_router = APIRouter()
|
||||
|
||||
|
||||
@core_router.get("/settings/{key}")
|
||||
def read_setting(key: str):
|
||||
"""Public read — Redis-backed."""
|
||||
v = get_setting_cached(key)
|
||||
if v is None:
|
||||
raise HTTPException(404, f"Setting {key} not found")
|
||||
return {"key": key, "value": v}
|
||||
|
||||
|
||||
@core_router.put("/settings/{key}")
|
||||
def write_setting(
|
||||
key: str,
|
||||
body: dict = Body(...),
|
||||
_: dict = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if "value" not in body:
|
||||
raise HTTPException(400, "Missing 'value' in body")
|
||||
set_setting(db, key, body["value"])
|
||||
return {"key": key, "value": body["value"]}
|
||||
|
||||
|
||||
app.include_router(core_router, prefix="/api/core", tags=["core"])
|
||||
63
backend/core/migrations/env.py
Normal file
63
backend/core/migrations/env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from core.config import settings
|
||||
from core.db import Base
|
||||
|
||||
# Import all model modules so Base.metadata contains everything ----
|
||||
# Core
|
||||
from core import events # noqa: F401
|
||||
from core import settings_service # noqa: F401
|
||||
|
||||
# Apps — import each app's models if present
|
||||
import importlib
|
||||
|
||||
APPS_DIR = Path(__file__).resolve().parent.parent.parent / "apps"
|
||||
if APPS_DIR.exists():
|
||||
for entry in sorted(APPS_DIR.iterdir()):
|
||||
models_py = entry / "models.py"
|
||||
if models_py.exists():
|
||||
importlib.import_module(f"apps.{entry.name}.models")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/core/migrations/script.py.mako
Normal file
26
backend/core/migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
47
backend/core/migrations/versions/0001_core_init.py
Normal file
47
backend/core/migrations/versions/0001_core_init.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""core init: events + settings + pgvector
|
||||
|
||||
Revision ID: 0001_core
|
||||
Revises:
|
||||
Create Date: 2026-04-16
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001_core"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# pgvector extension (used by ai_core later, set up early for cleanliness)
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
|
||||
op.create_table(
|
||||
"events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("type", sa.String(128), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_events_type", "events", ["type"])
|
||||
|
||||
op.create_table(
|
||||
"settings",
|
||||
sa.Column("key", sa.String(128), primary_key=True),
|
||||
sa.Column("value", sa.JSON(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("settings")
|
||||
op.drop_index("ix_events_type", table_name="events")
|
||||
op.drop_table("events")
|
||||
40
backend/core/migrations/versions/0002_auth_users.py
Normal file
40
backend/core/migrations/versions/0002_auth_users.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""auth: users
|
||||
|
||||
Revision ID: 0002_auth
|
||||
Revises: 0001_core
|
||||
Create Date: 2026-04-16
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_auth"
|
||||
down_revision: str | None = "0001_core"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("role", sa.String(32), nullable=False, server_default="customer"),
|
||||
sa.Column("name", sa.String(128), nullable=False, server_default=""),
|
||||
sa.Column("locale", sa.String(8), nullable=False, server_default="de"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
op.drop_table("users")
|
||||
59
backend/core/migrations/versions/0003_catalog.py
Normal file
59
backend/core/migrations/versions/0003_catalog.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""catalog: categories + products
|
||||
|
||||
Revision ID: 0003_catalog
|
||||
Revises: 0002_auth
|
||||
Create Date: 2026-04-16
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003_catalog"
|
||||
down_revision: str | None = "0002_auth"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"categories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("slug", sa.String(128), nullable=False, unique=True),
|
||||
sa.Column("name", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.ForeignKeyConstraint(["parent_id"], ["categories.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_categories_slug", "categories", ["slug"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"products",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("sku", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("name", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("description", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("price", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
|
||||
sa.Column("stock", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("image_url", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||
sa.Column("attributes", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["category_id"], ["categories.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_products_sku", "products", ["sku"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_products_sku", table_name="products")
|
||||
op.drop_table("products")
|
||||
op.drop_index("ix_categories_slug", table_name="categories")
|
||||
op.drop_table("categories")
|
||||
46
backend/core/migrations/versions/0004_cart.py
Normal file
46
backend/core/migrations/versions/0004_cart.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""cart: carts + cart_items
|
||||
|
||||
Revision ID: 0004_cart
|
||||
Revises: 0003_catalog
|
||||
Create Date: 2026-04-16
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_cart"
|
||||
down_revision: str | None = "0003_catalog"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"carts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False, unique=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_table(
|
||||
"cart_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("cart_id", sa.Integer(), nullable=False),
|
||||
sa.Column("product_id", sa.Integer(), nullable=False),
|
||||
sa.Column("qty", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.ForeignKeyConstraint(["cart_id"], ["carts.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("cart_id", "product_id", name="uq_cart_product"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("cart_items")
|
||||
op.drop_table("carts")
|
||||
56
backend/core/migrations/versions/0005_orders.py
Normal file
56
backend/core/migrations/versions/0005_orders.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""orders: orders + order_status_history
|
||||
|
||||
Revision ID: 0005_orders
|
||||
Revises: 0004_cart
|
||||
Create Date: 2026-04-16
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_orders"
|
||||
down_revision: str | None = "0004_cart"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"orders",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="paid"),
|
||||
sa.Column("total", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"),
|
||||
sa.Column("address", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("payment", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("items", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_table(
|
||||
"order_status_history",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("order_id", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(32), nullable=False),
|
||||
sa.Column("note", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("order_status_history")
|
||||
op.drop_table("orders")
|
||||
62
backend/core/migrations/versions/0006_ai_core.py
Normal file
62
backend/core/migrations/versions/0006_ai_core.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""ai_core: ai_documents + ai_audit
|
||||
|
||||
Revision ID: 0006_ai_core
|
||||
Revises: 0005_orders
|
||||
Create Date: 2026-04-16
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
from core.config import settings
|
||||
|
||||
revision: str = "0006_ai_core"
|
||||
down_revision: str | None = "0005_orders"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_documents",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("source_type", sa.String(64), nullable=False),
|
||||
sa.Column("source_id", sa.String(64), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column("embedding", Vector(settings.OLLAMA_EMBED_DIM), nullable=False),
|
||||
sa.Column("meta", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_ai_documents_source_type", "ai_documents", ["source_type"])
|
||||
op.create_index("ix_ai_documents_source_id", "ai_documents", ["source_id"])
|
||||
|
||||
op.create_table(
|
||||
"ai_audit",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tool", sa.String(128), nullable=False),
|
||||
sa.Column("args", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("result", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("ok", sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("ai_audit")
|
||||
op.drop_index("ix_ai_documents_source_id", table_name="ai_documents")
|
||||
op.drop_index("ix_ai_documents_source_type", table_name="ai_documents")
|
||||
op.drop_table("ai_documents")
|
||||
5
backend/core/redis_client.py
Normal file
5
backend/core/redis_client.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import redis
|
||||
|
||||
from core.config import settings
|
||||
|
||||
redis_client = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
93
backend/core/security.py
Normal file
93
backend/core/security.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.db import get_db
|
||||
|
||||
_ph = PasswordHasher()
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def hash_password(pw: str) -> str:
|
||||
return _ph.hash(pw)
|
||||
|
||||
|
||||
def verify_password(pw: str, hashed: str) -> bool:
|
||||
try:
|
||||
return _ph.verify(hashed, pw)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def _make_token(sub: str, role: str, delta: timedelta, token_type: str) -> str:
|
||||
exp = datetime.now(UTC) + delta
|
||||
payload = {"sub": sub, "role": role, "type": token_type, "exp": exp}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def make_access_token(user_id: int, role: str) -> str:
|
||||
return _make_token(str(user_id), role, timedelta(minutes=settings.JWT_ACCESS_MINUTES), "access")
|
||||
|
||||
|
||||
def make_refresh_token(user_id: int, role: str) -> str:
|
||||
return _make_token(str(user_id), role, timedelta(days=settings.JWT_REFRESH_DAYS), "refresh")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
except JWTError as e:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") from e
|
||||
|
||||
|
||||
def current_user_claims(token: str | None = Depends(oauth2_scheme)) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token")
|
||||
claims = decode_token(token)
|
||||
if claims.get("type") != "access":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong token type")
|
||||
return claims
|
||||
|
||||
|
||||
def require_admin(claims: dict = Depends(current_user_claims)) -> dict:
|
||||
if claims.get("role") != "admin":
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin role required")
|
||||
return claims
|
||||
|
||||
|
||||
def optional_user(token: str | None = Depends(oauth2_scheme)) -> dict | None:
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
claims = decode_token(token)
|
||||
return claims if claims.get("type") == "access" else None
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user_id(claims: dict = Depends(current_user_claims)) -> int:
|
||||
return int(claims["sub"])
|
||||
|
||||
|
||||
# Re-export for DI-free apps
|
||||
__all__ = [
|
||||
"hash_password",
|
||||
"verify_password",
|
||||
"make_access_token",
|
||||
"make_refresh_token",
|
||||
"decode_token",
|
||||
"current_user_claims",
|
||||
"require_admin",
|
||||
"optional_user",
|
||||
"get_current_user_id",
|
||||
"oauth2_scheme",
|
||||
"get_db",
|
||||
"Session",
|
||||
]
|
||||
265
backend/core/seed.py
Normal file
265
backend/core/seed.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Seed DB with admin + demo customer + categories + 12 clothing products."""
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db import SessionLocal
|
||||
from core.events import event_bus
|
||||
from core.security import hash_password
|
||||
from core.settings_service import set_setting
|
||||
|
||||
from apps.auth.models import User
|
||||
from apps.catalog.models import Category, Product
|
||||
from apps.catalog.projector import rebuild_all
|
||||
|
||||
CATEGORIES = [
|
||||
{"slug": "oberteile", "name": {"de": "Oberteile", "en": "Tops"}, "sort_order": 1},
|
||||
{"slug": "hosen", "name": {"de": "Hosen", "en": "Pants"}, "sort_order": 2},
|
||||
{"slug": "schuhe", "name": {"de": "Schuhe", "en": "Shoes"}, "sort_order": 3},
|
||||
{"slug": "accessoires", "name": {"de": "Accessoires", "en": "Accessories"}, "sort_order": 4},
|
||||
]
|
||||
|
||||
|
||||
def _placeholder(color_hex: str, label: str) -> str:
|
||||
# Simple SVG data URL — zero external dependencies
|
||||
from urllib.parse import quote
|
||||
|
||||
svg = (
|
||||
f"<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'>"
|
||||
f"<rect width='400' height='400' fill='#{color_hex}'/>"
|
||||
f"<text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' "
|
||||
f"font-family='Arial' font-size='26' fill='#ffffff'>{label}</text>"
|
||||
f"</svg>"
|
||||
)
|
||||
return "data:image/svg+xml;utf8," + quote(svg)
|
||||
|
||||
|
||||
PRODUCTS = [
|
||||
{
|
||||
"sku": "PULLI-GRUEN-M",
|
||||
"name": {"de": "Grüner Kuschelpulli", "en": "Green Cozy Sweater"},
|
||||
"description": {
|
||||
"de": "Weicher grüner Pullover aus Bio-Baumwolle, ideal für kühle Tage.",
|
||||
"en": "Soft green sweater made of organic cotton, perfect for chilly days.",
|
||||
},
|
||||
"price": 49.90,
|
||||
"stock": 20,
|
||||
"cat": "oberteile",
|
||||
"color": "green",
|
||||
"img": ("2e7d32", "Grüner Pulli"),
|
||||
},
|
||||
{
|
||||
"sku": "PULLI-BLAU-L",
|
||||
"name": {"de": "Blauer Rundhalspullover", "en": "Blue Crewneck Sweater"},
|
||||
"description": {
|
||||
"de": "Klassischer blauer Pullover mit Rundhalsausschnitt.",
|
||||
"en": "Classic blue crewneck sweater.",
|
||||
},
|
||||
"price": 42.00,
|
||||
"stock": 15,
|
||||
"cat": "oberteile",
|
||||
"color": "blue",
|
||||
"img": ("1565c0", "Blauer Pulli"),
|
||||
},
|
||||
{
|
||||
"sku": "TSHIRT-WHITE-M",
|
||||
"name": {"de": "Weißes Basic T-Shirt", "en": "White Basic T-Shirt"},
|
||||
"description": {
|
||||
"de": "Leichtes Basic-Shirt aus 100% Bio-Baumwolle.",
|
||||
"en": "Light basic shirt made of 100% organic cotton.",
|
||||
},
|
||||
"price": 14.90,
|
||||
"stock": 80,
|
||||
"cat": "oberteile",
|
||||
"color": "white",
|
||||
"img": ("f5f5f5", "Weißes Shirt"),
|
||||
},
|
||||
{
|
||||
"sku": "TSHIRT-BLACK-L",
|
||||
"name": {"de": "Schwarzes T-Shirt", "en": "Black T-Shirt"},
|
||||
"description": {
|
||||
"de": "Klassisches schwarzes Shirt, regular fit.",
|
||||
"en": "Classic black shirt, regular fit.",
|
||||
},
|
||||
"price": 15.90,
|
||||
"stock": 80,
|
||||
"cat": "oberteile",
|
||||
"color": "black",
|
||||
"img": ("212121", "Schwarzes Shirt"),
|
||||
},
|
||||
{
|
||||
"sku": "JACKE-OUTDOOR-M",
|
||||
"name": {"de": "Warme Outdoor-Jacke", "en": "Warm Outdoor Jacket"},
|
||||
"description": {
|
||||
"de": "Wasserabweisende, wärmende Jacke zum Wandern und Radeln im Herbst.",
|
||||
"en": "Water-repellent, warm jacket for hiking and cycling in autumn.",
|
||||
},
|
||||
"price": 129.00,
|
||||
"stock": 10,
|
||||
"cat": "oberteile",
|
||||
"color": "olive",
|
||||
"img": ("556b2f", "Outdoor-Jacke"),
|
||||
},
|
||||
{
|
||||
"sku": "JEANS-BLAU-32",
|
||||
"name": {"de": "Blaue Jeans Straight", "en": "Blue Straight Jeans"},
|
||||
"description": {
|
||||
"de": "Gerade geschnittene klassische Jeans in Dunkelblau.",
|
||||
"en": "Straight-cut classic jeans in dark blue.",
|
||||
},
|
||||
"price": 69.00,
|
||||
"stock": 40,
|
||||
"cat": "hosen",
|
||||
"color": "blue",
|
||||
"img": ("0d47a1", "Blaue Jeans"),
|
||||
},
|
||||
{
|
||||
"sku": "JEANS-SCHWARZ-34",
|
||||
"name": {"de": "Schwarze Slim Jeans", "en": "Black Slim Jeans"},
|
||||
"description": {
|
||||
"de": "Slim-Fit-Jeans in tiefem Schwarz, leicht elastisch.",
|
||||
"en": "Slim-fit jeans in deep black, slightly stretchy.",
|
||||
},
|
||||
"price": 74.00,
|
||||
"stock": 25,
|
||||
"cat": "hosen",
|
||||
"color": "black",
|
||||
"img": ("1b1b1b", "Schwarze Jeans"),
|
||||
},
|
||||
{
|
||||
"sku": "HOSE-WANDER-L",
|
||||
"name": {"de": "Wanderhose robust", "en": "Robust Hiking Pants"},
|
||||
"description": {
|
||||
"de": "Robuste Wanderhose für alle Jahreszeiten, wasserabweisend.",
|
||||
"en": "Robust hiking pants for all seasons, water-repellent.",
|
||||
},
|
||||
"price": 89.00,
|
||||
"stock": 18,
|
||||
"cat": "hosen",
|
||||
"color": "khaki",
|
||||
"img": ("6b8e23", "Wanderhose"),
|
||||
},
|
||||
{
|
||||
"sku": "SNEAKER-WEISS-42",
|
||||
"name": {"de": "Weiße Sneaker", "en": "White Sneakers"},
|
||||
"description": {
|
||||
"de": "Zeitlose weiße Sneaker für den Alltag.",
|
||||
"en": "Timeless white sneakers for everyday use.",
|
||||
},
|
||||
"price": 79.00,
|
||||
"stock": 30,
|
||||
"cat": "schuhe",
|
||||
"color": "white",
|
||||
"img": ("eeeeee", "Weiße Sneaker"),
|
||||
},
|
||||
{
|
||||
"sku": "WANDERSCHUH-43",
|
||||
"name": {"de": "Wanderschuhe warm", "en": "Warm Hiking Boots"},
|
||||
"description": {
|
||||
"de": "Warme Wanderschuhe mit guter Dämpfung und rutschfester Sohle.",
|
||||
"en": "Warm hiking boots with great cushioning and slip-resistant sole.",
|
||||
},
|
||||
"price": 149.00,
|
||||
"stock": 12,
|
||||
"cat": "schuhe",
|
||||
"color": "brown",
|
||||
"img": ("6d4c41", "Wanderschuhe"),
|
||||
},
|
||||
{
|
||||
"sku": "MUETZE-WOLLE",
|
||||
"name": {"de": "Wollmütze grün", "en": "Green Wool Beanie"},
|
||||
"description": {
|
||||
"de": "Warme grüne Wollmütze für kalte Tage.",
|
||||
"en": "Warm green wool beanie for cold days.",
|
||||
},
|
||||
"price": 19.90,
|
||||
"stock": 50,
|
||||
"cat": "accessoires",
|
||||
"color": "green",
|
||||
"img": ("388e3c", "Mütze"),
|
||||
},
|
||||
{
|
||||
"sku": "SCHAL-GRAU",
|
||||
"name": {"de": "Grauer Schal", "en": "Grey Scarf"},
|
||||
"description": {
|
||||
"de": "Weicher grauer Schal aus Merinowolle.",
|
||||
"en": "Soft grey scarf made of merino wool.",
|
||||
},
|
||||
"price": 29.00,
|
||||
"stock": 35,
|
||||
"cat": "accessoires",
|
||||
"color": "grey",
|
||||
"img": ("757575", "Grauer Schal"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Shop name setting
|
||||
if not db.query(User).filter_by(email="admin@example.com").first():
|
||||
set_setting(db, "core.shop_name", "Demo Shop")
|
||||
|
||||
# Users
|
||||
if not db.query(User).filter_by(email="admin@example.com").first():
|
||||
admin = User(
|
||||
email="admin@example.com",
|
||||
password_hash=hash_password("admin123"),
|
||||
role="admin",
|
||||
name="Admin",
|
||||
locale="de",
|
||||
)
|
||||
db.add(admin)
|
||||
if not db.query(User).filter_by(email="kunde@example.com").first():
|
||||
customer = User(
|
||||
email="kunde@example.com",
|
||||
password_hash=hash_password("kunde123"),
|
||||
role="customer",
|
||||
name="Demo Kunde",
|
||||
locale="de",
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
|
||||
# Categories
|
||||
cats_by_slug: dict[str, Category] = {}
|
||||
for c in CATEGORIES:
|
||||
row = db.query(Category).filter_by(slug=c["slug"]).first()
|
||||
if not row:
|
||||
row = Category(slug=c["slug"], name=c["name"], sort_order=c["sort_order"])
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
event_bus.publish("category.created", {"id": row.id}, db=db)
|
||||
cats_by_slug[c["slug"]] = row
|
||||
|
||||
# Products
|
||||
for pd in PRODUCTS:
|
||||
if db.query(Product).filter_by(sku=pd["sku"]).first():
|
||||
continue
|
||||
image = _placeholder(*pd["img"])
|
||||
p = Product(
|
||||
sku=pd["sku"],
|
||||
name=pd["name"],
|
||||
description=pd["description"],
|
||||
price=pd["price"],
|
||||
currency="EUR",
|
||||
stock=pd["stock"],
|
||||
active=True,
|
||||
image_url=image,
|
||||
category_id=cats_by_slug[pd["cat"]].id,
|
||||
attributes={"color": pd["color"]},
|
||||
)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
event_bus.publish("product.created", {"id": p.id, "sku": p.sku}, db=db)
|
||||
|
||||
# Rebuild Redis cache (idempotent)
|
||||
rebuild_all(db)
|
||||
print("Seed complete.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
43
backend/core/settings_service.py
Normal file
43
backend/core/settings_service.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Key-Value settings store with Redis projection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, String
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from core.db import Base
|
||||
from core.events import event_bus
|
||||
from core.redis_client import redis_client
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSON)
|
||||
|
||||
|
||||
def get_setting(db: Session, key: str, default: Any = None) -> Any:
|
||||
row = db.get(Setting, key)
|
||||
return row.value.get("v") if row else default
|
||||
|
||||
|
||||
def set_setting(db: Session, key: str, value: Any) -> None:
|
||||
row = db.get(Setting, key)
|
||||
if row:
|
||||
row.value = {"v": value}
|
||||
else:
|
||||
row = Setting(key=key, value={"v": value})
|
||||
db.add(row)
|
||||
db.commit()
|
||||
redis_client.set(f"setting:{key}", json.dumps(value))
|
||||
event_bus.publish("core.settings_updated", {"key": key, "value": value}, db=db)
|
||||
|
||||
|
||||
def get_setting_cached(key: str, default: Any = None) -> Any:
|
||||
raw = redis_client.get(f"setting:{key}")
|
||||
if raw is None:
|
||||
return default
|
||||
return json.loads(raw)
|
||||
Reference in New Issue
Block a user