Compare commits

..

No commits in common. "065944a5b13fefa8fbf2834cc13f9a5b01db6112" and "cb49f0cee1d18ec050d78a84af654a86fa36a731" have entirely different histories.

4 changed files with 8 additions and 71 deletions

View File

@ -169,7 +169,7 @@ if "comments" not in db.tables:
## FastAPI patterns
- **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).
- **All routes are async.** Use `await request.form()` to read form data.
- **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).
- 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).
- Pydantic models exist in `models.py` but routers use `await request.form()` directly for validation.
- 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`
- 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 `await request.form()`, never Pydantic models
- Use `templates.TemplateResponse(...)` from `devplacepy.templating`
- Redirect with `RedirectResponse(url=..., status_code=302)`
- Log every action: `logger.info(...)`

View File

@ -1,4 +1,4 @@
from typing import Literal
from typing import Optional, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS
@ -159,17 +159,3 @@ 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)

View File

@ -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, AdminSettingsForm
from devplacepy.models import AdminRoleForm, AdminPasswordForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, db, build_pagination
from devplacepy.templating import templates
@ -210,10 +210,11 @@ async def admin_news_delete(request: Request, uid: str):
@router.post("/settings")
async def admin_settings_save(request: Request, data: Annotated[AdminSettingsForm, Form()]):
async def admin_settings_save(request: Request):
admin = require_admin(request)
form = await request.form()
settings = get_table("site_settings")
for key, value in data.model_dump().items():
for key, value in form.multi_items():
existing = settings.find_one(key=key)
if existing:
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])

View File

@ -1,50 +0,0 @@
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