Compare commits

...

36 Commits

Author SHA1 Message Date
412a06be2c chore: exclure données runtime du versionnement (db, uploads, cache) 2026-03-22 14:29:54 +01:00
8ddfe545d9 aorus 2026-03-22 14:20:07 +01:00
76d0984b06 feat(planning): vue Gantt + toggle calendrier/gantt 2026-03-22 12:51:32 +01:00
4fca4b9278 aorus 2026-03-22 12:51:31 +01:00
d9512248df Téléverser les fichiers vers "data" 2026-03-22 12:34:50 +01:00
a070b9c499 Supprimer data/jardin.db 2026-03-22 12:34:27 +01:00
a30e83a724 aorus 2026-03-22 12:17:01 +01:00
7afca6ed04 aorus 2026-03-22 11:42:57 +01:00
2043a1b8b5 maj 2026-03-09 18:26:04 +01:00
2d5e5a05a2 claude 5 2026-03-09 18:19:38 +01:00
4c279c387c fix(plantes): submitPlant — créer/modifier PlantVariety lors de la soumission du formulaire plante
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:49:05 +01:00
149d8caa06 fix(plantes): test plant_variety + seed PlantVariety + formatPlantLabel + migrate.py nettoyage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:45:52 +01:00
672ac529e7 fix(plantes): deleteVariety/submitVariety — try/catch + refresh detailPlantObj
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:38:06 +01:00
174ed9c25d feat(plantes): popup variété + bouton Variété + temp_germination/temps_levee_j
- Ajoute detailPlantObj (ref<Plant>) synchronisé dans openDetails/prevVariety/nextVariety/closeDetail
- Renomme detailVarieties (ref<Plant[]>) en detailPlantGroup pour la navigation par groupe de nom_commun
- Ajoute detailVarieties comme computed<PlantVariety[]> depuis detailPlantObj.value.varieties
- Ajoute refs/fonctions formulaire variété : showFormVariety, editVariety, formVariety, openAddVariety, openEditVariety, closeFormVariety, submitVariety, deleteVariety
- Bouton  Variété dans le footer du popup détail
- Liste des PlantVariety dans le popup détail (avec édition/suppression et alerte DLUO)
- Champs temp_germination et temps_levee_j dans la section caractéristiques
- Popup formulaire variété (z-[70]) avec tous les champs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:34:39 +01:00
05b2ddc27c feat(plantes): store plants — actions variety CRUD 2026-03-08 19:29:58 +01:00
32c7781d14 feat(plantes): API plants.ts — Plant + PlantVariety + endpoints varieties
Remplace Plant (variete/boutique/tags inline) par Plant + PlantVariety séparés.
Ajoute temp_germination, temps_levee_j, varieties[]. Ajoute CRUD variétés dans plantsApi.
Corrige PlantesView et TachesView pour lire boutique/variete via varieties?.[0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:28:19 +01:00
d4d104b2c2 fix(plantes): import_graines — idempotence plant_variety + media + import unicodedata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:08:32 +01:00
0f5ebd25be feat(plantes): script import graines + arbustre (JSON → plant_variety)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:04:56 +01:00
1b7a8b8f25 fix(db): activer PRAGMA foreign_keys=ON pour SQLite (ON DELETE CASCADE effectif)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:58:09 +01:00
1095edffdb feat(plantes): router plants — GET retourne varieties + CRUD /varieties 2026-03-08 18:54:30 +01:00
8edcf5fd8d feat(plantes): migrate.py — sections plant_variety + temp_germination/temps_levee_j 2026-03-08 18:52:48 +01:00
1d4708585e fix(plantes): script migration — try/except rollback + DB_PATH absolu + commentaires IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:51:50 +01:00
18ee6e1fbe feat(plantes): script migration one-shot plant_variety + fusion haricot grimpant 2026-03-08 17:36:11 +01:00
4a7ecffbb8 fix(plantes): PlantImage __tablename__ explicite + varieties Field(default_factory=list)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:34:55 +01:00
b41a0f817c fix(plantes): PlantWithVarieties — ajouter created_at manquant 2026-03-08 14:11:35 +01:00
de967141ba feat(plantes): modèle Plant épuré + PlantVariety + PlantWithVarieties 2026-03-08 14:10:12 +01:00
734c33a12e docs: plan implémentation plantes/variétés — 8 tâches 2026-03-08 14:06:21 +01:00
e40351e0be docs: design plantes/variétés — Option B 2 tables + import graines 2026-03-08 14:01:42 +01:00
f8e64d6a2c feat(intrants): IntratsView with Achats + Fabrications tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:24:26 +01:00
80173171b3 feat(intrants): add /intrants route + sidebar nav 2026-03-08 13:19:11 +01:00
8bf281a3fb feat(intrants): frontend API clients + Pinia stores 2026-03-08 10:10:10 +01:00
d2f2f6d7d7 feat(intrants): register achats + fabrications routers 2026-03-08 10:09:23 +01:00
107640e561 feat(intrants): CRUD + statut router for fabrications 2026-03-08 10:08:27 +01:00
a5c503e1f3 feat(intrants): CRUD router for achats 2026-03-08 10:08:14 +01:00
75f18c9eb8 feat(intrants): add migration for achat_intrant + fabrication tables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:07:28 +01:00
faa469e688 feat(intrants): add AchatIntrant + Fabrication SQLModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:05:53 +01:00
153 changed files with 7733 additions and 312 deletions

8
.gitignore vendored
View File

@@ -8,3 +8,11 @@ frontend/node_modules/
frontend/dist/ frontend/dist/
*.egg-info/ *.egg-info/
.pytest_cache/ .pytest_cache/
# Données runtime — ne pas versionner (BDD, uploads, cache météo)
data/jardin.db
data/jardin.db-shm
data/jardin.db-wal
data/meteo_cache.json
data/uploads/
data/skyfield/

View File

@@ -12,6 +12,9 @@ plante :
- [x] plante du potager, fleur, arbre ou arbuste - [x] plante du potager, fleur, arbre ou arbuste
- [x] liste de plantes courantes seedée : carotte, tomate, ail, oignon, haricot, petits pois, poireaux, pomme de terre, salade, fraise, framboise, persil, échalote, courgette, chou-fleur, chou boule, ... - [x] liste de plantes courantes seedée : carotte, tomate, ail, oignon, haricot, petits pois, poireaux, pomme de terre, salade, fraise, framboise, persil, échalote, courgette, chou-fleur, chou boule, ...
- [x] association des plantes (favorables / défavorables) : tags noms communs, validation croisée, édition depuis popup plante - [x] association des plantes (favorables / défavorables) : tags noms communs, validation croisée, édition depuis popup plante
- [ ] ajouter un bouton "ajouter varieté" a gauche de modifier ce qui affiche un popup speciifque variété ou je peut saisir les champ specifique a une varieté et/ou modifier le contenu de champs de la plante "nom commun" ne supprime pas les champs et contenu de nom commun, mais se substitue. possibilite d'inserer les capture d'image du sachet de graine ( 2 photo avant et arriere) optimisiation de la taille de l'image
- [ ] analyse le dossier doc/graine et arbustre ( json et image) et ingrer une seule foisdans la bdd les élement, attention il y aura necessité de créer de nouvelle varité en fonction du nom commun. fait une selection intelligente des champ json utile dans ma base de donnée et qui concerne les caracteristiique de la varité . il y aura certainement la nencessité de rajoter des champ. verifie que les champ date de semis, repiquage, resolte soit bien present ( date => mois a cocher), verifie ensoleillement, arrosage, conseils, t° de germination, maladies, distance de semis, temps de levée. ces champs doivent aussi apparaire dans le poptup plante " nom commun" . brainstorming general pour la gestion des plantes pour une structure de donnée coherente, evolutive et efficace
- [ ] dans la base de donnée actuelle des plantes y a t il dans plan qui peuvent etre fusionner en créeant des vatiété ( ex haricot et haricot grimpant ?) analyse et propose une modiifcation de la bdd qui créer ainsi les nouvelles varietés
taches: taches:
- [x] liste des tâches courantes au jardin pré-remplie (seed) - [x] liste des tâches courantes au jardin pré-remplie (seed)
@@ -25,6 +28,7 @@ outils:
planning: planning:
- [x] PlanningView : calendrier 4 semaines, tâches et plantations par jour - [x] PlanningView : calendrier 4 semaines, tâches et plantations par jour
- [] une vue calendrier et une vue gantt 2 bouton dans le hedader pour selectionner ou calendrier ou gantt
calendrier: calendrier:
- [x] renommer le header lunaire en calendrier (Météo + Lunaire + Dictons + navigation) - [x] renommer le header lunaire en calendrier (Météo + Lunaire + Dictons + navigation)

View File

@@ -1,9 +1,17 @@
from sqlmodel import SQLModel, create_engine, Session from sqlmodel import SQLModel, create_engine, Session
from sqlalchemy import event
from app.config import DATABASE_URL from app.config import DATABASE_URL
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:
yield session yield session

View File

@@ -63,6 +63,8 @@ from app.routers import ( # noqa
lunar, lunar,
meteo, meteo,
identify, identify,
achats,
fabrications,
) )
app.include_router(gardens.router, prefix="/api") app.include_router(gardens.router, prefix="/api")
@@ -79,6 +81,8 @@ app.include_router(recoltes.router, prefix="/api")
app.include_router(lunar.router, prefix="/api") app.include_router(lunar.router, prefix="/api")
app.include_router(meteo.router, prefix="/api") app.include_router(meteo.router, prefix="/api")
app.include_router(identify.router, prefix="/api") app.include_router(identify.router, prefix="/api")
app.include_router(achats.router, prefix="/api")
app.include_router(fabrications.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")

View File

@@ -15,6 +15,13 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("url_reference", "TEXT", None), ("url_reference", "TEXT", None),
("associations_favorables", "TEXT", None), # JSON list[str] ("associations_favorables", "TEXT", None), # JSON list[str]
("associations_defavorables", "TEXT", None), # JSON list[str] ("associations_defavorables", "TEXT", None), # JSON list[str]
("temp_germination", "TEXT", None),
("temps_levee_j", "TEXT", None),
],
"plant_variety": [
("variete", "TEXT", None),
("tags", "TEXT", None),
("notes_variete", "TEXT", None),
("boutique_nom", "TEXT", None), ("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None), ("boutique_url", "TEXT", None),
("prix_achat", "REAL", None), ("prix_achat", "REAL", None),
@@ -77,6 +84,34 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("photos", "TEXT", None), ("photos", "TEXT", None),
("videos", "TEXT", None), ("videos", "TEXT", None),
], ],
"achat_intrant": [
("categorie", "TEXT", None),
("nom", "TEXT", None),
("marque", "TEXT", None),
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
("prix", "REAL", None),
("poids", "TEXT", None),
("date_achat", "TEXT", None),
("dluo", "TEXT", None),
("notes", "TEXT", None),
("jardin_id", "INTEGER", None),
("plantation_id", "INTEGER", None),
("tache_id", "INTEGER", None),
],
"fabrication": [
("type", "TEXT", None),
("nom", "TEXT", None),
("ingredients", "TEXT", None),
("date_debut", "TEXT", None),
("date_fin_prevue", "TEXT", None),
("statut", "TEXT", "'en_cours'"),
("quantite_produite", "TEXT", None),
("notes", "TEXT", None),
("jardin_id", "INTEGER", None),
("plantation_id", "INTEGER", None),
("tache_id", "INTEGER", None),
],
} }

