Files
jardin/docs/plans/2026-03-08-plantes-varietes.md

1171 lines
40 KiB
Markdown
Raw 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.
# 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<Plant[]>('/api/plants', { params: categorie ? { categorie } : {} }).then(r => r.data),
get: (id: number) => client.get<Plant>(`/api/plants/${id}`).then(r => r.data),
create: (p: Partial<Plant>) => client.post<Plant>('/api/plants', p).then(r => r.data),
update: (id: number, p: Partial<Plant>) => client.put<Plant>(`/api/plants/${id}`, p).then(r => r.data),
delete: (id: number) => client.delete(`/api/plants/${id}`),
// Variétés
createVariety: (plantId: number, v: Partial<PlantVariety>) =>
client.post<PlantVariety>(`/api/plants/${plantId}/varieties`, v).then(r => r.data),
updateVariety: (plantId: number, vid: number, v: Partial<PlantVariety>) =>
client.put<PlantVariety>(`/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<Plant[]>([])
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<Plant>) {
const created = await plantsApi.create(p)
plants.value.push(created)
return created
}
async function update(id: number, p: Partial<Plant>) {
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<PlantVariety>) {
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<PlantVariety>) {
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 `<script setup lang="ts">`, apporter ces changements :
**a) Mettre à jour l'import API** — remplacer `import type { Plant } from '@/api/plants'` par :
```typescript
import type { Plant, PlantVariety } from '@/api/plants'
```
**b) Les `detailVarieties` viennent maintenant de `plant.varieties[]`**
Remplacer la computed `detailVarieties` (qui groupait par nom_commun) par :
```typescript
// detailVarieties : charger depuis plant.varieties de la plante affichée
const detailVarieties = computed<PlantVariety[]>(() => {
if (!detailPlantObj.value) return []
return detailPlantObj.value.varieties ?? []
})
```
Et ajouter `detailPlantObj` comme ref pointant sur la plante `Plant` affichée :
```typescript
const detailPlantObj = ref<Plant | null>(null)
```
Dans `openDetail(plant)` : assigner `detailPlantObj.value = plant`
**c) Ajouter les refs pour le popup variété**
```typescript
const showFormVariety = ref(false)
const editVariety = ref<PlantVariety | null>(null)
const formVariety = reactive<Partial<PlantVariety>>({
variete: '', tags: '', notes_variete: '',
boutique_nom: '', boutique_url: '', prix_achat: undefined,
date_achat: '', poids: '', dluo: '',
})
```
**d) Ajouter les fonctions variété**
```typescript
function openAddVariety() {
if (!detailPlantObj.value) return
editVariety.value = null
Object.assign(formVariety, {
variete: '', tags: '', notes_variete: '',
boutique_nom: '', boutique_url: '', prix_achat: undefined,
date_achat: '', poids: '', dluo: '',
})
showFormVariety.value = true
}
function openEditVariety(v: PlantVariety) {
editVariety.value = v
Object.assign(formVariety, { ...v })
showFormVariety.value = true
}
function closeFormVariety() {
showFormVariety.value = false
editVariety.value = null
}
async function submitVariety() {
if (!detailPlantObj.value?.id) return
const payload = { ...formVariety, prix_achat: formVariety.prix_achat ?? undefined }
if (editVariety.value?.id) {
await plantsStore.updateVariety(detailPlantObj.value.id, editVariety.value.id, payload)
toast.success('Variété modifiée')
} else {
await plantsStore.createVariety(detailPlantObj.value.id, payload)
toast.success('Variété ajoutée')
}
closeFormVariety()
}
async function deleteVariety(vid: number) {
if (!detailPlantObj.value?.id) return
if (!confirm('Supprimer cette variété ?')) return
await plantsStore.removeVariety(detailPlantObj.value.id, vid)
toast.success('Variété supprimée')
}
```
**Step 3: Adapter le template — popup détail**
Dans la popup de détail de la plante (header du popup), ajouter le bouton **" Variété"** à gauche du bouton "Modifier" existant :
```html
<!-- Dans les boutons du footer de la popup détail -->
<button @click="openAddVariety"
class="btn-primary !bg-green !text-bg py-2 px-4 font-black uppercase text-xs flex items-center gap-1">
Variété
</button>
```
**Step 4: Ajouter l'affichage des champs temp_germination + temps_levee_j**
Dans la section "Caractéristiques culture" du popup détail, après les mois de semis, ajouter :
```html
<div v-if="detailPlantObj?.temp_germination" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">T° germination</span>
<span class="text-text text-sm">{{ detailPlantObj.temp_germination }}</span>
</div>
<div v-if="detailPlantObj?.temps_levee_j" class="bg-bg/30 p-3 rounded-xl border border-bg-soft">
<span class="text-[9px] font-black text-text-muted uppercase block mb-0.5">Temps de levée</span>
<span class="text-text text-sm">{{ detailPlantObj.temps_levee_j }}</span>
</div>
```
**Step 5: Ajouter la liste des variétés dans le popup détail**
Après la section associations, ajouter un bloc variétés :
```html
<!-- Variétés -->
<div v-if="detailVarieties.length" class="space-y-2 mt-4">
<h3 class="text-[9px] font-black text-text-muted uppercase tracking-widest">
🌿 Variétés ({{ detailVarieties.length }})
</h3>
<div class="space-y-1">
<div v-for="v in detailVarieties" :key="v.id"
class="flex items-center justify-between bg-bg/30 px-3 py-2 rounded-lg border border-bg-soft">
<div>
<span class="text-text text-sm font-bold">{{ v.variete || '(sans nom)' }}</span>
<span v-if="v.boutique_nom" class="text-text-muted text-xs ml-2">🛒 {{ v.boutique_nom }}</span>
<span v-if="v.prix_achat" class="text-yellow text-xs ml-2">{{ v.prix_achat.toFixed(2) }}€</span>
<span v-if="v.dluo && isDluoExpired(v.dluo)" class="text-red text-xs ml-1">⚠️ DLUO</span>
</div>
<div class="flex gap-1">
<button @click="openEditVariety(v)" class="text-text-muted hover:text-yellow text-xs px-2">✏️</button>
<button @click="deleteVariety(v.id!)" class="text-text-muted hover:text-red text-xs px-2"></button>
</div>
</div>
</div>
</div>
```
**Step 6: Ajouter le popup formulaire variété**
Ajouter après le popup formulaire plante (avant la fermeture `</div>` du root) :
```html
<!-- ====== POPUP FORMULAIRE VARIÉTÉ ====== -->
<div v-if="showFormVariety" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[70] flex items-center justify-center p-4" @click.self="closeFormVariety">
<div class="bg-bg-hard rounded-3xl p-6 w-full max-w-xl border border-bg-soft shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-5 border-b border-bg-soft pb-4">
<h2 class="text-text font-black text-xl uppercase">
{{ editVariety ? 'Modifier la variété' : ' Nouvelle variété' }}
</h2>
<button @click="closeFormVariety" class="text-text-muted hover:text-red text-2xl"></button>
</div>
<p class="text-text-muted text-xs mb-4 italic">
Plante : <span class="text-yellow font-bold">{{ detailPlantObj?.nom_commun }}</span>
</p>
<form @submit.prevent="submitVariety" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Nom de la variété *</label>
<input v-model="formVariety.variete" required
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none"
placeholder="Ex: Nantaise, Cornue des Andes, Moneymaker…" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Tags</label>
<input v-model="formVariety.tags"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none"
placeholder="bio, f1, ancien, résistant…" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Enseigne</label>
<select v-model="formVariety.boutique_nom"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm outline-none focus:border-yellow appearance-none">
<option value="">— Non renseigné —</option>
<option v-for="b in BOUTIQUES" :key="b" :value="b">{{ b }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Prix (€)</label>
<input v-model.number="formVariety.prix_achat" type="number" step="0.01" min="0"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Poids / Qté sachet</label>
<input v-model="formVariety.poids" placeholder="ex: 5g, 100 graines"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Date d'achat</label>
<input v-model="formVariety.date_achat" type="date"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div>
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">DLUO</label>
<input v-model="formVariety.dluo" type="date"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">URL produit</label>
<input v-model="formVariety.boutique_url" type="url" placeholder="https://…"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none" />
</div>
<div class="md:col-span-2">
<label class="text-text-muted text-[10px] font-black uppercase tracking-widest block mb-1">Notes spécifiques à cette variété</label>
<textarea v-model="formVariety.notes_variete" rows="3"
class="w-full bg-bg border border-bg-soft rounded-xl px-4 py-3 text-text text-sm focus:border-yellow outline-none resize-none" />
</div>
<div class="md:col-span-2 flex justify-between pt-4 border-t border-bg-soft">
<button type="button" @click="closeFormVariety"
class="text-text-muted hover:text-red uppercase text-xs font-bold px-6">Annuler</button>
<button type="submit"
class="btn-primary px-8 py-3 !bg-green !text-bg font-black">
{{ editVariety ? 'Sauvegarder' : 'Ajouter' }}
</button>
</div>
</form>
</div>
</div>
```
**Step 7: Vérifier le build TypeScript**
```bash
cd frontend && npm run build 2>&1 | grep -E "error|warn|built"
```
Expected: `✓ built in X.XXs` sans erreur TypeScript.
**Step 8: Commit**
```bash
git add frontend/src/views/PlantesView.vue
git commit -m "feat(plantes): popup variété + bouton Variété + temp_germination/temps_levee_j"
```
---
## Test manuel final
1. Lancer : `docker compose up --build`
2. Aller sur `/plantes`
3. Cliquer sur une plante → popup → bouton **" Variété"** → formulaire pré-rempli
4. Créer une variété "Nantaise Bio" pour Carotte → apparaît dans la liste variétés
5. Vérifier que `temp_germination` s'affiche si renseigné (après import graines)
6. Exécuter les scripts de migration et d'import, vérifier les nouvelles plantes/variétés