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:
@@ -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 1–65535")
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user