fix: resolve open risks — invoice race condition, SMTP config, workflow seeds

- billing/service.py: pg_advisory_xact_lock on invoice_number_seq per year
  → prevents duplicate INV-YYYY-NNNN under concurrent requests
- admin.py: SMTP settings in system_settings (smtp_host/port/user/password/
  from_address/enabled) with GET+PUT support; seed-workflows endpoint creates
  4 standard workflow definitions (still-cycles, still-eevee, turntable,
  multi-angle) idempotently
- notifications/service.py: send_email_notification_stub now sends real
  SMTP email via smtplib when smtp_enabled=true in system_settings
- Admin.tsx: SMTP settings panel (host/port/user/password/from + enable
  toggle, save button); Seed Standard Workflows maintenance button
- Upload.tsx: fix TS error — title→aria-label on Lucide icons
- Admin.tsx Settings type: add render_backend/flamenco_* fields (TS fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:15:45 +01:00
parent f19a6ccde8
commit c0ea60d984
5 changed files with 289 additions and 10 deletions
+95
View File
@@ -34,6 +34,13 @@ SETTINGS_DEFAULTS: dict[str, str] = {
"blender_max_concurrent_renders": "3",
"product_thumbnail_priority": '["latest_render","cad_thumbnail"]',
"render_stall_timeout_minutes": "120",
# SMTP (email notifications — disabled by default)
"smtp_enabled": "false",
"smtp_host": "",
"smtp_port": "587",
"smtp_user": "",
"smtp_password": "",
"smtp_from_address": "",
}
@@ -50,6 +57,12 @@ class SettingsOut(BaseModel):
blender_max_concurrent_renders: int = 3
product_thumbnail_priority: str = '["latest_render","cad_thumbnail"]'
render_stall_timeout_minutes: int = 120
smtp_enabled: bool = False
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
class SettingsUpdate(BaseModel):
@@ -65,6 +78,12 @@ class SettingsUpdate(BaseModel):
blender_max_concurrent_renders: int | None = None
product_thumbnail_priority: str | None = None
render_stall_timeout_minutes: int | None = None
smtp_enabled: bool | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from_address: str | None = None
@router.get("/users", response_model=list[UserOut])
@@ -166,6 +185,12 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
blender_max_concurrent_renders=int(raw["blender_max_concurrent_renders"]),
product_thumbnail_priority=raw.get("product_thumbnail_priority", '["latest_render","cad_thumbnail"]'),
render_stall_timeout_minutes=int(raw.get("render_stall_timeout_minutes", "120")),
smtp_enabled=raw.get("smtp_enabled", "false").lower() == "true",
smtp_host=raw.get("smtp_host", ""),
smtp_port=int(raw.get("smtp_port", "587")),
smtp_user=raw.get("smtp_user", ""),
smtp_password=raw.get("smtp_password", ""),
smtp_from_address=raw.get("smtp_from_address", ""),
)
@@ -246,6 +271,20 @@ async def update_settings(
updates["render_stall_timeout_minutes"] = str(body.render_stall_timeout_minutes)
if body.product_thumbnail_priority is not None:
updates["product_thumbnail_priority"] = body.product_thumbnail_priority
if body.smtp_enabled is not None:
updates["smtp_enabled"] = "true" if body.smtp_enabled else "false"
if body.smtp_host is not None:
updates["smtp_host"] = body.smtp_host
if body.smtp_port is not None:
if not (1 <= body.smtp_port <= 65535):
raise HTTPException(400, detail="smtp_port must be 165535")
updates["smtp_port"] = str(body.smtp_port)
if body.smtp_user is not None:
updates["smtp_user"] = body.smtp_user
if body.smtp_password is not None:
updates["smtp_password"] = body.smtp_password
if body.smtp_from_address is not None:
updates["smtp_from_address"] = body.smtp_from_address
for k, v in updates.items():
await _save_setting(db, k, v)
@@ -355,6 +394,62 @@ async def generate_missing_stls(
return {"queued": queued, "message": f"Queued {queued} missing STL generation task(s)"}
@router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
async def seed_workflows(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Create the standard workflow definitions if they do not already exist."""
from app.domains.rendering.models import WorkflowDefinition
STANDARD_WORKFLOWS = [
{
"name": "Still Image — Cycles",
"config": {
"type": "still",
"params": {"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]},
},
},
{
"name": "Still Image — EEVEE",
"config": {
"type": "still",
"params": {"render_engine": "eevee", "samples": 64, "resolution": [1920, 1080]},
},
},
{
"name": "Turntable Animation",
"config": {
"type": "turntable",
"params": {"render_engine": "cycles", "samples": 64, "fps": 24, "duration_s": 5},
},
},
{
"name": "Multi-Angle (0° / 45° / 90°)",
"config": {
"type": "multi_angle",
"params": {"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]},
},
},
]
existing_result = await db.execute(select(WorkflowDefinition))
existing_names = {wf.name for wf in existing_result.scalars().all()}
created = 0
for wf_data in STANDARD_WORKFLOWS:
if wf_data["name"] not in existing_names:
db.add(WorkflowDefinition(
name=wf_data["name"],
config=wf_data["config"],
is_active=True,
))
created += 1
await db.commit()
return {"created": created, "message": f"Created {created} workflow definition(s)"}
@router.get("/settings/renderer-status")
async def renderer_status(
admin: User = Depends(require_admin),