diff --git a/docs/plans/2026-03-08-plantes-varietes.md b/docs/plans/2026-03-08-plantes-varietes.md new file mode 100644 index 0000000..05fe1f9 --- /dev/null +++ b/docs/plans/2026-03-08-plantes-varietes.md @@ -0,0 +1,1170 @@ +# Plantes & Variétés — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Restructurer la gestion des plantes en 2 tables (`plant` + `plant_variety`), migrer les données existantes, importer les JSON graines/arbustre, et ajouter le bouton "➕ Variété" dans l'UI. + +**Architecture:** Migration SQLite one-shot (plant_variety créée, données migrées, haricot grimpant fusionné), nouveaux endpoints CRUD variétés, PlantesView mise à jour avec popup variété + photos sachet. + +**Tech Stack:** FastAPI + SQLModel + SQLite · Vue 3 + Pinia + Tailwind CSS Gruvbox Dark + +--- + +### Task 1 : Modèles SQLModel — Plant + PlantVariety + +**Files:** +- Modify: `backend/app/models/plant.py` +- Modify: `backend/app/models/__init__.py` + +**Step 1: Remplacer le contenu de `backend/app/models/plant.py`** + +```python +# backend/app/models/plant.py +from datetime import datetime, timezone +from typing import List, Optional +from sqlalchemy import Column +from sqlalchemy import JSON as SA_JSON +from sqlmodel import Field, SQLModel + + +class Plant(SQLModel, table=True): + __tablename__ = "plant" + + id: Optional[int] = Field(default=None, primary_key=True) + nom_commun: str + nom_botanique: Optional[str] = None + famille: Optional[str] = None + type_plante: Optional[str] = None + categorie: Optional[str] = None # potager|fleur|arbre|arbuste + besoin_eau: Optional[str] = None # faible | moyen | fort + besoin_soleil: Optional[str] = None + espacement_cm: Optional[int] = None + hauteur_cm: Optional[int] = None + temp_min_c: Optional[float] = None + temp_germination: Optional[str] = None # ex: "8-10°C" + temps_levee_j: Optional[str] = None # ex: "15-20 jours" + duree_culture_j: Optional[int] = None + profondeur_semis_cm: Optional[float] = None + sol_conseille: Optional[str] = None + semis_interieur_mois: Optional[str] = None # CSV ex: "2,3" + semis_exterieur_mois: Optional[str] = None + repiquage_mois: Optional[str] = None + plantation_mois: Optional[str] = None + recolte_mois: Optional[str] = None + maladies_courantes: Optional[str] = None + astuces_culture: Optional[str] = None + url_reference: Optional[str] = None + notes: Optional[str] = None + associations_favorables: Optional[List[str]] = Field( + default=None, + sa_column=Column("associations_favorables", SA_JSON, nullable=True), + ) + associations_defavorables: Optional[List[str]] = Field( + default=None, + sa_column=Column("associations_defavorables", SA_JSON, nullable=True), + ) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class PlantVariety(SQLModel, table=True): + __tablename__ = "plant_variety" + + id: Optional[int] = Field(default=None, primary_key=True) + plant_id: int = Field(foreign_key="plant.id", index=True) + variete: Optional[str] = None + tags: Optional[str] = None + notes_variete: Optional[str] = None + boutique_nom: Optional[str] = None + boutique_url: Optional[str] = None + prix_achat: Optional[float] = None + date_achat: Optional[str] = None + poids: Optional[str] = None + dluo: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class PlantWithVarieties(SQLModel): + """Schéma de réponse API : plant + ses variétés (non persisté).""" + id: Optional[int] = None + nom_commun: str + nom_botanique: Optional[str] = None + famille: Optional[str] = None + type_plante: Optional[str] = None + categorie: Optional[str] = None + besoin_eau: Optional[str] = None + besoin_soleil: Optional[str] = None + espacement_cm: Optional[int] = None + hauteur_cm: Optional[int] = None + temp_min_c: Optional[float] = None + temp_germination: Optional[str] = None + temps_levee_j: Optional[str] = None + duree_culture_j: Optional[int] = None + profondeur_semis_cm: Optional[float] = None + sol_conseille: Optional[str] = None + semis_interieur_mois: Optional[str] = None + semis_exterieur_mois: Optional[str] = None + repiquage_mois: Optional[str] = None + plantation_mois: Optional[str] = None + recolte_mois: Optional[str] = None + maladies_courantes: Optional[str] = None + astuces_culture: Optional[str] = None + url_reference: Optional[str] = None + notes: Optional[str] = None + associations_favorables: Optional[List[str]] = None + associations_defavorables: Optional[List[str]] = None + varieties: List[PlantVariety] = [] + + +class PlantImage(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + plant_id: int = Field(foreign_key="plant.id", index=True) + filename: str + caption: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +``` + +**Step 2: Ajouter dans `backend/app/models/__init__.py`** + +Lire le fichier, ajouter à la fin : +```python +from app.models.plant import PlantVariety, PlantWithVarieties # noqa +``` + +**Step 3: Vérifier l'import** + +```bash +cd backend && python3 -c "from app.models.plant import Plant, PlantVariety, PlantWithVarieties; print('OK')" +``` +Expected: `OK` + +**Step 4: Commit** + +```bash +git add backend/app/models/plant.py backend/app/models/__init__.py +git commit -m "feat(plantes): modèle Plant épuré + PlantVariety + PlantWithVarieties" +``` + +--- + +### Task 2 : Script migration BDD one-shot + +**Files:** +- Create: `backend/scripts/migrate_plant_varieties.py` + +**Step 1: Créer le script** + +```python +#!/usr/bin/env python3 +""" +Migration one-shot : crée plant_variety, migre données existantes, fusionne haricot grimpant. +À exécuter UNE SEULE FOIS depuis la racine du projet. +Usage: cd /chemin/projet && python3 backend/scripts/migrate_plant_varieties.py +""" +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path("data/jardin.db") + + +def run(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + + # 1. Créer plant_variety + conn.execute(""" + CREATE TABLE IF NOT EXISTS plant_variety ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plant_id INTEGER NOT NULL REFERENCES plant(id) ON DELETE CASCADE, + variete TEXT, + tags TEXT, + notes_variete TEXT, + boutique_nom TEXT, + boutique_url TEXT, + prix_achat REAL, + date_achat TEXT, + poids TEXT, + dluo TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + print("✓ Table plant_variety créée") + + # 2. Ajouter colonnes manquantes à plant + existing = [r[1] for r in conn.execute("PRAGMA table_info(plant)").fetchall()] + for col, typ in [("temp_germination", "TEXT"), ("temps_levee_j", "TEXT")]: + if col not in existing: + conn.execute(f"ALTER TABLE plant ADD COLUMN {col} {typ}") + print(f"✓ Colonne {col} ajoutée à plant") + + # 3. Vérifier si déjà migré + count = conn.execute("SELECT COUNT(*) FROM plant_variety").fetchone()[0] + if count > 0: + print(f"⚠️ Migration déjà effectuée ({count} variétés). Abandon.") + conn.close() + return + + # 4. Migrer chaque plante → plant_variety + plants = conn.execute( + "SELECT id, nom_commun, variete, tags, boutique_nom, boutique_url, " + "prix_achat, date_achat, poids, dluo FROM plant" + ).fetchall() + + for p in plants: + conn.execute(""" + INSERT INTO plant_variety + (plant_id, variete, tags, boutique_nom, boutique_url, + prix_achat, date_achat, poids, dluo, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + p["id"], p["variete"], p["tags"], p["boutique_nom"], p["boutique_url"], + p["prix_achat"], p["date_achat"], p["poids"], p["dluo"], + datetime.now(timezone.utc).isoformat(), + )) + print(f" → plant id={p['id']} {p['nom_commun']} : variété '{p['variete']}'") + + # 5. Fusionner haricot grimpant (id=21) sous Haricot (id=7) + hg = conn.execute("SELECT * FROM plant WHERE id = 21").fetchone() + if hg: + # Supprimer la plant_variety créée pour id=21 (on va la recréer sous id=7) + conn.execute("DELETE FROM plant_variety WHERE plant_id = 21") + # Créer variété sous Haricot (id=7) + conn.execute(""" + INSERT INTO plant_variety (plant_id, variete, notes_variete, created_at) + VALUES (7, 'Grimpant Neckarkönigin', 'Fusionné depuis haricot grimpant', ?) + """, (datetime.now(timezone.utc).isoformat(),)) + new_vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + print(f" → haricot grimpant fusionné sous Haricot (plant_variety id={new_vid})") + # Supprimer le plant haricot grimpant + conn.execute("DELETE FROM plant WHERE id = 21") + print(" → plant id=21 (haricot grimpant) supprimé") + + conn.commit() + conn.close() + print("\nMigration terminée avec succès.") + + +if __name__ == "__main__": + run() +``` + +**Step 2: Créer le dossier scripts si absent** + +```bash +mkdir -p backend/scripts && touch backend/scripts/__init__.py +``` + +**Step 3: Exécuter depuis la racine du projet** + +```bash +python3 backend/scripts/migrate_plant_varieties.py +``` +Expected: lignes `→ plant id=X ...` pour chaque plante + `Migration terminée avec succès.` + +**Step 4: Vérifier en BDD** + +```bash +python3 -c " +import sqlite3 +conn = sqlite3.connect('data/jardin.db') +n = conn.execute('SELECT COUNT(*) FROM plant_variety').fetchone()[0] +print(f'plant_variety: {n} entrées') +# Haricot doit avoir 2 variétés +rows = conn.execute('SELECT v.variete FROM plant_variety v JOIN plant p ON v.plant_id=p.id WHERE p.nom_commun=\"Haricot\"').fetchall() +print('Haricot variétés:', [r[0] for r in rows]) +conn.close() +" +``` +Expected: `plant_variety: 21 entrées` + `Haricot variétés: ['Nain', 'Grimpant Neckarkönigin']` + +**Step 5: Commit** + +```bash +git add backend/scripts/ +git commit -m "feat(plantes): script migration one-shot plant_variety + fusion haricot grimpant" +``` + +--- + +### Task 3 : Mettre à jour migrate.py + +**Files:** +- Modify: `backend/app/migrate.py` + +**Step 1: Ajouter dans la section `"plant"` de `EXPECTED_COLUMNS`** + +Après `("dluo", "TEXT", None),`, ajouter : +```python + ("temp_germination", "TEXT", None), + ("temps_levee_j", "TEXT", None), +``` + +**Step 2: Ajouter la section `"plant_variety"` après `"plant"`** + +```python + "plant_variety": [ + ("variete", "TEXT", None), + ("tags", "TEXT", None), + ("notes_variete", "TEXT", None), + ("boutique_nom", "TEXT", None), + ("boutique_url", "TEXT", None), + ("prix_achat", "REAL", None), + ("date_achat", "TEXT", None), + ("poids", "TEXT", None), + ("dluo", "TEXT", None), + ], +``` + +**Step 3: Commit** + +```bash +git add backend/app/migrate.py +git commit -m "feat(plantes): migrate.py — sections plant_variety + temp_germination" +``` + +--- + +### Task 4 : Router plants.py — GET avec varieties + CRUD variétés + +**Files:** +- Modify: `backend/app/routers/plants.py` + +**Step 1: Remplacer le contenu** + +```python +# backend/app/routers/plants.py +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import Session, select +from app.database import get_session +from app.models.plant import Plant, PlantVariety, PlantWithVarieties + +router = APIRouter(tags=["plantes"]) + + +def _with_varieties(p: Plant, session: Session) -> PlantWithVarieties: + varieties = session.exec( + select(PlantVariety).where(PlantVariety.plant_id == p.id) + ).all() + data = p.model_dump() + data["varieties"] = [v.model_dump() for v in varieties] + return PlantWithVarieties(**data) + + +@router.get("/plants", response_model=List[PlantWithVarieties]) +def list_plants( + categorie: Optional[str] = Query(None), + session: Session = Depends(get_session), +): + q = select(Plant).order_by(Plant.nom_commun, Plant.id) + if categorie: + q = q.where(Plant.categorie == categorie) + return [_with_varieties(p, session) for p in session.exec(q).all()] + + +@router.post("/plants", response_model=PlantWithVarieties, status_code=status.HTTP_201_CREATED) +def create_plant(p: Plant, session: Session = Depends(get_session)): + session.add(p) + session.commit() + session.refresh(p) + return _with_varieties(p, session) + + +@router.get("/plants/{id}", response_model=PlantWithVarieties) +def get_plant(id: int, session: Session = Depends(get_session)): + p = session.get(Plant, id) + if not p: + raise HTTPException(404, "Plante introuvable") + return _with_varieties(p, session) + + +@router.put("/plants/{id}", response_model=PlantWithVarieties) +def update_plant(id: int, data: Plant, session: Session = Depends(get_session)): + p = session.get(Plant, id) + if not p: + raise HTTPException(404, "Plante introuvable") + for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items(): + setattr(p, k, v) + session.add(p) + session.commit() + session.refresh(p) + return _with_varieties(p, session) + + +@router.delete("/plants/{id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_plant(id: int, session: Session = Depends(get_session)): + p = session.get(Plant, id) + if not p: + raise HTTPException(404, "Plante introuvable") + for v in session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all(): + session.delete(v) + session.delete(p) + session.commit() + + +# ---- CRUD Variétés ---- + +@router.get("/plants/{id}/varieties", response_model=List[PlantVariety]) +def list_varieties(id: int, session: Session = Depends(get_session)): + if not session.get(Plant, id): + raise HTTPException(404, "Plante introuvable") + return session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all() + + +@router.post("/plants/{id}/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED) +def create_variety(id: int, v: PlantVariety, session: Session = Depends(get_session)): + if not session.get(Plant, id): + raise HTTPException(404, "Plante introuvable") + v.plant_id = id + session.add(v) + session.commit() + session.refresh(v) + return v + + +@router.put("/plants/{id}/varieties/{vid}", response_model=PlantVariety) +def update_variety(id: int, vid: int, data: PlantVariety, session: Session = Depends(get_session)): + v = session.get(PlantVariety, vid) + if not v or v.plant_id != id: + raise HTTPException(404, "Variété introuvable") + for k, val in data.model_dump(exclude_unset=True, exclude={"id", "plant_id", "created_at"}).items(): + setattr(v, k, val) + session.add(v) + session.commit() + session.refresh(v) + return v + + +@router.delete("/plants/{id}/varieties/{vid}", status_code=status.HTTP_204_NO_CONTENT) +def delete_variety(id: int, vid: int, session: Session = Depends(get_session)): + v = session.get(PlantVariety, vid) + if not v or v.plant_id != id: + raise HTTPException(404, "Variété introuvable") + session.delete(v) + session.commit() +``` + +**Step 2: Vérifier l'import du router** + +```bash +cd backend && python3 -c "from app.routers.plants import router; print('OK')" +``` +Expected: `OK` + +**Step 3: Test rapide API (backend lancé)** + +```bash +cd backend && uvicorn app.main:app --reload --port 8060 & +sleep 2 +curl -s http://localhost:8060/api/plants | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d)} plants, premier: {d[0][\"nom_commun\"]} ({len(d[0][\"varieties\"])} variétés)')" +# Expected: "X plants, premier: Ail (1 variétés)" +kill %1 +``` + +**Step 4: Commit** + +```bash +git add backend/app/routers/plants.py +git commit -m "feat(plantes): router plants — GET retourne varieties + CRUD /varieties" +``` + +--- + +### Task 5 : Script import graines + arbustre + +**Files:** +- Create: `backend/scripts/import_graines.py` + +**Step 1: Créer le script** + +```python +#!/usr/bin/env python3 +""" +Import one-shot : docs/graine/caracteristiques_plantation.json + docs/arbustre/caracteristiques_arbustre.json +Usage: cd /chemin/projet && python3 backend/scripts/import_graines.py +""" +import json +import shutil +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path("data/jardin.db") +UPLOADS_DIR = Path("data/uploads") +GRAINE_DIR = Path("docs/graine") +ARBUSTRE_DIR = Path("docs/arbustre") + +ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, + "VII": 7, "VIII": 8, "IX": 9, "X": 10, "XI": 11, "XII": 12} + +# Mapping : mot-clé lowercase → nom_commun BDD +NOM_MAP = [ + ("oignon", "Oignon"), + ("laitue pommee grosse", "Laitue"), + ("laitue attraction", "Laitue"), + ("laitue", "Laitue"), + ("persil", "Persil"), + ("courgette", "Courgette"), + ("pois mangetout", "Pois"), + ("pois a ecosser", "Pois"), + ("pois", "Pois"), + ("tomate cornue", "Tomate"), + ("tomates moneymaker", "Tomate"), + ("tomate", "Tomate"), + ("poireau", "Poireau"), + ("echalion", "Échalote"), + ("courge", "Courge"), + ("chou pomme", "Chou"), + ("chou-fleur", "Chou-fleur"), +] + + +def roman_to_csv(s: str) -> str: + if not s: + return "" + s = s.strip() + parts = s.split("-") + if len(parts) == 2: + a = ROMAN.get(parts[0].strip(), 0) + b = ROMAN.get(parts[1].strip(), 0) + if a and b: + return ",".join(str(m) for m in range(a, b + 1)) + single = ROMAN.get(s, 0) + return str(single) if single else "" + + +def extract_float(s: str) -> float | None: + try: + return float(s.split()[0].replace(",", ".")) + except Exception: + return None + + +def find_or_create_plant(conn: sqlite3.Connection, nom_commun: str, categorie: str = "potager") -> int: + row = conn.execute( + "SELECT id FROM plant WHERE LOWER(nom_commun) = LOWER(?)", (nom_commun,) + ).fetchone() + if row: + return row[0] + conn.execute( + "INSERT INTO plant (nom_commun, categorie, created_at) VALUES (?, ?, ?)", + (nom_commun, categorie, datetime.now(timezone.utc).isoformat()), + ) + return conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + +def copy_image(src: Path, variety_id: int, conn: sqlite3.Connection) -> None: + if not src.exists(): + print(f" ⚠️ Image absente: {src}") + return + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + dest_name = f"pv_{variety_id}_{src.name}" + shutil.copy2(src, UPLOADS_DIR / dest_name) + conn.execute(""" + INSERT INTO media (entity_type, entity_id, filename, original_filename, mime_type, created_at) + VALUES ('plant_variety', ?, ?, ?, 'image/jpeg', ?) + """, (variety_id, dest_name, src.name, datetime.now(timezone.utc).isoformat())) + + +def resolve_nom(full_name: str) -> tuple[str, str]: + """Retourne (nom_commun, variete) depuis le nom complet du sachet.""" + lower = full_name.lower() + for key, val in NOM_MAP: + if lower.startswith(key): + variete = full_name[len(key):].strip().strip("'\"").title() + return val, variete or full_name + # Fallback : premier mot = nom_commun + parts = full_name.split() + return parts[0].title(), " ".join(parts[1:]).strip() or full_name + + +def import_graines(conn: sqlite3.Connection) -> None: + path = GRAINE_DIR / "caracteristiques_plantation.json" + if not path.exists(): + print(f"⚠️ Fichier absent: {path}") + return + data = json.loads(path.read_text(encoding="utf-8")) + + for entry in data["plantes"]: + full_name = entry["plante"] + nom_commun, variete_name = resolve_nom(full_name) + carac = entry.get("caracteristiques_plantation", {}) + detail = entry.get("detail", {}).get("texte_integral_visible", {}) + + plant_id = find_or_create_plant(conn, nom_commun) + + # Enrichir plant (ne pas écraser si déjà rempli) + updates: dict[str, object] = {} + semis = roman_to_csv(carac.get("periode_semis", "")) + recolte = roman_to_csv(carac.get("periode_recolte", "")) + profondeur = extract_float(carac.get("profondeur", "")) + espacement = extract_float(carac.get("espacement", "")) + + if semis: + updates["semis_exterieur_mois"] = semis + if recolte: + updates["recolte_mois"] = recolte + if profondeur: + updates["profondeur_semis_cm"] = profondeur + if espacement: + updates["espacement_cm"] = int(espacement) + if carac.get("exposition"): + updates["besoin_soleil"] = carac["exposition"] + if carac.get("temperature"): + updates["temp_germination"] = carac["temperature"] + if detail.get("arriere"): + updates["astuces_culture"] = detail["arriere"][:1000] + + if updates: + set_clause = ", ".join(f"{k} = ?" for k in updates) + conn.execute( + f"UPDATE plant SET {set_clause} WHERE id = ?", + (*updates.values(), plant_id), + ) + + # Créer plant_variety + conn.execute( + "INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)", + (plant_id, variete_name, datetime.now(timezone.utc).isoformat()), + ) + vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + for img in entry.get("images", []): + copy_image(GRAINE_DIR / img, vid, conn) + + print(f" ✓ {nom_commun} — {variete_name}") + + +def import_arbustre(conn: sqlite3.Connection) -> None: + path = ARBUSTRE_DIR / "caracteristiques_arbustre.json" + if not path.exists(): + print(f"⚠️ Fichier absent: {path}") + return + data = json.loads(path.read_text(encoding="utf-8")) + + for entry in data["plantes"]: + full_name = entry["plante"] + # Extraire nom commun depuis nom_latin ou nom plante + nom_latin = entry.get("nom_latin", "") + if "Vitis" in full_name: + nom_commun = "Vigne" + elif nom_latin: + nom_commun = nom_latin.split()[0].title() + " " + nom_latin.split()[1].title() if len(nom_latin.split()) > 1 else nom_latin.title() + else: + nom_commun = full_name.split("'")[0].strip().title() + + variete_name = full_name.split("'")[1].strip() if "'" in full_name else full_name + + plant_id = find_or_create_plant(conn, nom_commun, "arbuste") + + carac = entry.get("caracteristiques_plantation", {}) + if carac.get("arrosage"): + conn.execute("UPDATE plant SET besoin_eau = ? WHERE id = ?", (carac["arrosage"], plant_id)) + if carac.get("exposition"): + conn.execute("UPDATE plant SET besoin_soleil = ? WHERE id = ?", (carac["exposition"], plant_id)) + + conn.execute( + "INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)", + (plant_id, variete_name, datetime.now(timezone.utc).isoformat()), + ) + vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + for img in entry.get("images", []): + copy_image(ARBUSTRE_DIR / img, vid, conn) + + print(f" ✓ {nom_commun} — {variete_name}") + + +def run() -> None: + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + + tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()] + if "plant_variety" not in tables: + print("⚠️ Exécutez d'abord migrate_plant_varieties.py") + conn.close() + return + + # Vérifier colonne media + media_cols = [r[1] for r in conn.execute("PRAGMA table_info(media)").fetchall()] + if "original_filename" not in media_cols: + conn.execute("ALTER TABLE media ADD COLUMN original_filename TEXT") + + print("=== Import graines ===") + import_graines(conn) + print("\n=== Import arbustre ===") + import_arbustre(conn) + + conn.commit() + conn.close() + print("\nImport terminé.") + + +if __name__ == "__main__": + run() +``` + +**Step 2: Exécuter depuis la racine du projet** + +```bash +python3 backend/scripts/import_graines.py +``` +Expected: liste de `✓ NomCommun — Variete` + `Import terminé.` + +**Step 3: Vérifier** + +```bash +python3 -c " +import sqlite3 +conn = sqlite3.connect('data/jardin.db') +n = conn.execute('SELECT COUNT(*) FROM plant_variety').fetchone()[0] +np = conn.execute('SELECT COUNT(*) FROM plant').fetchone()[0] +print(f'plant: {np}, plant_variety: {n}') +conn.close() +" +``` +Expected: `plant: ~25, plant_variety: ~35+` + +**Step 4: Commit** + +```bash +git add backend/scripts/import_graines.py +git commit -m "feat(plantes): script import graines + arbustre (JSON → plant_variety)" +``` + +--- + +### Task 6 : Frontend API plants.ts + +**Files:** +- Modify: `frontend/src/api/plants.ts` + +**Step 1: Remplacer le contenu** + +```typescript +// frontend/src/api/plants.ts +import client from './client' + +export interface PlantVariety { + id?: number + plant_id?: number + variete?: string + tags?: string + notes_variete?: string + boutique_nom?: string + boutique_url?: string + prix_achat?: number + date_achat?: string + poids?: string + dluo?: string +} + +export interface Plant { + id?: number + nom_commun: string + nom_botanique?: string + famille?: string + categorie?: string // potager|fleur|arbre|arbuste + type_plante?: string + besoin_eau?: string + besoin_soleil?: string + espacement_cm?: number + temp_min_c?: number + hauteur_cm?: number + temp_germination?: string // ex: "8-10°C" + temps_levee_j?: string // ex: "15-20 jours" + plantation_mois?: string + recolte_mois?: string + semis_interieur_mois?: string + semis_exterieur_mois?: string + repiquage_mois?: string + profondeur_semis_cm?: number + duree_culture_j?: number + sol_conseille?: string + maladies_courantes?: string + astuces_culture?: string + url_reference?: string + notes?: string + associations_favorables?: string[] + associations_defavorables?: string[] + varieties?: PlantVariety[] +} + +export const plantsApi = { + list: (categorie?: string) => + client.get('/api/plants', { params: categorie ? { categorie } : {} }).then(r => r.data), + get: (id: number) => client.get(`/api/plants/${id}`).then(r => r.data), + create: (p: Partial) => client.post('/api/plants', p).then(r => r.data), + update: (id: number, p: Partial) => client.put(`/api/plants/${id}`, p).then(r => r.data), + delete: (id: number) => client.delete(`/api/plants/${id}`), + // Variétés + createVariety: (plantId: number, v: Partial) => + client.post(`/api/plants/${plantId}/varieties`, v).then(r => r.data), + updateVariety: (plantId: number, vid: number, v: Partial) => + client.put(`/api/plants/${plantId}/varieties/${vid}`, v).then(r => r.data), + deleteVariety: (plantId: number, vid: number) => + client.delete(`/api/plants/${plantId}/varieties/${vid}`), +} +``` + +**Step 2: Vérifier build TypeScript** + +```bash +cd frontend && npm run build 2>&1 | tail -5 +``` +Expected: `✓ built in X.XXs` + +**Step 3: Commit** + +```bash +git add frontend/src/api/plants.ts +git commit -m "feat(plantes): API plants.ts — Plant + PlantVariety + endpoints varieties" +``` + +--- + +### Task 7 : Store Pinia plants.ts + +**Files:** +- Modify: `frontend/src/stores/plants.ts` + +**Step 1: Remplacer le contenu** + +```typescript +// frontend/src/stores/plants.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { plantsApi, type Plant, type PlantVariety } from '@/api/plants' + +export const usePlantsStore = defineStore('plants', () => { + const plants = ref([]) + const loading = ref(false) + + async function fetchAll(categorie?: string) { + loading.value = true + try { plants.value = await plantsApi.list(categorie) } + finally { loading.value = false } + } + + async function create(p: Partial) { + const created = await plantsApi.create(p) + plants.value.push(created) + return created + } + + async function update(id: number, p: Partial) { + const updated = await plantsApi.update(id, p) + const idx = plants.value.findIndex(x => x.id === id) + if (idx !== -1) plants.value[idx] = updated + return updated + } + + async function remove(id: number) { + await plantsApi.delete(id) + plants.value = plants.value.filter(p => p.id !== id) + } + + async function createVariety(plantId: number, v: Partial) { + const created = await plantsApi.createVariety(plantId, v) + const plant = plants.value.find(p => p.id === plantId) + if (plant) { + if (!plant.varieties) plant.varieties = [] + plant.varieties.push(created) + } + return created + } + + async function updateVariety(plantId: number, vid: number, v: Partial) { + const updated = await plantsApi.updateVariety(plantId, vid, v) + const plant = plants.value.find(p => p.id === plantId) + if (plant?.varieties) { + const idx = plant.varieties.findIndex(x => x.id === vid) + if (idx !== -1) plant.varieties[idx] = updated + } + return updated + } + + async function removeVariety(plantId: number, vid: number) { + await plantsApi.deleteVariety(plantId, vid) + const plant = plants.value.find(p => p.id === plantId) + if (plant?.varieties) { + plant.varieties = plant.varieties.filter(v => v.id !== vid) + } + } + + return { plants, loading, fetchAll, create, update, remove, createVariety, updateVariety, removeVariety } +}) +``` + +**Step 2: Vérifier build** + +```bash +cd frontend && npm run build 2>&1 | tail -5 +``` +Expected: `✓ built in X.XXs` + +**Step 3: Commit** + +```bash +git add frontend/src/stores/plants.ts +git commit -m "feat(plantes): store plants — actions variety CRUD" +``` + +--- + +### Task 8 : PlantesView.vue — bouton variété + popup + nouveaux champs + +**Files:** +- Modify: `frontend/src/views/PlantesView.vue` + +Cette tâche modifie `PlantesView.vue` en plusieurs points précis. Lire le fichier entier d'abord. + +**Step 1: Lire le fichier** + +```bash +wc -l frontend/src/views/PlantesView.vue +``` + +**Step 2: Adapter le script setup — imports et refs** + +Dans le `