From c0ea60d984ef7202d573ecc377b60d56234ed6f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?=
Date: Fri, 6 Mar 2026 18:15:45 +0100
Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20open=20risks=20=E2=80=94=20inv?=
=?UTF-8?q?oice=20race=20condition,=20SMTP=20config,=20workflow=20seeds?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
backend/app/api/routers/admin.py | 95 ++++++++++++++
backend/app/domains/billing/service.py | 10 +-
backend/app/domains/notifications/service.py | 63 +++++++++-
frontend/src/pages/Admin.tsx | 123 +++++++++++++++++++
frontend/src/pages/Upload.tsx | 8 +-
5 files changed, 289 insertions(+), 10 deletions(-)
diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py
index 210795d..7416606 100644
--- a/backend/app/api/routers/admin.py
+++ b/backend/app/api/routers/admin.py
@@ -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),
diff --git a/backend/app/domains/billing/service.py b/backend/app/domains/billing/service.py
index e0ef713..f5e934b 100644
--- a/backend/app/domains/billing/service.py
+++ b/backend/app/domains/billing/service.py
@@ -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
diff --git a/backend/app/domains/notifications/service.py b/backend/app/domains/notifications/service.py
index b6759c8..3f45578 100644
--- a/backend/app/domains/notifications/service.py
+++ b/backend/app/domains/notifications/service.py
@@ -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:
diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx
index edb2de8..ea30be9 100644
--- a/frontend/src/pages/Admin.tsx
+++ b/frontend/src/pages/Admin.tsx
@@ -75,6 +75,15 @@ export default function AdminPage() {
blender_max_concurrent_renders: number
render_stall_timeout_minutes: number
product_thumbnail_priority: string // JSON array
+ render_backend: string
+ flamenco_manager_url: string
+ flamenco_worker_count: number
+ smtp_enabled: boolean
+ smtp_host: string
+ smtp_port: number
+ smtp_user: string
+ smtp_password: string
+ smtp_from_address: string
}
const { data: settings } = useQuery({
@@ -138,6 +147,18 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
+ const seedWorkflowsMut = useMutation({
+ mutationFn: () => api.post('/admin/settings/seed-workflows'),
+ onSuccess: (res) => {
+ toast.success(res.data.message || 'Workflows seeded')
+ qc.invalidateQueries({ queryKey: ['workflows'] })
+ },
+ onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
+ })
+
+ const [smtpDraft, setSmtpDraft] = useState>({})
+ const smtp = { ...settings, ...smtpDraft } as Settings
+
type FlamencoStatus = {
manager: { available: boolean; version: string | null; name: string | null; error?: string }
workers: any[]
@@ -965,10 +986,112 @@ export default function AdminPage() {
Generates low + high STL files for any completed STEP file that is missing them.
+
+
+
+ Creates the 4 standard workflow definitions (still/turntable/multi-angle) if they don't exist.
+
+
}
+ {/* ------------------------------------------------------------------ */}
+ {/* E-Mail / SMTP Settings */}
+ {/* ------------------------------------------------------------------ */}
+ {isAdmin && (
+
+
+
E-Mail Notifications (SMTP)
+
+ Configure outbound SMTP for email notifications. Enable only when credentials are set.
+
+
+
+
+
+ {smtp.smtp_enabled && (
+ Active
+ )}
+
+
+
+
+
+ )}
+
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx
index b9dafd6..a87d7ec 100644
--- a/frontend/src/pages/Upload.tsx
+++ b/frontend/src/pages/Upload.tsx
@@ -453,9 +453,9 @@ export default function UploadPage() {
{!hasId ? null : row.has_step ? (
-
+
) : (
-
+
)}
|
@@ -598,9 +598,9 @@ export default function UploadPage() {
{row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'} |
{row.has_step ? (
-
+
) : (
-
+
)}
|
{outputTypes.map((ot) => (