View File

@@ -1,5 +1,5 @@
from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa
from app.models.plant import Plant, PlantImage # noqa from app.models.plant import Plant, PlantImage, PlantVariety, PlantWithVarieties # noqa
from app.models.planting import Planting, PlantingEvent # noqa from app.models.planting import Planting, PlantingEvent # noqa
from app.models.task import Task # noqa from app.models.task import Task # noqa
from app.models.settings import UserSettings, LunarCalendarEntry # noqa from app.models.settings import UserSettings, LunarCalendarEntry # noqa
@@ -10,3 +10,4 @@ from app.models.astuce import Astuce # noqa
from app.models.recolte import Recolte, Observation # noqa from app.models.recolte import Recolte, Observation # noqa
from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa
from app.models.saint import SaintDuJour # noqa from app.models.saint import SaintDuJour # noqa
from app.models.intrant import AchatIntrant, Fabrication # noqa

View File

@@ -0,0 +1,57 @@
# backend/app/models/intrant.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 AchatIntrant(SQLModel, table=True):
__tablename__ = "achat_intrant"
id: Optional[int] = Field(default=None, primary_key=True)
categorie: str # terreau | engrais | traitement | autre
nom: str
marque: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
prix: Optional[float] = None
poids: Optional[str] = None # "20L", "1kg", "500ml"
date_achat: Optional[str] = None # ISO date
dluo: Optional[str] = None # ISO date
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Ingredient(SQLModel):
"""Modèle pour un ingrédient de fabrication (non persisté seul)."""
nom: str
quantite: str # "1kg", "10L"
class Fabrication(SQLModel, table=True):
__tablename__ = "fabrication"
id: Optional[int] = Field(default=None, primary_key=True)
type: str # compost | decoction | purin | autre
nom: str
ingredients: Optional[List[dict]] = Field(
default=None,
sa_column=Column("ingredients", SA_JSON, nullable=True),
)
date_debut: Optional[str] = None # ISO date
date_fin_prevue: Optional[str] = None # ISO date
statut: str = "en_cours" # en_cours | pret | utilise | echec
quantite_produite: Optional[str] = None # "8L", "50kg"
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class FabricationStatutUpdate(SQLModel):
statut: str

