Initial commit: Sproutly plant tracking app
This commit is contained in:
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal 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
23
backend/database.py
Normal 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
86
backend/main.py
Normal 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
98
backend/models.py
Normal 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
9
backend/requirements.txt
Normal 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
|
||||
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()
|
||||
121
backend/schemas.py
Normal file
121
backend/schemas.py
Normal 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]
|
||||
Reference in New Issue
Block a user