Compare commits

..

12 Commits

Author SHA1 Message Date
1853bca745 Fix UIkit tab/switcher list markers showing as squares
All checks were successful
Deploy / deploy (push) Successful in 1m39s
Add global CSS to remove ::marker pseudo-elements from uk-tab and
uk-switcher components. Also clean up tab structure to match MonsterUI
idioms (uk-active on Li, use None instead of empty string for cls).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:56:25 +00:00
94701c2f7e Fix registry filter input/button width ratio
Use Grid with col-span instead of flex to give the filter input 10/12
of the width and the Apply button only 2/12.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:42:34 +00:00
5c12eb553c Add build version indicator to sidebar menu
Display commit date and short hash (e.g., "2026-01-08 fb59ef7") below
the ANIMALTRACK title in the sidebar. In development, reads from git
directly; in Docker, reads from BUILD_DATE/BUILD_COMMIT env vars
injected at build time from the Nix flake.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:36:44 +00:00
fb59ef72a8 Fix FastHTML empty value attribute omission in select options
FastHTML omits the value attribute when value="" (empty string), causing
browsers to use the option's text content as the submitted value. This
made forms send "Keep current" or "No change" text instead of empty
string, failing Pydantic enum validation.

Fixed by using "-" as a sentinel value instead of "" for "no change"
options, and updating route handlers to treat "-" as None.

Affected forms:
- Promote form (sex, repro_status)
- Update attributes form (sex, life_stage, repro_status)
- Outcome form (yield_product_code)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:31:11 +00:00
29fbe68c73 Add backdating support to egg harvest and sell forms
Both forms now have datetime pickers like the feed forms, allowing
users to record events at past timestamps. Each form has a unique
field_id (harvest_datetime, sell_datetime) to avoid conflicts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:24:26 +00:00
4b951d428f Remove bootstrapping data. 2026-01-08 09:21:09 +00:00
1d322de67b Fix datetime picker on multi-form pages
The event_datetime_field JavaScript used querySelector to find the
ts_utc hidden input by name, which breaks when multiple forms have
ts_utc fields (like feed give and purchase forms). Now each hidden
field gets a unique ID based on the field_id parameter, and the
JavaScript uses getElementById for correct scoping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:17:56 +00:00
d4a29130f6 Fix feed error response rendering with to_xml
The 422 error handlers were using str() to convert FT objects to HTML,
which produces Python repr output instead of HTML. Changed to use
to_xml() like other routes (eggs.py, products.py) do.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:11:08 +00:00
3f510d8d76 Add animal_id filter support for registry selection
The registry selection feature builds filters like animal_id:X|Y|Z
but the parser didn't recognize animal_id as a valid field.

- Add animal_id to VALID_FIELDS in parser.py
- Add animal_id handler in resolver.py _build_filter_clause

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 08:44:28 +00:00
abb1c87e6c Add registry selection + expandable affected animals
Registry improvements:
- Add checkbox column for selecting animals in the table
- Add selection toolbar with count display
- Add Actions dropdown (Move, Add Tag, Update Attributes, Record Outcome)
- Selection persists across infinite scroll via JavaScript
- Navigate to action page with filter=animal_id:X|Y|Z for selected animals

Event detail improvements:
- Show more animal details: sex (M/F/?), life stage, location name
- Add "Show all X animals" button when >20 animals affected
- HTMX endpoint to load full list on demand
- Separate affected_animals_list component for HTMX swaps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:03:25 +00:00
ad1f91098b Fix MonsterUI Select bug, UI improvements, enable animal rename
- Replace broken MonsterUI LabelSelect with raw HTML Select elements
  (was sending label text instead of value attribute)
- Fix wrong ReproStatus options in promote form (use enum values)
- Add spacing between name and details in animal selection list
- Fix registry filter layout, add Clear button
- Use phonetic ID in animal details panel title
- Change feed price input from cents to euros
- Allow renaming already-identified animals (remove identified check)
- Fix FeedGiven 422 error (same MonsterUI Select bug)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:57:09 +00:00
14bf2fa4ae Fix CSRF 403, improve registry UI, add phonetic IDs
- Fix CSRF token handling for production: generate tokens with HMAC,
  set cookie via afterware, inject into HTMX requests via JS
- Improve registry page: filter at top with better proportions,
  compact horizontal pill layout for facets
- Add phonetic ID encoding (e.g., "tobi-kafu-meli") for animal display
  instead of truncated ULIDs
- Remove "subadult" life stage (migration converts to juvenile)
- Change "Death (natural)" outcome label to just "Death"
- Show sex/life stage in animal picker alongside species/location

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:20:26 +00:00
31 changed files with 1030 additions and 381 deletions

View File

@@ -1,118 +0,0 @@
# AnimalTrack Data Entry Steps
# Based on data.txt historical records
# Petre's duck and goose flock
## Prerequisites
- Ensure you have at least one location set up in AnimalTrack
## Step 1: Initial Duck Flock (13.02.2025)
Action: Create Cohort
- Species: duck
- Life stage: adult
- Origin: purchased (or unknown)
- Sex: female, Count: 35
Repeat for males:
- Sex: male, Count: 6
Notes: "Initial flock - 41 ducks total, 1 noted as adult drake"
## Step 2: Initial Chinese Geese (13.02.2025)
Action: Create Cohort
- Species: goose
- Life stage: adult
- Origin: unknown
- Sex: unknown, Count: 3
Notes: "Chinese geese - existed before tracking started"
## Step 3: Gift - 2 Female Ducks (27.04.2025)
Action: Record Outcome
- Filter: species:duck sex:female status:alive
- Select: 2 animals
- Outcome: sold
Notes: "gift"
## Step 4: Harvest - 4 Male Ducks (12.05.2025)
Action: Record Outcome
- Filter: species:duck sex:male status:alive
- Select: 4 animals
- Outcome: harvest
## Step 5: Hatch - 13 Ducklings (15.06.2025)
Action: Record Hatch
- Species: duck
- Hatched live: 13
- Location: [your duck area]
## Step 6: Put Down - 1 Weak Female Duck (27.06.2025)
Action: Record Outcome
- Filter: species:duck sex:female status:alive
- Select: 1 animal
- Outcome: death
Notes: "weak, put down"
## Checkpoint 27.06.2025
Expected state: 34 adult ducks (32 female, 2 male) + 13 ducklings
## Step 7: Found Dead - 1 Female Duck (22.07.2025)
Action: Record Outcome
- Filter: species:duck sex:female status:alive life_stage:adult
- Select: 1 animal
- Outcome: death
Notes: "found dead"
## Step 8: Harvest - 8 Female Ducks (25.07.2025)
Action: Record Outcome
- Filter: species:duck sex:female status:alive life_stage:adult
- Select: 8 animals
- Outcome: harvest
## Step 9: Harvest - 10 Female Ducks (06.08.2025)
Action: Record Outcome
- Filter: species:duck sex:female status:alive life_stage:adult
- Select: 10 animals
- Outcome: harvest
Notes: "After this: 14 female + 2 male adults + 13 ducklings"
## Step 10: Purchase - 4 Toulouse Goslings (04.08.2025)
Action: Create Cohort
- Species: goose
- Life stage: hatchling
- Origin: purchased
- Sex: unknown, Count: 4
Notes: "Toulouse goslings from OLX (Oia)"
## Step 11: Harvest - 3 Chinese Geese (08.10.2025)
Action: Record Outcome
- Filter: species:goose status:alive
- Select: 3 animals (the Chinese geese)
- Outcome: harvest
Notes: "All Chinese geese harvested, 4 Toulouse remain"
## Step 12: Predator Loss - 2 Geese (19.10.2025)
Action: Record Outcome
- Filter: species:goose status:alive
- Select: 2 animals
- Outcome: predator_loss
## Step 13: Predator Loss - 1 Female Duck (19.12.2025)
Action: Record Outcome
- Filter: species:duck sex:female status:alive
- Select: 1 animal
- Outcome: predator_loss
## Step 14: Predator Loss - 2 Ducks (25.12.2025)
Action: Record Outcome
- Filter: species:duck status:alive
- Select: 2 animals (1 female, 1 male)
- Outcome: predator_loss
## Final State (25.12.2025)
- 27 ducks (mix of original adults + grown June hatchlings)
- 2 Toulouse geese
## Note on Ducklings Sex
The 13 ducklings from June need their sex updated once you know.
After they mature, use:
Action: Update Attributes
- Filter: species:duck life_stage:hatchling status:alive
- Update life_stage to: adult
- For known females/males, update sex accordingly

View File

