40 KiB
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
# 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 :
from app.models.plant import PlantVariety, PlantWithVarieties # noqa
Step 3: Vérifier l'import
cd backend && python3 -c "from app.models.plant import Plant, PlantVariety, PlantWithVarieties; print('OK')"
Expected: OK
Step 4: Commit
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
#!/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
mkdir -p backend/scripts && touch backend/scripts/__init__.py
Step 3: Exécuter depuis la racine du projet
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
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
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 :
("temp_germination", "TEXT", None),
("temps_levee_j", "TEXT", None),
Step 2: Ajouter la section "plant_variety" après "plant"
"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
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
# 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
cd backend && python3 -c "from app.routers.plants import router; print('OK')"
Expected: OK
Step 3: Test rapide API (backend lancé)
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
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
#!/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
python3 backend/scripts/import_graines.py
Expected: liste de ✓ NomCommun — Variete + Import terminé.
Step 3: Vérifier
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
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
// 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
cd frontend && npm run build 2>&1 | tail -5
Expected: ✓ built in X.XXs
Step 3: Commit
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
// 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
cd frontend && npm run build 2>&1 | tail -5
Expected: ✓ built in X.XXs
Step 3: Commit
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
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 :
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 :
// 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 :
const detailPlantObj = ref<Plant | null>(null)
Dans openDetail(plant) : assigner detailPlantObj.value = plant
c) Ajouter les refs pour le popup variété
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é
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 :
<!-- 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 :
<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 :
<!-- 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) :
<!-- ====== 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
cd frontend && npm run build 2>&1 | grep -E "error|warn|built"
Expected: ✓ built in X.XXs sans erreur TypeScript.
Step 8: Commit
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
- Lancer :
docker compose up --build - Aller sur
/plantes - Cliquer sur une plante → popup → bouton "➕ Variété" → formulaire pré-rempli
- Créer une variété "Nantaise Bio" pour Carotte → apparaît dans la liste variétés
- Vérifier que
temp_germinations'affiche si renseigné (après import graines) - Exécuter les scripts de migration et d'import, vérifier les nouvelles plantes/variétés