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>
This commit is contained in:
2025-12-31 17:48:16 +00:00
parent 5ba068b36a
commit 229842fb45
14 changed files with 1896 additions and 28 deletions

View File

@@ -0,0 +1,162 @@
# ABOUTME: Projection for location lifecycle events.
# ABOUTME: Handles LocationCreated, LocationRenamed, and LocationArchived events.
import time
from typing import Any
from animaltrack.events.types import LOCATION_ARCHIVED, LOCATION_CREATED, LOCATION_RENAMED
from animaltrack.models.events import Event
from animaltrack.models.reference import Location
from animaltrack.projections.base import Projection
from animaltrack.repositories.locations import LocationRepository
class LocationProjection(Projection):
"""Maintains location reference data from events.
This projection handles location lifecycle events, maintaining:
- locations: Reference table of all locations
"""
def __init__(self, db: Any) -> None:
"""Initialize the projection with a database connection.
Args:
db: A fastlite database connection.
"""
super().__init__(db)
self.location_repo = LocationRepository(db)
def get_event_types(self) -> list[str]:
"""Return the event types this projection handles."""
return [LOCATION_CREATED, LOCATION_RENAMED, LOCATION_ARCHIVED]
def apply(self, event: Event) -> None:
"""Apply location event to update reference data."""
if event.type == LOCATION_CREATED:
self._apply_location_created(event)
elif event.type == LOCATION_RENAMED:
self._apply_location_renamed(event)
elif event.type == LOCATION_ARCHIVED:
self._apply_location_archived(event)
def revert(self, event: Event) -> None:
"""Revert location event from reference data."""
if event.type == LOCATION_CREATED:
self._revert_location_created(event)
elif event.type == LOCATION_RENAMED:
self._revert_location_renamed(event)
elif event.type == LOCATION_ARCHIVED:
self._revert_location_archived(event)
def _apply_location_created(self, event: Event) -> None:
"""Apply location created event.
Inserts a new location into the locations table.
"""
location_id = event.entity_refs.get("location_id")
name = event.payload.get("name")
ts_utc = event.ts_utc
location = Location(
id=location_id,
name=name,
active=True,
created_at_utc=ts_utc,
updated_at_utc=ts_utc,
)
self.location_repo.upsert(location)
def _revert_location_created(self, event: Event) -> None:
"""Revert location created event.
Removes the location from the locations table.
"""
location_id = event.entity_refs.get("location_id")
self.db.execute(
"DELETE FROM locations WHERE id = ?",
(location_id,),
)
def _apply_location_renamed(self, event: Event) -> None:
"""Apply location renamed event.
Updates the location name in the locations table.
"""
location_id = event.entity_refs.get("location_id")
new_name = event.payload.get("new_name")
ts_utc = event.ts_utc
self.db.execute(
"UPDATE locations SET name = ?, updated_at_utc = ? WHERE id = ?",
(new_name, ts_utc, location_id),
)
def _revert_location_renamed(self, event: Event) -> None:
"""Revert location renamed event.
This requires looking up the previous name from event history.
For simplicity, we search for the most recent prior name.
"""
location_id = event.entity_refs.get("location_id")
ts_utc = int(time.time() * 1000)
# Find the previous name from event history:
# 1. Check if there's an earlier LocationRenamed event
# 2. Or fall back to the LocationCreated event
row = self.db.execute(
"""
SELECT CASE
WHEN type = ? THEN JSON_EXTRACT(payload, '$.new_name')
WHEN type = ? THEN JSON_EXTRACT(payload, '$.name')
END as name
FROM events
WHERE entity_refs LIKE ?
AND type IN (?, ?)
AND ts_utc < ?
ORDER BY ts_utc DESC
LIMIT 1
""",
(
LOCATION_RENAMED,
LOCATION_CREATED,
f'%"{location_id}"%',
LOCATION_RENAMED,
LOCATION_CREATED,
event.ts_utc,
),
).fetchone()
if row and row[0]:
previous_name = row[0]
self.db.execute(
"UPDATE locations SET name = ?, updated_at_utc = ? WHERE id = ?",
(previous_name, ts_utc, location_id),
)
def _apply_location_archived(self, event: Event) -> None:
"""Apply location archived event.
Sets the location to inactive.
"""
location_id = event.entity_refs.get("location_id")
ts_utc = event.ts_utc
self.db.execute(
"UPDATE locations SET active = 0, updated_at_utc = ? WHERE id = ?",
(ts_utc, location_id),
)
def _revert_location_archived(self, event: Event) -> None:
"""Revert location archived event.
Sets the location back to active.
"""
location_id = event.entity_refs.get("location_id")
ts_utc = int(time.time() * 1000)
self.db.execute(
"UPDATE locations SET active = 1, updated_at_utc = ? WHERE id = ?",
(ts_utc, location_id),
)

