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
+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:
"""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
+58 -5
View File
@@ -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: