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

40 KiB
Raw Blame History

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

  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