- 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>
125 lines
3.9 KiB
Python
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"}
|