feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists

Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
This commit is contained in:
2026-05-23 03:44:04 +00:00
parent 26a2d3280f
commit 92911bedc9
23 changed files with 612 additions and 291 deletions
+14 -34
View File
@@ -1,6 +1,8 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
from devplacepy.attachments import get_attachments, delete_target_attachments
@@ -127,24 +129,12 @@ async def gist_detail(request: Request, gist_slug: str):
@router.post("/create")
async def create_gist(request: Request):
async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
user = require_user(request)
form = await request.form()
title = form.get("title", "").strip()
description = form.get("description", "").strip()
source_code = form.get("source_code", "").strip()
language = form.get("language", "plaintext")
if not title:
return RedirectResponse(url="/gists", status_code=302)
if len(title) > 200:
return RedirectResponse(url="/gists", status_code=302)
if not source_code:
return RedirectResponse(url="/gists", status_code=302)
if len(source_code) > 50000:
return RedirectResponse(url="/gists", status_code=302)
if len(description) > 5000:
return RedirectResponse(url="/gists", status_code=302)
title = data.title.strip()
description = data.description.strip()
source_code = data.source_code.strip()
language = data.language
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
@@ -165,10 +155,8 @@ async def create_gist(request: Request):
"created_at": datetime.now(timezone.utc).isoformat(),
})
attachment_uids = form.getlist("attachment_uids") if hasattr(form, "getlist") else []
from devplacepy.attachments import link_attachments
link_attachments(attachment_uids, "gist", uid)
link_attachments(data.attachment_uids, "gist", uid)
create_mention_notifications(description or "", user["uid"], f"/gists/{gist_slug}")
@@ -177,25 +165,17 @@ async def create_gist(request: Request):
@router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str):
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request)
gists = get_table("gists")
gist = resolve_by_slug(gists, gist_slug)
if not gist or gist["user_uid"] != user["uid"]:
return RedirectResponse(url="/gists", status_code=302)
form = await request.form()
title = form.get("title", "").strip()
description = form.get("description", "").strip()
source_code = form.get("source_code", "").strip()
language = form.get("language", "plaintext")
if not title or len(title) > 200:
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
if not source_code or len(source_code) > 50000:
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
if len(description) > 5000:
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
title = data.title.strip()
description = data.description.strip()
source_code = data.source_code.strip()
language = data.language
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages: