- Settings table gets ntfy_username, ntfy_password, ntfy_api_key columns - Backend applies Basic or Bearer auth header when sending notifications - Settings page UI lets you toggle between no auth, basic, or token auth - Masked credential display on load to avoid exposing stored secrets - README updated with auth modes documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.7 KiB
Python
163 lines
5.7 KiB
Python
import base64
|
|
from datetime import date, timedelta
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
import httpx
|
|
from database import get_db
|
|
from models import Settings, Variety, Batch, NotificationLog, BatchStatus
|
|
|
|
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) -> str:
|
|
settings = db.query(Settings).filter(Settings.id == 1).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).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.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")
|
|
db.add(log)
|
|
db.commit()
|
|
return True, "sent"
|
|
|
|
except Exception as e:
|
|
log = NotificationLog(message=message, status="failed", error=str(e))
|
|
db.add(log)
|
|
db.commit()
|
|
return False, str(e)
|
|
|
|
|
|
@router.post("/test")
|
|
async def send_test_notification(db: Session = Depends(get_db)):
|
|
settings = db.query(Settings).filter(Settings.id == 1).first()
|
|
ok, detail = await send_ntfy(
|
|
settings,
|
|
"Sproutly Test",
|
|
"Your Sproutly notifications are working!",
|
|
db,
|
|
priority="default",
|
|
)
|
|
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)):
|
|
settings = db.query(Settings).filter(Settings.id == 1).first()
|
|
summary = build_daily_summary(db)
|
|
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)):
|
|
logs = db.query(NotificationLog).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]
|