Compare commits

...

2 Commits

Author SHA1 Message Date
065944a5b1 Update
Some checks failed
DevPlace CI / test (push) Has been cancelled
2026-05-23 05:56:21 +02:00
c47ab9e635 Update 2026-05-23 05:55:50 +02:00
4 changed files with 71 additions and 8 deletions

View File

@ -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(...)`

View File

@ -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)

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
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
View 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