- 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>
122 lines
3.8 KiB
Python
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"}
|