Skip to content

Commit 338deb0

Browse files
x3ekclaude
andauthored
feat(admin): notes edit/delete UI + fix broken create flow (#71) (#78)
* feat(admin): add note partials and render_partial for HTMX swaps Extract the per-note markup into themes/default/admin/_note_item.html plus a new _note_edit_form.html. Add ThemeEngine.render_partial() so the admin notes endpoints can return self-contained HTML fragments without the heavy default render context (no nav/favicon/featured_posts fetches). Partials live only in the default theme — the AsyncHybridLoader fallback means blue-tech and terminal pick them up automatically. The three admin templates now {% include %} the shared partial. Refs #71 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin): make notes endpoints respond to HTMX form submissions Branch /admin/notes POST/PUT/DELETE on the HX-Request header. HTMX requests get rendered HTML partials (_note_item.html); non-HTMX callers continue to get JSON (NoteResponse / {status: deleted}). Form-urlencoded payloads from HTMX forms are now accepted alongside JSON via parse_note_create / parse_note_update helpers. Also: unchecking the is_public checkbox in the edit form now persists is_public=False (form data omits absent checkboxes, so the parser explicitly sets the field). Auth failures on HTMX requests now attach HX-Redirect: /auth/login so the browser bounces to login without any client JS. Adds two new routes for inline editing: GET /admin/notes/{id}/edit (returns _note_edit_form.html) and GET /admin/notes/{id}/view (returns _note_item.html, used by the edit form's Cancel button). Refs #71 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(admin): cover HTMX/JSON dual-response notes CRUD 13 new tests against the admin notes endpoints: - JSON path returns NoteResponse; HTMX path returns rendered HTML partial - Unchecked is_public checkbox persists False (both create and update) - 404s on missing notes for PUT/DELETE/edit-form - DELETE with HX-Request returns empty 200; without it returns {status: deleted} - HX-Redirect header attached only on HTMX auth failures Refs #71 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(deps): add python-multipart for form parsing Required by Starlette/FastAPI's request.form() to parse application/x-www-form-urlencoded bodies, which the new HTMX admin notes flow relies on. Without it, PUT /admin/notes/{id} from the inline edit form returns 500 with 'The python-multipart library must be installed to use form parsing.' Refs #71 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin): address Copilot review on #78 Three issues raised by Copilot's PR review: 1. parse_note_create/parse_note_update now catch JSON decode errors and Pydantic ValidationError, re-raising as HTTPException(422). Previously a malformed JSON body or missing required field would bubble up as 500 because the validation happened outside FastAPI's parameter binding. 2. Form parsing no longer coerces missing fields to empty strings. Absent fields are simply not included in the dict, so Pydantic enforces required-ness on create (path/text both 422 if missing) and absent text on update is treated as 'no change' (text=None) instead of overwriting with ''. 3. ThemeEngine.render_partial now saves and restores loader.current_theme in a try/finally so concurrent HTMX requests with different themes can't leak state into each other. Adds 6 new tests covering the 422 paths, missing-field behaviors on form data, and theme-state restoration (including on render exceptions). Refs #71, #78 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4545f16 commit 338deb0

9 files changed

Lines changed: 695 additions & 115 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"greenlet>=3.0.0",
3030
"authlib>=1.4.0",
3131
"itsdangerous>=2.2.0",
32+
"python-multipart>=0.0.20",
3233
]
3334

3435
[project.optional-dependencies]

src/squishmark/routers/admin.py

Lines changed: 165 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Admin routes for notes, analytics, and cache management."""
22

3+
import json
34
import logging
45
from typing import Annotated, Any
56

67
from fastapi import APIRouter, Depends, HTTPException, Request
78
from fastapi.responses import HTMLResponse
8-
from pydantic import BaseModel
9+
from pydantic import BaseModel, ValidationError
910
from sqlalchemy.ext.asyncio import AsyncSession
1011

1112
from squishmark.config import get_settings
1213
from squishmark.models.content import Config
13-
from squishmark.models.db import get_db_session
14+
from squishmark.models.db import Note, get_db_session
1415
from squishmark.services.analytics import AnalyticsService
1516
from squishmark.services.cache import get_cache
1617
from squishmark.services.github import get_github_service
@@ -22,6 +23,11 @@
2223
router = APIRouter(prefix="/admin", tags=["admin"])
2324

2425

26+
def is_htmx(request: Request) -> bool:
27+
"""Return True when the request was made by HTMX."""
28+
return request.headers.get("HX-Request") == "true"
29+
30+
2531
# Pydantic models for request/response
2632
class NoteCreate(BaseModel):
2733
"""Request body for creating a note."""
@@ -66,6 +72,9 @@ async def get_current_admin(request: Request) -> str:
6672
6773
Raises HTTPException 401 if not authenticated.
6874
Raises HTTPException 403 if not an admin.
75+
76+
For HTMX requests, attaches an ``HX-Redirect`` header so the browser
77+
is redirected to the login page without any client JavaScript.
6978
"""
7079
settings = get_settings()
7180

