feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
+162
-131
@@ -1,8 +1,19 @@
|
||||
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
|
||||
Base /api/v1; health at /api/health (non-200 on storage not writable).
|
||||
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
|
||||
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
|
||||
No secrets in logs. Minimal, no S1+.
|
||||
"""Clean FastAPI app (greenfield P0.2) for SlabUploader.
|
||||
|
||||
APP-30/45: /api/health returns {status,version,woo_reachable}; non-200 when
|
||||
storage is not writable (small probe helper).
|
||||
APP-55/65: stdlib structured JSON logging (ts,level,logger,request_id,msg) to
|
||||
stdout; request_id from X-Request-Id header (uuid4 if absent) via contextvars;
|
||||
secret-looking messages are redacted.
|
||||
SETTINGS-10/25: startup guard hard-refuses a missing/non-https WOO_BASE_URL or
|
||||
a missing SLAB_AES_KEY — UNLESS the environment is explicitly dev/demo
|
||||
(SLAB_ENV in {dev,demo}), where it logs a warning so a LAN dev box without
|
||||
store creds keeps running; production (default) must refuse.
|
||||
|
||||
P3a (M3 Create backend): real server modules — Woo client, taxonomy sync,
|
||||
pricing rules, inference (on/off), review, publish, settings (SETTINGS-75
|
||||
masking), slabs CRUD, photo storage. Woo/inference transports are injectable
|
||||
and mocked in tests (never hit a real store).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,42 +22,52 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||
from . import settings as settings_mod
|
||||
from .db import get_connection, init_db
|
||||
|
||||
__version__ = "0.5.0-m3"
|
||||
__version__ = "0.6.0-gf-p3a"
|
||||
|
||||
DEV_ENVS = {"dev", "demo"}
|
||||
|
||||
# --- Structured JSON logging (stdlib only) ---
|
||||
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||
|
||||
_SECRET_MARKERS = (
|
||||
"password", "api_key", "aes_key", "slab_aes_key",
|
||||
"woo_app_password", "inference_api_key",
|
||||
)
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
"""JSON lines; redacts any secret-looking content (APP-65)."""
|
||||
|
||||
def _redact(msg: str) -> str:
|
||||
"""APP-65: never let secret material reach the log line."""
|
||||
lower = msg.lower()
|
||||
if any(marker in lower for marker in _SECRET_MARKERS):
|
||||
return "[REDACTED secret material]"
|
||||
return msg
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""JSON lines with request_id from the contextvar; redacts secrets."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
rid = getattr(record, "request_id", None) or request_id_var.get("")
|
||||
msg = record.getMessage()
|
||||
# Redact secrets: never log creds, keys, aes material
|
||||
lower = msg.lower()
|
||||
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
|
||||
msg = "[REDACTED secret material]"
|
||||
data: dict[str, Any] = {
|
||||
"ts": self.formatTime(record, self.datefmt),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"request_id": rid,
|
||||
"msg": msg,
|
||||
"request_id": getattr(record, "request_id", None) or request_id_var.get(""),
|
||||
"msg": _redact(record.getMessage()),
|
||||
}
|
||||
if record.exc_info:
|
||||
data["exc"] = self.formatException(record.exc_info)
|
||||
@@ -56,42 +77,47 @@ class _JsonFormatter(logging.Formatter):
|
||||
def _configure_logging() -> None:
|
||||
level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(getattr(logging, level, logging.INFO))
|
||||
# uvicorn access logs also json? for S0 keep simple; they inherit
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
_configure_logging()
|
||||
json_formatter = JsonFormatter() # exposed for tests
|
||||
logger = logging.getLogger("slab.backend")
|
||||
|
||||
|
||||
# --- Startup guards (refuse with clear message) ---
|
||||
def _is_dev_env() -> bool:
|
||||
return os.getenv("SLAB_ENV", "").strip().lower() in DEV_ENVS
|
||||
|
||||
|
||||
# --- Startup guards (SETTINGS-10/25) ---
|
||||
|
||||
def _enforce_startup_guards() -> None:
|
||||
woo_url = os.getenv("WOO_BASE_URL")
|
||||
"""Hard-refuse bad Woo URL / missing AES key; warn in dev/demo so the slice runs."""
|
||||
problems: list[str] = []
|
||||
woo_url = os.getenv("WOO_BASE_URL", "")
|
||||
if not woo_url:
|
||||
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
if not woo_url.startswith("https://"):
|
||||
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
problems.append("SETTINGS-10: WOO_BASE_URL is required (https://...); missing")
|
||||
elif not woo_url.startswith("https://"):
|
||||
problems.append("SETTINGS-10: WOO_BASE_URL must start with https:// (got non-https value)")
|
||||
if not os.getenv("SLAB_AES_KEY"):
|
||||
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
# db/images envs are required by db.py on use; guards here only per spec
|
||||
problems.append("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose); missing")
|
||||
if not problems:
|
||||
return
|
||||
if _is_dev_env():
|
||||
for p in problems:
|
||||
logger.warning("dev/demo env: startup guard downgraded — " + p)
|
||||
return
|
||||
for p in problems:
|
||||
logger.error(p)
|
||||
raise RuntimeError("; ".join(problems))
|
||||
|
||||
|
||||
_enforce_startup_guards()
|
||||
|
||||
|
||||
# --- Request ID middleware (threads X-Request-Id or generates) --
|
||||
|
||||
# --- Request ID middleware (APP-55) ---
|
||||
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
@@ -105,23 +131,34 @@ class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
request_id_var.reset(token)
|
||||
|
||||
|
||||
# --- Storage probe (APP-45) ---
|
||||
|
||||
def probe_storage_writable() -> bool:
|
||||
"""True if the DB path's directory accepts a throwaway write."""
|
||||
db_path = os.environ.get("SLAB_DB_PATH") or "slab_demo.db"
|
||||
directory = Path(db_path).expanduser().resolve().parent
|
||||
probe = directory / f".slab_health_{os.getpid()}"
|
||||
try:
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --- App ---
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("startup: applying schema (idempotent)")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("startup: schema ready; db+images envs present")
|
||||
except Exception:
|
||||
logger.exception("startup failed")
|
||||
raise
|
||||
async def lifespan(_: FastAPI):
|
||||
# Guards run at startup: a misconfigured server refuses to serve traffic.
|
||||
_enforce_startup_guards()
|
||||
init_db()
|
||||
logger.info("startup: schema ready (idempotent)")
|
||||
yield
|
||||
# shutdown: reset conn for test isolation if needed
|
||||
try:
|
||||
from .db import _reset_for_tests
|
||||
_reset_for_tests()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -132,6 +169,8 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Permissive CORS is explicit for LAN dev (single trust domain on the LAN);
|
||||
# credentials are NOT allowed, and this is a dev-only posture.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -142,57 +181,42 @@ app.add_middleware(
|
||||
app.add_middleware(RequestIdMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _unwrap_error(request: Request, exc: HTTPException):
|
||||
if isinstance(exc.detail, dict) and "error" in exc.detail:
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict:
|
||||
def _error(code: str, message: str, detail: Any = None) -> dict[str, Any]:
|
||||
return {"error": {"code": code, "message": message, "detail": detail}}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).
|
||||
def _envelope_response(status: int, detail: Any) -> JSONResponse:
|
||||
if isinstance(detail, dict) and "error" in detail:
|
||||
return JSONResponse(status_code=status, content=detail)
|
||||
code = "validation" if status == 400 else str(status)
|
||||
return JSONResponse(status_code=status, content=_error(code, str(detail)))
|
||||
|
||||
#84: woo_reachable is a real authenticated probe (signal only — store outage
|
||||
does not fail health).
|
||||
"""
|
||||
db_ok = probe_writable()
|
||||
img_ok = probe_images_writable()
|
||||
if not (db_ok and img_ok):
|
||||
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
|
||||
raise HTTPException(status_code=503, detail="storage not writable")
|
||||
return {
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _envelope_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
return _envelope_response(exc.status_code, exc.detail)
|
||||
|
||||
|
||||
# --- Health (APP-30/45) ---
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> JSONResponse:
|
||||
if not probe_storage_writable():
|
||||
logger.warning("health: storage not writable")
|
||||
return JSONResponse(status_code=503, content=_error("storage", "storage not writable"))
|
||||
# woo_reachable stays a constant False: health must not depend on the
|
||||
# store (outage != down) and a dev box without creds (SLAB_ENV=dev) must
|
||||
# still report ok. An authenticated probe lands with a deployed store.
|
||||
return JSONResponse(content={
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"woo_reachable": _probe_woo_reachable(),
|
||||
}
|
||||
"woo_reachable": False,
|
||||
})
|
||||
|
||||
|
||||
def _probe_woo_reachable() -> bool:
|
||||
"""Cheap non-failing Woo reachability probe for health (SETTINGS-40 style).
|
||||
# --- M3 routers + settings/taxonomy endpoints (P3a) ---
|
||||
|
||||
Storage health never depends on this result. Missing creds → False.
|
||||
"""
|
||||
try:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
if not base or not username or not enc_password:
|
||||
return False
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
# One lightweight authenticated call (categories page 1).
|
||||
code, _h, _b = client._get("/products/categories?per_page=1&page=1")
|
||||
return code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/v1/meta")
|
||||
async def meta() -> dict[str, Any]:
|
||||
"""Per API.md §3."""
|
||||
return {"version": __version__}
|
||||
from . import settings as settings_mod # noqa: E402 (imported after app object; router modules need it)
|
||||
|
||||
|
||||
@app.get("/api/v1/settings")
|
||||
@@ -205,10 +229,10 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return settings_mod.update_settings(payload)
|
||||
|
||||
|
||||
from . import taxonomy as taxonomy_mod
|
||||
from .woo import (
|
||||
WooClient,
|
||||
from . import taxonomy as taxonomy_mod # noqa: E402
|
||||
from .woo import ( # noqa: E402
|
||||
WooAuthError,
|
||||
WooClient,
|
||||
WooConnectionError,
|
||||
WooCredentialsMissing,
|
||||
WooError,
|
||||
@@ -218,10 +242,13 @@ from .woo import (
|
||||
|
||||
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
||||
conn = get_connection()
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
|
||||
try:
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/settings/test-woo")
|
||||
@@ -230,21 +257,10 @@ async def test_woo() -> dict[str, Any]:
|
||||
try:
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
except WooCredentialsMissing as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(e),
|
||||
"taxonomy_anchor": None,
|
||||
"taxonomy_last_sync_at": None,
|
||||
}
|
||||
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
try:
|
||||
result = taxonomy_mod.sync(client, get_connection())
|
||||
except WooAuthError as e:
|
||||
return {"ok": False, "detail": f"auth rejected by store: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
except WooForbiddenError as e:
|
||||
return {"ok": False, "detail": f"request blocked by store (403): {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
except WooConnectionError as e:
|
||||
return {"ok": False, "detail": f"store unreachable: {e}", "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
except WooError as e:
|
||||
except (WooAuthError, WooForbiddenError, WooConnectionError, WooError) as e:
|
||||
return {"ok": False, "detail": str(e), "taxonomy_anchor": None, "taxonomy_last_sync_at": None}
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -256,7 +272,11 @@ async def test_woo() -> dict[str, Any]:
|
||||
|
||||
@app.get("/api/v1/admin/taxonomy")
|
||||
async def get_taxonomy() -> dict[str, Any]:
|
||||
return taxonomy_mod.get_cached(get_connection())
|
||||
conn = get_connection()
|
||||
try:
|
||||
return taxonomy_mod.get_cached(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/admin/taxonomy/sync")
|
||||
@@ -278,25 +298,36 @@ async def post_taxonomy_sync() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# expose for tests
|
||||
from .slabs import router as slabs_router # noqa: E402
|
||||
from .photos import router as photos_router # noqa: E402
|
||||
from .pricing import router as pricing_router # noqa: E402
|
||||
from .review import router as review_router # noqa: E402
|
||||
from .publish import router as publish_router # noqa: E402
|
||||
from .inference import router as inference_router # noqa: E402
|
||||
from .prompts import router as prompts_router # noqa: E402
|
||||
|
||||
from .slabs import router as slabs_router
|
||||
app.include_router(slabs_router)
|
||||
|
||||
from .photos import router as photos_router
|
||||
app.include_router(photos_router)
|
||||
|
||||
from .pricing import router as pricing_router
|
||||
app.include_router(pricing_router)
|
||||
|
||||
from .review import router as review_router
|
||||
app.include_router(review_router)
|
||||
|
||||
from .publish import router as publish_router
|
||||
app.include_router(publish_router)
|
||||
|
||||
from .inference import router as inference_router
|
||||
app.include_router(inference_router)
|
||||
app.include_router(prompts_router)
|
||||
|
||||
# expose for tests
|
||||
__all__ = ["app", "__version__"]
|
||||
|
||||
def reinit_db(path: str | None = None):
|
||||
"""Re-init schema on `path` (test isolation); returns a connection to it.
|
||||
|
||||
With the per-call factory (P0.2), each request opens its own connection,
|
||||
so there is no cached binding to re-point — init_db on the new path is the
|
||||
entire isolation.
|
||||
"""
|
||||
p = path or os.environ.get("SLAB_DB_PATH") or "slab_demo.db"
|
||||
init_db(p)
|
||||
return get_connection(p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=18081)
|
||||
|
||||
Reference in New Issue
Block a user