Files
sproutly/backend/routers/notifications.py
derekc 4db9988406 Add multi-user authentication with JWT
- Users table with email/bcrypt-hashed password; register and login via /auth/ endpoints
- JWT tokens (30-day expiry) stored in localStorage; all API routes require Bearer auth
- All data (varieties, batches, settings, notification logs) scoped to the authenticated user
- Login/register screen overlays the app; sidebar shows user email and logout button
- Scheduler sends daily ntfy summaries for every configured user
- DB schema rewritten for multi-user; SECRET_KEY added to env

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

161 lines
6.0 KiB
Python

import base64
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
import httpx
from auth import get_current_user
from database import get_db
from models import Batch, BatchStatus, NotificationLog, Settings, User, Variety
router = APIRouter(prefix="/notifications", tags=["notifications"])
ACTIVE_STATUSES = [
BatchStatus.planned, BatchStatus.germinating, BatchStatus.seedling,
BatchStatus.potted_up, BatchStatus.hardening, BatchStatus.garden,
]
def build_daily_summary(db: Session, user_id: int) -> str:
settings = db.query(Settings).filter(Settings.user_id == user_id).first()
today = date.today()
lines = [f"Sproutly Daily Summary — {today.strftime('%A, %B %d')}"]
lines.append("")
tasks = []
if settings and settings.last_frost_date:
last_frost = settings.last_frost_date.replace(year=today.year)
if last_frost < today - timedelta(days=60):
last_frost = last_frost.replace(year=today.year + 1)
days_to_frost = (last_frost - today).days
if 0 <= days_to_frost <= 14:
lines.append(f"Last frost in {days_to_frost} days ({last_frost.strftime('%B %d')})!")
lines.append("")
varieties = db.query(Variety).filter(Variety.user_id == user_id).all()
for v in varieties:
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
if v.weeks_to_start:
sd = last_frost - timedelta(weeks=v.weeks_to_start)
d = (sd - today).days
if -1 <= d <= 3:
tasks.append(f"Start seeds: {full_name} (due {sd.strftime('%b %d')})")
if v.weeks_to_greenhouse:
gd = last_frost - timedelta(weeks=v.weeks_to_greenhouse)
d = (gd - today).days
if -1 <= d <= 3:
tasks.append(f"Pot up: {full_name} (due {gd.strftime('%b %d')})")
if v.weeks_to_garden is not None:
if v.weeks_to_garden >= 0:
td = last_frost + timedelta(weeks=v.weeks_to_garden)
else:
td = last_frost - timedelta(weeks=abs(v.weeks_to_garden))
d = (td - today).days
if -1 <= d <= 3:
tasks.append(f"Transplant to garden: {full_name} (due {td.strftime('%b %d')})")
batches = (
db.query(Batch)
.filter(Batch.user_id == user_id, Batch.status.in_(ACTIVE_STATUSES))
.all()
)
for b in batches:
v = b.variety
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
label = b.label or full_name
if b.status == BatchStatus.hardening:
tasks.append(f"Harden off: {label}")
if b.status == BatchStatus.germinating and b.sow_date:
expected = b.sow_date + timedelta(days=v.days_to_germinate or 7)
d = (expected - today).days
if -2 <= d <= 1:
tasks.append(f"Check germination: {label}")
if tasks:
lines.append("Today's tasks:")
for t in tasks:
lines.append(f" - {t}")
else:
lines.append("No urgent tasks today. Keep it up!")
active_count = sum(1 for b in batches if b.status not in [BatchStatus.harvested, BatchStatus.failed])
lines.append("")
lines.append(f"Active batches: {active_count}")
return "\n".join(lines)
async def send_ntfy(settings: Settings, title: str, message: str, db: Session, priority: str = "default"):
if not settings or not settings.ntfy_topic:
return False, "ntfy topic not configured"
server = (settings.ntfy_server or "https://ntfy.sh").rstrip("/")
url = f"{server}/{settings.ntfy_topic}"
headers = {
"Title": title,
"Priority": priority,
"Tags": "seedling",
}
if settings.ntfy_api_key:
headers["Authorization"] = f"Bearer {settings.ntfy_api_key}"
elif settings.ntfy_username:
creds = base64.b64encode(
f"{settings.ntfy_username}:{settings.ntfy_password or ''}".encode()
).decode()
headers["Authorization"] = f"Basic {creds}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, content=message.encode("utf-8"), headers=headers)
resp.raise_for_status()
log = NotificationLog(message=message, status="sent", user_id=settings.user_id)
db.add(log)
db.commit()
return True, "sent"
except Exception as e:
log = NotificationLog(message=message, status="failed", error=str(e), user_id=settings.user_id)
db.add(log)
db.commit()
return False, str(e)
@router.post("/test")
async def send_test_notification(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(Settings).filter(Settings.user_id == current_user.id).first()
ok, detail = await send_ntfy(settings, "Sproutly Test", "Your Sproutly notifications are working!", db)
if not ok:
raise HTTPException(status_code=400, detail=detail)
return {"status": "sent"}
@router.post("/daily")
async def send_daily_summary(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(Settings).filter(Settings.user_id == current_user.id).first()
summary = build_daily_summary(db, current_user.id)
ok, detail = await send_ntfy(settings, "Sproutly Daily Summary", summary, db)
if not ok:
raise HTTPException(status_code=400, detail=detail)
return {"status": "sent", "message": summary}
@router.get("/log")
def get_notification_log(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
logs = (
db.query(NotificationLog)
.filter(NotificationLog.user_id == current_user.id)
.order_by(NotificationLog.sent_at.desc())
.limit(50)
.all()
)
return [{"id": l.id, "sent_at": l.sent_at, "status": l.status, "message": l.message, "error": l.error} for l in logs]