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,
|
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))
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class LifeStage(str, Enum):
|
|||||||
|
|
||||||
HATCHLING = "hatchling"
|
HATCHLING = "hatchling"
|
||||||
JUVENILE = "juvenile"
|
JUVENILE = "juvenile"
|
||||||
SUBADULT = "subadult"
|
|
||||||
ADULT = "adult"
|
ADULT = "adult"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
# Sidebar with facets
|
# Filter at top - full width
|
||||||
facet_sidebar(facets, filter_str, locations, species_list),
|
registry_header(filter_str, total_count),
|
||||||
# Main content
|
# Grid with sidebar and table
|
||||||
Div(
|
Grid(
|
||||||
# Header with filter
|
# Sidebar with facets
|
||||||
registry_header(filter_str, total_count),
|
facet_sidebar(facets, filter_str, locations, species_list),
|
||||||
# Animal table
|
# Main content - table
|
||||||
animal_table(animals, next_cursor, filter_str),
|
Div(
|
||||||
cls="col-span-3",
|
animal_table(animals, next_cursor, filter_str),
|
||||||
|
cls="col-span-3",
|
||||||
|
),
|
||||||
|
cols_sm=1,
|
||||||
|
cols_md=4,
|
||||||
|
cls="gap-4",
|
||||||
),
|
),
|
||||||
cols_sm=1,
|
cls="p-4",
|
||||||
cols_md=4,
|
|
||||||
cls="gap-4 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(
|
Div(
|
||||||
LabelInput(
|
# Filter input - wider with flex-grow
|
||||||
"Filter",
|
Div(
|
||||||
id="filter",
|
LabelInput(
|
||||||
name="filter",
|
"Filter",
|
||||||
value=filter_str,
|
id="filter",
|
||||||
placeholder='e.g., species:duck status:alive location:"Strip 1"',
|
name="filter",
|
||||||
cls="flex-1",
|
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),
|
# Apply button - fixed size
|
||||||
cls="flex gap-2 items-end",
|
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"),
|
||||||
*items,
|
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_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 = {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user