feat: email i18n + Resend audience restructuring
Add lang parameter to all enqueue() calls for email internationalization. Restructure Resend audiences to 3 named audiences (owners, suppliers, waitlist). Use _t() translation function in all email template handlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Auth domain: magic link authentication, user management, decorators.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
@@ -47,19 +48,16 @@ async def pull_auth_lang() -> None:
|
||||
# SQL Queries
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_user_by_id(user_id: int) -> dict | None:
|
||||
"""Get user by ID."""
|
||||
return await fetch_one(
|
||||
"SELECT * FROM users WHERE id = ? AND deleted_at IS NULL",
|
||||
(user_id,)
|
||||
)
|
||||
return await fetch_one("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,))
|
||||
|
||||
|
||||
async def get_user_by_email(email: str) -> dict | None:
|
||||
"""Get user by email."""
|
||||
return await fetch_one(
|
||||
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL",
|
||||
(email.lower(),)
|
||||
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", (email.lower(),)
|
||||
)
|
||||
|
||||
|
||||
@@ -67,8 +65,7 @@ async def create_user(email: str) -> int:
|
||||
"""Create new user, return ID."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
return await execute(
|
||||
"INSERT INTO users (email, created_at) VALUES (?, ?)",
|
||||
(email.lower(), now)
|
||||
"INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now)
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +84,7 @@ async def create_auth_token(user_id: int, token: str, minutes: int = None) -> in
|
||||
expires = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
return await execute(
|
||||
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
||||
(user_id, token, expires.isoformat())
|
||||
(user_id, token, expires.isoformat()),
|
||||
)
|
||||
|
||||
|
||||
@@ -100,15 +97,14 @@ async def get_valid_token(token: str) -> dict | None:
|
||||
JOIN users u ON u.id = at.user_id
|
||||
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
|
||||
""",
|
||||
(token, datetime.utcnow().isoformat())
|
||||
(token, datetime.utcnow().isoformat()),
|
||||
)
|
||||
|
||||
|
||||
async def mark_token_used(token_id: int) -> None:
|
||||
"""Mark token as used."""
|
||||
await execute(
|
||||
"UPDATE auth_tokens SET used_at = ? WHERE id = ?",
|
||||
(datetime.utcnow().isoformat(), token_id)
|
||||
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -116,19 +112,23 @@ async def mark_token_used(token_id: int) -> None:
|
||||
# Decorators
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Require authenticated user."""
|
||||
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if not g.get("user"):
|
||||
await flash("Please sign in to continue.", "warning")
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""Require user to have at least one of the given roles."""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
@@ -140,7 +140,9 @@ def role_required(*roles):
|
||||
await flash("You don't have permission to access that page.", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -174,6 +176,7 @@ def subscription_required(
|
||||
|
||||
Reads from g.subscription (eager-loaded in load_user) — zero extra queries.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
@@ -191,7 +194,9 @@ def subscription_required(
|
||||
return redirect(url_for("billing.pricing"))
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -199,6 +204,7 @@ def subscription_required(
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
async def login():
|
||||
@@ -231,6 +237,7 @@ async def login():
|
||||
|
||||
# Queue email
|
||||
from ..worker import enqueue
|
||||
|
||||
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
|
||||
|
||||
await flash(_t["auth_flash_login_sent"], "success")
|
||||
@@ -292,6 +299,7 @@ async def signup():
|
||||
|
||||
# Queue emails
|
||||
from ..worker import enqueue
|
||||
|
||||
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
|
||||
await enqueue("send_welcome", {"email": email, "lang": g.lang})
|
||||
|
||||
@@ -397,6 +405,7 @@ async def resend():
|
||||
await create_auth_token(user["id"], token)
|
||||
|
||||
from ..worker import enqueue
|
||||
|
||||
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
|
||||
|
||||
# Always show success (don't reveal if email exists)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core infrastructure: database, config, email, and shared utilities.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
@@ -30,6 +31,7 @@ def _env(key: str, default: str) -> str:
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Config:
|
||||
APP_NAME: str = _env("APP_NAME", "Padelnomics")
|
||||
SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production")
|
||||
@@ -166,6 +168,7 @@ class transaction:
|
||||
await self.db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Email
|
||||
# =============================================================================
|
||||
@@ -181,40 +184,87 @@ EMAIL_ADDRESSES = {
|
||||
# Input validation helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
|
||||
_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset(
|
||||
{
|
||||
# Germany / Austria / Switzerland common disposables
|
||||
"byom.de", "trash-mail.de", "spamgourmet.de", "mailnull.com",
|
||||
"spambog.de", "trashmail.de", "wegwerf-email.de", "spam4.me",
|
||||
"byom.de",
|
||||
"trash-mail.de",
|
||||
"spamgourmet.de",
|
||||
"mailnull.com",
|
||||
"spambog.de",
|
||||
"trashmail.de",
|
||||
"wegwerf-email.de",
|
||||
"spam4.me",
|
||||
"yopmail.de",
|
||||
# Global well-known disposables
|
||||
"guerrillamail.com", "guerrillamail.net", "guerrillamail.org",
|
||||
"guerrillamail.biz", "guerrillamail.de", "guerrillamail.info",
|
||||
"guerrillamailblock.com", "grr.la", "spam4.me",
|
||||
"mailinator.com", "mailinator.net", "mailinator.org",
|
||||
"tempmail.com", "temp-mail.org", "tempmail.net", "tempmail.io",
|
||||
"10minutemail.com", "10minutemail.net", "10minutemail.org",
|
||||
"10minemail.com", "10minutemail.de",
|
||||
"yopmail.com", "yopmail.fr", "yopmail.net",
|
||||
"sharklasers.com", "guerrillamail.info", "grr.la",
|
||||
"throwam.com", "throwam.net",
|
||||
"maildrop.cc", "dispostable.com",
|
||||
"discard.email", "discardmail.com", "discardmail.de",
|
||||
"spamgourmet.com", "spamgourmet.net",
|
||||
"trashmail.at", "trashmail.com", "trashmail.io",
|
||||
"trashmail.me", "trashmail.net", "trashmail.org",
|
||||
"trash-mail.at", "trash-mail.com",
|
||||
"fakeinbox.com", "fakemail.fr", "fakemail.net",
|
||||
"getnada.com", "getairmail.com",
|
||||
"bccto.me", "chacuo.net",
|
||||
"crapmail.org", "crap.email",
|
||||
"spamherelots.com", "spamhereplease.com",
|
||||
"throwam.com", "throwam.net",
|
||||
"spamspot.com", "spamthisplease.com",
|
||||
"guerrillamail.com",
|
||||
"guerrillamail.net",
|
||||
"guerrillamail.org",
|
||||
"guerrillamail.biz",
|
||||
"guerrillamail.de",
|
||||
"guerrillamail.info",
|
||||
"guerrillamailblock.com",
|
||||
"grr.la",
|
||||
"spam4.me",
|
||||
"mailinator.com",
|
||||
"mailinator.net",
|
||||
"mailinator.org",
|
||||
"tempmail.com",
|
||||
"temp-mail.org",
|
||||
"tempmail.net",
|
||||
"tempmail.io",
|
||||
"10minutemail.com",
|
||||
"10minutemail.net",
|
||||
"10minutemail.org",
|
||||
"10minemail.com",
|
||||
"10minutemail.de",
|
||||
"yopmail.com",
|
||||
"yopmail.fr",
|
||||
"yopmail.net",
|
||||
"sharklasers.com",
|
||||
"guerrillamail.info",
|
||||
"grr.la",
|
||||
"throwam.com",
|
||||
"throwam.net",
|
||||
"maildrop.cc",
|
||||
"dispostable.com",
|
||||
"discard.email",
|
||||
"discardmail.com",
|
||||
"discardmail.de",
|
||||
"spamgourmet.com",
|
||||
"spamgourmet.net",
|
||||
"trashmail.at",
|
||||
"trashmail.com",
|
||||
"trashmail.io",
|
||||
"trashmail.me",
|
||||
"trashmail.net",
|
||||
"trashmail.org",
|
||||
"trash-mail.at",
|
||||
"trash-mail.com",
|
||||
"fakeinbox.com",
|
||||
"fakemail.fr",
|
||||
"fakemail.net",
|
||||
"getnada.com",
|
||||
"getairmail.com",
|
||||
"bccto.me",
|
||||
"chacuo.net",
|
||||
"crapmail.org",
|
||||
"crap.email",
|
||||
"spamherelots.com",
|
||||
"spamhereplease.com",
|
||||
"throwam.com",
|
||||
"throwam.net",
|
||||
"spamspot.com",
|
||||
"spamthisplease.com",
|
||||
"filzmail.com",
|
||||
"mytemp.email", "mynullmail.com",
|
||||
"mailnesia.com", "mailnull.com",
|
||||
"no-spam.ws", "noblepioneer.com",
|
||||
"nospam.ze.tc", "nospam4.us",
|
||||
"mytemp.email",
|
||||
"mynullmail.com",
|
||||
"mailnesia.com",
|
||||
"mailnull.com",
|
||||
"no-spam.ws",
|
||||
"noblepioneer.com",
|
||||
"nospam.ze.tc",
|
||||
"nospam4.us",
|
||||
"owlpic.com",
|
||||
"pookmail.com",
|
||||
"poof.email",
|
||||
@@ -226,7 +276,9 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
|
||||
"shitmail.me",
|
||||
"smellfear.com",
|
||||
"spamavert.com",
|
||||
"spambog.com", "spambog.net", "spambog.ru",
|
||||
"spambog.com",
|
||||
"spambog.net",
|
||||
"spambog.ru",
|
||||
"spamgob.com",
|
||||
"spamherelots.com",
|
||||
"spamslicer.com",
|
||||
@@ -237,14 +289,17 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
|
||||
"throwam.com",
|
||||
"tilien.com",
|
||||
"tmailinator.com",
|
||||
"trashdevil.com", "trashdevil.de",
|
||||
"trashdevil.com",
|
||||
"trashdevil.de",
|
||||
"trbvm.com",
|
||||
"turual.com",
|
||||
"uggsrock.com",
|
||||
"viditag.com",
|
||||
"vomoto.com",
|
||||
"vpn.st",
|
||||
"wegwerfemail.de", "wegwerfemail.net", "wegwerfemail.org",
|
||||
"wegwerfemail.de",
|
||||
"wegwerfemail.net",
|
||||
"wegwerfemail.org",
|
||||
"wetrainbayarea.com",
|
||||
"willhackforfood.biz",
|
||||
"wuzupmail.net",
|
||||
@@ -255,7 +310,8 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
|
||||
"yogamaven.com",
|
||||
"z1p.biz",
|
||||
"zoemail.org",
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_disposable_email(email: str) -> bool:
|
||||
@@ -299,22 +355,26 @@ async def send_email(
|
||||
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
try:
|
||||
resend.Emails.send({
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": from_addr or config.EMAIL_FROM,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
"text": text or html,
|
||||
})
|
||||
}
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[EMAIL] Error sending to {to}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Waitlist
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _get_or_create_resend_audience(name: str) -> str | None:
|
||||
"""Get cached Resend audience ID, or create one via API. Returns None on failure."""
|
||||
row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,))
|
||||
@@ -349,7 +409,9 @@ def _audience_for_blueprint(blueprint: str) -> str:
|
||||
return _BLUEPRINT_TO_AUDIENCE.get(blueprint, "newsletter")
|
||||
|
||||
|
||||
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
|
||||
async def capture_waitlist_email(
|
||||
email: str, intent: str, plan: str = None, email_intent: str = None
|
||||
) -> bool:
|
||||
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
|
||||
|
||||
Args:
|
||||
@@ -365,7 +427,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
|
||||
try:
|
||||
cursor_result = await execute(
|
||||
"INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)",
|
||||
(email, intent, plan, request.remote_addr)
|
||||
(email, intent, plan, request.remote_addr),
|
||||
)
|
||||
is_new = cursor_result > 0
|
||||
except Exception:
|
||||
@@ -375,6 +437,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
|
||||
# Enqueue confirmation email only if new
|
||||
if is_new:
|
||||
from .worker import enqueue
|
||||
|
||||
email_intent_value = email_intent if email_intent is not None else intent
|
||||
lang = g.get("lang", "en") if g else "en"
|
||||
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang})
|
||||
@@ -394,10 +457,12 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
|
||||
|
||||
return is_new
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CSRF Protection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_csrf_token() -> str:
|
||||
"""Get or create CSRF token for current session."""
|
||||
if "csrf_token" not in session:
|
||||
@@ -412,6 +477,7 @@ def validate_csrf_token(token: str) -> bool:
|
||||
|
||||
def csrf_protect(f):
|
||||
"""Decorator to require valid CSRF token for POST requests."""
|
||||
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if request.method == "POST":
|
||||
@@ -420,12 +486,15 @@ def csrf_protect(f):
|
||||
if not validate_csrf_token(token):
|
||||
return {"error": "Invalid CSRF token"}, 403
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rate Limiting (SQLite-based)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
|
||||
"""
|
||||
Check if rate limit exceeded. Returns (is_allowed, info).
|
||||
@@ -438,13 +507,12 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
|
||||
|
||||
# Clean old entries and count recent
|
||||
await execute(
|
||||
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
|
||||
(key, window_start.isoformat())
|
||||
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat())
|
||||
)
|
||||
|
||||
result = await fetch_one(
|
||||
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
|
||||
(key, window_start.isoformat())
|
||||
(key, window_start.isoformat()),
|
||||
)
|
||||
count = result["count"] if result else 0
|
||||
|
||||
@@ -458,16 +526,14 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
|
||||
return False, info
|
||||
|
||||
# Record this request
|
||||
await execute(
|
||||
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
|
||||
(key, now.isoformat())
|
||||
)
|
||||
await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat()))
|
||||
|
||||
return True, info
|
||||
|
||||
|
||||
def rate_limit(limit: int = None, window: int = None, key_func=None):
|
||||
"""Decorator for rate limiting routes."""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
@@ -483,9 +549,12 @@ def rate_limit(limit: int = None, window: int = None, key_func=None):
|
||||
return response, 429
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request ID Tracking
|
||||
# =============================================================================
|
||||
@@ -500,6 +569,7 @@ def get_request_id() -> str:
|
||||
|
||||
def setup_request_id(app):
|
||||
"""Setup request ID middleware."""
|
||||
|
||||
@app.before_request
|
||||
async def set_request_id():
|
||||
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
|
||||
@@ -511,6 +581,7 @@ def setup_request_id(app):
|
||||
response.headers["X-Request-ID"] = get_request_id()
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Webhook Signature Verification
|
||||
# =============================================================================
|
||||
@@ -526,21 +597,19 @@ def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
# Soft Delete Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def soft_delete(table: str, id: int) -> bool:
|
||||
"""Mark record as deleted."""
|
||||
result = await execute(
|
||||
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), id)
|
||||
(datetime.utcnow().isoformat(), id),
|
||||
)
|
||||
return result > 0
|
||||
|
||||
|
||||
async def restore(table: str, id: int) -> bool:
|
||||
"""Restore soft-deleted record."""
|
||||
result = await execute(
|
||||
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
|
||||
(id,)
|
||||
)
|
||||
result = await execute(f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", (id,))
|
||||
return result > 0
|
||||
|
||||
|
||||
@@ -554,8 +623,7 @@ async def purge_deleted(table: str, days: int = 30) -> int:
|
||||
"""Purge records deleted more than X days ago."""
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
||||
return await execute(
|
||||
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
|
||||
(cutoff,)
|
||||
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
|
||||
)
|
||||
|
||||
|
||||
@@ -563,11 +631,10 @@ async def purge_deleted(table: str, days: int = 30) -> int:
|
||||
# Paddle Product Lookup
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_paddle_price(key: str) -> str | None:
|
||||
"""Look up a Paddle price ID by product key from the paddle_products table."""
|
||||
row = await fetch_one(
|
||||
"SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)
|
||||
)
|
||||
row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
|
||||
return row["paddle_price_id"] if row else None
|
||||
|
||||
|
||||
@@ -581,6 +648,7 @@ async def get_all_paddle_prices() -> dict[str, str]:
|
||||
# Text Utilities
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def slugify(text: str, max_length_chars: int = 80) -> str:
|
||||
"""Convert text to URL-safe slug."""
|
||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||
@@ -593,6 +661,7 @@ def slugify(text: str, max_length_chars: int = 80) -> str:
|
||||
# A/B Testing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _has_functional_consent() -> bool:
|
||||
"""Return True if the visitor has accepted functional cookies."""
|
||||
return "functional" in request.cookies.get("cookie_consent", "")
|
||||
@@ -605,6 +674,7 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
||||
cookie consent. Without consent a random variant is picked per-request
|
||||
(so the page renders fine and Umami is tagged), but no cookie is set.
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
@@ -622,7 +692,9 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
||||
if has_consent:
|
||||
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -646,6 +718,7 @@ def waitlist_gate(template: str, **extra_context):
|
||||
# POST handling and normal signup code here
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
@@ -655,5 +728,7 @@ def waitlist_gate(template: str, **extra_context):
|
||||
ctx[key] = val() if callable(val) else val
|
||||
return await render_template(template, **ctx)
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Supplier directory: public, searchable listing of padel court suppliers.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,17 +18,39 @@ bp = Blueprint(
|
||||
)
|
||||
|
||||
COUNTRY_LABELS = {
|
||||
"DE": "Germany", "ES": "Spain", "IT": "Italy", "FR": "France",
|
||||
"PT": "Portugal", "GB": "United Kingdom", "NL": "Netherlands",
|
||||
"BE": "Belgium", "SE": "Sweden", "DK": "Denmark", "FI": "Finland",
|
||||
"NO": "Norway", "AT": "Austria", "SI": "Slovenia", "IS": "Iceland",
|
||||
"CH": "Switzerland", "EE": "Estonia",
|
||||
"US": "United States", "CA": "Canada",
|
||||
"MX": "Mexico", "BR": "Brazil", "AR": "Argentina",
|
||||
"AE": "UAE", "SA": "Saudi Arabia", "TR": "Turkey",
|
||||
"CN": "China", "IN": "India", "SG": "Singapore",
|
||||
"ID": "Indonesia", "TH": "Thailand", "AU": "Australia",
|
||||
"ZA": "South Africa", "EG": "Egypt",
|
||||
"DE": "Germany",
|
||||
"ES": "Spain",
|
||||
"IT": "Italy",
|
||||
"FR": "France",
|
||||
"PT": "Portugal",
|
||||
"GB": "United Kingdom",
|
||||
"NL": "Netherlands",
|
||||
"BE": "Belgium",
|
||||
"SE": "Sweden",
|
||||
"DK": "Denmark",
|
||||
"FI": "Finland",
|
||||
"NO": "Norway",
|
||||
"AT": "Austria",
|
||||
"SI": "Slovenia",
|
||||
"IS": "Iceland",
|
||||
"CH": "Switzerland",
|
||||
"EE": "Estonia",
|
||||
"US": "United States",
|
||||
"CA": "Canada",
|
||||
"MX": "Mexico",
|
||||
"BR": "Brazil",
|
||||
"AR": "Argentina",
|
||||
"AE": "UAE",
|
||||
"SA": "Saudi Arabia",
|
||||
"TR": "Turkey",
|
||||
"CN": "China",
|
||||
"IN": "India",
|
||||
"SG": "Singapore",
|
||||
"ID": "Indonesia",
|
||||
"TH": "Thailand",
|
||||
"AU": "Australia",
|
||||
"ZA": "South Africa",
|
||||
"EG": "Egypt",
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
@@ -75,9 +98,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
|
||||
terms = [t for t in q.split() if t]
|
||||
if terms:
|
||||
fts_q = " ".join(t + "*" for t in terms)
|
||||
wheres.append(
|
||||
"s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)"
|
||||
)
|
||||
wheres.append("s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)")
|
||||
params.append(fts_q)
|
||||
|
||||
if country:
|
||||
@@ -127,6 +148,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
|
||||
tuple(supplier_ids),
|
||||
)
|
||||
import json
|
||||
|
||||
for row in color_rows:
|
||||
meta = {}
|
||||
if row["metadata"]:
|
||||
@@ -170,14 +192,11 @@ async def index():
|
||||
)
|
||||
|
||||
category_counts = await fetch_all(
|
||||
"SELECT category, COUNT(*) as cnt FROM suppliers"
|
||||
" GROUP BY category ORDER BY cnt DESC"
|
||||
"SELECT category, COUNT(*) as cnt FROM suppliers GROUP BY category ORDER BY cnt DESC"
|
||||
)
|
||||
|
||||
total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
|
||||
total_countries = await fetch_one(
|
||||
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
|
||||
)
|
||||
total_countries = await fetch_one("SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers")
|
||||
|
||||
return await render_template(
|
||||
"directory.html",
|
||||
@@ -195,6 +214,7 @@ async def supplier_detail(slug: str):
|
||||
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,))
|
||||
if not supplier:
|
||||
from quart import abort
|
||||
|
||||
abort(404)
|
||||
|
||||
# Get active boosts
|
||||
@@ -206,7 +226,9 @@ async def supplier_detail(slug: str):
|
||||
|
||||
# Parse services_offered into list
|
||||
raw_services = (supplier.get("services_offered") or "").strip()
|
||||
services_list = [s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else []
|
||||
services_list = (
|
||||
[s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else []
|
||||
)
|
||||
|
||||
# Build social links dict
|
||||
social_links = {
|
||||
@@ -250,6 +272,7 @@ async def supplier_enquiry(slug: str):
|
||||
)
|
||||
if not supplier:
|
||||
from quart import abort
|
||||
|
||||
abort(404)
|
||||
|
||||
form = await request.form
|
||||
@@ -294,7 +317,10 @@ async def supplier_enquiry(slug: str):
|
||||
# Enqueue email to supplier
|
||||
if supplier.get("contact_email"):
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_supplier_enquiry_email", {
|
||||
|
||||
await enqueue(
|
||||
"send_supplier_enquiry_email",
|
||||
{
|
||||
"supplier_id": supplier["id"],
|
||||
"supplier_name": supplier["name"],
|
||||
"supplier_email": supplier["contact_email"],
|
||||
@@ -302,7 +328,8 @@ async def supplier_enquiry(slug: str):
|
||||
"contact_email": contact_email,
|
||||
"message": message,
|
||||
"lang": g.get("lang", "en"),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"partials/enquiry_result.html",
|
||||
@@ -317,6 +344,7 @@ async def supplier_website(slug: str):
|
||||
supplier = await fetch_one("SELECT website FROM suppliers WHERE slug = ?", (slug,))
|
||||
if not supplier or not supplier["website"]:
|
||||
from quart import abort
|
||||
|
||||
abort(404)
|
||||
url = supplier["website"]
|
||||
if not url.startswith("http"):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Leads domain: capture interest in court suppliers and financing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
@@ -41,6 +42,7 @@ bp = Blueprint(
|
||||
# Heat Score Calculation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def calculate_heat_score(form: dict) -> str:
|
||||
"""Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'."""
|
||||
score = 0
|
||||
@@ -83,6 +85,7 @@ def calculate_heat_score(form: dict) -> str:
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/suppliers", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
@@ -183,7 +186,11 @@ def _get_quote_steps(lang: str) -> list:
|
||||
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
|
||||
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
||||
{"n": 8, "title": t["q8_heading"], "required": ["services_needed"]},
|
||||
{"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]},
|
||||
{
|
||||
"n": 9,
|
||||
"title": t["q9_heading"],
|
||||
"required": ["contact_name", "contact_email", "contact_phone"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -235,7 +242,9 @@ async def quote_step(step):
|
||||
if errors:
|
||||
return await render_template(
|
||||
f"partials/quote_step_{step}.html",
|
||||
data=accumulated, step=step, steps=steps,
|
||||
data=accumulated,
|
||||
step=step,
|
||||
steps=steps,
|
||||
errors=errors,
|
||||
)
|
||||
# Return next step
|
||||
@@ -244,7 +253,9 @@ async def quote_step(step):
|
||||
next_step = len(steps)
|
||||
return await render_template(
|
||||
f"partials/quote_step_{next_step}.html",
|
||||
data=accumulated, step=next_step, steps=steps,
|
||||
data=accumulated,
|
||||
step=next_step,
|
||||
steps=steps,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
@@ -252,7 +263,9 @@ async def quote_step(step):
|
||||
accumulated = _parse_accumulated(request.args)
|
||||
return await render_template(
|
||||
f"partials/quote_step_{step}.html",
|
||||
data=accumulated, step=step, steps=steps,
|
||||
data=accumulated,
|
||||
step=step,
|
||||
steps=steps,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
@@ -296,7 +309,9 @@ async def quote_request():
|
||||
if field_errors:
|
||||
if is_json:
|
||||
return jsonify({"ok": False, "errors": field_errors}), 422
|
||||
form_data = {k: v for k, v in form.items() if not k.startswith("_") and k != "csrf_token"}
|
||||
form_data = {
|
||||
k: v for k, v in form.items() if not k.startswith("_") and k != "csrf_token"
|
||||
}
|
||||
form_data["services_needed"] = services
|
||||
return await render_template(
|
||||
"quote_request.html",
|
||||
@@ -310,6 +325,7 @@ async def quote_request():
|
||||
|
||||
# Compute credit cost from heat tier
|
||||
from ..credits import HEAT_CREDIT_COSTS
|
||||
|
||||
credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
|
||||
|
||||
services_json = json.dumps(services) if services else None
|
||||
@@ -318,10 +334,7 @@ async def quote_request():
|
||||
contact_email = form.get("contact_email", "").strip().lower()
|
||||
|
||||
# Logged-in user with matching email → skip verification
|
||||
is_verified_user = (
|
||||
g.user is not None
|
||||
and g.user["email"].lower() == contact_email
|
||||
)
|
||||
is_verified_user = g.user is not None and g.user["email"].lower() == contact_email
|
||||
status = "new" if is_verified_user else "pending_verification"
|
||||
|
||||
lead_id = await execute(
|
||||
@@ -370,6 +383,7 @@ async def quote_request():
|
||||
if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY:
|
||||
try:
|
||||
import resend
|
||||
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
resend.Contacts.remove(
|
||||
audience_id=config.RESEND_AUDIENCE_PLANNER,
|
||||
@@ -423,13 +437,14 @@ async def quote_request():
|
||||
token = secrets.token_urlsafe(32)
|
||||
await create_auth_token(new_user_id, token, minutes=60)
|
||||
|
||||
lead_token_row = await fetch_one(
|
||||
"SELECT token FROM lead_requests WHERE id = ?", (lead_id,)
|
||||
)
|
||||
lead_token_row = await fetch_one("SELECT token FROM lead_requests WHERE id = ?", (lead_id,))
|
||||
lead_token = lead_token_row["token"]
|
||||
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_quote_verification", {
|
||||
|
||||
await enqueue(
|
||||
"send_quote_verification",
|
||||
{
|
||||
"email": contact_email,
|
||||
"token": token,
|
||||
"lead_id": lead_id,
|
||||
@@ -439,7 +454,8 @@ async def quote_request():
|
||||
"facility_type": form.get("facility_type", ""),
|
||||
"court_count": form.get("court_count", ""),
|
||||
"country": form.get("country", ""),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
if is_json:
|
||||
return jsonify({"ok": True, "pending_verification": True})
|
||||
@@ -465,7 +481,9 @@ async def quote_request():
|
||||
start_step = 2 # skip project step, already filled
|
||||
return await render_template(
|
||||
"quote_request.html",
|
||||
data=data, step=start_step, steps=_get_quote_steps(g.get("lang", "en")),
|
||||
data=data,
|
||||
step=start_step,
|
||||
steps=_get_quote_steps(g.get("lang", "en")),
|
||||
)
|
||||
|
||||
|
||||
@@ -500,6 +518,7 @@ async def verify_quote():
|
||||
|
||||
# Compute credit cost and activate lead
|
||||
from ..credits import compute_credit_cost
|
||||
|
||||
credit_cost = compute_credit_cost(dict(lead))
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
@@ -535,6 +554,7 @@ async def verify_quote():
|
||||
|
||||
# Send welcome email
|
||||
from ..worker import enqueue
|
||||
|
||||
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
|
||||
|
||||
return await render_template(
|
||||
|
||||
@@ -86,10 +86,34 @@ PLAN_FEATURES = {
|
||||
}
|
||||
|
||||
BOOST_OPTIONS = [
|
||||
{"key": "boost_logo", "type": "logo", "name_key": "sd_boost_logo_name", "price": 29, "desc_key": "sd_boost_logo_desc"},
|
||||
{"key": "boost_highlight", "type": "highlight", "name_key": "sd_boost_highlight_name", "price": 39, "desc_key": "sd_boost_highlight_desc"},
|
||||
{"key": "boost_verified", "type": "verified", "name_key": "sd_boost_verified_name", "price": 49, "desc_key": "sd_boost_verified_desc"},
|
||||
{"key": "boost_card_color", "type": "card_color", "name_key": "sd_boost_card_color_name", "price": 19, "desc_key": "sd_boost_card_color_desc"},
|
||||
{
|
||||
"key": "boost_logo",
|
||||
"type": "logo",
|
||||
"name_key": "sd_boost_logo_name",
|
||||
"price": 29,
|
||||
"desc_key": "sd_boost_logo_desc",
|
||||
},
|
||||
{
|
||||
"key": "boost_highlight",
|
||||
"type": "highlight",
|
||||
"name_key": "sd_boost_highlight_name",
|
||||
"price": 39,
|
||||
"desc_key": "sd_boost_highlight_desc",
|
||||
},
|
||||
{
|
||||
"key": "boost_verified",
|
||||
"type": "verified",
|
||||
"name_key": "sd_boost_verified_name",
|
||||
"price": 49,
|
||||
"desc_key": "sd_boost_verified_desc",
|
||||
},
|
||||
{
|
||||
"key": "boost_card_color",
|
||||
"type": "card_color",
|
||||
"name_key": "sd_boost_card_color_name",
|
||||
"price": 19,
|
||||
"desc_key": "sd_boost_card_color_desc",
|
||||
},
|
||||
]
|
||||
|
||||
CREDIT_PACK_OPTIONS = [
|
||||
@@ -100,14 +124,51 @@ CREDIT_PACK_OPTIONS = [
|
||||
]
|
||||
|
||||
SERVICE_CATEGORIES = [
|
||||
"manufacturer", "turnkey", "consultant", "hall_builder",
|
||||
"turf", "lighting", "software", "industry_body", "franchise",
|
||||
"manufacturer",
|
||||
"turnkey",
|
||||
"consultant",
|
||||
"hall_builder",
|
||||
"turf",
|
||||
"lighting",
|
||||
"software",
|
||||
"industry_body",
|
||||
"franchise",
|
||||
]
|
||||
|
||||
COUNTRIES = [
|
||||
"DE", "ES", "IT", "FR", "PT", "GB", "NL", "BE", "SE", "DK", "FI",
|
||||
"NO", "AT", "SI", "IS", "CH", "EE", "US", "CA", "MX", "BR", "AR",
|
||||
"AE", "SA", "TR", "CN", "IN", "SG", "ID", "TH", "AU", "ZA", "EG",
|
||||
"DE",
|
||||
"ES",
|
||||
"IT",
|
||||
"FR",
|
||||
"PT",
|
||||
"GB",
|
||||
"NL",
|
||||
"BE",
|
||||
"SE",
|
||||
"DK",
|
||||
"FI",
|
||||
"NO",
|
||||
"AT",
|
||||
"SI",
|
||||
"IS",
|
||||
"CH",
|
||||
"EE",
|
||||
"US",
|
||||
"CA",
|
||||
"MX",
|
||||
"BR",
|
||||
"AR",
|
||||
"AE",
|
||||
"SA",
|
||||
"TR",
|
||||
"CN",
|
||||
"IN",
|
||||
"SG",
|
||||
"ID",
|
||||
"TH",
|
||||
"AU",
|
||||
"ZA",
|
||||
"EG",
|
||||
]
|
||||
|
||||
|
||||
@@ -122,15 +183,14 @@ def _parse_accumulated(form_or_args):
|
||||
|
||||
def _get_supplier_for_user(user_id: int):
|
||||
"""Get the supplier record claimed by a user."""
|
||||
return fetch_one(
|
||||
"SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,)
|
||||
)
|
||||
return fetch_one("SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth decorator
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _supplier_required(f):
|
||||
"""Require authenticated user with a claimed supplier on any paid tier (basic, growth, pro)."""
|
||||
from functools import wraps
|
||||
@@ -181,6 +241,7 @@ def _lead_tier_required(f):
|
||||
# Signup Wizard
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/signup")
|
||||
@waitlist_gate(
|
||||
"suppliers/waitlist.html",
|
||||
@@ -370,6 +431,7 @@ async def signup_checkout():
|
||||
return jsonify({"error": "Email is required."}), 400
|
||||
|
||||
from ..auth.routes import create_user, get_user_by_email
|
||||
|
||||
user = await get_user_by_email(email)
|
||||
if not user:
|
||||
user_id = await create_user(email)
|
||||
@@ -413,13 +475,15 @@ async def signup_checkout():
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
return jsonify(
|
||||
{
|
||||
"items": items,
|
||||
"customData": custom_data,
|
||||
"settings": {
|
||||
"successUrl": f"{config.BASE_URL}/suppliers/signup/success",
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/claim/<slug>")
|
||||
@@ -445,6 +509,7 @@ async def signup_success():
|
||||
# Supplier Lead Feed
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="", limit=50):
|
||||
"""Shared query for lead feed — used by standalone and dashboard."""
|
||||
wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"]
|
||||
@@ -538,12 +603,16 @@ async def unlock_lead(token: str):
|
||||
|
||||
# Enqueue lead forward email
|
||||
from ..worker import enqueue
|
||||
|
||||
lang = g.get("lang", "en")
|
||||
await enqueue("send_lead_forward_email", {
|
||||
await enqueue(
|
||||
"send_lead_forward_email",
|
||||
{
|
||||
"lead_id": lead_id,
|
||||
"supplier_id": supplier["id"],
|
||||
"lang": lang,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
# Notify entrepreneur on first unlock
|
||||
lead = result["lead"]
|
||||
@@ -577,6 +646,7 @@ async def unlock_lead(token: str):
|
||||
# Supplier Dashboard
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@bp.route("/dashboard")
|
||||
@_supplier_required
|
||||
async def dashboard():
|
||||
@@ -680,7 +750,9 @@ async def dashboard_leads():
|
||||
|
||||
# Look up scenario IDs for unlocked leads
|
||||
scenario_ids = {}
|
||||
unlocked_user_ids = [lead["user_id"] for lead in leads if lead.get("is_unlocked") and lead.get("user_id")]
|
||||
unlocked_user_ids = [
|
||||
lead["user_id"] for lead in leads if lead.get("is_unlocked") and lead.get("user_id")
|
||||
]
|
||||
if unlocked_user_ids:
|
||||
placeholders = ",".join("?" * len(unlocked_user_ids))
|
||||
scenarios = await fetch_all(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Background task worker - SQLite-based queue (no Redis needed).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
@@ -90,15 +91,17 @@ def _email_button(url: str, label: str) -> str:
|
||||
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
|
||||
f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
|
||||
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
|
||||
f'{label}</a></td></tr></table>'
|
||||
f"{label}</a></td></tr></table>"
|
||||
)
|
||||
|
||||
|
||||
def task(name: str):
|
||||
"""Decorator to register a task handler."""
|
||||
|
||||
def decorator(f):
|
||||
HANDLERS[name] = f
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -106,6 +109,7 @@ def task(name: str):
|
||||
# Task Queue Operations
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int:
|
||||
"""Add a task to the queue."""
|
||||
return await execute(
|
||||
@@ -118,7 +122,7 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None)
|
||||
json.dumps(payload or {}),
|
||||
(run_at or datetime.utcnow()).isoformat(),
|
||||
datetime.utcnow().isoformat(),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +136,7 @@ async def get_pending_tasks(limit: int = 10) -> list[dict]:
|
||||
ORDER BY run_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(now, limit)
|
||||
(now, limit),
|
||||
)
|
||||
|
||||
|
||||
@@ -140,7 +144,7 @@ async def mark_complete(task_id: int) -> None:
|
||||
"""Mark task as completed."""
|
||||
await execute(
|
||||
"UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?",
|
||||
(datetime.utcnow().isoformat(), task_id)
|
||||
(datetime.utcnow().isoformat(), task_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -159,12 +163,11 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
|
||||
SET status = 'pending', error = ?, retries = ?, run_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(error, retries + 1, run_at.isoformat(), task_id)
|
||||
(error, retries + 1, run_at.isoformat(), task_id),
|
||||
)
|
||||
else:
|
||||
await execute(
|
||||
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?",
|
||||
(error, task_id)
|
||||
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", (error, task_id)
|
||||
)
|
||||
|
||||
|
||||
@@ -172,6 +175,7 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
|
||||
# Built-in Task Handlers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@task("send_email")
|
||||
async def handle_send_email(payload: dict) -> None:
|
||||
"""Send an email."""
|
||||
@@ -228,7 +232,9 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
||||
print(f" {link}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
first_name = payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
|
||||
first_name = (
|
||||
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
|
||||
)
|
||||
project_desc = ""
|
||||
parts = []
|
||||
if payload.get("court_count"):
|
||||
@@ -322,10 +328,7 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
|
||||
@task("cleanup_expired_tokens")
|
||||
async def handle_cleanup_tokens(payload: dict) -> None:
|
||||
"""Clean up expired auth tokens."""
|
||||
await execute(
|
||||
"DELETE FROM auth_tokens WHERE expires_at < ?",
|
||||
(datetime.utcnow().isoformat(),)
|
||||
)
|
||||
await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (datetime.utcnow().isoformat(),))
|
||||
|
||||
|
||||
@task("cleanup_rate_limits")
|
||||
@@ -360,7 +363,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
|
||||
(t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
|
||||
(t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
|
||||
(t("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
|
||||
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: €{budget}"),
|
||||
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: \u20ac{budget}"),
|
||||
(t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
|
||||
(t("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
|
||||
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
|
||||
@@ -565,8 +568,7 @@ async def handle_cleanup_tasks(payload: dict) -> None:
|
||||
"""Clean up completed/failed tasks older than 7 days."""
|
||||
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
|
||||
await execute(
|
||||
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?",
|
||||
(cutoff,)
|
||||
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,)
|
||||
)
|
||||
|
||||
|
||||
@@ -574,6 +576,7 @@ async def handle_cleanup_tasks(payload: dict) -> None:
|
||||
# Worker Loop
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def process_task(task: dict) -> None:
|
||||
"""Process a single task."""
|
||||
task_name = task["task_name"]
|
||||
@@ -632,6 +635,7 @@ async def run_scheduler() -> None:
|
||||
|
||||
# Monthly credit refill — run on the 1st of each month
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.utcnow()
|
||||
this_month = f"{today.year}-{today.month:02d}"
|
||||
if today.day == 1 and last_credit_refill != this_month:
|
||||
|
||||
Reference in New Issue
Block a user