v4.0.0.2
This commit is contained in:
+410
-1
@@ -36,6 +36,7 @@ from mail_api.config import (
|
||||
CLIENT_PROFILES_FILE as _CLIENT_PROFILES_FILE,
|
||||
CORS_ALLOWED_ORIGINS,
|
||||
CP_ADMIN_EMAILS,
|
||||
DEPLOY_SMOKE_SECRET,
|
||||
DEV_MODE,
|
||||
DRAFTS_FILE as _DRAFTS_FILE,
|
||||
EMAIL_SEND_TIMEOUT_SECONDS,
|
||||
@@ -43,6 +44,7 @@ from mail_api.config import (
|
||||
FORM_MAX_SECONDS,
|
||||
FORM_MIN_SECONDS,
|
||||
FROM_EMAIL,
|
||||
LEGACY_SEED_FILE as _LEGACY_SEED_FILE,
|
||||
LOGO_URL,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_SEND_ATTEMPTS,
|
||||
@@ -62,6 +64,7 @@ from mail_api.models import (
|
||||
BirthdayAutoSendRequest,
|
||||
BirthdayEmailRequest,
|
||||
BookingSubmission,
|
||||
ClientStatusUpdate,
|
||||
ContractSubmission,
|
||||
OnboardingSubmission,
|
||||
RenderMessageRequest,
|
||||
@@ -227,6 +230,70 @@ async def _seed_admin_state_from_json_if_needed() -> None:
|
||||
logger.warning("Admin seed from JSON failed: %s", exc)
|
||||
|
||||
|
||||
async def _merge_legacy_seed_if_present() -> None:
|
||||
"""Merge the shipped legacy-clients-seed.json into _client_profiles.
|
||||
|
||||
Add-only: never overwrites an email that already exists in the live data.
|
||||
Idempotent: re-running on every boot is a no-op once the entries are in.
|
||||
|
||||
Writes the updated profiles back to the JSON file + admin_kv so the merged
|
||||
state survives container restarts.
|
||||
"""
|
||||
global _client_profiles
|
||||
|
||||
seed_path = _LEGACY_SEED_FILE
|
||||
if not seed_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
seed = json.loads(seed_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("Legacy seed file unreadable (%s): %s", seed_path, exc)
|
||||
return
|
||||
|
||||
if not isinstance(seed, dict) or not seed:
|
||||
return
|
||||
|
||||
added: list[str] = []
|
||||
skipped_existing = 0
|
||||
|
||||
for raw_email, profile in seed.items():
|
||||
if not isinstance(raw_email, str) or not isinstance(profile, dict):
|
||||
continue
|
||||
email = raw_email.strip().lower()
|
||||
if not email:
|
||||
continue
|
||||
if email == OWNER_EMAIL.strip().lower():
|
||||
continue
|
||||
if email in _client_profiles:
|
||||
skipped_existing += 1
|
||||
continue
|
||||
_client_profiles[email] = profile
|
||||
added.append(email)
|
||||
|
||||
if not added:
|
||||
logger.info(
|
||||
"Legacy seed already merged (existing=%d, candidates=%d).",
|
||||
skipped_existing, len(seed),
|
||||
)
|
||||
return
|
||||
|
||||
snapshot = dict(_client_profiles)
|
||||
try:
|
||||
await asyncio.to_thread(_save_client_profiles_file, snapshot)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not save client_profiles after legacy merge: %s", exc)
|
||||
try:
|
||||
await _persist_admin_state("client_profiles", snapshot)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist client_profiles to postgres after legacy merge: %s", exc)
|
||||
|
||||
logger.info(
|
||||
"Legacy seed merged: added=%d skipped_existing=%d total_after=%d",
|
||||
len(added), skipped_existing, len(_client_profiles),
|
||||
)
|
||||
|
||||
|
||||
async def _load_allowed_emails_async() -> set[str]:
|
||||
if admin_db.is_enabled():
|
||||
data = await admin_db.get_kv("allowed_emails")
|
||||
@@ -308,6 +375,16 @@ async def _register_email(email: str) -> None:
|
||||
logger.info("Auth: registered new allowed email: %s", normalized)
|
||||
|
||||
|
||||
def _client_is_reachable(profile: dict) -> bool:
|
||||
"""True if outreach (welcome pack, birthday email, etc.) should still target
|
||||
this client. Excludes lifecycle states that mean the relationship has ended.
|
||||
"""
|
||||
lifecycle = profile.get("lifecycle")
|
||||
if not isinstance(lifecycle, dict):
|
||||
return True
|
||||
return lifecycle.get("status") not in {"cancelled", "archived"}
|
||||
|
||||
|
||||
async def _store_client_profile(email: str, profile: dict) -> None:
|
||||
normalized = email.strip().lower()
|
||||
if not normalized:
|
||||
@@ -458,6 +535,21 @@ def _get_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _is_deploy_smoke(request: Request) -> bool:
|
||||
"""True when the request carries a matching X-Deploy-Smoke header.
|
||||
|
||||
Used by the deploy script to verify the form endpoints are reachable and
|
||||
parse a valid payload, without producing a real submission. Disabled
|
||||
entirely when DEPLOY_SMOKE_SECRET is unset.
|
||||
"""
|
||||
if not DEPLOY_SMOKE_SECRET:
|
||||
return False
|
||||
presented = request.headers.get("x-deploy-smoke") or ""
|
||||
if not presented:
|
||||
return False
|
||||
return secrets.compare_digest(presented, DEPLOY_SMOKE_SECRET)
|
||||
|
||||
|
||||
_submit_attempts_by_ip: dict[str, deque[float]] = {}
|
||||
_submit_attempts_by_email: dict[str, deque[float]] = {}
|
||||
_submit_rate_limit_lock = asyncio.Lock()
|
||||
@@ -1477,6 +1569,213 @@ def _birthday_ics_attachment(dog_name: str, dog_birth_date: str, owner_name: str
|
||||
}
|
||||
|
||||
|
||||
def _pdf_escape(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\n", "<br>")
|
||||
)
|
||||
|
||||
|
||||
def owner_onboarding_pdf_html(data: OnboardingSubmission) -> str:
|
||||
"""Clean, full-width, black-and-white, Arial HTML for the printable onboarding PDF."""
|
||||
submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||||
|
||||
snapshot = data.submissionSnapshot or {}
|
||||
sections = snapshot.get("sections") if isinstance(snapshot, dict) else None
|
||||
|
||||
def render_value(value: Any) -> str:
|
||||
if isinstance(value, list):
|
||||
items = [_pdf_escape(item) for item in value if str(item).strip()]
|
||||
return ", ".join(items) if items else "—"
|
||||
text = _pdf_escape(value).strip()
|
||||
return text if text else "—"
|
||||
|
||||
sections_html_parts: list[str] = []
|
||||
|
||||
if isinstance(sections, list) and sections:
|
||||
for section in sections:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
title = _pdf_escape(section.get("title", ""))
|
||||
fields = section.get("fields") or []
|
||||
rows_html = ""
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
label = _pdf_escape(field.get("label", ""))
|
||||
rows_html += (
|
||||
"<tr>"
|
||||
f"<th>{label}</th>"
|
||||
f"<td>{render_value(field.get('value'))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
if rows_html:
|
||||
sections_html_parts.append(
|
||||
f"<section class='pdf-section'>"
|
||||
f"<h2>{title}</h2>"
|
||||
f"<table class='pdf-table'><tbody>{rows_html}</tbody></table>"
|
||||
f"</section>"
|
||||
)
|
||||
else:
|
||||
# Fallback if snapshot is missing — render the core fields directly.
|
||||
def row(label: str, value: Any) -> str:
|
||||
return f"<tr><th>{_pdf_escape(label)}</th><td>{render_value(value)}</td></tr>"
|
||||
|
||||
owner_rows = (
|
||||
row("Name", data.fullName)
|
||||
+ row("Email", str(data.email))
|
||||
+ row("Phone", data.phone)
|
||||
+ row("Address", data.address)
|
||||
)
|
||||
dog_rows = (
|
||||
row("Dog", data.dogName)
|
||||
+ row("Breed", data.dogBreed)
|
||||
+ row("Date of birth", data.dogAge or "")
|
||||
+ row("Services", data.servicesNeeded)
|
||||
+ row("Temperament / routine", data.temperament)
|
||||
+ row("Medical notes", data.medicalNotes)
|
||||
+ row("Home access", data.accessInstructions)
|
||||
)
|
||||
safety_rows = (
|
||||
row("Vet clinic", data.vetName)
|
||||
+ row("Vet phone", data.vetPhone)
|
||||
+ row("Emergency contact", data.emergencyContactName)
|
||||
+ row("Emergency phone", data.emergencyContactPhone)
|
||||
+ row("Council registration", "Confirmed" if data.councilRegistrationConfirmed else "Not confirmed")
|
||||
+ row("Vaccinations", "Confirmed" if data.vaccinationsConfirmed else "Not confirmed")
|
||||
+ row("Emergency vet consent", "Confirmed" if data.emergencyVetConsent else "Not confirmed")
|
||||
+ row("Declaration", "Signed" if data.termsAccepted else "Not signed")
|
||||
)
|
||||
sections_html_parts.append(
|
||||
f"<section class='pdf-section'><h2>Owner Details</h2><table class='pdf-table'><tbody>{owner_rows}</tbody></table></section>"
|
||||
f"<section class='pdf-section'><h2>Dog Details</h2><table class='pdf-table'><tbody>{dog_rows}</tbody></table></section>"
|
||||
f"<section class='pdf-section'><h2>Safety</h2><table class='pdf-table'><tbody>{safety_rows}</tbody></table></section>"
|
||||
)
|
||||
|
||||
signature_html = ""
|
||||
if data.signatureDataUrl:
|
||||
signature_html = (
|
||||
"<section class='pdf-section pdf-signature'>"
|
||||
"<h2>Signature</h2>"
|
||||
f"<img src='{data.signatureDataUrl}' alt='Client signature'>"
|
||||
f"<div class='pdf-signed-line'>Signed by {_pdf_escape(data.fullName)} on {_pdf_escape(submitted_at)}</div>"
|
||||
"</section>"
|
||||
)
|
||||
|
||||
body_html = "".join(sections_html_parts) + signature_html
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Goodwalk onboarding form — {_pdf_escape(data.fullName)}</title>
|
||||
<style>
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 14mm 12mm 14mm 12mm;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
html, body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.4;
|
||||
}}
|
||||
.pdf-doc {{ width: 100%; }}
|
||||
.pdf-header {{
|
||||
width: 100%;
|
||||
border-bottom: 1.5pt solid #000;
|
||||
padding-bottom: 8pt;
|
||||
margin-bottom: 14pt;
|
||||
}}
|
||||
.pdf-header h1 {{
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4pt 0;
|
||||
letter-spacing: 0.5pt;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.pdf-header .pdf-meta {{
|
||||
font-size: 9.5pt;
|
||||
color: #000;
|
||||
}}
|
||||
.pdf-section {{
|
||||
width: 100%;
|
||||
margin: 0 0 14pt 0;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
.pdf-section h2 {{
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6pt;
|
||||
margin: 0 0 6pt 0;
|
||||
padding: 0 0 3pt 0;
|
||||
border-bottom: 0.75pt solid #000;
|
||||
}}
|
||||
table.pdf-table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}}
|
||||
table.pdf-table th,
|
||||
table.pdf-table td {{
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 5pt 8pt;
|
||||
border-bottom: 0.4pt solid #000;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}}
|
||||
table.pdf-table th {{
|
||||
width: 34%;
|
||||
font-weight: 700;
|
||||
background: #ffffff;
|
||||
}}
|
||||
table.pdf-table td {{
|
||||
width: 66%;
|
||||
font-weight: 400;
|
||||
}}
|
||||
.pdf-signature img {{
|
||||
display: block;
|
||||
max-width: 70%;
|
||||
max-height: 60mm;
|
||||
height: auto;
|
||||
border: 0.5pt solid #000;
|
||||
padding: 4pt;
|
||||
margin: 4pt 0 6pt 0;
|
||||
background: #fff;
|
||||
}}
|
||||
.pdf-signed-line {{
|
||||
font-size: 9.5pt;
|
||||
border-top: 0.4pt solid #000;
|
||||
padding-top: 4pt;
|
||||
margin-top: 4pt;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pdf-doc">
|
||||
<header class="pdf-header">
|
||||
<h1>Goodwalk Onboarding Form</h1>
|
||||
<div class="pdf-meta">
|
||||
<strong>{_pdf_escape(data.fullName)}</strong> · {_pdf_escape(data.dogName)} · Submitted {_pdf_escape(submitted_at)}
|
||||
</div>
|
||||
</header>
|
||||
{body_html}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _render_pdf_sync(html: str) -> bytes:
|
||||
from weasyprint import HTML # imported lazily so unit tests don't require the native libs
|
||||
return HTML(string=html).write_pdf()
|
||||
@@ -1678,6 +1977,9 @@ async def _startup_mail_check() -> None:
|
||||
except Exception:
|
||||
logger.exception("Admin state refresh from postgres failed; using JSON snapshot")
|
||||
|
||||
# 3. Merge any shipped legacy seed (add-only — never clobbers live entries).
|
||||
await _merge_legacy_seed_if_present()
|
||||
|
||||
try:
|
||||
await _send_startup_test_email()
|
||||
except Exception:
|
||||
@@ -2728,6 +3030,42 @@ async def owner_render_message(data: RenderMessageRequest, request: Request):
|
||||
return {"ok": True, "html": html}
|
||||
|
||||
|
||||
@app.post("/owner/render-welcome-pack")
|
||||
async def owner_render_welcome_pack(data: WelcomePackEmailRequest, request: Request):
|
||||
"""Render the welcome pack email as HTML for in-modal preview."""
|
||||
await _require_owner_email(request)
|
||||
|
||||
email = str(data.email).strip().lower()
|
||||
profile = _client_profiles.get(email, {})
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
|
||||
html = _welcome_pack_email_html(
|
||||
owner_name,
|
||||
dog_name,
|
||||
_trimmed(data.serviceType),
|
||||
_trimmed(data.priceDetails),
|
||||
_trimmed(data.startDate),
|
||||
)
|
||||
return {"ok": True, "html": html}
|
||||
|
||||
|
||||
@app.post("/owner/render-birthday-email")
|
||||
async def owner_render_birthday_email(data: BirthdayEmailRequest, request: Request):
|
||||
"""Render the birthday email as HTML for in-modal preview."""
|
||||
await _require_owner_email(request)
|
||||
|
||||
email = str(data.email).strip().lower()
|
||||
profile = _client_profiles.get(email, {})
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Client profile not found.")
|
||||
|
||||
owner_name = str(profile.get("fullName", "")).strip()
|
||||
dog_name = str(profile.get("dogName", "")).strip()
|
||||
html = _birthday_email_html(owner_name, dog_name)
|
||||
return {"ok": True, "html": html}
|
||||
|
||||
|
||||
@app.post("/owner/send-message")
|
||||
async def owner_send_message(data: SendMessageRequest, request: Request):
|
||||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||
@@ -2896,6 +3234,8 @@ async def owner_pending_onboarding(request: Request):
|
||||
continue
|
||||
if profile.get("onboardingCompleted"):
|
||||
continue
|
||||
if not _client_is_reachable(profile):
|
||||
continue
|
||||
|
||||
pending_clients.append({
|
||||
"email": email,
|
||||
@@ -2947,6 +3287,8 @@ async def owner_completed_onboarding(request: Request):
|
||||
continue
|
||||
if not profile.get("onboardingCompleted"):
|
||||
continue
|
||||
if not _client_is_reachable(profile):
|
||||
continue
|
||||
|
||||
completed_clients.append({
|
||||
"email": email,
|
||||
@@ -3011,6 +3353,7 @@ async def owner_all_clients(request: Request):
|
||||
if email == OWNER_EMAIL.strip().lower():
|
||||
continue
|
||||
|
||||
lifecycle = profile.get("lifecycle") if isinstance(profile.get("lifecycle"), dict) else None
|
||||
clients.append({
|
||||
"email": email,
|
||||
"fullName": profile.get("fullName", ""),
|
||||
@@ -3018,6 +3361,7 @@ async def owner_all_clients(request: Request):
|
||||
"dogName": profile.get("dogName", ""),
|
||||
"dogBreed": profile.get("dogBreed", ""),
|
||||
"status": "completed" if profile.get("onboardingCompleted") else "pending",
|
||||
"lifecycle": lifecycle or {"status": "active", "reason": "", "changedAt": "", "changedBy": ""},
|
||||
"lastActivityAt": profile.get("onboardingSubmittedAt", "") or profile.get("lastEnquiryAt", "") or profile.get("welcomePackSentAt", ""),
|
||||
"welcomePackSentAt": profile.get("welcomePackSentAt", ""),
|
||||
})
|
||||
@@ -3068,6 +3412,8 @@ async def owner_birthdays(request: Request):
|
||||
continue
|
||||
if not profile.get("onboardingCompleted"):
|
||||
continue
|
||||
if not _client_is_reachable(profile):
|
||||
continue
|
||||
|
||||
upcoming = _upcoming_birthday_date(str(profile.get("dogAge", "")), today)
|
||||
if not upcoming:
|
||||
@@ -3255,6 +3601,56 @@ async def owner_send_birthday_email(data: BirthdayEmailRequest, request: Request
|
||||
return {"ok": True, "sentAt": datetime.now().isoformat(timespec="seconds"), "preview": bool(data.preview)}
|
||||
|
||||
|
||||
@app.post("/owner/client-status")
|
||||
async def owner_client_status(data: ClientStatusUpdate, request: Request):
|
||||
"""Set a client's lifecycle status (active / paused / cancelled / archived).
|
||||
|
||||
Soft-delete only: no client record is ever removed. Each change is recorded
|
||||
in the profile's lifecycleHistory list and the global activity feed.
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8])
|
||||
owner_email = await _require_owner_email(request)
|
||||
|
||||
email = str(data.email).strip().lower()
|
||||
profile = _client_profiles.get(email)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
|
||||
reason = (data.reason or "").strip()[:500]
|
||||
now_iso = datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
existing_history = profile.get("lifecycleHistory")
|
||||
history: list[dict[str, Any]] = list(existing_history) if isinstance(existing_history, list) else []
|
||||
history.append({
|
||||
"status": data.status,
|
||||
"reason": reason,
|
||||
"changedAt": now_iso,
|
||||
"changedBy": owner_email,
|
||||
})
|
||||
# Cap history to a sensible size so the JSON file doesn't grow unbounded.
|
||||
history = history[-50:]
|
||||
|
||||
lifecycle = {
|
||||
"status": data.status,
|
||||
"reason": reason,
|
||||
"changedAt": now_iso,
|
||||
"changedBy": owner_email,
|
||||
}
|
||||
|
||||
await _store_client_profile(email, {
|
||||
"lifecycle": lifecycle,
|
||||
"lifecycleHistory": history,
|
||||
})
|
||||
|
||||
await admin_db.record_event(
|
||||
event_type="owner_client_status_changed",
|
||||
request_id=request_id, actor_email=owner_email, ip=_get_ip(request), status="ok",
|
||||
detail={"clientEmail": email, "status": data.status, "reason": reason},
|
||||
)
|
||||
logger.info("[%s] owner: %s set %s -> %s", request_id, owner_email, email, data.status)
|
||||
return {"ok": True, "email": email, "lifecycle": lifecycle}
|
||||
|
||||
|
||||
@app.post("/owner/birthday-auto-send")
|
||||
async def owner_birthday_auto_send(data: BirthdayAutoSendRequest, request: Request):
|
||||
owner_email = await _require_owner_email(request)
|
||||
@@ -3285,6 +3681,10 @@ async def submit_booking(data: BookingSubmission, request: Request):
|
||||
ip = _get_ip(request)
|
||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||||
|
||||
if _is_deploy_smoke(request):
|
||||
logger.info("[%s] /submit deploy-smoke bypass (no email, no db write)", request_id)
|
||||
return {"ok": True, "request_id": request_id, "smoke": True}
|
||||
|
||||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||||
_enforce_form_timing(request_id, data)
|
||||
|
||||
@@ -3625,6 +4025,10 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request):
|
||||
ip = _get_ip(request)
|
||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||||
|
||||
if _is_deploy_smoke(request):
|
||||
logger.info("[%s] /onboarding-submit deploy-smoke bypass (no email, no db write)", request_id)
|
||||
return {"ok": True, "request_id": request_id, "smoke": True}
|
||||
|
||||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||||
_enforce_form_timing(request_id, data)
|
||||
|
||||
@@ -3670,7 +4074,8 @@ async def submit_onboarding(data: OnboardingSubmission, request: Request):
|
||||
if birthday_attachment:
|
||||
attachments.append(birthday_attachment)
|
||||
if ONBOARDING_PDF_ATTACHMENT_ENABLED:
|
||||
pdf_attachment = await _signed_form_pdf_attachment(owner_html, data.fullName, "onboarding", request_id)
|
||||
pdf_html = owner_onboarding_pdf_html(data)
|
||||
pdf_attachment = await _signed_form_pdf_attachment(pdf_html, data.fullName, "onboarding", request_id)
|
||||
if pdf_attachment:
|
||||
attachments.append(pdf_attachment)
|
||||
if attachments:
|
||||
@@ -3755,6 +4160,10 @@ async def submit_contract(data: ContractSubmission, request: Request):
|
||||
ip = _get_ip(request)
|
||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||||
|
||||
if _is_deploy_smoke(request):
|
||||
logger.info("[%s] /contract-submit deploy-smoke bypass (no email, no db write)", request_id)
|
||||
return {"ok": True, "request_id": request_id, "smoke": True}
|
||||
|
||||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||||
_enforce_form_timing(request_id, data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user