- JWT stored in HttpOnly, Secure, SameSite=Strict cookie — JS cannot read the token at all; SameSite=Strict prevents CSRF without tokens - Non-sensitive user payload returned in response body and stored in localStorage for UI purposes only (not usable for auth) - Add POST /api/auth/logout endpoint that clears the cookie server-side - Add SECURE_COOKIES env var (default true) for local HTTP testing - Extract login.html inline script to login.js (CSP compliance) - Remove Authorization: Bearer header from API calls; add credentials: include so cookies are sent automatically - CSP script-src includes unsafe-inline to support existing onclick handlers throughout the app Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
6.0 KiB
Python
184 lines
6.0 KiB
Python
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
|
|
class UserPayload(BaseModel):
|
|
sub: str
|
|
username: str
|
|
is_admin: bool
|
|
timezone: str
|
|
exp: int
|
|
admin_id: Optional[int] = None
|
|
|
|
class AuthResponse(BaseModel):
|
|
user: UserPayload
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str = Field(min_length=10)
|
|
|
|
class ResetPasswordRequest(BaseModel):
|
|
new_password: str = Field(min_length=10)
|
|
|
|
class TimezoneUpdate(BaseModel):
|
|
timezone: str = Field(min_length=1, max_length=64)
|
|
|
|
|
|
# ── Users ─────────────────────────────────────────────────────────────────────
|
|
|
|
class UserCreate(BaseModel):
|
|
username: str = Field(min_length=2, max_length=64)
|
|
password: str = Field(min_length=10)
|
|
|
|
class UserOut(BaseModel):
|
|
id: int
|
|
username: str
|
|
is_admin: bool
|
|
is_disabled: bool
|
|
timezone: str
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Egg Collections ───────────────────────────────────────────────────────────
|
|
|
|
class EggCollectionCreate(BaseModel):
|
|
date: date
|
|
eggs: int = Field(ge=0)
|
|
notes: Optional[str] = None
|
|
|
|
class EggCollectionUpdate(BaseModel):
|
|
date: Optional[date] = None
|
|
eggs: Optional[int] = Field(default=None, ge=0)
|
|
notes: Optional[str] = None
|
|
|
|
class EggCollectionOut(BaseModel):
|
|
id: int
|
|
date: date
|
|
eggs: int
|
|
notes: Optional[str]
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Flock History ─────────────────────────────────────────────────────────────
|
|
|
|
class FlockHistoryCreate(BaseModel):
|
|
date: date
|
|
chicken_count: int = Field(ge=0)
|
|
notes: Optional[str] = None
|
|
|
|
class FlockHistoryUpdate(BaseModel):
|
|
date: Optional[date] = None
|
|
chicken_count: Optional[int] = Field(default=None, ge=0)
|
|
notes: Optional[str] = None
|
|
|
|
class FlockHistoryOut(BaseModel):
|
|
id: int
|
|
date: date
|
|
chicken_count: int
|
|
notes: Optional[str]
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Feed Purchases ────────────────────────────────────────────────────────────
|
|
|
|
class FeedPurchaseCreate(BaseModel):
|
|
date: date
|
|
bags: Decimal = Field(gt=0, decimal_places=2)
|
|
price_per_bag: Decimal = Field(gt=0, decimal_places=2)
|
|
notes: Optional[str] = None
|
|
|
|
class FeedPurchaseUpdate(BaseModel):
|
|
date: Optional[date] = None
|
|
bags: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
|
|
price_per_bag: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
|
|
notes: Optional[str] = None
|
|
|
|
class FeedPurchaseOut(BaseModel):
|
|
id: int
|
|
date: date
|
|
bags: Decimal
|
|
price_per_bag: Decimal
|
|
notes: Optional[str]
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Other Purchases ───────────────────────────────────────────────────────────
|
|
|
|
class OtherPurchaseCreate(BaseModel):
|
|
date: date
|
|
total: Decimal = Field(gt=0, decimal_places=2)
|
|
notes: Optional[str] = None
|
|
|
|
class OtherPurchaseUpdate(BaseModel):
|
|
date: Optional[date] = None
|
|
total: Optional[Decimal] = Field(default=None, gt=0, decimal_places=2)
|
|
notes: Optional[str] = None
|
|
|
|
class OtherPurchaseOut(BaseModel):
|
|
id: int
|
|
date: date
|
|
total: Decimal
|
|
notes: Optional[str]
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
|
|
|
class MonthlySummary(BaseModel):
|
|
year: int
|
|
month: int
|
|
month_label: str
|
|
total_eggs: int
|
|
days_logged: int
|
|
avg_eggs_per_day: Optional[float]
|
|
flock_at_month_end: Optional[int]
|
|
avg_eggs_per_hen_per_day: Optional[float]
|
|
feed_cost: Optional[Decimal]
|
|
other_cost: Optional[Decimal]
|
|
cost_per_egg: Optional[Decimal]
|
|
cost_per_dozen: Optional[Decimal]
|
|
|
|
|
|
class DashboardStats(BaseModel):
|
|
current_flock: Optional[int]
|
|
total_eggs_alltime: int
|
|
total_eggs_30d: int
|
|
total_eggs_7d: int
|
|
avg_eggs_per_day_30d: Optional[float]
|
|
avg_eggs_per_hen_day_30d: Optional[float]
|
|
days_tracked: int
|
|
|
|
class BudgetStats(BaseModel):
|
|
total_feed_cost: Optional[Decimal]
|
|
total_feed_cost_30d: Optional[Decimal]
|
|
total_other_cost: Optional[Decimal]
|
|
total_other_cost_30d: Optional[Decimal]
|
|
total_eggs_alltime: int
|
|
total_eggs_30d: int
|
|
cost_per_egg: Optional[Decimal]
|
|
cost_per_dozen: Optional[Decimal]
|
|
cost_per_egg_30d: Optional[Decimal]
|
|
cost_per_dozen_30d: Optional[Decimal]
|