From 9cd890b9368f4d7014e13cd685e71822e938aae9 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 1 Jan 2026 20:03:34 +0000 Subject: [PATCH] fix: checkbox selection bug and add event log improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix checkbox selection not working: remove duplicate subset_mode hidden fields from 5 form templates and add resolved_ids to checkbox component - Add all-events view to event log (shows events without location like AnimalOutcome, FeedPurchased, ProductSold) - Add event type filter dropdown alongside location filter - Make event log items clickable to open event detail slide-over - Add event delete UI with confirmation dialog (admin only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/web/routes/events.py | 247 +++++++++++++++++- src/animaltrack/web/templates/actions.py | 12 - .../web/templates/animal_select.py | 7 + src/animaltrack/web/templates/base.py | 3 +- src/animaltrack/web/templates/event_detail.py | 113 +++++++- src/animaltrack/web/templates/events.py | 92 +++++-- src/animaltrack/web/templates/move.py | 3 - tests/test_web_events.py | 6 +- 8 files changed, 435 insertions(+), 48 deletions(-) diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py index aafa355..c585a63 100644 --- a/src/animaltrack/web/routes/events.py +++ b/src/animaltrack/web/routes/events.py @@ -8,9 +8,17 @@ from typing import Any from fasthtml.common import APIRouter, to_xml from starlette.requests import Request -from starlette.responses import HTMLResponse +from starlette.responses import HTMLResponse, JSONResponse +from animaltrack.events.delete import delete_event +from animaltrack.events.dependencies import find_dependent_events +from animaltrack.events.exceptions import ( + DependentEventsError, + EventNotFoundError, + EventTombstonedError, +) from animaltrack.events.store import EventStore +from animaltrack.models.reference import UserRole from animaltrack.repositories.locations import LocationRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.web.templates import render_page @@ -58,40 +66,166 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str, return events +def get_all_events( + db: Any, + event_type: str | None = None, + limit: int = 100, +) -> list[dict[str, Any]]: + """Get all events from the events table. + + This queries the main events table directly, so it includes + events that are not location-scoped (AnimalOutcome, FeedPurchased, etc.). + + Args: + db: Database connection. + event_type: Optional event type filter. + limit: Maximum number of events to return. + + Returns: + List of event entries, newest first. + """ + if event_type: + rows = db.execute( + """ + SELECT id, ts_utc, type, actor, payload + FROM events + WHERE type = ? + ORDER BY ts_utc DESC + LIMIT ? + """, + (event_type, limit), + ).fetchall() + else: + rows = db.execute( + """ + SELECT id, ts_utc, type, actor, payload + FROM events + ORDER BY ts_utc DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + events = [] + for row in rows: + payload = json.loads(row[4]) + # Build a summary from the payload + summary = _build_summary_from_payload(row[2], payload) + events.append( + { + "event_id": row[0], + "location_id": None, # Not always present + "ts_utc": row[1], + "type": row[2], + "actor": row[3], + "summary": summary, + } + ) + return events + + +def _build_summary_from_payload(event_type: str, payload: dict[str, Any]) -> dict[str, Any]: + """Build a display summary from event payload.""" + if event_type == "AnimalCohortCreated": + return { + "species": payload.get("species"), + "count": payload.get("count"), + "origin": payload.get("origin"), + } + if event_type == "AnimalMoved": + return {"animal_count": len(payload.get("resolved_ids", []))} + if event_type == "AnimalTagged": + return { + "tag": payload.get("tag"), + "animal_count": len(payload.get("resolved_ids", [])), + } + if event_type == "AnimalTagEnded": + return { + "tag": payload.get("tag"), + "animal_count": len(payload.get("resolved_ids", [])), + } + if event_type == "AnimalOutcome": + return { + "outcome": payload.get("outcome"), + "animal_count": len(payload.get("resolved_ids", [])), + } + if event_type == "ProductCollected": + return { + "product_code": payload.get("product_code"), + "quantity": payload.get("quantity"), + } + if event_type == "FeedGiven": + kg = payload.get("quantity_grams", 0) / 1000 if payload.get("quantity_grams") else 0 + return { + "feed_type_code": payload.get("feed_code"), + "amount_kg": round(kg, 3), + } + if event_type == "FeedPurchased": + kg = payload.get("quantity_grams", 0) / 1000 if payload.get("quantity_grams") else 0 + return { + "feed_code": payload.get("feed_code"), + "amount_kg": round(kg, 3), + } + if event_type == "ProductSold": + return { + "product_code": payload.get("product_code"), + "quantity": payload.get("quantity"), + } + if event_type == "HatchRecorded": + return { + "species": payload.get("species"), + "hatched_live": payload.get("hatched_live"), + } + # Fallback + return {"event_type": event_type} + + @ar("/event-log") def event_log_index(request: Request): - """GET /event-log - Event log for a location.""" + """GET /event-log - Event log for all events or filtered by location/type.""" db = request.app.state.db # Get username for user defaults lookup auth = request.scope.get("auth") username = auth.username if auth else None - # Get location_id from query params - location_id = request.query_params.get("location_id") + # Get filter params from query + location_id = request.query_params.get("location_id", "") + event_type = request.query_params.get("event_type", "") - # If no query param, try user defaults - if not location_id and username: + # "all" means show all events (no location filter) + show_all = location_id == "all" or location_id == "" + + # If no query param and not explicitly "all", try user defaults + if not location_id and not event_type and username: defaults = UserDefaultsRepository(db).get(username, "event_log") - if defaults: + if defaults and defaults.location_id: location_id = defaults.location_id + show_all = False # Get all locations for selector location_repo = LocationRepository(db) locations = location_repo.list_active() - # Find location name if we have a location_id + # Find location name if we have a specific location_id location_name = None - if location_id: + if location_id and location_id != "all": for loc in locations: if loc.id == location_id: location_name = loc.name break - # Get event log if we have a valid location + # Get events based on filter events = [] - if location_id and location_name: + if show_all or not location_id: + # Show all events (from main events table) + events = get_all_events(db, event_type=event_type or None) + elif location_id and location_name: + # Show events for specific location events = get_event_log(db, location_id) + # Filter by event type if specified + if event_type: + events = [e for e in events if e["type"] == event_type] # Check if HTMX request is_htmx = request.headers.get("HX-Request") == "true" @@ -103,7 +237,7 @@ def event_log_index(request: Request): # Full page render return render_page( request, - event_log_panel(events, locations, location_id), + event_log_panel(events, locations, location_id, event_type), title="Event Log - AnimalTrack", active_nav="event_log", ) @@ -172,7 +306,94 @@ def event_detail(request: Request, event_id: str): if loc: location_names[loc_id] = loc.name + # Get user role for delete button visibility + auth = request.scope.get("auth") + user_role = auth.role if auth else None + # Return slide-over panel HTML return HTMLResponse( - content=to_xml(event_detail_panel(event, affected_animals, is_tombstoned, location_names)), + content=to_xml( + event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role) + ), ) + + +@ar("/events/{event_id}/delete", methods=["POST"]) +async def event_delete(request: Request, event_id: str): + """POST /events/{event_id}/delete - Delete an event (admin only). + + Form params: + - reason: Optional reason for deletion + - cascade: Whether to delete dependent events too + + Returns JSON response indicating success or error. + """ + db = request.app.state.db + + # Check authorization + auth = request.scope.get("auth") + if not auth: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + if auth.role != UserRole.admin: + return JSONResponse({"error": "Admin role required"}, status_code=403) + + # Parse form data + form = await request.form() + reason = form.get("reason", "") + cascade = form.get("cascade", "false") == "true" + + # Get event store and registry + event_store = EventStore(db) + registry = request.app.state.registry + + try: + # Check for dependent events first + dependents = find_dependent_events(db, event_store, event_id) + + if dependents and not cascade: + return JSONResponse( + { + "error": "has_dependents", + "dependent_count": len(dependents), + "message": f"This event has {len(dependents)} dependent event(s). " + "Delete them first or enable cascade delete.", + }, + status_code=409, + ) + + # Perform the deletion + deleted_ids = delete_event( + db=db, + event_store=event_store, + event_id=event_id, + actor=auth.username, + role="admin", + cascade=cascade, + reason=reason or None, + registry=registry, + ) + + return JSONResponse( + { + "success": True, + "deleted_count": len(deleted_ids), + "deleted_ids": deleted_ids, + } + ) + + except EventNotFoundError: + return JSONResponse({"error": "Event not found"}, status_code=404) + + except EventTombstonedError: + return JSONResponse({"error": "Event has already been deleted"}, status_code=409) + + except DependentEventsError as e: + return JSONResponse( + { + "error": "has_dependents", + "dependent_count": len(e.dependents), + "message": str(e), + }, + status_code=409, + ) diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py index 44f29cf..dd1bcae 100644 --- a/src/animaltrack/web/templates/actions.py +++ b/src/animaltrack/web/templates/actions.py @@ -568,14 +568,12 @@ def tag_add_form( # Selection component - show checkboxes if animals provided and > 1 # Wrapped in a container with ID for HTMX targeting selection_content = None - subset_mode = False if animals and len(animals) > 1: # Show checkbox list for subset selection selection_content = Div( P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), animal_checkbox_list(animals, resolved_ids), ) - subset_mode = True elif resolved_count > 0: selection_content = Div( P( @@ -641,7 +639,6 @@ def tag_add_form( *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), - Hidden(name="subset_mode", value="true" if subset_mode else ""), Hidden(name="nonce", value=str(ULID())), # Submit button Button("Add Tag", type="submit", cls=ButtonT.primary), @@ -781,14 +778,12 @@ def tag_end_form( # Selection component - show checkboxes if animals provided and > 1 # Wrapped in a container with ID for HTMX targeting selection_content = None - subset_mode = False if animals and len(animals) > 1: # Show checkbox list for subset selection selection_content = Div( P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), animal_checkbox_list(animals, resolved_ids), ) - subset_mode = True elif resolved_count > 0: selection_content = Div( P( @@ -864,7 +859,6 @@ def tag_end_form( *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), - Hidden(name="subset_mode", value="true" if subset_mode else ""), Hidden(name="nonce", value=str(ULID())), # Submit button Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags), @@ -1000,14 +994,12 @@ def attrs_form( # Selection component - show checkboxes if animals provided and > 1 # Wrapped in a container with ID for HTMX targeting selection_content = None - subset_mode = False if animals and len(animals) > 1: # Show checkbox list for subset selection selection_content = Div( P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), animal_checkbox_list(animals, resolved_ids), ) - subset_mode = True elif resolved_count > 0: selection_content = Div( P( @@ -1111,7 +1103,6 @@ def attrs_form( *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), - Hidden(name="subset_mode", value="true" if subset_mode else ""), Hidden(name="nonce", value=str(ULID())), # Submit button Button("Update Attributes", type="submit", cls=ButtonT.primary), @@ -1257,14 +1248,12 @@ def outcome_form( # Selection component - show checkboxes if animals provided and > 1 # Wrapped in a container with ID for HTMX targeting selection_content = None - subset_mode = False if animals and len(animals) > 1: # Show checkbox list for subset selection selection_content = Div( P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"), animal_checkbox_list(animals, resolved_ids), ) - subset_mode = True elif resolved_count > 0: # Fallback to simple count display selection_content = Div( @@ -1408,7 +1397,6 @@ def outcome_form( *resolved_id_fields, Hidden(name="roster_hash", value=roster_hash), Hidden(name="confirmed", value=""), - Hidden(name="subset_mode", value="true" if subset_mode else ""), Hidden(name="nonce", value=str(ULID())), # Submit button Button("Record Outcome", type="submit", cls=ButtonT.destructive), diff --git a/src/animaltrack/web/templates/animal_select.py b/src/animaltrack/web/templates/animal_select.py index 1bdc9a1..aeb232c 100644 --- a/src/animaltrack/web/templates/animal_select.py +++ b/src/animaltrack/web/templates/animal_select.py @@ -83,12 +83,19 @@ def animal_checkbox_list( cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2", ) + # Hidden fields for all resolved IDs (needed for validation) + resolved_id_fields = [ + Input(type="hidden", name="resolved_ids", value=a.animal_id) for a in animals + ] + return Div( count_display, Div( *items, cls="max-h-64 overflow-y-auto", ), + # Hidden fields for resolved_ids (all animals from filter resolution) + *resolved_id_fields, # Hidden field to indicate subset selection mode Input(type="hidden", name="subset_mode", value="true"), # Hidden field for roster_hash - will be updated via JS diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index e387c6e..f5ab09d 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -60,7 +60,8 @@ def EventSlideOverScript(): # noqa: N802 // HTMX event: after loading event content, open the panel document.body.addEventListener('htmx:afterSwap', function(evt) { - if (evt.detail.target.id === 'event-slide-over') { + if (evt.detail.target.id === 'event-slide-over' || + evt.detail.target.id === 'event-panel-content') { openEventPanel(); } }); diff --git a/src/animaltrack/web/templates/event_detail.py b/src/animaltrack/web/templates/event_detail.py index 29d223d..ddcc5c4 100644 --- a/src/animaltrack/web/templates/event_detail.py +++ b/src/animaltrack/web/templates/event_detail.py @@ -4,9 +4,10 @@ from datetime import UTC, datetime from typing import Any -from fasthtml.common import H3, A, Button, Div, Li, P, Span, Ul +from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul from animaltrack.models.events import Event +from animaltrack.models.reference import UserRole def format_timestamp(ts_utc: int) -> str: @@ -20,6 +21,7 @@ def event_detail_panel( affected_animals: list[dict[str, Any]], is_tombstoned: bool = False, location_names: dict[str, str] | None = None, + user_role: UserRole | None = None, ) -> Div: """Event detail slide-over panel content. @@ -28,6 +30,7 @@ def event_detail_panel( affected_animals: List of animals affected by this event. is_tombstoned: Whether the event has been deleted. location_names: Map of location IDs to names. + user_role: User's role for delete button visibility. Returns: Div containing the panel content. @@ -66,6 +69,8 @@ def event_detail_panel( entity_refs_section(event.entity_refs, location_names), # Affected animals affected_animals_section(affected_animals), + # Delete button (admin only, not for tombstoned events) + delete_section(event.id) if user_role == UserRole.admin and not is_tombstoned else None, id="event-panel-content", cls="bg-[#141413] h-full overflow-y-auto", ) @@ -320,3 +325,109 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div: Ul(*animal_items, cls="space-y-1"), cls="p-4", ) + + +def delete_section(event_id: str) -> Div: + """Section with delete button and confirmation dialog for admins.""" + return Div( + # Delete button + Button( + "Delete Event", + type="button", + onclick="showDeleteConfirm()", + cls="w-full bg-red-800 hover:bg-red-700 text-white font-medium py-2 px-4 rounded", + ), + # Confirmation dialog (hidden by default) + Div( + Div( + P( + "Are you sure you want to delete this event?", + cls="text-stone-200 font-medium mb-2", + ), + P( + "This will reverse its effects on projections.", + cls="text-stone-400 text-sm mb-4", + ), + # Delete status message + Div( + id="delete-status", + cls="text-sm mb-4", + ), + # Buttons + Div( + Button( + "Cancel", + type="button", + onclick="hideDeleteConfirm()", + cls="bg-stone-700 hover:bg-stone-600 text-white font-medium py-2 px-4 rounded mr-2", + ), + Button( + "Delete", + type="button", + onclick=f"deleteEvent('{event_id}')", + id="confirm-delete-btn", + cls="bg-red-700 hover:bg-red-600 text-white font-medium py-2 px-4 rounded", + ), + cls="flex justify-end", + ), + cls="bg-stone-800 p-4 rounded-lg", + ), + id="delete-confirm-dialog", + cls="hidden", + ), + # JavaScript for delete functionality + delete_script(), + cls="p-4 border-t border-stone-700", + ) + + +def delete_script() -> Script: + """JavaScript for delete confirmation dialog.""" + return Script(""" + function showDeleteConfirm() { + document.getElementById('delete-confirm-dialog').classList.remove('hidden'); + } + + function hideDeleteConfirm() { + document.getElementById('delete-confirm-dialog').classList.add('hidden'); + document.getElementById('delete-status').innerHTML = ''; + } + + async function deleteEvent(eventId) { + const statusEl = document.getElementById('delete-status'); + const deleteBtn = document.getElementById('confirm-delete-btn'); + + statusEl.innerHTML = 'Deleting...'; + deleteBtn.disabled = true; + + try { + const response = await fetch('/events/' + eventId + '/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'reason=Deleted via UI' + }); + + const data = await response.json(); + + if (response.ok) { + statusEl.innerHTML = 'Event deleted successfully!'; + // Close panel and refresh page after brief delay + setTimeout(function() { + closeEventPanel(); + window.location.reload(); + }, 1000); + } else if (data.error === 'has_dependents') { + statusEl.innerHTML = '' + data.message + ''; + deleteBtn.disabled = false; + } else { + statusEl.innerHTML = 'Error: ' + (data.error || 'Unknown error') + ''; + deleteBtn.disabled = false; + } + } catch (err) { + statusEl.innerHTML = 'Error: ' + err.message + ''; + deleteBtn.disabled = false; + } + } + """) diff --git a/src/animaltrack/web/templates/events.py b/src/animaltrack/web/templates/events.py index e4211fc..1c6ee20 100644 --- a/src/animaltrack/web/templates/events.py +++ b/src/animaltrack/web/templates/events.py @@ -55,6 +55,16 @@ def format_event_summary(event_type: str, summary: dict[str, Any]) -> str: count = summary.get("animal_count", 0) return f"{outcome.capitalize()}: {count} animal(s)" + if event_type == "FeedPurchased": + feed_code = summary.get("feed_code", "feed") + amount = summary.get("amount_kg", 0) + return f"Purchased {amount}kg {feed_code}" + + if event_type == "ProductSold": + product = summary.get("product_code", "product") + qty = summary.get("quantity", 0) + return f"Sold {qty} {product}" + # Fallback return event_type @@ -65,11 +75,13 @@ def event_type_badge_class(event_type: str) -> str: "ProductCollected": "bg-amber-100 text-amber-800", "AnimalCohortCreated": "bg-green-100 text-green-800", "FeedGiven": "bg-blue-100 text-blue-800", + "FeedPurchased": "bg-cyan-100 text-cyan-800", "AnimalMoved": "bg-purple-100 text-purple-800", "HatchRecorded": "bg-pink-100 text-pink-800", "AnimalTagged": "bg-indigo-100 text-indigo-800", "AnimalTagEnded": "bg-slate-100 text-slate-800", "AnimalOutcome": "bg-red-100 text-red-800", + "ProductSold": "bg-emerald-100 text-emerald-800", } return type_colors.get(event_type, "bg-gray-100 text-gray-800") @@ -94,7 +106,10 @@ def event_log_item( ), P(summary_text, cls="text-sm text-stone-700"), P(f"by {actor}", cls="text-xs text-stone-400"), - cls="py-3 border-b border-stone-200 last:border-0", + cls="py-3 border-b border-stone-200 last:border-0 cursor-pointer hover:bg-stone-50", + hx_get=f"/events/{event_id}", + hx_target="#event-panel-content", + hx_swap="innerHTML", ) @@ -102,7 +117,7 @@ def event_log_list(events: list[dict[str, Any]]) -> Any: """Render the event log list.""" if not events: return Div( - P("No events recorded at this location yet.", cls="text-stone-500 text-sm"), + P("No events found matching the current filters.", cls="text-stone-500 text-sm"), cls="p-4 text-center", ) @@ -120,9 +135,12 @@ def event_log_list(events: list[dict[str, Any]]) -> Any: return Ul(*items, cls="divide-y divide-stone-200") -def location_selector(locations: list[Any], selected_location_id: str | None) -> Any: +def location_selector( + locations: list[Any], selected_location_id: str | None, selected_event_type: str = "" +) -> Any: """Render location selector dropdown.""" - options = [Option("Select a location...", value="", selected=not selected_location_id)] + is_all = selected_location_id in ("", "all", None) + options = [Option("All locations", value="all", selected=is_all)] for loc in locations: options.append(Option(loc.name, value=loc.id, selected=loc.id == selected_location_id)) @@ -131,25 +149,70 @@ def location_selector(locations: list[Any], selected_location_id: str | None) -> Select( *options, name="location_id", + id="location-filter", cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm " "focus:border-amber-500 focus:ring-amber-500 sm:text-sm", hx_get="/event-log", hx_trigger="change", hx_target="#event-log-content", hx_swap="innerHTML", - hx_include="this", + hx_include="#event-type-filter", ), - cls="mb-4 max-w-xs", + cls="max-w-xs", + ) + + +# List of known event types for the filter dropdown +EVENT_TYPES = [ + "AnimalCohortCreated", + "AnimalMoved", + "AnimalOutcome", + "AnimalTagEnded", + "AnimalTagged", + "FeedGiven", + "FeedPurchased", + "HatchRecorded", + "ProductCollected", + "ProductSold", +] + + +def event_type_selector(selected_event_type: str = "") -> Any: + """Render event type filter dropdown.""" + options = [Option("All types", value="", selected=not selected_event_type)] + for event_type in EVENT_TYPES: + options.append( + Option(event_type, value=event_type, selected=event_type == selected_event_type) + ) + + return Div( + Label("Event Type", cls="text-sm font-medium text-stone-700"), + Select( + *options, + name="event_type", + id="event-type-filter", + cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm " + "focus:border-amber-500 focus:ring-amber-500 sm:text-sm", + hx_get="/event-log", + hx_trigger="change", + hx_target="#event-log-content", + hx_swap="innerHTML", + hx_include="#location-filter", + ), + cls="max-w-xs", ) def event_log_panel( - events: list[dict[str, Any]], locations: list[Any], selected_location_id: str | None + events: list[dict[str, Any]], + locations: list[Any], + selected_location_id: str | None, + selected_event_type: str = "", ) -> Any: """Render the full event log panel.""" # Find location name for header location_name = None - if selected_location_id: + if selected_location_id and selected_location_id not in ("", "all"): for loc in locations: if loc.id == selected_location_id: location_name = loc.name @@ -159,14 +222,13 @@ def event_log_panel( return Div( H3(header_text, cls="text-lg font-semibold mb-4"), - location_selector(locations, selected_location_id), Div( - event_log_list(events) - if selected_location_id - else P( - "Select a location to view events.", - cls="text-stone-500 text-sm text-center py-4", - ), + location_selector(locations, selected_location_id, selected_event_type), + event_type_selector(selected_event_type), + cls="flex gap-4 mb-4", + ), + Div( + event_log_list(events), id="event-log-content", ), cls="bg-white rounded-lg shadow p-4", diff --git a/src/animaltrack/web/templates/move.py b/src/animaltrack/web/templates/move.py index a94be6d..3cc9f5c 100644 --- a/src/animaltrack/web/templates/move.py +++ b/src/animaltrack/web/templates/move.py @@ -68,7 +68,6 @@ def move_form( # Selection component - show checkboxes if animals provided and > 1 # Wrapped in a container with ID for HTMX targeting selection_content = None - subset_mode = False if animals and len(animals) > 1: # Show checkbox list for subset selection location_info = f" from {from_location_name}" if from_location_name else "" @@ -76,7 +75,6 @@ def move_form( P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"), animal_checkbox_list(animals, resolved_ids), ) - subset_mode = True elif resolved_count > 0: location_info = f" from {from_location_name}" if from_location_name else "" selection_content = Div( @@ -145,7 +143,6 @@ def move_form( Hidden(name="from_location_id", value=from_location_id or ""), Hidden(name="resolver_version", value="v1"), Hidden(name="confirmed", value=""), - Hidden(name="subset_mode", value="true" if subset_mode else ""), Hidden(name="nonce", value=str(ULID())), # Submit button Button("Move Animals", type="submit", cls=ButtonT.primary), diff --git a/tests/test_web_events.py b/tests/test_web_events.py index 0596759..8357522 100644 --- a/tests/test_web_events.py +++ b/tests/test_web_events.py @@ -108,11 +108,11 @@ class TestEventLogRoute: """Tests for GET /event-log route.""" def test_event_log_without_location_shows_selector(self, client): - """Event log without location_id shows location selector.""" + """Event log without location_id shows location selector with 'All locations' default.""" response = client.get("/event-log") assert response.status_code == 200 - # Should show location selector prompt - assert "Select a location" in response.text + # Should show location selector with "All locations" option + assert "All locations" in response.text def test_event_log_returns_empty_for_new_location(self, client, valid_location_id): """Event log returns empty state for location with no events."""