Files
jardin/docs/plans/2026-02-21-jardin-webapp-implementation.md
gilles c1fe3e2636 docs: plan d'implémentation webapp jardin (11 tâches)
Plan complet : Docker, FastAPI + SQLModel + tests TDD, Vue 3 + Tailwind Gruvbox.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:07:19 +01:00

2385 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```bash
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`**
```env
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`**
```yaml
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`**
```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`**
```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`**
```nginx
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**
```bash
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`**
```python
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`**
```python
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)
```python
def run_seed():
pass
```
**Step 5: Créer `backend/app/main.py`**
```python
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**
```bash
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`**
```python
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`**
```python
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`**
```python
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`**
```python
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`**
```python
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`**
```python
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**
```bash
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`**
```python
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`**
```python
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**
```bash
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`**
```python
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 :
```python
from fastapi import APIRouter
router = APIRouter()
```
**Step 6: Enregistrer les routers dans `backend/app/main.py`** — ajouter après le middleware :
```python
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 :
```python
from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
```
**Step 7: Lancer les tests — vérifier qu'ils passent**
```bash
cd backend && pytest tests/test_gardens.py -v
```
Attendu : tous PASS.
**Step 8: Commit**
```bash
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`**
```python
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`**
```python
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`**
```python
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**
```bash
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`**
```python
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`**
```python
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`**
```python
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**
```bash
cd backend && pytest tests/ -v
```
Attendu : tous PASS.
**Step 9: Commit**
```bash
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`**
```python
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`**
```python
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`**
```python
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**
```bash
cd backend && pytest tests/ -v
```
Attendu : tous PASS.
**Step 5: Commit**
```bash
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`**
```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`**
```typescript
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`**
```javascript
/** @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`**
```javascript
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
```
**Step 5: Créer `frontend/tsconfig.json`**
```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`**
```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`**
```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`**
```typescript
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)
```vue
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
```
**Step 10: Vérifier l'install**
```bash
cd frontend && npm install && npm run build
```
Attendu : build sans erreur, dossier `dist/` créé.
**Step 11: Commit**
```bash
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`**
```typescript
import axios from 'axios'
export default axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '',
})
```
**Step 2: Créer `frontend/src/api/gardens.ts`**
```typescript
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`**
```typescript
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`**
```typescript
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`**
```typescript
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`**
```typescript
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`**
```typescript
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`**
```typescript
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`**
```typescript
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**
```bash
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`**
```typescript
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`**
```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`**
```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`**
```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**
```bash
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`**
```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`**
```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`**
```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`**
```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`**
```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`**
```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 :
```vue
<!-- 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**
```bash
cd frontend && npm run build
```
Attendu : build sans erreur TypeScript.
**Step 9: Commit**
```bash
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`**
```markdown
# 🌿 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 :**
```bash
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 :**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
## Tests
```bash
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**
```bash
cd frontend && npm run build
```
Attendu : build sans erreur.
**Step 4: Commit final**
```bash
git add README.md
git commit -m "docs: README installation + guide développement"
```
---
## Résumé des commits attendus
1. `chore: scaffold projet + docker-compose`
2. `feat(backend): setup FastAPI + SQLite + config`
3. `feat(backend): modèles SQLModel (10 tables)`
4. `feat(backend): CRUD jardins + tests`
5. `feat(backend): CRUD variétés, plantations, tâches + tests`
6. `feat(backend): settings, upload media, seed données démo`
7. `feat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvbox`
8. `feat(frontend): API layer + stores Pinia`
9. `feat(frontend): layout header + drawer + router`
10. `feat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantations`
11. `docs: README installation + guide développement`