@@ -1,14 +0,0 @@
41 ducks, 1 adult drake 13.02.2025
-2 female ducks 27.04.2025 gift
-4 male ducks 12.05.2025 harvest
+13 ducklings 15.06.2025
-1 weak female duck 27.06.2025 put it down
34 adult ducks of which 2 drakes, 27.06.2025
-1 female duck, found dead, 22.07.2025
-8 female ducks, 25.07.2025 harvest
-10 female ducks 6.08.2025 harvest; 15 adults (14 female 2 male) + 13 ducklings left
+4 goslings 4.08.2025 bought from OLX (Oiã)
-3 Chinese geese 08.10.2025 harvest (0 chinese and 4 toulouse goslings left)
-2 geese predator 19.10.2025
-1 female duck predator 19.12.2025
-2 ducks (1 female and 1 male) predator 25.12.2025

View File

@@ -1,4 +1,4 @@
{ pkgs, pythonEnv, python }:
{ pkgs, pythonEnv, python, buildDate ? "unknown", buildCommit ? "unknown" }:
let
# Build animaltrack as a package
@@ -59,6 +59,8 @@ pkgs.dockerTools.buildImage {
"PATH=${pkgs.lib.makeBinPath [ pkgs.busybox pkgs.bash pkgs.sqlite pythonEnv animaltrack ]}"
"PYTHONPATH=${pythonEnv}/${pythonEnv.sitePackages}:${animaltrack}/${pythonEnv.sitePackages}"
"PYTHONUNBUFFERED=1"
"BUILD_DATE=${buildDate}"
"BUILD_COMMIT=${buildCommit}"
];
ExposedPorts = {
"5000/tcp" = {};

View File

@@ -67,7 +67,12 @@
in
{
packages.${system} = {
dockerImage = import ./docker.nix { inherit pkgs pythonEnv python; };
dockerImage = let
buildDate = let
d = self.lastModifiedDate or "00000000000000";
in "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}";
buildCommit = self.shortRev or self.dirtyShortRev or "unknown";
in import ./docker.nix { inherit pkgs pythonEnv python buildDate buildCommit; };
};
devShells.${system}.default = pkgs.mkShell {

View File

@@ -0,0 +1,70 @@
-- ABOUTME: Removes subadult life stage, migrating existing records to juvenile.
-- ABOUTME: Updates CHECK constraints on animal_registry and live_animals_by_location.
-- Update existing subadult animals to juvenile in animal_registry
UPDATE animal_registry SET life_stage = 'juvenile' WHERE life_stage = 'subadult';
-- Update existing subadult animals in live_animals_by_location
UPDATE live_animals_by_location SET life_stage = 'juvenile' WHERE life_stage = 'subadult';
-- SQLite doesn't support ALTER TABLE to modify CHECK constraints
-- We need to recreate the tables with the updated constraint
-- Step 1: Recreate animal_registry table
CREATE TABLE animal_registry_new (
animal_id TEXT PRIMARY KEY CHECK(length(animal_id) = 26),
species_code TEXT NOT NULL REFERENCES species(code),
identified INTEGER NOT NULL DEFAULT 0 CHECK(identified IN (0, 1)),
nickname TEXT,
sex TEXT NOT NULL CHECK(sex IN ('male', 'female', 'unknown')),
repro_status TEXT NOT NULL CHECK(repro_status IN ('intact', 'wether', 'spayed', 'unknown')),
life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling', 'juvenile', 'adult')),
status TEXT NOT NULL CHECK(status IN ('alive', 'dead', 'harvested', 'sold', 'merged_into')),
location_id TEXT NOT NULL REFERENCES locations(id),
origin TEXT NOT NULL CHECK(origin IN ('hatched', 'purchased', 'rescued', 'unknown')),
born_or_hatched_at INTEGER,
acquired_at INTEGER,
first_seen_utc INTEGER NOT NULL,
last_event_utc INTEGER NOT NULL
);
INSERT INTO animal_registry_new SELECT * FROM animal_registry;
DROP TABLE animal_registry;
ALTER TABLE animal_registry_new RENAME TO animal_registry;
-- Recreate indexes for animal_registry
CREATE UNIQUE INDEX idx_ar_nickname_active
ON animal_registry(nickname)
WHERE nickname IS NOT NULL
AND status NOT IN ('dead', 'harvested', 'sold', 'merged_into');
CREATE INDEX idx_ar_location ON animal_registry(location_id);
CREATE INDEX idx_ar_filter ON animal_registry(species_code, sex, life_stage, identified);
CREATE INDEX idx_ar_status ON animal_registry(status);
CREATE INDEX idx_ar_last_event ON animal_registry(last_event_utc);
-- Step 2: Recreate live_animals_by_location table
CREATE TABLE live_animals_by_location_new (
animal_id TEXT PRIMARY KEY CHECK(length(animal_id) = 26),
location_id TEXT NOT NULL REFERENCES locations(id),
species_code TEXT NOT NULL REFERENCES species(code),
identified INTEGER NOT NULL DEFAULT 0 CHECK(identified IN (0, 1)),
nickname TEXT,
sex TEXT NOT NULL CHECK(sex IN ('male', 'female', 'unknown')),
repro_status TEXT NOT NULL CHECK(repro_status IN ('intact', 'wether', 'spayed', 'unknown')),
life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling', 'juvenile', 'adult')),
first_seen_utc INTEGER NOT NULL,
last_move_utc INTEGER,
tags TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(tags))
);
INSERT INTO live_animals_by_location_new SELECT * FROM live_animals_by_location;
DROP TABLE live_animals_by_location;
ALTER TABLE live_animals_by_location_new RENAME TO live_animals_by_location;
-- Recreate indexes for live_animals_by_location
CREATE INDEX idx_labl_location ON live_animals_by_location(location_id);
CREATE INDEX idx_labl_filter ON live_animals_by_location(location_id, species_code, sex, life_stage, identified);

5
nits
View File

@@ -1,5 +0,0 @@
Animal detail slider, event timeline, I want to be able to click on events and go to a detail view about them.
Create animal cohort form, click button, succeeds, comes back to the same form when successful. There should be a notification that it worked and link to detail view of the event created. Audit all other forms for this.
If I want to sell 2 ducks matching "species:duck sex:female status:alive", how do I do it? The record outcome form says 35 animals selected if I use that filter.

View File

@@ -168,7 +168,7 @@ CREATE TABLE animal_registry (
nickname TEXT,
sex TEXT NOT NULL CHECK(sex IN ('male','female','unknown')),
repro_status TEXT NOT NULL CHECK(repro_status IN ('intact','wether','spayed','unknown')),
life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','subadult','adult')),
life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','adult')),
status TEXT NOT NULL CHECK(status IN ('alive','dead','harvested','sold','merged_into')),
location_id TEXT NOT NULL REFERENCES locations(id),
origin TEXT NOT NULL CHECK(origin IN ('hatched','purchased','rescued','unknown')),
@@ -193,7 +193,7 @@ CREATE TABLE live_animals_by_location (
nickname TEXT,
sex TEXT NOT NULL CHECK(sex IN ('male','female','unknown')),
repro_status TEXT NOT NULL CHECK(repro_status IN ('intact','wether','spayed','unknown')),
life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','subadult','adult')),
life_stage TEXT NOT NULL CHECK(life_stage IN ('hatchling','juvenile','adult')),
first_seen_utc INTEGER NOT NULL,
last_move_utc INTEGER,
tags TEXT NOT NULL CHECK(json_valid(tags))

View File

@@ -0,0 +1,34 @@
# ABOUTME: Provides build information (date + commit hash) for version display.
# ABOUTME: Reads from env vars (Docker) or git (development).
import os
import subprocess
def get_build_info() -> str:
"""Returns build info in '2025-01-08 fb59ef7' format.
Checks BUILD_DATE and BUILD_COMMIT env vars first (set by Docker),
then falls back to reading git info at runtime (development).
Returns 'unknown' if neither source is available.
"""
build_date = os.environ.get("BUILD_DATE")
build_commit = os.environ.get("BUILD_COMMIT")
if build_date and build_commit:
return f"{build_date} {build_commit}"
# Try git at runtime (development mode)
try:
result = subprocess.run(
["git", "log", "-1", "--format=%cs %h"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (subprocess.SubprocessError, FileNotFoundError, OSError):
pass
return "unknown"

View File

@@ -26,7 +26,6 @@ class LifeStage(str, Enum):
HATCHLING = "hatchling"
JUVENILE = "juvenile"
SUBADULT = "subadult"
ADULT = "adult"

View File

@@ -1,8 +1,18 @@
# ABOUTME: ULID generation utility for creating unique identifiers.
# ABOUTME: Provides a simple wrapper around the python-ulid library.
# ABOUTME: Provides phonetic encoding for human-readable animal ID display.
from ulid import ULID
# Consonant-Vowel syllables for phonetic encoding
# 16 consonants x 5 vowels = 80 syllables
# We avoid confusing letters: c/k, j/g, q/k, h (silent), y (sometimes vowel)
_CONSONANTS = "bdfgklmnprstvwxz" # 16 consonants
_VOWELS = "aeiou" # 5 vowels
# Build syllable lookup table (80 entries, ~6.3 bits each)
_SYLLABLES = tuple(c + v for c in _CONSONANTS for v in _VOWELS)
_SYLLABLE_TO_INDEX = {syl: i for i, syl in enumerate(_SYLLABLES)}
def generate_id() -> str:
"""Generate a new ULID as a 26-character string.
@@ -11,3 +21,56 @@ def generate_id() -> str:
A 26-character uppercase alphanumeric ULID string.
"""
return str(ULID())
def ulid_to_phonetic(ulid_str: str, syllable_count: int = 4) -> str:
"""Convert ULID to phonetic representation.
Uses the random portion of the ULID (bytes 6-16) since the first
6 bytes are timestamp and would be identical for IDs generated
close together. With 4 syllables using 80 syllables each, encodes
~25 bits (about 40 million unique values).
Args:
ulid_str: 26-character ULID string.
syllable_count: Number of syllables (default 4).
Returns:
Hyphenated phonetic string like "tobi-kafu-meli-dova".
"""
# Parse ULID to get raw bytes
ulid = ULID.from_str(ulid_str)
ulid_bytes = ulid.bytes # 16 bytes = 128 bits
# Skip the first 6 bytes (timestamp) and use the random portion
# ULID format: 48 bits timestamp (6 bytes) + 80 bits random (10 bytes)
random_bytes = ulid_bytes[6:] # 10 bytes of randomness
# Convert random bytes to integer for extraction
value = int.from_bytes(random_bytes, "big")
# Extract syllables using modular arithmetic
result = []
for _ in range(syllable_count):
idx = value % 80
result.append(_SYLLABLES[idx])
value //= 80
return "-".join(result)
def format_animal_id(animal_id: str, nickname: str | None = None) -> str:
"""Format animal ID for display.
Returns nickname if available, otherwise phonetic encoding.
Args:
animal_id: The 26-character ULID.
nickname: Optional animal nickname.
Returns:
Display string for the animal.
"""
if nickname:
return nickname
return ulid_to_phonetic(animal_id)

View File

@@ -7,7 +7,7 @@ from animaltrack.selection.ast import FieldFilter, FilterAST
# Supported filter fields
VALID_FIELDS = frozenset(
{"location", "species", "sex", "life_stage", "identified", "tag", "status"}
{"animal_id", "location", "species", "sex", "life_stage", "identified", "tag", "status"}
)
# Fields that can be used as flags (without :value)

View File

@@ -148,7 +148,16 @@ def _build_filter_clause(field_filter: FieldFilter, ts_utc: int) -> tuple[str, l
field = field_filter.field
values = list(field_filter.values)
if field == "species":
if field == "animal_id":
# Direct animal ID filter
placeholders = ",".join("?" * len(values))
query = f"""
SELECT animal_id FROM animal_registry
WHERE animal_id IN ({placeholders})
"""
return query, values
elif field == "species":
# Species from animal_registry (current state)
placeholders = ",".join("?" * len(values))
query = f"""

View File

@@ -18,6 +18,7 @@ from animaltrack.web.exceptions import AuthenticationError, AuthorizationError
from animaltrack.web.middleware import (
auth_before,
csrf_before,
generate_csrf_token,
request_id_before,
)
from animaltrack.web.responses import error_toast
@@ -105,6 +106,39 @@ def create_app(
return None
def after(req: Request, resp, sess):
"""Afterware to set CSRF cookie on page responses."""
# Skip in dev mode (CSRF is bypassed anyway)
if settings.dev_mode:
return resp
# Only set cookie on GET requests (page loads)
if req.method != "GET":
return resp
# Check if cookie already set
if settings.csrf_cookie_name in req.cookies:
return resp
# Skip non-HTML responses (API endpoints, static files, etc.)
content_type = resp.headers.get("content-type", "")
if "text/html" not in content_type:
return resp
# Generate and set token
token = generate_csrf_token(settings.csrf_secret)
# Set cookie - httponly=False so JS can read it for HTMX
resp.set_cookie(
settings.csrf_cookie_name,
token,
httponly=False,
samesite="strict",
secure=not settings.dev_mode,
max_age=86400, # 24 hours
)
return resp
# Configure beforeware with skip patterns
beforeware = Beforeware(
before,
@@ -139,6 +173,7 @@ def create_app(
# Create FastHTML app with HTMX extensions, MonsterUI theme, and static path
app, rt = fast_app(
before=beforeware,
after=after,
hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config
exts=["head-support", "preload"],
static_path=static_path_for_fasthtml,

View File

@@ -1,8 +1,11 @@
# ABOUTME: Middleware functions for authentication, CSRF, and request logging.
# ABOUTME: Implements Beforeware pattern for FastHTML request processing.
import hashlib
import hmac
import ipaddress
import json
import secrets
import time
from urllib.parse import urlparse
@@ -18,6 +21,27 @@ from animaltrack.repositories.users import UserRepository
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
def generate_csrf_token(secret: str) -> str:
"""Generate a CSRF token using HMAC.
Creates a random nonce and signs it with the secret to produce a token.
The token format is: nonce:signature (hex encoded).
Args:
secret: The csrf_secret from settings.
Returns:
A token string in format "nonce:signature".
"""
nonce = secrets.token_hex(16)
signature = hmac.new(
secret.encode("utf-8"),
nonce.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{nonce}:{signature}"
def is_safe_method(method: str) -> bool:
"""Check if HTTP method is safe (doesn't require CSRF protection).

View File

@@ -159,7 +159,7 @@ async def animal_cohort(request: Request, session):
return _render_cohort_error(
request, locations, species_list, "Please select a location", form
)
if not life_stage or life_stage not in ("hatchling", "juvenile", "subadult", "adult"):
if not life_stage or life_stage not in ("hatchling", "juvenile", "adult"):
return _render_cohort_error(
request, locations, species_list, "Please select a life stage", form
)
@@ -408,13 +408,13 @@ def promote_index(request: Request, animal_id: str):
if animal.status != "alive":
return HTMLResponse(content="Only alive animals can be promoted", status_code=400)
if animal.identified:
return HTMLResponse(content="Animal is already identified", status_code=400)
# Title depends on whether animal is already identified (rename vs promote)
title = "Rename Animal - AnimalTrack" if animal.identified else "Promote Animal - AnimalTrack"
return render_page(
request,
promote_form(animal),
title="Promote Animal - AnimalTrack",
title=title,
active_nav=None,
)
@@ -426,10 +426,13 @@ async def animal_promote(request: Request):
form = await request.form()
# Extract form data
# Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes
animal_id = form.get("animal_id", "")
nickname = form.get("nickname", "") or None
sex = form.get("sex", "") or None
repro_status = form.get("repro_status", "") or None
sex_raw = form.get("sex", "")
sex = None if sex_raw in ("", "-") else sex_raw
repro_raw = form.get("repro_status", "")
repro_status = None if repro_raw in ("", "-") else repro_raw
distinguishing_traits = form.get("distinguishing_traits", "") or None
notes = form.get("notes", "") or None
nonce = form.get("nonce")
@@ -448,9 +451,6 @@ async def animal_promote(request: Request):
if animal.status != "alive":
return _render_promote_error(request, animal, "Only alive animals can be promoted", form)
if animal.identified:
return _render_promote_error(request, animal, "Animal is already identified", form)
# Create payload
try:
payload = AnimalPromotedPayload(
@@ -1037,10 +1037,14 @@ async def animal_attrs(request: Request, session):
form = await request.form()
# Extract form data
# Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes
filter_str = form.get("filter", "")
sex = form.get("sex", "").strip() or None
life_stage = form.get("life_stage", "").strip() or None
repro_status = form.get("repro_status", "").strip() or None
sex_raw = form.get("sex", "").strip()
sex = None if sex_raw in ("", "-") else sex_raw
life_stage_raw = form.get("life_stage", "").strip()
life_stage = None if life_stage_raw in ("", "-") else life_stage_raw
repro_raw = form.get("repro_status", "").strip()
repro_status = None if repro_raw in ("", "-") else repro_raw
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
@@ -1280,7 +1284,9 @@ async def animal_outcome(request: Request, session):
nonce = form.get("nonce")
# Yield item fields
yield_product_code = form.get("yield_product_code", "").strip() or None
# Note: "-" is used as sentinel for "no selection" because FastHTML omits empty value attributes
yield_product_raw = form.get("yield_product_code", "").strip()
yield_product_code = None if yield_product_raw in ("", "-") else yield_product_raw
yield_unit = form.get("yield_unit", "").strip() or None
yield_quantity_str = form.get("yield_quantity", "").strip()
yield_weight_str = form.get("yield_weight_kg", "").strip()

View File

@@ -26,6 +26,26 @@ from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import render_page
from animaltrack.web.templates.eggs import eggs_page
def _parse_ts_utc(form_value: str | None) -> int:
"""Parse ts_utc from form, defaulting to current time if empty or zero.
Args:
form_value: The ts_utc value from form data.
Returns:
Timestamp in milliseconds. Returns current time if form_value is
None, empty, or "0".
"""
if not form_value or form_value == "0":
return int(time.time() * 1000)
try:
ts = int(form_value)
return ts if ts > 0 else int(time.time() * 1000)
except (ValueError, TypeError):
return int(time.time() * 1000)
# APIRouter for multi-file route organization
ar = APIRouter()
@@ -147,8 +167,8 @@ async def product_collected(request: Request, session):
request, locations, products, location_id, "Quantity must be at least 1"
)
# Get current timestamp
ts_utc = int(time.time() * 1000)
# Get timestamp - use provided or current (supports backdating)
ts_utc = _parse_ts_utc(form.get("ts_utc"))
# Resolve ducks at location
resolved_ids = resolve_ducks_at_location(db, location_id, ts_utc)
@@ -276,8 +296,8 @@ async def product_sold(request: Request, session):
request, locations, products, product_code, "Total price cannot be negative"
)
# Get current timestamp
ts_utc = int(time.time() * 1000)
# Get timestamp - use provided or current (supports backdating)
ts_utc = _parse_ts_utc(form.get("ts_utc"))
# Create product service
event_store = EventStore(db)

View File

@@ -247,29 +247,60 @@ def event_log_index(request: Request, htmx):
)
def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
def get_event_animals(db: Any, event_id: str, limit: int | None = None) -> list[dict[str, Any]]:
"""Get animals affected by an event with display info.
Args:
db: Database connection.
event_id: Event ID to look up animals for.
limit: Maximum number of animals to return (None for all).
Returns:
List of animal dicts with id, nickname, species_name.
List of animal dicts with id, nickname, species_name, sex, life_stage, location_name.
"""
rows = db.execute(
"""
SELECT ar.animal_id, ar.nickname, s.name as species_name
query = """
SELECT ar.animal_id, ar.nickname, s.name as species_name,
ar.sex, ar.life_stage, l.name as location_name
FROM event_animals ea
JOIN animal_registry ar ON ar.animal_id = ea.animal_id
JOIN species s ON s.code = ar.species_code
LEFT JOIN locations l ON l.id = ar.location_id
WHERE ea.event_id = ?
ORDER BY ar.nickname NULLS LAST, ar.animal_id
""",
(event_id,),
).fetchall()
"""
if limit:
query += f" LIMIT {limit}"
return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows]
rows = db.execute(query, (event_id,)).fetchall()
return [
{
"id": row[0],
"nickname": row[1],
"species_name": row[2],
"sex": row[3],
"life_stage": row[4],
"location_name": row[5],
}
for row in rows
]
def get_event_animal_count(db: Any, event_id: str) -> int:
"""Get count of animals affected by an event.
Args:
db: Database connection.
event_id: Event ID to count animals for.
Returns:
Total number of animals affected.
"""
row = db.execute(
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
(event_id,),
).fetchone()
return row[0] if row else 0
@ar("/events/{event_id}")
@@ -292,8 +323,9 @@ def event_detail(request: Request, event_id: str, htmx):
# Check if tombstoned
is_tombstoned = event_store.is_tombstoned(event_id)
# Get affected animals
affected_animals = get_event_animals(db, event_id)
# Get affected animals (limited to first 20 for performance)
affected_animals = get_event_animals(db, event_id, limit=20)
total_animal_count = get_event_animal_count(db, event_id)
# Get location names if entity_refs has location IDs
location_names = {}
@@ -317,7 +349,9 @@ def event_detail(request: Request, event_id: str, htmx):
user_role = auth.role if auth else None
# Build the panel
panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
panel = event_detail_panel(
event, affected_animals, total_animal_count, is_tombstoned, location_names, user_role
)
# HTMX request (slide-over) → return just panel
if htmx.request:
@@ -327,6 +361,24 @@ def event_detail(request: Request, event_id: str, htmx):
return render_page(request, panel, title=f"Event {event.id}")
@ar("/events/{event_id}/animals")
def event_animals_all(request: Request, event_id: str):
"""GET /events/{event_id}/animals - Get all affected animals for an event.
This endpoint is used via HTMX to load the full list when user clicks "Show all".
"""
from animaltrack.web.templates.event_detail import affected_animals_list
db = request.app.state.db
# Get all animals (no limit)
animals = get_event_animals(db, event_id)
total_count = len(animals)
# Return just the list component for HTMX swap
return affected_animals_list(animals, total_count, expanded=True)
@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).

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
import time
from typing import Any
from fasthtml.common import APIRouter, add_toast
from fasthtml.common import APIRouter, add_toast, to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -273,7 +273,7 @@ async def feed_purchased(request: Request, session):
feed_type_code = form.get("feed_type_code", "")
bag_size_kg_str = form.get("bag_size_kg", "0")
bags_count_str = form.get("bags_count", "0")
bag_price_cents_str = form.get("bag_price_cents", "0")
bag_price_euros_str = form.get("bag_price_euros", "0")
vendor = form.get("vendor") or None
notes = form.get("notes") or None
nonce = form.get("nonce")
@@ -329,9 +329,10 @@ async def feed_purchased(request: Request, session):
"Bags count must be at least 1",
)
# Validate bag_price_cents
# Validate bag_price_euros and convert to cents
try:
bag_price_cents = int(bag_price_cents_str)
bag_price_euros = float(bag_price_euros_str)
bag_price_cents = int(round(bag_price_euros * 100))
except ValueError:
return _render_purchase_error(
request,
@@ -436,7 +437,7 @@ def _render_give_error(
HTMLResponse with 422 status.
"""
return HTMLResponse(
content=str(
content=to_xml(
render_page(
request,
feed_page(
@@ -470,7 +471,7 @@ def _render_purchase_error(request, locations, feed_types, error_message):
HTMLResponse with 422 status.
"""
return HTMLResponse(
content=str(
content=to_xml(
render_page(
request,
feed_page(

View File

@@ -4,7 +4,7 @@
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Span
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Select, Span
from monsterui.all import (
Alert,
AlertT,
@@ -47,6 +47,7 @@ def event_datetime_field(
"""
picker_id = f"{field_id}_picker"
input_id = f"{field_id}_input"
ts_utc_id = f"{field_id}_ts_utc"
# If initial value is set, start with picker expanded
has_initial = bool(initial_value)
@@ -57,7 +58,7 @@ def event_datetime_field(
toggle_onclick = f"""
var picker = document.getElementById('{picker_id}');
var input = document.getElementById('{input_id}');
var tsField = document.querySelector('input[name="ts_utc"]');
var tsField = document.getElementById('{ts_utc_id}');
if (picker.style.display === 'none') {{
picker.style.display = 'block';
this.textContent = 'Use current time';
@@ -70,14 +71,14 @@ def event_datetime_field(
"""
# Inline JavaScript for input change handler
input_onchange = """
var tsField = document.querySelector('input[name="ts_utc"]');
if (tsField && this.value) {
input_onchange = f"""
var tsField = document.getElementById('{ts_utc_id}');
if (tsField && this.value) {{
var date = new Date(this.value);
tsField.value = date.getTime().toString();
} else if (tsField) {
}} else if (tsField) {{
tsField.value = '0';
}
}}
"""
return Div(
@@ -111,7 +112,7 @@ def event_datetime_field(
),
cls="mt-1",
),
Hidden(name="ts_utc", value=initial_ts),
Hidden(id=ts_utc_id, name="ts_utc", value=initial_ts),
cls="space-y-1",
)
@@ -174,7 +175,6 @@ def cohort_form(
life_stages = [
("hatchling", "Hatchling"),
("juvenile", "Juvenile"),
("subadult", "Subadult"),
("adult", "Adult"),
]
life_stage_options = [
@@ -437,9 +437,15 @@ def promote_form(
"""
display_id = f"{animal.animal_id[:8]}..."
# Title and action text depend on whether animal is already identified
is_rename = animal.identified
form_title = "Rename Animal" if is_rename else "Promote Animal"
action_text = "Renaming" if is_rename else "Promoting"
# Build sex options (optional - can refine current value)
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
sexes = [
("", "Keep current"),
("-", "Keep current"),
("female", "Female"),
("male", "Male"),
]
@@ -447,13 +453,14 @@ def promote_form(
for code, label in sexes:
sex_options.append(Option(label, value=code, selected=code == selected_sex))
# Build repro status options (optional)
# Build repro status options (optional) - must match ReproStatus enum
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
repro_statuses = [
("", "Unknown"),
("breeding", "Breeding"),
("non_breeding", "Non-Breeding"),
("broody", "Broody"),
("molting", "Molting"),
("-", "Keep current"),
("intact", "Intact"),
("wether", "Wether (castrated male)"),
("spayed", "Spayed (female)"),
("unknown", "Unknown"),
]
repro_status_options = []
for code, label in repro_statuses:
@@ -467,36 +474,34 @@ def promote_form(
error_component = Alert(error, cls=AlertT.warning)
return Form(
H2("Promote Animal", cls="text-xl font-bold mb-4"),
H2(form_title, cls="text-xl font-bold mb-4"),
# Animal info
Div(
P(f"Promoting: {display_id}", cls="text-sm text-stone-400"),
P(f"{action_text}: {display_id}", cls="text-sm text-stone-400"),
P(f"Species: {animal.species_code}, Sex: {animal.sex}", cls="text-sm text-stone-400"),
cls="p-3 bg-slate-800 rounded-md mb-4",
),
# Error message if present
error_component,
# Nickname input (optional)
# Nickname input - label changes for rename vs promote
LabelInput(
"Nickname (optional)",
"New Name" if is_rename else "Nickname (optional)",
id="nickname",
name="nickname",
value=nickname_value,
placeholder="Give this animal a name",
value=nickname_value or (animal.nickname or ""),
placeholder="Enter a name for this animal",
),
# Sex refinement dropdown (optional)
LabelSelect(
*sex_options,
label="Refine Sex (optional)",
id="sex",
name="sex",
# Sex refinement dropdown (optional) - using raw Select to fix value handling
Div(
FormLabel("Refine Sex (optional)", _for="sex"),
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
cls="space-y-2",
),
# Repro status dropdown (optional)
LabelSelect(
*repro_status_options,
label="Reproductive Status",
id="repro_status",
name="repro_status",
# Repro status dropdown (optional) - using raw Select to fix value handling
Div(
FormLabel("Reproductive Status", _for="repro_status"),
Select(*repro_status_options, name="repro_status", id="repro_status", cls="uk-select"),
cls="space-y-2",
),
# Distinguishing traits (optional)
LabelTextArea(
@@ -516,8 +521,12 @@ def promote_form(
# Hidden fields
Hidden(name="animal_id", value=animal.animal_id),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Promote to Identified", type="submit", cls=ButtonT.primary),
# Submit button - text changes for rename vs promote
Button(
"Save Changes" if is_rename else "Promote to Identified",
type="submit",
cls=ButtonT.primary,
),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
@@ -1025,25 +1034,27 @@ def attrs_form(
)
# Build sex options
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
sex_options = [
Option("No change", value="", selected=True),
Option("No change", value="-", selected=True),
Option("Female", value="female"),
Option("Male", value="male"),
Option("Unknown", value="unknown"),
]
# Build life stage options
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
life_stage_options = [
Option("No change", value="", selected=True),
Option("No change", value="-", selected=True),
Option("Hatchling", value="hatchling"),
Option("Juvenile", value="juvenile"),
Option("Subadult", value="subadult"),
Option("Adult", value="adult"),
]
# Build repro status options (intact, wether, spayed, unknown)
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
repro_status_options = [
Option("No change", value="", selected=True),
Option("No change", value="-", selected=True),
Option("Intact", value="intact"),
Option("Wether (castrated male)", value="wether"),
Option("Spayed (female)", value="spayed"),
@@ -1073,24 +1084,21 @@ def attrs_form(
),
# Selection container - updated via HTMX when filter changes
selection_container,
# Attribute dropdowns
LabelSelect(
*sex_options,
label="Sex",
id="sex",
name="sex",
# Attribute dropdowns - using raw Select to fix value handling
Div(
FormLabel("Sex", _for="sex"),
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
cls="space-y-2",
),
LabelSelect(
*life_stage_options,
label="Life Stage",
id="life_stage",
name="life_stage",
Div(
FormLabel("Life Stage", _for="life_stage"),
Select(*life_stage_options, name="life_stage", id="life_stage", cls="uk-select"),
cls="space-y-2",
),
LabelSelect(
*repro_status_options,
label="Reproductive Status",
id="repro_status",
name="repro_status",
Div(
FormLabel("Reproductive Status", _for="repro_status"),
Select(*repro_status_options, name="repro_status", id="repro_status", cls="uk-select"),
cls="space-y-2",
),
# Optional notes
LabelTextArea(
@@ -1282,7 +1290,7 @@ def outcome_form(
# Build outcome options
outcome_options = [
Option("Select outcome...", value="", selected=True, disabled=True),
Option("Death (natural)", value="death"),
Option("Death", value="death"),
Option("Harvest", value="harvest"),
Option("Sold", value="sold"),
Option("Predator Loss", value="predator_loss"),
@@ -1290,7 +1298,8 @@ def outcome_form(
]
# Build product options for yield items
product_options = [Option("Select product...", value="", selected=True)]
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
product_options = [Option("Select product...", value="-", selected=True)]
for code, name in products:
product_options.append(Option(f"{name} ({code})", value=code))

View File

@@ -7,6 +7,7 @@ from typing import Any
from fasthtml.common import H2, H3, A, Div, Li, P, Span, Ul
from monsterui.all import Button, ButtonT, Card, Grid
from animaltrack.id_gen import format_animal_id
from animaltrack.repositories.animal_timeline import (
AnimalDetail,
MergeInfo,
@@ -61,7 +62,7 @@ def back_to_registry_link() -> Div:
def animal_header_card(animal: AnimalDetail, merge_info: MergeInfo | None) -> Card:
"""Header card with animal summary."""
display_name = animal.nickname or f"{animal.animal_id[:8]}..."
display_name = format_animal_id(animal.animal_id, animal.nickname)
status_badge = status_badge_component(animal.status)
tags_display = (
@@ -160,10 +161,11 @@ def quick_actions_card(animal: AnimalDetail) -> Card:
href=f"/actions/tag-add?filter=animal_id:{animal.animal_id}",
)
)
if not animal.identified:
# Show "Promote" for unidentified animals, "Rename" for identified ones
promote_label = "Rename" if animal.identified else "Promote"
actions.append(
A(
Button("Promote", cls=ButtonT.default + " w-full"),
Button(promote_label, cls=ButtonT.default + " w-full"),
href=f"/actions/promote/{animal.animal_id}",
)
)

View File

@@ -3,6 +3,7 @@
from fasthtml.common import Div, Input, Label, P, Span
from animaltrack.id_gen import format_animal_id
from animaltrack.repositories.animals import AnimalListItem
@@ -29,7 +30,12 @@ def animal_checkbox_list(
items = []
for animal in animals[:max_display]:
is_checked = animal.animal_id in selected_set
display_name = animal.nickname or animal.animal_id[:8] + "..."
display_name = format_animal_id(animal.animal_id, animal.nickname)
# Format sex as single letter (M/F/?)
sex_code = {"male": "M", "female": "F", "unknown": "?"}.get(animal.sex, "?")
# Format life stage as abbreviation
stage_abbr = animal.life_stage[:3].title() # hat, juv, adu
items.append(
Label(
@@ -41,9 +47,9 @@ def animal_checkbox_list(
cls="uk-checkbox mr-2",
hx_on_change="updateSelectionCount()",
),
Span(display_name, cls="text-stone-200"),
Span(display_name, cls="text-stone-200 mr-1"),
Span(
f" ({animal.species_code}, {animal.location_name})",
f"({animal.species_code}, {sex_code}, {stage_abbr}, {animal.location_name})",
cls="text-stone-500 text-sm",
),
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",

View File

@@ -14,6 +14,19 @@ from animaltrack.web.templates.sidebar import (
)
def TabStyles(): # noqa: N802
"""CSS styles to fix UIkit tab/switcher list markers."""
return Style("""
/* Remove list markers from UIkit tabs and switchers */
.uk-tab, .uk-tab-alt, .uk-switcher, .uk-switcher > li {
list-style: none !important;
}
.uk-tab > li::marker, .uk-tab-alt > li::marker, .uk-switcher > li::marker {
content: none !important;
}
""")
def EventSlideOverStyles(): # noqa: N802
"""CSS styles for event detail slide-over panel."""
return Style("""
@@ -68,6 +81,37 @@ def EventSlideOverScript(): # noqa: N802
""")
def CsrfHeaderScript(): # noqa: N802
"""JavaScript to inject CSRF token into HTMX requests.
Reads the csrf_token cookie and adds it as x-csrf-token header
to all HTMX requests. This is required for POST/PUT/DELETE
requests to pass CSRF validation.
"""
return Script("""
// Read CSRF token from cookie
function getCsrfToken() {
var name = 'csrf_token=';
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length);
}
}
return '';
}
// Configure HTMX to send CSRF token with all requests
document.body.addEventListener('htmx:configRequest', function(event) {
var token = getCsrfToken();
if (token) {
event.detail.headers['x-csrf-token'] = token;
}
});
""")
def EventSlideOver(): # noqa: N802
"""Event detail slide-over panel container."""
return Div(
@@ -124,9 +168,11 @@ def page(
Title(title),
BottomNavStyles(),
SidebarStyles(),
TabStyles(),
EventSlideOverStyles(),
SidebarScript(),
EventSlideOverScript(),
CsrfHeaderScript(),
# Desktop sidebar
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
# Mobile menu drawer

View File

@@ -16,6 +16,7 @@ from monsterui.all import (
from ulid import ULID
from animaltrack.models.reference import Location, Product
from animaltrack.web.templates.actions import event_datetime_field
def eggs_page(
@@ -51,20 +52,8 @@ def eggs_page(
H1("Eggs", cls="text-2xl font-bold mb-6"),
# Tab navigation
TabContainer(
Li(
A(
"Harvest",
href="#",
cls="uk-active" if harvest_active else "",
),
),
Li(
A(
"Sell",
href="#",
cls="" if harvest_active else "uk-active",
),
),
Li(A("Harvest", href="#"), cls="uk-active" if harvest_active else None),
Li(A("Sell", href="#"), cls=None if harvest_active else "uk-active"),
uk_switcher="connect: #egg-forms; animation: uk-animation-fade",
alt=True,
),
@@ -77,7 +66,7 @@ def eggs_page(
error=harvest_error,
action=harvest_action,
),
cls="uk-active" if harvest_active else "",
cls="uk-active" if harvest_active else None,
),
Li(
sell_form(
@@ -86,7 +75,7 @@ def eggs_page(
error=sell_error,
action=sell_action,
),
cls="" if harvest_active else "uk-active",
cls=None if harvest_active else "uk-active",
),
),
cls="p-4",
@@ -163,6 +152,8 @@ def harvest_form(
name="notes",
placeholder="Optional notes",
),
# Event datetime picker (for backdating)
event_datetime_field("harvest_datetime"),
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# Submit button
@@ -263,6 +254,8 @@ def sell_form(
name="notes",
placeholder="Optional notes",
),
# Event datetime picker (for backdating)
event_datetime_field("sell_datetime"),
# Hidden nonce for idempotency
Hidden(name="nonce", value=str(ULID())),
# Submit button

View File

@@ -6,6 +6,7 @@ from typing import Any
from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul
from animaltrack.id_gen import format_animal_id
from animaltrack.models.events import Event
from animaltrack.models.reference import UserRole
@@ -19,6 +20,7 @@ def format_timestamp(ts_utc: int) -> str:
def event_detail_panel(
event: Event,
affected_animals: list[dict[str, Any]],
total_animal_count: int = 0,
is_tombstoned: bool = False,
location_names: dict[str, str] | None = None,
user_role: UserRole | None = None,
@@ -27,7 +29,8 @@ def event_detail_panel(
Args:
event: The event to display.
affected_animals: List of animals affected by this event.
affected_animals: List of animals affected by this event (may be limited).
total_animal_count: Total number of affected animals.
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.
@@ -37,6 +40,8 @@ def event_detail_panel(
"""
if location_names is None:
location_names = {}
if total_animal_count == 0:
total_animal_count = len(affected_animals)
return Div(
# Header with close button
@@ -68,7 +73,7 @@ def event_detail_panel(
# Entity references
entity_refs_section(event.entity_refs, location_names),
# Affected animals
affected_animals_section(affected_animals),
affected_animals_section(affected_animals, total_animal_count, event.id),
# 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",
@@ -354,20 +359,76 @@ def entity_refs_section(
)
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
"""Section showing affected animals."""
if not animals:
def affected_animals_section(
animals: list[dict[str, Any]],
total_count: int,
event_id: str,
) -> Div:
"""Section showing affected animals with expandable list.
Args:
animals: List of animals to display (may be limited).
total_count: Total number of affected animals.
event_id: Event ID for "Show all" button.
Returns:
Div containing the affected animals section.
"""
if not animals and total_count == 0:
return Div()
return Div(
H3(
f"Affected Animals ({total_count})",
cls="text-sm font-semibold text-stone-400 mb-2",
),
affected_animals_list(animals, total_count, event_id=event_id),
cls="p-4",
id="affected-animals-section",
)
def affected_animals_list(
animals: list[dict[str, Any]],
total_count: int,
event_id: str | None = None,
expanded: bool = False,
) -> Div:
"""List of affected animals with optional expand button.
Args:
animals: List of animals to display.
total_count: Total number of affected animals.
event_id: Event ID for "Show all" button (None if expanded).
expanded: Whether showing full list.
Returns:
Div containing the animal list.
"""
animal_items = []
for animal in animals[:20]: # Limit display
display_name = animal.get("nickname") or animal["id"][:8] + "..."
for animal in animals:
display_name = format_animal_id(animal["id"], animal.get("nickname"))
# Build details string: sex abbreviation, life stage, location
sex_abbr = {"male": "M", "female": "F", "unknown": "?"}.get(
animal.get("sex", "unknown"), "?"
)
life_stage = animal.get("life_stage", "").replace("_", " ")
location = animal.get("location_name", "")
details_parts = [sex_abbr]
if life_stage:
details_parts.append(life_stage)
if location:
details_parts.append(location)
details_str = ", ".join(details_parts)
animal_items.append(
Li(
A(
Span(display_name, cls="text-amber-500 hover:underline"),
Span(display_name, cls="text-amber-500 hover:underline mr-1"),
Span(
f" ({animal.get('species_name', '')})",
f"({details_str})",
cls="text-stone-500 text-xs",
),
href=f"/animals/{animal['id']}",
@@ -376,22 +437,23 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
)
)
more_count = len(animals) - 20
if more_count > 0:
animal_items.append(
Li(
Span(f"... and {more_count} more", cls="text-stone-500 text-sm"),
cls="py-1",
)
# Show "Show all X animals" button if there are more
more_count = total_count - len(animals)
show_all_button = None
if more_count > 0 and not expanded and event_id:
show_all_button = Button(
f"Show all {total_count} animals",
hx_get=f"/events/{event_id}/animals",
hx_target="#affected-animals-list",
hx_swap="outerHTML",
cls="mt-2 text-sm text-amber-500 hover:text-amber-400 hover:underline",
type="button",
)
return Div(
H3(
f"Affected Animals ({len(animals)})",
cls="text-sm font-semibold text-stone-400 mb-2",
),
Ul(*animal_items, cls="space-y-1"),
cls="p-4",
show_all_button,
id="affected-animals-list",
)

View File

@@ -4,12 +4,12 @@
from collections.abc import Callable
from typing import Any
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Select, Ul
from monsterui.all import (
Button,
ButtonT,
FormLabel,
LabelInput,
LabelSelect,
LabelTextArea,
TabContainer,
)
@@ -56,20 +56,8 @@ def feed_page(
H1("Feed", cls="text-2xl font-bold mb-6"),
# Tab navigation
TabContainer(
Li(
A(
"Give Feed",
href="#",
cls="uk-active" if give_active else "",
),
),
Li(
A(
"Purchase Feed",
href="#",
cls="" if give_active else "uk-active",
),
),
Li(A("Give Feed", href="#"), cls="uk-active" if give_active else None),
Li(A("Purchase Feed", href="#"), cls=None if give_active else "uk-active"),
uk_switcher="connect: #feed-forms; animation: uk-animation-fade",
alt=True,
),
@@ -86,11 +74,11 @@ def feed_page(
balance_warning=balance_warning,
action=give_action,
),
cls="uk-active" if give_active else "",
cls="uk-active" if give_active else None,
),
Li(
purchase_feed_form(feed_types, error=purchase_error, action=purchase_action),
cls="" if give_active else "uk-active",
cls=None if give_active else "uk-active",
),
),
cls="p-4",
@@ -170,19 +158,17 @@ def give_feed_form(
H2("Give Feed", cls="text-xl font-bold mb-4"),
error_component,
warning_component,
# Location dropdown
LabelSelect(
*location_options,
label="Location",
id="location_id",
name="location_id",
# Location dropdown - using raw Select to fix value handling
Div(
FormLabel("Location", _for="location_id"),
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
cls="space-y-2",
),
# Feed type dropdown
LabelSelect(
*feed_type_options,
label="Feed Type",
id="feed_type_code",
name="feed_type_code",
# Feed type dropdown - using raw Select to fix value handling
Div(
FormLabel("Feed Type", _for="feed_type_code"),
Select(*feed_type_options, name="feed_type_code", id="feed_type_code", cls="uk-select"),
cls="space-y-2",
),
# Amount input
LabelInput(
@@ -247,12 +233,16 @@ def purchase_feed_form(
return Form(
H2("Purchase Feed", cls="text-xl font-bold mb-4"),
error_component,
# Feed type dropdown
LabelSelect(
# Feed type dropdown - using raw Select to fix value handling
Div(
FormLabel("Feed Type", _for="purchase_feed_type_code"),
Select(
*feed_type_options,
label="Feed Type",
id="purchase_feed_type_code",
name="feed_type_code",
id="purchase_feed_type_code",
cls="uk-select",
),
cls="space-y-2",
),
# Bag size
LabelInput(
@@ -276,15 +266,15 @@ def purchase_feed_form(
value="1",
required=True,
),
# Price per bag (cents)
# Price per bag (euros)
LabelInput(
"Price per Bag (cents)",
id="bag_price_cents",
name="bag_price_cents",
"Price per Bag ()",
id="bag_price_euros",
name="bag_price_euros",
type="number",
min="0",
step="1",
placeholder="e.g., 2400 for 24.00",
step="0.01",
placeholder="e.g., 24.00",
required=True,
),
# Optional vendor

View File

@@ -5,9 +5,27 @@ from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr
from monsterui.all import Button, ButtonT, Card, Grid, LabelInput
from fasthtml.common import (
H2,
A,
Div,
Form,
Input,
Li,
P,
Script,
Span,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
Ul,
)
from monsterui.all import Button, ButtonT, FormLabel, Grid
from animaltrack.id_gen import format_animal_id
from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
@@ -20,8 +38,8 @@ def registry_page(
total_count: int = 0,
locations: list[Location] | None = None,
species_list: list[Species] | None = None,
) -> Grid:
"""Full registry page with sidebar and table.
) -> Div:
"""Full registry page with filter at top, then sidebar + table.
Args:
animals: List of animals for the current page.
@@ -33,27 +51,32 @@ def registry_page(
species_list: List of species for facet labels.
Returns:
Grid component with sidebar and main content.
Div component with header, sidebar, and main content.
"""
return Grid(
return Div(
# Filter at top - full width
registry_header(filter_str, total_count),
# Grid with sidebar and table
Grid(
# Sidebar with facets
facet_sidebar(facets, filter_str, locations, species_list),
# Main content
# Main content - selection toolbar + table
Div(
# Header with filter
registry_header(filter_str, total_count),
# Animal table
selection_toolbar(),
animal_table(animals, next_cursor, filter_str),
selection_script(),
cls="col-span-3",
),
cols_sm=1,
cols_md=4,
cls="gap-4 p-4",
cls="gap-4",
),
cls="p-4",
)
def registry_header(filter_str: str, total_count: int) -> Div:
"""Header with title and filter input.
"""Header with title, count, and prominent filter.
Args:
filter_str: Current filter string.
@@ -63,30 +86,45 @@ def registry_header(filter_str: str, total_count: int) -> Div:
Div with header and filter form.
"""
return Div(
# Top row: Title and count
Div(
H2("Animal Registry", cls="text-xl font-bold"),
Span(f"{total_count} animals", cls="text-sm text-stone-400"),
cls="flex items-center justify-between",
Span(f"{total_count} animals", cls="text-sm text-stone-400 ml-3"),
cls="flex items-baseline mb-4",
),
# Filter form
# Filter form - full width, prominent
Form(
Div(
LabelInput(
"Filter",
# Label above the input row
FormLabel("Filter", _for="filter", cls="mb-2 block"),
Grid(
# Filter input - takes most of the width
Input(
id="filter",
name="filter",
value=filter_str,
placeholder='e.g., species:duck status:alive location:"Strip 1"',
cls="flex-1",
placeholder='species:duck status:alive location:"Strip 1"',
cls="uk-input col-span-10",
),
Button("Apply", type="submit", cls=ButtonT.primary),
cls="flex gap-2 items-end",
# Buttons container
Div(
Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"),
# Clear button (only shown if filter is active)
A(
"Clear",
href="/registry",
cls="px-3 py-2 text-stone-400 hover:text-stone-200",
)
if filter_str
else None,
cls="flex gap-2 col-span-2",
),
cols=12,
cls="gap-2 items-center",
),
action="/registry",
method="get",
cls="mt-4",
),
cls="mb-4",
cls="mb-6 pb-4 border-b border-stone-700",
)
@@ -96,7 +134,7 @@ def facet_sidebar(
locations: list[Location] | None,
species_list: list[Species] | None,
) -> Div:
"""Sidebar with clickable facet counts.
"""Sidebar with compact clickable facet counts.
Args:
facets: Facet counts for display.
@@ -116,7 +154,7 @@ def facet_sidebar(
facet_section("Sex", facets.by_sex, filter_str, "sex"),
facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"),
facet_section("Location", facets.by_location, filter_str, "location", location_map),
cls="space-y-4",
cls="space-y-3",
)
@@ -127,7 +165,7 @@ def facet_section(
field: str,
label_map: dict[str, str] | None = None,
) -> Any:
"""Single facet section with clickable items.
"""Single facet section with compact pill-style items.
Args:
title: Section title.
@@ -137,11 +175,12 @@ def facet_section(
label_map: Optional mapping from value to display label.
Returns:
Card component with facet items, or None if no counts.
Div component with facet pills, or None if no counts.
"""
if not counts:
return None
# Build inline pill items
items = []
for value, count in sorted(counts.items(), key=lambda x: -x[1]):
label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
@@ -153,19 +192,19 @@ def facet_section(
href = f"/registry?{urlencode({'filter': new_filter})}"
items.append(
A(
Div(
Span(label, cls="text-sm"),
Span(str(count), cls="text-xs text-stone-400 ml-auto"),
cls="flex justify-between items-center",
),
Span(label, cls="text-xs"),
Span(str(count), cls="text-xs text-stone-500 ml-1"),
href=href,
cls="block p-2 hover:bg-slate-800 rounded",
cls="inline-flex items-center px-2 py-1 rounded bg-stone-800 hover:bg-stone-700 mr-1 mb-1",
)
)
return Card(
P(title, cls="font-bold text-sm mb-2"),
return Div(
P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
Div(
*items,
cls="flex flex-wrap",
),
)
@@ -187,6 +226,14 @@ def animal_table(
return Table(
Thead(
Tr(
Th(
Input(
type="checkbox",
id="select-all-checkbox",
cls="uk-checkbox",
),
cls="w-8",
),
Th("ID", shrink=True),
Th("Species"),
Th("Sex"),
@@ -220,8 +267,8 @@ def animal_row(animal: AnimalListItem) -> Tr:
last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC)
last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M")
# Display ID (truncated or nickname)
display_id = animal.nickname or animal.animal_id[:8] + "..."
# Display ID (phonetic encoding or nickname)
display_id = format_animal_id(animal.animal_id, animal.nickname)
# Status badge styling
status_cls = {
@@ -238,6 +285,13 @@ def animal_row(animal: AnimalListItem) -> Tr:
tags_str += "..."
return Tr(
Td(
Input(
type="checkbox",
cls="uk-checkbox animal-checkbox",
data_animal_id=animal.animal_id,
),
),
Td(
A(
display_id,
@@ -303,10 +357,208 @@ def load_more_sentinel(cursor: str, filter_str: str) -> Tr:
"Loading more...",
cls="text-center text-stone-400 py-4",
),
colspan="8",
colspan="9", # Updated for checkbox column
),
hx_get=url,
hx_trigger="revealed",
hx_swap="outerHTML",
id="load-more-sentinel",
)
def selection_toolbar() -> Div:
"""Toolbar for bulk actions on selected animals.
Returns:
Div with selection count and actions dropdown.
"""
return Div(
# Left side: selection info and controls
Div(
Span("0 selected", id="selection-count", cls="text-sm text-stone-400"),
A(
"Select all",
href="#",
id="select-all-btn",
cls="text-sm text-amber-500 hover:underline ml-3",
),
A(
"Clear",
href="#",
id="clear-selection-btn",
cls="text-sm text-stone-400 hover:text-stone-200 ml-3 hidden",
),
cls="flex items-center",
),
# Right side: actions dropdown
Div(
Button(
"Actions",
id="actions-btn",
cls=f"{ButtonT.default} px-4",
disabled=True,
),
Div(
Ul(
Li(
A(
"Move",
href="#",
data_action="move",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
Li(
A(
"Add Tag",
href="#",
data_action="tag-add",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
Li(
A(
"Update Attributes",
href="#",
data_action="attrs",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
Li(
A(
"Record Outcome",
href="#",
data_action="outcome",
cls="block px-4 py-2 hover:bg-stone-700",
)
),
cls="uk-nav uk-dropdown-nav",
),
uk_dropdown="mode: click; pos: bottom-right",
cls="uk-dropdown",
),
cls="uk-inline",
),
cls="flex justify-between items-center mb-4 py-2 px-3 bg-stone-800/50 rounded",
id="selection-toolbar",
)
def selection_script() -> Script:
"""JavaScript for handling animal selection.
Returns:
Script element with selection logic.
"""
return Script("""
(function() {
const selectedIds = new Set();
function updateUI() {
const count = selectedIds.size;
document.getElementById('selection-count').textContent = count + ' selected';
const actionsBtn = document.getElementById('actions-btn');
actionsBtn.disabled = count === 0;
const clearBtn = document.getElementById('clear-selection-btn');
if (count > 0) {
clearBtn.classList.remove('hidden');
} else {
clearBtn.classList.add('hidden');
}
// Update all checkboxes to reflect state
document.querySelectorAll('.animal-checkbox').forEach(cb => {
cb.checked = selectedIds.has(cb.dataset.animalId);
});
// Update header checkbox
const headerCb = document.getElementById('select-all-checkbox');
const allCheckboxes = document.querySelectorAll('.animal-checkbox');
if (allCheckboxes.length > 0) {
const allChecked = Array.from(allCheckboxes).every(cb => selectedIds.has(cb.dataset.animalId));
const someChecked = selectedIds.size > 0;
headerCb.checked = allChecked;
headerCb.indeterminate = someChecked && !allChecked;
}
}
// Handle individual checkbox changes
document.addEventListener('change', function(e) {
if (e.target.classList.contains('animal-checkbox')) {
const animalId = e.target.dataset.animalId;
if (e.target.checked) {
selectedIds.add(animalId);
} else {
selectedIds.delete(animalId);
}
updateUI();
}
});
// Handle header checkbox (select all visible)
document.getElementById('select-all-checkbox').addEventListener('change', function(e) {
document.querySelectorAll('.animal-checkbox').forEach(cb => {
if (e.target.checked) {
selectedIds.add(cb.dataset.animalId);
} else {
selectedIds.delete(cb.dataset.animalId);
}
});
updateUI();
});
// Select all button
document.getElementById('select-all-btn').addEventListener('click', function(e) {
e.preventDefault();
document.querySelectorAll('.animal-checkbox').forEach(cb => {
selectedIds.add(cb.dataset.animalId);
});
updateUI();
});
// Clear selection button
document.getElementById('clear-selection-btn').addEventListener('click', function(e) {
e.preventDefault();
selectedIds.clear();
updateUI();
});
// Handle action clicks
document.querySelectorAll('[data-action]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
if (selectedIds.size === 0) return;
const action = this.dataset.action;
const filter = 'animal_id:' + Array.from(selectedIds).join('|');
let url;
switch(action) {
case 'move':
url = '/move?filter=' + encodeURIComponent(filter);
break;
case 'tag-add':
url = '/actions/tag-add?filter=' + encodeURIComponent(filter);
break;
case 'attrs':
url = '/actions/attrs?filter=' + encodeURIComponent(filter);
break;
case 'outcome':
url = '/actions/outcome?filter=' + encodeURIComponent(filter);
break;
}
if (url) {
window.location.href = url;
}
});
});
// Re-run updateUI after HTMX swaps (for infinite scroll)
document.body.addEventListener('htmx:afterSwap', function(e) {
// After new rows are loaded, restore checkbox states
updateUI();
});
})();
""")

View File

@@ -4,6 +4,7 @@
from fasthtml.common import A, Button, Div, Nav, Script, Span, Style
from fasthtml.svg import Path, Svg
from animaltrack.build_info import get_build_info
from animaltrack.models.reference import UserRole
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
@@ -213,7 +214,8 @@ def Sidebar( # noqa: N802
return Nav(
# Logo/Brand
Div(
Span("ANIMALTRACK", cls="text-amber-600 font-bold tracking-wider text-sm"),
Div("ANIMALTRACK", cls="text-amber-600 font-bold tracking-wider text-sm"),
Div(get_build_info(), cls="text-stone-600 text-[10px] tracking-wide"),
cls="px-4 py-4 border-b border-stone-800",
),
# Primary navigation

View File

@@ -81,7 +81,6 @@ class TestEnums:
"""LifeStage enum has correct values."""
assert LifeStage.HATCHLING.value == "hatchling"
assert LifeStage.JUVENILE.value == "juvenile"
assert LifeStage.SUBADULT.value == "subadult"
assert LifeStage.ADULT.value == "adult"
def test_animal_status_values(self):

View File

@@ -1,7 +1,7 @@
# ABOUTME: Tests for ULID generation utility.
# ABOUTME: Verifies that generated IDs are valid 26-character ULIDs and unique.
# ABOUTME: Tests for ULID generation and phonetic encoding utilities.
# ABOUTME: Verifies ULID format, uniqueness, and phonetic display encoding.
from animaltrack.id_gen import generate_id
from animaltrack.id_gen import format_animal_id, generate_id, ulid_to_phonetic
class TestGenerateId:
@@ -28,3 +28,74 @@ class TestGenerateId:
# Crockford base32 excludes I, L, O, U
valid_chars = set("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
assert all(c in valid_chars for c in result)
class TestUlidToPhonetic:
"""Tests for the ulid_to_phonetic function."""
def test_returns_hyphenated_syllables(self):
"""Result is hyphen-separated syllables."""
ulid = generate_id()
result = ulid_to_phonetic(ulid)
parts = result.split("-")
assert len(parts) == 4 # Default syllable count
def test_syllables_are_cv_format(self):
"""Each syllable is consonant-vowel format."""
ulid = generate_id()
result = ulid_to_phonetic(ulid)
consonants = set("bdfgklmnprstvwxz")
vowels = set("aeiou")
for syllable in result.split("-"):
assert len(syllable) == 2
assert syllable[0] in consonants
assert syllable[1] in vowels
def test_same_ulid_produces_same_phonetic(self):
"""Same ULID always produces the same phonetic."""
ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
result1 = ulid_to_phonetic(ulid)
result2 = ulid_to_phonetic(ulid)
assert result1 == result2
def test_different_ulids_produce_different_phonetics(self):
"""Different ULIDs produce different phonetics (with high probability)."""
ulids = [generate_id() for _ in range(100)]
phonetics = [ulid_to_phonetic(u) for u in ulids]
# All should be unique
assert len(set(phonetics)) == 100
def test_custom_syllable_count(self):
"""Can specify custom number of syllables."""
ulid = generate_id()
result = ulid_to_phonetic(ulid, syllable_count=3)
assert len(result.split("-")) == 3
result = ulid_to_phonetic(ulid, syllable_count=5)
assert len(result.split("-")) == 5
class TestFormatAnimalId:
"""Tests for the format_animal_id function."""
def test_returns_nickname_when_provided(self):
"""Returns nickname if provided."""
ulid = generate_id()
result = format_animal_id(ulid, nickname="Daisy")
assert result == "Daisy"
def test_returns_phonetic_when_no_nickname(self):
"""Returns phonetic encoding when nickname is None."""
ulid = generate_id()
result = format_animal_id(ulid, nickname=None)
# Should be phonetic format (4 syllables, hyphen-separated)
parts = result.split("-")
assert len(parts) == 4
def test_returns_phonetic_when_nickname_empty(self):
"""Returns phonetic encoding when nickname is empty string."""
ulid = generate_id()
# Empty string is falsy, should use phonetic
result = format_animal_id(ulid, nickname="")
parts = result.split("-")
assert len(parts) == 4

View File

@@ -141,7 +141,7 @@ class TestAnimalRegistryTable:
)
def test_life_stage_check_constraint(self, migrated_db):
"""Life stage must be hatchling, juvenile, subadult, or adult."""
"""Life stage must be hatchling, juvenile, or adult."""
_insert_species(migrated_db)
_insert_location(migrated_db)

View File

@@ -1,5 +1,39 @@
# ABOUTME: Tests for CSRF validation logic.
# ABOUTME: Covers token matching, origin/referer validation, and safe methods.
# ABOUTME: Covers token generation, token matching, origin/referer validation, and safe methods.
class TestGenerateCSRFToken:
"""Tests for CSRF token generation."""
def test_generates_token_with_nonce_and_signature(self):
"""Token format is nonce:signature."""
from animaltrack.web.middleware import generate_csrf_token
token = generate_csrf_token("test-secret")
parts = token.split(":")
assert len(parts) == 2
# Nonce is 32 hex chars (16 bytes)
assert len(parts[0]) == 32
# Signature is 64 hex chars (SHA256)
assert len(parts[1]) == 64
def test_generates_unique_tokens(self):
"""Each call generates a different token (random nonce)."""
from animaltrack.web.middleware import generate_csrf_token
token1 = generate_csrf_token("test-secret")
token2 = generate_csrf_token("test-secret")
assert token1 != token2
def test_tokens_are_hex_encoded(self):
"""Token parts are valid hex strings."""
from animaltrack.web.middleware import generate_csrf_token
token = generate_csrf_token("test-secret")
nonce, signature = token.split(":")
# These should not raise ValueError
int(nonce, 16)
int(signature, 16)
class TestValidateCSRFToken: