Compare commits
2 Commits
cb49f0cee1
...
065944a5b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 065944a5b1 | |||
| c47ab9e635 |
@ -169,7 +169,7 @@ if "comments" not in db.tables:
|
||||
|
||||
## FastAPI patterns
|
||||
|
||||
- **All routes are async.** Use `await request.form()` to read form data.
|
||||
- **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models.py`). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
|
||||
- **Return `RedirectResponse(url=..., status_code=302)`** for redirects.
|
||||
- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
|
||||
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
|
||||
@ -183,7 +183,7 @@ if "comments" not in db.tables:
|
||||
|
||||
- No comments/docstrings in source - code is self-documenting.
|
||||
- Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list).
|
||||
- Pydantic models exist in `models.py` but routers use `await request.form()` directly for validation.
|
||||
- Form validation uses Pydantic models in `models.py` via `Annotated[Model, Form()]` params; invalid input is caught by the global `RequestValidationError` handler in `main.py` (auth pages re-render with messages at 400, other routes redirect).
|
||||
- Template globals: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `format_date(dt_str, include_time=False)` (ISO → `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`).
|
||||
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests).
|
||||
- All `RedirectResponse` must use `status_code=302` (integer, not `status` module).
|
||||
@ -515,7 +515,7 @@ The feed page (`GET /feed`) is accessible without authentication:
|
||||
### Step 2: Implement backend
|
||||
|
||||
- Add/modify the route in `routers/{area}.py`
|
||||
- Use `await request.form()`, never Pydantic models
|
||||
- Validate form input with a typed `Annotated[Model, Form()]` param (define the model in `models.py`); read raw `await request.form()` only for file uploads
|
||||
- Use `templates.TemplateResponse(...)` from `devplacepy.templating`
|
||||
- Redirect with `RedirectResponse(url=..., status_code=302)`
|
||||
- Log every action: `logger.info(...)`
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Optional, Literal
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from devplacepy.constants import TOPICS
|
||||
|
||||
@ -159,3 +159,17 @@ class AdminRoleForm(BaseModel):
|
||||
|
||||
class AdminPasswordForm(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminSettingsForm(BaseModel):
|
||||
site_name: str = Field(default="", max_length=200)
|
||||
site_description: str = Field(default="", max_length=500)
|
||||
site_tagline: str = Field(default="", max_length=500)
|
||||
news_grade_threshold: str = Field(default="", max_length=10)
|
||||
news_api_url: str = Field(default="", max_length=500)
|
||||
news_ai_url: str = Field(default="", max_length=500)
|
||||
news_ai_model: str = Field(default="", max_length=200)
|
||||
news_ai_key: str = Field(default="", max_length=500)
|
||||
max_upload_size_mb: str = Field(default="", max_length=10)
|
||||
allowed_file_types: str = Field(default="", max_length=1000)
|
||||
max_attachments_per_resource: str = Field(default="", max_length=10)
|
||||
|
||||
@ -2,7 +2,7 @@ import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import AdminRoleForm, AdminPasswordForm
|
||||
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, db, build_pagination
|
||||
from devplacepy.templating import templates
|
||||
@ -210,11 +210,10 @@ async def admin_news_delete(request: Request, uid: str):
|
||||
|
||||
|
||||
@router.post("/settings")
|
||||
async def admin_settings_save(request: Request):
|
||||
async def admin_settings_save(request: Request, data: Annotated[AdminSettingsForm, Form()]):
|
||||
admin = require_admin(request)
|
||||
form = await request.form()
|
||||
settings = get_table("site_settings")
|
||||
for key, value in form.multi_items():
|
||||
for key, value in data.model_dump().items():
|
||||
existing = settings.find_one(key=key)
|
||||
if existing:
|
||||
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
|
||||
|
||||
50
tests/test_validation.py
Normal file
50
tests/test_validation.py
Normal file
@ -0,0 +1,50 @@
|
||||
import time
|
||||
import requests
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def _session():
|
||||
s = requests.Session()
|
||||
name = f"val_{int(time.time() * 1000)}"
|
||||
s.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": name, "email": f"{name}@t.dev",
|
||||
"password": "secret123", "confirm_password": "secret123",
|
||||
}, allow_redirects=True)
|
||||
return s
|
||||
|
||||
|
||||
def test_vote_bad_value_is_handled_not_500(app_server):
|
||||
s = _session()
|
||||
r = s.post(f"{BASE_URL}/votes/post/nonexistent", data={"value": "abc"}, allow_redirects=False)
|
||||
assert r.status_code != 500
|
||||
assert r.status_code in (302, 303, 400)
|
||||
|
||||
|
||||
def test_oversized_post_content_rejected(app_server):
|
||||
s = _session()
|
||||
r = s.post(f"{BASE_URL}/posts/create", data={"content": "x" * 2001, "topic": "random"}, allow_redirects=False)
|
||||
assert r.status_code in (302, 303, 400)
|
||||
assert "/posts/" not in (r.headers.get("location") or "")
|
||||
|
||||
|
||||
def test_short_post_content_rejected(app_server):
|
||||
s = _session()
|
||||
r = s.post(f"{BASE_URL}/posts/create", data={"content": "short", "topic": "random"}, allow_redirects=False)
|
||||
assert "/posts/" not in (r.headers.get("location") or "")
|
||||
|
||||
|
||||
def test_profile_update_overlong_bio_rejected(app_server):
|
||||
s = _session()
|
||||
r = s.post(f"{BASE_URL}/profile/update", data={
|
||||
"bio": "x" * 600, "location": "", "git_link": "", "website": "",
|
||||
}, allow_redirects=False)
|
||||
assert r.status_code in (302, 303, 400)
|
||||
|
||||
|
||||
def test_signup_short_username_rerenders_with_message(app_server):
|
||||
r = requests.post(f"{BASE_URL}/auth/signup", data={
|
||||
"username": "ab", "email": "x@y.zz",
|
||||
"password": "secret123", "confirm_password": "secret123",
|
||||
}, allow_redirects=False)
|
||||
assert r.status_code == 400
|
||||
assert "Username must be between 3 and 32 characters" in r.text
|
||||
Loading…
Reference in New Issue
Block a user