Files
SlabUploader/backend/settings.py
Ty 49c22c9385 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.
2026-09-25 06:57:29 -07:00

158 lines
5.3 KiB
Python

"""Settings service (SETTINGS-5/15/20/25/30/75/85/90).
Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool}
by default (SETTINGS-75 masking). SLAB_UAT_UNMASK_SECRETS=1 is a dev-test-only
unmask (never set in prod). woo_base_url is env read-only (SETTINGS-85).
Partial PUT; unknown keys ignored. Decrypt failure -> configured:false (no 500,
no leak).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import HTTPException
from .crypto import decrypt, encrypt
from .db import get_connection
SECRET_KEYS: set[str] = {
"woo_app_password",
"inference_base_url",
"inference_api_key",
}
PLAIN_KEYS: set[str] = {
"woo_wp_username",
"inference_model",
"inference_enabled",
"content_llm_enabled",
"brand_voice",
"geo_context",
"woo_create_status",
"taxonomy_last_sync_at",
"taxonomy_anchor",
"crop_fill",
}
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
# Plain keys surfaced as numbers in the API (stored as TEXT in `settings`).
NUMERIC_KEYS: set[str] = {"crop_fill"}
DEFAULTS: dict[str, Any] = {
"inference_enabled": False,
"content_llm_enabled": False,
"woo_create_status": "draft",
# #99: global crop fill %, one source of truth for the ref line + PNG crop.
"crop_fill": 90,
}
def _row_to_value(row: dict) -> Any:
v = row.get("value")
if v is None:
return None
if isinstance(v, str) and v.lower() in ("true", "false"):
return v.lower() == "true"
return v
def get_settings() -> dict[str, Any]:
conn = get_connection()
rows = {
r["key"]: {"value": r["value"], "value_enc": r["value_enc"]}
for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()
}
result: dict[str, Any] = {"woo_base_url": os.environ.get("WOO_BASE_URL", "")}
# TEMPORARY UAT override (owner-directed, LAN/HTTP only): when set, secrets
# are returned as decrypted plaintext for dev-test inspection. Default OFF ->
# SETTINGS-75 masking intact. Never set in prod.
unmask = os.environ.get("SLAB_UAT_UNMASK_SECRETS") == "1"
for k in sorted(ALL_KEYS):
row = rows.get(k, {})
if k in SECRET_KEYS:
enc = row.get("value_enc")
if unmask:
if enc:
try:
result[k] = decrypt(enc)
except Exception:
result[k] = ""
else:
result[k] = ""
elif enc:
try:
decrypt(enc) # verify roundtrip possible
result[k] = {"configured": True}
except Exception:
result[k] = {"configured": False}
else:
result[k] = {"configured": False}
else:
val = _row_to_value(row) if k in rows else None
result[k] = val if val is not None else DEFAULTS.get(k)
if k in NUMERIC_KEYS:
try:
f = float(result[k])
result[k] = int(f) if f.is_integer() else f
except (TypeError, ValueError):
result[k] = DEFAULTS.get(k)
return result
def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
if "woo_base_url" in payload:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "validation",
"message": "woo_base_url is read-only (SETTINGS-85: env only)",
"detail": {"field": "woo_base_url"},
}
},
)
# #99: rules live here in the 4xx layer, not in the DB (structural only).
if "crop_fill" in payload:
raw = payload["crop_fill"]
try:
pct = float(raw)
except (TypeError, ValueError):
pct = None
if pct is None or not (1 <= pct <= 100):
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "validation",
"message": "crop_fill must be a number between 1 and 100 (percent)",
"detail": {"field": "crop_fill"},
}
},
)
conn = get_connection()
for k, v in payload.items():
if k not in ALL_KEYS:
continue # partial update: unknown keys ignored
if k in SECRET_KEYS:
if v in (None, "", False):
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", (k, None))
else:
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,?)",
(k, None, encrypt(str(v))),
)
else:
if v in (None, ""):
conn.execute("DELETE FROM settings WHERE key=?", (k,))
elif k in ("inference_enabled", "content_llm_enabled"):
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
(k, "true" if bool(v) else "false"))
else:
conn.execute("INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)", (k, str(v)))
conn.commit()
return get_settings()