pdf-quiz-generator/backend/app/routers/contact.py
Daniel 2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- 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>
2026-04-04 22:48:26 +02:00

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} &lt;{email}&gt;</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}