View File

@@ -4,23 +4,24 @@
import time
from typing import Any
from animaltrack.id_gen import generate_id
from animaltrack.events.store import EventStore
from animaltrack.models.reference import (
FeedType,
Location,
Product,
ProductUnit,
Species,
User,
UserRole,
)
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections.location import LocationProjection
from animaltrack.repositories import (
FeedTypeRepository,
LocationRepository,
ProductRepository,
SpeciesRepository,
UserRepository,
)
from animaltrack.services.location import LocationService
def run_seeds(db: Any) -> None:
@@ -73,12 +74,17 @@ def _seed_users(db: Any, now_utc: int) -> None:
def _seed_locations(db: Any, now_utc: int) -> None:
"""Seed location data.
"""Seed location data via LocationCreated events.
Locations are created only if they don't exist (by name).
This preserves existing ULIDs on re-seeding.
Uses LocationService.create_location() which is idempotent -
locations are created only if they don't exist (by name).
This emits LocationCreated events per spec §23.
"""
repo = LocationRepository(db)
# Initialize LocationService with projection
event_store = EventStore(db)
registry = ProjectionRegistry()
registry.register(LocationProjection(db))
location_service = LocationService(db, event_store, registry)
location_names = [
"Strip 1",
@@ -92,16 +98,12 @@ def _seed_locations(db: Any, now_utc: int) -> None:
]
for name in location_names:
existing = repo.get_by_name(name)
if existing is None:
location = Location(
id=generate_id(),
name=name,
active=True,
created_at_utc=now_utc,
updated_at_utc=now_utc,
)
repo.upsert(location)
# create_location is idempotent - returns None if already exists
location_service.create_location(
name=name,
ts_utc=now_utc,
actor="system",
)
def _seed_species(db: Any, now_utc: int) -> None:

View File

@@ -0,0 +1,253 @@
# ABOUTME: Service layer for location operations.
# ABOUTME: Coordinates event creation with projection updates for location lifecycle.
from typing import Any
from animaltrack.db import transaction
from animaltrack.events.payloads import (
LocationArchivedPayload,
LocationCreatedPayload,
LocationRenamedPayload,
)
from animaltrack.events.processor import process_event
from animaltrack.events.store import EventStore
from animaltrack.events.types import LOCATION_ARCHIVED, LOCATION_CREATED, LOCATION_RENAMED
from animaltrack.id_gen import generate_id
from animaltrack.models.events import Event
from animaltrack.projections import ProjectionRegistry
from animaltrack.repositories.locations import LocationRepository
class LocationServiceError(Exception):
"""Base exception for location service errors."""
class ValidationError(LocationServiceError):
"""Raised when validation fails."""
class LocationService:
"""Service for location lifecycle operations.
Provides methods to create, rename, and archive locations.
All operations are atomic and emit events.
"""
def __init__(
self,
db: Any,
event_store: EventStore,
registry: ProjectionRegistry,
) -> None:
"""Initialize the service.
Args:
db: A fastlite database connection.
event_store: The event store for appending events.
registry: Registry of projections to update.
"""
self.db = db
self.event_store = event_store
self.registry = registry
self.location_repo = LocationRepository(db)
def create_location(
self,
name: str,
ts_utc: int,
actor: str,
nonce: str | None = None,
route: str | None = None,
) -> Event | None:
"""Create a new location.
Creates a LocationCreated event if no location with this name exists.
This is idempotent per spec §23 - returns None if location already exists.
Args:
name: The location name.
ts_utc: Timestamp in milliseconds since epoch.
actor: The user performing the action.
nonce: Optional idempotency nonce.
route: Required if nonce provided.
Returns:
The created event, or None if location already exists (idempotent).
Raises:
ValidationError: If name is empty or whitespace-only.
"""
# Validate name
if not name or not name.strip():
msg = "Location name cannot be empty"
raise ValidationError(msg)
name = name.strip()
# Check if location with this name already exists (idempotent)
existing = self.location_repo.get_by_name(name)
if existing is not None:
return None
# Generate a new location ID
location_id = generate_id()
# Create payload
payload = LocationCreatedPayload(
location_id=location_id,
name=name,
)
# Build entity_refs
entity_refs = {
"location_id": location_id,
}
with transaction(self.db):
event = self.event_store.append_event(
event_type=LOCATION_CREATED,
ts_utc=ts_utc,
actor=actor,
entity_refs=entity_refs,
payload=payload.model_dump(),
nonce=nonce,
route=route,
)
process_event(event, self.registry)
return event
def rename_location(
self,
location_id: str,
new_name: str,
ts_utc: int,
actor: str,
nonce: str | None = None,
route: str | None = None,
) -> Event:
"""Rename an existing location.
Creates a LocationRenamed event.
Args:
location_id: The location ID (ULID).
new_name: The new name for the location.
ts_utc: Timestamp in milliseconds since epoch.
actor: The user performing the action.
nonce: Optional idempotency nonce.
route: Required if nonce provided.
Returns:
The created event.
Raises:
ValidationError: If location not found, name is empty, or name already exists.
"""
# Validate new_name
if not new_name or not new_name.strip():
msg = "Location name cannot be empty"
raise ValidationError(msg)
new_name = new_name.strip()
# Check location exists
location = self.location_repo.get(location_id)
if location is None:
msg = f"Location '{location_id}' not found"
raise ValidationError(msg)
# Check new name doesn't conflict with another location (but allow same name)
existing = self.location_repo.get_by_name(new_name)
if existing is not None and existing.id != location_id:
msg = f"Location with name '{new_name}' already exists"
raise ValidationError(msg)
# Create payload
payload = LocationRenamedPayload(
location_id=location_id,
new_name=new_name,
)
# Build entity_refs
entity_refs = {
"location_id": location_id,
}
with transaction(self.db):
event = self.event_store.append_event(
event_type=LOCATION_RENAMED,
ts_utc=ts_utc,
actor=actor,
entity_refs=entity_refs,
payload=payload.model_dump(),
nonce=nonce,
route=route,
)
process_event(event, self.registry)
return event
def archive_location(
self,
location_id: str,
ts_utc: int,
actor: str,
nonce: str | None = None,
route: str | None = None,
) -> Event:
"""Archive a location.
Creates a LocationArchived event. The location will be marked inactive
and hidden from active location lists.
Args:
location_id: The location ID (ULID).
ts_utc: Timestamp in milliseconds since epoch.
actor: The user performing the action.
nonce: Optional idempotency nonce.
route: Required if nonce provided.
Returns:
The created event.
Raises:
ValidationError: If location not found or already archived.
"""
# Check location exists
location = self.location_repo.get(location_id)
if location is None:
msg = f"Location '{location_id}' not found"
raise ValidationError(msg)
# Check not already archived
if not location.active:
msg = f"Location '{location_id}' is already archived"
raise ValidationError(msg)
# Create payload
payload = LocationArchivedPayload(
location_id=location_id,
)
# Build entity_refs
entity_refs = {
"location_id": location_id,
}
with transaction(self.db):
event = self.event_store.append_event(
event_type=LOCATION_ARCHIVED,
ts_utc=ts_utc,
actor=actor,
entity_refs=entity_refs,
payload=payload.model_dump(),
nonce=nonce,
route=route,
)
process_event(event, self.registry)
return event

View File

@@ -26,6 +26,7 @@ from animaltrack.web.routes import (
register_events_routes,
register_feed_routes,
register_health_routes,
register_location_routes,
register_move_routes,
register_product_routes,
register_registry_routes,
@@ -152,6 +153,7 @@ def create_app(
register_egg_routes(rt, app)
register_events_routes(rt, app)
register_feed_routes(rt, app)
register_location_routes(rt, app)
register_move_routes(rt, app)
register_product_routes(rt, app)
register_registry_routes(rt, app)

View File

@@ -0,0 +1,64 @@
# ABOUTME: Helper functions for creating standardized HTTP responses.
# ABOUTME: Provides error_response, error_toast, and success_toast utilities.
import json
from starlette.responses import HTMLResponse
def error_response(content: str, status_code: int = 422) -> HTMLResponse:
"""Create an HTML error response.
Args:
content: The HTML content to include in the response body.
status_code: The HTTP status code (default 422 for validation errors).
Returns:
An HTMLResponse with the specified content and status code.
Standard status codes:
- 422: Validation errors (missing fields, invalid values)
- 409: Conflicts (roster mismatch, dependents blocking delete)
- 403: Authorization failed (wrong role, CSRF)
- 401: Authentication failed (no user)
"""
return HTMLResponse(content=content, status_code=status_code)
def error_toast(message: str, status_code: int = 422) -> HTMLResponse:
"""Create an error response with a toast notification.
Returns a minimal HTML response with an HX-Trigger header that
triggers a toast notification on the client side.
Args:
message: The error message to display in the toast.
status_code: The HTTP status code (default 422 for validation errors).
Returns:
An HTMLResponse with HX-Trigger header for toast display.
"""
response = HTMLResponse(content="", status_code=status_code)
response.headers["HX-Trigger"] = json.dumps(
{"showToast": {"message": message, "type": "error"}}
)
return response
def success_toast(message: str) -> dict:
"""Create a toast trigger dict for a success message.
This returns a dict that can be JSON-serialized and used as
an HX-Trigger header value.
Args:
message: The success message to display in the toast.
Returns:
A dict suitable for use as HX-Trigger header value.
Usage:
response = HTMLResponse(content=...)
response.headers["HX-Trigger"] = json.dumps(success_toast("Done!"))
"""
return {"showToast": {"message": message, "type": "success"}}

View File

@@ -7,6 +7,7 @@ from animaltrack.web.routes.eggs import register_egg_routes
from animaltrack.web.routes.events import register_events_routes
from animaltrack.web.routes.feed import register_feed_routes
from animaltrack.web.routes.health import register_health_routes
from animaltrack.web.routes.locations import register_location_routes
from animaltrack.web.routes.move import register_move_routes
from animaltrack.web.routes.products import register_product_routes
from animaltrack.web.routes.registry import register_registry_routes
@@ -18,6 +19,7 @@ __all__ = [
"register_events_routes",
"register_feed_routes",
"register_health_routes",
"register_location_routes",
"register_move_routes",
"register_product_routes",
"register_registry_routes",

View File

@@ -0,0 +1,253 @@
# 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)

View File

@@ -1,11 +1,70 @@
# ABOUTME: Base HTML template for AnimalTrack pages.
# ABOUTME: Provides consistent layout with MonsterUI theme and bottom nav.
from fasthtml.common import Container, Div, Title
from fasthtml.common import Container, Div, Script, Title
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
def toast_container():
"""Create a toast container for displaying notifications.
This container holds toast notifications that appear in the top-right corner.
Toasts are triggered via HTMX events (HX-Trigger header with showToast).
"""
return Div(
id="toast-container",
cls="toast toast-end toast-top z-50",
)
def toast_script():
"""JavaScript to handle showToast events from HTMX.
Listens for the showToast event and creates toast notifications
that auto-dismiss after 5 seconds.
"""
script = """
document.body.addEventListener('showToast', function(evt) {
var message = evt.detail.message || 'Action completed';
var type = evt.detail.type || 'success';
// Create alert element with appropriate styling
var alertClass = 'alert shadow-lg mb-2 ';
if (type === 'success') {
alertClass += 'alert-success';
} else if (type === 'error') {
alertClass += 'alert-error';
} else if (type === 'warning') {
alertClass += 'alert-warning';
} else {
alertClass += 'alert-info';
}
var toast = document.createElement('div');
toast.className = alertClass;
toast.innerHTML = '<span>' + message + '</span>';
var container = document.getElementById('toast-container');
if (container) {
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.5s';
setTimeout(function() {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 500);
}, 5000);
}
});
"""
return Script(script)
def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
"""
Base page template wrapper with navigation.
@@ -15,6 +74,7 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
- Bottom navigation styling
- Content container with nav padding
- Fixed bottom navigation bar
- Toast container for notifications
Args:
content: Page content (FT components)
@@ -27,6 +87,7 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
return (
Title(title),
BottomNavStyles(),
toast_script(),
# Main content with bottom padding for fixed nav
# hx-boost enables AJAX for all descendant forms/links
Div(
@@ -35,5 +96,6 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
hx_target="body",
cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100",
),
toast_container(),
BottomNav(active_id=active_nav),
)

