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),
+9 -1
View File
@@ -200,8 +200,16 @@ VALID_STATUSES = {"draft", "sent", "paid", "cancelled"}
async def generate_invoice_number(db: AsyncSession, tenant_id: uuid.UUID | None) -> str:
"""Generate sequential invoice number: INV-YYYY-NNNN."""
"""Generate sequential invoice number: INV-YYYY-NNNN.
Uses a PostgreSQL advisory lock to prevent race conditions when multiple
requests create invoices concurrently. The lock is automatically released
when the surrounding transaction commits or rolls back.
"""
from sqlalchemy import text
year = datetime.utcnow().year
# Advisory lock keyed on year — serialises concurrent invoice creation
await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"invoice_number_seq_{year}"})
count_result = await db.execute(
select(func.count()).select_from(Invoice).where(
func.extract("year", Invoice.created_at) == year
+58 -5
View File
@@ -112,11 +112,64 @@ def send_email_notification_stub(
subject: str,
body: str,
) -> None:
"""Email notification stub — logs only, email sending not yet active."""
logger.info(
"[EMAIL STUB] Would send email to user=%s event=%s subject=%s",
to_user_id, event_type, subject
)
"""Send email notification via SMTP if configured and enabled, else log only."""
engine = _get_engine()
try:
from sqlalchemy.orm import Session
from app.models.system_setting import SystemSetting
with Session(engine) as s:
rows = s.execute(
__import__("sqlalchemy").select(SystemSetting).where(
SystemSetting.key.in_(["smtp_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_address"])
)
).scalars().all()
cfg = {r.key: r.value for r in rows}
except Exception:
cfg = {}
smtp_enabled = cfg.get("smtp_enabled", "false").lower() == "true"
smtp_host = cfg.get("smtp_host", "")
if not smtp_enabled or not smtp_host:
logger.info(
"[EMAIL STUB] Would send email to user=%s event=%s subject=%s (smtp disabled)",
to_user_id, event_type, subject
)
return
# Resolve to_address from user record
to_address: str | None = None
try:
from sqlalchemy.orm import Session
from app.models.user import User
with Session(engine) as s:
u = s.get(User, to_user_id) if to_user_id else None
if u:
to_address = u.email
except Exception:
pass
if not to_address:
logger.warning("[EMAIL] Could not resolve email for user=%s", to_user_id)
return
try:
import smtplib
from email.mime.text import MIMEText
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = cfg.get("smtp_from_address") or cfg.get("smtp_user", "noreply@schaeffler.com")
msg["To"] = to_address
port = int(cfg.get("smtp_port", "587"))
with smtplib.SMTP(smtp_host, port) as smtp:
smtp.ehlo()
smtp.starttls()
if cfg.get("smtp_user") and cfg.get("smtp_password"):
smtp.login(cfg["smtp_user"], cfg["smtp_password"])
smtp.sendmail(msg["From"], [to_address], msg.as_string())
logger.info("[EMAIL] Sent to %s event=%s", to_address, event_type)
except Exception as exc:
logger.error("[EMAIL] Failed to send to %s: %s", to_address, exc)
async def get_notification_configs(db: AsyncSession, user_id: uuid.UUID) -> list: