- 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>
254 lines
7.7 KiB
Python
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)
|