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:
@@ -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