feat: implement animal-tag-add and animal-tag-end routes (Step 9.1)
Add selection-based tag actions with optimistic locking: - GET /actions/tag-add and POST /actions/animal-tag-add - GET /actions/tag-end and POST /actions/animal-tag-end - Form templates with selection preview and tag input/dropdown - Diff panel for handling selection mismatches (409 response) - Add TagProjection to the action service registry - 16 tests covering form rendering, success, validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ from starlette.responses import HTMLResponse
|
||||
from animaltrack.events.payloads import (
|
||||
AnimalCohortCreatedPayload,
|
||||
AnimalPromotedPayload,
|
||||
AnimalTagEndedPayload,
|
||||
AnimalTaggedPayload,
|
||||
HatchRecordedPayload,
|
||||
)
|
||||
from animaltrack.events.store import EventStore
|
||||
@@ -21,12 +23,23 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
|
||||
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||
from animaltrack.projections.intervals import IntervalProjection
|
||||
from animaltrack.projections.tags import TagProjection
|
||||
from animaltrack.repositories.animals import AnimalRepository
|
||||
from animaltrack.repositories.locations import LocationRepository
|
||||
from animaltrack.repositories.species import SpeciesRepository
|
||||
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||
from animaltrack.services.animal import AnimalService, ValidationError
|
||||
from animaltrack.web.templates import page
|
||||
from animaltrack.web.templates.actions import cohort_form, hatch_form, promote_form
|
||||
from animaltrack.web.templates.actions import (
|
||||
cohort_form,
|
||||
hatch_form,
|
||||
promote_form,
|
||||
tag_add_diff_panel,
|
||||
tag_add_form,
|
||||
tag_end_diff_panel,
|
||||
tag_end_form,
|
||||
)
|
||||
|
||||
|
||||
def _create_animal_service(db: Any) -> AnimalService:
|
||||
@@ -44,6 +57,7 @@ def _create_animal_service(db: Any) -> AnimalService:
|
||||
registry.register(EventAnimalsProjection(db))
|
||||
registry.register(IntervalProjection(db))
|
||||
registry.register(EventLogProjection(db))
|
||||
registry.register(TagProjection(db))
|
||||
return AnimalService(db, event_store, registry)
|
||||
|
||||
|
||||
@@ -441,6 +455,436 @@ def _render_promote_error(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Add Tag
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def tag_add_index(request: Request):
|
||||
"""GET /actions/tag-add - Add Tag form."""
|
||||
db = request.app.state.db
|
||||
|
||||
# Get filter from query params
|
||||
filter_str = request.query_params.get("filter", "")
|
||||
|
||||
# Resolve selection if filter provided
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
resolution = resolve_filter(db, filter_ast, ts_utc)
|
||||
resolved_ids = resolution.animal_ids
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
|
||||
return page(
|
||||
tag_add_form(
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
),
|
||||
title="Add Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
|
||||
|
||||
async def animal_tag_add(request: Request):
|
||||
"""POST /actions/animal-tag-add - Add tag to animals."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
|
||||
# Extract form data
|
||||
filter_str = form.get("filter", "")
|
||||
tag = form.get("tag", "").strip()
|
||||
roster_hash = form.get("roster_hash", "")
|
||||
confirmed = form.get("confirmed", "") == "true"
|
||||
nonce = form.get("nonce")
|
||||
|
||||
# Get timestamp - use provided or current
|
||||
ts_utc_str = form.get("ts_utc", "0")
|
||||
try:
|
||||
ts_utc = int(ts_utc_str)
|
||||
if ts_utc == 0:
|
||||
ts_utc = int(time.time() * 1000)
|
||||
except ValueError:
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Validation: tag required
|
||||
if not tag:
|
||||
return _render_tag_add_error_form(db, filter_str, "Please enter a tag")
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
return _render_tag_add_error_form(db, filter_str, "No animals selected")
|
||||
|
||||
# Build selection context for validation
|
||||
context = SelectionContext(
|
||||
filter=filter_str,
|
||||
resolved_ids=list(resolved_ids),
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=None,
|
||||
confirmed=confirmed,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
result = validate_selection(db, context)
|
||||
|
||||
if not result.valid:
|
||||
# Mismatch detected - return 409 with diff panel
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
tag_add_diff_panel(
|
||||
diff=result.diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=result.resolved_ids,
|
||||
roster_hash=result.roster_hash,
|
||||
tag=tag,
|
||||
ts_utc=ts_utc,
|
||||
),
|
||||
title="Add Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
),
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs
|
||||
if confirmed:
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
ids_to_tag = current_resolution.animal_ids
|
||||
else:
|
||||
ids_to_tag = resolved_ids
|
||||
|
||||
# Check we still have animals
|
||||
if not ids_to_tag:
|
||||
return _render_tag_add_error_form(db, filter_str, "No animals remaining to tag")
|
||||
|
||||
# Create payload
|
||||
try:
|
||||
payload = AnimalTaggedPayload(
|
||||
resolved_ids=list(ids_to_tag),
|
||||
tag=tag,
|
||||
)
|
||||
except Exception as e:
|
||||
return _render_tag_add_error_form(db, filter_str, str(e))
|
||||
|
||||
# Get actor from auth
|
||||
auth = request.scope.get("auth")
|
||||
actor = auth.username if auth else "unknown"
|
||||
|
||||
# Add tag
|
||||
service = _create_animal_service(db)
|
||||
|
||||
try:
|
||||
event = service.add_tag(
|
||||
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-tag-add"
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_tag_add_error_form(db, filter_str, str(e))
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
tag_add_form(),
|
||||
title="Add Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
actually_tagged = event.entity_refs.get("actually_tagged", [])
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Tagged {len(actually_tagged)} animal(s) as '{tag}'",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_tag_add_error_form(db, filter_str, error_message):
|
||||
"""Render tag add form with error message."""
|
||||
# Re-resolve to show current selection info
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
resolution = resolve_filter(db, filter_ast, ts_utc)
|
||||
resolved_ids = resolution.animal_ids
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
tag_add_form(
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
error=error_message,
|
||||
),
|
||||
title="Add Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# End Tag
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_active_tags_for_animals(db: Any, animal_ids: list[str]) -> list[str]:
|
||||
"""Get tags that are active on at least one of the given animals.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
animal_ids: List of animal IDs to check.
|
||||
|
||||
Returns:
|
||||
Sorted list of unique active tag names.
|
||||
"""
|
||||
if not animal_ids:
|
||||
return []
|
||||
|
||||
placeholders = ",".join("?" * len(animal_ids))
|
||||
rows = db.execute(
|
||||
f"""
|
||||
SELECT DISTINCT tag
|
||||
FROM animal_tag_intervals
|
||||
WHERE animal_id IN ({placeholders})
|
||||
AND end_utc IS NULL
|
||||
ORDER BY tag
|
||||
""",
|
||||
animal_ids,
|
||||
).fetchall()
|
||||
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def tag_end_index(request: Request):
|
||||
"""GET /actions/tag-end - End Tag form."""
|
||||
db = request.app.state.db
|
||||
|
||||
# Get filter from query params
|
||||
filter_str = request.query_params.get("filter", "")
|
||||
|
||||
# Resolve selection if filter provided
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
active_tags: list[str] = []
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
resolution = resolve_filter(db, filter_ast, ts_utc)
|
||||
resolved_ids = resolution.animal_ids
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
active_tags = _get_active_tags_for_animals(db, resolved_ids)
|
||||
|
||||
return page(
|
||||
tag_end_form(
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
active_tags=active_tags,
|
||||
),
|
||||
title="End Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
|
||||
|
||||
async def animal_tag_end(request: Request):
|
||||
"""POST /actions/animal-tag-end - End tag on animals."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
|
||||
# Extract form data
|
||||
filter_str = form.get("filter", "")
|
||||
tag = form.get("tag", "").strip()
|
||||
roster_hash = form.get("roster_hash", "")
|
||||
confirmed = form.get("confirmed", "") == "true"
|
||||
nonce = form.get("nonce")
|
||||
|
||||
# Get timestamp - use provided or current
|
||||
ts_utc_str = form.get("ts_utc", "0")
|
||||
try:
|
||||
ts_utc = int(ts_utc_str)
|
||||
if ts_utc == 0:
|
||||
ts_utc = int(time.time() * 1000)
|
||||
except ValueError:
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Validation: tag required
|
||||
if not tag:
|
||||
return _render_tag_end_error_form(db, filter_str, "Please select a tag to end")
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
return _render_tag_end_error_form(db, filter_str, "No animals selected")
|
||||
|
||||
# Build selection context for validation
|
||||
context = SelectionContext(
|
||||
filter=filter_str,
|
||||
resolved_ids=list(resolved_ids),
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=None,
|
||||
confirmed=confirmed,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
result = validate_selection(db, context)
|
||||
|
||||
if not result.valid:
|
||||
# Mismatch detected - return 409 with diff panel
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
tag_end_diff_panel(
|
||||
diff=result.diff,
|
||||
filter_str=filter_str,
|
||||
resolved_ids=result.resolved_ids,
|
||||
roster_hash=result.roster_hash,
|
||||
tag=tag,
|
||||
ts_utc=ts_utc,
|
||||
),
|
||||
title="End Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
),
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs
|
||||
if confirmed:
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
ids_to_untag = current_resolution.animal_ids
|
||||
else:
|
||||
ids_to_untag = resolved_ids
|
||||
|
||||
# Check we still have animals
|
||||
if not ids_to_untag:
|
||||
return _render_tag_end_error_form(db, filter_str, "No animals remaining")
|
||||
|
||||
# Create payload
|
||||
try:
|
||||
payload = AnimalTagEndedPayload(
|
||||
resolved_ids=list(ids_to_untag),
|
||||
tag=tag,
|
||||
)
|
||||
except Exception as e:
|
||||
return _render_tag_end_error_form(db, filter_str, str(e))
|
||||
|
||||
# Get actor from auth
|
||||
auth = request.scope.get("auth")
|
||||
actor = auth.username if auth else "unknown"
|
||||
|
||||
# End tag
|
||||
service = _create_animal_service(db)
|
||||
|
||||
try:
|
||||
event = service.end_tag(
|
||||
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-tag-end"
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_tag_end_error_form(db, filter_str, str(e))
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
tag_end_form(),
|
||||
title="End Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
actually_ended = event.entity_refs.get("actually_ended", [])
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Ended tag '{tag}' on {len(actually_ended)} animal(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_tag_end_error_form(db, filter_str, error_message):
|
||||
"""Render tag end form with error message."""
|
||||
# Re-resolve to show current selection info
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
active_tags: list[str] = []
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
resolution = resolve_filter(db, filter_ast, ts_utc)
|
||||
resolved_ids = resolution.animal_ids
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
active_tags = _get_active_tags_for_animals(db, resolved_ids)
|
||||
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
tag_end_form(
|
||||
filter_str=filter_str,
|
||||
resolved_ids=resolved_ids,
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
active_tags=active_tags,
|
||||
error=error_message,
|
||||
),
|
||||
title="End Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
)
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Registration
|
||||
# =============================================================================
|
||||
@@ -462,3 +906,9 @@ def register_action_routes(rt, app):
|
||||
# Single animal actions
|
||||
rt("/actions/promote/{animal_id}")(promote_index)
|
||||
rt("/actions/animal-promote", methods=["POST"])(animal_promote)
|
||||
|
||||
# Selection-based actions
|
||||
rt("/actions/tag-add")(tag_add_index)
|
||||
rt("/actions/animal-tag-add", methods=["POST"])(animal_tag_add)
|
||||
rt("/actions/tag-end")(tag_end_index)
|
||||
rt("/actions/animal-tag-end", methods=["POST"])(animal_tag_end)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H2, Div, Form, Hidden, Option, P
|
||||
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
|
||||
from monsterui.all import (
|
||||
Alert,
|
||||
AlertT,
|
||||
@@ -18,6 +18,7 @@ from ulid import ULID
|
||||
|
||||
from animaltrack.models.animals import Animal
|
||||
from animaltrack.models.reference import Location, Species
|
||||
from animaltrack.selection.validation import SelectionDiff
|
||||
|
||||
# =============================================================================
|
||||
# Cohort Creation Form
|
||||
@@ -395,3 +396,365 @@ def promote_form(
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Add Tag Form
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def tag_add_form(
|
||||
filter_str: str = "",
|
||||
resolved_ids: list[str] | None = None,
|
||||
roster_hash: str = "",
|
||||
ts_utc: int | None = None,
|
||||
resolved_count: int = 0,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-tag-add",
|
||||
) -> Form:
|
||||
"""Create the Add Tag form.
|
||||
|
||||
Args:
|
||||
filter_str: Current filter string (DSL).
|
||||
resolved_ids: Resolved animal IDs from filter.
|
||||
roster_hash: Hash of resolved selection.
|
||||
ts_utc: Timestamp of resolution.
|
||||
resolved_count: Number of resolved animals.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
|
||||
Returns:
|
||||
Form component for adding tags to animals.
|
||||
"""
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
selection_preview = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
" animals selected",
|
||||
cls="text-sm",
|
||||
),
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
|
||||
# Hidden fields for resolved_ids (as multiple values)
|
||||
resolved_id_fields = [
|
||||
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
|
||||
]
|
||||
|
||||
return Form(
|
||||
H2("Add Tag", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Filter input
|
||||
LabelInput(
|
||||
"Filter",
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder='e.g., location:"Strip 1" species:duck',
|
||||
),
|
||||
# Selection preview
|
||||
selection_preview,
|
||||
# Tag input
|
||||
LabelInput(
|
||||
"Tag",
|
||||
id="tag",
|
||||
name="tag",
|
||||
placeholder="Enter tag name",
|
||||
),
|
||||
# Hidden fields for selection context
|
||||
*resolved_id_fields,
|
||||
Hidden(name="roster_hash", value=roster_hash),
|
||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Add Tag", type="submit", cls=ButtonT.primary),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
|
||||
def tag_add_diff_panel(
|
||||
diff: SelectionDiff,
|
||||
filter_str: str,
|
||||
resolved_ids: list[str],
|
||||
roster_hash: str,
|
||||
tag: str,
|
||||
ts_utc: int,
|
||||
action: Callable[..., Any] | str = "/actions/animal-tag-add",
|
||||
) -> Div:
|
||||
"""Create the mismatch confirmation panel for tag add.
|
||||
|
||||
Args:
|
||||
diff: SelectionDiff with added/removed counts.
|
||||
filter_str: Original filter string.
|
||||
resolved_ids: Server's resolved IDs (current).
|
||||
roster_hash: Server's roster hash (current).
|
||||
tag: Tag to add.
|
||||
ts_utc: Timestamp for resolution.
|
||||
action: Route function or URL for confirmation submit.
|
||||
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
||||
if diff.added:
|
||||
changes.append(f"{len(diff.added)} animals were added")
|
||||
|
||||
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
|
||||
|
||||
# Build confirmation form with hidden fields
|
||||
resolved_id_fields = [
|
||||
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
|
||||
]
|
||||
|
||||
confirm_form = Form(
|
||||
*resolved_id_fields,
|
||||
Hidden(name="filter", value=filter_str),
|
||||
Hidden(name="roster_hash", value=roster_hash),
|
||||
Hidden(name="tag", value=tag),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/tag-add'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm Tag ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
action=action,
|
||||
method="post",
|
||||
)
|
||||
|
||||
return Div(
|
||||
Alert(
|
||||
Div(
|
||||
P("Selection Changed", cls="font-bold text-lg mb-2"),
|
||||
P(changes_text, cls="mb-2"),
|
||||
P(
|
||||
f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# End Tag Form
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def tag_end_form(
|
||||
filter_str: str = "",
|
||||
resolved_ids: list[str] | None = None,
|
||||
roster_hash: str = "",
|
||||
ts_utc: int | None = None,
|
||||
resolved_count: int = 0,
|
||||
active_tags: list[str] | None = None,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-tag-end",
|
||||
) -> Form:
|
||||
"""Create the End Tag form.
|
||||
|
||||
Args:
|
||||
filter_str: Current filter string (DSL).
|
||||
resolved_ids: Resolved animal IDs from filter.
|
||||
roster_hash: Hash of resolved selection.
|
||||
ts_utc: Timestamp of resolution.
|
||||
resolved_count: Number of resolved animals.
|
||||
active_tags: List of tags active on selected animals.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
|
||||
Returns:
|
||||
Form component for ending tags on animals.
|
||||
"""
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
if active_tags is None:
|
||||
active_tags = []
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
selection_preview = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
" animals selected",
|
||||
cls="text-sm",
|
||||
),
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
|
||||
# Build tag options from active_tags
|
||||
tag_options = [Option("Select tag to end...", value="", disabled=True, selected=True)]
|
||||
for tag in active_tags:
|
||||
tag_options.append(Option(tag, value=tag))
|
||||
|
||||
# Hidden fields for resolved_ids (as multiple values)
|
||||
resolved_id_fields = [
|
||||
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
|
||||
]
|
||||
|
||||
return Form(
|
||||
H2("End Tag", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Filter input
|
||||
LabelInput(
|
||||
"Filter",
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder="e.g., tag:layer-birds species:duck",
|
||||
),
|
||||
# Selection preview
|
||||
selection_preview,
|
||||
# Tag dropdown
|
||||
LabelSelect(
|
||||
*tag_options,
|
||||
label="Tag to End",
|
||||
id="tag",
|
||||
name="tag",
|
||||
)
|
||||
if active_tags
|
||||
else Div(
|
||||
P("No active tags on selected animals", cls="text-sm text-stone-400"),
|
||||
cls="p-3 bg-slate-800 rounded-md",
|
||||
),
|
||||
# Hidden fields for selection context
|
||||
*resolved_id_fields,
|
||||
Hidden(name="roster_hash", value=roster_hash),
|
||||
Hidden(name="ts_utc", value=str(ts_utc or 0)),
|
||||
Hidden(name="confirmed", value=""),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
|
||||
def tag_end_diff_panel(
|
||||
diff: SelectionDiff,
|
||||
filter_str: str,
|
||||
resolved_ids: list[str],
|
||||
roster_hash: str,
|
||||
tag: str,
|
||||
ts_utc: int,
|
||||
action: Callable[..., Any] | str = "/actions/animal-tag-end",
|
||||
) -> Div:
|
||||
"""Create the mismatch confirmation panel for tag end.
|
||||
|
||||
Args:
|
||||
diff: SelectionDiff with added/removed counts.
|
||||
filter_str: Original filter string.
|
||||
resolved_ids: Server's resolved IDs (current).
|
||||
roster_hash: Server's roster hash (current).
|
||||
tag: Tag to end.
|
||||
ts_utc: Timestamp for resolution.
|
||||
action: Route function or URL for confirmation submit.
|
||||
|
||||
Returns:
|
||||
Div containing the diff panel with confirm button.
|
||||
"""
|
||||
# Build description of changes
|
||||
changes = []
|
||||
if diff.removed:
|
||||
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
|
||||
if diff.added:
|
||||
changes.append(f"{len(diff.added)} animals were added")
|
||||
|
||||
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
|
||||
|
||||
# Build confirmation form with hidden fields
|
||||
resolved_id_fields = [
|
||||
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
|
||||
]
|
||||
|
||||
confirm_form = Form(
|
||||
*resolved_id_fields,
|
||||
Hidden(name="filter", value=filter_str),
|
||||
Hidden(name="roster_hash", value=roster_hash),
|
||||
Hidden(name="tag", value=tag),
|
||||
Hidden(name="ts_utc", value=str(ts_utc)),
|
||||
Hidden(name="confirmed", value="true"),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
Div(
|
||||
Button(
|
||||
"Cancel",
|
||||
type="button",
|
||||
cls=ButtonT.default,
|
||||
onclick="window.location.href='/actions/tag-end'",
|
||||
),
|
||||
Button(
|
||||
f"Confirm End Tag ({diff.server_count} animals)",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
),
|
||||
cls="flex gap-3 mt-4",
|
||||
),
|
||||
action=action,
|
||||
method="post",
|
||||
)
|
||||
|
||||
return Div(
|
||||
Alert(
|
||||
Div(
|
||||
P("Selection Changed", cls="font-bold text-lg mb-2"),
|
||||
P(changes_text, cls="mb-2"),
|
||||
P(
|
||||
f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
|
||||
cls="text-sm",
|
||||
),
|
||||
),
|
||||
cls=AlertT.warning,
|
||||
),
|
||||
confirm_form,
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user