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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,11 +112,64 @@ def send_email_notification_stub(
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Email notification stub — logs only, email sending not yet active."""
|
||||
"""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",
|
||||
"[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:
|
||||
|
||||
@@ -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<Partial<Settings>>({})
|
||||
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.
|
||||
</p>
|
||||
</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>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* 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 */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
@@ -453,9 +453,9 @@ export default function UploadPage() {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{!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>
|
||||
</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 text-center">
|
||||
{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>
|
||||
{outputTypes.map((ot) => (
|
||||
|
||||
Reference in New Issue
Block a user