Add multi-user auth, admin panel, and timezone support; rename to Yolkbook
- Rename app from Eggtracker to Yolkbook throughout - Add JWT-based authentication (python-jose, passlib/bcrypt) - Add users table; all data tables gain user_id FK for full data isolation - Super admin credentials sourced from ADMIN_USERNAME/ADMIN_PASSWORD env vars, synced on every startup; orphaned rows auto-assigned to admin post-migration - Login page with self-registration; JWT stored in localStorage (30-day expiry) - Admin panel (/admin): list users, reset passwords, disable/enable, delete, and impersonate (Login As) with Return to Admin banner - Settings modal (gear icon in nav): timezone selector and change password - Timezone stored per-user; stats date windows computed in user's timezone; date input setToday() respects user timezone via Intl API - migrate_v2.sql for existing single-user installs - Auto-migration adds timezone column to users on startup - Updated README with full setup, auth, admin, and migration docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,30 @@
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase
|
||||
from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase, User
|
||||
from schemas import DashboardStats, BudgetStats, MonthlySummary
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||
|
||||
|
||||
def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
|
||||
"""
|
||||
For each collection in the last 30 days, look up the flock size that was
|
||||
in effect on that date using a correlated subquery, then average eggs/hen
|
||||
across those days. This gives an accurate result even when flock size changed.
|
||||
"""
|
||||
def _today(user_timezone: str) -> date:
|
||||
try:
|
||||
return datetime.now(ZoneInfo(user_timezone)).date()
|
||||
except ZoneInfoNotFoundError:
|
||||
return date.today()
|
||||
|
||||
|
||||
def _avg_per_hen_30d(db: Session, user_id: int, start_30d: date) -> float | None:
|
||||
flock_at_date = (
|
||||
select(FlockHistory.chicken_count)
|
||||
.where(FlockHistory.user_id == user_id)
|
||||
.where(FlockHistory.date <= EggCollection.date)
|
||||
.order_by(FlockHistory.date.desc())
|
||||
.limit(1)
|
||||
@@ -29,6 +34,7 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
|
||||
|
||||
rows = db.execute(
|
||||
select(EggCollection.eggs, flock_at_date.label('flock_count'))
|
||||
.where(EggCollection.user_id == user_id)
|
||||
.where(EggCollection.date >= start_30d)
|
||||
).all()
|
||||
|
||||
@@ -38,15 +44,18 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
|
||||
return round(sum(e / f for e, f in valid) / len(valid), 3)
|
||||
|
||||
|
||||
def _current_flock(db: Session) -> int | None:
|
||||
def _current_flock(db: Session, user_id: int) -> int | None:
|
||||
row = db.scalars(
|
||||
select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1)
|
||||
select(FlockHistory)
|
||||
.where(FlockHistory.user_id == user_id)
|
||||
.order_by(FlockHistory.date.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return row.chicken_count if row else None
|
||||
|
||||
|
||||
def _total_eggs(db: Session, start: date | None = None, end: date | None = None) -> int:
|
||||
q = select(func.coalesce(func.sum(EggCollection.eggs), 0))
|
||||
def _total_eggs(db: Session, user_id: int, start: date | None = None, end: date | None = None) -> int:
|
||||
q = select(func.coalesce(func.sum(EggCollection.eggs), 0)).where(EggCollection.user_id == user_id)
|
||||
if start:
|
||||
q = q.where(EggCollection.date >= start)
|
||||
if end:
|
||||
@@ -54,10 +63,10 @@ def _total_eggs(db: Session, start: date | None = None, end: date | None = None)
|
||||
return db.scalar(q)
|
||||
|
||||
|
||||
def _total_feed_cost(db: Session, start: date | None = None, end: date | None = None):
|
||||
def _total_feed_cost(db: Session, user_id: int, start: date | None = None, end: date | None = None):
|
||||
q = select(
|
||||
func.coalesce(func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag), 0)
|
||||
)
|
||||
).where(FeedPurchase.user_id == user_id)
|
||||
if start:
|
||||
q = q.where(FeedPurchase.date >= start)
|
||||
if end:
|
||||
@@ -65,8 +74,8 @@ def _total_feed_cost(db: Session, start: date | None = None, end: date | None =
|
||||
return db.scalar(q)
|
||||
|
||||
|
||||
def _total_other_cost(db: Session, start: date | None = None, end: date | None = None):
|
||||
q = select(func.coalesce(func.sum(OtherPurchase.total), 0))
|
||||
def _total_other_cost(db: Session, user_id: int, start: date | None = None, end: date | None = None):
|
||||
q = select(func.coalesce(func.sum(OtherPurchase.total), 0)).where(OtherPurchase.user_id == user_id)
|
||||
if start:
|
||||
q = q.where(OtherPurchase.date >= start)
|
||||
if end:
|
||||
@@ -75,29 +84,33 @@ def _total_other_cost(db: Session, start: date | None = None, end: date | None =
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardStats)
|
||||
def dashboard_stats(db: Session = Depends(get_db)):
|
||||
today = date.today()
|
||||
start_30d = today - timedelta(days=30)
|
||||
start_7d = today - timedelta(days=7)
|
||||
def dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uid = current_user.id
|
||||
today = _today(current_user.timezone)
|
||||
start_30d = today - timedelta(days=30)
|
||||
start_7d = today - timedelta(days=7)
|
||||
|
||||
total_alltime = _total_eggs(db)
|
||||
total_30d = _total_eggs(db, start=start_30d)
|
||||
total_7d = _total_eggs(db, start=start_7d)
|
||||
flock = _current_flock(db)
|
||||
total_alltime = _total_eggs(db, uid)
|
||||
total_30d = _total_eggs(db, uid, start=start_30d)
|
||||
total_7d = _total_eggs(db, uid, start=start_7d)
|
||||
flock = _current_flock(db, uid)
|
||||
|
||||
# Count how many distinct days have a collection logged
|
||||
days_tracked = db.scalar(
|
||||
select(func.count(func.distinct(EggCollection.date)))
|
||||
.where(EggCollection.user_id == uid)
|
||||
)
|
||||
|
||||
# Average eggs per day over the last 30 days (only counting days with data)
|
||||
days_with_data_30d = db.scalar(
|
||||
select(func.count(func.distinct(EggCollection.date)))
|
||||
.where(EggCollection.user_id == uid)
|
||||
.where(EggCollection.date >= start_30d)
|
||||
)
|
||||
|
||||
avg_per_day = round(total_30d / days_with_data_30d, 2) if days_with_data_30d else None
|
||||
avg_per_hen = _avg_per_hen_30d(db, start_30d)
|
||||
avg_per_hen = _avg_per_hen_30d(db, uid, start_30d)
|
||||
|
||||
return DashboardStats(
|
||||
current_flock=flock,
|
||||
@@ -111,10 +124,13 @@ def dashboard_stats(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/monthly", response_model=list[MonthlySummary])
|
||||
def monthly_stats(db: Session = Depends(get_db)):
|
||||
def monthly_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uid = current_user.id
|
||||
MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||
|
||||
# Monthly egg totals
|
||||
egg_rows = db.execute(
|
||||
select(
|
||||
func.year(EggCollection.date).label('year'),
|
||||
@@ -122,6 +138,7 @@ def monthly_stats(db: Session = Depends(get_db)):
|
||||
func.sum(EggCollection.eggs).label('total_eggs'),
|
||||
func.count(EggCollection.date).label('days_logged'),
|
||||
)
|
||||
.where(EggCollection.user_id == uid)
|
||||
.group_by(func.year(EggCollection.date), func.month(EggCollection.date))
|
||||
.order_by(func.year(EggCollection.date).desc(), func.month(EggCollection.date).desc())
|
||||
).all()
|
||||
@@ -129,25 +146,25 @@ def monthly_stats(db: Session = Depends(get_db)):
|
||||
if not egg_rows:
|
||||
return []
|
||||
|
||||
# Monthly feed costs
|
||||
feed_rows = db.execute(
|
||||
select(
|
||||
func.year(FeedPurchase.date).label('year'),
|
||||
func.month(FeedPurchase.date).label('month'),
|
||||
func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag).label('feed_cost'),
|
||||
)
|
||||
.where(FeedPurchase.user_id == uid)
|
||||
.group_by(func.year(FeedPurchase.date), func.month(FeedPurchase.date))
|
||||
).all()
|
||||
|
||||
feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows}
|
||||
|
||||
# Monthly other costs
|
||||
other_rows = db.execute(
|
||||
select(
|
||||
func.year(OtherPurchase.date).label('year'),
|
||||
func.month(OtherPurchase.date).label('month'),
|
||||
func.sum(OtherPurchase.total).label('other_cost'),
|
||||
)
|
||||
.where(OtherPurchase.user_id == uid)
|
||||
.group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date))
|
||||
).all()
|
||||
|
||||
@@ -159,9 +176,9 @@ def monthly_stats(db: Session = Depends(get_db)):
|
||||
last_day = calendar.monthrange(y, m)[1]
|
||||
month_end = date(y, m, last_day)
|
||||
|
||||
# Flock size in effect at the end of this month
|
||||
flock_row = db.scalars(
|
||||
select(FlockHistory)
|
||||
.where(FlockHistory.user_id == uid)
|
||||
.where(FlockHistory.date <= month_end)
|
||||
.order_by(FlockHistory.date.desc())
|
||||
.limit(1)
|
||||
@@ -201,16 +218,20 @@ def monthly_stats(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/budget", response_model=BudgetStats)
|
||||
def budget_stats(db: Session = Depends(get_db)):
|
||||
today = date.today()
|
||||
def budget_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uid = current_user.id
|
||||
today = _today(current_user.timezone)
|
||||
start_30d = today - timedelta(days=30)
|
||||
|
||||
total_feed_cost = _total_feed_cost(db)
|
||||
total_feed_cost_30d = _total_feed_cost(db, start=start_30d)
|
||||
total_other_cost = _total_other_cost(db)
|
||||
total_other_cost_30d = _total_other_cost(db, start=start_30d)
|
||||
total_eggs = _total_eggs(db)
|
||||
total_eggs_30d = _total_eggs(db, start=start_30d)
|
||||
total_feed_cost = _total_feed_cost(db, uid)
|
||||
total_feed_cost_30d = _total_feed_cost(db, uid, start=start_30d)
|
||||
total_other_cost = _total_other_cost(db, uid)
|
||||
total_other_cost_30d = _total_other_cost(db, uid, start=start_30d)
|
||||
total_eggs = _total_eggs(db, uid)
|
||||
total_eggs_30d = _total_eggs(db, uid, start=start_30d)
|
||||
|
||||
def cost_per_egg(cost, eggs):
|
||||
if not eggs or not cost:
|
||||
|
||||
Reference in New Issue
Block a user