@@ -74,14 +83,16 @@ async def get_current_admin(request: Request) -> str:
7483
logger.warning("Auth bypassed - returning dev-admin user")
7584
return "dev-admin"
7685

86+
htmx_headers = {"HX-Redirect": "/auth/login"} if is_htmx(request) else None
87+
7788
# Check for user in session (set by OAuth callback)
7889
user = request.session.get("user") if hasattr(request, "session") else None
7990

8091
if user is None:
81-
raise HTTPException(status_code=401, detail="Not authenticated")
92+
raise HTTPException(status_code=401, detail="Not authenticated", headers=htmx_headers)
8293

8394
if user["login"] not in settings.admin_users_list:
84-
raise HTTPException(status_code=403, detail="Not authorized")
95+
raise HTTPException(status_code=403, detail="Not authorized", headers=htmx_headers)
8596

8697
return user["login"]
8798

@@ -90,6 +101,93 @@ async def get_current_admin(request: Request) -> str:
90101
DbSession = Annotated[AsyncSession, Depends(get_db_session)]
91102

92103

104+
def _to_note_response(note: Note) -> NoteResponse:
105+
"""Convert an ORM ``Note`` to the JSON-serializable ``NoteResponse``."""
106+
return NoteResponse(
107+
id=note.id,
108+
path=note.path,
109+
text=note.text,
110+
is_public=note.is_public,
111+
author=note.author,
112+
created_at=note.created_at.isoformat(),
113+
updated_at=note.updated_at.isoformat(),
114+
)
115+
116+
117+
async def _render_note_partial(template_name: str, **context: Any) -> str:
118+
"""Render a notes admin partial using the active site theme."""
119+
github_service = get_github_service()
120+
config_data = await github_service.get_config()
121+
theme_name = Config.from_dict(config_data).theme.name
122+
theme_engine = await get_theme_engine(github_service)
123+
return theme_engine.render_partial(template_name, theme_override=theme_name, **context)
124+
125+
126+
def _is_form_request(request: Request) -> bool:
127+
content_type = request.headers.get("content-type", "")
128+
return content_type.startswith(("application/x-www-form-urlencoded", "multipart/form-data"))
129+
130+
131+
async def _load_json_body(request: Request) -> dict:
132+
"""Decode a JSON body, raising 422 on malformed JSON to match FastAPI defaults."""
133+
try:
134+
return await request.json()
135+
except json.JSONDecodeError as exc:
136+
raise HTTPException(status_code=422, detail=f"Invalid JSON: {exc.msg}") from exc
137+
138+
139+
async def parse_note_create(request: Request) -> NoteCreate:
140+
"""Parse a ``NoteCreate`` payload from either form data or JSON.
141+
142+
HTMX submits standard HTML forms as ``application/x-www-form-urlencoded``.
143+
Non-HTMX API callers continue to send JSON. Validation errors are returned
144+
as 422 (matching FastAPI's default body-binding behavior) regardless of
145+
which path produced them.
146+
"""
147+
if _is_form_request(request):
148+
form = await request.form()
149+
# Pass through what was actually sent: absent fields stay absent so
150+
# Pydantic enforces required-ness instead of silently coercing to "".
151+
data: dict[str, Any] = {"is_public": "is_public" in form}
152+
if "path" in form:
153+
data["path"] = str(form["path"])
154+
if "text" in form:
155+
data["text"] = str(form["text"])
156+
else:
157+
data = await _load_json_body(request)
158+
try:
159+
return NoteCreate.model_validate(data)
160+
except ValidationError as exc:
161+
raise HTTPException(status_code=422, detail=exc.errors()) from exc
162+
163+
164+
async def parse_note_update(request: Request) -> NoteUpdate:
165+
"""Parse a ``NoteUpdate`` payload from either form data or JSON.
166+
167+
Form semantics:
168+
- ``is_public`` is always set explicitly (True if the checkbox is present,
169+
False otherwise) — never None — so unchecking the checkbox actually
170+
persists ``is_public=False`` rather than being treated as "no change".
171+
- ``text`` is left absent (``None``, meaning "no change") when the form
172+
doesn't send it. The HTMX edit form always submits ``text`` because the
173+
field is ``required``, so this only affects programmatic form callers
174+
that intentionally omit the field.
175+
176+
Validation errors are returned as 422 from both paths.
177+
"""
178+
if _is_form_request(request):
179+
form = await request.form()
180+
data: dict[str, Any] = {"is_public": "is_public" in form}
181+
if "text" in form:
182+
data["text"] = str(form["text"])
183+
else:
184+
data = await _load_json_body(request)
185+
try:
186+
return NoteUpdate.model_validate(data)
187+
except ValidationError as exc:
188+
raise HTTPException(status_code=422, detail=exc.errors()) from exc
189+
190+
93191
# Admin dashboard
94192
@router.get("", response_class=HTMLResponse)
95193
async def admin_dashboard(
@@ -122,18 +220,7 @@ async def admin_dashboard(
122220
config,
123221
user={"login": admin},
124222
analytics=analytics,
125-
notes=[
126-
NoteResponse(
127-
id=n.id,
128-
path=n.path,
129-
text=n.text,
130-
is_public=n.is_public,
131-
author=n.author,
132-
created_at=n.created_at.isoformat(),
133-
updated_at=n.updated_at.isoformat(),
134-
)
135-
for n in notes
136-
],
223+
notes=[_to_note_response(n) for n in notes],
137224
cache_size=cache.size,
138225
)
139226
except Exception:
@@ -180,55 +267,44 @@ async def list_notes(
180267
session: DbSession,
181268
) -> list[NoteResponse]:
182269
"""List all notes."""
270+
del admin # auth side-effect only
183271
notes_service = NotesService(session)
184272
notes = await notes_service.get_all()
185-
return [
186-
NoteResponse(
187-
id=n.id,
188-
path=n.path,
189-
text=n.text,
190-
is_public=n.is_public,
191-
author=n.author,
192-
created_at=n.created_at.isoformat(),
193-
updated_at=n.updated_at.isoformat(),
194-
)
195-
for n in notes
196-
]
273+
return [_to_note_response(n) for n in notes]
197274

198275

199-
@router.post("/notes", status_code=201)
276+
@router.post("/notes", status_code=201, response_model=None)
200277
async def create_note(
278+
request: Request,
201279
admin: AdminUser,
202280
session: DbSession,
203-
note_data: NoteCreate,
204-
) -> NoteResponse:
205-
"""Create a new note."""
281+
) -> NoteResponse | HTMLResponse:
282+
"""Create a new note. Returns an HTML partial for HTMX, JSON otherwise."""
283+
note_data = await parse_note_create(request)
206284
notes_service = NotesService(session)
207285
note = await notes_service.create(
208286
path=note_data.path,
209287
text=note_data.text,
210288
author=admin,
211289
is_public=note_data.is_public,
212290
)
213-
return NoteResponse(
214-
id=note.id,
215-
path=note.path,
216-
text=note.text,
217-
is_public=note.is_public,
218-
author=note.author,
219-
created_at=note.created_at.isoformat(),
220-
updated_at=note.updated_at.isoformat(),
221-
)
291+
response = _to_note_response(note)
292+
if is_htmx(request):
293+
html = await _render_note_partial("admin/_note_item.html", note=response)
294+
return HTMLResponse(content=html, status_code=201)
295+
return response
222296

