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", "blender_max_concurrent_renders": "3",
"product_thumbnail_priority": '["latest_render","cad_thumbnail"]', "product_thumbnail_priority": '["latest_render","cad_thumbnail"]',
"render_stall_timeout_minutes": "120", "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 blender_max_concurrent_renders: int = 3
product_thumbnail_priority: str = '["latest_render","cad_thumbnail"]' product_thumbnail_priority: str = '["latest_render","cad_thumbnail"]'
render_stall_timeout_minutes: int = 120 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): class SettingsUpdate(BaseModel):
@@ -65,6 +78,12 @@ class SettingsUpdate(BaseModel):
blender_max_concurrent_renders: int | None = None blender_max_concurrent_renders: int | None = None
product_thumbnail_priority: str | None = None product_thumbnail_priority: str | None = None
render_stall_timeout_minutes: int | 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]) @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"]), blender_max_concurrent_renders=int(raw["blender_max_concurrent_renders"]),
product_thumbnail_priority=raw.get("product_thumbnail_priority", '["latest_render","cad_thumbnail"]'), product_thumbnail_priority=raw.get("product_thumbnail_priority", '["latest_render","cad_thumbnail"]'),
render_stall_timeout_minutes=int(raw.get("render_stall_timeout_minutes", "120")), 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) updates["render_stall_timeout_minutes"] = str(body.render_stall_timeout_minutes)
if body.product_thumbnail_priority is not None: if body.product_thumbnail_priority is not None:
updates["product_thumbnail_priority"] = body.product_thumbnail_priority 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(): for k, v in updates.items():
await _save_setting(db, k, v) 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)"} 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") @router.get("/settings/renderer-status")
async def renderer_status( async def renderer_status(
admin: User = Depends(require_admin), 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: 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 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( count_result = await db.execute(
select(func.count()).select_from(Invoice).where( select(func.count()).select_from(Invoice).where(
func.extract("year", Invoice.created_at) == year func.extract("year", Invoice.created_at) == year
+58 -5
View File
@@ -112,11 +112,64 @@ def send_email_notification_stub(
subject: str, subject: str,
body: str, body: str,
) -> None: ) -> None:
"""Email notification stub — logs only, email sending not yet active.""" """Send email notification via SMTP if configured and enabled, else log only."""
logger.info( engine = _get_engine()
"[EMAIL STUB] Would send email to user=%s event=%s subject=%s", try:
to_user_id, event_type, subject 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: async def get_notification_configs(db: AsyncSession, user_id: uuid.UUID) -> list:
+123
View File
@@ -75,6 +75,15 @@ export default function AdminPage() {
blender_max_concurrent_renders: number blender_max_concurrent_renders: number
render_stall_timeout_minutes: number render_stall_timeout_minutes: number
product_thumbnail_priority: string // JSON array 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({ const { data: settings } = useQuery({
@@ -138,6 +147,18 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), 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<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings
type FlamencoStatus = { type FlamencoStatus = {
manager: { available: boolean; version: string | null; name: string | null; error?: string } manager: { available: boolean; version: string | null; name: string | null; error?: string }
workers: any[] workers: any[]
@@ -965,10 +986,112 @@ export default function AdminPage() {
Generates low + high STL files for any completed STEP file that is missing them. Generates low + high STL files for any completed STEP file that is missing them.
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<button
onClick={() => seedWorkflowsMut.mutate()}
disabled={seedWorkflowsMut.isPending}
className="btn-secondary text-sm"
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
{seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
</button>
<p className="text-xs text-content-muted">
Creates the 4 standard workflow definitions (still/turntable/multi-angle) if they don't exist.
</p>
</div>
</div> </div>
</div> </div>
</div>} </div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure outbound SMTP for email notifications. Enable only when credentials are set.
</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={smtp.smtp_enabled ?? false}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_enabled: e.target.checked }))}
className="w-4 h-4 rounded"
/>
<span className="text-sm font-medium text-content">Enable email sending</span>
</label>
{smtp.smtp_enabled && (
<span className="badge badge-green text-xs">Active</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">SMTP Host</label>
<input
type="text"
value={smtp.smtp_host ?? ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Port</label>
<input
type="number"
value={smtp.smtp_port ?? 587}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Username</label>
<input
type="text"
value={smtp.smtp_user ?? ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={smtp.smtp_password ?? ''}
onChange={(e) => 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"
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-content-secondary mb-1">From Address</label>
<input
type="email"
value={smtp.smtp_from_address ?? ''}
onChange={(e) => 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"
/>
</div>
</div>
<button
onClick={() => { updateSettingsMut.mutate(smtpDraft as any); setSmtpDraft({}) }}
disabled={updateSettingsMut.isPending || Object.keys(smtpDraft).length === 0}
className="btn-primary text-sm"
>
Save SMTP Settings
</button>
</div>
</div>
)}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Templates */} {/* Templates */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
+4 -4
View File
@@ -453,9 +453,9 @@ export default function UploadPage() {
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{!hasId ? null : row.has_step ? ( {!hasId ? null : row.has_step ? (
<CheckCircle size={14} className="text-green-500 mx-auto" title="STEP file linked" /> <CheckCircle size={14} className="text-green-500 mx-auto" aria-label="STEP file linked" />
) : ( ) : (
<X size={14} className="text-red-400 mx-auto" title="No STEP file" /> <X size={14} className="text-red-400 mx-auto" aria-label="No STEP file" />
)} )}
</td> </td>
</tr> </tr>
@@ -598,9 +598,9 @@ export default function UploadPage() {
<td className="px-4 py-2">{row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'}</td> <td className="px-4 py-2">{row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'}</td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{row.has_step ? ( {row.has_step ? (
<CheckCircle size={14} className="text-green-500 mx-auto" title="STEP file linked" /> <CheckCircle size={14} className="text-green-500 mx-auto" aria-label="STEP file linked" />
) : ( ) : (
<X size={14} className="text-red-400 mx-auto" title="No STEP file" /> <X size={14} className="text-red-400 mx-auto" aria-label="No STEP file" />
)} )}
</td> </td>
{outputTypes.map((ot) => ( {outputTypes.map((ot) => (