# 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
{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}
Vue calendrier — à venir dans la prochaine étape.