223297

224-
@router.put("/notes/{note_id}")
298+
@router.put("/notes/{note_id}", response_model=None)
225299
async def update_note(
300+
request: Request,
226301
admin: AdminUser,
227302
session: DbSession,
228303
note_id: int,
229-
note_data: NoteUpdate,
230-
) -> NoteResponse:
231-
"""Update a note."""
304+
) -> NoteResponse | HTMLResponse:
305+
"""Update a note. Returns an HTML partial for HTMX, JSON otherwise."""
306+
del admin # auth side-effect only
307+
note_data = await parse_note_update(request)
232308
notes_service = NotesService(session)
233309
note = await notes_service.update_note(
234310
note_id=note_id,
@@ -237,32 +313,63 @@ async def update_note(
237313
)
238314
if note is None:
239315
raise HTTPException(status_code=404, detail="Note not found")
240-
241-
return NoteResponse(
242-
id=note.id,
243-
path=note.path,
244-
text=note.text,
245-
is_public=note.is_public,
246-
author=note.author,
247-
created_at=note.created_at.isoformat(),
248-
updated_at=note.updated_at.isoformat(),
249-
)
316+
response = _to_note_response(note)
317+
if is_htmx(request):
318+
html = await _render_note_partial("admin/_note_item.html", note=response)
319+
return HTMLResponse(content=html)
320+
return response
250321

251322

252-
@router.delete("/notes/{note_id}")
323+
@router.delete("/notes/{note_id}", response_model=None)
253324
async def delete_note(
325+
request: Request,
254326
admin: AdminUser,
255327
session: DbSession,
256328
note_id: int,
257-
) -> dict[str, str]:
258-
"""Delete a note."""
329+
) -> dict[str, str] | HTMLResponse:
330+
"""Delete a note. Returns an empty 200 for HTMX so the row is removed."""
331+
del admin # auth side-effect only
259332
notes_service = NotesService(session)
260333
deleted = await notes_service.delete(note_id)
261334
if not deleted:
262335
raise HTTPException(status_code=404, detail="Note not found")
336+
if is_htmx(request):
337+
return HTMLResponse(content="", status_code=200)
263338
return {"status": "deleted"}
264339