View File

@@ -1,3 +1,4 @@
# backend/app/models/plant.py
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from sqlalchemy import Column from sqlalchemy import Column
@@ -11,20 +12,20 @@ class Plant(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
nom_commun: str nom_commun: str
nom_botanique: Optional[str] = None nom_botanique: Optional[str] = None
variete: Optional[str] = None
famille: Optional[str] = None famille: Optional[str] = None
tags: Optional[str] = None # CSV type_plante: Optional[str] = None
type_plante: Optional[str] = None # legume | fruit | aromatique | fleur
categorie: Optional[str] = None # potager|fleur|arbre|arbuste categorie: Optional[str] = None # potager|fleur|arbre|arbuste
besoin_eau: Optional[str] = None # faible | moyen | fort besoin_eau: Optional[str] = None # faible | moyen | fort
besoin_soleil: Optional[str] = None besoin_soleil: Optional[str] = None
espacement_cm: Optional[int] = None espacement_cm: Optional[int] = None
hauteur_cm: Optional[int] = None hauteur_cm: Optional[int] = None
temp_min_c: Optional[float] = 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 duree_culture_j: Optional[int] = None
profondeur_semis_cm: Optional[float] = None profondeur_semis_cm: Optional[float] = None
sol_conseille: Optional[str] = None sol_conseille: Optional[str] = None
semis_interieur_mois: Optional[str] = None # ex: "2,3" semis_interieur_mois: Optional[str] = None # CSV ex: "2,3"
semis_exterieur_mois: Optional[str] = None semis_exterieur_mois: Optional[str] = None
repiquage_mois: Optional[str] = None repiquage_mois: Optional[str] = None
plantation_mois: Optional[str] = None plantation_mois: Optional[str] = None
@@ -41,17 +42,62 @@ class Plant(SQLModel, table=True):
default=None, default=None,
sa_column=Column("associations_defavorables", SA_JSON, nullable=True), sa_column=Column("associations_defavorables", SA_JSON, nullable=True),
) )
# Boutique / approvisionnement (par variété)
boutique_nom: Optional[str] = None # ex: "Gamm Vert", "Lidl", "Amazon"
boutique_url: Optional[str] = None # URL fiche produit
prix_achat: Optional[float] = None
date_achat: Optional[str] = None # ISO date
poids: Optional[str] = None # ex: "5g", "100g", "50 graines"
dluo: Optional[str] = None # date limite utilisation optimale
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 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
created_at: Optional[datetime] = None
associations_favorables: Optional[List[str]] = None
associations_defavorables: Optional[List[str]] = None
varieties: List[PlantVariety] = Field(default_factory=list)
class PlantImage(SQLModel, table=True): class PlantImage(SQLModel, table=True):
__tablename__ = "plant_image"
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
plant_id: int = Field(foreign_key="plant.id", index=True) plant_id: int = Field(foreign_key="plant.id", index=True)
filename: str filename: str

