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 (
|
from animaltrack.events.payloads import (
|
||||||
AnimalCohortCreatedPayload,
|
AnimalCohortCreatedPayload,
|
||||||
AnimalPromotedPayload,
|
AnimalPromotedPayload,
|
||||||
|
AnimalTagEndedPayload,
|
||||||
|
AnimalTaggedPayload,
|
||||||
HatchRecordedPayload,
|
HatchRecordedPayload,
|
||||||
)
|
)
|
||||||
from animaltrack.events.store import EventStore
|
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.animal_registry import AnimalRegistryProjection
|
||||||
from animaltrack.projections.event_animals import EventAnimalsProjection
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||||
from animaltrack.projections.intervals import IntervalProjection
|
from animaltrack.projections.intervals import IntervalProjection
|
||||||
|
from animaltrack.projections.tags import TagProjection
|
||||||
from animaltrack.repositories.animals import AnimalRepository
|
from animaltrack.repositories.animals import AnimalRepository
|
||||||
from animaltrack.repositories.locations import LocationRepository
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
from animaltrack.repositories.species import SpeciesRepository
|
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.services.animal import AnimalService, ValidationError
|
||||||
from animaltrack.web.templates import page
|
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:
|
def _create_animal_service(db: Any) -> AnimalService:
|
||||||
@@ -44,6 +57,7 @@ def _create_animal_service(db: Any) -> AnimalService:
|
|||||||
registry.register(EventAnimalsProjection(db))
|
registry.register(EventAnimalsProjection(db))
|
||||||
registry.register(IntervalProjection(db))
|
registry.register(IntervalProjection(db))
|
||||||
registry.register(EventLogProjection(db))
|
registry.register(EventLogProjection(db))
|
||||||
|
registry.register(TagProjection(db))
|
||||||
return AnimalService(db, event_store, registry)
|
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
|
# Route Registration
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -462,3 +906,9 @@ def register_action_routes(rt, app):
|
|||||||
# Single animal actions
|
# Single animal actions
|
||||||
rt("/actions/promote/{animal_id}")(promote_index)
|
rt("/actions/promote/{animal_id}")(promote_index)
|
||||||
rt("/actions/animal-promote", methods=["POST"])(animal_promote)
|
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 collections.abc import Callable
|
||||||
from typing import Any
|
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 (
|
from monsterui.all import (
|
||||||
Alert,
|
Alert,
|
||||||
AlertT,
|
AlertT,
|
||||||
@@ -18,6 +18,7 @@ from ulid import ULID
|
|||||||
|
|
||||||
from animaltrack.models.animals import Animal
|
from animaltrack.models.animals import Animal
|
||||||
from animaltrack.models.reference import Location, Species
|
from animaltrack.models.reference import Location, Species
|
||||||
|
from animaltrack.selection.validation import SelectionDiff
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Cohort Creation Form
|
# Cohort Creation Form
|
||||||
@@ -395,3 +396,365 @@ def promote_form(
|
|||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -584,3 +584,387 @@ class TestPromoteValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Add Tag Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def animals_for_tagging(seeded_db, client, location_strip1_id):
|
||||||
|
"""Create animals for tag testing and return their IDs."""
|
||||||
|
# Create a cohort with 3 animals
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-cohort",
|
||||||
|
data={
|
||||||
|
"species": "duck",
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"count": "3",
|
||||||
|
"life_stage": "adult",
|
||||||
|
"sex": "female",
|
||||||
|
"origin": "purchased",
|
||||||
|
"nonce": "test-tag-fixture-cohort-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Get the animal IDs
|
||||||
|
rows = seeded_db.execute(
|
||||||
|
"SELECT animal_id FROM animal_registry WHERE origin = 'purchased' AND life_stage = 'adult' ORDER BY animal_id DESC LIMIT 3"
|
||||||
|
).fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagAddFormRendering:
|
||||||
|
"""Tests for GET /actions/tag-add form rendering."""
|
||||||
|
|
||||||
|
def test_tag_add_form_renders(self, client):
|
||||||
|
"""GET /actions/tag-add returns 200 with form elements."""
|
||||||
|
resp = client.get("/actions/tag-add")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Add Tag" in resp.text
|
||||||
|
|
||||||
|
def test_tag_add_form_has_filter_field(self, client):
|
||||||
|
"""Form has filter input field."""
|
||||||
|
resp = client.get("/actions/tag-add")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'name="filter"' in resp.text
|
||||||
|
|
||||||
|
def test_tag_add_form_has_tag_field(self, client):
|
||||||
|
"""Form has tag input field."""
|
||||||
|
resp = client.get("/actions/tag-add")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'name="tag"' in resp.text
|
||||||
|
|
||||||
|
def test_tag_add_form_with_filter_shows_selection(self, client, animals_for_tagging):
|
||||||
|
"""Form with filter shows selection preview."""
|
||||||
|
# Use species filter which is a valid filter field
|
||||||
|
resp = client.get("/actions/tag-add?filter=species:duck")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Should show selection count
|
||||||
|
assert "selected" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagAddSuccess:
|
||||||
|
"""Tests for successful POST /actions/animal-tag-add."""
|
||||||
|
|
||||||
|
def test_tag_add_creates_event(self, client, seeded_db, animals_for_tagging):
|
||||||
|
"""POST creates AnimalTagged event when valid."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-add",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "test-tag",
|
||||||
|
"resolved_ids": animals_for_tagging,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-nonce-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify event was created
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT type FROM events WHERE type = 'AnimalTagged' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert event_row is not None
|
||||||
|
assert event_row[0] == "AnimalTagged"
|
||||||
|
|
||||||
|
def test_tag_add_creates_tag_intervals(self, client, seeded_db, animals_for_tagging):
|
||||||
|
"""POST creates tag intervals for animals."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-add",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "layer-birds",
|
||||||
|
"resolved_ids": animals_for_tagging,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-nonce-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify tag intervals were created
|
||||||
|
tag_count = seeded_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'layer-birds' AND end_utc IS NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert tag_count >= len(animals_for_tagging)
|
||||||
|
|
||||||
|
def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging):
|
||||||
|
"""Successful tag add returns HX-Trigger with toast."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-add",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "test-tag-toast",
|
||||||
|
"resolved_ids": animals_for_tagging,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-nonce-3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "HX-Trigger" in resp.headers
|
||||||
|
assert "showToast" in resp.headers["HX-Trigger"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagAddValidation:
|
||||||
|
"""Tests for validation errors in POST /actions/animal-tag-add."""
|
||||||
|
|
||||||
|
def test_tag_add_missing_tag_returns_422(self, client, animals_for_tagging):
|
||||||
|
"""Missing tag returns 422."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-add",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
# Missing tag
|
||||||
|
"resolved_ids": animals_for_tagging,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-nonce-4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_tag_add_no_animals_returns_422(self, client):
|
||||||
|
"""No animals selected returns 422."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-add",
|
||||||
|
data={
|
||||||
|
"filter": "",
|
||||||
|
"tag": "test-tag",
|
||||||
|
# No resolved_ids
|
||||||
|
"roster_hash": "",
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-nonce-5",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# End Tag Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tagged_animals(seeded_db, client, animals_for_tagging):
|
||||||
|
"""Tag animals and return their IDs."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
# First tag the animals
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-add",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "test-end-tag",
|
||||||
|
"resolved_ids": animals_for_tagging,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-fixture-tag-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
return animals_for_tagging
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagEndFormRendering:
|
||||||
|
"""Tests for GET /actions/tag-end form rendering."""
|
||||||
|
|
||||||
|
def test_tag_end_form_renders(self, client):
|
||||||
|
"""GET /actions/tag-end returns 200 with form elements."""
|
||||||
|
resp = client.get("/actions/tag-end")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "End Tag" in resp.text
|
||||||
|
|
||||||
|
def test_tag_end_form_has_filter_field(self, client):
|
||||||
|
"""Form has filter input field."""
|
||||||
|
resp = client.get("/actions/tag-end")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'name="filter"' in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagEndSuccess:
|
||||||
|
"""Tests for successful POST /actions/animal-tag-end."""
|
||||||
|
|
||||||
|
def test_tag_end_creates_event(self, client, seeded_db, tagged_animals):
|
||||||
|
"""POST creates AnimalTagEnded event when valid."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-end",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "test-end-tag",
|
||||||
|
"resolved_ids": tagged_animals,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-end-nonce-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify event was created
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT type FROM events WHERE type = 'AnimalTagEnded' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert event_row is not None
|
||||||
|
assert event_row[0] == "AnimalTagEnded"
|
||||||
|
|
||||||
|
def test_tag_end_closes_intervals(self, client, seeded_db, tagged_animals):
|
||||||
|
"""POST closes tag intervals for animals."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
# Verify intervals are open before
|
||||||
|
open_before = seeded_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'test-end-tag' AND end_utc IS NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert open_before >= len(tagged_animals)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-end",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "test-end-tag",
|
||||||
|
"resolved_ids": tagged_animals,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-end-nonce-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify intervals are closed after
|
||||||
|
open_after = seeded_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'test-end-tag' AND end_utc IS NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert open_after == 0
|
||||||
|
|
||||||
|
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
|
||||||
|
"""Successful tag end returns HX-Trigger with toast."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-end",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
"tag": "test-end-tag",
|
||||||
|
"resolved_ids": tagged_animals,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-end-nonce-3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "HX-Trigger" in resp.headers
|
||||||
|
assert "showToast" in resp.headers["HX-Trigger"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagEndValidation:
|
||||||
|
"""Tests for validation errors in POST /actions/animal-tag-end."""
|
||||||
|
|
||||||
|
def test_tag_end_missing_tag_returns_422(self, client, tagged_animals):
|
||||||
|
"""Missing tag returns 422."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from animaltrack.selection import compute_roster_hash
|
||||||
|
|
||||||
|
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-end",
|
||||||
|
data={
|
||||||
|
"filter": "species:duck",
|
||||||
|
# Missing tag
|
||||||
|
"resolved_ids": tagged_animals,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-end-nonce-4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_tag_end_no_animals_returns_422(self, client):
|
||||||
|
"""No animals selected returns 422."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-tag-end",
|
||||||
|
data={
|
||||||
|
"filter": "",
|
||||||
|
"tag": "some-tag",
|
||||||
|
# No resolved_ids
|
||||||
|
"roster_hash": "",
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-tag-end-nonce-5",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|||||||
Reference in New Issue
Block a user