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:
70
migrations/0011-remove-subadult-lifestage.sql
Normal file
70
migrations/0011-remove-subadult-lifestage.sql
Normal 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);
|
||||
4
spec.md
4
spec.md
@@ -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))
|
||||
|
||||
@@ -26,7 +26,6 @@ class LifeStage(str, Enum):
|
||||
|
||||
HATCHLING = "hatchling"
|
||||
JUVENILE = "juvenile"
|
||||
SUBADULT = "subadult"
|
||||
ADULT = "adult"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -174,7 +174,6 @@ def cohort_form(
|
||||
life_stages = [
|
||||
("hatchling", "Hatchling"),
|
||||
("juvenile", "Juvenile"),
|
||||
("subadult", "Subadult"),
|
||||
("adult", "Adult"),
|
||||
]
|
||||
life_stage_options = [
|
||||
@@ -1037,7 +1036,6 @@ def attrs_form(
|
||||
Option("No change", value="", selected=True),
|
||||
Option("Hatchling", value="hatchling"),
|
||||
Option("Juvenile", value="juvenile"),
|
||||
Option("Subadult", value="subadult"),
|
||||
Option("Adult", value="adult"),
|
||||
]
|
||||
|
||||
@@ -1282,7 +1280,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"),
|
||||
|
||||
@@ -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(
|
||||
@@ -43,7 +49,7 @@ def animal_checkbox_list(
|
||||
),
|
||||
Span(display_name, cls="text-stone-200"),
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
"""Event detail slide-over panel container."""
|
||||
return Div(
|
||||
@@ -127,6 +158,7 @@ def page(
|
||||
EventSlideOverStyles(),
|
||||
SidebarScript(),
|
||||
EventSlideOverScript(),
|
||||
CsrfHeaderScript(),
|
||||
# Desktop sidebar
|
||||
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
|
||||
# Mobile menu drawer
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -361,7 +362,7 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
||||
|
||||
animal_items = []
|
||||
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(
|
||||
Li(
|
||||
A(
|
||||
|
||||
@@ -6,8 +6,9 @@ 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 monsterui.all import Button, ButtonT, Grid, LabelInput
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.models.reference import Location, Species
|
||||
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
|
||||
|
||||
@@ -20,8 +21,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 +34,30 @@ 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(
|
||||
# Sidebar with facets
|
||||
facet_sidebar(facets, filter_str, locations, species_list),
|
||||
# Main content
|
||||
Div(
|
||||
# Header with filter
|
||||
registry_header(filter_str, total_count),
|
||||
# Animal table
|
||||
animal_table(animals, next_cursor, filter_str),
|
||||
cls="col-span-3",
|
||||
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 - table
|
||||
Div(
|
||||
animal_table(animals, next_cursor, filter_str),
|
||||
cls="col-span-3",
|
||||
),
|
||||
cols_sm=1,
|
||||
cols_md=4,
|
||||
cls="gap-4",
|
||||
),
|
||||
cols_sm=1,
|
||||
cols_md=4,
|
||||
cls="gap-4 p-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 +67,34 @@ 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",
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder='e.g., species:duck status:alive location:"Strip 1"',
|
||||
cls="flex-1",
|
||||
# Filter input - wider with flex-grow
|
||||
Div(
|
||||
LabelInput(
|
||||
"Filter",
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder='species:duck status:alive location:"Strip 1"',
|
||||
),
|
||||
cls="flex-1 min-w-0", # min-w-0 prevents flex overflow
|
||||
),
|
||||
Button("Apply", type="submit", cls=ButtonT.primary),
|
||||
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",
|
||||
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,
|
||||
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 +124,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 +135,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 +145,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 +162,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"),
|
||||
*items,
|
||||
return Div(
|
||||
P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
|
||||
Div(
|
||||
*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_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 = {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user