@router.get("/wizard/options") async def wizard_options(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """All selectable entities for the 12-step wizard in one call.""" lists = (await db.execute(select(RecipientList).where(RecipientList.user_id == user.id) .order_by(RecipientList.id.desc()))).scalars().all() subjects = (await db.execute(select(SubjectLine).where(SubjectLine.user_id == user.id) .order_by(SubjectLine.id.desc()))).scalars().all() templates = (await db.execute(select(EmailTemplate).where(EmailTemplate.user_id == user.id) .order_by(EmailTemplate.id.desc()))).scalars().all() accounts = (await db.execute(select(GmailAccount).where(GmailAccount.user_id == user.id, GmailAccount.status == "authorized", GmailAccount.is_active == True))).scalars().all() # noqa: E712 phones = (await db.execute(select(CompanyPhoneNumber).where(CompanyPhoneNumber.user_id == user.id, CompanyPhoneNumber.is_active == True))).scalars().all() # noqa: E712 attachments = (await db.execute(select(Attachment).where(Attachment.user_id == user.id) .order_by(Attachment.id.desc()))).scalars().all() async def valid_count(list_id: int) -> int: return (await db.execute(select(func.count()).select_from(Recipient) .where(Recipient.list_id == list_id, Recipient.is_valid == True))).scalar() or 0 # noqa: E712 list_out = [] for rl in lists: list_out.append({"id": rl.id, "name": rl.name, "row_count": rl.row_count, "valid_count": await valid_count(rl.id)}) return { "lists": list_out, "subjects": [{"id": s.id, "subject": s.subject[:160]} for s in subjects], "templates": [{"id": t.id, "name": t.name} for t in templates], "accounts": [{"id": a.id, "email": a.email, "sends_today": a.sends_today, "daily_limit": a.daily_limit, "sending_method": a.sending_method} for a in accounts], "phones": [{"id": p.id, "phone": p.phone, "label": p.label} for p in phones], "attachments": [{"id": a.id, "name": a.name, "kind": a.kind, "filename": a.filename} for a in attachments], "limits": {"max_recipients": cfg.MAX_RECIPIENTS_PER_CAMPAIGN, "max_accounts": cfg.MAX_GMAIL_ACCOUNTS_PER_CAMPAIGN, "max_subjects": cfg.MAX_SUBJECTS}, } @router.post("/validate-step") async def validate_step(payload: dict, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """Wizard step validation between steps (step 3, 6, 9, 11 checks).""" step = int(payload.get("step", 0)) data = payload.get("data", {}) errors = [] if step == 1: if not (data.get("name") or "").strip(): errors.append("Campaign name is required") if step == 2: if not data.get("recipient_list_id"): errors.append("Select a recipient list") if step == 3: ids = data.get("subject_set_ids") or [] if not ids: errors.append("Select at least one subject line") if len(ids) > cfg.MAX_SUBJECTS: errors.append(f"Max {cfg.MAX_SUBJECTS} subject variations") strategy = data.get("subject_rotation") if strategy not in VALID_ENUMS["subject_rotation"]: errors.append("Invalid subject rotation strategy") if step == 4: ids = data.get("template_ids") or [] if not ids: errors.append("Select at least one template") mode = data.get("template_sequence_mode") if mode not in VALID_ENUMS["template_sequence_mode"]: errors.append("Invalid template sequence mode") if step == 5: mode = data.get("attach_mode", "none") if mode not in VALID_ENUMS["attach_mode"]: errors.append("Invalid attachment mode") elif mode != "none" and not (data.get("attach_pdf_template_id") or data.get("attachment_id")): errors.append("Select a template/attachment for PDF or file mode") if step == 6: vc = data.get("variable_config") or {} try: if "amount_min" in vc and "amount_max" in vc: if float(vc["amount_min"]) > float(vc["amount_max"]): errors.append("Amount min must be <= max") if "shipping_min" in vc and "shipping_max" in vc: if float(vc["shipping_min"]) > float(vc["shipping_max"]): errors.append("Shipping min must be <= max") except (TypeError, ValueError): errors.append("Ranges must be numeric") if step == 7: if not data.get("account_ids"): errors.append("Select at least one Gmail account") elif len(data["account_ids"]) > cfg.MAX_GMAIL_ACCOUNTS_PER_CAMPAIGN: errors.append(f"Max {cfg.MAX_GMAIL_ACCOUNTS_PER_CAMPAIGN} accounts") if step == 8: strategy = data.get("rotation_strategy") if strategy not in VALID_ENUMS["rotation_strategy"]: errors.append("Invalid rotation strategy") if strategy == "weighted": weights = data.get("rotation_weights") or {} if not weights: errors.append("Weighted rotation requires weights per account") if step == 9: try: mpa = int(data.get("max_per_account", 200)) if not (1 <= mpa <= 2000): errors.append("max_per_account must be 1-2000") except (TypeError, ValueError): errors.append("max_per_account must be an integer") try: si = float(data.get("send_interval_seconds", 1.0)) if not (0.5 <= si <= 3600): errors.append("send_interval_seconds must be 0.5-3600") except (TypeError, ValueError): errors.append("send_interval_seconds must be numeric") if step == 10: mode = data.get("phone_selection") if mode not in VALID_ENUMS["phone_selection"]: errors.append("Invalid phone selection mode") if step == 11: if not data.get("unsubscribe_enabled", True): errors.append("Unsubscribe handling must stay enabled (compliance requirement)") return {"step": step, "valid": not errors, "errors": errors} ===NAV 158:const NAV = [ 160: { id: "index", ico: "◧", label: "Dashboard", href: "/" }, 161: { id: "campaigns", ico: "✦", label: "Campaigns", href: "/campaigns" }, 163: { id: "gmail-accounts", ico: "✉", label: "Gmail Accounts", href: "/gmail-accounts" }, 164: { id: "recipients", ico: "☰", label: "Recipients", href: "/recipients" }, 165: { id: "send-test", ico: "➤", label: "Send Test", href: "/send-test" }, 166: { id: "logs", ico: "≣", label: "Logs", href: "/logs" }, 168: { id: "subjects", ico: "T", label: "Subject Lines", href: "/subjects" }, 169: { id: "templates", ico: "❑", label: "HTML Templates", href: "/templates" }, 170: { id: "saved-templates", ico: "❒", label: "Saved Templates", href: "/saved-templates" }, 171: { id: "attachments", ico: "◫", label: "Attachments", href: "/attachments" }, 172: { id: "phones", ico: "☏", label: "Company Phones", href: "/phones" }, 174: { id: "suppression", ico: "⊘", label: "Suppression List", href: "/suppression" }, 175: { id: "settings", ico: "⚙", label: "Settings", href: "/settings" }, 191: nav.append(el("a", { class: "nav-link" + (active ? " active" : ""), href: item.href },