Compare commits
12 Commits
a4b4fe6ab8
...
1853bca745
| Author | SHA1 | Date | |
|---|---|---|---|
| 1853bca745 | |||
| 94701c2f7e | |||
| 5c12eb553c | |||
| fb59ef72a8 | |||
| 29fbe68c73 | |||
| 4b951d428f | |||
| 1d322de67b | |||
| d4a29130f6 | |||
| 3f510d8d76 | |||
| abb1c87e6c | |||
| ad1f91098b | |||
| 14bf2fa4ae |
@@ -1,118 +0,0 @@
|
||||
# AnimalTrack Data Entry Steps
|
||||
# Based on data.txt historical records
|
||||
# Petre's duck and goose flock
|
||||
|
||||
## Prerequisites
|
||||
- Ensure you have at least one location set up in AnimalTrack
|
||||
|
||||
## Step 1: Initial Duck Flock (13.02.2025)
|
||||
Action: Create Cohort
|
||||
- Species: duck
|
||||
- Life stage: adult
|
||||
- Origin: purchased (or unknown)
|
||||
- Sex: female, Count: 35
|
||||
Repeat for males:
|
||||
- Sex: male, Count: 6
|
||||
Notes: "Initial flock - 41 ducks total, 1 noted as adult drake"
|
||||
|
||||
## Step 2: Initial Chinese Geese (13.02.2025)
|
||||
Action: Create Cohort
|
||||
- Species: goose
|
||||
- Life stage: adult
|
||||
- Origin: unknown
|
||||
- Sex: unknown, Count: 3
|
||||
Notes: "Chinese geese - existed before tracking started"
|
||||
|
||||
## Step 3: Gift - 2 Female Ducks (27.04.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:female status:alive
|
||||
- Select: 2 animals
|
||||
- Outcome: sold
|
||||
Notes: "gift"
|
||||
|
||||
## Step 4: Harvest - 4 Male Ducks (12.05.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:male status:alive
|
||||
- Select: 4 animals
|
||||
- Outcome: harvest
|
||||
|
||||
## Step 5: Hatch - 13 Ducklings (15.06.2025)
|
||||
Action: Record Hatch
|
||||
- Species: duck
|
||||
- Hatched live: 13
|
||||
- Location: [your duck area]
|
||||
|
||||
## Step 6: Put Down - 1 Weak Female Duck (27.06.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:female status:alive
|
||||
- Select: 1 animal
|
||||
- Outcome: death
|
||||
Notes: "weak, put down"
|
||||
|
||||
## Checkpoint 27.06.2025
|
||||
Expected state: 34 adult ducks (32 female, 2 male) + 13 ducklings
|
||||
|
||||
## Step 7: Found Dead - 1 Female Duck (22.07.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:female status:alive life_stage:adult
|
||||
- Select: 1 animal
|
||||
- Outcome: death
|
||||
Notes: "found dead"
|
||||
|
||||
## Step 8: Harvest - 8 Female Ducks (25.07.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:female status:alive life_stage:adult
|
||||
- Select: 8 animals
|
||||
- Outcome: harvest
|
||||
|
||||
## Step 9: Harvest - 10 Female Ducks (06.08.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:female status:alive life_stage:adult
|
||||
- Select: 10 animals
|
||||
- Outcome: harvest
|
||||
Notes: "After this: 14 female + 2 male adults + 13 ducklings"
|
||||
|
||||
## Step 10: Purchase - 4 Toulouse Goslings (04.08.2025)
|
||||
Action: Create Cohort
|
||||
- Species: goose
|
||||
- Life stage: hatchling
|
||||
- Origin: purchased
|
||||
- Sex: unknown, Count: 4
|
||||
Notes: "Toulouse goslings from OLX (Oia)"
|
||||
|
||||
## Step 11: Harvest - 3 Chinese Geese (08.10.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:goose status:alive
|
||||
- Select: 3 animals (the Chinese geese)
|
||||
- Outcome: harvest
|
||||
Notes: "All Chinese geese harvested, 4 Toulouse remain"
|
||||
|
||||
## Step 12: Predator Loss - 2 Geese (19.10.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:goose status:alive
|
||||
- Select: 2 animals
|
||||
- Outcome: predator_loss
|
||||
|
||||
## Step 13: Predator Loss - 1 Female Duck (19.12.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck sex:female status:alive
|
||||
- Select: 1 animal
|
||||
- Outcome: predator_loss
|
||||
|
||||
## Step 14: Predator Loss - 2 Ducks (25.12.2025)
|
||||
Action: Record Outcome
|
||||
- Filter: species:duck status:alive
|
||||
- Select: 2 animals (1 female, 1 male)
|
||||
- Outcome: predator_loss
|
||||
|
||||
## Final State (25.12.2025)
|
||||
- 27 ducks (mix of original adults + grown June hatchlings)
|
||||
- 2 Toulouse geese
|
||||
|
||||
## Note on Ducklings Sex
|
||||
The 13 ducklings from June need their sex updated once you know.
|
||||
After they mature, use:
|
||||
Action: Update Attributes
|
||||
- Filter: species:duck life_stage:hatchling status:alive
|
||||
- Update life_stage to: adult
|
||||
- For known females/males, update sex accordingly
|
||||
14
data.txt
14
data.txt
@@ -1,14 +0,0 @@
|
||||
41 ducks, 1 adult drake 13.02.2025
|
||||
-2 female ducks 27.04.2025 gift
|
||||
-4 male ducks 12.05.2025 harvest
|
||||
+13 ducklings 15.06.2025
|
||||
-1 weak female duck 27.06.2025 put it down
|
||||
34 adult ducks of which 2 drakes, 27.06.2025
|
||||
-1 female duck, found dead, 22.07.2025
|
||||
-8 female ducks, 25.07.2025 harvest
|
||||
-10 female ducks 6.08.2025 harvest; 15 adults (14 female 2 male) + 13 ducklings left
|
||||
+4 goslings 4.08.2025 bought from OLX (Oiã)
|
||||
-3 Chinese geese 08.10.2025 harvest (0 chinese and 4 toulouse goslings left)
|
||||
-2 geese predator 19.10.2025
|
||||
-1 female duck predator 19.12.2025
|
||||
-2 ducks (1 female and 1 male) predator 25.12.2025
|
||||
@@ -1,4 +1,4 @@
|
||||
{ pkgs, pythonEnv, python }:
|
||||
{ pkgs, pythonEnv, python, buildDate ? "unknown", buildCommit ? "unknown" }:
|
||||
|
||||
let
|
||||
# Build animaltrack as a package
|
||||
@@ -59,6 +59,8 @@ pkgs.dockerTools.buildImage {
|
||||
"PATH=${pkgs.lib.makeBinPath [ pkgs.busybox pkgs.bash pkgs.sqlite pythonEnv animaltrack ]}"
|
||||
"PYTHONPATH=${pythonEnv}/${pythonEnv.sitePackages}:${animaltrack}/${pythonEnv.sitePackages}"
|
||||
"PYTHONUNBUFFERED=1"
|
||||
"BUILD_DATE=${buildDate}"
|
||||
"BUILD_COMMIT=${buildCommit}"
|
||||
];
|
||||
ExposedPorts = {
|
||||
"5000/tcp" = {};
|
||||
|
||||
@@ -67,7 +67,12 @@
|
||||
in
|
||||
{
|
||||
packages.${system} = {
|
||||
dockerImage = import ./docker.nix { inherit pkgs pythonEnv python; };
|
||||
dockerImage = let
|
||||
buildDate = let
|
||||
d = self.lastModifiedDate or "00000000000000";
|
||||
in "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}";
|
||||
buildCommit = self.shortRev or self.dirtyShortRev or "unknown";
|
||||
in import ./docker.nix { inherit pkgs pythonEnv python buildDate buildCommit; };
|
||||
};
|
||||
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
|
||||
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);
|
||||
5
nits
5
nits
@@ -1,5 +0,0 @@
|
||||
Animal detail slider, event timeline, I want to be able to click on events and go to a detail view about them.
|
||||
|
||||
Create animal cohort form, click button, succeeds, comes back to the same form when successful. There should be a notification that it worked and link to detail view of the event created. Audit all other forms for this.
|
||||
|
||||
If I want to sell 2 ducks matching "species:duck sex:female status:alive", how do I do it? The record outcome form says 35 animals selected if I use that filter.
|
||||
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))
|
||||
|
||||
34
src/animaltrack/build_info.py
Normal file
34
src/animaltrack/build_info.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# ABOUTME: Provides build information (date + commit hash) for version display.
|
||||
# ABOUTME: Reads from env vars (Docker) or git (development).
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_build_info() -> str:
|
||||
"""Returns build info in '2025-01-08 fb59ef7' format.
|
||||
|
||||
Checks BUILD_DATE and BUILD_COMMIT env vars first (set by Docker),
|
||||
then falls back to reading git info at runtime (development).
|
||||
Returns 'unknown' if neither source is available.
|
||||
"""
|
||||
build_date = os.environ.get("BUILD_DATE")
|
||||
build_commit = os.environ.get("BUILD_COMMIT")
|
||||
|
||||
if build_date and build_commit:
|
||||
return f"{build_date} {build_commit}"
|
||||
|
||||
# Try git at runtime (development mode)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%cs %h"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
@@ -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)
|
||||
|
||||
@@ -7,7 +7,7 @@ from animaltrack.selection.ast import FieldFilter, FilterAST
|
||||
|
||||
# Supported filter fields
|
||||
VALID_FIELDS = frozenset(
|
||||
{"location", "species", "sex", "life_stage", "identified", "tag", "status"}
|
||||
{"animal_id", "location", "species", "sex", "life_stage", "identified", "tag", "status"}
|
||||
)
|
||||
|
||||
# Fields that can be used as flags (without :value)
|
||||
|
||||
@@ -148,7 +148,16 @@ def _build_filter_clause(field_filter: FieldFilter, ts_utc: int) -> tuple[str, l
|
||||
field = field_filter.field
|
||||
values = list(field_filter.values)
|
||||
|
||||
if field == "species":
|
||||
if field == "animal_id":
|
||||
# Direct animal ID filter
|
||||
placeholders = ",".join("?" * len(values))
|
||||
query = f"""
|
||||
SELECT animal_id FROM animal_registry
|
||||
WHERE animal_id IN ({placeholders})
|
||||
"""
|
||||
return query, values
|
||||
|
||||
elif field == "species":
|
||||
# Species from animal_registry (current state)
|
||||
placeholders = ",".join("?" * len(values))
|
||||
query = f"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -408,13 +408,13 @@ def promote_index(request: Request, animal_id: str):
|
||||
if animal.status != "alive":
|
||||
return HTMLResponse(content="Only alive animals can be promoted", status_code=400)
|
||||
|
||||
if animal.identified:
|
||||
return HTMLResponse(content="Animal is already identified", status_code=400)
|
||||
# Title depends on whether animal is already identified (rename vs promote)
|
||||
title = "Rename Animal - AnimalTrack" if animal.identified else "Promote Animal - AnimalTrack"
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
promote_form(animal),
|
||||
title="Promote Animal - AnimalTrack",
|
||||
title=title,
|
||||
active_nav=None,
|
||||
)
|
||||
|
||||
@@ -426,10 +426,13 @@ async def animal_promote(request: Request):
|
||||
form = await request.form()
|
||||
|
||||
# Extract form data
|
||||
# Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes
|
||||
animal_id = form.get("animal_id", "")
|
||||
nickname = form.get("nickname", "") or None
|
||||
sex = form.get("sex", "") or None
|
||||
repro_status = form.get("repro_status", "") or None
|
||||
sex_raw = form.get("sex", "")
|
||||
sex = None if sex_raw in ("", "-") else sex_raw
|
||||
repro_raw = form.get("repro_status", "")
|
||||
repro_status = None if repro_raw in ("", "-") else repro_raw
|
||||
distinguishing_traits = form.get("distinguishing_traits", "") or None
|
||||
notes = form.get("notes", "") or None
|
||||
nonce = form.get("nonce")
|
||||
@@ -448,9 +451,6 @@ async def animal_promote(request: Request):
|
||||
if animal.status != "alive":
|
||||
return _render_promote_error(request, animal, "Only alive animals can be promoted", form)
|
||||
|
||||
if animal.identified:
|
||||
return _render_promote_error(request, animal, "Animal is already identified", form)
|
||||
|
||||
# Create payload
|
||||
try:
|
||||
payload = AnimalPromotedPayload(
|
||||
@@ -1037,10 +1037,14 @@ async def animal_attrs(request: Request, session):
|
||||
form = await request.form()
|
||||
|
||||
# Extract form data
|
||||
# Note: "-" is used as sentinel for "no change" because FastHTML omits empty value attributes
|
||||
filter_str = form.get("filter", "")
|
||||
sex = form.get("sex", "").strip() or None
|
||||
life_stage = form.get("life_stage", "").strip() or None
|
||||
repro_status = form.get("repro_status", "").strip() or None
|
||||
sex_raw = form.get("sex", "").strip()
|
||||
sex = None if sex_raw in ("", "-") else sex_raw
|
||||
life_stage_raw = form.get("life_stage", "").strip()
|
||||
life_stage = None if life_stage_raw in ("", "-") else life_stage_raw
|
||||
repro_raw = form.get("repro_status", "").strip()
|
||||
repro_status = None if repro_raw in ("", "-") else repro_raw
|
||||
roster_hash = form.get("roster_hash", "")
|
||||
confirmed = form.get("confirmed", "") == "true"
|
||||
nonce = form.get("nonce")
|
||||
@@ -1280,7 +1284,9 @@ async def animal_outcome(request: Request, session):
|
||||
nonce = form.get("nonce")
|
||||
|
||||
# Yield item fields
|
||||
yield_product_code = form.get("yield_product_code", "").strip() or None
|
||||
# Note: "-" is used as sentinel for "no selection" because FastHTML omits empty value attributes
|
||||
yield_product_raw = form.get("yield_product_code", "").strip()
|
||||
yield_product_code = None if yield_product_raw in ("", "-") else yield_product_raw
|
||||
yield_unit = form.get("yield_unit", "").strip() or None
|
||||
yield_quantity_str = form.get("yield_quantity", "").strip()
|
||||
yield_weight_str = form.get("yield_weight_kg", "").strip()
|
||||
|
||||
@@ -26,6 +26,26 @@ from animaltrack.services.products import ProductService, ValidationError
|
||||
from animaltrack.web.templates import render_page
|
||||
from animaltrack.web.templates.eggs import eggs_page
|
||||
|
||||
|
||||
def _parse_ts_utc(form_value: str | None) -> int:
|
||||
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||
|
||||
Args:
|
||||
form_value: The ts_utc value from form data.
|
||||
|
||||
Returns:
|
||||
Timestamp in milliseconds. Returns current time if form_value is
|
||||
None, empty, or "0".
|
||||
"""
|
||||
if not form_value or form_value == "0":
|
||||
return int(time.time() * 1000)
|
||||
try:
|
||||
ts = int(form_value)
|
||||
return ts if ts > 0 else int(time.time() * 1000)
|
||||
except (ValueError, TypeError):
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
# APIRouter for multi-file route organization
|
||||
ar = APIRouter()
|
||||
|
||||
@@ -147,8 +167,8 @@ async def product_collected(request: Request, session):
|
||||
request, locations, products, location_id, "Quantity must be at least 1"
|
||||
)
|
||||
|
||||
# Get current timestamp
|
||||
ts_utc = int(time.time() * 1000)
|
||||
# Get timestamp - use provided or current (supports backdating)
|
||||
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||
|
||||
# Resolve ducks at location
|
||||
resolved_ids = resolve_ducks_at_location(db, location_id, ts_utc)
|
||||
@@ -276,8 +296,8 @@ async def product_sold(request: Request, session):
|
||||
request, locations, products, product_code, "Total price cannot be negative"
|
||||
)
|
||||
|
||||
# Get current timestamp
|
||||
ts_utc = int(time.time() * 1000)
|
||||
# Get timestamp - use provided or current (supports backdating)
|
||||
ts_utc = _parse_ts_utc(form.get("ts_utc"))
|
||||
|
||||
# Create product service
|
||||
event_store = EventStore(db)
|
||||
|
||||
@@ -247,29 +247,60 @@ def event_log_index(request: Request, htmx):
|
||||
)
|
||||
|
||||
|
||||
def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
|
||||
def get_event_animals(db: Any, event_id: str, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Get animals affected by an event with display info.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
event_id: Event ID to look up animals for.
|
||||
limit: Maximum number of animals to return (None for all).
|
||||
|
||||
Returns:
|
||||
List of animal dicts with id, nickname, species_name.
|
||||
List of animal dicts with id, nickname, species_name, sex, life_stage, location_name.
|
||||
"""
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT ar.animal_id, ar.nickname, s.name as species_name
|
||||
query = """
|
||||
SELECT ar.animal_id, ar.nickname, s.name as species_name,
|
||||
ar.sex, ar.life_stage, l.name as location_name
|
||||
FROM event_animals ea
|
||||
JOIN animal_registry ar ON ar.animal_id = ea.animal_id
|
||||
JOIN species s ON s.code = ar.species_code
|
||||
LEFT JOIN locations l ON l.id = ar.location_id
|
||||
WHERE ea.event_id = ?
|
||||
ORDER BY ar.nickname NULLS LAST, ar.animal_id
|
||||
""",
|
||||
(event_id,),
|
||||
).fetchall()
|
||||
"""
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
|
||||
return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows]
|
||||
rows = db.execute(query, (event_id,)).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"nickname": row[1],
|
||||
"species_name": row[2],
|
||||
"sex": row[3],
|
||||
"life_stage": row[4],
|
||||
"location_name": row[5],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def get_event_animal_count(db: Any, event_id: str) -> int:
|
||||
"""Get count of animals affected by an event.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
event_id: Event ID to count animals for.
|
||||
|
||||
Returns:
|
||||
Total number of animals affected.
|
||||
"""
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
|
||||
@ar("/events/{event_id}")
|
||||
@@ -292,8 +323,9 @@ def event_detail(request: Request, event_id: str, htmx):
|
||||
# Check if tombstoned
|
||||
is_tombstoned = event_store.is_tombstoned(event_id)
|
||||
|
||||
# Get affected animals
|
||||
affected_animals = get_event_animals(db, event_id)
|
||||
# Get affected animals (limited to first 20 for performance)
|
||||
affected_animals = get_event_animals(db, event_id, limit=20)
|
||||
total_animal_count = get_event_animal_count(db, event_id)
|
||||
|
||||
# Get location names if entity_refs has location IDs
|
||||
location_names = {}
|
||||
@@ -317,7 +349,9 @@ def event_detail(request: Request, event_id: str, htmx):
|
||||
user_role = auth.role if auth else None
|
||||
|
||||
# Build the panel
|
||||
panel = event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
|
||||
panel = event_detail_panel(
|
||||
event, affected_animals, total_animal_count, is_tombstoned, location_names, user_role
|
||||
)
|
||||
|
||||
# HTMX request (slide-over) → return just panel
|
||||
if htmx.request:
|
||||
@@ -327,6 +361,24 @@ def event_detail(request: Request, event_id: str, htmx):
|
||||
return render_page(request, panel, title=f"Event {event.id}")
|
||||
|
||||
|
||||
@ar("/events/{event_id}/animals")
|
||||
def event_animals_all(request: Request, event_id: str):
|
||||
"""GET /events/{event_id}/animals - Get all affected animals for an event.
|
||||
|
||||
This endpoint is used via HTMX to load the full list when user clicks "Show all".
|
||||
"""
|
||||
from animaltrack.web.templates.event_detail import affected_animals_list
|
||||
|
||||
db = request.app.state.db
|
||||
|
||||
# Get all animals (no limit)
|
||||
animals = get_event_animals(db, event_id)
|
||||
total_count = len(animals)
|
||||
|
||||
# Return just the list component for HTMX swap
|
||||
return affected_animals_list(animals, total_count, expanded=True)
|
||||
|
||||
|
||||
@ar("/events/{event_id}/delete", methods=["POST"])
|
||||
async def event_delete(request: Request, event_id: str):
|
||||
"""POST /events/{event_id}/delete - Delete an event (admin only).
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import APIRouter, add_toast
|
||||
from fasthtml.common import APIRouter, add_toast, to_xml
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -273,7 +273,7 @@ async def feed_purchased(request: Request, session):
|
||||
feed_type_code = form.get("feed_type_code", "")
|
||||
bag_size_kg_str = form.get("bag_size_kg", "0")
|
||||
bags_count_str = form.get("bags_count", "0")
|
||||
bag_price_cents_str = form.get("bag_price_cents", "0")
|
||||
bag_price_euros_str = form.get("bag_price_euros", "0")
|
||||
vendor = form.get("vendor") or None
|
||||
notes = form.get("notes") or None
|
||||
nonce = form.get("nonce")
|
||||
@@ -329,9 +329,10 @@ async def feed_purchased(request: Request, session):
|
||||
"Bags count must be at least 1",
|
||||
)
|
||||
|
||||
# Validate bag_price_cents
|
||||
# Validate bag_price_euros and convert to cents
|
||||
try:
|
||||
bag_price_cents = int(bag_price_cents_str)
|
||||
bag_price_euros = float(bag_price_euros_str)
|
||||
bag_price_cents = int(round(bag_price_euros * 100))
|
||||
except ValueError:
|
||||
return _render_purchase_error(
|
||||
request,
|
||||
@@ -436,7 +437,7 @@ def _render_give_error(
|
||||
HTMLResponse with 422 status.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
content=str(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
feed_page(
|
||||
@@ -470,7 +471,7 @@ def _render_purchase_error(request, locations, feed_types, error_message):
|
||||
HTMLResponse with 422 status.
|
||||
"""
|
||||
return HTMLResponse(
|
||||
content=str(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
feed_page(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Span
|
||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Select, Span
|
||||
from monsterui.all import (
|
||||
Alert,
|
||||
AlertT,
|
||||
@@ -47,6 +47,7 @@ def event_datetime_field(
|
||||
"""
|
||||
picker_id = f"{field_id}_picker"
|
||||
input_id = f"{field_id}_input"
|
||||
ts_utc_id = f"{field_id}_ts_utc"
|
||||
|
||||
# If initial value is set, start with picker expanded
|
||||
has_initial = bool(initial_value)
|
||||
@@ -57,7 +58,7 @@ def event_datetime_field(
|
||||
toggle_onclick = f"""
|
||||
var picker = document.getElementById('{picker_id}');
|
||||
var input = document.getElementById('{input_id}');
|
||||
var tsField = document.querySelector('input[name="ts_utc"]');
|
||||
var tsField = document.getElementById('{ts_utc_id}');
|
||||
if (picker.style.display === 'none') {{
|
||||
picker.style.display = 'block';
|
||||
this.textContent = 'Use current time';
|
||||
@@ -70,14 +71,14 @@ def event_datetime_field(
|
||||
"""
|
||||
|
||||
# Inline JavaScript for input change handler
|
||||
input_onchange = """
|
||||
var tsField = document.querySelector('input[name="ts_utc"]');
|
||||
if (tsField && this.value) {
|
||||
input_onchange = f"""
|
||||
var tsField = document.getElementById('{ts_utc_id}');
|
||||
if (tsField && this.value) {{
|
||||
var date = new Date(this.value);
|
||||
tsField.value = date.getTime().toString();
|
||||
} else if (tsField) {
|
||||
}} else if (tsField) {{
|
||||
tsField.value = '0';
|
||||
}
|
||||
}}
|
||||
"""
|
||||
|
||||
return Div(
|
||||
@@ -111,7 +112,7 @@ def event_datetime_field(
|
||||
),
|
||||
cls="mt-1",
|
||||
),
|
||||
Hidden(name="ts_utc", value=initial_ts),
|
||||
Hidden(id=ts_utc_id, name="ts_utc", value=initial_ts),
|
||||
cls="space-y-1",
|
||||
)
|
||||
|
||||
@@ -174,7 +175,6 @@ def cohort_form(
|
||||
life_stages = [
|
||||
("hatchling", "Hatchling"),
|
||||
("juvenile", "Juvenile"),
|
||||
("subadult", "Subadult"),
|
||||
("adult", "Adult"),
|
||||
]
|
||||
life_stage_options = [
|
||||
@@ -437,9 +437,15 @@ def promote_form(
|
||||
"""
|
||||
display_id = f"{animal.animal_id[:8]}..."
|
||||
|
||||
# Title and action text depend on whether animal is already identified
|
||||
is_rename = animal.identified
|
||||
form_title = "Rename Animal" if is_rename else "Promote Animal"
|
||||
action_text = "Renaming" if is_rename else "Promoting"
|
||||
|
||||
# Build sex options (optional - can refine current value)
|
||||
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
|
||||
sexes = [
|
||||
("", "Keep current"),
|
||||
("-", "Keep current"),
|
||||
("female", "Female"),
|
||||
("male", "Male"),
|
||||
]
|
||||
@@ -447,13 +453,14 @@ def promote_form(
|
||||
for code, label in sexes:
|
||||
sex_options.append(Option(label, value=code, selected=code == selected_sex))
|
||||
|
||||
# Build repro status options (optional)
|
||||
# Build repro status options (optional) - must match ReproStatus enum
|
||||
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
|
||||
repro_statuses = [
|
||||
("", "Unknown"),
|
||||
("breeding", "Breeding"),
|
||||
("non_breeding", "Non-Breeding"),
|
||||
("broody", "Broody"),
|
||||
("molting", "Molting"),
|
||||
("-", "Keep current"),
|
||||
("intact", "Intact"),
|
||||
("wether", "Wether (castrated male)"),
|
||||
("spayed", "Spayed (female)"),
|
||||
("unknown", "Unknown"),
|
||||
]
|
||||
repro_status_options = []
|
||||
for code, label in repro_statuses:
|
||||
@@ -467,36 +474,34 @@ def promote_form(
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
return Form(
|
||||
H2("Promote Animal", cls="text-xl font-bold mb-4"),
|
||||
H2(form_title, cls="text-xl font-bold mb-4"),
|
||||
# Animal info
|
||||
Div(
|
||||
P(f"Promoting: {display_id}", cls="text-sm text-stone-400"),
|
||||
P(f"{action_text}: {display_id}", cls="text-sm text-stone-400"),
|
||||
P(f"Species: {animal.species_code}, Sex: {animal.sex}", cls="text-sm text-stone-400"),
|
||||
cls="p-3 bg-slate-800 rounded-md mb-4",
|
||||
),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Nickname input (optional)
|
||||
# Nickname input - label changes for rename vs promote
|
||||
LabelInput(
|
||||
"Nickname (optional)",
|
||||
"New Name" if is_rename else "Nickname (optional)",
|
||||
id="nickname",
|
||||
name="nickname",
|
||||
value=nickname_value,
|
||||
placeholder="Give this animal a name",
|
||||
value=nickname_value or (animal.nickname or ""),
|
||||
placeholder="Enter a name for this animal",
|
||||
),
|
||||
# Sex refinement dropdown (optional)
|
||||
LabelSelect(
|
||||
*sex_options,
|
||||
label="Refine Sex (optional)",
|
||||
id="sex",
|
||||
name="sex",
|
||||
# Sex refinement dropdown (optional) - using raw Select to fix value handling
|
||||
Div(
|
||||
FormLabel("Refine Sex (optional)", _for="sex"),
|
||||
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Repro status dropdown (optional)
|
||||
LabelSelect(
|
||||
*repro_status_options,
|
||||
label="Reproductive Status",
|
||||
id="repro_status",
|
||||
name="repro_status",
|
||||
# Repro status dropdown (optional) - using raw Select to fix value handling
|
||||
Div(
|
||||
FormLabel("Reproductive Status", _for="repro_status"),
|
||||
Select(*repro_status_options, name="repro_status", id="repro_status", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Distinguishing traits (optional)
|
||||
LabelTextArea(
|
||||
@@ -516,8 +521,12 @@ def promote_form(
|
||||
# Hidden fields
|
||||
Hidden(name="animal_id", value=animal.animal_id),
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Promote to Identified", type="submit", cls=ButtonT.primary),
|
||||
# Submit button - text changes for rename vs promote
|
||||
Button(
|
||||
"Save Changes" if is_rename else "Promote to Identified",
|
||||
type="submit",
|
||||
cls=ButtonT.primary,
|
||||
),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
@@ -1025,25 +1034,27 @@ def attrs_form(
|
||||
)
|
||||
|
||||
# Build sex options
|
||||
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
|
||||
sex_options = [
|
||||
Option("No change", value="", selected=True),
|
||||
Option("No change", value="-", selected=True),
|
||||
Option("Female", value="female"),
|
||||
Option("Male", value="male"),
|
||||
Option("Unknown", value="unknown"),
|
||||
]
|
||||
|
||||
# Build life stage options
|
||||
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
|
||||
life_stage_options = [
|
||||
Option("No change", value="", selected=True),
|
||||
Option("No change", value="-", selected=True),
|
||||
Option("Hatchling", value="hatchling"),
|
||||
Option("Juvenile", value="juvenile"),
|
||||
Option("Subadult", value="subadult"),
|
||||
Option("Adult", value="adult"),
|
||||
]
|
||||
|
||||
# Build repro status options (intact, wether, spayed, unknown)
|
||||
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
|
||||
repro_status_options = [
|
||||
Option("No change", value="", selected=True),
|
||||
Option("No change", value="-", selected=True),
|
||||
Option("Intact", value="intact"),
|
||||
Option("Wether (castrated male)", value="wether"),
|
||||
Option("Spayed (female)", value="spayed"),
|
||||
@@ -1073,24 +1084,21 @@ def attrs_form(
|
||||
),
|
||||
# Selection container - updated via HTMX when filter changes
|
||||
selection_container,
|
||||
# Attribute dropdowns
|
||||
LabelSelect(
|
||||
*sex_options,
|
||||
label="Sex",
|
||||
id="sex",
|
||||
name="sex",
|
||||
# Attribute dropdowns - using raw Select to fix value handling
|
||||
Div(
|
||||
FormLabel("Sex", _for="sex"),
|
||||
Select(*sex_options, name="sex", id="sex", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
LabelSelect(
|
||||
*life_stage_options,
|
||||
label="Life Stage",
|
||||
id="life_stage",
|
||||
name="life_stage",
|
||||
Div(
|
||||
FormLabel("Life Stage", _for="life_stage"),
|
||||
Select(*life_stage_options, name="life_stage", id="life_stage", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
LabelSelect(
|
||||
*repro_status_options,
|
||||
label="Reproductive Status",
|
||||
id="repro_status",
|
||||
name="repro_status",
|
||||
Div(
|
||||
FormLabel("Reproductive Status", _for="repro_status"),
|
||||
Select(*repro_status_options, name="repro_status", id="repro_status", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Optional notes
|
||||
LabelTextArea(
|
||||
@@ -1282,7 +1290,7 @@ def outcome_form(
|
||||
# Build outcome options
|
||||
outcome_options = [
|
||||
Option("Select outcome...", value="", selected=True, disabled=True),
|
||||
Option("Death (natural)", value="death"),
|
||||
Option("Death", value="death"),
|
||||
Option("Harvest", value="harvest"),
|
||||
Option("Sold", value="sold"),
|
||||
Option("Predator Loss", value="predator_loss"),
|
||||
@@ -1290,7 +1298,8 @@ def outcome_form(
|
||||
]
|
||||
|
||||
# Build product options for yield items
|
||||
product_options = [Option("Select product...", value="", selected=True)]
|
||||
# Note: Use "-" sentinel instead of "" because FastHTML omits empty value attributes
|
||||
product_options = [Option("Select product...", value="-", selected=True)]
|
||||
for code, name in products:
|
||||
product_options.append(Option(f"{name} ({code})", value=code))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from fasthtml.common import H2, H3, A, Div, Li, P, Span, Ul
|
||||
from monsterui.all import Button, ButtonT, Card, Grid
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.repositories.animal_timeline import (
|
||||
AnimalDetail,
|
||||
MergeInfo,
|
||||
@@ -61,7 +62,7 @@ def back_to_registry_link() -> Div:
|
||||
|
||||
def animal_header_card(animal: AnimalDetail, merge_info: MergeInfo | None) -> Card:
|
||||
"""Header card with animal summary."""
|
||||
display_name = animal.nickname or f"{animal.animal_id[:8]}..."
|
||||
display_name = format_animal_id(animal.animal_id, animal.nickname)
|
||||
status_badge = status_badge_component(animal.status)
|
||||
|
||||
tags_display = (
|
||||
@@ -160,13 +161,14 @@ def quick_actions_card(animal: AnimalDetail) -> Card:
|
||||
href=f"/actions/tag-add?filter=animal_id:{animal.animal_id}",
|
||||
)
|
||||
)
|
||||
if not animal.identified:
|
||||
actions.append(
|
||||
A(
|
||||
Button("Promote", cls=ButtonT.default + " w-full"),
|
||||
href=f"/actions/promote/{animal.animal_id}",
|
||||
)
|
||||
# Show "Promote" for unidentified animals, "Rename" for identified ones
|
||||
promote_label = "Rename" if animal.identified else "Promote"
|
||||
actions.append(
|
||||
A(
|
||||
Button(promote_label, cls=ButtonT.default + " w-full"),
|
||||
href=f"/actions/promote/{animal.animal_id}",
|
||||
)
|
||||
)
|
||||
actions.append(
|
||||
A(
|
||||
Button("Record Outcome", cls=ButtonT.destructive + " w-full"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from fasthtml.common import Div, Input, Label, P, Span
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.repositories.animals import AnimalListItem
|
||||
|
||||
|
||||
@@ -29,7 +30,12 @@ def animal_checkbox_list(
|
||||
items = []
|
||||
for animal in animals[:max_display]:
|
||||
is_checked = animal.animal_id in selected_set
|
||||
display_name = animal.nickname or animal.animal_id[:8] + "..."
|
||||
display_name = format_animal_id(animal.animal_id, animal.nickname)
|
||||
|
||||
# Format sex as single letter (M/F/?)
|
||||
sex_code = {"male": "M", "female": "F", "unknown": "?"}.get(animal.sex, "?")
|
||||
# Format life stage as abbreviation
|
||||
stage_abbr = animal.life_stage[:3].title() # hat, juv, adu
|
||||
|
||||
items.append(
|
||||
Label(
|
||||
@@ -41,9 +47,9 @@ def animal_checkbox_list(
|
||||
cls="uk-checkbox mr-2",
|
||||
hx_on_change="updateSelectionCount()",
|
||||
),
|
||||
Span(display_name, cls="text-stone-200"),
|
||||
Span(display_name, cls="text-stone-200 mr-1"),
|
||||
Span(
|
||||
f" ({animal.species_code}, {animal.location_name})",
|
||||
f"({animal.species_code}, {sex_code}, {stage_abbr}, {animal.location_name})",
|
||||
cls="text-stone-500 text-sm",
|
||||
),
|
||||
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",
|
||||
|
||||
@@ -14,6 +14,19 @@ from animaltrack.web.templates.sidebar import (
|
||||
)
|
||||
|
||||
|
||||
def TabStyles(): # noqa: N802
|
||||
"""CSS styles to fix UIkit tab/switcher list markers."""
|
||||
return Style("""
|
||||
/* Remove list markers from UIkit tabs and switchers */
|
||||
.uk-tab, .uk-tab-alt, .uk-switcher, .uk-switcher > li {
|
||||
list-style: none !important;
|
||||
}
|
||||
.uk-tab > li::marker, .uk-tab-alt > li::marker, .uk-switcher > li::marker {
|
||||
content: none !important;
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
def EventSlideOverStyles(): # noqa: N802
|
||||
"""CSS styles for event detail slide-over panel."""
|
||||
return Style("""
|
||||
@@ -68,6 +81,37 @@ def EventSlideOverScript(): # noqa: N802
|
||||
""")
|
||||
|
||||
|
||||
def CsrfHeaderScript(): # noqa: N802
|
||||
"""JavaScript to inject CSRF token into HTMX requests.
|
||||
|
||||
Reads the csrf_token cookie and adds it as x-csrf-token header
|
||||
to all HTMX requests. This is required for POST/PUT/DELETE
|
||||
requests to pass CSRF validation.
|
||||
"""
|
||||
return Script("""
|
||||
// Read CSRF token from cookie
|
||||
function getCsrfToken() {
|
||||
var name = 'csrf_token=';
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var cookie = cookies[i].trim();
|
||||
if (cookie.indexOf(name) === 0) {
|
||||
return cookie.substring(name.length);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Configure HTMX to send CSRF token with all requests
|
||||
document.body.addEventListener('htmx:configRequest', function(event) {
|
||||
var token = getCsrfToken();
|
||||
if (token) {
|
||||
event.detail.headers['x-csrf-token'] = token;
|
||||
}
|
||||
});
|
||||
""")
|
||||
|
||||
|
||||
def EventSlideOver(): # noqa: N802
|
||||
"""Event detail slide-over panel container."""
|
||||
return Div(
|
||||
@@ -124,9 +168,11 @@ def page(
|
||||
Title(title),
|
||||
BottomNavStyles(),
|
||||
SidebarStyles(),
|
||||
TabStyles(),
|
||||
EventSlideOverStyles(),
|
||||
SidebarScript(),
|
||||
EventSlideOverScript(),
|
||||
CsrfHeaderScript(),
|
||||
# Desktop sidebar
|
||||
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
|
||||
# Mobile menu drawer
|
||||
|
||||
@@ -16,6 +16,7 @@ from monsterui.all import (
|
||||
from ulid import ULID
|
||||
|
||||
from animaltrack.models.reference import Location, Product
|
||||
from animaltrack.web.templates.actions import event_datetime_field
|
||||
|
||||
|
||||
def eggs_page(
|
||||
@@ -51,20 +52,8 @@ def eggs_page(
|
||||
H1("Eggs", cls="text-2xl font-bold mb-6"),
|
||||
# Tab navigation
|
||||
TabContainer(
|
||||
Li(
|
||||
A(
|
||||
"Harvest",
|
||||
href="#",
|
||||
cls="uk-active" if harvest_active else "",
|
||||
),
|
||||
),
|
||||
Li(
|
||||
A(
|
||||
"Sell",
|
||||
href="#",
|
||||
cls="" if harvest_active else "uk-active",
|
||||
),
|
||||
),
|
||||
Li(A("Harvest", href="#"), cls="uk-active" if harvest_active else None),
|
||||
Li(A("Sell", href="#"), cls=None if harvest_active else "uk-active"),
|
||||
uk_switcher="connect: #egg-forms; animation: uk-animation-fade",
|
||||
alt=True,
|
||||
),
|
||||
@@ -77,7 +66,7 @@ def eggs_page(
|
||||
error=harvest_error,
|
||||
action=harvest_action,
|
||||
),
|
||||
cls="uk-active" if harvest_active else "",
|
||||
cls="uk-active" if harvest_active else None,
|
||||
),
|
||||
Li(
|
||||
sell_form(
|
||||
@@ -86,7 +75,7 @@ def eggs_page(
|
||||
error=sell_error,
|
||||
action=sell_action,
|
||||
),
|
||||
cls="" if harvest_active else "uk-active",
|
||||
cls=None if harvest_active else "uk-active",
|
||||
),
|
||||
),
|
||||
cls="p-4",
|
||||
@@ -163,6 +152,8 @@ def harvest_form(
|
||||
name="notes",
|
||||
placeholder="Optional notes",
|
||||
),
|
||||
# Event datetime picker (for backdating)
|
||||
event_datetime_field("harvest_datetime"),
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
@@ -263,6 +254,8 @@ def sell_form(
|
||||
name="notes",
|
||||
placeholder="Optional notes",
|
||||
),
|
||||
# Event datetime picker (for backdating)
|
||||
event_datetime_field("sell_datetime"),
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.models.reference import UserRole
|
||||
|
||||
@@ -19,6 +20,7 @@ def format_timestamp(ts_utc: int) -> str:
|
||||
def event_detail_panel(
|
||||
event: Event,
|
||||
affected_animals: list[dict[str, Any]],
|
||||
total_animal_count: int = 0,
|
||||
is_tombstoned: bool = False,
|
||||
location_names: dict[str, str] | None = None,
|
||||
user_role: UserRole | None = None,
|
||||
@@ -27,7 +29,8 @@ def event_detail_panel(
|
||||
|
||||
Args:
|
||||
event: The event to display.
|
||||
affected_animals: List of animals affected by this event.
|
||||
affected_animals: List of animals affected by this event (may be limited).
|
||||
total_animal_count: Total number of affected animals.
|
||||
is_tombstoned: Whether the event has been deleted.
|
||||
location_names: Map of location IDs to names.
|
||||
user_role: User's role for delete button visibility.
|
||||
@@ -37,6 +40,8 @@ def event_detail_panel(
|
||||
"""
|
||||
if location_names is None:
|
||||
location_names = {}
|
||||
if total_animal_count == 0:
|
||||
total_animal_count = len(affected_animals)
|
||||
|
||||
return Div(
|
||||
# Header with close button
|
||||
@@ -68,7 +73,7 @@ def event_detail_panel(
|
||||
# Entity references
|
||||
entity_refs_section(event.entity_refs, location_names),
|
||||
# Affected animals
|
||||
affected_animals_section(affected_animals),
|
||||
affected_animals_section(affected_animals, total_animal_count, event.id),
|
||||
# Delete button (admin only, not for tombstoned events)
|
||||
delete_section(event.id) if user_role == UserRole.ADMIN and not is_tombstoned else None,
|
||||
id="event-panel-content",
|
||||
@@ -354,20 +359,76 @@ def entity_refs_section(
|
||||
)
|
||||
|
||||
|
||||
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
||||
"""Section showing affected animals."""
|
||||
if not animals:
|
||||
def affected_animals_section(
|
||||
animals: list[dict[str, Any]],
|
||||
total_count: int,
|
||||
event_id: str,
|
||||
) -> Div:
|
||||
"""Section showing affected animals with expandable list.
|
||||
|
||||
Args:
|
||||
animals: List of animals to display (may be limited).
|
||||
total_count: Total number of affected animals.
|
||||
event_id: Event ID for "Show all" button.
|
||||
|
||||
Returns:
|
||||
Div containing the affected animals section.
|
||||
"""
|
||||
if not animals and total_count == 0:
|
||||
return Div()
|
||||
|
||||
return Div(
|
||||
H3(
|
||||
f"Affected Animals ({total_count})",
|
||||
cls="text-sm font-semibold text-stone-400 mb-2",
|
||||
),
|
||||
affected_animals_list(animals, total_count, event_id=event_id),
|
||||
cls="p-4",
|
||||
id="affected-animals-section",
|
||||
)
|
||||
|
||||
|
||||
def affected_animals_list(
|
||||
animals: list[dict[str, Any]],
|
||||
total_count: int,
|
||||
event_id: str | None = None,
|
||||
expanded: bool = False,
|
||||
) -> Div:
|
||||
"""List of affected animals with optional expand button.
|
||||
|
||||
Args:
|
||||
animals: List of animals to display.
|
||||
total_count: Total number of affected animals.
|
||||
event_id: Event ID for "Show all" button (None if expanded).
|
||||
expanded: Whether showing full list.
|
||||
|
||||
Returns:
|
||||
Div containing the animal list.
|
||||
"""
|
||||
animal_items = []
|
||||
for animal in animals[:20]: # Limit display
|
||||
display_name = animal.get("nickname") or animal["id"][:8] + "..."
|
||||
for animal in animals:
|
||||
display_name = format_animal_id(animal["id"], animal.get("nickname"))
|
||||
|
||||
# Build details string: sex abbreviation, life stage, location
|
||||
sex_abbr = {"male": "M", "female": "F", "unknown": "?"}.get(
|
||||
animal.get("sex", "unknown"), "?"
|
||||
)
|
||||
life_stage = animal.get("life_stage", "").replace("_", " ")
|
||||
location = animal.get("location_name", "")
|
||||
|
||||
details_parts = [sex_abbr]
|
||||
if life_stage:
|
||||
details_parts.append(life_stage)
|
||||
if location:
|
||||
details_parts.append(location)
|
||||
details_str = ", ".join(details_parts)
|
||||
|
||||
animal_items.append(
|
||||
Li(
|
||||
A(
|
||||
Span(display_name, cls="text-amber-500 hover:underline"),
|
||||
Span(display_name, cls="text-amber-500 hover:underline mr-1"),
|
||||
Span(
|
||||
f" ({animal.get('species_name', '')})",
|
||||
f"({details_str})",
|
||||
cls="text-stone-500 text-xs",
|
||||
),
|
||||
href=f"/animals/{animal['id']}",
|
||||
@@ -376,22 +437,23 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
||||
)
|
||||
)
|
||||
|
||||
more_count = len(animals) - 20
|
||||
if more_count > 0:
|
||||
animal_items.append(
|
||||
Li(
|
||||
Span(f"... and {more_count} more", cls="text-stone-500 text-sm"),
|
||||
cls="py-1",
|
||||
)
|
||||
# Show "Show all X animals" button if there are more
|
||||
more_count = total_count - len(animals)
|
||||
show_all_button = None
|
||||
if more_count > 0 and not expanded and event_id:
|
||||
show_all_button = Button(
|
||||
f"Show all {total_count} animals",
|
||||
hx_get=f"/events/{event_id}/animals",
|
||||
hx_target="#affected-animals-list",
|
||||
hx_swap="outerHTML",
|
||||
cls="mt-2 text-sm text-amber-500 hover:text-amber-400 hover:underline",
|
||||
type="button",
|
||||
)
|
||||
|
||||
return Div(
|
||||
H3(
|
||||
f"Affected Animals ({len(animals)})",
|
||||
cls="text-sm font-semibold text-stone-400 mb-2",
|
||||
),
|
||||
Ul(*animal_items, cls="space-y-1"),
|
||||
cls="p-4",
|
||||
show_all_button,
|
||||
id="affected-animals-list",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
|
||||
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Select, Ul
|
||||
from monsterui.all import (
|
||||
Button,
|
||||
ButtonT,
|
||||
FormLabel,
|
||||
LabelInput,
|
||||
LabelSelect,
|
||||
LabelTextArea,
|
||||
TabContainer,
|
||||
)
|
||||
@@ -56,20 +56,8 @@ def feed_page(
|
||||
H1("Feed", cls="text-2xl font-bold mb-6"),
|
||||
# Tab navigation
|
||||
TabContainer(
|
||||
Li(
|
||||
A(
|
||||
"Give Feed",
|
||||
href="#",
|
||||
cls="uk-active" if give_active else "",
|
||||
),
|
||||
),
|
||||
Li(
|
||||
A(
|
||||
"Purchase Feed",
|
||||
href="#",
|
||||
cls="" if give_active else "uk-active",
|
||||
),
|
||||
),
|
||||
Li(A("Give Feed", href="#"), cls="uk-active" if give_active else None),
|
||||
Li(A("Purchase Feed", href="#"), cls=None if give_active else "uk-active"),
|
||||
uk_switcher="connect: #feed-forms; animation: uk-animation-fade",
|
||||
alt=True,
|
||||
),
|
||||
@@ -86,11 +74,11 @@ def feed_page(
|
||||
balance_warning=balance_warning,
|
||||
action=give_action,
|
||||
),
|
||||
cls="uk-active" if give_active else "",
|
||||
cls="uk-active" if give_active else None,
|
||||
),
|
||||
Li(
|
||||
purchase_feed_form(feed_types, error=purchase_error, action=purchase_action),
|
||||
cls="" if give_active else "uk-active",
|
||||
cls=None if give_active else "uk-active",
|
||||
),
|
||||
),
|
||||
cls="p-4",
|
||||
@@ -170,19 +158,17 @@ def give_feed_form(
|
||||
H2("Give Feed", cls="text-xl font-bold mb-4"),
|
||||
error_component,
|
||||
warning_component,
|
||||
# Location dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Location",
|
||||
id="location_id",
|
||||
name="location_id",
|
||||
# Location dropdown - using raw Select to fix value handling
|
||||
Div(
|
||||
FormLabel("Location", _for="location_id"),
|
||||
Select(*location_options, name="location_id", id="location_id", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Feed type dropdown
|
||||
LabelSelect(
|
||||
*feed_type_options,
|
||||
label="Feed Type",
|
||||
id="feed_type_code",
|
||||
name="feed_type_code",
|
||||
# Feed type dropdown - using raw Select to fix value handling
|
||||
Div(
|
||||
FormLabel("Feed Type", _for="feed_type_code"),
|
||||
Select(*feed_type_options, name="feed_type_code", id="feed_type_code", cls="uk-select"),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Amount input
|
||||
LabelInput(
|
||||
@@ -247,12 +233,16 @@ def purchase_feed_form(
|
||||
return Form(
|
||||
H2("Purchase Feed", cls="text-xl font-bold mb-4"),
|
||||
error_component,
|
||||
# Feed type dropdown
|
||||
LabelSelect(
|
||||
*feed_type_options,
|
||||
label="Feed Type",
|
||||
id="purchase_feed_type_code",
|
||||
name="feed_type_code",
|
||||
# Feed type dropdown - using raw Select to fix value handling
|
||||
Div(
|
||||
FormLabel("Feed Type", _for="purchase_feed_type_code"),
|
||||
Select(
|
||||
*feed_type_options,
|
||||
name="feed_type_code",
|
||||
id="purchase_feed_type_code",
|
||||
cls="uk-select",
|
||||
),
|
||||
cls="space-y-2",
|
||||
),
|
||||
# Bag size
|
||||
LabelInput(
|
||||
@@ -276,15 +266,15 @@ def purchase_feed_form(
|
||||
value="1",
|
||||
required=True,
|
||||
),
|
||||
# Price per bag (cents)
|
||||
# Price per bag (euros)
|
||||
LabelInput(
|
||||
"Price per Bag (cents)",
|
||||
id="bag_price_cents",
|
||||
name="bag_price_cents",
|
||||
"Price per Bag (€)",
|
||||
id="bag_price_euros",
|
||||
name="bag_price_euros",
|
||||
type="number",
|
||||
min="0",
|
||||
step="1",
|
||||
placeholder="e.g., 2400 for 24.00",
|
||||
step="0.01",
|
||||
placeholder="e.g., 24.00",
|
||||
required=True,
|
||||
),
|
||||
# Optional vendor
|
||||
|
||||
@@ -5,9 +5,27 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr
|
||||
from monsterui.all import Button, ButtonT, Card, Grid, LabelInput
|
||||
from fasthtml.common import (
|
||||
H2,
|
||||
A,
|
||||
Div,
|
||||
Form,
|
||||
Input,
|
||||
Li,
|
||||
P,
|
||||
Script,
|
||||
Span,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Ul,
|
||||
)
|
||||
from monsterui.all import Button, ButtonT, FormLabel, Grid
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.models.reference import Location, Species
|
||||
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
|
||||
|
||||
@@ -20,8 +38,8 @@ def registry_page(
|
||||
total_count: int = 0,
|
||||
locations: list[Location] | None = None,
|
||||
species_list: list[Species] | None = None,
|
||||
) -> Grid:
|
||||
"""Full registry page with sidebar and table.
|
||||
) -> Div:
|
||||
"""Full registry page with filter at top, then sidebar + table.
|
||||
|
||||
Args:
|
||||
animals: List of animals for the current page.
|
||||
@@ -33,27 +51,32 @@ def registry_page(
|
||||
species_list: List of species for facet labels.
|
||||
|
||||
Returns:
|
||||
Grid component with sidebar and main content.
|
||||
Div component with header, sidebar, and main content.
|
||||
"""
|
||||
return Grid(
|
||||
# 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 - selection toolbar + table
|
||||
Div(
|
||||
selection_toolbar(),
|
||||
animal_table(animals, next_cursor, filter_str),
|
||||
selection_script(),
|
||||
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 +86,45 @@ def registry_header(filter_str: str, total_count: int) -> Div:
|
||||
Div with header and filter form.
|
||||
"""
|
||||
return Div(
|
||||
# Top row: Title and count
|
||||
Div(
|
||||
H2("Animal Registry", cls="text-xl font-bold"),
|
||||
Span(f"{total_count} animals", cls="text-sm text-stone-400"),
|
||||
cls="flex items-center justify-between",
|
||||
Span(f"{total_count} animals", cls="text-sm text-stone-400 ml-3"),
|
||||
cls="flex items-baseline mb-4",
|
||||
),
|
||||
# Filter form
|
||||
# Filter form - full width, prominent
|
||||
Form(
|
||||
Div(
|
||||
LabelInput(
|
||||
"Filter",
|
||||
# Label above the input row
|
||||
FormLabel("Filter", _for="filter", cls="mb-2 block"),
|
||||
Grid(
|
||||
# Filter input - takes most of the width
|
||||
Input(
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder='e.g., species:duck status:alive location:"Strip 1"',
|
||||
cls="flex-1",
|
||||
placeholder='species:duck status:alive location:"Strip 1"',
|
||||
cls="uk-input col-span-10",
|
||||
),
|
||||
Button("Apply", type="submit", cls=ButtonT.primary),
|
||||
cls="flex gap-2 items-end",
|
||||
# Buttons container
|
||||
Div(
|
||||
Button("Apply", type="submit", cls=f"{ButtonT.primary} px-4"),
|
||||
# Clear button (only shown if filter is active)
|
||||
A(
|
||||
"Clear",
|
||||
href="/registry",
|
||||
cls="px-3 py-2 text-stone-400 hover:text-stone-200",
|
||||
)
|
||||
if filter_str
|
||||
else None,
|
||||
cls="flex gap-2 col-span-2",
|
||||
),
|
||||
cols=12,
|
||||
cls="gap-2 items-center",
|
||||
),
|
||||
action="/registry",
|
||||
method="get",
|
||||
cls="mt-4",
|
||||
),
|
||||
cls="mb-4",
|
||||
cls="mb-6 pb-4 border-b border-stone-700",
|
||||
)
|
||||
|
||||
|
||||
@@ -96,7 +134,7 @@ def facet_sidebar(
|
||||
locations: list[Location] | None,
|
||||
species_list: list[Species] | None,
|
||||
) -> Div:
|
||||
"""Sidebar with clickable facet counts.
|
||||
"""Sidebar with compact clickable facet counts.
|
||||
|
||||
Args:
|
||||
facets: Facet counts for display.
|
||||
@@ -116,7 +154,7 @@ def facet_sidebar(
|
||||
facet_section("Sex", facets.by_sex, filter_str, "sex"),
|
||||
facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"),
|
||||
facet_section("Location", facets.by_location, filter_str, "location", location_map),
|
||||
cls="space-y-4",
|
||||
cls="space-y-3",
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +165,7 @@ def facet_section(
|
||||
field: str,
|
||||
label_map: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
"""Single facet section with clickable items.
|
||||
"""Single facet section with compact pill-style items.
|
||||
|
||||
Args:
|
||||
title: Section title.
|
||||
@@ -137,11 +175,12 @@ def facet_section(
|
||||
label_map: Optional mapping from value to display label.
|
||||
|
||||
Returns:
|
||||
Card component with facet items, or None if no counts.
|
||||
Div component with facet pills, or None if no counts.
|
||||
"""
|
||||
if not counts:
|
||||
return None
|
||||
|
||||
# Build inline pill items
|
||||
items = []
|
||||
for value, count in sorted(counts.items(), key=lambda x: -x[1]):
|
||||
label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
|
||||
@@ -153,19 +192,19 @@ def facet_section(
|
||||
href = f"/registry?{urlencode({'filter': new_filter})}"
|
||||
items.append(
|
||||
A(
|
||||
Div(
|
||||
Span(label, cls="text-sm"),
|
||||
Span(str(count), cls="text-xs text-stone-400 ml-auto"),
|
||||
cls="flex justify-between items-center",
|
||||
),
|
||||
Span(label, cls="text-xs"),
|
||||
Span(str(count), cls="text-xs text-stone-500 ml-1"),
|
||||
href=href,
|
||||
cls="block p-2 hover:bg-slate-800 rounded",
|
||||
cls="inline-flex items-center px-2 py-1 rounded bg-stone-800 hover:bg-stone-700 mr-1 mb-1",
|
||||
)
|
||||
)
|
||||
|
||||
return Card(
|
||||
P(title, cls="font-bold text-sm mb-2"),
|
||||
*items,
|
||||
return Div(
|
||||
P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
|
||||
Div(
|
||||
*items,
|
||||
cls="flex flex-wrap",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -187,6 +226,14 @@ def animal_table(
|
||||
return Table(
|
||||
Thead(
|
||||
Tr(
|
||||
Th(
|
||||
Input(
|
||||
type="checkbox",
|
||||
id="select-all-checkbox",
|
||||
cls="uk-checkbox",
|
||||
),
|
||||
cls="w-8",
|
||||
),
|
||||
Th("ID", shrink=True),
|
||||
Th("Species"),
|
||||
Th("Sex"),
|
||||
@@ -220,8 +267,8 @@ def animal_row(animal: AnimalListItem) -> Tr:
|
||||
last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC)
|
||||
last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Display ID (truncated or nickname)
|
||||
display_id = animal.nickname or animal.animal_id[:8] + "..."
|
||||
# Display ID (phonetic encoding or nickname)
|
||||
display_id = format_animal_id(animal.animal_id, animal.nickname)
|
||||
|
||||
# Status badge styling
|
||||
status_cls = {
|
||||
@@ -238,6 +285,13 @@ def animal_row(animal: AnimalListItem) -> Tr:
|
||||
tags_str += "..."
|
||||
|
||||
return Tr(
|
||||
Td(
|
||||
Input(
|
||||
type="checkbox",
|
||||
cls="uk-checkbox animal-checkbox",
|
||||
data_animal_id=animal.animal_id,
|
||||
),
|
||||
),
|
||||
Td(
|
||||
A(
|
||||
display_id,
|
||||
@@ -303,10 +357,208 @@ def load_more_sentinel(cursor: str, filter_str: str) -> Tr:
|
||||
"Loading more...",
|
||||
cls="text-center text-stone-400 py-4",
|
||||
),
|
||||
colspan="8",
|
||||
colspan="9", # Updated for checkbox column
|
||||
),
|
||||
hx_get=url,
|
||||
hx_trigger="revealed",
|
||||
hx_swap="outerHTML",
|
||||
id="load-more-sentinel",
|
||||
)
|
||||
|
||||
|
||||
def selection_toolbar() -> Div:
|
||||
"""Toolbar for bulk actions on selected animals.
|
||||
|
||||
Returns:
|
||||
Div with selection count and actions dropdown.
|
||||
"""
|
||||
return Div(
|
||||
# Left side: selection info and controls
|
||||
Div(
|
||||
Span("0 selected", id="selection-count", cls="text-sm text-stone-400"),
|
||||
A(
|
||||
"Select all",
|
||||
href="#",
|
||||
id="select-all-btn",
|
||||
cls="text-sm text-amber-500 hover:underline ml-3",
|
||||
),
|
||||
A(
|
||||
"Clear",
|
||||
href="#",
|
||||
id="clear-selection-btn",
|
||||
cls="text-sm text-stone-400 hover:text-stone-200 ml-3 hidden",
|
||||
),
|
||||
cls="flex items-center",
|
||||
),
|
||||
# Right side: actions dropdown
|
||||
Div(
|
||||
Button(
|
||||
"Actions",
|
||||
id="actions-btn",
|
||||
cls=f"{ButtonT.default} px-4",
|
||||
disabled=True,
|
||||
),
|
||||
Div(
|
||||
Ul(
|
||||
Li(
|
||||
A(
|
||||
"Move",
|
||||
href="#",
|
||||
data_action="move",
|
||||
cls="block px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
),
|
||||
Li(
|
||||
A(
|
||||
"Add Tag",
|
||||
href="#",
|
||||
data_action="tag-add",
|
||||
cls="block px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
),
|
||||
Li(
|
||||
A(
|
||||
"Update Attributes",
|
||||
href="#",
|
||||
data_action="attrs",
|
||||
cls="block px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
),
|
||||
Li(
|
||||
A(
|
||||
"Record Outcome",
|
||||
href="#",
|
||||
data_action="outcome",
|
||||
cls="block px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
),
|
||||
cls="uk-nav uk-dropdown-nav",
|
||||
),
|
||||
uk_dropdown="mode: click; pos: bottom-right",
|
||||
cls="uk-dropdown",
|
||||
),
|
||||
cls="uk-inline",
|
||||
),
|
||||
cls="flex justify-between items-center mb-4 py-2 px-3 bg-stone-800/50 rounded",
|
||||
id="selection-toolbar",
|
||||
)
|
||||
|
||||
|
||||
def selection_script() -> Script:
|
||||
"""JavaScript for handling animal selection.
|
||||
|
||||
Returns:
|
||||
Script element with selection logic.
|
||||
"""
|
||||
return Script("""
|
||||
(function() {
|
||||
const selectedIds = new Set();
|
||||
|
||||
function updateUI() {
|
||||
const count = selectedIds.size;
|
||||
document.getElementById('selection-count').textContent = count + ' selected';
|
||||
|
||||
const actionsBtn = document.getElementById('actions-btn');
|
||||
actionsBtn.disabled = count === 0;
|
||||
|
||||
const clearBtn = document.getElementById('clear-selection-btn');
|
||||
if (count > 0) {
|
||||
clearBtn.classList.remove('hidden');
|
||||
} else {
|
||||
clearBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update all checkboxes to reflect state
|
||||
document.querySelectorAll('.animal-checkbox').forEach(cb => {
|
||||
cb.checked = selectedIds.has(cb.dataset.animalId);
|
||||
});
|
||||
|
||||
// Update header checkbox
|
||||
const headerCb = document.getElementById('select-all-checkbox');
|
||||
const allCheckboxes = document.querySelectorAll('.animal-checkbox');
|
||||
if (allCheckboxes.length > 0) {
|
||||
const allChecked = Array.from(allCheckboxes).every(cb => selectedIds.has(cb.dataset.animalId));
|
||||
const someChecked = selectedIds.size > 0;
|
||||
headerCb.checked = allChecked;
|
||||
headerCb.indeterminate = someChecked && !allChecked;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle individual checkbox changes
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('animal-checkbox')) {
|
||||
const animalId = e.target.dataset.animalId;
|
||||
if (e.target.checked) {
|
||||
selectedIds.add(animalId);
|
||||
} else {
|
||||
selectedIds.delete(animalId);
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle header checkbox (select all visible)
|
||||
document.getElementById('select-all-checkbox').addEventListener('change', function(e) {
|
||||
document.querySelectorAll('.animal-checkbox').forEach(cb => {
|
||||
if (e.target.checked) {
|
||||
selectedIds.add(cb.dataset.animalId);
|
||||
} else {
|
||||
selectedIds.delete(cb.dataset.animalId);
|
||||
}
|
||||
});
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Select all button
|
||||
document.getElementById('select-all-btn').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.animal-checkbox').forEach(cb => {
|
||||
selectedIds.add(cb.dataset.animalId);
|
||||
});
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Clear selection button
|
||||
document.getElementById('clear-selection-btn').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
selectedIds.clear();
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Handle action clicks
|
||||
document.querySelectorAll('[data-action]').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
const action = this.dataset.action;
|
||||
const filter = 'animal_id:' + Array.from(selectedIds).join('|');
|
||||
|
||||
let url;
|
||||
switch(action) {
|
||||
case 'move':
|
||||
url = '/move?filter=' + encodeURIComponent(filter);
|
||||
break;
|
||||
case 'tag-add':
|
||||
url = '/actions/tag-add?filter=' + encodeURIComponent(filter);
|
||||
break;
|
||||
case 'attrs':
|
||||
url = '/actions/attrs?filter=' + encodeURIComponent(filter);
|
||||
break;
|
||||
case 'outcome':
|
||||
url = '/actions/outcome?filter=' + encodeURIComponent(filter);
|
||||
break;
|
||||
}
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Re-run updateUI after HTMX swaps (for infinite scroll)
|
||||
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||
// After new rows are loaded, restore checkbox states
|
||||
updateUI();
|
||||
});
|
||||
})();
|
||||
""")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from fasthtml.common import A, Button, Div, Nav, Script, Span, Style
|
||||
from fasthtml.svg import Path, Svg
|
||||
|
||||
from animaltrack.build_info import get_build_info
|
||||
from animaltrack.models.reference import UserRole
|
||||
from animaltrack.web.templates.icons import EggIcon, FeedIcon, MoveIcon
|
||||
|
||||
@@ -213,7 +214,8 @@ def Sidebar( # noqa: N802
|
||||
return Nav(
|
||||
# Logo/Brand
|
||||
Div(
|
||||
Span("ANIMALTRACK", cls="text-amber-600 font-bold tracking-wider text-sm"),
|
||||
Div("ANIMALTRACK", cls="text-amber-600 font-bold tracking-wider text-sm"),
|
||||
Div(get_build_info(), cls="text-stone-600 text-[10px] tracking-wide"),
|
||||
cls="px-4 py-4 border-b border-stone-800",
|
||||
),
|
||||
# Primary navigation
|
||||
|
||||
@@ -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