265340

341+
@router.get("/notes/{note_id}/edit", response_class=HTMLResponse)
342+
async def edit_note_form(
343+
admin: AdminUser,
344+
session: DbSession,
345+
note_id: int,
346+
) -> HTMLResponse:
347+
"""Return the inline edit form for a note (HTMX swap target)."""
348+
del admin # auth side-effect only
349+
notes_service = NotesService(session)
350+
note = await notes_service.get_by_id(note_id)
351+
if note is None:
352+
raise HTTPException(status_code=404, detail="Note not found")
353+
html = await _render_note_partial("admin/_note_edit_form.html", note=_to_note_response(note))
354+
return HTMLResponse(content=html)
355+
356+
357+
@router.get("/notes/{note_id}/view", response_class=HTMLResponse)
358+
async def view_note(
359+
admin: AdminUser,
360+
session: DbSession,
361+
note_id: int,
362+
) -> HTMLResponse:
363+
"""Return the read-only note row (used by the edit form's Cancel button)."""
364+
del admin # auth side-effect only
365+
notes_service = NotesService(session)
366+
note = await notes_service.get_by_id(note_id)
367+
if note is None:
368+
raise HTTPException(status_code=404, detail="Note not found")
369+
html = await _render_note_partial("admin/_note_item.html", note=_to_note_response(note))
370+
return HTMLResponse(content=html)
371+
372+
266373
# Cache management
267374
@router.post("/cache/refresh")
268375
async def refresh_cache(

src/squishmark/services/theme/engine.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,30 @@ async def render_admin(
282282
"""Render the admin dashboard."""
283283
return await self.render("admin/admin.html", config, theme_override=theme_override, **context)
284284

285+
def render_partial(
286+
self,
287+
template_name: str,
288+
theme_override: str | None = None,
289+
**context: Any,
290+
) -> str:
291+
"""Render a small HTML fragment without the heavy default context.
292+
293+
For HTMX swaps where we only need a self-contained snippet (no nav,
294+
no favicon, no GitHub fetches). The partial must not depend on
295+
site/theme/canonical context.
296+
297+
The previous ``current_theme`` is restored after rendering so concurrent
298+
requests don't see each other's theme leak through this shared loader.
299+
"""
300+
previous_theme = self.loader.current_theme
301+
try:
302+
if theme_override:
303+
self.loader.current_theme = theme_override
304+
template = self.env.get_template(template_name)
305+
return template.render(**context)
306+
finally:
307+
self.loader.current_theme = previous_theme
308+
285309

286310
# Global theme engine instance
287311
_theme_engine: ThemeEngine | None = None

0 commit comments

Comments
 (0)