View File

@@ -0,0 +1,60 @@
# backend/app/routers/achats.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.intrant import AchatIntrant
router = APIRouter(tags=["intrants"])
@router.get("/achats", response_model=List[AchatIntrant])
def list_achats(
categorie: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(AchatIntrant)
if categorie:
q = q.where(AchatIntrant.categorie == categorie)
if jardin_id:
q = q.where(AchatIntrant.jardin_id == jardin_id)
return session.exec(q.order_by(AchatIntrant.created_at.desc())).all()
@router.post("/achats", response_model=AchatIntrant, status_code=status.HTTP_201_CREATED)
def create_achat(a: AchatIntrant, session: Session = Depends(get_session)):
session.add(a)
session.commit()
session.refresh(a)
return a
@router.get("/achats/{id}", response_model=AchatIntrant)
def get_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
return a
@router.put("/achats/{id}", response_model=AchatIntrant)
def update_achat(id: int, data: AchatIntrant, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(a, k, v)
session.add(a)
session.commit()
session.refresh(a)
return a
@router.delete("/achats/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
session.delete(a)
session.commit()

View File

@@ -0,0 +1,78 @@
# backend/app/routers/fabrications.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.intrant import Fabrication, FabricationStatutUpdate
router = APIRouter(tags=["intrants"])
@router.get("/fabrications", response_model=List[Fabrication])
def list_fabrications(
type: Optional[str] = Query(None),
statut: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(Fabrication)
if type:
q = q.where(Fabrication.type == type)
if statut:
q = q.where(Fabrication.statut == statut)
if jardin_id:
q = q.where(Fabrication.jardin_id == jardin_id)
return session.exec(q.order_by(Fabrication.created_at.desc())).all()
@router.post("/fabrications", response_model=Fabrication, status_code=status.HTTP_201_CREATED)
def create_fabrication(f: Fabrication, session: Session = Depends(get_session)):
session.add(f)
session.commit()
session.refresh(f)
return f
@router.get("/fabrications/{id}", response_model=Fabrication)
def get_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
return f
@router.put("/fabrications/{id}", response_model=Fabrication)
def update_fabrication(id: int, data: Fabrication, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(f, k, v)
session.add(f)
session.commit()
session.refresh(f)
return f
@router.patch("/fabrications/{id}/statut", response_model=Fabrication)
def update_statut(id: int, data: FabricationStatutUpdate, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
valid = {"en_cours", "pret", "utilise", "echec"}
if data.statut not in valid:
raise HTTPException(400, f"Statut invalide. Valeurs: {valid}")
f.statut = data.statut
session.add(f)
session.commit()
session.refresh(f)
return f
@router.delete("/fabrications/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
session.delete(f)
session.commit()

View File

@@ -10,6 +10,7 @@ from sqlmodel import Session, select
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
from app.database import get_session from app.database import get_session
from app.models.media import Attachment, Media from app.models.media import Attachment, Media
from app.models.settings import UserSettings
class MediaPatch(BaseModel): class MediaPatch(BaseModel):
@@ -102,6 +103,20 @@ def _canonicalize_rows(rows: List[Media], session: Session) -> None:
session.commit() session.commit()
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
def _is_heic(content_type: str, filename: str) -> bool:
if content_type.lower() in ("image/heic", "image/heif"):
return True
fn = (filename or "").lower()
return fn.endswith(".heic") or fn.endswith(".heif")
def _save_webp(data: bytes, max_px: int) -> str: def _save_webp(data: bytes, max_px: int) -> str:
try: try:
from PIL import Image from PIL import Image
@@ -122,12 +137,28 @@ def _save_webp(data: bytes, max_px: int) -> str:
@router.post("/upload") @router.post("/upload")
async def upload_file(file: UploadFile = File(...)): async def upload_file(
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
data = await file.read() data = await file.read()
ct = file.content_type or "" ct = file.content_type or ""
if ct.startswith("image/"):
name = _save_webp(data, 1200) # Lire la largeur max configurée (défaut 1200, 0 = taille originale)
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
max_px = 1200
if setting:
try:
max_px = int(setting.valeur)
except (ValueError, TypeError):
pass
if max_px <= 0:
max_px = 99999
heic = _is_heic(ct, file.filename or "")
if heic or ct.startswith("image/"):
name = _save_webp(data, max_px)
thumb = _save_webp(data, 300) thumb = _save_webp(data, 300)
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"} return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}

View File

@@ -1,40 +1,50 @@
# backend/app/routers/plants.py
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select from sqlmodel import Session, select
from app.database import get_session from app.database import get_session
from app.models.plant import Plant from app.models.plant import Plant, PlantVariety, PlantWithVarieties
router = APIRouter(tags=["plantes"]) router = APIRouter(tags=["plantes"])
@router.get("/plants", response_model=List[Plant]) 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( def list_plants(
categorie: Optional[str] = Query(None), categorie: Optional[str] = Query(None),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
q = select(Plant).order_by(Plant.nom_commun, Plant.variete, Plant.id) q = select(Plant).order_by(Plant.nom_commun, Plant.id)
if categorie: if categorie:
q = q.where(Plant.categorie == categorie) q = q.where(Plant.categorie == categorie)
return session.exec(q).all() return [_with_varieties(p, session) for p in session.exec(q).all()]
@router.post("/plants", response_model=Plant, status_code=status.HTTP_201_CREATED) @router.post("/plants", response_model=PlantWithVarieties, status_code=status.HTTP_201_CREATED)
def create_plant(p: Plant, session: Session = Depends(get_session)): def create_plant(p: Plant, session: Session = Depends(get_session)):
session.add(p) session.add(p)
session.commit() session.commit()
session.refresh(p) session.refresh(p)
return p return _with_varieties(p, session)
@router.get("/plants/{id}", response_model=Plant) @router.get("/plants/{id}", response_model=PlantWithVarieties)
def get_plant(id: int, session: Session = Depends(get_session)): def get_plant(id: int, session: Session = Depends(get_session)):
p = session.get(Plant, id) p = session.get(Plant, id)
if not p: if not p:
raise HTTPException(404, "Plante introuvable") raise HTTPException(404, "Plante introuvable")
return p return _with_varieties(p, session)
@router.put("/plants/{id}", response_model=Plant) @router.put("/plants/{id}", response_model=PlantWithVarieties)
def update_plant(id: int, data: Plant, session: Session = Depends(get_session)): def update_plant(id: int, data: Plant, session: Session = Depends(get_session)):
p = session.get(Plant, id) p = session.get(Plant, id)
if not p: if not p:
@@ -44,7 +54,7 @@ def update_plant(id: int, data: Plant, session: Session = Depends(get_session)):
session.add(p) session.add(p)
session.commit() session.commit()
session.refresh(p) session.refresh(p)
return p return _with_varieties(p, session)
@router.delete("/plants/{id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/plants/{id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -52,5 +62,49 @@ def delete_plant(id: int, session: Session = Depends(get_session)):
p = session.get(Plant, id) p = session.get(Plant, id)
if not p: if not p:
raise HTTPException(404, "Plante introuvable") 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.delete(p)
session.commit() 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()

View File

@@ -8,9 +8,10 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy import text
from sqlmodel import Session, select from sqlmodel import Session, select
from app.database import get_session from app.database import get_session
from app.models.settings import UserSettings from app.models.settings import UserSettings
@@ -235,8 +236,8 @@ def get_debug_system_stats() -> dict[str, Any]:
} }
@router.get("/settings/backup/download") def _create_backup_zip() -> tuple[Path, str]:
def download_backup_zip() -> FileResponse: """Crée l'archive ZIP de sauvegarde. Retourne (chemin_tmp, nom_fichier)."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ts = now.strftime("%Y%m%d_%H%M%S") ts = now.strftime("%Y%m%d_%H%M%S")
db_path = _resolve_sqlite_db_path() db_path = _resolve_sqlite_db_path()
@@ -247,17 +248,12 @@ def download_backup_zip() -> FileResponse:
os.close(fd) os.close(fd)
tmp_zip = Path(tmp_zip_path) tmp_zip = Path(tmp_zip_path)
stats = { stats = {"database_files": 0, "upload_files": 0, "text_files": 0}
"database_files": 0,
"upload_files": 0,
"text_files": 0,
}
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf: with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
if db_path and db_path.is_file(): if db_path and db_path.is_file():
zipf.write(db_path, arcname=f"db/{db_path.name}") zipf.write(db_path, arcname=f"db/{db_path.name}")
stats["database_files"] = 1 stats["database_files"] = 1
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads") stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir) stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
@@ -274,10 +270,245 @@ def download_backup_zip() -> FileResponse:
} }
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2)) zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
download_name = f"jardin_backup_{ts}.zip" return tmp_zip, f"jardin_backup_{ts}.zip"
@router.get("/settings/backup/download")
def download_backup_zip() -> FileResponse:
tmp_zip, download_name = _create_backup_zip()
return FileResponse( return FileResponse(
path=str(tmp_zip), path=str(tmp_zip),
media_type="application/zip", media_type="application/zip",
filename=download_name, filename=download_name,
background=BackgroundTask(_safe_remove, str(tmp_zip)), background=BackgroundTask(_safe_remove, str(tmp_zip)),
) )
def _merge_db_add_only(backup_db_path: Path, current_db_path: Path) -> dict[str, int]:
"""Insère dans la BDD courante les lignes absentes de la BDD de sauvegarde (INSERT OR IGNORE)."""
import sqlite3
stats = {"rows_added": 0, "rows_skipped": 0}
backup_conn = sqlite3.connect(str(backup_db_path))
current_conn = sqlite3.connect(str(current_db_path))
current_conn.execute("PRAGMA foreign_keys=OFF")
try:
tables = backup_conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
for (table,) in tables:
try:
cur = backup_conn.execute(f'SELECT * FROM "{table}"')
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
if not rows:
continue
col_names = ", ".join(f'"{c}"' for c in cols)
placeholders = ", ".join(["?"] * len(cols))
before = current_conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
current_conn.executemany(
f'INSERT OR IGNORE INTO "{table}" ({col_names}) VALUES ({placeholders})',
rows,
)
after = current_conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
added = after - before
stats["rows_added"] += added
stats["rows_skipped"] += len(rows) - added
except Exception:
pass
current_conn.commit()
finally:
backup_conn.close()
current_conn.close()
return stats
@router.post("/settings/backup/restore")
async def restore_backup(
file: UploadFile = File(...),
overwrite: bool = Form(default=True),
) -> dict[str, Any]:
"""Restaure une sauvegarde ZIP (DB + uploads). overwrite=true écrase, false ajoute uniquement."""
import shutil
db_path = _resolve_sqlite_db_path()
uploads_dir = Path(UPLOAD_DIR).resolve()
data = await file.read()
if len(data) < 4 or data[:2] != b'PK':
raise HTTPException(400, "Le fichier n'est pas une archive ZIP valide.")
fd, tmp_zip_path = tempfile.mkstemp(suffix=".zip")
os.close(fd)
tmp_zip = Path(tmp_zip_path)
tmp_extract = Path(tempfile.mkdtemp(prefix="jardin_restore_"))
try:
tmp_zip.write_bytes(data)
with zipfile.ZipFile(tmp_zip, "r") as zipf:
zipf.extractall(str(tmp_extract))
stats: dict[str, Any] = {
"uploads_copies": 0,
"uploads_ignores": 0,
"db_restauree": False,
"db_lignes_ajoutees": 0,
"erreurs": 0,
}
# --- Uploads ---
backup_uploads = tmp_extract / "uploads"
if backup_uploads.is_dir():
uploads_dir.mkdir(parents=True, exist_ok=True)
for src in backup_uploads.rglob("*"):
if not src.is_file():
continue
dst = uploads_dir / src.relative_to(backup_uploads)
dst.parent.mkdir(parents=True, exist_ok=True)
if overwrite or not dst.exists():
try:
shutil.copy2(str(src), str(dst))
stats["uploads_copies"] += 1
except Exception:
stats["erreurs"] += 1
else:
stats["uploads_ignores"] += 1
# --- Base de données ---
backup_db_dir = tmp_extract / "db"
db_files = sorted(backup_db_dir.glob("*.db")) if backup_db_dir.is_dir() else []
if db_files and db_path:
backup_db_file = db_files[0]
if overwrite:
from app.database import engine
try:
with engine.connect() as conn:
conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
except Exception:
pass
engine.dispose()
shutil.copy2(str(backup_db_file), str(db_path))
stats["db_restauree"] = True
else:
merge = _merge_db_add_only(backup_db_file, db_path)
stats["db_lignes_ajoutees"] = merge["rows_added"]
stats["db_restauree"] = True
return {"ok": True, **stats}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(500, f"Erreur lors de la restauration : {exc}") from exc
finally:
_safe_remove(str(tmp_zip))
shutil.rmtree(str(tmp_extract), ignore_errors=True)
@router.post("/settings/images/resize-all")
def resize_all_images(session: Session = Depends(get_session)) -> dict[str, Any]:
"""Redimensionne les images pleine taille de la bibliothèque dont la largeur dépasse le paramètre configuré."""
from PIL import Image
import io as _io
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
max_px = 1200
if setting:
try:
max_px = int(setting.valeur)
except (ValueError, TypeError):
pass
if max_px <= 0:
return {"ok": True, "redimensionnees": 0, "ignorees": 0, "erreurs": 0,
"message": "Taille originale configurée — aucune modification."}
from app.models.media import Media as MediaModel
urls = session.exec(select(MediaModel.url)).all()
uploads_dir = Path(UPLOAD_DIR).resolve()
redimensionnees = 0
ignorees = 0
erreurs = 0
for url in urls:
if not url:
continue
# /uploads/filename.webp → data/uploads/filename.webp
filename = url.lstrip("/").removeprefix("uploads/")
file_path = uploads_dir / filename
if not file_path.is_file():
ignorees += 1
continue
try:
with Image.open(file_path) as img:
w, h = img.size
if w <= max_px and h <= max_px:
ignorees += 1
continue
img_copy = img.copy()
img_copy.thumbnail((max_px, max_px), Image.LANCZOS)
img_copy.save(file_path, "WEBP", quality=85)
redimensionnees += 1
except Exception:
erreurs += 1
return {"ok": True, "redimensionnees": redimensionnees, "ignorees": ignorees, "erreurs": erreurs}
@router.post("/settings/backup/samba")
def backup_to_samba(session: Session = Depends(get_session)) -> dict[str, Any]:
"""Envoie une sauvegarde ZIP vers un partage Samba/CIFS."""
def _get(key: str, default: str = "") -> str:
row = session.exec(select(UserSettings).where(UserSettings.cle == key)).first()
return row.valeur if row else default
server = _get("samba_serveur").strip()
share = _get("samba_partage").strip()
username = _get("samba_utilisateur").strip()
password = _get("samba_motdepasse")
subfolder = _get("samba_sous_dossier").strip().strip("/\\")
if not server or not share:
raise HTTPException(400, "Configuration Samba incomplète : serveur et partage requis.")
try:
import smbclient # type: ignore
except ImportError:
raise HTTPException(500, "Module smbprotocol non installé dans l'environnement.")
tmp_zip, filename = _create_backup_zip()
try:
smbclient.register_session(server, username=username or None, password=password or None)
remote_dir = f"\\\\{server}\\{share}"
if subfolder:
remote_dir = f"{remote_dir}\\{subfolder}"
try:
smbclient.makedirs(remote_dir, exist_ok=True)
except Exception:
pass
remote_path = f"{remote_dir}\\{filename}"
with open(tmp_zip, "rb") as local_f:
data = local_f.read()
with smbclient.open_file(remote_path, mode="wb") as smb_f:
smb_f.write(data)
return {"ok": True, "fichier": filename, "chemin": remote_path}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(500, f"Erreur Samba : {exc}") from exc
finally:
_safe_remove(str(tmp_zip))

View File

@@ -6,7 +6,7 @@ import app.models # noqa
def run_seed(): def run_seed():
from app.models.garden import Garden, GardenCell, Measurement from app.models.garden import Garden, GardenCell, Measurement
from app.models.plant import Plant from app.models.plant import Plant, PlantVariety
from app.models.planting import Planting, PlantingEvent from app.models.planting import Planting, PlantingEvent
from app.models.task import Task from app.models.task import Task
from app.models.tool import Tool from app.models.tool import Tool
@@ -131,11 +131,24 @@ def run_seed():
plantes = [] plantes = []
for data in plantes_data: for data in plantes_data:
variete = data.pop('variete', None)
p = Plant(**data) p = Plant(**data)
session.add(p) session.add(p)
plantes.append(p) plantes.append(p)
session.flush() session.flush()
# Créer les variétés pour les plantes qui en avaient une
plantes_varietes = [
("Andine Cornue", 0), # Tomate
("Verte", 1), # Courgette
("Batavia", 3), # Laitue
("Nain", 6), # Haricot
("Mange-tout", 7), # Pois
("Milan", 15), # Chou
]
for variete_nom, idx in plantes_varietes:
session.add(PlantVariety(plant_id=plantes[idx].id, variete=variete_nom))
tomate = plantes[0] tomate = plantes[0]
courgette = plantes[1] courgette = plantes[1]

0
backend/data/jardin.db Normal file
View File

Binary file not shown.

View File

@@ -6,6 +6,8 @@ aiofiles==24.1.0
pytest==8.3.3 pytest==8.3.3
httpx==0.28.0 httpx==0.28.0
Pillow==11.1.0 Pillow==11.1.0
pillow-heif==0.21.0
smbprotocol==1.15.0
skyfield==1.49 skyfield==1.49
pytz==2025.1 pytz==2025.1
numpy==2.2.3 numpy==2.2.3

View File

View File

@@ -0,0 +1,300 @@
#!/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
import unicodedata
import uuid
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
DB_PATH = ROOT / "data" / "jardin.db"
UPLOADS_DIR = ROOT / "data" / "uploads"
GRAINE_DIR = ROOT / "docs" / "graine"
ARBUSTRE_DIR = ROOT / "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", "Echalote"),
("courge", "Courge"),
("chou pomme", "Chou"),
("chou-fleur", "Chou-fleur"),
]
def roman_to_csv(s: str) -> str:
if not s:
return ""
s = s.strip()
# Handle "(selon sachet)" or other parenthetical notes
if "(" in s:
s = s.split("(")[0].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:
if not s:
return None
try:
# Handle "2-3 cm" → take first number
first = s.split()[0].split("-")[0].replace(",", ".")
return float(first)
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" WARNING image absente: {src}")
return
# Vérifier si cette image existe déjà dans media pour cette variété
existing_m = conn.execute(
"SELECT id FROM media WHERE entity_type = 'plant_variety' AND entity_id = ? AND url LIKE ?",
(variety_id, f"%{src.stem}%")
).fetchone()
if existing_m:
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# Use UUID-based filename like the rest of the app
dest_name = f"{uuid.uuid4()}.jpg"
shutil.copy2(src, UPLOADS_DIR / dest_name)
url = f"/uploads/{dest_name}"
conn.execute("""
INSERT INTO media (entity_type, entity_id, url, created_at)
VALUES ('plant_variety', ?, ?, ?)
""", (variety_id, url, datetime.now(timezone.utc).isoformat()))
def normalize(s: str) -> str:
"""Normalise string: minuscules, supprime accents simples."""
return ''.join(c for c in unicodedata.normalize('NFD', s.lower()) if unicodedata.category(c) != 'Mn')
def resolve_nom(full_name: str) -> tuple[str, str]:
"""Retourne (nom_commun, variete) depuis le nom complet du sachet."""
norm = normalize(full_name)
for key, val in NOM_MAP:
norm_key = normalize(key)
if norm.startswith(norm_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"WARNING fichier absent: {path}")
return
data = json.loads(path.read_text(encoding="utf-8"))
key = "plantes" if "plantes" in data else list(data.keys())[0]
entries = data[key]
for entry in entries:
full_name = entry.get("plante", "")
if not full_name:
continue
nom_commun, variete_name = resolve_nom(full_name)
carac = entry.get("caracteristiques_plantation", {})
detail = entry.get("detail", {})
texte = detail.get("texte_integral_visible", {}) if isinstance(detail, dict) else {}
plant_id = find_or_create_plant(conn, nom_commun)
# Enrichir plant (ne pas écraser si déjà rempli)
updates: dict = {}
semis = roman_to_csv(carac.get("periode_semis", ""))
recolte = roman_to_csv(carac.get("periode_recolte", ""))
profondeur = extract_float(carac.get("profondeur") or "")
espacement_raw = carac.get("espacement") or ""
espacement = extract_float(espacement_raw)
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 isinstance(texte, dict) and texte.get("arriere"):
updates["astuces_culture"] = texte["arriere"][:1000]
elif isinstance(texte, str) and texte:
updates["astuces_culture"] = texte[: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),
)
# Vérifier si cette variété existe déjà pour cette plante
existing_v = conn.execute(
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
(plant_id, variete_name)
).fetchone()
if existing_v:
print(f"{nom_commun}{variete_name} (déjà importé)")
continue
# 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" OK {nom_commun} - {variete_name}")
def import_arbustre(conn: sqlite3.Connection) -> None:
path = ARBUSTRE_DIR / "caracteristiques_arbustre.json"
if not path.exists():
print(f"WARNING fichier absent: {path}")
return
data = json.loads(path.read_text(encoding="utf-8"))
key = "plantes" if "plantes" in data else list(data.keys())[0]
entries = data[key]
for entry in entries:
full_name = entry.get("plante", "")
if not full_name:
continue
nom_latin = entry.get("nom_latin", "") or ""
# Determine nom_commun from the plante field
if "Vitis" in full_name or "Vitis" in nom_latin:
nom_commun = "Vigne"
elif "Ribes nigrum" in full_name or "Ribes nigrum" in nom_latin:
nom_commun = "Cassissier"
elif "Rubus idaeus" in full_name or "Rubus idaeus" in nom_latin:
nom_commun = "Framboisier"
elif "'" in full_name:
nom_commun = full_name.split("'")[0].strip().title()
elif nom_latin:
parts = nom_latin.split()
nom_commun = (parts[0] + " " + parts[1]).title() if len(parts) > 1 else nom_latin.title()
else:
nom_commun = full_name.split()[0].title()
# variete_name: content inside quotes
if "'" in full_name:
variete_name = full_name.split("'")[1].strip()
else:
variete_name = full_name
plant_id = find_or_create_plant(conn, nom_commun, "arbuste")
carac = entry.get("caracteristiques_plantation", {})
arrosage = carac.get("arrosage")
exposition = carac.get("exposition")
if arrosage:
conn.execute("UPDATE plant SET besoin_eau = ? WHERE id = ?", (arrosage, plant_id))
if exposition:
conn.execute("UPDATE plant SET besoin_soleil = ? WHERE id = ?", (exposition, plant_id))
# Vérifier si cette variété existe déjà pour cette plante
existing_v = conn.execute(
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
(plant_id, variete_name)
).fetchone()
if existing_v:
print(f"{nom_commun}{variete_name} (déjà importé)")
continue
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" OK {nom_commun} - {variete_name}")
def run() -> None:
if not DB_PATH.exists():
print(f"ERREUR : base de données introuvable : {DB_PATH}")
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
if "plant_variety" not in tables:
print("WARNING Exécutez d'abord migrate_plant_varieties.py")
return
print("=== Import graines ===")
import_graines(conn)
print("\n=== Import arbustre ===")
import_arbustre(conn)
conn.commit()
print("\nImport terminé.")
except Exception as e:
conn.rollback()
print(f"ERREUR - rollback : {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,100 @@
#!/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(__file__).resolve().parent.parent.parent / "data" / "jardin.db"
def run():
if not DB_PATH.exists():
print(f"ERREUR : base de données introuvable : {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
# 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.")
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)
# IDs stables dans le seed de production : Haricot=7, haricot grimpant=21
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()
print("\nMigration terminée avec succès.")
except Exception as e:
conn.rollback()
print(f"ERREUR — rollback effectué : {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
run()

View File

@@ -12,14 +12,30 @@ def test_list_plants(client):
assert len(r.json()) == 2 assert len(r.json()) == 2
def test_allow_same_common_name_with_different_varieties(client): def test_plant_variety_crud(client):
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Roma"}) # Créer une plante
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Andine Cornue"}) r = client.post("/api/plants", json={"nom_commun": "Tomate"})
r = client.get("/api/plants") assert r.status_code == 201
assert r.status_code == 200 plant_id = r.json()["id"]
tomates = [p for p in r.json() if p["nom_commun"] == "Tomate"]
assert len(tomates) == 2 # Créer deux variétés
assert {p.get("variete") for p in tomates} == {"Roma", "Andine Cornue"} r1 = client.post(f"/api/plants/{plant_id}/varieties", json={"variete": "Roma"})
assert r1.status_code == 201
vid1 = r1.json()["id"]
r2 = client.post(f"/api/plants/{plant_id}/varieties", json={"variete": "Andine Cornue"})
assert r2.status_code == 201
# GET /plants/{id} doit retourner les 2 variétés
r = client.get(f"/api/plants/{plant_id}")
varieties = r.json().get("varieties", [])
assert len(varieties) == 2
assert {v["variete"] for v in varieties} == {"Roma", "Andine Cornue"}
# Supprimer une variété
client.delete(f"/api/plants/{plant_id}/varieties/{vid1}")
r = client.get(f"/api/plants/{plant_id}")
assert len(r.json()["varieties"]) == 1
def test_get_plant(client): def test_get_plant(client):

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
{"cached_at": "2026-02-22T12:59:49.373422+00:00", "days": [{"date": "2026-02-22", "t_max": 14.1, "t_min": 2.1, "pluie_mm": 0, "vent_kmh": 10.8, "code": 3, "label": "Couvert", "icone": "☁️"}, {"date": "2026-02-23", "t_max": 12.0, "t_min": 4.5, "pluie_mm": 0, "vent_kmh": 16.8, "code": 3, "label": "Couvert", "icone": "☁️"}, {"date": "2026-02-24", "t_max": 14.0, "t_min": 4.1, "pluie_mm": 0, "vent_kmh": 6.4, "code": 45, "label": "Brouillard", "icone": "🌫"}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Some files were not shown because too many files have changed in this diff Show More