Initial commit: Sproutly plant tracking app
This commit is contained in:
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
58
backend/routers/batches.py
Normal file
58
backend/routers/batches.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
from database import get_db
|
||||
from models import Batch, Variety
|
||||
from schemas import BatchCreate, BatchUpdate, BatchOut
|
||||
|
||||
router = APIRouter(prefix="/batches", tags=["batches"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[BatchOut])
|
||||
def list_batches(db: Session = Depends(get_db)):
|
||||
return (
|
||||
db.query(Batch)
|
||||
.options(joinedload(Batch.variety))
|
||||
.order_by(Batch.sow_date.desc().nullslast(), Batch.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{batch_id}", response_model=BatchOut)
|
||||
def get_batch(batch_id: int, db: Session = Depends(get_db)):
|
||||
b = db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == batch_id).first()
|
||||
if not b:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
return b
|
||||
|
||||
|
||||
@router.post("/", response_model=BatchOut, status_code=201)
|
||||
def create_batch(data: BatchCreate, db: Session = Depends(get_db)):
|
||||
if not db.query(Variety).filter(Variety.id == data.variety_id).first():
|
||||
raise HTTPException(status_code=404, detail="Variety not found")
|
||||
b = Batch(**data.model_dump())
|
||||
db.add(b)
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
return db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == b.id).first()
|
||||
|
||||
|
||||
@router.put("/{batch_id}", response_model=BatchOut)
|
||||
def update_batch(batch_id: int, data: BatchUpdate, db: Session = Depends(get_db)):
|
||||
b = db.query(Batch).filter(Batch.id == batch_id).first()
|
||||
if not b:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
for field, value in data.model_dump().items():
|
||||
setattr(b, field, value)
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
return db.query(Batch).options(joinedload(Batch.variety)).filter(Batch.id == b.id).first()
|
||||
|
||||
|
||||
@router.delete("/{batch_id}", status_code=204)
|
||||
def delete_batch(batch_id: int, db: Session = Depends(get_db)):
|
||||
b = db.query(Batch).filter(Batch.id == batch_id).first()
|
||||
if not b:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
db.delete(b)
|
||||
db.commit()
|
||||
210
backend/routers/dashboard.py
Normal file
210
backend/routers/dashboard.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from database import get_db
|
||||
from models import Variety, Batch, Settings, BatchStatus
|
||||
from schemas import DashboardOut, Task, TimelineEntry, BatchOut
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
ACTIVE_STATUSES = [
|
||||
BatchStatus.planned, BatchStatus.germinating, BatchStatus.seedling,
|
||||
BatchStatus.potted_up, BatchStatus.hardening, BatchStatus.garden,
|
||||
]
|
||||
|
||||
|
||||
def urgency(days_away: int) -> str:
|
||||
if days_away < 0:
|
||||
return "overdue"
|
||||
if days_away == 0:
|
||||
return "today"
|
||||
if days_away <= 7:
|
||||
return "week"
|
||||
return "month"
|
||||
|
||||
|
||||
def make_task(task_type: str, title: str, detail: str, due: date, color: str,
|
||||
batch_id=None, variety_id=None) -> Task:
|
||||
days_away = (due - date.today()).days
|
||||
return Task(
|
||||
type=task_type,
|
||||
title=title,
|
||||
detail=detail,
|
||||
due_date=due,
|
||||
days_away=days_away,
|
||||
urgency=urgency(days_away),
|
||||
variety_color=color,
|
||||
batch_id=batch_id,
|
||||
variety_id=variety_id,
|
||||
)
|
||||
|
||||
|
||||
def day_of_year(d: date) -> int:
|
||||
return d.timetuple().tm_yday
|
||||
|
||||
|
||||
@router.get("/", response_model=DashboardOut)
|
||||
def get_dashboard(db: Session = Depends(get_db)):
|
||||
settings = db.query(Settings).filter(Settings.id == 1).first()
|
||||
today = date.today()
|
||||
last_frost = None
|
||||
|
||||
if settings and settings.last_frost_date:
|
||||
last_frost = settings.last_frost_date.replace(year=today.year)
|
||||
# If last frost has passed this year, use next year's date for planning
|
||||
# but keep this year for historical display
|
||||
planning_frost = last_frost
|
||||
if last_frost < today - timedelta(days=60):
|
||||
planning_frost = last_frost.replace(year=today.year + 1)
|
||||
else:
|
||||
planning_frost = None
|
||||
|
||||
all_tasks: List[Task] = []
|
||||
timeline: List[TimelineEntry] = []
|
||||
|
||||
varieties = db.query(Variety).all()
|
||||
|
||||
for v in varieties:
|
||||
full_name = f"{v.name} ({v.variety_name})" if v.variety_name else v.name
|
||||
|
||||
# --- Timeline entry ---
|
||||
entry = TimelineEntry(
|
||||
variety_id=v.id,
|
||||
name=v.name,
|
||||
full_name=full_name,
|
||||
color=v.color or "#52b788",
|
||||
)
|
||||
|
||||
if planning_frost:
|
||||
if v.weeks_to_start:
|
||||
sd = planning_frost - timedelta(weeks=v.weeks_to_start)
|
||||
entry.start_day = day_of_year(sd)
|
||||
if v.weeks_to_greenhouse:
|
||||
gd = planning_frost - timedelta(weeks=v.weeks_to_greenhouse)
|
||||
entry.greenhouse_day = day_of_year(gd)
|
||||
if v.weeks_to_garden is not None:
|
||||
if v.weeks_to_garden >= 0:
|
||||
td = planning_frost + timedelta(weeks=v.weeks_to_garden)
|
||||
else:
|
||||
td = planning_frost - timedelta(weeks=abs(v.weeks_to_garden))
|
||||
entry.garden_day = day_of_year(td)
|
||||
entry.end_day = day_of_year(td) + 80 # rough season length
|
||||
|
||||
# --- Tasks from variety schedule (only within 30 days) ---
|
||||
if v.weeks_to_start:
|
||||
start_date = planning_frost - timedelta(weeks=v.weeks_to_start)
|
||||
days = (start_date - today).days
|
||||
if -7 <= days <= 30:
|
||||
all_tasks.append(make_task(
|
||||
"start_seeds",
|
||||
f"Start {full_name} seeds",
|
||||
f"Sow indoors — {v.days_to_germinate} days to germinate",
|
||||
start_date, v.color or "#52b788",
|
||||
variety_id=v.id,
|
||||
))
|
||||
|
||||
if v.weeks_to_greenhouse:
|
||||
gh_date = planning_frost - timedelta(weeks=v.weeks_to_greenhouse)
|
||||
days = (gh_date - today).days
|
||||
if -7 <= days <= 30:
|
||||
all_tasks.append(make_task(
|
||||
"pot_up",
|
||||
f"Pot up {full_name}",
|
||||
"Move seedlings to larger containers / greenhouse",
|
||||
gh_date, v.color or "#52b788",
|
||||
variety_id=v.id,
|
||||
))
|
||||
|
||||
if v.weeks_to_garden is not None:
|
||||
if v.weeks_to_garden >= 0:
|
||||
garden_date = planning_frost + timedelta(weeks=v.weeks_to_garden)
|
||||
else:
|
||||
garden_date = planning_frost - timedelta(weeks=abs(v.weeks_to_garden))
|
||||
days = (garden_date - today).days
|
||||
if -7 <= days <= 30:
|
||||
all_tasks.append(make_task(
|
||||
"transplant",
|
||||
f"Transplant {full_name} to garden",
|
||||
"Harden off first if starting from indoors",
|
||||
garden_date, v.color or "#52b788",
|
||||
variety_id=v.id,
|
||||
))
|
||||
|
||||
timeline.append(entry)
|
||||
|
||||
# --- Tasks from active batches ---
|
||||
batches = (
|
||||
db.query(Batch)
|
||||
.options(joinedload(Batch.variety))
|
||||
.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
|
||||
color = v.color or "#52b788"
|
||||
label = b.label or full_name
|
||||
|
||||
if b.status == BatchStatus.planned and b.sow_date:
|
||||
days = (b.sow_date - today).days
|
||||
if days <= 0:
|
||||
all_tasks.append(make_task(
|
||||
"check_batch", f"Start batch: {label}",
|
||||
"Sow date has arrived — time to plant seeds!",
|
||||
b.sow_date, color, batch_id=b.id,
|
||||
))
|
||||
|
||||
if b.status == BatchStatus.germinating and b.sow_date:
|
||||
expected = b.sow_date + timedelta(days=v.days_to_germinate or 7)
|
||||
days = (expected - today).days
|
||||
if -14 <= days <= 7:
|
||||
all_tasks.append(make_task(
|
||||
"check_batch", f"Check germination: {label}",
|
||||
"Germination window reached — check for sprouts",
|
||||
expected, color, batch_id=b.id,
|
||||
))
|
||||
|
||||
if b.status == BatchStatus.hardening:
|
||||
all_tasks.append(make_task(
|
||||
"check_batch", f"Continue hardening off: {label}",
|
||||
"Gradually increase outdoor exposure each day",
|
||||
today, color, batch_id=b.id,
|
||||
))
|
||||
|
||||
# Deduplicate and filter to -7 to +30 day window
|
||||
seen = set()
|
||||
filtered = []
|
||||
for t in all_tasks:
|
||||
key = (t.type, t.title, str(t.due_date))
|
||||
if key not in seen and -7 <= t.days_away <= 30:
|
||||
seen.add(key)
|
||||
filtered.append(t)
|
||||
|
||||
filtered.sort(key=lambda t: t.days_away)
|
||||
|
||||
# Active batches for display
|
||||
active_batch_objs = [b for b in batches if b.status != BatchStatus.garden]
|
||||
|
||||
# Stats
|
||||
all_batches = db.query(Batch).all()
|
||||
stats = {
|
||||
"total_varieties": len(varieties),
|
||||
"total_batches": len(all_batches),
|
||||
"active_batches": sum(1 for b in all_batches if b.status in ACTIVE_STATUSES),
|
||||
"in_garden": sum(1 for b in all_batches if b.status == BatchStatus.garden),
|
||||
"tasks_count": len(filtered),
|
||||
}
|
||||
|
||||
return DashboardOut(
|
||||
tasks_overdue=[t for t in filtered if t.urgency == "overdue"],
|
||||
tasks_today=[t for t in filtered if t.urgency == "today"],
|
||||
tasks_week=[t for t in filtered if t.urgency == "week"],
|
||||
tasks_month=[t for t in filtered if t.urgency == "month"],
|
||||
active_batches=active_batch_objs,
|
||||
timeline=timeline,
|
||||
stats=stats,
|
||||
last_frost_date=last_frost,
|
||||
location_name=settings.location_name if settings else None,
|
||||
)
|
||||
152
backend/routers/notifications.py
Normal file
152
backend/routers/notifications.py
Normal file
@@ -0,0 +1,152 @@
|
||||
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}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
content=message.encode("utf-8"),
|
||||
headers={
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Tags": "seedling",
|
||||
},
|
||||
)
|
||||
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]
|
||||
31
backend/routers/settings.py
Normal file
31
backend/routers/settings.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models import Settings
|
||||
from schemas import SettingsUpdate, SettingsOut
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/", response_model=SettingsOut)
|
||||
def get_settings(db: Session = Depends(get_db)):
|
||||
s = db.query(Settings).filter(Settings.id == 1).first()
|
||||
if not s:
|
||||
s = Settings(id=1)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
@router.put("/", response_model=SettingsOut)
|
||||
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db)):
|
||||
s = db.query(Settings).filter(Settings.id == 1).first()
|
||||
if not s:
|
||||
s = Settings(id=1)
|
||||
db.add(s)
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(s, field, value)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
51
backend/routers/varieties.py
Normal file
51
backend/routers/varieties.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from database import get_db
|
||||
from models import Variety
|
||||
from schemas import VarietyCreate, VarietyUpdate, VarietyOut
|
||||
|
||||
router = APIRouter(prefix="/varieties", tags=["varieties"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[VarietyOut])
|
||||
def list_varieties(db: Session = Depends(get_db)):
|
||||
return db.query(Variety).order_by(Variety.category, Variety.name).all()
|
||||
|
||||
|
||||
@router.get("/{variety_id}", response_model=VarietyOut)
|
||||
def get_variety(variety_id: int, db: Session = Depends(get_db)):
|
||||
v = db.query(Variety).filter(Variety.id == variety_id).first()
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="Variety not found")
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/", response_model=VarietyOut, status_code=201)
|
||||
def create_variety(data: VarietyCreate, db: Session = Depends(get_db)):
|
||||
v = Variety(**data.model_dump())
|
||||
db.add(v)
|
||||
db.commit()
|
||||
db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.put("/{variety_id}", response_model=VarietyOut)
|
||||
def update_variety(variety_id: int, data: VarietyUpdate, db: Session = Depends(get_db)):
|
||||
v = db.query(Variety).filter(Variety.id == variety_id).first()
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="Variety not found")
|
||||
for field, value in data.model_dump().items():
|
||||
setattr(v, field, value)
|
||||
db.commit()
|
||||
db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.delete("/{variety_id}", status_code=204)
|
||||
def delete_variety(variety_id: int, db: Session = Depends(get_db)):
|
||||
v = db.query(Variety).filter(Variety.id == variety_id).first()
|
||||
if not v:
|
||||
raise HTTPException(status_code=404, detail="Variety not found")
|
||||
db.delete(v)
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user