Files
yolkbook/backend/main.py
derekc 60fed6d464 Implement performance improvements across backend and frontend
- models.py: add composite (user_id, date) indexes to flock_history,
  feed_purchases, and other_purchases for faster date-filtered queries
  (egg_collections already had one via its unique constraint)
- main.py: add v2.2 migration to create the three composite indexes on
  existing installs at startup
- stats.py: fix N+1 query in monthly_stats — flock history is now fetched
  once and looked up per month using bisect_right instead of one DB query
  per month row; also remove unnecessary Decimal(str(...)) round-trips
  since SQLAlchemy already returns Numeric columns as Decimal
- eggs.py: add limit parameter (default 500, max 1000) to list_eggs to
  cap unbounded fetches on large datasets
- dashboard.js: pass start= (30 days ago) when fetching eggs so the
  dashboard only loads the data it actually needs for the chart and
  recent collections list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:02:58 -07:00

125 lines
3.9 KiB
Python

import os
import logging
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select, update, text
from database import Base, engine, SessionLocal
from models import User, EggCollection, FlockHistory, FeedPurchase, OtherPurchase
from auth import hash_password
from routers import eggs, flock, feed, stats, other
from routers import auth_router, admin
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger("yolkbook")
_REQUIRED_ENV = ["ADMIN_USERNAME", "ADMIN_PASSWORD", "JWT_SECRET", "DATABASE_URL"]
def _validate_env():
missing = [k for k in _REQUIRED_ENV if not os.environ.get(k)]
if missing:
logger.critical("Missing required environment variables: %s", ", ".join(missing))
sys.exit(1)
def _seed_admin():
"""Create or update the admin user from environment variables.
Also assigns any records with NULL user_id to the admin (post-migration).
"""
admin_username = os.environ["ADMIN_USERNAME"]
admin_password = os.environ["ADMIN_PASSWORD"]
with SessionLocal() as db:
admin_user = db.scalars(
select(User).where(User.username == admin_username)
).first()
if admin_user is None:
admin_user = User(
username=admin_username,
hashed_password=hash_password(admin_password),
is_admin=True,
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
logger.info("Admin user '%s' created.", admin_username)
else:
# Always sync password + admin flag from env vars
admin_user.hashed_password = hash_password(admin_password)
admin_user.is_admin = True
db.commit()
# Assign orphaned records (from pre-migration data) to admin
for model in [EggCollection, FlockHistory, FeedPurchase, OtherPurchase]:
db.execute(
update(model)
.where(model.user_id == None) # noqa: E711
.values(user_id=admin_user.id)
)
db.commit()
def _run_migrations():
"""Apply incremental schema changes that create_all won't handle on existing tables."""
with SessionLocal() as db:
# v2.1 — timezone column on users
try:
db.execute(text(
"ALTER TABLE users ADD COLUMN timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'"
))
db.commit()
except Exception:
db.rollback() # column already exists — safe to ignore
# v2.2 — composite (user_id, date) indexes for faster filtered queries
for sql in [
"CREATE INDEX ix_flock_history_user_date ON flock_history (user_id, date)",
"CREATE INDEX ix_feed_purchases_user_date ON feed_purchases (user_id, date)",
"CREATE INDEX ix_other_purchases_user_date ON other_purchases (user_id, date)",
]:
try:
db.execute(text(sql))
db.commit()
except Exception:
db.rollback() # index already exists — safe to ignore
@asynccontextmanager
async def lifespan(app: FastAPI):
_validate_env()
Base.metadata.create_all(bind=engine)
_run_migrations()
_seed_admin()
yield
app = FastAPI(title="Yolkbook API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(admin.router)
app.include_router(eggs.router)
app.include_router(flock.router)
app.include_router(feed.router)
app.include_router(other.router)
app.include_router(stats.router)
@app.get("/api/health")
def health():
return {"status": "ok"}