From 3acb731a6c24147ee2878beb1b0c7efb50b35817 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Wed, 31 Dec 2025 12:50:38 +0000 Subject: [PATCH] feat: implement animal-tag-add and animal-tag-end routes (Step 9.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/animaltrack/web/routes/actions.py | 452 ++++++++++++++++++++++- src/animaltrack/web/templates/actions.py | 365 +++++++++++++++++- tests/test_web_actions.py | 384 +++++++++++++++++++ 3 files changed, 1199 insertions(+), 2 deletions(-) diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index eac40aa..fdffa77 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -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) diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index 191d3ef..975fce0 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -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", + ) diff --git a/tests/test_web_actions.py b/tests/test_web_actions.py index cd1c7bc..de814be 100644 --- a/tests/test_web_actions.py +++ b/tests/test_web_actions.py @@ -584,3 +584,387 @@ class TestPromoteValidation: ) 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