View File

@@ -0,0 +1,170 @@
# ABOUTME: Templates for Location management pages.
# ABOUTME: Admin-only pages for creating, renaming, and archiving locations.
from uuid import uuid4
from fasthtml.common import H1, Div, Form, Hidden, Table, Tbody, Td, Th, Thead, Tr
from monsterui.all import (
Alert,
AlertT,
Button,
ButtonT,
Card,
DivFullySpaced,
Grid,
LabelInput,
)
from animaltrack.models.reference import Location
def location_list(
locations: list[Location],
error: str | None = None,
) -> Div:
"""Create the location list page.
Args:
locations: List of all locations.
error: Optional error message to display.
Returns:
Div containing the location management page.
"""
# Error alert if present
error_alert = None
if error:
error_alert = Alert(error, cls=AlertT.error)
# Create new location form
create_form = Card(
Form(
LabelInput(
"Location Name",
name="name",
type="text",
required=True,
placeholder="Enter location name",
),
Hidden(name="nonce", value=str(uuid4())),
Button("Create Location", type="submit", cls=ButtonT.primary),
hx_post="/actions/location-created",
hx_target="#location-list",
hx_swap="outerHTML",
),
header=Div("Create New Location", cls="text-lg font-semibold"),
)
# Location table
rows = []
for loc in locations:
status = "Active" if loc.active else "Archived"
status_badge = Div(status, cls="badge badge-success" if loc.active else "badge badge-ghost")
# Action buttons
actions = DivFullySpaced(
Button(
"Rename",
cls=ButtonT.ghost + " btn-sm",
hx_get=f"/locations/{loc.id}/rename",
hx_target="#modal-container",
hx_swap="innerHTML",
)
if loc.active
else None,
Button(
"Archive",
cls=ButtonT.ghost + " btn-sm text-warning",
hx_post="/actions/location-archived",
hx_vals=f'{{"location_id": "{loc.id}", "nonce": "{uuid4()}"}}',
hx_target="#location-list",
hx_swap="outerHTML",
hx_confirm="Are you sure you want to archive this location?",
)
if loc.active
else None,
)
rows.append(
Tr(
Td(loc.name),
Td(status_badge),
Td(actions),
)
)
table = Table(
Thead(
Tr(
Th("Name"),
Th("Status"),
Th("Actions"),
)
),
Tbody(*rows),
cls="table table-zebra w-full",
)
location_card = Card(
table,
header=Div("All Locations", cls="text-lg font-semibold"),
)
return Div(
H1("Location Management", cls="text-2xl font-bold mb-4"),
error_alert,
Grid(
create_form,
cols_sm=1,
cols_md=1,
cols_lg=1,
gap=4,
),
Div(id="modal-container"),
Div(
location_card,
id="location-list",
cls="mt-4",
),
)
def rename_form(
location: Location,
error: str | None = None,
) -> Div:
"""Create the rename location form.
Args:
location: The location to rename.
error: Optional error message to display.
Returns:
Div containing the rename form.
"""
error_alert = None
if error:
error_alert = Alert(error, cls=AlertT.error)
return Card(
error_alert,
Form(
LabelInput(
"New Name",
name="new_name",
type="text",
required=True,
value=location.name,
),
Hidden(name="location_id", value=location.id),
Hidden(name="nonce", value=str(uuid4())),
DivFullySpaced(
Button("Cancel", type="button", cls=ButtonT.ghost, hx_get="/locations"),
Button("Rename", type="submit", cls=ButtonT.primary),
),
hx_post="/actions/location-renamed",
hx_target="#location-list",
hx_swap="outerHTML",
),
header=Div(f"Rename: {location.name}", cls="text-lg font-semibold"),
)