feat: implement user defaults persistence (Step 9.3)

Add user_defaults table and repository for persisting form defaults
across sessions. Feed and egg forms now load/save user preferences.

Changes:
- Add migration 0009-user-defaults.sql with table schema
- Add UserDefault model and UserDefaultsRepository
- Integrate defaults into feed route (location, feed_type, amount)
- Integrate defaults into egg route (location)
- Add repository unit tests and route integration tests

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 14:35:27 +00:00
parent d89c46ab51
commit 719d1e6ce7
9 changed files with 566 additions and 6 deletions

View File

@@ -2,6 +2,7 @@
# ABOUTME: These models validate data before database insertion and provide type safety.
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field, field_validator
@@ -127,3 +128,29 @@ class User(BaseModel):
msg = "Timestamp must be non-negative"
raise ValueError(msg)
return v
UserDefaultAction = Literal["collect_egg", "feed_given"]
class UserDefault(BaseModel):
"""User default form values that persist across sessions."""
username: str
action: UserDefaultAction
location_id: str | None = None
species: str | None = None
animal_filter: str | None = None
feed_type_code: str | None = None
amount_kg: int | None = None
bag_size_kg: int | None = None
updated_at_utc: int
@field_validator("updated_at_utc")
@classmethod
def timestamp_must_be_non_negative(cls, v: int) -> int:
"""Timestamps must be >= 0 (milliseconds since Unix epoch)."""
if v < 0:
msg = "Timestamp must be non-negative"
raise ValueError(msg)
return v

View File

@@ -5,6 +5,7 @@ from animaltrack.repositories.feed_types import FeedTypeRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.products import ProductRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository
__all__ = [
@@ -12,5 +13,6 @@ __all__ = [
"LocationRepository",
"ProductRepository",
"SpeciesRepository",
"UserDefaultsRepository",
"UserRepository",
]

View File

@@ -0,0 +1,77 @@
# ABOUTME: Repository for user form defaults.
# ABOUTME: Provides get and upsert operations for the user_defaults table.
from typing import Any
from animaltrack.models.reference import UserDefault, UserDefaultAction
class UserDefaultsRepository:
"""Repository for managing user default form values."""
def __init__(self, db: Any) -> None:
"""Initialize repository with database connection.
Args:
db: A fastlite database connection.
"""
self.db = db
def get(self, username: str, action: UserDefaultAction) -> UserDefault | None:
"""Get user defaults for a specific action.
Args:
username: The username.
action: The action type ('collect_egg' or 'feed_given').
Returns:
The UserDefault if found, None otherwise.
"""
row = self.db.execute(
"""
SELECT username, action, location_id, species, animal_filter,
feed_type_code, amount_kg, bag_size_kg, updated_at_utc
FROM user_defaults
WHERE username = ? AND action = ?
""",
(username, action),
).fetchone()
if row is None:
return None
return UserDefault(
username=row[0],
action=row[1],
location_id=row[2],
species=row[3],
animal_filter=row[4],
feed_type_code=row[5],
amount_kg=row[6],
bag_size_kg=row[7],
updated_at_utc=row[8],
)
def upsert(self, defaults: UserDefault) -> None:
"""Insert or update user defaults.
Args:
defaults: The user defaults to upsert.
"""
self.db.execute(
"""
INSERT OR REPLACE INTO user_defaults
(username, action, location_id, species, animal_filter,
feed_type_code, amount_kg, bag_size_kg, updated_at_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
defaults.username,
defaults.action,
defaults.location_id,
defaults.species,
defaults.animal_filter,
defaults.feed_type_code,
defaults.amount_kg,
defaults.bag_size_kg,
defaults.updated_at_utc,
),
)

View File

@@ -13,12 +13,15 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import ProductCollectedPayload
from animaltrack.events.store import EventStore
from animaltrack.models.reference import UserDefault
from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.projections.products import ProductsProjection
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository
from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates.eggs import egg_form
@@ -58,6 +61,15 @@ def egg_index(request: Request):
# Check for pre-selected location from query params
selected_location_id = request.query_params.get("location_id")
# If no query param, load from user defaults
if not selected_location_id:
auth = request.scope.get("auth")
username = auth.username if auth else None
if username:
defaults = UserDefaultsRepository(db).get(username, "collect_egg")
if defaults:
selected_location_id = defaults.location_id
return page(
egg_form(locations, selected_location_id=selected_location_id, action=product_collected),
title="Egg - AnimalTrack",
@@ -137,6 +149,17 @@ async def product_collected(request: Request):
except ValidationError as e:
return _render_error_form(locations, location_id, str(e))
# Save user defaults (only if user exists in database)
if UserRepository(db).get(actor):
UserDefaultsRepository(db).upsert(
UserDefault(
username=actor,
action="collect_egg",
location_id=location_id,
updated_at_utc=ts_utc,
)
)
# Success: re-render form with location sticking, qty cleared
response = HTMLResponse(
content=to_xml(

View File

@@ -12,10 +12,13 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
from animaltrack.events.store import EventStore
from animaltrack.models.reference import UserDefault
from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.feed import FeedInventoryProjection
from animaltrack.repositories.feed_types import FeedTypeRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository
from animaltrack.services.feed import FeedService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates.feed import feed_page
@@ -49,11 +52,28 @@ def feed_index(request: Request):
if active_tab not in ("give", "purchase"):
active_tab = "give"
# Load user defaults
auth = request.scope.get("auth")
username = auth.username if auth else None
selected_location_id = None
selected_feed_type_code = None
default_amount_kg = None
if username:
defaults = UserDefaultsRepository(db).get(username, "feed_given")
if defaults:
selected_location_id = defaults.location_id
selected_feed_type_code = defaults.feed_type_code
default_amount_kg = defaults.amount_kg
return page(
feed_page(
locations,
feed_types,
active_tab=active_tab,
selected_location_id=selected_location_id,
selected_feed_type_code=selected_feed_type_code,
default_amount_kg=default_amount_kg,
give_action=feed_given,
purchase_action=feed_purchased,
),
@@ -173,6 +193,19 @@ async def feed_given(request: Request):
if balance is not None and balance < 0:
balance_warning = f"Warning: Inventory balance is now negative ({balance} kg)"
# Save user defaults (only if user exists in database)
if UserRepository(db).get(actor):
UserDefaultsRepository(db).upsert(
UserDefault(
username=actor,
action="feed_given",
location_id=location_id,
feed_type_code=feed_type_code,
amount_kg=amount_kg,
updated_at_utc=ts_utc,
)
)
# Success: re-render form with location/type sticking, amount reset
response = HTMLResponse(
content=str(