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>
This commit is contained in:
2026-01-05 15:20:26 +00:00
parent a4b4fe6ab8
commit 14bf2fa4ae
16 changed files with 405 additions and 64 deletions

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);

View File

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

View File

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

View File

@@ -1,8 +1,18 @@
# ABOUTME: ULID generation utility for creating unique identifiers. # 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 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: def generate_id() -> str:
"""Generate a new ULID as a 26-character string. """Generate a new ULID as a 26-character string.
@@ -11,3 +21,56 @@ def generate_id() -> str:
A 26-character uppercase alphanumeric ULID string. A 26-character uppercase alphanumeric ULID string.
""" """
return str(ULID()) 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

@@ -18,6 +18,7 @@ from animaltrack.web.exceptions import AuthenticationError, AuthorizationError
from animaltrack.web.middleware import ( from animaltrack.web.middleware import (
auth_before, auth_before,
csrf_before, csrf_before,
generate_csrf_token,
request_id_before, request_id_before,
) )
from animaltrack.web.responses import error_toast from animaltrack.web.responses import error_toast
@@ -105,6 +106,39 @@ def create_app(
return None 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 # Configure beforeware with skip patterns
beforeware = Beforeware( beforeware = Beforeware(
before, before,
@@ -139,6 +173,7 @@ def create_app(
# Create FastHTML app with HTMX extensions, MonsterUI theme, and static path # Create FastHTML app with HTMX extensions, MonsterUI theme, and static path
app, rt = fast_app( app, rt = fast_app(
before=beforeware, before=beforeware,
after=after,
hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config
exts=["head-support", "preload"], exts=["head-support", "preload"],
static_path=static_path_for_fasthtml, static_path=static_path_for_fasthtml,

View File

@@ -1,8 +1,11 @@
# ABOUTME: Middleware functions for authentication, CSRF, and request logging. # ABOUTME: Middleware functions for authentication, CSRF, and request logging.
# ABOUTME: Implements Beforeware pattern for FastHTML request processing. # ABOUTME: Implements Beforeware pattern for FastHTML request processing.
import hashlib
import hmac
import ipaddress import ipaddress
import json import json
import secrets
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -18,6 +21,27 @@ from animaltrack.repositories.users import UserRepository
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) 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: def is_safe_method(method: str) -> bool:
"""Check if HTTP method is safe (doesn't require CSRF protection). """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( return _render_cohort_error(
request, locations, species_list, "Please select a location", form 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( return _render_cohort_error(
request, locations, species_list, "Please select a life stage", form request, locations, species_list, "Please select a life stage", form
) )

View File

@@ -174,7 +174,6 @@ def cohort_form(
life_stages = [ life_stages = [
("hatchling", "Hatchling"), ("hatchling", "Hatchling"),
("juvenile", "Juvenile"), ("juvenile", "Juvenile"),
("subadult", "Subadult"),
("adult", "Adult"), ("adult", "Adult"),
] ]
life_stage_options = [ life_stage_options = [
@@ -1037,7 +1036,6 @@ def attrs_form(
Option("No change", value="", selected=True), Option("No change", value="", selected=True),
Option("Hatchling", value="hatchling"), Option("Hatchling", value="hatchling"),
Option("Juvenile", value="juvenile"), Option("Juvenile", value="juvenile"),
Option("Subadult", value="subadult"),
Option("Adult", value="adult"), Option("Adult", value="adult"),
] ]
@@ -1282,7 +1280,7 @@ def outcome_form(
# Build outcome options # Build outcome options
outcome_options = [ outcome_options = [
Option("Select outcome...", value="", selected=True, disabled=True), Option("Select outcome...", value="", selected=True, disabled=True),
Option("Death (natural)", value="death"), Option("Death", value="death"),
Option("Harvest", value="harvest"), Option("Harvest", value="harvest"),
Option("Sold", value="sold"), Option("Sold", value="sold"),
Option("Predator Loss", value="predator_loss"), Option("Predator Loss", value="predator_loss"),

View File

@@ -3,6 +3,7 @@
from fasthtml.common import Div, Input, Label, P, Span from fasthtml.common import Div, Input, Label, P, Span
from animaltrack.id_gen import format_animal_id
from animaltrack.repositories.animals import AnimalListItem from animaltrack.repositories.animals import AnimalListItem
@@ -29,7 +30,12 @@ def animal_checkbox_list(
items = [] items = []
for animal in animals[:max_display]: for animal in animals[:max_display]:
is_checked = animal.animal_id in selected_set 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( items.append(
Label( Label(
@@ -43,7 +49,7 @@ def animal_checkbox_list(
), ),
Span(display_name, cls="text-stone-200"), Span(display_name, cls="text-stone-200"),
Span( 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="text-stone-500 text-sm",
), ),
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer", cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",

View File

@@ -68,6 +68,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 def EventSlideOver(): # noqa: N802
"""Event detail slide-over panel container.""" """Event detail slide-over panel container."""
return Div( return Div(
@@ -127,6 +158,7 @@ def page(
EventSlideOverStyles(), EventSlideOverStyles(),
SidebarScript(), SidebarScript(),
EventSlideOverScript(), EventSlideOverScript(),
CsrfHeaderScript(),
# Desktop sidebar # Desktop sidebar
Sidebar(active_nav=active_nav, user_role=user_role, username=username), Sidebar(active_nav=active_nav, user_role=user_role, username=username),
# Mobile menu drawer # Mobile menu drawer

View File

@@ -6,6 +6,7 @@ from typing import Any
from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul 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.events import Event
from animaltrack.models.reference import UserRole from animaltrack.models.reference import UserRole
@@ -361,7 +362,7 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
animal_items = [] animal_items = []
for animal in animals[:20]: # Limit display for animal in animals[:20]: # Limit display
display_name = animal.get("nickname") or animal["id"][:8] + "..." display_name = format_animal_id(animal["id"], animal.get("nickname"))
animal_items.append( animal_items.append(
Li( Li(
A( A(

View File

@@ -6,8 +6,9 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr 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 monsterui.all import Button, ButtonT, Grid, LabelInput
from animaltrack.id_gen import format_animal_id
from animaltrack.models.reference import Location, Species from animaltrack.models.reference import Location, Species
from animaltrack.repositories.animals import AnimalListItem, FacetCounts from animaltrack.repositories.animals import AnimalListItem, FacetCounts
@@ -20,8 +21,8 @@ def registry_page(
total_count: int = 0, total_count: int = 0,
locations: list[Location] | None = None, locations: list[Location] | None = None,
species_list: list[Species] | None = None, species_list: list[Species] | None = None,
) -> Grid: ) -> Div:
"""Full registry page with sidebar and table. """Full registry page with filter at top, then sidebar + table.
Args: Args:
animals: List of animals for the current page. animals: List of animals for the current page.
@@ -33,27 +34,30 @@ def registry_page(
species_list: List of species for facet labels. species_list: List of species for facet labels.
Returns: 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 # Sidebar with facets
facet_sidebar(facets, filter_str, locations, species_list), facet_sidebar(facets, filter_str, locations, species_list),
# Main content # Main content - table
Div( Div(
# Header with filter
registry_header(filter_str, total_count),
# Animal table
animal_table(animals, next_cursor, filter_str), animal_table(animals, next_cursor, filter_str),
cls="col-span-3", cls="col-span-3",
), ),
cols_sm=1, cols_sm=1,
cols_md=4, cols_md=4,
cls="gap-4 p-4", cls="gap-4",
),
cls="p-4",
) )
def registry_header(filter_str: str, total_count: int) -> Div: def registry_header(filter_str: str, total_count: int) -> Div:
"""Header with title and filter input. """Header with title, count, and prominent filter.
Args: Args:
filter_str: Current filter string. filter_str: Current filter string.
@@ -63,30 +67,34 @@ def registry_header(filter_str: str, total_count: int) -> Div:
Div with header and filter form. Div with header and filter form.
""" """
return Div( return Div(
# Top row: Title and count
Div( Div(
H2("Animal Registry", cls="text-xl font-bold"), H2("Animal Registry", cls="text-xl font-bold"),
Span(f"{total_count} animals", cls="text-sm text-stone-400"), Span(f"{total_count} animals", cls="text-sm text-stone-400 ml-3"),
cls="flex items-center justify-between", cls="flex items-baseline mb-4",
), ),
# Filter form # Filter form - full width, prominent
Form( Form(
Div(
# Filter input - wider with flex-grow
Div( Div(
LabelInput( LabelInput(
"Filter", "Filter",
id="filter", id="filter",
name="filter", name="filter",
value=filter_str, value=filter_str,
placeholder='e.g., species:duck status:alive location:"Strip 1"', placeholder='species:duck status:alive location:"Strip 1"',
cls="flex-1",
), ),
Button("Apply", type="submit", cls=ButtonT.primary), cls="flex-1 min-w-0", # min-w-0 prevents flex overflow
cls="flex gap-2 items-end", ),
# Apply button - fixed size
Button("Apply", type="submit", cls=f"{ButtonT.primary} shrink-0"),
cls="flex gap-3 items-end",
), ),
action="/registry", action="/registry",
method="get", method="get",
cls="mt-4",
), ),
cls="mb-4", cls="mb-6 pb-4 border-b border-stone-700",
) )
@@ -96,7 +104,7 @@ def facet_sidebar(
locations: list[Location] | None, locations: list[Location] | None,
species_list: list[Species] | None, species_list: list[Species] | None,
) -> Div: ) -> Div:
"""Sidebar with clickable facet counts. """Sidebar with compact clickable facet counts.
Args: Args:
facets: Facet counts for display. facets: Facet counts for display.
@@ -116,7 +124,7 @@ def facet_sidebar(
facet_section("Sex", facets.by_sex, filter_str, "sex"), facet_section("Sex", facets.by_sex, filter_str, "sex"),
facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"), facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"),
facet_section("Location", facets.by_location, filter_str, "location", location_map), facet_section("Location", facets.by_location, filter_str, "location", location_map),
cls="space-y-4", cls="space-y-3",
) )
@@ -127,7 +135,7 @@ def facet_section(
field: str, field: str,
label_map: dict[str, str] | None = None, label_map: dict[str, str] | None = None,
) -> Any: ) -> Any:
"""Single facet section with clickable items. """Single facet section with compact pill-style items.
Args: Args:
title: Section title. title: Section title.
@@ -137,11 +145,12 @@ def facet_section(
label_map: Optional mapping from value to display label. label_map: Optional mapping from value to display label.
Returns: Returns:
Card component with facet items, or None if no counts. Div component with facet pills, or None if no counts.
""" """
if not counts: if not counts:
return None return None
# Build inline pill items
items = [] items = []
for value, count in sorted(counts.items(), key=lambda x: -x[1]): 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() label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
@@ -153,19 +162,19 @@ def facet_section(
href = f"/registry?{urlencode({'filter': new_filter})}" href = f"/registry?{urlencode({'filter': new_filter})}"
items.append( items.append(
A( A(
Div( Span(label, cls="text-xs"),
Span(label, cls="text-sm"), Span(str(count), cls="text-xs text-stone-500 ml-1"),
Span(str(count), cls="text-xs text-stone-400 ml-auto"),
cls="flex justify-between items-center",
),
href=href, 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( return Div(
P(title, cls="font-bold text-sm mb-2"), P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
Div(
*items, *items,
cls="flex flex-wrap",
),
) )
@@ -220,8 +229,8 @@ def animal_row(animal: AnimalListItem) -> Tr:
last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC) last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC)
last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M") last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M")
# Display ID (truncated or nickname) # Display ID (phonetic encoding or nickname)
display_id = animal.nickname or animal.animal_id[:8] + "..." display_id = format_animal_id(animal.animal_id, animal.nickname)
# Status badge styling # Status badge styling
status_cls = { status_cls = {

View File

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

View File

@@ -1,7 +1,7 @@
# ABOUTME: Tests for ULID generation utility. # ABOUTME: Tests for ULID generation and phonetic encoding utilities.
# ABOUTME: Verifies that generated IDs are valid 26-character ULIDs and unique. # 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: class TestGenerateId:
@@ -28,3 +28,74 @@ class TestGenerateId:
# Crockford base32 excludes I, L, O, U # Crockford base32 excludes I, L, O, U
valid_chars = set("0123456789ABCDEFGHJKMNPQRSTVWXYZ") valid_chars = set("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
assert all(c in valid_chars for c in result) 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): 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_species(migrated_db)
_insert_location(migrated_db) _insert_location(migrated_db)

View File

@@ -1,5 +1,39 @@
# ABOUTME: Tests for CSRF validation logic. # 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: class TestValidateCSRFToken: