Files
sproutly/backend/main.py
derekc bd2bd43395 Add super admin panel and update README
- Admin account bootstrapped from ADMIN_EMAIL/ADMIN_PASSWORD env vars on startup
- Admin panel: list users, view content, reset passwords, disable/delete accounts
- is_admin and is_disabled columns on users table
- Disabled accounts blocked at login
- README updated with admin setup instructions and panel docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 00:24:27 -07:00

122 lines
3.8 KiB
Python

import asyncio
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from database import SessionLocal
from models import Settings, NotificationLog, User
from routers import varieties, batches, dashboard, settings, notifications
from routers import auth as auth_router, admin as admin_router
from routers.notifications import build_daily_summary, send_ntfy
from auth import hash_password
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("sproutly")
scheduler = AsyncIOScheduler()
async def scheduled_daily_notification():
db = SessionLocal()
try:
all_settings = db.query(Settings).filter(Settings.ntfy_topic.isnot(None)).all()
for s in all_settings:
if not s.ntfy_topic:
continue
summary = build_daily_summary(db, s.user_id)
ok, detail = await send_ntfy(s, "Sproutly Daily Summary", summary, db)
logger.info(f"Daily notification for user {s.user_id}: {detail}")
except Exception as e:
logger.error(f"Daily notification error: {e}")
log = NotificationLog(message="scheduler error", status="failed", error=str(e))
db.add(log)
db.commit()
finally:
db.close()
def get_notification_schedule(db) -> tuple[int, int]:
"""Use the earliest configured notification time across all users."""
try:
s = db.query(Settings).filter(Settings.ntfy_topic.isnot(None)).first()
if s and s.notification_time:
h, m = s.notification_time.split(":")
return int(h), int(m)
except Exception:
pass
return 7, 0
def bootstrap_admin(retries: int = 5, delay: float = 2.0):
import time
admin_email = os.environ.get("ADMIN_EMAIL")
admin_password = os.environ.get("ADMIN_PASSWORD")
if not admin_email or not admin_password:
return
for attempt in range(retries):
db = SessionLocal()
try:
admin = db.query(User).filter(User.email == admin_email).first()
if admin:
admin.hashed_password = hash_password(admin_password)
admin.is_admin = True
admin.is_disabled = False
else:
admin = User(email=admin_email, hashed_password=hash_password(admin_password), is_admin=True)
db.add(admin)
db.commit()
logger.info(f"Admin user ready: {admin_email}")
return
except Exception as e:
logger.warning(f"Admin bootstrap attempt {attempt + 1} failed: {e}")
time.sleep(delay)
finally:
db.close()
logger.error("Admin bootstrap failed after all retries")
@asynccontextmanager
async def lifespan(app: FastAPI):
bootstrap_admin()
db = SessionLocal()
hour, minute = get_notification_schedule(db)
db.close()
scheduler.add_job(
scheduled_daily_notification,
CronTrigger(hour=hour, minute=minute),
id="daily_summary",
replace_existing=True,
)
scheduler.start()
logger.info(f"Scheduler started — daily notification at {hour:02d}:{minute:02d}")
yield
scheduler.shutdown()
app = FastAPI(title="Sproutly API", version="2.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(admin_router.router)
app.include_router(varieties.router)
app.include_router(batches.router)
app.include_router(dashboard.router)
app.include_router(settings.router)
app.include_router(notifications.router)
@app.get("/health")
def health():
return {"status": "ok", "service": "sproutly"}