Initial commit: Sproutly plant tracking app

This commit is contained in:
2026-03-08 23:27:16 -07:00
commit 4f66102bbb
20 changed files with 2643 additions and 0 deletions

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

23
backend/database.py Normal file
View File

@@ -0,0 +1,23 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME", "sproutly")
DB_USER = os.getenv("DB_USER", "sproutly")
DB_PASSWORD = os.getenv("DB_PASSWORD", "sproutly_secret")
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

86
backend/main.py Normal file
View File

@@ -0,0 +1,86 @@
import asyncio
import logging
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
from routers import varieties, batches, dashboard, settings, notifications
from routers.notifications import build_daily_summary, send_ntfy
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("sproutly")
scheduler = AsyncIOScheduler()
async def scheduled_daily_notification():
db = SessionLocal()
try:
s = db.query(Settings).filter(Settings.id == 1).first()
if not s or not s.ntfy_topic:
logger.info("Daily notification skipped: ntfy not configured")
return
summary = build_daily_summary(db)
ok, detail = await send_ntfy(s, "Sproutly Daily Summary", summary, db)
logger.info(f"Daily notification: {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]:
try:
s = db.query(Settings).filter(Settings.id == 1).first()
if s and s.notification_time:
h, m = s.notification_time.split(":")
return int(h), int(m)
except Exception:
pass
return 7, 0
@asynccontextmanager
async def lifespan(app: FastAPI):
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="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
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"}

98
backend/models.py Normal file
View File

@@ -0,0 +1,98 @@
import enum
from sqlalchemy import Column, Integer, String, Date, Boolean, Text, DateTime, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class Category(str, enum.Enum):
vegetable = "vegetable"
herb = "herb"
flower = "flower"
fruit = "fruit"
class SunRequirement(str, enum.Enum):
full_sun = "full_sun"
part_shade = "part_shade"
full_shade = "full_shade"
class WaterNeeds(str, enum.Enum):
low = "low"
medium = "medium"
high = "high"
class BatchStatus(str, enum.Enum):
planned = "planned"
germinating = "germinating"
seedling = "seedling"
potted_up = "potted_up"
hardening = "hardening"
garden = "garden"
harvested = "harvested"
failed = "failed"
class Variety(Base):
__tablename__ = "varieties"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
variety_name = Column(String(100))
category = Column(Enum(Category), default=Category.vegetable)
weeks_to_start = Column(Integer)
weeks_to_greenhouse = Column(Integer)
weeks_to_garden = Column(Integer)
days_to_germinate = Column(Integer, default=7)
direct_sow_ok = Column(Boolean, default=False)
frost_tolerant = Column(Boolean, default=False)
sun_requirement = Column(Enum(SunRequirement), default=SunRequirement.full_sun)
water_needs = Column(Enum(WaterNeeds), default=WaterNeeds.medium)
color = Column(String(7), default="#52b788")
notes = Column(Text)
created_at = Column(DateTime, server_default=func.now())
batches = relationship("Batch", back_populates="variety", cascade="all, delete-orphan")
class Batch(Base):
__tablename__ = "batches"
id = Column(Integer, primary_key=True, index=True)
variety_id = Column(Integer, ForeignKey("varieties.id"), nullable=False)
label = Column(String(100))
quantity = Column(Integer, default=1)
sow_date = Column(Date)
germination_date = Column(Date)
greenhouse_date = Column(Date)
garden_date = Column(Date)
status = Column(Enum(BatchStatus), default=BatchStatus.planned)
notes = Column(Text)
created_at = Column(DateTime, server_default=func.now())
variety = relationship("Variety", back_populates="batches")
class Settings(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, default=1)
last_frost_date = Column(Date)
first_frost_fall_date = Column(Date)
ntfy_topic = Column(String(200))
ntfy_server = Column(String(200), default="https://ntfy.sh")
notification_time = Column(String(5), default="07:00")
timezone = Column(String(50), default="UTC")
location_name = Column(String(100))
class NotificationLog(Base):
__tablename__ = "notification_log"
id = Column(Integer, primary_key=True, index=True)
sent_at = Column(DateTime, server_default=func.now())
message = Column(Text)
status = Column(String(20))
error = Column(Text)

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
pymysql==1.1.1
cryptography==42.0.7
python-dotenv==1.0.1
httpx==0.27.0
apscheduler==3.10.4
pydantic==2.7.1

View File

View 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()

View 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,
)

View 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]

View 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

View 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()

121
backend/schemas.py Normal file
View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional, List
from pydantic import BaseModel
from models import Category, SunRequirement, WaterNeeds, BatchStatus
# --- Variety ---
class VarietyBase(BaseModel):
name: str
variety_name: Optional[str] = None
category: Category = Category.vegetable
weeks_to_start: Optional[int] = None
weeks_to_greenhouse: Optional[int] = None
weeks_to_garden: Optional[int] = None
days_to_germinate: int = 7
direct_sow_ok: bool = False
frost_tolerant: bool = False
sun_requirement: SunRequirement = SunRequirement.full_sun
water_needs: WaterNeeds = WaterNeeds.medium
color: str = "#52b788"
notes: Optional[str] = None
class VarietyCreate(VarietyBase):
pass
class VarietyUpdate(VarietyBase):
pass
class VarietyOut(VarietyBase):
id: int
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# --- Batch ---
class BatchBase(BaseModel):
variety_id: int
label: Optional[str] = None
quantity: int = 1
sow_date: Optional[date] = None
germination_date: Optional[date] = None
greenhouse_date: Optional[date] = None
garden_date: Optional[date] = None
status: BatchStatus = BatchStatus.planned
notes: Optional[str] = None
class BatchCreate(BatchBase):
pass
class BatchUpdate(BatchBase):
pass
class BatchOut(BatchBase):
id: int
created_at: Optional[datetime] = None
variety: Optional[VarietyOut] = None
model_config = {"from_attributes": True}
# --- Settings ---
class SettingsUpdate(BaseModel):
last_frost_date: Optional[date] = None
first_frost_fall_date: Optional[date] = None
ntfy_topic: Optional[str] = None
ntfy_server: Optional[str] = "https://ntfy.sh"
notification_time: Optional[str] = "07:00"
timezone: Optional[str] = "UTC"
location_name: Optional[str] = None
class SettingsOut(SettingsUpdate):
model_config = {"from_attributes": True}
# --- Dashboard ---
class Task(BaseModel):
type: str # "start_seeds", "pot_up", "transplant", "check_batch"
title: str
detail: str
due_date: date
days_away: int # negative = overdue
urgency: str # "overdue", "today", "week", "month"
variety_color: str
batch_id: Optional[int] = None
variety_id: Optional[int] = None
class TimelineEntry(BaseModel):
variety_id: int
name: str
full_name: str
color: str
start_day: Optional[int] = None # day of year
greenhouse_day: Optional[int] = None
garden_day: Optional[int] = None
end_day: Optional[int] = None # approx harvest / end of season
class DashboardOut(BaseModel):
tasks_overdue: List[Task]
tasks_today: List[Task]
tasks_week: List[Task]
tasks_month: List[Task]
active_batches: List[BatchOut]
timeline: List[TimelineEntry]
stats: dict
last_frost_date: Optional[date]
location_name: Optional[str]