Files
animaltrack/src/animaltrack/web/routes/locations.py
Petru Paler 229842fb45 feat: implement location events & error handling (Step 10.2)
- Add LocationService with create/rename/archive methods
- Add LocationProjection for event handling
- Add admin-only location management routes at /locations
- Add error response helpers (error_response, error_toast, success_toast)
- Add toast handler JS to base template for HX-Trigger notifications
- Update seeds.py to emit LocationCreated events per spec §23
- Archived locations block animal moves with 422 error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 17:48:16 +00:00

254 lines
7.7 KiB
Python

# ABOUTME: Routes for Location management functionality (admin-only).
# ABOUTME: Handles GET /locations and POST /actions/location-* routes.
from __future__ import annotations
import json
import time
from fasthtml.common import to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.events.store import EventStore
from animaltrack.models.reference import UserRole
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections.location import LocationProjection
from animaltrack.repositories.locations import LocationRepository
from animaltrack.services.location import LocationService, ValidationError
from animaltrack.web.auth import require_role
from animaltrack.web.responses import success_toast
from animaltrack.web.templates import page
from animaltrack.web.templates.locations import location_list, rename_form
def _get_location_service(db) -> LocationService:
"""Create a LocationService with projections."""
event_store = EventStore(db)
registry = ProjectionRegistry()
registry.register(LocationProjection(db))
return LocationService(db, event_store, registry)
# =============================================================================
# GET /locations - Location List
# =============================================================================
@require_role(UserRole.ADMIN)
async def locations_index(req: Request):
"""GET /locations - Location management page (admin-only)."""
db = req.app.state.db
locations = LocationRepository(db).list_all()
return page(
location_list(locations),
title="Locations - AnimalTrack",
active_nav=None,
)
# =============================================================================
# GET /locations/{id}/rename - Rename Form
# =============================================================================
@require_role(UserRole.ADMIN)
async def location_rename_form(req: Request, location_id: str):
"""GET /locations/{id}/rename - Rename location form (admin-only)."""
db = req.app.state.db
location = LocationRepository(db).get(location_id)
if location is None:
return HTMLResponse(content="Location not found", status_code=404)
return HTMLResponse(content=to_xml(rename_form(location)))
# =============================================================================
# POST /actions/location-created
# =============================================================================
@require_role(UserRole.ADMIN)
async def location_created(req: Request):
"""POST /actions/location-created - Create a new location (admin-only)."""
db = req.app.state.db
form = await req.form()
name = form.get("name", "").strip()
nonce = form.get("nonce")
# Validation
if not name:
return _render_error_list(db, "Location name cannot be empty")
ts_utc = int(time.time() * 1000)
# Get actor from auth
auth = req.scope.get("auth")
actor = auth.username if auth else "unknown"
# Create location
location_service = _get_location_service(db)
try:
event = location_service.create_location(
name=name,
ts_utc=ts_utc,
actor=actor,
nonce=nonce,
route="/actions/location-created",
)
except ValidationError as e:
return _render_error_list(db, str(e))
# Get updated list
locations = LocationRepository(db).list_all()
# Success response with toast
response = HTMLResponse(
content=to_xml(location_list(locations)),
)
if event is not None:
response.headers["HX-Trigger"] = json.dumps(success_toast(f"Created location: {name}"))
else:
# Idempotent - location already exists
response.headers["HX-Trigger"] = json.dumps(
success_toast(f"Location already exists: {name}")
)
return response
# =============================================================================
# POST /actions/location-renamed
# =============================================================================
@require_role(UserRole.ADMIN)
async def location_renamed(req: Request):
"""POST /actions/location-renamed - Rename a location (admin-only)."""
db = req.app.state.db
form = await req.form()
location_id = form.get("location_id", "").strip()
new_name = form.get("new_name", "").strip()
nonce = form.get("nonce")
# Validation
if not location_id:
return _render_error_list(db, "Location ID is required")
if not new_name:
return _render_error_list(db, "New name cannot be empty")
ts_utc = int(time.time() * 1000)
# Get actor from auth
auth = req.scope.get("auth")
actor = auth.username if auth else "unknown"
# Rename location
location_service = _get_location_service(db)
try:
location_service.rename_location(
location_id=location_id,
new_name=new_name,
ts_utc=ts_utc,
actor=actor,
nonce=nonce,
route="/actions/location-renamed",
)
except ValidationError as e:
return _render_error_list(db, str(e))
# Get updated list
locations = LocationRepository(db).list_all()
# Success response with toast
response = HTMLResponse(
content=to_xml(location_list(locations)),
)
response.headers["HX-Trigger"] = json.dumps(success_toast(f"Renamed location to: {new_name}"))
return response
# =============================================================================
# POST /actions/location-archived
# =============================================================================
@require_role(UserRole.ADMIN)
async def location_archived(req: Request):
"""POST /actions/location-archived - Archive a location (admin-only)."""
db = req.app.state.db
form = await req.form()
location_id = form.get("location_id", "").strip()
nonce = form.get("nonce")
# Validation
if not location_id:
return _render_error_list(db, "Location ID is required")
ts_utc = int(time.time() * 1000)
# Get actor from auth
auth = req.scope.get("auth")
actor = auth.username if auth else "unknown"
# Archive location
location_service = _get_location_service(db)
try:
location_service.archive_location(
location_id=location_id,
ts_utc=ts_utc,
actor=actor,
nonce=nonce,
route="/actions/location-archived",
)
except ValidationError as e:
return _render_error_list(db, str(e))
# Get updated list
locations = LocationRepository(db).list_all()
# Success response with toast
response = HTMLResponse(
content=to_xml(location_list(locations)),
)
response.headers["HX-Trigger"] = json.dumps(success_toast("Location archived"))
return response
# =============================================================================
# Helper Functions
# =============================================================================
def _render_error_list(db, error_message: str) -> HTMLResponse:
"""Render location list with error message."""
locations = LocationRepository(db).list_all()
return HTMLResponse(
content=to_xml(location_list(locations, error=error_message)),
status_code=422,
)
def register_location_routes(rt, app):
"""Register location management routes.
Args:
rt: FastHTML route decorator.
app: FastHTML application instance.
"""
rt("/locations")(locations_index)
rt("/locations/{location_id}/rename")(location_rename_form)
rt("/actions/location-created", methods=["POST"])(location_created)
rt("/actions/location-renamed", methods=["POST"])(location_renamed)
rt("/actions/location-archived", methods=["POST"])(location_archived)