Compare commits
2 Commits
cb49f0cee1
...
065944a5b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 065944a5b1 | |||
| c47ab9e635 |
@ -169,7 +169,7 @@ if "comments" not in db.tables:
|
|||||||
|
|
||||||
## FastAPI patterns
|
## 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 `RedirectResponse(url=..., status_code=302)`** for redirects.
|
||||||
- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
|
- **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`.
|
- **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.
|
- 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).
|
- 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`).
|
- 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).
|
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests).
|
||||||
- All `RedirectResponse` must use `status_code=302` (integer, not `status` module).
|
- 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
|
### Step 2: Implement backend
|
||||||
|
|
||||||
- Add/modify the route in `routers/{area}.py`
|
- 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`
|
- Use `templates.TemplateResponse(...)` from `devplacepy.templating`
|
||||||
- Redirect with `RedirectResponse(url=..., status_code=302)`
|
- Redirect with `RedirectResponse(url=..., status_code=302)`
|
||||||
- Log every action: `logger.info(...)`
|
- 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 pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from devplacepy.constants import TOPICS
|
from devplacepy.constants import TOPICS
|
||||||
|
|
||||||
@ -159,3 +159,17 @@ class AdminRoleForm(BaseModel):
|
|||||||
|
|
||||||
class AdminPasswordForm(BaseModel):
|
class AdminPasswordForm(BaseModel):
|
||||||
password: str = Field(min_length=6, max_length=128)
|
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 typing import Annotated
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Request, Form
|
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 fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from devplacepy.database import get_table, db, build_pagination
|
from devplacepy.database import get_table, db, build_pagination
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
@ -210,11 +210,10 @@ async def admin_news_delete(request: Request, uid: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/settings")
|
@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)
|
admin = require_admin(request)
|
||||||
form = await request.form()
|
|
||||||
settings = get_table("site_settings")
|
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)
|
existing = settings.find_one(key=key)
|
||||||
if existing:
|
if existing:
|
||||||
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
|
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