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 + )} +
+
+
+ + setSmtpDraft(d => ({ ...d, smtp_host: e.target.value }))} + placeholder="smtp.example.com" + className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+
+ + setSmtpDraft(d => ({ ...d, smtp_port: parseInt(e.target.value) || 587 }))} + className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+
+ + setSmtpDraft(d => ({ ...d, smtp_user: e.target.value }))} + placeholder="user@example.com" + className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+
+ + setSmtpDraft(d => ({ ...d, smtp_password: e.target.value }))} + placeholder="••••••••" + className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+
+ + setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))} + placeholder="noreply@schaeffler.com" + className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+
+ +
+
+ )} + {/* ------------------------------------------------------------------ */} {/* 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) => (