- Fix tag filtering (sa_text import shadowing caused UnboundLocalError) - Add TagBrowser component with per-section search - Multi-category selection (OR within categories, AND with tags) - AI image validation: has_figure field in extraction prompt - Skip known branding images by MD5 hash + dimension filters - Fix quiz timer auto-submit (wrong useEffect dependency) - Fix QuizResponse schema: section_id nullable - Fix Question.quiz_id → source_quiz_id attribute name - Fix SQL injection in quizzes.py vector search - Add PDF processing progress steps via Redis - Add delete user from admin panel - Admin page: no spinner flash on data refresh - Upload progress: axios 1.x e.progress, remove manual Content-Type - Duplicate model error: 409 with clear message - Backend startup: retry DDL migration on lock timeout - Replace all silent except:pass with warning logs - Comprehensive multi-page documentation (docs/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""Contact form — stores submissions and emails admin.
|
|
Table is created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers.
|
|
"""
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.config import settings
|
|
|
|
router = APIRouter()
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class ContactRequest(BaseModel):
|
|
name: str
|
|
email: EmailStr
|
|
type: str # "question" | "moderator"
|
|
message: str
|
|
turnstile_token: str | None = None
|
|
|
|
|
|
async def _verify_turnstile(token: str) -> bool:
|
|
"""Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured."""
|
|
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
|
|
if not secret:
|
|
return True # Not configured — allow in dev/no-captcha mode
|
|
try:
|
|
import httpx
|
|
resp = httpx.post(
|
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
data={"secret": secret, "response": token},
|
|
timeout=10,
|
|
)
|
|
return resp.json().get("success", False)
|
|
except Exception as e:
|
|
log.warning(f"Turnstile verification failed: {e}")
|
|
return False
|
|
|
|
|
|
@router.post("")
|
|
async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
|
|
"""Public endpoint — no auth required."""
|
|
if req.type not in ("question", "moderator"):
|
|
raise HTTPException(status_code=400, detail="Type must be 'question' or 'moderator'")
|
|
|
|
name = req.name.strip()[:120]
|
|
message = req.message.strip()[:2000]
|
|
if not name or not message:
|
|
raise HTTPException(status_code=400, detail="Name and message are required")
|
|
|
|
# Verify Turnstile if configured
|
|
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
|
|
if secret:
|
|
if not req.turnstile_token:
|
|
raise HTTPException(status_code=400, detail="Bot verification required")
|
|
if not await _verify_turnstile(req.turnstile_token):
|
|
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
|
|
|
|
db.execute(text(
|
|
"INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)"
|
|
), {"name": name, "email": req.email.lower().strip(), "type": req.type, "message": message})
|
|
db.commit()
|
|
|
|
# Email admin
|
|
await _email_admin(name, req.email, req.type, message)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
async def _email_admin(name: str, email: str, type_: str, message: str):
|
|
try:
|
|
from app.services.email_service import _send
|
|
label = "Moderator Application" if type_ == "moderator" else "Contact Form Question"
|
|
body = (
|
|
f"<h2>New {label} — PedsHub</h2>"
|
|
f"<p><strong>From:</strong> {name} <{email}></p>"
|
|
f"<p><strong>Message:</strong></p>"
|
|
f"<blockquote style='border-left:4px solid #2563eb;padding-left:12px;color:#334155'>"
|
|
f"{message.replace(chr(10), '<br>')}"
|
|
f"</blockquote>"
|
|
)
|
|
admin_email = getattr(settings, "ADMIN_EMAIL", None) or getattr(settings, "SMTP_FROM", None)
|
|
if admin_email:
|
|
await _send(admin_email, f"[PedsHub] {label} from {name}", body)
|
|
except Exception as e:
|
|
log.warning(f"Failed to email admin about contact submission: {e}")
|
|
|
|
|
|
# ── Admin view of submissions ─────────────────────────────────────────────────
|
|
|
|
@router.get("/submissions")
|
|
def list_submissions(
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Admin: list all contact submissions."""
|
|
from app.utils.auth import require_admin
|
|
rows = db.execute(text(
|
|
"SELECT id, name, email, type, message, read, created_at FROM contact_submissions ORDER BY created_at DESC"
|
|
)).fetchall()
|
|
return [
|
|
{
|
|
"id": r.id, "name": r.name, "email": r.email,
|
|
"type": r.type, "message": r.message, "read": r.read,
|
|
"created_at": r.created_at.isoformat(),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.put("/submissions/{submission_id}/read")
|
|
def mark_read(
|
|
submission_id: int,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
result = db.execute(text("UPDATE contact_submissions SET read=1 WHERE id=:id"), {"id": submission_id})
|
|
db.commit()
|
|
if result.rowcount == 0:
|
|
raise HTTPException(status_code=404, detail="Not found")
|
|
return {"success": True}
|