Compare commits
36 Commits
14636bd58f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 412a06be2c | |||
| 8ddfe545d9 | |||
| 76d0984b06 | |||
| 4fca4b9278 | |||
| d9512248df | |||
| a070b9c499 | |||
| a30e83a724 | |||
| 7afca6ed04 | |||
| 2043a1b8b5 | |||
| 2d5e5a05a2 | |||
| 4c279c387c | |||
| 149d8caa06 | |||
| 672ac529e7 | |||
| 174ed9c25d | |||
| 05b2ddc27c | |||
| 32c7781d14 | |||
| d4d104b2c2 | |||
| 0f5ebd25be | |||
| 1b7a8b8f25 | |||
| 1095edffdb | |||
| 8edcf5fd8d | |||
| 1d4708585e | |||
| 18ee6e1fbe | |||
| 4a7ecffbb8 | |||
| b41a0f817c | |||
| de967141ba | |||
| 734c33a12e | |||
| e40351e0be | |||
| f8e64d6a2c | |||
| 80173171b3 | |||
| 8bf281a3fb | |||
| d2f2f6d7d7 | |||
| 107640e561 | |||
| a5c503e1f3 | |||
| 75f18c9eb8 | |||
| faa469e688 |
8
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
57
backend/app/models/intrant.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
60
backend/app/routers/achats.py
Normal 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()
|
||||||
78
backend/app/routers/fabrications.py
Normal 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()
|
||||||
@@ -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}"}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
0
backend/scripts/__init__.py
Normal file
300
backend/scripts/import_graines.py
Normal 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()
|
||||||
100
backend/scripts/migrate_plant_varieties.py
Normal 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()
|
||||||
@@ -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):
|
||||||
|
|||||||
BIN
data/jardin.db
@@ -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": "🌫"}]}
|
|
||||||
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB |