Plan complet : Docker, FastAPI + SQLModel + tests TDD, Vue 3 + Tailwind Gruvbox. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
72 KiB
Webapp Jardin — Plan d'implémentation
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Scaffold complet (Docker + FastAPI backend + Vue 3 frontend) avec CRUD fonctionnel pour jardins, variétés, plantations et tâches, données de démo incluses.
Architecture: Monorepo backend/ (FastAPI + SQLModel + SQLite) + frontend/ (Vue 3 + Vite + TypeScript + Tailwind + Pinia). Docker Compose orchestre les deux services + volume persistant data/.
Tech Stack: Python 3.12, FastAPI 0.115, SQLModel, SQLite, Vue 3, Vite, TypeScript, Tailwind CSS 3, Pinia, Vue Router 4, Axios, Docker Compose, Nginx
Task 1: Structure du projet + Docker Compose
Files:
- Create:
docker-compose.yml - Create:
backend/Dockerfile - Create:
frontend/Dockerfile - Create:
frontend/nginx.conf - Create:
.env.example - Create:
data/.gitkeep - Create:
.gitignore
Step 1: Créer les répertoires
mkdir -p backend/app/{models,routers} backend/tests data \
frontend/src/{api,components,router,stores,views}
touch data/.gitkeep backend/app/__init__.py backend/app/models/__init__.py backend/app/routers/__init__.py backend/tests/__init__.py
Step 2: Créer .env.example
BACKEND_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://localhost
DATABASE_URL=sqlite:////data/jardin.db
UPLOAD_DIR=/data/uploads
VITE_API_URL=http://localhost:8000
Step 3: Créer .gitignore
data/*.db
data/uploads/
backend/__pycache__/
backend/.venv/
backend/*.pyc
frontend/node_modules/
frontend/dist/
.env
*.egg-info/
.pytest_cache/
Step 4: Créer docker-compose.yml
services:
backend:
build: ./backend
volumes:
- ./data:/data
ports:
- "8000:8000"
environment:
- DATABASE_URL=sqlite:////data/jardin.db
- UPLOAD_DIR=/data/uploads
- CORS_ORIGINS=http://localhost
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
Step 5: Créer backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data/uploads
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Step 6: Créer frontend/Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Step 7: Créer frontend/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
location /uploads/ {
proxy_pass http://backend:8000;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Step 8: Commit
git add .
git commit -m "chore: scaffold projet + docker-compose"
Task 2: Backend — Setup FastAPI + DB
Files:
- Create:
backend/requirements.txt - Create:
backend/app/config.py - Create:
backend/app/database.py - Create:
backend/app/seed.py - Create:
backend/app/main.py
Step 1: Créer backend/requirements.txt
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlmodel==0.0.22
python-multipart==0.0.12
aiofiles==24.1.0
pytest==8.3.3
httpx==0.28.0
Step 2: Créer backend/app/config.py
import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./jardin.db")
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads")
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
Step 3: Créer backend/app/database.py
from sqlmodel import SQLModel, create_engine, Session
from app.config import DATABASE_URL
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
def get_session():
with Session(engine) as session:
yield session
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
Step 4: Créer backend/app/seed.py (stub, rempli en Task 6)
def run_seed():
pass
Step 5: Créer backend/app/main.py
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import CORS_ORIGINS, UPLOAD_DIR
from app.database import create_db_and_tables
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(UPLOAD_DIR, exist_ok=True)
import app.models # noqa — enregistre tous les modèles avant create_all
create_db_and_tables()
from app.seed import run_seed
run_seed()
yield
app = FastAPI(title="Jardin API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
def health():
return {"status": "ok"}
# Routers enregistrés après leur création (Tasks 4-6)
Step 6: Commit
git add backend/
git commit -m "feat(backend): setup FastAPI + SQLite + config"
Task 3: Backend — Modèles SQLModel
Files:
- Create:
backend/app/models/garden.py - Create:
backend/app/models/plant.py - Create:
backend/app/models/planting.py - Create:
backend/app/models/task.py - Create:
backend/app/models/settings.py - Modify:
backend/app/models/__init__.py
Step 1: Créer backend/app/models/garden.py
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Garden(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
nom: str
description: Optional[str] = None
type: str = "plein_air" # plein_air | serre | tunnel
latitude: Optional[float] = None
longitude: Optional[float] = None
altitude: Optional[float] = None
adresse: Optional[str] = None
exposition: Optional[str] = None
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
sol_type: Optional[str] = None
sol_ph: Optional[float] = None
grille_largeur: int = 6
grille_hauteur: int = 4
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class GardenCell(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
garden_id: int = Field(foreign_key="garden.id", index=True)
col: int
row: int
libelle: Optional[str] = None
largeur_m: Optional[float] = None
hauteur_m: Optional[float] = None
etat: str = "libre" # libre | occupe
notes: Optional[str] = None
class GardenImage(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
garden_id: int = Field(foreign_key="garden.id", index=True)
filename: str
caption: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
class Measurement(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
garden_id: int = Field(foreign_key="garden.id", index=True)
temp_air: Optional[float] = None
temp_sol: Optional[float] = None
humidite_air: Optional[float] = None
humidite_sol: Optional[float] = None
source: str = "manuel" # manuel | capteur
ts: datetime = Field(default_factory=datetime.utcnow)
Step 2: Créer backend/app/models/plant.py
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class PlantVariety(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
nom_commun: str
nom_botanique: Optional[str] = None
variete: Optional[str] = None
famille: Optional[str] = None
tags: Optional[str] = None # CSV
type_plante: Optional[str] = None # legume | fruit | aromatique | fleur
besoin_eau: Optional[str] = None # faible | moyen | fort
besoin_soleil: Optional[str] = None
espacement_cm: Optional[int] = None
temp_min_c: Optional[float] = None
duree_culture_j: Optional[int] = None
profondeur_semis_cm: Optional[float] = None
sol_conseille: Optional[str] = None
semis_interieur_mois: Optional[str] = None # ex: "2,3"
semis_exterieur_mois: Optional[str] = None
repiquage_mois: Optional[str] = None
plantation_mois: Optional[str] = None
recolte_mois: Optional[str] = None
notes: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
class PlantImage(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
variety_id: int = Field(foreign_key="plantvariety.id", index=True)
filename: str
caption: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
Step 3: Créer backend/app/models/planting.py
from datetime import date, datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Planting(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
garden_id: int = Field(foreign_key="garden.id", index=True)
variety_id: int = Field(foreign_key="plantvariety.id", index=True)
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
date_semis: Optional[date] = None
date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None
quantite: int = 1
statut: str = "prevu" # prevu | en_cours | termine | echoue
date_recolte_debut: Optional[date] = None
date_recolte_fin: Optional[date] = None
rendement_estime: Optional[float] = None
rendement_reel: Optional[float] = None
notes: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class PlantingEvent(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
planting_id: int = Field(foreign_key="planting.id", index=True)
type: str # arrosage | taille | traitement | observation | autre
note: Optional[str] = None
ts: datetime = Field(default_factory=datetime.utcnow)
Step 4: Créer backend/app/models/task.py
from datetime import date, datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class Task(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
titre: str
description: Optional[str] = None
garden_id: Optional[int] = Field(default=None, foreign_key="garden.id")
planting_id: Optional[int] = Field(default=None, foreign_key="planting.id")
priorite: str = "normale" # basse | normale | haute
echeance: Optional[date] = None
recurrence: Optional[str] = None # quotidien | hebdomadaire | mensuel
statut: str = "a_faire" # a_faire | en_cours | fait | annule
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
Step 5: Créer backend/app/models/settings.py
from datetime import date
from typing import Optional
from sqlmodel import Field, SQLModel
class UserSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
cle: str = Field(unique=True)
valeur: str
class LunarCalendarEntry(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
jour: date = Field(unique=True)
phase: str # nouvelle_lune | premier_quartier | pleine_lune | dernier_quartier | croissante | decroissante
type_jour: Optional[str] = None # racine | feuille | fleur | fruit
lune_montante: Optional[bool] = None
Step 6: Remplir backend/app/models/__init__.py
from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa
from app.models.plant import PlantVariety, PlantImage # noqa
from app.models.planting import Planting, PlantingEvent # noqa
from app.models.task import Task # noqa
from app.models.settings import UserSettings, LunarCalendarEntry # noqa
Step 7: Commit
git add backend/app/models/
git commit -m "feat(backend): modèles SQLModel (10 tables)"
Task 4: Backend — Router jardins (TDD)
Files:
- Create:
backend/tests/conftest.py - Create:
backend/tests/test_gardens.py - Create:
backend/app/routers/gardens.py - Modify:
backend/app/main.py
Step 1: Créer backend/tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from sqlmodel.pool import StaticPool
import app.models # noqa — force l'enregistrement des modèles
from app.main import app
from app.database import get_session
@pytest.fixture(name="session")
def session_fixture():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture(name="client")
def client_fixture(session: Session):
def get_session_override():
yield session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Step 2: Créer backend/tests/test_gardens.py
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
def test_create_garden(client):
r = client.post("/api/gardens", json={"nom": "Mon potager", "type": "plein_air"})
assert r.status_code == 201
data = r.json()
assert data["nom"] == "Mon potager"
assert data["id"] is not None
def test_list_gardens(client):
client.post("/api/gardens", json={"nom": "Jardin 1", "type": "plein_air"})
client.post("/api/gardens", json={"nom": "Jardin 2", "type": "serre"})
r = client.get("/api/gardens")
assert r.status_code == 200
assert len(r.json()) == 2
def test_get_garden(client):
r = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"})
id = r.json()["id"]
r2 = client.get(f"/api/gardens/{id}")
assert r2.status_code == 200
assert r2.json()["nom"] == "Potager"
def test_update_garden(client):
r = client.post("/api/gardens", json={"nom": "Vieux nom", "type": "plein_air"})
id = r.json()["id"]
r2 = client.put(f"/api/gardens/{id}", json={"nom": "Nouveau nom", "type": "serre"})
assert r2.status_code == 200
assert r2.json()["nom"] == "Nouveau nom"
def test_delete_garden(client):
r = client.post("/api/gardens", json={"nom": "À supprimer", "type": "plein_air"})
id = r.json()["id"]
r2 = client.delete(f"/api/gardens/{id}")
assert r2.status_code == 204
r3 = client.get(f"/api/gardens/{id}")
assert r3.status_code == 404
def test_create_measurement(client):
r = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"})
id = r.json()["id"]
r2 = client.post(
f"/api/gardens/{id}/measurements",
json={"temp_air": 22.5, "humidite_air": 60.0},
)
assert r2.status_code == 201
Step 3: Lancer les tests — vérifier qu'ils échouent
cd backend && pip install -r requirements.txt && pytest tests/test_gardens.py -v
Attendu : ImportError ou 404 (routers non créés).
Step 4: Créer backend/app/routers/gardens.py
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
router = APIRouter(tags=["jardins"])
@router.get("/gardens", response_model=List[Garden])
def list_gardens(session: Session = Depends(get_session)):
return session.exec(select(Garden)).all()
@router.post("/gardens", response_model=Garden, status_code=status.HTTP_201_CREATED)
def create_garden(garden: Garden, session: Session = Depends(get_session)):
session.add(garden)
session.commit()
session.refresh(garden)
return garden
@router.get("/gardens/{id}", response_model=Garden)
def get_garden(id: int, session: Session = Depends(get_session)):
g = session.get(Garden, id)
if not g:
raise HTTPException(status_code=404, detail="Jardin introuvable")
return g
@router.put("/gardens/{id}", response_model=Garden)
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
g = session.get(Garden, id)
if not g:
raise HTTPException(status_code=404, detail="Jardin introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(g, k, v)
g.updated_at = datetime.utcnow()
session.add(g)
session.commit()
session.refresh(g)
return g
@router.delete("/gardens/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_garden(id: int, session: Session = Depends(get_session)):
g = session.get(Garden, id)
if not g:
raise HTTPException(status_code=404, detail="Jardin introuvable")
session.delete(g)
session.commit()
@router.get("/gardens/{id}/cells", response_model=List[GardenCell])
def list_cells(id: int, session: Session = Depends(get_session)):
return session.exec(select(GardenCell).where(GardenCell.garden_id == id)).all()
@router.post("/gardens/{id}/cells", response_model=GardenCell, status_code=status.HTTP_201_CREATED)
def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_session)):
cell.garden_id = id
session.add(cell)
session.commit()
session.refresh(cell)
return cell
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
def list_measurements(id: int, session: Session = Depends(get_session)):
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
@router.post("/gardens/{id}/measurements", response_model=Measurement, status_code=status.HTTP_201_CREATED)
def create_measurement(id: int, m: Measurement, session: Session = Depends(get_session)):
m.garden_id = id
session.add(m)
session.commit()
session.refresh(m)
return m
Step 5: Créer les routers stub pour les autres ressources (pour que main.py démarre) :
backend/app/routers/varieties.py, plantings.py, tasks.py, settings.py, media.py — chacun :
from fastapi import APIRouter
router = APIRouter()
Step 6: Enregistrer les routers dans backend/app/main.py — ajouter après le middleware :
from app.routers import gardens, varieties, plantings, tasks, settings, media
app.include_router(gardens.router, prefix="/api")
app.include_router(varieties.router, prefix="/api")
app.include_router(plantings.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(media.router, prefix="/api")
Aussi ajouter le mount des uploads après les routers :
from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
Step 7: Lancer les tests — vérifier qu'ils passent
cd backend && pytest tests/test_gardens.py -v
Attendu : tous PASS.
Step 8: Commit
git add backend/
git commit -m "feat(backend): CRUD jardins + tests"
Task 5: Backend — Routers variétés, plantations, tâches (TDD)
Files:
- Create:
backend/tests/test_varieties.py - Create:
backend/tests/test_plantings.py - Create:
backend/tests/test_tasks.py - Modify:
backend/app/routers/varieties.py - Modify:
backend/app/routers/plantings.py - Modify:
backend/app/routers/tasks.py
Step 1: Créer backend/tests/test_varieties.py
def test_create_variety(client):
r = client.post("/api/varieties", json={"nom_commun": "Tomate", "famille": "Solanacées"})
assert r.status_code == 201
assert r.json()["nom_commun"] == "Tomate"
def test_list_varieties(client):
client.post("/api/varieties", json={"nom_commun": "Tomate"})
client.post("/api/varieties", json={"nom_commun": "Courgette"})
r = client.get("/api/varieties")
assert r.status_code == 200
assert len(r.json()) == 2
def test_get_variety(client):
r = client.post("/api/varieties", json={"nom_commun": "Basilic"})
id = r.json()["id"]
r2 = client.get(f"/api/varieties/{id}")
assert r2.status_code == 200
def test_delete_variety(client):
r = client.post("/api/varieties", json={"nom_commun": "Test"})
id = r.json()["id"]
r2 = client.delete(f"/api/varieties/{id}")
assert r2.status_code == 204
Step 2: Créer backend/tests/test_plantings.py
def test_create_planting(client):
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
r = client.post("/api/plantings", json={
"garden_id": g["id"], "variety_id": v["id"], "quantite": 3
})
assert r.status_code == 201
assert r.json()["statut"] == "prevu"
def test_list_plantings(client):
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]})
r = client.get("/api/plantings")
assert r.status_code == 200
assert len(r.json()) >= 1
def test_add_planting_event(client):
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
p = client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]}).json()
r = client.post(f"/api/plantings/{p['id']}/events", json={"type": "arrosage", "note": "Bien arrosé"})
assert r.status_code == 201
Step 3: Créer backend/tests/test_tasks.py
def test_create_task(client):
r = client.post("/api/tasks", json={"titre": "Arroser les tomates", "priorite": "haute"})
assert r.status_code == 201
assert r.json()["statut"] == "a_faire"
def test_list_tasks(client):
client.post("/api/tasks", json={"titre": "Tâche 1"})
client.post("/api/tasks", json={"titre": "Tâche 2"})
r = client.get("/api/tasks")
assert r.status_code == 200
assert len(r.json()) == 2
def test_filter_tasks_by_statut(client):
client.post("/api/tasks", json={"titre": "À faire", "statut": "a_faire"})
client.post("/api/tasks", json={"titre": "Fait", "statut": "fait"})
r = client.get("/api/tasks?statut=a_faire")
assert r.status_code == 200
assert all(t["statut"] == "a_faire" for t in r.json())
def test_update_task_statut(client):
r = client.post("/api/tasks", json={"titre": "À faire"})
id = r.json()["id"]
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
assert r2.status_code == 200
assert r2.json()["statut"] == "fait"
Step 4: Lancer les tests — vérifier qu'ils échouent
cd backend && pytest tests/test_varieties.py tests/test_plantings.py tests/test_tasks.py -v
Attendu : 404 (routers vides).
Step 5: Implémenter backend/app/routers/varieties.py
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.plant import PlantVariety
router = APIRouter(tags=["variétés"])
@router.get("/varieties", response_model=List[PlantVariety])
def list_varieties(session: Session = Depends(get_session)):
return session.exec(select(PlantVariety)).all()
@router.post("/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED)
def create_variety(v: PlantVariety, session: Session = Depends(get_session)):
session.add(v)
session.commit()
session.refresh(v)
return v
@router.get("/varieties/{id}", response_model=PlantVariety)
def get_variety(id: int, session: Session = Depends(get_session)):
v = session.get(PlantVariety, id)
if not v:
raise HTTPException(status_code=404, detail="Variété introuvable")
return v
@router.put("/varieties/{id}", response_model=PlantVariety)
def update_variety(id: int, data: PlantVariety, session: Session = Depends(get_session)):
v = session.get(PlantVariety, id)
if not v:
raise HTTPException(status_code=404, detail="Variété introuvable")
for k, val in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(v, k, val)
session.add(v)
session.commit()
session.refresh(v)
return v
@router.delete("/varieties/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_variety(id: int, session: Session = Depends(get_session)):
v = session.get(PlantVariety, id)
if not v:
raise HTTPException(status_code=404, detail="Variété introuvable")
session.delete(v)
session.commit()
Step 6: Implémenter backend/app/routers/plantings.py
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.planting import Planting, PlantingEvent
router = APIRouter(tags=["plantations"])
@router.get("/plantings", response_model=List[Planting])
def list_plantings(session: Session = Depends(get_session)):
return session.exec(select(Planting)).all()
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
def create_planting(p: Planting, session: Session = Depends(get_session)):
session.add(p)
session.commit()
session.refresh(p)
return p
@router.get("/plantings/{id}", response_model=Planting)
def get_planting(id: int, session: Session = Depends(get_session)):
p = session.get(Planting, id)
if not p:
raise HTTPException(status_code=404, detail="Plantation introuvable")
return p
@router.put("/plantings/{id}", response_model=Planting)
def update_planting(id: int, data: Planting, session: Session = Depends(get_session)):
p = session.get(Planting, id)
if not p:
raise HTTPException(status_code=404, detail="Plantation introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(p, k, v)
p.updated_at = datetime.utcnow()
session.add(p)
session.commit()
session.refresh(p)
return p
@router.delete("/plantings/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_planting(id: int, session: Session = Depends(get_session)):
p = session.get(Planting, id)
if not p:
raise HTTPException(status_code=404, detail="Plantation introuvable")
session.delete(p)
session.commit()
@router.get("/plantings/{id}/events", response_model=List[PlantingEvent])
def list_events(id: int, session: Session = Depends(get_session)):
return session.exec(select(PlantingEvent).where(PlantingEvent.planting_id == id)).all()
@router.post("/plantings/{id}/events", response_model=PlantingEvent, status_code=status.HTTP_201_CREATED)
def create_event(id: int, e: PlantingEvent, session: Session = Depends(get_session)):
e.planting_id = id
session.add(e)
session.commit()
session.refresh(e)
return e
Step 7: Implémenter backend/app/routers/tasks.py
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.task import Task
router = APIRouter(tags=["tâches"])
@router.get("/tasks", response_model=List[Task])
def list_tasks(
statut: Optional[str] = None,
garden_id: Optional[int] = None,
session: Session = Depends(get_session),
):
q = select(Task)
if statut:
q = q.where(Task.statut == statut)
if garden_id:
q = q.where(Task.garden_id == garden_id)
return session.exec(q).all()
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(t: Task, session: Session = Depends(get_session)):
session.add(t)
session.commit()
session.refresh(t)
return t
@router.get("/tasks/{id}", response_model=Task)
def get_task(id: int, session: Session = Depends(get_session)):
t = session.get(Task, id)
if not t:
raise HTTPException(status_code=404, detail="Tâche introuvable")
return t
@router.put("/tasks/{id}", response_model=Task)
def update_task(id: int, data: Task, session: Session = Depends(get_session)):
t = session.get(Task, id)
if not t:
raise HTTPException(status_code=404, detail="Tâche introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(t, k, v)
t.updated_at = datetime.utcnow()
session.add(t)
session.commit()
session.refresh(t)
return t
@router.delete("/tasks/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(id: int, session: Session = Depends(get_session)):
t = session.get(Task, id)
if not t:
raise HTTPException(status_code=404, detail="Tâche introuvable")
session.delete(t)
session.commit()
Step 8: Lancer tous les tests
cd backend && pytest tests/ -v
Attendu : tous PASS.
Step 9: Commit
git add backend/
git commit -m "feat(backend): CRUD variétés, plantations, tâches + tests"
Task 6: Backend — Settings, upload media + seed
Files:
- Modify:
backend/app/routers/settings.py - Modify:
backend/app/routers/media.py - Modify:
backend/app/seed.py
Step 1: Implémenter backend/app/routers/settings.py
from datetime import date
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.database import get_session
from app.models.settings import UserSettings, LunarCalendarEntry
router = APIRouter(tags=["réglages"])
@router.get("/settings")
def get_settings(session: Session = Depends(get_session)):
rows = session.exec(select(UserSettings)).all()
return {r.cle: r.valeur for r in rows}
@router.put("/settings")
def update_settings(data: dict, session: Session = Depends(get_session)):
for cle, valeur in data.items():
row = session.exec(select(UserSettings).where(UserSettings.cle == cle)).first()
if row:
row.valeur = str(valeur)
else:
row = UserSettings(cle=cle, valeur=str(valeur))
session.add(row)
session.commit()
return {"ok": True}
@router.get("/lunar")
def get_lunar(month: str, session: Session = Depends(get_session)):
year, m = map(int, month.split("-"))
first = date(year, m, 1)
last_m, last_y = (m + 1, year) if m < 12 else (1, year + 1)
last = date(last_y, last_m, 1)
return session.exec(
select(LunarCalendarEntry)
.where(LunarCalendarEntry.jour >= first)
.where(LunarCalendarEntry.jour < last)
).all()
Step 2: Implémenter backend/app/routers/media.py
import os
import uuid
from fastapi import APIRouter, File, HTTPException, UploadFile
from app.config import UPLOAD_DIR
router = APIRouter(tags=["media"])
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
@router.post("/upload")
async def upload_file(file: UploadFile = File(...)):
ext = os.path.splitext(file.filename or "")[-1].lower()
if ext not in ALLOWED_EXT:
raise HTTPException(status_code=400, detail="Format non supporté")
filename = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(UPLOAD_DIR, filename)
content = await file.read()
with open(dest, "wb") as f:
f.write(content)
return {"filename": filename, "url": f"/uploads/{filename}"}
@router.delete("/upload/{filename}", status_code=204)
def delete_file(filename: str):
path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(path):
os.remove(path)
Step 3: Remplir backend/app/seed.py
from datetime import date
from sqlmodel import Session, select
from app.database import engine
import app.models # noqa
def run_seed():
from app.models.garden import Garden, GardenCell, Measurement
from app.models.plant import PlantVariety
from app.models.planting import Planting, PlantingEvent
from app.models.task import Task
with Session(engine) as session:
if session.exec(select(Garden)).first():
return # déjà seedé
jardin = Garden(
nom="Mon potager",
description="Potager principal plein sud",
type="plein_air",
exposition="S",
ombre="plein_soleil",
sol_type="limoneux",
grille_largeur=6,
grille_hauteur=4,
)
session.add(jardin)
session.flush()
for row in range(4):
for col in range(6):
session.add(GardenCell(
garden_id=jardin.id,
col=col, row=row,
libelle=f"{chr(65 + row)}{col + 1}",
))
session.add(Measurement(garden_id=jardin.id, temp_air=18.0, humidite_air=65.0))
tomate = PlantVariety(
nom_commun="Tomate", variete="Andine Cornue",
famille="Solanacées", type_plante="legume",
besoin_eau="fort", espacement_cm=60,
plantation_mois="4,5", recolte_mois="7,8,9",
)
courgette = PlantVariety(
nom_commun="Courgette", variete="Verte",
famille="Cucurbitacées", type_plante="legume",
besoin_eau="moyen", espacement_cm=80,
plantation_mois="5,6", recolte_mois="7,8",
)
salade = PlantVariety(
nom_commun="Laitue", variete="Batavia",
famille="Astéracées", type_plante="legume",
besoin_eau="moyen", espacement_cm=25,
)
session.add_all([tomate, courgette, salade])
session.flush()
p1 = Planting(garden_id=jardin.id, variety_id=tomate.id,
date_plantation=date(2026, 5, 1), quantite=6, statut="en_cours")
p2 = Planting(garden_id=jardin.id, variety_id=courgette.id,
date_plantation=date(2026, 5, 15), quantite=3, statut="prevu")
session.add_all([p1, p2])
session.flush()
session.add(PlantingEvent(planting_id=p1.id, type="arrosage", note="Arrosage du matin"))
session.add(Task(titre="Arroser les tomates", priorite="haute",
statut="a_faire", garden_id=jardin.id))
session.add(Task(titre="Traiter contre les pucerons", priorite="normale", statut="a_faire"))
session.add(Task(titre="Préparer le compost", priorite="basse", statut="en_cours"))
session.commit()
Step 4: Lancer tous les tests une dernière fois
cd backend && pytest tests/ -v
Attendu : tous PASS.
Step 5: Commit
git add backend/
git commit -m "feat(backend): settings, upload media, seed données démo"
Task 7: Frontend — Scaffold Vue 3 + Vite + Tailwind
Files:
- Create:
frontend/package.json - Create:
frontend/vite.config.ts - Create:
frontend/tailwind.config.js - Create:
frontend/postcss.config.js - Create:
frontend/tsconfig.json - Create:
frontend/index.html - Create:
frontend/src/style.css - Create:
frontend/src/main.ts - Create:
frontend/src/App.vue
Step 1: Créer frontend/package.json
{
"name": "jardin-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"axios": "^1.7.9"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7",
"typescript": "^5.7.3",
"vue-tsc": "^2.2.0",
"tailwindcss": "^3.4.17",
"postcss": "^8.5.1",
"autoprefixer": "^10.4.20"
}
}
Step 2: Créer frontend/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
proxy: {
'/api': 'http://localhost:8000',
'/uploads': 'http://localhost:8000',
},
},
})
Step 3: Créer frontend/tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {
colors: {
bg: '#282828',
'bg-soft': '#3c3836',
'bg-hard': '#1d2021',
text: '#ebdbb2',
'text-muted': '#a89984',
green: '#b8bb26',
yellow: '#fabd2f',
blue: '#83a598',
orange: '#fe8019',
red: '#fb4934',
purple: '#d3869b',
aqua: '#8ec07c',
},
fontFamily: {
mono: ['"Fira Code"', '"Courier New"', 'monospace'],
},
},
},
}
Step 4: Créer frontend/postcss.config.js
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
Step 5: Créer frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
Step 6: Créer frontend/index.html
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🌿 Jardin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Step 7: Créer frontend/src/style.css
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-bg text-text font-mono;
min-height: 100vh;
}
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #1d2021; }
::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; }
Step 8: Créer frontend/src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(createPinia()).use(router).mount('#app')
Step 9: Créer frontend/src/App.vue (stub — sera remplacé en Task 9)
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
Step 10: Vérifier l'install
cd frontend && npm install && npm run build
Attendu : build sans erreur, dossier dist/ créé.
Step 11: Commit
git add frontend/
git commit -m "feat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvbox"
Task 8: Frontend — API layer + Stores Pinia
Files:
- Create:
frontend/src/api/client.ts - Create:
frontend/src/api/gardens.ts - Create:
frontend/src/api/varieties.ts - Create:
frontend/src/api/plantings.ts - Create:
frontend/src/api/tasks.ts - Create:
frontend/src/stores/gardens.ts - Create:
frontend/src/stores/varieties.ts - Create:
frontend/src/stores/plantings.ts - Create:
frontend/src/stores/tasks.ts
Step 1: Créer frontend/src/api/client.ts
import axios from 'axios'
export default axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '',
})
Step 2: Créer frontend/src/api/gardens.ts
import client from './client'
export interface Garden {
id?: number
nom: string
description?: string
type: string
latitude?: number
longitude?: number
adresse?: string
exposition?: string
ombre?: string
sol_type?: string
grille_largeur: number
grille_hauteur: number
}
export interface GardenCell {
id?: number
garden_id?: number
col: number
row: number
libelle?: string
etat: string
notes?: string
}
export interface Measurement {
id?: number
garden_id?: number
temp_air?: number
temp_sol?: number
humidite_air?: number
humidite_sol?: number
source?: string
ts?: string
}
export const gardensApi = {
list: () => client.get<Garden[]>('/api/gardens').then(r => r.data),
get: (id: number) => client.get<Garden>(`/api/gardens/${id}`).then(r => r.data),
create: (g: Partial<Garden>) => client.post<Garden>('/api/gardens', g).then(r => r.data),
update: (id: number, g: Partial<Garden>) => client.put<Garden>(`/api/gardens/${id}`, g).then(r => r.data),
delete: (id: number) => client.delete(`/api/gardens/${id}`),
cells: (id: number) => client.get<GardenCell[]>(`/api/gardens/${id}/cells`).then(r => r.data),
measurements: (id: number) => client.get<Measurement[]>(`/api/gardens/${id}/measurements`).then(r => r.data),
addMeasurement: (id: number, m: Partial<Measurement>) =>
client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data),
}
Step 3: Créer frontend/src/api/varieties.ts
import client from './client'
export interface PlantVariety {
id?: number
nom_commun: string
nom_botanique?: string
variete?: string
famille?: string
type_plante?: string
besoin_eau?: string
espacement_cm?: number
plantation_mois?: string
recolte_mois?: string
notes?: string
}
export const varietiesApi = {
list: () => client.get<PlantVariety[]>('/api/varieties').then(r => r.data),
get: (id: number) => client.get<PlantVariety>(`/api/varieties/${id}`).then(r => r.data),
create: (v: Partial<PlantVariety>) => client.post<PlantVariety>('/api/varieties', v).then(r => r.data),
update: (id: number, v: Partial<PlantVariety>) => client.put<PlantVariety>(`/api/varieties/${id}`, v).then(r => r.data),
delete: (id: number) => client.delete(`/api/varieties/${id}`),
}
Step 4: Créer frontend/src/api/plantings.ts
import client from './client'
export interface Planting {
id?: number
garden_id: number
variety_id: number
cell_id?: number
date_plantation?: string
quantite: number
statut: string
notes?: string
}
export interface PlantingEvent {
id?: number
planting_id?: number
type: string
note?: string
ts?: string
}
export const plantingsApi = {
list: () => client.get<Planting[]>('/api/plantings').then(r => r.data),
get: (id: number) => client.get<Planting>(`/api/plantings/${id}`).then(r => r.data),
create: (p: Partial<Planting>) => client.post<Planting>('/api/plantings', p).then(r => r.data),
update: (id: number, p: Partial<Planting>) => client.put<Planting>(`/api/plantings/${id}`, p).then(r => r.data),
delete: (id: number) => client.delete(`/api/plantings/${id}`),
events: (id: number) => client.get<PlantingEvent[]>(`/api/plantings/${id}/events`).then(r => r.data),
addEvent: (id: number, e: Partial<PlantingEvent>) =>
client.post<PlantingEvent>(`/api/plantings/${id}/events`, e).then(r => r.data),
}
Step 5: Créer frontend/src/api/tasks.ts
import client from './client'
export interface Task {
id?: number
titre: string
description?: string
garden_id?: number
priorite: string
echeance?: string
statut: string
}
export const tasksApi = {
list: (params?: { statut?: string; garden_id?: number }) =>
client.get<Task[]>('/api/tasks', { params }).then(r => r.data),
get: (id: number) => client.get<Task>(`/api/tasks/${id}`).then(r => r.data),
create: (t: Partial<Task>) => client.post<Task>('/api/tasks', t).then(r => r.data),
update: (id: number, t: Partial<Task>) => client.put<Task>(`/api/tasks/${id}`, t).then(r => r.data),
delete: (id: number) => client.delete(`/api/tasks/${id}`),
}
Step 6: Créer frontend/src/stores/gardens.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { gardensApi, type Garden } from '@/api/gardens'
export const useGardensStore = defineStore('gardens', () => {
const gardens = ref<Garden[]>([])
const loading = ref(false)
async function fetchAll() {
loading.value = true
gardens.value = await gardensApi.list()
loading.value = false
}
async function create(g: Partial<Garden>) {
const created = await gardensApi.create(g)
gardens.value.push(created)
return created
}
async function remove(id: number) {
await gardensApi.delete(id)
gardens.value = gardens.value.filter(g => g.id !== id)
}
return { gardens, loading, fetchAll, create, remove }
})
Step 7: Créer frontend/src/stores/varieties.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { varietiesApi, type PlantVariety } from '@/api/varieties'
export const useVarietiesStore = defineStore('varieties', () => {
const varieties = ref<PlantVariety[]>([])
const loading = ref(false)
async function fetchAll() {
loading.value = true
varieties.value = await varietiesApi.list()
loading.value = false
}
async function create(v: Partial<PlantVariety>) {
const created = await varietiesApi.create(v)
varieties.value.push(created)
return created
}
async function remove(id: number) {
await varietiesApi.delete(id)
varieties.value = varieties.value.filter(v => v.id !== id)
}
return { varieties, loading, fetchAll, create, remove }
})
Step 8: Créer frontend/src/stores/plantings.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { plantingsApi, type Planting } from '@/api/plantings'
export const usePlantingsStore = defineStore('plantings', () => {
const plantings = ref<Planting[]>([])
const loading = ref(false)
async function fetchAll() {
loading.value = true
plantings.value = await plantingsApi.list()
loading.value = false
}
async function create(p: Partial<Planting>) {
const created = await plantingsApi.create(p)
plantings.value.push(created)
return created
}
async function remove(id: number) {
await plantingsApi.delete(id)
plantings.value = plantings.value.filter(p => p.id !== id)
}
return { plantings, loading, fetchAll, create, remove }
})
Step 9: Créer frontend/src/stores/tasks.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { tasksApi, type Task } from '@/api/tasks'
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const loading = ref(false)
async function fetchAll(params?: { statut?: string; garden_id?: number }) {
loading.value = true
tasks.value = await tasksApi.list(params)
loading.value = false
}
async function create(t: Partial<Task>) {
const created = await tasksApi.create(t)
tasks.value.push(created)
return created
}
async function updateStatut(id: number, statut: string) {
const t = tasks.value.find(t => t.id === id)
if (!t) return
const updated = await tasksApi.update(id, { ...t, statut })
Object.assign(t, updated)
}
async function remove(id: number) {
await tasksApi.delete(id)
tasks.value = tasks.value.filter(t => t.id !== id)
}
return { tasks, loading, fetchAll, create, updateStatut, remove }
})
Step 10: Commit
git add frontend/src/api/ frontend/src/stores/
git commit -m "feat(frontend): API layer + stores Pinia"
Task 9: Frontend — Layout + Router
Files:
- Create:
frontend/src/router/index.ts - Create:
frontend/src/components/AppHeader.vue - Create:
frontend/src/components/AppDrawer.vue - Modify:
frontend/src/App.vue
Step 1: Créer frontend/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/DashboardView.vue') },
{ path: '/jardins', component: () => import('@/views/JardinsView.vue') },
{ path: '/jardins/:id', component: () => import('@/views/JardinDetailView.vue') },
{ path: '/varietes', component: () => import('@/views/VarietesView.vue') },
{ path: '/plantations', component: () => import('@/views/PlantationsView.vue') },
{ path: '/planning', component: () => import('@/views/PlanningView.vue') },
{ path: '/taches', component: () => import('@/views/TachesView.vue') },
{ path: '/lunaire', component: () => import('@/views/LunaireView.vue') },
{ path: '/reglages', component: () => import('@/views/ReglagesView.vue') },
],
})
Step 2: Créer frontend/src/components/AppHeader.vue
<template>
<header class="fixed top-0 left-0 right-0 z-50 bg-bg-hard border-b border-bg-soft h-14 flex items-center px-4 gap-4">
<button class="md:hidden text-text-muted hover:text-text text-xl" @click="$emit('toggle-drawer')">☰</button>
<RouterLink to="/" class="text-green font-bold text-lg tracking-wide">🌿 Jardin</RouterLink>
<nav class="hidden md:flex gap-5 ml-4">
<RouterLink
v-for="l in links" :key="l.to" :to="l.to"
class="text-text-muted hover:text-text transition-colors text-sm"
active-class="text-green font-semibold"
>{{ l.label }}</RouterLink>
</nav>
</header>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineEmits(['toggle-drawer'])
const links = [
{ to: '/', label: 'Dashboard' },
{ to: '/jardins', label: 'Jardins' },
{ to: '/varietes', label: 'Variétés' },
{ to: '/plantations', label: 'Plantations' },
{ to: '/taches', label: 'Tâches' },
{ to: '/planning', label: 'Planning' },
{ to: '/lunaire', label: 'Lunaire' },
{ to: '/reglages', label: 'Réglages' },
]
</script>
Step 3: Créer frontend/src/components/AppDrawer.vue
<template>
<Transition name="slide">
<div v-if="open" class="fixed inset-0 z-40 flex md:hidden" @click.self="$emit('close')">
<nav class="bg-bg-hard w-64 h-full p-6 flex flex-col gap-1 border-r border-bg-soft shadow-2xl">
<span class="text-green font-bold text-xl mb-6">🌿 Jardin</span>
<RouterLink
v-for="l in links" :key="l.to" :to="l.to"
class="text-text-muted hover:text-text py-2 px-3 rounded-lg text-sm transition-colors"
active-class="bg-bg-soft text-green"
@click="$emit('close')"
>{{ l.label }}</RouterLink>
</nav>
</div>
</Transition>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineProps<{ open: boolean }>()
defineEmits(['close'])
const links = [
{ to: '/', label: 'Dashboard' },
{ to: '/jardins', label: 'Jardins' },
{ to: '/varietes', label: 'Variétés' },
{ to: '/plantations', label: 'Plantations' },
{ to: '/taches', label: 'Tâches' },
{ to: '/planning', label: 'Planning' },
{ to: '/lunaire', label: 'Calendrier lunaire' },
{ to: '/reglages', label: 'Réglages' },
]
</script>
<style scoped>
.slide-enter-active, .slide-leave-active { transition: opacity 0.2s; }
.slide-enter-from, .slide-leave-to { opacity: 0; }
</style>
Step 4: Remplacer frontend/src/App.vue
<template>
<AppHeader @toggle-drawer="drawerOpen = !drawerOpen" />
<AppDrawer :open="drawerOpen" @close="drawerOpen = false" />
<main class="pt-14 min-h-screen">
<RouterView />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppDrawer from '@/components/AppDrawer.vue'
const drawerOpen = ref(false)
</script>
Step 5: Commit
git add frontend/src/
git commit -m "feat(frontend): layout header + drawer + router"
Task 10: Frontend — Vues MVP
Files:
- Create:
frontend/src/views/DashboardView.vue - Create:
frontend/src/views/JardinsView.vue - Create:
frontend/src/views/JardinDetailView.vue - Create:
frontend/src/views/VarietesView.vue - Create:
frontend/src/views/PlantationsView.vue - Create:
frontend/src/views/TachesView.vue - Create:
frontend/src/views/PlanningView.vue - Create:
frontend/src/views/LunaireView.vue - Create:
frontend/src/views/ReglagesView.vue
Step 1: Créer frontend/src/views/DashboardView.vue
<template>
<div class="p-4 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-green mb-6">Tableau de bord</h1>
<section class="mb-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Tâches à faire</h2>
<div v-if="!tasksStore.tasks.filter(t => t.statut === 'a_faire').length" class="text-text-muted text-sm">
Aucune tâche en attente.
</div>
<div
v-for="t in tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5)"
:key="t.id"
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard"
>
<span :class="{ 'text-red': t.priorite === 'haute', 'text-yellow': t.priorite === 'normale', 'text-text-muted': t.priorite === 'basse' }">●</span>
<span class="text-text text-sm flex-1">{{ t.titre }}</span>
<button class="text-xs text-green hover:underline" @click="tasksStore.updateStatut(t.id!, 'fait')">✓ Fait</button>
</div>
</section>
<section>
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Jardins</h2>
<div
v-for="g in gardensStore.gardens"
:key="g.id"
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard cursor-pointer hover:border-green transition-colors"
@click="router.push(`/jardins/${g.id}`)"
>
<span class="text-text font-medium">{{ g.nom }}</span>
<span class="ml-2 text-xs text-text-muted px-2 py-0.5 bg-bg rounded">{{ g.type }}</span>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGardensStore } from '@/stores/gardens'
import { useTasksStore } from '@/stores/tasks'
const router = useRouter()
const gardensStore = useGardensStore()
const tasksStore = useTasksStore()
onMounted(() => { gardensStore.fetchAll(); tasksStore.fetchAll() })
</script>
Step 2: Créer frontend/src/views/JardinsView.vue
<template>
<div class="p-4 max-w-2xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">Jardins</h1>
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity"
@click="showForm = !showForm">+ Nouveau</button>
</div>
<form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
<div class="grid gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Nom *</label>
<input v-model="form.nom" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Type</label>
<select v-model="form.type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="plein_air">Plein air</option>
<option value="serre">Serre</option>
<option value="tunnel">Tunnel</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Largeur grille</label>
<input v-model.number="form.grille_largeur" type="number" min="1" max="20" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Hauteur grille</label>
<input v-model.number="form.grille_hauteur" type="number" min="1" max="20" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
</div>
</form>
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-for="g in store.gardens" :key="g.id"
class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard flex items-center gap-3 cursor-pointer hover:border-green transition-colors group"
@click="router.push(`/jardins/${g.id}`)">
<div class="flex-1">
<div class="text-text font-medium group-hover:text-green transition-colors">{{ g.nom }}</div>
<div class="text-text-muted text-xs mt-1">{{ g.type }} · {{ g.grille_largeur }}×{{ g.grille_hauteur }} cases</div>
</div>
<button class="text-text-muted hover:text-red text-sm px-2 py-1 rounded hover:bg-bg transition-colors"
@click.stop="store.remove(g.id!)">✕</button>
</div>
<div v-if="!store.loading && !store.gardens.length" class="text-text-muted text-sm text-center py-8">
Aucun jardin. Créez-en un !
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useGardensStore } from '@/stores/gardens'
const router = useRouter()
const store = useGardensStore()
const showForm = ref(false)
const form = reactive({ nom: '', type: 'plein_air', grille_largeur: 6, grille_hauteur: 4 })
onMounted(() => store.fetchAll())
async function submit() {
await store.create({ ...form })
showForm.value = false
Object.assign(form, { nom: '', type: 'plein_air', grille_largeur: 6, grille_hauteur: 4 })
}
</script>
Step 3: Créer frontend/src/views/JardinDetailView.vue
<template>
<div class="p-4 max-w-3xl mx-auto">
<button class="text-text-muted text-sm mb-4 hover:text-text flex items-center gap-1" @click="router.back()">← Retour</button>
<div v-if="garden">
<h1 class="text-2xl font-bold text-green mb-1">{{ garden.nom }}</h1>
<p class="text-text-muted text-sm mb-6">{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}</p>
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">
Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
</h2>
<div class="overflow-x-auto pb-2">
<div class="grid gap-1 w-max"
:style="`grid-template-columns: repeat(${garden.grille_largeur}, 52px)`">
<div
v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
class="w-[52px] h-[52px] bg-bg-soft border border-bg-hard rounded-md flex items-center justify-center text-xs text-text-muted cursor-pointer hover:border-green transition-colors select-none"
:class="{ 'border-orange/60 bg-orange/10 text-orange': cell.etat === 'occupe' }"
:title="cell.libelle"
>
{{ cell.libelle }}
</div>
</div>
</div>
</div>
<div v-else class="text-text-muted text-sm">Chargement...</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { gardensApi, type Garden, type GardenCell } from '@/api/gardens'
const route = useRoute()
const router = useRouter()
const garden = ref<Garden | null>(null)
const cells = ref<GardenCell[]>([])
const displayCells = computed(() => {
if (!garden.value) return []
const map = new Map(cells.value.map(c => [`${c.row}-${c.col}`, c]))
const result: GardenCell[] = []
for (let row = 0; row < garden.value.grille_hauteur; row++) {
for (let col = 0; col < garden.value.grille_largeur; col++) {
result.push(map.get(`${row}-${col}`) ?? {
col, row,
libelle: `${String.fromCharCode(65 + row)}${col + 1}`,
etat: 'libre',
})
}
}
return result
})
onMounted(async () => {
const id = Number(route.params.id)
garden.value = await gardensApi.get(id)
cells.value = await gardensApi.cells(id)
})
</script>
Step 4: Créer frontend/src/views/VarietesView.vue
<template>
<div class="p-4 max-w-2xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">Variétés</h1>
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
@click="showForm = !showForm">+ Nouvelle</button>
</div>
<form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
<div class="grid gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Nom commun *</label>
<input v-model="form.nom_commun" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Variété</label>
<input v-model="form.variete" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Famille</label>
<input v-model="form.famille" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Besoin en eau</label>
<select v-model="form.besoin_eau" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="">—</option>
<option value="faible">Faible</option>
<option value="moyen">Moyen</option>
<option value="fort">Fort</option>
</select>
</div>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
</div>
</form>
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-for="v in store.varieties" :key="v.id"
class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard flex items-start gap-3">
<div class="flex-1">
<div class="text-text font-medium">{{ v.nom_commun }} <span v-if="v.variete" class="text-text-muted text-xs">— {{ v.variete }}</span></div>
<div class="text-text-muted text-xs mt-1">{{ v.famille }}</div>
<div class="flex gap-2 mt-2">
<span v-if="v.besoin_eau" class="text-xs px-2 py-0.5 bg-bg rounded text-blue">💧 {{ v.besoin_eau }}</span>
<span v-if="v.espacement_cm" class="text-xs px-2 py-0.5 bg-bg rounded text-text-muted">↔ {{ v.espacement_cm }}cm</span>
</div>
</div>
<button class="text-text-muted hover:text-red text-sm px-2 py-1 rounded hover:bg-bg transition-colors"
@click="store.remove(v.id!)">✕</button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useVarietiesStore } from '@/stores/varieties'
const store = useVarietiesStore()
const showForm = ref(false)
const form = reactive({ nom_commun: '', variete: '', famille: '', besoin_eau: '' })
onMounted(() => store.fetchAll())
async function submit() {
await store.create({ ...form })
showForm.value = false
Object.assign(form, { nom_commun: '', variete: '', famille: '', besoin_eau: '' })
}
</script>
Step 5: Créer frontend/src/views/TachesView.vue
<template>
<div class="p-4 max-w-2xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-green">Tâches</h1>
<button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
@click="showForm = !showForm">+ Nouvelle</button>
</div>
<form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
<div class="grid gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Titre *</label>
<input v-model="form.titre" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-text-muted text-xs block mb-1">Priorité</label>
<select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
<option value="basse">Basse</option>
<option value="normale">Normale</option>
<option value="haute">Haute</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs block mb-1">Échéance</label>
<input v-model="form.echeance" type="date" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
<button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
</div>
</form>
<div v-for="[groupe, label] in groupes" :key="groupe" class="mb-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">{{ label }}</h2>
<div v-if="!byStatut(groupe).length" class="text-text-muted text-xs pl-2">—</div>
<div v-for="t in byStatut(groupe)" :key="t.id"
class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard">
<span :class="{
'text-red': t.priorite === 'haute',
'text-yellow': t.priorite === 'normale',
'text-text-muted': t.priorite === 'basse'
}">●</span>
<span class="text-text text-sm flex-1">{{ t.titre }}</span>
<div class="flex gap-1">
<button v-if="t.statut === 'a_faire'" class="text-xs text-blue hover:underline"
@click="store.updateStatut(t.id!, 'en_cours')">→ En cours</button>
<button v-if="t.statut === 'en_cours'" class="text-xs text-green hover:underline"
@click="store.updateStatut(t.id!, 'fait')">✓ Fait</button>
<button class="text-xs text-text-muted hover:text-red ml-2" @click="store.remove(t.id!)">✕</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useTasksStore } from '@/stores/tasks'
const store = useTasksStore()
const showForm = ref(false)
const form = reactive({ titre: '', priorite: 'normale', statut: 'a_faire', echeance: '' })
const groupes: [string, string][] = [
['a_faire', 'À faire'],
['en_cours', 'En cours'],
['fait', 'Terminé'],
]
const byStatut = (s: string) => store.tasks.filter(t => t.statut === s)
onMounted(() => store.fetchAll())
async function submit() {
await store.create({ ...form })
showForm.value = false
Object.assign(form, { titre: '', priorite: 'normale', statut: 'a_faire', echeance: '' })
}
</script>
Step 6: Créer frontend/src/views/PlantationsView.vue
<template>
<div class="p-4 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-green mb-6">Plantations</h1>
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
<div v-for="p in store.plantings" :key="p.id"
class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard">
<div class="flex items-start gap-3">
<div class="flex-1">
<div class="text-text font-medium">Plantation #{{ p.id }}</div>
<div class="text-text-muted text-xs mt-1">
Jardin {{ p.garden_id }} · Variété {{ p.variety_id }} · {{ p.quantite }} plant(s)
</div>
<span class="inline-block mt-2 text-xs px-2 py-0.5 rounded"
:class="{
'bg-blue/20 text-blue': p.statut === 'prevu',
'bg-green/20 text-green': p.statut === 'en_cours',
'bg-text-muted/20 text-text-muted': p.statut === 'termine',
'bg-red/20 text-red': p.statut === 'echoue',
}">{{ p.statut }}</span>
</div>
<button class="text-text-muted hover:text-red text-sm" @click="store.remove(p.id!)">✕</button>
</div>
</div>
<div v-if="!store.loading && !store.plantings.length" class="text-text-muted text-sm text-center py-8">
Aucune plantation enregistrée.
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { usePlantingsStore } from '@/stores/plantings'
const store = usePlantingsStore()
onMounted(() => store.fetchAll())
</script>
Step 7: Créer les vues squelettes (PlanningView, LunaireView, ReglagesView)
Pour chacune, même template minimal :
<!-- PlanningView.vue -->
<template>
<div class="p-4 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-green mb-4">Planning</h1>
<p class="text-text-muted text-sm">Vue calendrier — à venir dans la prochaine étape.</p>
</div>
</template>
(Même structure pour LunaireView avec titre "Calendrier lunaire" et ReglagesView avec titre "Réglages")
Step 8: Vérifier que tout tourne
cd frontend && npm run build
Attendu : build sans erreur TypeScript.
Step 9: Commit
git add frontend/src/views/
git commit -m "feat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantations"
Task 11: README + vérification finale
Files:
- Create:
README.md
Step 1: Créer README.md
# 🌿 Jardin — Application de gestion de jardins
Interface web **mobile-first** pour gérer vos jardins, cultures, tâches et calendrier lunaire.
Thème : Gruvbox Dark Seventies.
## Prérequis
- Docker + Docker Compose
## Lancement
```bash
cp .env.example .env
docker compose up --build
- Application : http://localhost
- API (docs) : http://localhost:8000/docs
Développement local
Backend :
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
DATABASE_URL=sqlite:///./data/jardin.db UPLOAD_DIR=./data/uploads uvicorn app.main:app --reload
Frontend :
cd frontend
npm install
npm run dev # http://localhost:5173
Tests
cd backend && pytest tests/ -v
Sauvegarde
La base SQLite est dans data/jardin.db. Copiez ce fichier pour sauvegarder.
**Step 2: Lancer tous les tests backend**
```bash
cd backend && pytest tests/ -v
Attendu : tous PASS, aucune erreur.
Step 3: Vérifier le build frontend
cd frontend && npm run build
Attendu : build sans erreur.
Step 4: Commit final
git add README.md
git commit -m "docs: README installation + guide développement"
Résumé des commits attendus
chore: scaffold projet + docker-composefeat(backend): setup FastAPI + SQLite + configfeat(backend): modèles SQLModel (10 tables)feat(backend): CRUD jardins + testsfeat(backend): CRUD variétés, plantations, tâches + testsfeat(backend): settings, upload media, seed données démofeat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvboxfeat(frontend): API layer + stores Piniafeat(frontend): layout header + drawer + routerfeat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantationsdocs: README installation + guide développement