Compare commits
44 Commits
20af00d653
...
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 | |||
| 14636bd58f | |||
| 7967f63fea | |||
| 9db5cbf236 | |||
| fb33540bb0 | |||
| 155de270dc | |||
| 0d3bf205b1 | |||
| a9f0556d73 | |||
| 55387f4b0e |
@@ -65,7 +65,19 @@
|
|||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(.venv/bin/python:*)",
|
"Bash(.venv/bin/python:*)",
|
||||||
"Bash(.venv/bin/pip install:*)",
|
"Bash(.venv/bin/pip install:*)",
|
||||||
"Bash(grep:*)"
|
"Bash(grep:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(DB=/home/gilles/Documents/vscode/jardin/data/jardin.db)",
|
||||||
|
"Bash(__NEW_LINE_c71d992d355b0a42__ echo \"=== Comptages ===\")",
|
||||||
|
"Bash(__NEW_LINE_c71d992d355b0a42__ echo \"\")",
|
||||||
|
"Bash(__NEW_LINE_bc37df477a517ffd__ echo \"\")",
|
||||||
|
"Bash(__NEW_LINE_cef0a7fc7759860e__ echo \"\")",
|
||||||
|
"Bash(docker compose restart:*)",
|
||||||
|
"Bash(docker compose build:*)",
|
||||||
|
"Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")",
|
||||||
|
"Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx vite:*)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/home/gilles/Documents/vscode/jardin/frontend/src",
|
"/home/gilles/Documents/vscode/jardin/frontend/src",
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ REDIS_URL=redis://redis:6379
|
|||||||
STATION_URL=http://10.0.0.8:8081/
|
STATION_URL=http://10.0.0.8:8081/
|
||||||
METEO_LAT=45.14
|
METEO_LAT=45.14
|
||||||
METEO_LON=4.12
|
METEO_LON=4.12
|
||||||
|
ENABLE_SCHEDULER=1
|
||||||
|
ENABLE_BOOTSTRAP=1
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 🌿 Jardin — Application de gestion de jardins
|
||||||
|
|
||||||
|
Interface web **mobile-first** pour gérer jardins, cultures, tâches et calendrier lunaire, avec détection d'espèces via IA.
|
||||||
|
Thème visuel : **Gruvbox Dark Seventies**.
|
||||||
|
|
||||||
|
## 🏗️ Architecture du Projet
|
||||||
|
|
||||||
|
Le projet est composé de trois services principaux orchestrés par Docker Compose :
|
||||||
|
|
||||||
|
1. **Backend (FastAPI)** : API REST gérant la logique métier, la base de données (SQLite/SQLModel) et l'intégration des services (lunaire, météo).
|
||||||
|
2. **Frontend (Vue 3)** : Interface utilisateur réactive avec Vite, Pinia pour le store, et Tailwind CSS pour le style.
|
||||||
|
3. **AI Service (FastAPI + YOLO)** : Service spécialisé dans la détection et classification de plantes via un modèle YOLOv8 (`ultralytics`).
|
||||||
|
4. **Redis** : Utilisé pour le cache et les tâches planifiées.
|
||||||
|
|
||||||
|
## 🚀 Démarrage Rapide
|
||||||
|
|
||||||
|
### Avec Docker (Recommandé)
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
- **Application** : [http://localhost:8061](http://localhost:8061)
|
||||||
|
- **API Documentation (Swagger)** : [http://localhost:8060/docs](http://localhost:8060/docs)
|
||||||
|
|
||||||
|
### Développement Local
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# Variables d'env par défaut pour le dev local
|
||||||
|
export DATABASE_URL=sqlite:///./data/jardin.db
|
||||||
|
export UPLOAD_DIR=./data/uploads
|
||||||
|
uvicorn app.main:app --reload --port 8060
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev -- --port 8061
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AI Service
|
||||||
|
```bash
|
||||||
|
cd ai-service
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --port 8070
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Commandes de Test et Qualité
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run lint # Vérification TypeScript (vue-tsc)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Conventions de Développement
|
||||||
|
|
||||||
|
- **Langue** : Code en anglais (variables, fonctions, routes), commentaires et documentation en français.
|
||||||
|
- **Style Backend** : PEP 8. Utilisation de `SQLModel` pour les modèles de données (union de SQLAlchemy et Pydantic).
|
||||||
|
- **Style Frontend** : Composition API (Vue 3). Utilisation de TypeScript obligatoire. Tailwind CSS pour le styling atomique.
|
||||||
|
- **API** : Préfixe `/api` pour tous les endpoints. Documentation automatique via Swagger.
|
||||||
|
- **Base de données** : SQLite par défaut pour la simplicité et la portabilité (située dans `data/jardin.db`).
|
||||||
|
- **Media** : Les images uploadées sont stockées dans `data/uploads/` et servies via `/uploads`.
|
||||||
|
|
||||||
|
## 📂 Structure des Données (Modèles SQLModel)
|
||||||
|
|
||||||
|
- `Garden` : Jardins (nom, dimensions, exposition, géolocalisation).
|
||||||
|
- `Plant` : Bibliothèque de plantes (nom, famille, exigences).
|
||||||
|
- `Variety` : Variétés spécifiques de plantes.
|
||||||
|
- `Planting` : Instances de plantation dans un jardin (date, état, position).
|
||||||
|
- `Task` : Tâches à accomplir (arrosage, taille, etc.).
|
||||||
|
- `Settings` : Paramètres utilisateur (lat/long pour météo/lune).
|
||||||
|
- `Meteo` : Données météo locales et prévisions.
|
||||||
|
- `Lunar` : Calculs de phases et conseils lunaires.
|
||||||
|
|
||||||
|
## 🤖 Service IA
|
||||||
|
|
||||||
|
Le service de détection utilise le modèle `foduucom/plant-leaf-detection-and-classification` via YOLOv8.
|
||||||
|
Endpoint : `POST /detect` acceptant une image.
|
||||||
|
Il est intégré au backend via le router `identify`.
|
||||||
@@ -99,7 +99,10 @@ cp data/jardin.db data/jardin_backup_$(date +%Y%m%d).db
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Documentation interactive disponible sur http://localhost:8000/docs (Swagger UI).
|
Documentation interactive disponible sur http://localhost:8060/docs (Swagger UI).
|
||||||
|
|
||||||
|
Guide reseau local / VM / OpenClaw:
|
||||||
|
- `docs/api_utilisation_reseau_local_openclaw.md`
|
||||||
|
|
||||||
Endpoints principaux :
|
Endpoints principaux :
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,89 @@
|
|||||||
- [ ] photo possibilité d'ajouter des photos, upload ( prevoir mecanisme : transformer en webp et redimensionner)
|
- [x] photo : upload + conversion WebP + thumbnail automatique
|
||||||
- couleur predominante : plantes: vert; jardin : marron; arrosage : bleu; outils: jaune
|
- couleur predominante : plantes: vert; jardin : marron; arrosage : bleu; outils: jaune
|
||||||
- ajout icones representatives
|
- [ ] ajout icones representatives dimensionnables
|
||||||
|
|
||||||
jardin :
|
jardin :
|
||||||
- [ ] ajouter les caracteristiques pour un jardin: photo, geolocalisation, type de terre, ph, ensoleillement, exposition, dimension,surface, ...
|
- [x] ajouter les caracteristiques pour un jardin: photo, geolocalisation, type de terre, ph, ensoleillement, exposition, dimension, surface, ...
|
||||||
-
|
- [x] dans l'edition du jardin definir si carré potager avec dimension x;y en cm
|
||||||
|
|
||||||
plante :
|
plante :
|
||||||
- [ ] header : varietés => remplacer par plante ( pareil dans tous le programme)
|
- [x] header : varietés => remplacé par plante (partout dans le programme)
|
||||||
- [ ] pour une plante, ajouter des caracteristiques : photo, nom, varités, famille, resistance au froid , maladie commune et astuces , methode de semis et de plantation, ... (brainstorming)
|
- [x] pour une plante, ajouter des caracteristiques : photo, nom, variétés, famille, résistance au froid, maladie commune et astuces, méthode de semis et de plantation, ...
|
||||||
- [ ] plante du potager, fleur, arbre ou arbustre
|
- [x] plante du potager, fleur, arbre ou arbuste
|
||||||
- [ ] brainstorming pour ajouter une liste de plantes courante du jardin: carotte, tomate, ail, oignon, haricot, petis pois, poireaux, pomme de terre, salade, fraise, framboise, persil, echalote,courgette, choux fleur, choux 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, ...
|
||||||
- [ ] association des plantes et plantes ne devant pas etre planté a proximite
|
- [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:
|
||||||
- [ ] brainstorming pour preremplir la liste des taches courantes au jardin
|
- [x] liste des tâches courantes au jardin pré-remplie (seed)
|
||||||
- [ ] un tache peut etre unique ou avoir une frequence
|
- [x] une tâche peut être unique ou avoir une fréquence (frequence_jours + date_prochaine)
|
||||||
- [ ] une tache peut utiliser un outils et s'applique a une platantion ( plantation: plantes dans une zone d'un jardin)
|
- [x] une tâche peut utiliser un outil et s'applique à une plantation
|
||||||
|
|
||||||
outils:
|
outils:
|
||||||
- [ ] brainstorming pour ajouter des outils de jardinage
|
- [x] outils de jardinage : CRUD complet, catégories
|
||||||
- [ ] liste dans le header
|
- [x] liste dans le header (OutilsView)
|
||||||
- [ ] créer une 1ere liste d'outils commun du jardin (grelinete, pelle, beche, pioche, sarcloir,....)
|
- [x] 1ère liste d'outils communs seedée (grelinette, pelle, bêche, pioche, sarcloir, ...)
|
||||||
|
|
||||||
planning:
|
planning:
|
||||||
- [ ] brainstorming
|
- [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:
|
||||||
- [ ] renommer le header lunaire en calendrier ( lunaire, dictons, meteo, taches, )
|
- [x] renommer le header lunaire en calendrier (Météo + Lunaire + Dictons + navigation)
|
||||||
- [ ] brainstorming
|
- [x] calendrier lunaire avec icônes et texte (phases + types jours : racine/feuille/fleur/fruit)
|
||||||
- [ ] calendrier lunaire avec icones et texte
|
- [x] dictons courants (région France, Auvergne, Haute-Loire, Yssingeaux)
|
||||||
- [ ] calendrier ajouter dictons courant ( brainstorming region france, auvergne, haute-loire, yssingeaux)
|
- [x] dossier calendrier_lunaire analysé et intégré
|
||||||
- analyse le dossier calendrier_lunaire
|
|
||||||
|
|
||||||
meteo:
|
meteo:
|
||||||
- [ ] brainstorming
|
- [x] station météo locale (WeeWX) : données veille 1×/jour + actuelles 1×/heure
|
||||||
- [ ] calendrier bi-hebdo ? avec prevision meteo
|
- [x] open-meteo.com : prévisions 1×/heure
|
||||||
- recupere les infos sur ma station meteo locale ( donnéée de la veille une fois par jours et donnée actuelle 1 fois par heure)( brainstorming a partir des script d'essai)
|
- [x] tableau journalier synthétique (passé/présent/futur, colonne station + open-meteo)
|
||||||
- recupere les infos sur https://open-meteo.com/ une fois par heure pour les prevision ( brainstorming a partir des script d'essai)
|
- [x] dossiers prevision_meteo et station_meteo analysés et intégrés
|
||||||
- presentation meteo sous forme de tableau journalier synthetique ( passé, present, futur ( avec des colonnes pour station meteo locale et site open-meteo separé)) ( brainstorming
|
|
||||||
- analyse le dossier prevision meteo et station_meteo
|
|
||||||
astuces :
|
|
||||||
- [ ] possibilité d'ajouter des astuces pour les plantes, le jardin, les taches
|
|
||||||
- [ ] brainstorming
|
|
||||||
|
|
||||||
|
astuces :
|
||||||
|
- [x] astuces pour les plantes, le jardin, les tâches : CRUD + filtres catégorie/mois/tag
|
||||||
|
- [ ] "Astuce du jour" dans le dashboard
|
||||||
|
|
||||||
capteur:
|
capteur:
|
||||||
- [ ] recuperation de capteur possible: ensoleillement, temperature ambiante, temperature du sol, humidite de l'air, humidite du sol, ph du sol,
|
- [ ] récupération de capteurs : ensoleillement, température ambiante/sol, humidité air/sol, pH sol
|
||||||
- [ ] configuration via serveur mqtt ( topic et payload)
|
- [ ] configuration via serveur MQTT (topic et payload)
|
||||||
- [ ] brainstorming
|
- [ ] capteur extérieur et capteur serre
|
||||||
- capteur exterieur et capteur serre
|
|
||||||
|
|
||||||
reglages :
|
reglages :
|
||||||
- [ ] application en mode desktop et pour smartphone ( responsive ?)
|
- [x] application responsive desktop et smartphone
|
||||||
- [ ] section pour chaque type: interface, jardin, plante, taches, calendrier, planning
|
- [x] backup ZIP (DB + uploads) téléchargeable
|
||||||
- [ ] backup et restaure (toutes les données: bdd, photo, pdf, txt)
|
- [ ] restauration depuis ZIP (upload + restore)
|
||||||
- [ ] ajout de detection de plante a partir de photos ( possibilite d'ajouter un service de detection de type de plantes a partir d'une photo)
|
- [ ] sections réglages par type : interface, jardin, plante, tâches, calendrier, planning
|
||||||
- reglage url station meteo local et site distant
|
- [x] détection de plante depuis photo (Pl@ntNet API + YOLO local)
|
||||||
|
- [x] réglage URL station météo locale et site distant
|
||||||
|
|
||||||
recolte:
|
recolte:
|
||||||
- [ ] ajouter possibiliter de saisir des quantites recoltés et a quelles dates ( brainstorming)
|
- [x] saisie des quantités récoltées avec dates (unités : kg/g/unités/litres/bottes)
|
||||||
- [ ] ajouter la possibiliter de suivre des maladies (mildioux ), des traitement, des ravageurs: limaces, taupe, chenille, ...
|
- [x] suivi maladies (mildiou), traitements, ravageurs (limaces, taupe, chenille) via Observations
|
||||||
|
|
||||||
frontend :
|
frontend :
|
||||||
- [ ] icones pour objet dimensionnable ds setting : jardin, plantes, tache, calendrier, meteo, outils
|
- [ ] icônes pour objets dimensionnables dans setting : jardin, plantes, tâche, calendrier, météo, outils
|
||||||
- [ ] icones pour plantes dimensionnable ds setting : tomate, pomme de terre, salade, carotte, ...
|
- [ ] icônes pour plantes dimensionnables dans setting : tomate, pomme de terre, salade, carotte, ...
|
||||||
- [ ] mode editions pour pouvoir modifier les different element, plantes, jardin, taches, calendrier, ...
|
- [x] mode édition pour les différents éléments (plantes, jardin, tâches, calendrier) via modales
|
||||||
- [ ] ajouter des images depuis iphones ( appareil photo)
|
- [ ] ajouter des images depuis iPhone (appareil photo natif)
|
||||||
- [ ] ajouter des pdf ou des annotation, des url dvalable pour tous types d'objet: jardin, plantes,outils,
|
- [x] PDF, annotations, URL pour tous types d'objets : jardin, plantes, outils (Attachments)
|
||||||
- verifier que l'application s'affiche correctement sur smartphone
|
- [ ] vérifier affichage correct sur smartphone (à tester)
|
||||||
- utilise le dossier icons pour le calendrier lunaire et la meteo ( icone svg adapter taille d'affichage dans setting)
|
- [ ] utiliser le dossier icons pour le calendrier lunaire et la météo (icônes SVG adaptatifs dans settings)
|
||||||
|
|
||||||
bibliotehque photo:
|
bibliotheque photo:
|
||||||
- ajoute une bibliotheque ( plante, legume, fleur, arbres et arbrisseau, adventices) avec un stockage de mes capture et le rsultat d'une identification des plante grace au web ( via api ou via ia : brainstorming) api key: 2b1088cHCJ4c7Cn2Vqq67xfve sur https://my.plantnet.org/dashboard ( https://my.plantnet.org/doc/api/openapi)
|
- [x] bibliothèque (plante, légume, fleur, arbres et arbrisseaux, adventices) + galerie lightbox
|
||||||
- brainstorming local ai detection style yolo ( fichier consigne_yolo.md)
|
- [x] identification via Pl@ntNet API (api key configurée)
|
||||||
|
- [x] détection locale style YOLO (consigne_yolo.md intégrée)
|
||||||
|
|
||||||
backend :
|
backend :
|
||||||
- [ ] methode simple pour mettre a jours la base de donnée ; brainstorming
|
- [x] migration automatique BDD (migrate.py : ALTER TABLE ADD COLUMN sans perte de données)
|
||||||
|
- [x] mise à jour BDD via API REST
|
||||||
|
- [ ] ajouter des étoiles 1 à 5 (satisfaction plante)
|
||||||
|
|
||||||
|
divers :
|
||||||
|
- [ ] page 404 catch-all (route Vue manquante)
|
||||||
|
- [ ] export/import JSON complet
|
||||||
|
- [ ] observations dans PlantationsView (backend prêt, UI manquante)
|
||||||
|
- [ ] tests backend : couverture ~60% → objectif 80%
|
||||||
|
|||||||
@@ -7282,3 +7282,218 @@ naive tzinfo: None
|
|||||||
aware tzinfo: UTC
|
aware tzinfo: UTC
|
||||||
You've hit your limit · resets 7pm (Europe/Paris)
|
You've hit your limit · resets 7pm (Europe/Paris)
|
||||||
|
|
||||||
|
|
||||||
|
## Mise a jour Codex - 2026-02-22
|
||||||
|
|
||||||
|
### Termine
|
||||||
|
- Task 5 (Service Open-Meteo enrichi):
|
||||||
|
- `backend/app/services/meteo.py`
|
||||||
|
- ajout aggregation journaliere `sol_0cm` depuis `hourly.soil_temperature_0cm`
|
||||||
|
- parsing defensif des valeurs numeriques
|
||||||
|
- tests: `backend/tests/test_meteo_service.py` (3 passes)
|
||||||
|
|
||||||
|
- Task 8 (Router Astuces filtres):
|
||||||
|
- `backend/app/routers/astuces.py`
|
||||||
|
- nouveaux filtres `categorie`, `tag`, `mois`
|
||||||
|
- compatibilite filtres existants `entity_type`, `entity_id`
|
||||||
|
- tests: `backend/tests/test_astuces_filters.py` (5 passes)
|
||||||
|
|
||||||
|
### Stabilisation tests
|
||||||
|
- Ajout de flags de runtime backend:
|
||||||
|
- `ENABLE_SCHEDULER` et `ENABLE_BOOTSTRAP` dans `backend/app/config.py`
|
||||||
|
- documentes dans `.env.example`
|
||||||
|
- `backend/app/main.py` respecte ces flags dans le lifespan
|
||||||
|
- `backend/tests/conftest.py` desactive scheduler/bootstrap pour les tests
|
||||||
|
- `conftest` fournit une session SQLModel par requete TestClient pour eviter les blocages thread/session
|
||||||
|
|
||||||
|
### Point restant
|
||||||
|
- `backend/tests/test_meteo.py::test_meteo_tableau_vide` reste bloquant dans ce contexte (timeout), malgre la desactivation scheduler/bootstrap.
|
||||||
|
- Les nouveaux tests unitaire meteo/astuces passent.
|
||||||
|
|
||||||
|
## Mise a jour Codex - Frontend (Tasks 9, 10, 11)
|
||||||
|
|
||||||
|
### Task 9 termine
|
||||||
|
- `frontend/src/api/meteo.ts` enrichi:
|
||||||
|
- `getTableau`, `getStationCurrent`, `getPrevisions`, `refresh`
|
||||||
|
- types `TableauRow`, `StationCurrent`, `OpenMeteoDay`
|
||||||
|
- `frontend/src/api/astuces.ts` cree
|
||||||
|
- `frontend/src/stores/astuces.ts` cree
|
||||||
|
|
||||||
|
### Task 10 termine
|
||||||
|
- `frontend/src/views/CalendrierView.vue`:
|
||||||
|
- onglet meteo refondu en tableau synthetique station + open-meteo
|
||||||
|
- widget station actuelle
|
||||||
|
- suppression ancien bloc `meteoData`
|
||||||
|
- ajout `loadTableau` + `loadStationCurrent`
|
||||||
|
|
||||||
|
### Task 11 termine
|
||||||
|
- `frontend/src/views/AstucesView.vue` cree (filtres + CRUD)
|
||||||
|
- route ajoutee: `/astuces` dans `frontend/src/router/index.ts`
|
||||||
|
- menu mobile: `frontend/src/components/AppDrawer.vue`
|
||||||
|
- menu desktop: `frontend/src/App.vue`
|
||||||
|
|
||||||
|
### Validation frontend
|
||||||
|
- `npm run lint` -> OK
|
||||||
|
- `npm run build` -> OK
|
||||||
|
|
||||||
|
## Mise a jour Codex - 2026-02-22 (Meteo, Jardin, UI)
|
||||||
|
|
||||||
|
### Migration executee (OK)
|
||||||
|
- Migration lancee dans le conteneur backend:
|
||||||
|
- `docker compose exec backend python -c "from app.migrate import run_migrations; run_migrations()"`
|
||||||
|
- Colonnes ajoutees en base SQLite:
|
||||||
|
- table `garden`: `carre_potager`, `carre_x_cm`, `carre_y_cm`
|
||||||
|
- table `astuce`: `photos`, `videos`
|
||||||
|
|
||||||
|
### Jardin: carre potager
|
||||||
|
- Backend:
|
||||||
|
- `backend/app/models/garden.py` ajoute les champs `carre_potager`, `carre_x_cm`, `carre_y_cm`
|
||||||
|
- `backend/app/migrate.py` mis a jour pour ces colonnes
|
||||||
|
- Frontend:
|
||||||
|
- `frontend/src/views/JardinsView.vue`
|
||||||
|
- ajout checkbox "Carre potager" + dimensions X/Y en cm
|
||||||
|
- conversion automatique cm -> m pour `longueur_m` / `largeur_m`
|
||||||
|
- surface calculee automatiquement si absente
|
||||||
|
|
||||||
|
### Popup edition plante responsive
|
||||||
|
- `frontend/src/views/PlantesView.vue`
|
||||||
|
- modal edition:
|
||||||
|
- smartphone: 1 colonne
|
||||||
|
- laptop/desktop: 2 colonnes (`lg:grid-cols-2`)
|
||||||
|
- notes + actions en largeur complete
|
||||||
|
|
||||||
|
### Meteo: vue unique + navigation temporelle
|
||||||
|
- Backend:
|
||||||
|
- `backend/app/routers/meteo.py`
|
||||||
|
- endpoint `/api/meteo/tableau` accepte desormais:
|
||||||
|
- `center_date=YYYY-MM-DD`
|
||||||
|
- `span` (jours avant/apres)
|
||||||
|
- Frontend:
|
||||||
|
- `frontend/src/views/CalendrierView.vue` refondu en vue meteo unique
|
||||||
|
- suppression des onglets `lunaire/meteo/taches/dictons`
|
||||||
|
- boutons navigation: `Prev`, `Today`, `Next`
|
||||||
|
- fenetre active sur +/- 15 jours autour de la date centrale
|
||||||
|
- detail a droite conserve (station, open-meteo, lunaire, dictons, saint)
|
||||||
|
|
||||||
|
### Navigation application
|
||||||
|
- Route principale renommee:
|
||||||
|
- `/meteo` -> `CalendrierView`
|
||||||
|
- Redirections conservees:
|
||||||
|
- `/calendrier` -> `/meteo`
|
||||||
|
- `/lunaire` -> `/meteo`
|
||||||
|
- Menus renommes en "Meteo":
|
||||||
|
- `frontend/src/App.vue` / `frontend/src/App.vue.js`
|
||||||
|
- `frontend/src/components/AppDrawer.vue` / `frontend/src/components/AppDrawer.vue.js`
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- Backend: compilation Python OK sur fichiers modifies
|
||||||
|
- Frontend: build OK (`npm --prefix frontend run build`)
|
||||||
|
|
||||||
|
## Mise a jour Codex - 2026-02-22 (Station/Open-Meteo, Dashboard, Outils video)
|
||||||
|
|
||||||
|
### Base de donnees meteo: mises a jour executees
|
||||||
|
- Script station locale execute avec succes:
|
||||||
|
- `python3 station_meteo/update_station_db.py`
|
||||||
|
- ecriture confirmee en base (`meteostation`):
|
||||||
|
- `current`: `2026-02-22T17:00` (pression `922.5`, vent `3.2`)
|
||||||
|
- `veille`: `2026-02-21T00:00`
|
||||||
|
- Backfill station locale (NOAA) execute:
|
||||||
|
- plage `2026-01-01` -> `2026-02-22`
|
||||||
|
- `53` jours traites, `53` upserts, `0` erreur
|
||||||
|
- Script historique Open-Meteo cree:
|
||||||
|
- `station_meteo/update_openmeteo_history_db.py`
|
||||||
|
- options: `--start-date`, `--end-date`, `--lat`, `--lon`, `--chunk-days`, `--dry-run`
|
||||||
|
- source: endpoint archive Open-Meteo
|
||||||
|
- cible: table `meteoopenmeteo` (upsert par date)
|
||||||
|
- Backfill Open-Meteo execute:
|
||||||
|
- `python3 station_meteo/update_openmeteo_history_db.py --start-date 2026-01-01 --end-date 2026-02-22`
|
||||||
|
- resultat: `53` lignes recuperees et mises a jour en base
|
||||||
|
|
||||||
|
### Dashboard / Meteo: ergonomie et visuel
|
||||||
|
- `frontend/src/views/DashboardView.vue`
|
||||||
|
- ajout d'un bloc "Condition actuelle" (icone meteo + libelle + temperature station + heure releve)
|
||||||
|
- affichage prevision sur 7 jours avec icones meteo
|
||||||
|
- suppression du scroll horizontal des cartes meteo:
|
||||||
|
- passage d'un layout `flex overflow-x-auto` a une grille responsive
|
||||||
|
- conteneur elargi (`max-w-6xl`) pour une meilleure lisibilite laptop
|
||||||
|
- `frontend/src/views/CalendrierView.vue`
|
||||||
|
- icone pression plus lisible dans le bandeau station:
|
||||||
|
- `⬛` remplace par `🧭`
|
||||||
|
|
||||||
|
### Navigation responsive
|
||||||
|
- `frontend/src/components/AppDrawer.vue`
|
||||||
|
- correction ouverture menu en fenetre reduite laptop/tablette:
|
||||||
|
- `md:hidden` -> `lg:hidden`
|
||||||
|
- le drawer est maintenant disponible sur toutes les largeurs < `lg`
|
||||||
|
|
||||||
|
### Jardins: popup edition responsive
|
||||||
|
- `frontend/src/views/JardinsView.vue`
|
||||||
|
- popup `Nouveau/Modifier jardin` passe en responsive:
|
||||||
|
- smartphone: `1` colonne
|
||||||
|
- laptop/desktop: `2` colonnes
|
||||||
|
- modal elargi (`max-w-4xl`)
|
||||||
|
- sections longues en pleine largeur (`lg:col-span-2`)
|
||||||
|
|
||||||
|
### Outils: ajout du champ video
|
||||||
|
- Backend:
|
||||||
|
- `backend/app/models/tool.py`: ajout `video_url`
|
||||||
|
- `backend/app/migrate.py`: ajout migration auto colonne `tool.video_url`
|
||||||
|
- test ajoute: `backend/tests/test_tools.py::test_tool_with_video_url`
|
||||||
|
- Frontend:
|
||||||
|
- `frontend/src/api/tools.ts`: type `video_url?: string`
|
||||||
|
- `frontend/src/views/OutilsView.vue`:
|
||||||
|
- upload video (`accept="video/*"`)
|
||||||
|
- preview video dans le formulaire
|
||||||
|
- enregistrement `video_url` via endpoint upload
|
||||||
|
- affichage video et lien "🎬 Video" sur les cartes outils
|
||||||
|
- Base locale:
|
||||||
|
- colonne `video_url` ajoutee et verifiee dans `data/jardin.db`
|
||||||
|
|
||||||
|
### Validation technique
|
||||||
|
- Frontend builds:
|
||||||
|
- `npm --prefix frontend run build` -> OK (plusieurs executions apres changements)
|
||||||
|
- Python compilation:
|
||||||
|
- `python3 -m py_compile` sur scripts/modeles modifies -> OK
|
||||||
|
- Note tests backend:
|
||||||
|
- `pytest backend/tests/test_tools.py` reste bloque dans ce contexte d'execution,
|
||||||
|
mais les changements de schema/code compilent et la colonne DB est presente.
|
||||||
|
|
||||||
|
## Mise a jour Codex - 2026-02-22 (Planning, Settings, Saints/Dictons, Outils)
|
||||||
|
|
||||||
|
### Planning (frontend)
|
||||||
|
- Fichier: `frontend/src/views/PlanningView.vue`
|
||||||
|
- Vue planning etendue a 4 semaines (28 jours)
|
||||||
|
- Navigation par boutons `Prev`, `Today`, `Next`
|
||||||
|
- Selection d'une case/jour avec panneau "Detail du jour"
|
||||||
|
- Ajout de marqueurs visuels (petits ronds colores) dans les cases pour signaler les taches non terminees
|
||||||
|
|
||||||
|
### Outils: notice en texte libre
|
||||||
|
- Fichier: `frontend/src/views/OutilsView.vue`
|
||||||
|
- Remplacement du champ "notice fichier texte" par une zone de texte (`notice_texte`)
|
||||||
|
- Affichage de la notice texte sur la carte outil
|
||||||
|
- Compatibilite conservee pour l'existant (`notice_fichier_url` en fallback)
|
||||||
|
- Test backend ajoute:
|
||||||
|
- `backend/tests/test_tools.py::test_tool_with_notice_texte`
|
||||||
|
|
||||||
|
### Settings: backup ZIP + test API backend
|
||||||
|
- Backend:
|
||||||
|
- `backend/app/routers/settings.py`
|
||||||
|
- nouvel endpoint `GET /api/settings/backup/download`
|
||||||
|
- archive ZIP contenant: base SQLite, uploads (images/videos), fichiers texte utiles, `manifest.json`
|
||||||
|
- Frontend:
|
||||||
|
- `frontend/src/api/settings.ts`: `downloadBackup()`
|
||||||
|
- `frontend/src/views/ReglagesView.vue`:
|
||||||
|
- bouton "Telecharger la sauvegarde (.zip)"
|
||||||
|
- section "Test API backend" avec liens rapides:
|
||||||
|
- `/docs`, `/redoc`, `/api/health`
|
||||||
|
|
||||||
|
### Saints / dictons (hors webapp)
|
||||||
|
- Dossier: `calendrier_lunaire/saints_dictons/`
|
||||||
|
- Fichiers JSON generes:
|
||||||
|
- `saints_du_jour.json`
|
||||||
|
- `dictons_du_jour.json`
|
||||||
|
- Scripts ajoutes:
|
||||||
|
- `export_saints_dictons_json.py` (source `saints_2026.json` -> 2 JSON separes)
|
||||||
|
- `import_saints_dictons_db.py` (import SQLite `replace`/`append`)
|
||||||
|
- Import teste sur base temporaire:
|
||||||
|
- resultat: `366` jours de saints + `366` dictons importes
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
|||||||
STATION_URL = os.getenv("STATION_URL", "http://10.0.0.8:8081/")
|
STATION_URL = os.getenv("STATION_URL", "http://10.0.0.8:8081/")
|
||||||
METEO_LAT = float(os.getenv("METEO_LAT", "45.14"))
|
METEO_LAT = float(os.getenv("METEO_LAT", "45.14"))
|
||||||
METEO_LON = float(os.getenv("METEO_LON", "4.12"))
|
METEO_LON = float(os.getenv("METEO_LON", "4.12"))
|
||||||
|
ENABLE_SCHEDULER = os.getenv("ENABLE_SCHEDULER", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
ENABLE_BOOTSTRAP = os.getenv("ENABLE_BOOTSTRAP", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import CORS_ORIGINS, UPLOAD_DIR
|
from app.config import CORS_ORIGINS, ENABLE_BOOTSTRAP, ENABLE_SCHEDULER, UPLOAD_DIR
|
||||||
from app.database import create_db_and_tables
|
from app.database import create_db_and_tables
|
||||||
|
|
||||||
|
|
||||||
@@ -15,22 +16,30 @@ async def lifespan(app: FastAPI):
|
|||||||
os.makedirs("/data/skyfield", exist_ok=True)
|
os.makedirs("/data/skyfield", exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
import app.models # noqa — enregistre tous les modèles avant create_all
|
if ENABLE_BOOTSTRAP:
|
||||||
from app.migrate import run_migrations
|
import app.models # noqa — enregistre tous les modèles avant create_all
|
||||||
run_migrations()
|
from app.migrate import run_migrations
|
||||||
create_db_and_tables()
|
run_migrations()
|
||||||
from app.seed import run_seed
|
create_db_and_tables()
|
||||||
run_seed()
|
from app.seed import run_seed
|
||||||
# Démarrer le scheduler météo
|
run_seed()
|
||||||
from app.services.scheduler import setup_scheduler
|
if ENABLE_SCHEDULER:
|
||||||
setup_scheduler()
|
from app.services.scheduler import setup_scheduler, backfill_station_missing_dates
|
||||||
|
setup_scheduler()
|
||||||
|
# Backfill des dates manquantes en arrière-plan (ne bloque pas le démarrage)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.run_in_executor(None, backfill_station_missing_dates)
|
||||||
yield
|
yield
|
||||||
# Arrêter le scheduler
|
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
|
||||||
from app.services.scheduler import scheduler
|
from app.services.scheduler import scheduler
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Jardin API", lifespan=lifespan)
|
app = FastAPI(
|
||||||
|
title="Jardin API",
|
||||||
|
lifespan=lifespan,
|
||||||
|
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.3/bundles/redoc.standalone.js"
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -48,11 +57,14 @@ from app.routers import ( # noqa
|
|||||||
media,
|
media,
|
||||||
tools,
|
tools,
|
||||||
dictons,
|
dictons,
|
||||||
|
saints,
|
||||||
astuces,
|
astuces,
|
||||||
recoltes,
|
recoltes,
|
||||||
lunar,
|
lunar,
|
||||||
meteo,
|
meteo,
|
||||||
identify,
|
identify,
|
||||||
|
achats,
|
||||||
|
fabrications,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(gardens.router, prefix="/api")
|
app.include_router(gardens.router, prefix="/api")
|
||||||
@@ -63,11 +75,14 @@ app.include_router(settings.router, prefix="/api")
|
|||||||
app.include_router(media.router, prefix="/api")
|
app.include_router(media.router, prefix="/api")
|
||||||
app.include_router(tools.router, prefix="/api")
|
app.include_router(tools.router, prefix="/api")
|
||||||
app.include_router(dictons.router, prefix="/api")
|
app.include_router(dictons.router, prefix="/api")
|
||||||
|
app.include_router(saints.router, prefix="/api")
|
||||||
app.include_router(astuces.router, prefix="/api")
|
app.include_router(astuces.router, prefix="/api")
|
||||||
app.include_router(recoltes.router, prefix="/api")
|
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")
|
||||||
|
|||||||
@@ -13,9 +13,34 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
("maladies_courantes", "TEXT", None),
|
("maladies_courantes", "TEXT", None),
|
||||||
("astuces_culture", "TEXT", None),
|
("astuces_culture", "TEXT", None),
|
||||||
("url_reference", "TEXT", None),
|
("url_reference", "TEXT", None),
|
||||||
|
("associations_favorables", "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_url", "TEXT", None),
|
||||||
|
("prix_achat", "REAL", None),
|
||||||
|
("date_achat", "TEXT", None),
|
||||||
|
("poids", "TEXT", None),
|
||||||
|
("dluo", "TEXT", None),
|
||||||
],
|
],
|
||||||
"garden": [
|
"garden": [
|
||||||
|
("latitude", "REAL", None),
|
||||||
|
("longitude", "REAL", None),
|
||||||
|
("altitude", "REAL", None),
|
||||||
|
("adresse", "TEXT", None),
|
||||||
|
("longueur_m", "REAL", None),
|
||||||
|
("largeur_m", "REAL", None),
|
||||||
("surface_m2", "REAL", None),
|
("surface_m2", "REAL", None),
|
||||||
|
("carre_potager", "INTEGER", "0"),
|
||||||
|
("carre_x_cm", "INTEGER", None),
|
||||||
|
("carre_y_cm", "INTEGER", None),
|
||||||
|
("photo_parcelle", "TEXT", None),
|
||||||
("ensoleillement", "TEXT", None),
|
("ensoleillement", "TEXT", None),
|
||||||
],
|
],
|
||||||
"task": [
|
"task": [
|
||||||
@@ -23,6 +48,26 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
("date_prochaine", "TEXT", None),
|
("date_prochaine", "TEXT", None),
|
||||||
("outil_id", "INTEGER", None),
|
("outil_id", "INTEGER", None),
|
||||||
],
|
],
|
||||||
|
"meteostation": [
|
||||||
|
("t_min", "REAL", None),
|
||||||
|
("t_max", "REAL", None),
|
||||||
|
],
|
||||||
|
"tool": [
|
||||||
|
("photo_url", "TEXT", None),
|
||||||
|
("video_url", "TEXT", None),
|
||||||
|
("notice_texte", "TEXT", None),
|
||||||
|
("notice_fichier_url", "TEXT", None),
|
||||||
|
("boutique_nom", "TEXT", None),
|
||||||
|
("boutique_url", "TEXT", None),
|
||||||
|
("prix_achat", "REAL", None),
|
||||||
|
],
|
||||||
|
"planting": [
|
||||||
|
("boutique_nom", "TEXT", None),
|
||||||
|
("boutique_url", "TEXT", None),
|
||||||
|
("tarif_achat", "REAL", None),
|
||||||
|
("date_achat", "TEXT", None),
|
||||||
|
("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect)
|
||||||
|
],
|
||||||
"plantvariety": [
|
"plantvariety": [
|
||||||
# ancien nom de table → migration vers "plant" si présente
|
# ancien nom de table → migration vers "plant" si présente
|
||||||
],
|
],
|
||||||
@@ -36,6 +81,36 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
("categorie", "TEXT", None),
|
("categorie", "TEXT", None),
|
||||||
("tags", "TEXT", None),
|
("tags", "TEXT", None),
|
||||||
("mois", "TEXT", None),
|
("mois", "TEXT", None),
|
||||||
|
("photos", "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
|
||||||
@@ -9,3 +9,5 @@ from app.models.dicton import Dicton # noqa
|
|||||||
from app.models.astuce import Astuce # noqa
|
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.intrant import AchatIntrant, Fabrication # noqa
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ class Astuce(SQLModel, table=True):
|
|||||||
categorie: Optional[str] = None # "plante"|"jardin"|"tache"|"general"|"ravageur"|"maladie"
|
categorie: Optional[str] = None # "plante"|"jardin"|"tache"|"general"|"ravageur"|"maladie"
|
||||||
tags: Optional[str] = None # JSON array string: '["tomate","semis"]'
|
tags: Optional[str] = None # JSON array string: '["tomate","semis"]'
|
||||||
mois: Optional[str] = None # JSON array string: '[3,4,5]' ou null = toute l'année
|
mois: Optional[str] = None # JSON array string: '[3,4,5]' ou null = toute l'année
|
||||||
|
photos: Optional[str] = None # JSON array string: '["/uploads/a.webp"]'
|
||||||
|
videos: Optional[str] = None # JSON array string: '["/uploads/b.mp4"]'
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ class Garden(SQLModel, table=True):
|
|||||||
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
|
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
|
||||||
sol_type: Optional[str] = None
|
sol_type: Optional[str] = None
|
||||||
sol_ph: Optional[float] = None
|
sol_ph: Optional[float] = None
|
||||||
|
longueur_m: Optional[float] = None
|
||||||
|
largeur_m: Optional[float] = None
|
||||||
surface_m2: Optional[float] = None
|
surface_m2: Optional[float] = None
|
||||||
|
carre_potager: bool = False
|
||||||
|
carre_x_cm: Optional[int] = None
|
||||||
|
carre_y_cm: Optional[int] = None
|
||||||
|
photo_parcelle: Optional[str] = None
|
||||||
ensoleillement: Optional[str] = None
|
ensoleillement: Optional[str] = None
|
||||||
grille_largeur: int = 6
|
grille_largeur: int = 6
|
||||||
grille_hauteur: int = 4
|
grille_hauteur: int = 4
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ from sqlmodel import Field, SQLModel
|
|||||||
|
|
||||||
class Media(SQLModel, table=True):
|
class Media(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
entity_type: str # jardin|plante|outil|plantation
|
entity_type: str # jardin|plante|adventice|outil|plantation|bibliotheque
|
||||||
entity_id: int
|
entity_id: int
|
||||||
url: str
|
url: str
|
||||||
thumbnail_url: Optional[str] = None
|
thumbnail_url: Optional[str] = None
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class MeteoStation(SQLModel, table=True):
|
|||||||
date_heure: str = Field(primary_key=True) # "2026-02-22T14:00"
|
date_heure: str = Field(primary_key=True) # "2026-02-22T14:00"
|
||||||
type: str = "current" # "current" | "veille"
|
type: str = "current" # "current" | "veille"
|
||||||
temp_ext: Optional[float] = None # °C extérieur
|
temp_ext: Optional[float] = None # °C extérieur
|
||||||
|
t_min: Optional[float] = None # résumé journée (NOAA)
|
||||||
|
t_max: Optional[float] = None # résumé journée (NOAA)
|
||||||
temp_int: Optional[float] = None # °C intérieur (serre)
|
temp_int: Optional[float] = None # °C intérieur (serre)
|
||||||
humidite: Optional[float] = None # %
|
humidite: Optional[float] = None # %
|
||||||
pression: Optional[float] = None # hPa
|
pression: Optional[float] = None # hPa
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
# backend/app/models/plant.py
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import JSON as SA_JSON
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
@@ -9,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
|
||||||
@@ -31,10 +34,70 @@ class Plant(SQLModel, table=True):
|
|||||||
astuces_culture: Optional[str] = None
|
astuces_culture: Optional[str] = None
|
||||||
url_reference: Optional[str] = None
|
url_reference: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
associations_favorables: Optional[List[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column("associations_favorables", SA_JSON, nullable=True),
|
||||||
|
)
|
||||||
|
associations_defavorables: Optional[List[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column("associations_defavorables", SA_JSON, nullable=True),
|
||||||
|
)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import JSON as SA_JSON
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
@@ -7,11 +9,16 @@ class PlantingCreate(SQLModel):
|
|||||||
garden_id: int
|
garden_id: int
|
||||||
variety_id: int
|
variety_id: int
|
||||||
cell_id: Optional[int] = None
|
cell_id: Optional[int] = None
|
||||||
|
cell_ids: Optional[List[int]] = None # multi-sélect zones
|
||||||
date_semis: Optional[date] = None
|
date_semis: Optional[date] = None
|
||||||
date_plantation: Optional[date] = None
|
date_plantation: Optional[date] = None
|
||||||
date_repiquage: Optional[date] = None
|
date_repiquage: Optional[date] = None
|
||||||
quantite: int = 1
|
quantite: int = 1
|
||||||
statut: str = "prevu"
|
statut: str = "prevu"
|
||||||
|
boutique_nom: Optional[str] = None
|
||||||
|
boutique_url: Optional[str] = None
|
||||||
|
tarif_achat: Optional[float] = None
|
||||||
|
date_achat: Optional[date] = None
|
||||||
date_recolte_debut: Optional[date] = None
|
date_recolte_debut: Optional[date] = None
|
||||||
date_recolte_fin: Optional[date] = None
|
date_recolte_fin: Optional[date] = None
|
||||||
rendement_estime: Optional[float] = None
|
rendement_estime: Optional[float] = None
|
||||||
@@ -24,11 +31,19 @@ class Planting(SQLModel, table=True):
|
|||||||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||||||
variety_id: int = Field(foreign_key="plant.id", index=True)
|
variety_id: int = Field(foreign_key="plant.id", index=True)
|
||||||
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
|
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
|
||||||
|
cell_ids: Optional[List[int]] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column("cell_ids", SA_JSON, nullable=True),
|
||||||
|
)
|
||||||
date_semis: Optional[date] = None
|
date_semis: Optional[date] = None
|
||||||
date_plantation: Optional[date] = None
|
date_plantation: Optional[date] = None
|
||||||
date_repiquage: Optional[date] = None
|
date_repiquage: Optional[date] = None
|
||||||
quantite: int = 1
|
quantite: int = 1
|
||||||
statut: str = "prevu" # prevu | en_cours | termine | echoue
|
statut: str = "prevu" # prevu | en_cours | termine | echoue
|
||||||
|
boutique_nom: Optional[str] = None
|
||||||
|
boutique_url: Optional[str] = None
|
||||||
|
tarif_achat: Optional[float] = None
|
||||||
|
date_achat: Optional[date] = None
|
||||||
date_recolte_debut: Optional[date] = None
|
date_recolte_debut: Optional[date] = None
|
||||||
date_recolte_fin: Optional[date] = None
|
date_recolte_fin: Optional[date] = None
|
||||||
rendement_estime: Optional[float] = None
|
rendement_estime: Optional[float] = None
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class SaintDuJour(SQLModel, table=True):
|
||||||
|
"""Saints fêtés pour un jour donné (indépendant de l'année)."""
|
||||||
|
|
||||||
|
__tablename__ = "saint_du_jour"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
mois: int = Field(index=True) # 1-12
|
||||||
|
jour: int = Field(index=True) # 1-31
|
||||||
|
saints_json: str = Field(default="[]") # JSON array : ["St-Basile", "St-Grégoire", ...]
|
||||||
|
source_url: Optional[str] = None # URL source de scraping
|
||||||
@@ -9,4 +9,10 @@ class Tool(SQLModel, table=True):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
|
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
|
||||||
photo_url: Optional[str] = None
|
photo_url: Optional[str] = None
|
||||||
|
video_url: Optional[str] = None
|
||||||
|
notice_texte: Optional[str] = None
|
||||||
|
notice_fichier_url: Optional[str] = None
|
||||||
|
boutique_nom: Optional[str] = None
|
||||||
|
boutique_url: Optional[str] = None
|
||||||
|
prix_achat: Optional[float] = None
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
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
|
||||||
@@ -7,10 +8,45 @@ from app.models.astuce import Astuce
|
|||||||
router = APIRouter(tags=["astuces"])
|
router = APIRouter(tags=["astuces"])
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_tags(raw: Optional[str]) -> list[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = [p.strip() for p in raw.split(",") if p.strip()]
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
return [str(x).strip().lower() for x in parsed if str(x).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_mois(raw: Optional[str]) -> list[int]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = [p.strip() for p in raw.split(",") if p.strip()]
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
result: list[int] = []
|
||||||
|
for x in parsed:
|
||||||
|
try:
|
||||||
|
month = int(x)
|
||||||
|
if 1 <= month <= 12:
|
||||||
|
result.append(month)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/astuces", response_model=List[Astuce])
|
@router.get("/astuces", response_model=List[Astuce])
|
||||||
def list_astuces(
|
def list_astuces(
|
||||||
entity_type: Optional[str] = Query(None),
|
entity_type: Optional[str] = Query(None),
|
||||||
entity_id: Optional[int] = Query(None),
|
entity_id: Optional[int] = Query(None),
|
||||||
|
categorie: Optional[str] = Query(None),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
mois: Optional[int] = Query(None, ge=1, le=12),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
q = select(Astuce)
|
q = select(Astuce)
|
||||||
@@ -18,7 +54,21 @@ def list_astuces(
|
|||||||
q = q.where(Astuce.entity_type == entity_type)
|
q = q.where(Astuce.entity_type == entity_type)
|
||||||
if entity_id is not None:
|
if entity_id is not None:
|
||||||
q = q.where(Astuce.entity_id == entity_id)
|
q = q.where(Astuce.entity_id == entity_id)
|
||||||
return session.exec(q).all()
|
|
||||||
|
if categorie:
|
||||||
|
q = q.where(Astuce.categorie == categorie)
|
||||||
|
|
||||||
|
items = session.exec(q).all()
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
wanted = tag.strip().lower()
|
||||||
|
items = [a for a in items if wanted in _decode_tags(a.tags)]
|
||||||
|
|
||||||
|
if mois is not None:
|
||||||
|
# mois null/empty = astuce valable toute l'année
|
||||||
|
items = [a for a in items if not _decode_mois(a.mois) or mois in _decode_mois(a.mois)]
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.post("/astuces", response_model=Astuce, status_code=status.HTTP_201_CREATED)
|
@router.post("/astuces", response_model=Astuce, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.config import UPLOAD_DIR
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
|
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
|
||||||
|
|
||||||
router = APIRouter(tags=["jardins"])
|
router = APIRouter(tags=["jardins"])
|
||||||
|
|
||||||
|
|
||||||
|
def _save_garden_photo(data: bytes) -> str:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
|
img.thumbnail((1800, 1800))
|
||||||
|
name = f"garden_{uuid.uuid4()}.webp"
|
||||||
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
|
img.save(path, "WEBP", quality=88)
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
name = f"garden_{uuid.uuid4()}.bin"
|
||||||
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gardens", response_model=List[Garden])
|
@router.get("/gardens", response_model=List[Garden])
|
||||||
def list_gardens(session: Session = Depends(get_session)):
|
def list_gardens(session: Session = Depends(get_session)):
|
||||||
return session.exec(select(Garden)).all()
|
return session.exec(select(Garden)).all()
|
||||||
@@ -31,6 +53,31 @@ def get_garden(id: int, session: Session = Depends(get_session)):
|
|||||||
return g
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gardens/{id}/photo", response_model=Garden)
|
||||||
|
async def upload_garden_photo(
|
||||||
|
id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
g = session.get(Garden, id)
|
||||||
|
if not g:
|
||||||
|
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
||||||
|
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
raise HTTPException(status_code=400, detail="Le fichier doit être une image")
|
||||||
|
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
data = await file.read()
|
||||||
|
filename = _save_garden_photo(data)
|
||||||
|
g.photo_parcelle = f"/uploads/{filename}"
|
||||||
|
g.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(g)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(g)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
@router.put("/gardens/{id}", response_model=Garden)
|
@router.put("/gardens/{id}", response_model=Garden)
|
||||||
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
|
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
|
||||||
g = session.get(Garden, id)
|
g = session.get(Garden, id)
|
||||||
@@ -68,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
|
|||||||
return cell
|
return cell
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/gardens/{id}/cells/{cell_id}", response_model=GardenCell)
|
||||||
|
def update_cell(id: int, cell_id: int, data: GardenCell, session: Session = Depends(get_session)):
|
||||||
|
c = session.get(GardenCell, cell_id)
|
||||||
|
if not c or c.garden_id != id:
|
||||||
|
raise HTTPException(status_code=404, detail="Case introuvable")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "garden_id"}).items():
|
||||||
|
setattr(c, k, v)
|
||||||
|
session.add(c)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
|
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
|
||||||
def list_measurements(id: int, session: Session = Depends(get_session)):
|
def list_measurements(id: int, session: Session = Depends(get_session)):
|
||||||
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session, select
|
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):
|
||||||
@@ -16,9 +18,105 @@ class MediaPatch(BaseModel):
|
|||||||
entity_id: Optional[int] = None
|
entity_id: Optional[int] = None
|
||||||
titre: Optional[str] = None
|
titre: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
CANONICAL_ENTITY_TYPES = {
|
||||||
|
"jardin",
|
||||||
|
"plante",
|
||||||
|
"adventice",
|
||||||
|
"outil",
|
||||||
|
"plantation",
|
||||||
|
"bibliotheque",
|
||||||
|
}
|
||||||
|
|
||||||
|
ENTITY_TYPE_ALIASES = {
|
||||||
|
# Canonique
|
||||||
|
"jardin": "jardin",
|
||||||
|
"plante": "plante",
|
||||||
|
"adventice": "adventice",
|
||||||
|
"outil": "outil",
|
||||||
|
"plantation": "plantation",
|
||||||
|
"bibliotheque": "bibliotheque",
|
||||||
|
# Variantes FR
|
||||||
|
"jardins": "jardin",
|
||||||
|
"plantes": "plante",
|
||||||
|
"adventices": "adventice",
|
||||||
|
"outils": "outil",
|
||||||
|
"plantations": "plantation",
|
||||||
|
"bibliotheques": "bibliotheque",
|
||||||
|
"bibliotheque_media": "bibliotheque",
|
||||||
|
# Variantes EN (courantes via API)
|
||||||
|
"garden": "jardin",
|
||||||
|
"gardens": "jardin",
|
||||||
|
"plant": "plante",
|
||||||
|
"plants": "plante",
|
||||||
|
"weed": "adventice",
|
||||||
|
"weeds": "adventice",
|
||||||
|
"tool": "outil",
|
||||||
|
"tools": "outil",
|
||||||
|
"planting": "plantation",
|
||||||
|
"plantings": "plantation",
|
||||||
|
"library": "bibliotheque",
|
||||||
|
"media_library": "bibliotheque",
|
||||||
|
}
|
||||||
|
|
||||||
router = APIRouter(tags=["media"])
|
router = APIRouter(tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_token(value: str) -> str:
|
||||||
|
token = (value or "").strip().lower()
|
||||||
|
token = unicodedata.normalize("NFKD", token).encode("ascii", "ignore").decode("ascii")
|
||||||
|
return token.replace("-", "_").replace(" ", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_entity_type(value: str, *, strict: bool = True) -> str:
|
||||||
|
token = _normalize_token(value)
|
||||||
|
canonical = ENTITY_TYPE_ALIASES.get(token, token)
|
||||||
|
if canonical in CANONICAL_ENTITY_TYPES:
|
||||||
|
return canonical
|
||||||
|
if strict:
|
||||||
|
allowed = ", ".join(sorted(CANONICAL_ENTITY_TYPES))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"entity_type invalide: '{value}'. Valeurs autorisees: {allowed}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_type_candidates(value: str) -> set[str]:
|
||||||
|
canonical = _normalize_entity_type(value, strict=True)
|
||||||
|
candidates = {canonical}
|
||||||
|
for alias, target in ENTITY_TYPE_ALIASES.items():
|
||||||
|
if target == canonical:
|
||||||
|
candidates.add(alias)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize_rows(rows: List[Media], session: Session) -> None:
|
||||||
|
changed = False
|
||||||
|
for media in rows:
|
||||||
|
normalized = _normalize_entity_type(media.entity_type, strict=False)
|
||||||
|
if normalized in CANONICAL_ENTITY_TYPES and normalized != media.entity_type:
|
||||||
|
media.entity_type = normalized
|
||||||
|
session.add(media)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
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
|
||||||
@@ -39,20 +137,36 @@ 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}"}
|
||||||
else:
|
|
||||||
name = f"{uuid.uuid4()}_{file.filename}"
|
name = f"{uuid.uuid4()}_{file.filename}"
|
||||||
path = os.path.join(UPLOAD_DIR, name)
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
return {"url": f"/uploads/{name}", "thumbnail_url": None}
|
return {"url": f"/uploads/{name}", "thumbnail_url": None}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media/all", response_model=List[Media])
|
@router.get("/media/all", response_model=List[Media])
|
||||||
@@ -63,8 +177,10 @@ def list_all_media(
|
|||||||
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
|
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
|
||||||
q = select(Media).order_by(Media.created_at.desc())
|
q = select(Media).order_by(Media.created_at.desc())
|
||||||
if entity_type:
|
if entity_type:
|
||||||
q = q.where(Media.entity_type == entity_type)
|
q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type)))
|
||||||
return session.exec(q).all()
|
rows = session.exec(q).all()
|
||||||
|
_canonicalize_rows(rows, session)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media", response_model=List[Media])
|
@router.get("/media", response_model=List[Media])
|
||||||
@@ -73,15 +189,19 @@ def list_media(
|
|||||||
entity_id: int = Query(...),
|
entity_id: int = Query(...),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
return session.exec(
|
rows = session.exec(
|
||||||
select(Media).where(
|
select(Media).where(
|
||||||
Media.entity_type == entity_type, Media.entity_id == entity_id
|
Media.entity_type.in_(_entity_type_candidates(entity_type)),
|
||||||
|
Media.entity_id == entity_id,
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
_canonicalize_rows(rows, session)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
||||||
def create_media(m: Media, session: Session = Depends(get_session)):
|
def create_media(m: Media, session: Session = Depends(get_session)):
|
||||||
|
m.entity_type = _normalize_entity_type(m.entity_type, strict=True)
|
||||||
session.add(m)
|
session.add(m)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(m)
|
session.refresh(m)
|
||||||
@@ -93,7 +213,12 @@ def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_se
|
|||||||
m = session.get(Media, id)
|
m = session.get(Media, id)
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "Media introuvable")
|
raise HTTPException(404, "Media introuvable")
|
||||||
for k, v in payload.model_dump(exclude_none=True).items():
|
|
||||||
|
updates = payload.model_dump(exclude_none=True)
|
||||||
|
if "entity_type" in updates:
|
||||||
|
updates["entity_type"] = _normalize_entity_type(updates["entity_type"], strict=True)
|
||||||
|
|
||||||
|
for k, v in updates.items():
|
||||||
setattr(m, k, v)
|
setattr(m, k, v)
|
||||||
session.add(m)
|
session.add(m)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
@@ -13,26 +14,35 @@ router = APIRouter(tags=["météo"])
|
|||||||
|
|
||||||
def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
|
def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
|
||||||
"""Agrège les mesures horaires d'une journée en résumé."""
|
"""Agrège les mesures horaires d'une journée en résumé."""
|
||||||
rows = session.exec(
|
try:
|
||||||
text(
|
rows = session.exec(
|
||||||
"SELECT temp_ext, pluie_mm, vent_kmh, humidite "
|
text(
|
||||||
"FROM meteostation WHERE substr(date_heure, 1, 10) = :d"
|
"SELECT temp_ext, t_min, t_max, pluie_mm, vent_kmh, humidite "
|
||||||
),
|
"FROM meteostation WHERE substr(date_heure, 1, 10) = :d"
|
||||||
params={"d": iso_date},
|
),
|
||||||
).fetchall()
|
params={"d": iso_date},
|
||||||
|
).fetchall()
|
||||||
|
except OperationalError:
|
||||||
|
return None
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
temps = [r[0] for r in rows if r[0] is not None]
|
temps = [r[0] for r in rows if r[0] is not None]
|
||||||
pluies = [r[1] for r in rows if r[1] is not None]
|
t_mins = [r[1] for r in rows if r[1] is not None]
|
||||||
vents = [r[2] for r in rows if r[2] is not None]
|
t_maxs = [r[2] for r in rows if r[2] is not None]
|
||||||
hums = [r[3] for r in rows if r[3] is not None]
|
pluies = [r[3] for r in rows if r[3] is not None]
|
||||||
|
vents = [r[4] for r in rows if r[4] is not None]
|
||||||
|
hums = [r[5] for r in rows if r[5] is not None]
|
||||||
|
|
||||||
|
min_candidates = temps + t_mins
|
||||||
|
max_candidates = temps + t_maxs
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"t_min": round(min(temps), 1) if temps else None,
|
"t_min": round(min(min_candidates), 1) if min_candidates else None,
|
||||||
"t_max": round(max(temps), 1) if temps else None,
|
"t_max": round(max(max_candidates), 1) if max_candidates else None,
|
||||||
"pluie_mm": round(sum(pluies), 1) if pluies else 0.0,
|
# WeeWX RSS expose souvent une pluie cumulée journalière.
|
||||||
|
"pluie_mm": round(max(pluies), 1) if pluies else 0.0,
|
||||||
"vent_kmh": round(max(vents), 1) if vents else None,
|
"vent_kmh": round(max(vents), 1) if vents else None,
|
||||||
"humidite": round(sum(hums) / len(hums), 0) if hums else None,
|
"humidite": round(sum(hums) / len(hums), 0) if hums else None,
|
||||||
}
|
}
|
||||||
@@ -40,12 +50,15 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
|
|||||||
|
|
||||||
def _station_current_row(session: Session) -> Optional[dict]:
|
def _station_current_row(session: Session) -> Optional[dict]:
|
||||||
"""Dernière mesure station (max 2h d'ancienneté)."""
|
"""Dernière mesure station (max 2h d'ancienneté)."""
|
||||||
row = session.exec(
|
try:
|
||||||
text(
|
row = session.exec(
|
||||||
"SELECT temp_ext, humidite, pression, pluie_mm, vent_kmh, vent_dir, uv, solaire, date_heure "
|
text(
|
||||||
"FROM meteostation WHERE type='current' ORDER BY date_heure DESC LIMIT 1"
|
"SELECT temp_ext, humidite, pression, pluie_mm, vent_kmh, vent_dir, uv, solaire, date_heure "
|
||||||
)
|
"FROM meteostation WHERE type='current' ORDER BY date_heure DESC LIMIT 1"
|
||||||
).fetchone()
|
)
|
||||||
|
).fetchone()
|
||||||
|
except OperationalError:
|
||||||
|
return None
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
@@ -58,13 +71,16 @@ def _station_current_row(session: Session) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
|
def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
|
||||||
row = session.exec(
|
try:
|
||||||
text(
|
row = session.exec(
|
||||||
"SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm "
|
text(
|
||||||
"FROM meteoopenmeteo WHERE date = :d"
|
"SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm "
|
||||||
),
|
"FROM meteoopenmeteo WHERE date = :d"
|
||||||
params={"d": iso_date},
|
),
|
||||||
).fetchone()
|
params={"d": iso_date},
|
||||||
|
).fetchone()
|
||||||
|
except OperationalError:
|
||||||
|
return None
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
@@ -77,22 +93,36 @@ def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/meteo/tableau")
|
@router.get("/meteo/tableau")
|
||||||
def get_tableau(session: Session = Depends(get_session)) -> dict[str, Any]:
|
def get_tableau(
|
||||||
"""Tableau synthétique : 7j passé + J0 + 7j futur."""
|
center_date: Optional[str] = Query(None, description="Date centrale YYYY-MM-DD"),
|
||||||
|
span: int = Query(7, ge=1, le=31, description="Nombre de jours avant/après la date centrale"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Tableau synthétique centré sur une date, avec historique + prévision."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
center = today
|
||||||
|
if center_date:
|
||||||
|
try:
|
||||||
|
center = date.fromisoformat(center_date)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="center_date invalide (format YYYY-MM-DD)") from exc
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
for delta in range(-7, 8):
|
for delta in range(-span, span + 1):
|
||||||
d = today + timedelta(days=delta)
|
d = center + timedelta(days=delta)
|
||||||
iso = d.isoformat()
|
iso = d.isoformat()
|
||||||
|
delta_today = (d - today).days
|
||||||
|
|
||||||
if delta < 0:
|
if delta_today < 0:
|
||||||
row_type = "passe"
|
row_type = "passe"
|
||||||
station = _station_daily_summary(session, iso)
|
station = _station_daily_summary(session, iso)
|
||||||
om = None # Pas de prévision pour le passé
|
om = _open_meteo_day(session, iso)
|
||||||
elif delta == 0:
|
elif delta_today == 0:
|
||||||
row_type = "aujourd_hui"
|
row_type = "aujourd_hui"
|
||||||
station = _station_current_row(session)
|
station_current = _station_current_row(session) or {}
|
||||||
|
station_daily = _station_daily_summary(session, iso) or {}
|
||||||
|
station = {**station_daily, **station_current} or None
|
||||||
om = _open_meteo_day(session, iso)
|
om = _open_meteo_day(session, iso)
|
||||||
else:
|
else:
|
||||||
row_type = "futur"
|
row_type = "futur"
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
||||||
def create_planting(data: PlantingCreate, session: Session = Depends(get_session)):
|
def create_planting(data: PlantingCreate, session: Session = Depends(get_session)):
|
||||||
p = Planting(**data.model_dump())
|
d = data.model_dump()
|
||||||
|
# Rétro-compatibilité : cell_id = première zone sélectionnée
|
||||||
|
if d.get("cell_ids") and not d.get("cell_id"):
|
||||||
|
d["cell_id"] = d["cell_ids"][0]
|
||||||
|
p = Planting(**d)
|
||||||
session.add(p)
|
session.add(p)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(p)
|
session.refresh(p)
|
||||||
@@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge
|
|||||||
p = session.get(Planting, id)
|
p = session.get(Planting, id)
|
||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||||||
for k, v in data.model_dump(exclude_unset=True).items():
|
d = data.model_dump(exclude_unset=True)
|
||||||
|
# Rétro-compatibilité : cell_id = première zone sélectionnée
|
||||||
|
if "cell_ids" in d:
|
||||||
|
ids = d["cell_ids"] or []
|
||||||
|
d["cell_id"] = ids[0] if ids else None
|
||||||
|
for k, v in d.items():
|
||||||
setattr(p, k, v)
|
setattr(p, k, v)
|
||||||
p.updated_at = datetime.now(timezone.utc)
|
p.updated_at = datetime.now(timezone.utc)
|
||||||
session.add(p)
|
session.add(p)
|
||||||
|
|||||||
@@ -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)
|
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()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Router saints — consultation des saints du jour (indépendant de l'année)."""
|
||||||
|
import json
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.saint import SaintDuJour
|
||||||
|
|
||||||
|
router = APIRouter(tags=["saints"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saints", response_model=List[SaintDuJour])
|
||||||
|
def list_saints(
|
||||||
|
mois: Optional[int] = Query(None, ge=1, le=12),
|
||||||
|
jour: Optional[int] = Query(None, ge=1, le=31),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Liste les saints. Filtrer par mois et/ou jour."""
|
||||||
|
q = select(SaintDuJour)
|
||||||
|
if mois is not None:
|
||||||
|
q = q.where(SaintDuJour.mois == mois)
|
||||||
|
if jour is not None:
|
||||||
|
q = q.where(SaintDuJour.jour == jour)
|
||||||
|
q = q.order_by(SaintDuJour.mois, SaintDuJour.jour)
|
||||||
|
return session.exec(q).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saints/jour", response_model=dict)
|
||||||
|
def get_saints_du_jour(
|
||||||
|
mois: int = Query(..., ge=1, le=12),
|
||||||
|
jour: int = Query(..., ge=1, le=31),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retourne les saints et leur liste parsée pour un jour précis."""
|
||||||
|
row = session.exec(
|
||||||
|
select(SaintDuJour).where(
|
||||||
|
SaintDuJour.mois == mois,
|
||||||
|
SaintDuJour.jour == jour,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return {"mois": mois, "jour": jour, "saints": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
saints_list = json.loads(row.saints_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
saints_list = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mois": row.mois,
|
||||||
|
"jour": row.jour,
|
||||||
|
"saints": saints_list,
|
||||||
|
"source_url": row.source_url,
|
||||||
|
}
|
||||||
@@ -1,10 +1,190 @@
|
|||||||
from fastapi import APIRouter, Depends
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
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
|
||||||
|
from app.config import DATABASE_URL, UPLOAD_DIR
|
||||||
|
|
||||||
router = APIRouter(tags=["réglages"])
|
router = APIRouter(tags=["réglages"])
|
||||||
|
|
||||||
|
_PREV_CPU_USAGE_USEC: int | None = None
|
||||||
|
_PREV_CPU_TS: float | None = None
|
||||||
|
_TEXT_EXTENSIONS = {
|
||||||
|
".txt", ".md", ".markdown", ".json", ".csv", ".log", ".ini", ".yaml", ".yml", ".xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_int_from_paths(paths: list[str]) -> int | None:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read().strip().split()[0]
|
||||||
|
return int(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cgroup_cpu_usage_usec() -> int | None:
|
||||||
|
# cgroup v2
|
||||||
|
try:
|
||||||
|
with open("/sys/fs/cgroup/cpu.stat", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("usage_usec "):
|
||||||
|
return int(line.split()[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# cgroup v1
|
||||||
|
ns = _read_int_from_paths(["/sys/fs/cgroup/cpuacct/cpuacct.usage"])
|
||||||
|
if ns is not None:
|
||||||
|
return ns // 1000
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cpu_quota_cores() -> float | None:
|
||||||
|
# cgroup v2
|
||||||
|
try:
|
||||||
|
with open("/sys/fs/cgroup/cpu.max", "r", encoding="utf-8") as f:
|
||||||
|
quota, period = f.read().strip().split()[:2]
|
||||||
|
if quota == "max":
|
||||||
|
return float(os.cpu_count() or 1)
|
||||||
|
q, p = int(quota), int(period)
|
||||||
|
if p > 0:
|
||||||
|
return max(q / p, 0.01)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# cgroup v1
|
||||||
|
quota = _read_int_from_paths(["/sys/fs/cgroup/cpu/cpu.cfs_quota_us"])
|
||||||
|
period = _read_int_from_paths(["/sys/fs/cgroup/cpu/cpu.cfs_period_us"])
|
||||||
|
if quota is not None and period is not None and quota > 0 and period > 0:
|
||||||
|
return max(quota / period, 0.01)
|
||||||
|
|
||||||
|
return float(os.cpu_count() or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_stats() -> dict[str, Any]:
|
||||||
|
used = _read_int_from_paths(
|
||||||
|
[
|
||||||
|
"/sys/fs/cgroup/memory.current", # cgroup v2
|
||||||
|
"/sys/fs/cgroup/memory/memory.usage_in_bytes", # cgroup v1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
limit = _read_int_from_paths(
|
||||||
|
[
|
||||||
|
"/sys/fs/cgroup/memory.max", # cgroup v2
|
||||||
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Certaines limites cgroup valent "max" ou des sentinelles tres grandes.
|
||||||
|
if limit is not None and limit >= 9_000_000_000_000_000_000:
|
||||||
|
limit = None
|
||||||
|
|
||||||
|
pct = None
|
||||||
|
if used is not None and limit and limit > 0:
|
||||||
|
pct = round((used / limit) * 100, 1)
|
||||||
|
|
||||||
|
return {"used_bytes": used, "limit_bytes": limit, "used_pct": pct}
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_stats() -> dict[str, Any]:
|
||||||
|
target = "/data" if os.path.isdir("/data") else "/"
|
||||||
|
total, used, free = shutil.disk_usage(target)
|
||||||
|
uploads_size = None
|
||||||
|
if os.path.isdir(UPLOAD_DIR):
|
||||||
|
try:
|
||||||
|
uploads_size = sum(
|
||||||
|
os.path.getsize(os.path.join(root, name))
|
||||||
|
for root, _, files in os.walk(UPLOAD_DIR)
|
||||||
|
for name in files
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
uploads_size = None
|
||||||
|
return {
|
||||||
|
"path": target,
|
||||||
|
"total_bytes": total,
|
||||||
|
"used_bytes": used,
|
||||||
|
"free_bytes": free,
|
||||||
|
"used_pct": round((used / total) * 100, 1) if total else None,
|
||||||
|
"uploads_bytes": uploads_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_remove(path: str) -> None:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sqlite_db_path() -> Path | None:
|
||||||
|
prefix = "sqlite:///"
|
||||||
|
if not DATABASE_URL.startswith(prefix):
|
||||||
|
return None
|
||||||
|
raw = DATABASE_URL[len(prefix):]
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
db_path = Path(raw)
|
||||||
|
if db_path.is_absolute():
|
||||||
|
return db_path
|
||||||
|
return (Path.cwd() / db_path).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_directory(zipf: zipfile.ZipFile, source_dir: Path, arc_prefix: str) -> int:
|
||||||
|
count = 0
|
||||||
|
if not source_dir.is_dir():
|
||||||
|
return count
|
||||||
|
for root, _, files in os.walk(source_dir):
|
||||||
|
root_path = Path(root)
|
||||||
|
for name in files:
|
||||||
|
file_path = root_path / name
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
rel = file_path.relative_to(source_dir)
|
||||||
|
arcname = str(Path(arc_prefix) / rel)
|
||||||
|
zipf.write(file_path, arcname=arcname)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_data_text_files(
|
||||||
|
zipf: zipfile.ZipFile,
|
||||||
|
data_root: Path,
|
||||||
|
db_path: Path | None,
|
||||||
|
uploads_dir: Path,
|
||||||
|
) -> int:
|
||||||
|
count = 0
|
||||||
|
if not data_root.is_dir():
|
||||||
|
return count
|
||||||
|
for root, _, files in os.walk(data_root):
|
||||||
|
root_path = Path(root)
|
||||||
|
for name in files:
|
||||||
|
file_path = root_path / name
|
||||||
|
if db_path and file_path == db_path:
|
||||||
|
continue
|
||||||
|
if uploads_dir in file_path.parents:
|
||||||
|
continue
|
||||||
|
if file_path.suffix.lower() not in _TEXT_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
rel = file_path.relative_to(data_root)
|
||||||
|
zipf.write(file_path, arcname=str(Path("data_text") / rel))
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
def get_settings(session: Session = Depends(get_session)):
|
def get_settings(session: Session = Depends(get_session)):
|
||||||
@@ -23,3 +203,312 @@ def update_settings(data: dict, session: Session = Depends(get_session)):
|
|||||||
session.add(row)
|
session.add(row)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/debug/system")
|
||||||
|
def get_debug_system_stats() -> dict[str, Any]:
|
||||||
|
"""Stats runtime du conteneur (utile pour affichage debug UI)."""
|
||||||
|
global _PREV_CPU_USAGE_USEC, _PREV_CPU_TS
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
usage_usec = _read_cgroup_cpu_usage_usec()
|
||||||
|
quota_cores = _cpu_quota_cores()
|
||||||
|
cpu_pct = None
|
||||||
|
|
||||||
|
if usage_usec is not None and _PREV_CPU_USAGE_USEC is not None and _PREV_CPU_TS is not None:
|
||||||
|
delta_usage = usage_usec - _PREV_CPU_USAGE_USEC
|
||||||
|
delta_time_usec = (now - _PREV_CPU_TS) * 1_000_000
|
||||||
|
if delta_time_usec > 0 and quota_cores and quota_cores > 0:
|
||||||
|
cpu_pct = round((delta_usage / (delta_time_usec * quota_cores)) * 100, 1)
|
||||||
|
|
||||||
|
_PREV_CPU_USAGE_USEC = usage_usec
|
||||||
|
_PREV_CPU_TS = now
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "container-cgroup",
|
||||||
|
"cpu": {
|
||||||
|
"usage_usec_total": usage_usec,
|
||||||
|
"quota_cores": quota_cores,
|
||||||
|
"used_pct": cpu_pct,
|
||||||
|
},
|
||||||
|
"memory": _memory_stats(),
|
||||||
|
"disk": _disk_stats(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_backup_zip() -> tuple[Path, str]:
|
||||||
|
"""Crée l'archive ZIP de sauvegarde. Retourne (chemin_tmp, nom_fichier)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ts = now.strftime("%Y%m%d_%H%M%S")
|
||||||
|
db_path = _resolve_sqlite_db_path()
|
||||||
|
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||||
|
data_root = db_path.parent if db_path else uploads_dir.parent
|
||||||
|
|
||||||
|
fd, tmp_zip_path = tempfile.mkstemp(prefix=f"jardin_backup_{ts}_", suffix=".zip")
|
||||||
|
os.close(fd)
|
||||||
|
tmp_zip = Path(tmp_zip_path)
|
||||||
|
|
||||||
|
stats = {"database_files": 0, "upload_files": 0, "text_files": 0}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
||||||
|
if db_path and db_path.is_file():
|
||||||
|
zipf.write(db_path, arcname=f"db/{db_path.name}")
|
||||||
|
stats["database_files"] = 1
|
||||||
|
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
|
||||||
|
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"generated_at_utc": now.isoformat(),
|
||||||
|
"database_url": DATABASE_URL,
|
||||||
|
"paths": {
|
||||||
|
"database_path": str(db_path) if db_path else None,
|
||||||
|
"uploads_path": str(uploads_dir),
|
||||||
|
"data_root": str(data_root),
|
||||||
|
},
|
||||||
|
"included": stats,
|
||||||
|
"text_extensions": sorted(_TEXT_EXTENSIONS),
|
||||||
|
}
|
||||||
|
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
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(
|
||||||
|
path=str(tmp_zip),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=download_name,
|
||||||
|
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))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ router = APIRouter(tags=["tâches"])
|
|||||||
def list_tasks(
|
def list_tasks(
|
||||||
statut: Optional[str] = None,
|
statut: Optional[str] = None,
|
||||||
garden_id: Optional[int] = None,
|
garden_id: Optional[int] = None,
|
||||||
|
planting_id: Optional[int] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
q = select(Task)
|
q = select(Task)
|
||||||
@@ -19,6 +20,9 @@ def list_tasks(
|
|||||||
q = q.where(Task.statut == statut)
|
q = q.where(Task.statut == statut)
|
||||||
if garden_id:
|
if garden_id:
|
||||||
q = q.where(Task.garden_id == garden_id)
|
q = q.where(Task.garden_id == garden_id)
|
||||||
|
if planting_id:
|
||||||
|
q = q.where(Task.planting_id == planting_id)
|
||||||
|
q = q.order_by(Task.echeance, Task.created_at.desc())
|
||||||
return session.exec(q).all()
|
return session.exec(q).all()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ SIGN_TO_TYPE = {
|
|||||||
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
|
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SAINTS_BY_MMDD = {
|
||||||
|
"04-23": "Saint Georges",
|
||||||
|
"04-25": "Saint Marc",
|
||||||
|
"05-11": "Saint Mamert",
|
||||||
|
"05-12": "Saint Pancrace",
|
||||||
|
"05-13": "Saint Servais",
|
||||||
|
"05-14": "Saint Boniface",
|
||||||
|
"05-19": "Saint Yves",
|
||||||
|
"05-25": "Saint Urbain",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DayInfo:
|
class DayInfo:
|
||||||
@@ -29,6 +40,7 @@ class DayInfo:
|
|||||||
montante_descendante: str
|
montante_descendante: str
|
||||||
signe: str
|
signe: str
|
||||||
type_jour: str
|
type_jour: str
|
||||||
|
saint_du_jour: str
|
||||||
perigee: bool
|
perigee: bool
|
||||||
apogee: bool
|
apogee: bool
|
||||||
noeud_lunaire: bool
|
noeud_lunaire: bool
|
||||||
@@ -126,6 +138,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
|
|||||||
lat, lon, dist = v_moon.ecliptic_latlon()
|
lat, lon, dist = v_moon.ecliptic_latlon()
|
||||||
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
|
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
|
||||||
type_jour = SIGN_TO_TYPE[signe]
|
type_jour = SIGN_TO_TYPE[signe]
|
||||||
|
saint_du_jour = SAINTS_BY_MMDD.get(d.strftime("%m-%d"), "")
|
||||||
result.append(
|
result.append(
|
||||||
DayInfo(
|
DayInfo(
|
||||||
date=d.isoformat(),
|
date=d.isoformat(),
|
||||||
@@ -135,6 +148,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
|
|||||||
montante_descendante=montante,
|
montante_descendante=montante,
|
||||||
signe=signe,
|
signe=signe,
|
||||||
type_jour=type_jour,
|
type_jour=type_jour,
|
||||||
|
saint_du_jour=saint_du_jour,
|
||||||
perigee=(d in perigee_days),
|
perigee=(d in perigee_days),
|
||||||
apogee=(d in apogee_days),
|
apogee=(d in apogee_days),
|
||||||
noeud_lunaire=(d in node_days),
|
noeud_lunaire=(d in node_days),
|
||||||
|
|||||||
@@ -32,6 +32,46 @@ _DAILY_FIELDS = [
|
|||||||
"et0_fao_evapotranspiration",
|
"et0_fao_evapotranspiration",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_HOURLY_FIELDS = [
|
||||||
|
"soil_temperature_0cm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: Any) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _value_at(values: list[Any], index: int, default: Any = None) -> Any:
|
||||||
|
if index < 0 or index >= len(values):
|
||||||
|
return default
|
||||||
|
return values[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_soil_average(raw: dict[str, Any]) -> dict[str, float]:
|
||||||
|
"""Construit un mapping ISO-date -> moyenne de soil_temperature_0cm."""
|
||||||
|
hourly = raw.get("hourly", {})
|
||||||
|
times = hourly.get("time", []) or []
|
||||||
|
soils = hourly.get("soil_temperature_0cm", []) or []
|
||||||
|
by_day: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
for idx, ts in enumerate(times):
|
||||||
|
soil = _to_float(_value_at(soils, idx))
|
||||||
|
if soil is None or not isinstance(ts, str) or len(ts) < 10:
|
||||||
|
continue
|
||||||
|
day = ts[:10]
|
||||||
|
by_day.setdefault(day, []).append(soil)
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: round(sum(vals) / len(vals), 2)
|
||||||
|
for day, vals in by_day.items()
|
||||||
|
if vals
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]:
|
def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]:
|
||||||
"""Appelle Open-Meteo et retourne la liste des jours (past_days=7 + forecast=8).
|
"""Appelle Open-Meteo et retourne la liste des jours (past_days=7 + forecast=8).
|
||||||
@@ -50,6 +90,8 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) ->
|
|||||||
]
|
]
|
||||||
for field in _DAILY_FIELDS:
|
for field in _DAILY_FIELDS:
|
||||||
params.append(("daily", field))
|
params.append(("daily", field))
|
||||||
|
for field in _HOURLY_FIELDS:
|
||||||
|
params.append(("hourly", field))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, params=params, timeout=15)
|
r = httpx.get(url, params=params, timeout=15)
|
||||||
@@ -61,22 +103,23 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) ->
|
|||||||
|
|
||||||
daily = raw.get("daily", {})
|
daily = raw.get("daily", {})
|
||||||
dates = daily.get("time", [])
|
dates = daily.get("time", [])
|
||||||
|
soil_by_day = _daily_soil_average(raw)
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
for i, d in enumerate(dates):
|
for i, d in enumerate(dates):
|
||||||
code = int(daily.get("weather_code", [0] * len(dates))[i] or 0)
|
code = int(_value_at(daily.get("weather_code", []), i, 0) or 0)
|
||||||
row = {
|
row = {
|
||||||
"date": d,
|
"date": d,
|
||||||
"t_min": daily.get("temperature_2m_min", [None] * len(dates))[i],
|
"t_min": _to_float(_value_at(daily.get("temperature_2m_min", []), i)),
|
||||||
"t_max": daily.get("temperature_2m_max", [None] * len(dates))[i],
|
"t_max": _to_float(_value_at(daily.get("temperature_2m_max", []), i)),
|
||||||
"pluie_mm": daily.get("precipitation_sum", [0] * len(dates))[i] or 0.0,
|
"pluie_mm": _to_float(_value_at(daily.get("precipitation_sum", []), i, 0.0)) or 0.0,
|
||||||
"vent_kmh": daily.get("wind_speed_10m_max", [0] * len(dates))[i] or 0.0,
|
"vent_kmh": _to_float(_value_at(daily.get("wind_speed_10m_max", []), i, 0.0)) or 0.0,
|
||||||
"wmo": code,
|
"wmo": code,
|
||||||
"label": WMO_LABELS.get(code, f"Code {code}"),
|
"label": WMO_LABELS.get(code, f"Code {code}"),
|
||||||
"humidite_moy": daily.get("relative_humidity_2m_max", [None] * len(dates))[i],
|
"humidite_moy": _to_float(_value_at(daily.get("relative_humidity_2m_max", []), i)),
|
||||||
"sol_0cm": None, # soil_temperature_0cm est hourly uniquement
|
"sol_0cm": soil_by_day.get(d),
|
||||||
"etp_mm": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i],
|
"etp_mm": _to_float(_value_at(daily.get("et0_fao_evapotranspiration", []), i)),
|
||||||
"fetched_at": now_iso,
|
"fetched_at": now_iso,
|
||||||
}
|
}
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|||||||
@@ -90,6 +90,70 @@ def _store_open_meteo() -> None:
|
|||||||
logger.info(f"Open-Meteo stocké : {len(rows)} jours")
|
logger.info(f"Open-Meteo stocké : {len(rows)} jours")
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_station_missing_dates(max_days_back: int = 365) -> None:
|
||||||
|
"""Remplit les dates manquantes de la station météo au démarrage.
|
||||||
|
|
||||||
|
Cherche toutes les dates sans entrée « veille » dans meteostation
|
||||||
|
depuis max_days_back jours en arrière jusqu'à hier (excl. aujourd'hui),
|
||||||
|
puis télécharge les fichiers NOAA mois par mois pour remplir les trous.
|
||||||
|
Un seul appel HTTP par mois manquant.
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from itertools import groupby
|
||||||
|
from app.services.station import fetch_month_summaries
|
||||||
|
from app.models.meteo import MeteoStation
|
||||||
|
from app.database import engine
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
start_date = today - timedelta(days=max_days_back)
|
||||||
|
|
||||||
|
# 1. Dates « veille » déjà présentes en BDD
|
||||||
|
with Session(engine) as session:
|
||||||
|
rows = session.exec(
|
||||||
|
select(MeteoStation.date_heure).where(MeteoStation.type == "veille")
|
||||||
|
).all()
|
||||||
|
existing_dates: set[str] = {dh[:10] for dh in rows}
|
||||||
|
|
||||||
|
# 2. Dates manquantes entre start_date et hier (aujourd'hui exclu)
|
||||||
|
missing: list[date] = []
|
||||||
|
cursor = start_date
|
||||||
|
while cursor < today:
|
||||||
|
if cursor.isoformat() not in existing_dates:
|
||||||
|
missing.append(cursor)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
logger.info("Backfill station : aucune date manquante")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Backfill station : {len(missing)} date(s) manquante(s) à récupérer")
|
||||||
|
|
||||||
|
# 3. Grouper par (année, mois) → 1 requête HTTP par mois
|
||||||
|
def month_key(d: date) -> tuple[int, int]:
|
||||||
|
return (d.year, d.month)
|
||||||
|
|
||||||
|
filled = 0
|
||||||
|
for (year, month), group_iter in groupby(sorted(missing), key=month_key):
|
||||||
|
month_data = fetch_month_summaries(year, month)
|
||||||
|
if not month_data:
|
||||||
|
logger.debug(f"Backfill station : pas de données NOAA pour {year}-{month:02d}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
for d in group_iter:
|
||||||
|
data = month_data.get(d.day)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
date_heure = f"{d.isoformat()}T00:00"
|
||||||
|
if not session.get(MeteoStation, date_heure):
|
||||||
|
session.add(MeteoStation(date_heure=date_heure, type="veille", **data))
|
||||||
|
filled += 1
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Backfill station terminé : {filled} date(s) insérée(s)")
|
||||||
|
|
||||||
|
|
||||||
def setup_scheduler() -> None:
|
def setup_scheduler() -> None:
|
||||||
"""Configure et démarre le scheduler."""
|
"""Configure et démarre le scheduler."""
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Service de collecte des données de la station météo locale WeeWX."""
|
"""Service de collecte des données de la station météo locale WeeWX."""
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -17,13 +19,37 @@ def _safe_float(text: str | None) -> float | None:
|
|||||||
try:
|
try:
|
||||||
cleaned = text.strip().replace(",", ".")
|
cleaned = text.strip().replace(",", ".")
|
||||||
# Retirer unités courantes
|
# Retirer unités courantes
|
||||||
for unit in [" °C", " %", " hPa", " km/h", " W/m²", "°C", "%", "hPa"]:
|
for unit in [
|
||||||
|
" °C",
|
||||||
|
" %", " %",
|
||||||
|
" hPa", " mbar",
|
||||||
|
" km/h", " m/s",
|
||||||
|
" mm/h", " mm",
|
||||||
|
" W/m²", " W/m2",
|
||||||
|
"°C", "%", "hPa", "mbar",
|
||||||
|
]:
|
||||||
cleaned = cleaned.replace(unit, "")
|
cleaned = cleaned.replace(unit, "")
|
||||||
return float(cleaned.strip())
|
return float(cleaned.strip())
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
text = unicodedata.normalize("NFKD", text)
|
||||||
|
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||||
|
text = text.lower()
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_kmh(value: float | None, unit: str | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
u = (unit or "").strip().lower()
|
||||||
|
if u == "m/s":
|
||||||
|
return round(value * 3.6, 1)
|
||||||
|
return round(value, 1)
|
||||||
|
|
||||||
|
|
||||||
def _direction_to_abbr(deg: float | None) -> str | None:
|
def _direction_to_abbr(deg: float | None) -> str | None:
|
||||||
if deg is None:
|
if deg is None:
|
||||||
return None
|
return None
|
||||||
@@ -51,37 +77,51 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
|
|||||||
if item is None:
|
if item is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
desc = item.findtext("description") or ""
|
desc = html.unescape(item.findtext("description") or "")
|
||||||
|
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
|
segments = [seg.strip() for seg in desc.split(";") if seg.strip()]
|
||||||
|
for seg in segments:
|
||||||
|
if ":" not in seg:
|
||||||
|
continue
|
||||||
|
raw_key, raw_value = seg.split(":", 1)
|
||||||
|
key = _normalize(raw_key)
|
||||||
|
value = raw_value.strip()
|
||||||
|
|
||||||
patterns = {
|
if "temperature exterieure" in key or "outside temperature" in key:
|
||||||
"temp_ext": r"(?:Outside|Ext(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
|
result["temp_ext"] = _safe_float(value)
|
||||||
"temp_int": r"(?:Inside|Int(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
|
continue
|
||||||
"humidite": r"(?:Outside\s*)?Hum(?:idity)?\s*[:\s]+(\d+(?:[.,]\d+)?)",
|
if "temperature interieure" in key or "inside temperature" in key:
|
||||||
"pression": r"(?:Bar(?:ometer)?|Pression)\s*[:\s]+(\d+(?:[.,]\d+)?)",
|
result["temp_int"] = _safe_float(value)
|
||||||
"pluie_mm": r"(?:Rain(?:fall)?|Pluie)\s*[:\s]+(\d+(?:[.,]\d+)?)",
|
continue
|
||||||
"vent_kmh": r"(?:Wind\s*Speed|Vent)\s*[:\s]+(\d+(?:[.,]\d+)?)",
|
if "hygrometrie exterieure" in key or "outside humidity" in key:
|
||||||
"uv": r"UV\s*[:\s]+(\d+(?:[.,]\d+)?)",
|
result["humidite"] = _safe_float(value)
|
||||||
"solaire": r"(?:Solar\s*Radiation|Solaire)\s*[:\s]+(\d+(?:[.,]\d+)?)",
|
continue
|
||||||
}
|
if "pression atmospherique" in key or "barometer" in key:
|
||||||
|
result["pression"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "precipitations" in key and "taux" not in key and "rate" not in key:
|
||||||
|
result["pluie_mm"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if key in {"uv", "ultra-violet"} or "ultra violet" in key:
|
||||||
|
result["uv"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "rayonnement solaire" in key or "solar radiation" in key:
|
||||||
|
result["solaire"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if key == "vent" or "wind" in key:
|
||||||
|
speed_match = re.search(r"(-?\d+(?:[.,]\d+)?)\s*(m/s|km/h)?", value, re.IGNORECASE)
|
||||||
|
speed_val = _safe_float(speed_match.group(1)) if speed_match else None
|
||||||
|
speed_unit = speed_match.group(2) if speed_match else None
|
||||||
|
result["vent_kmh"] = _to_kmh(speed_val, speed_unit)
|
||||||
|
|
||||||
for key, pattern in patterns.items():
|
deg_match = re.search(r"(\d{1,3}(?:[.,]\d+)?)\s*°", value)
|
||||||
m = re.search(pattern, desc, re.IGNORECASE)
|
if deg_match:
|
||||||
result[key] = _safe_float(m.group(1)) if m else None
|
result["vent_dir"] = _direction_to_abbr(_safe_float(deg_match.group(1)))
|
||||||
|
continue
|
||||||
|
|
||||||
vent_dir_m = re.search(
|
card_match = re.search(r"\b(N|NE|E|SE|S|SO|O|NO|NNE|ENE|ESE|SSE|SSO|OSO|ONO|NNO)\b", value, re.IGNORECASE)
|
||||||
r"(?:Wind\s*Dir(?:ection)?)\s*[:\s]+([NSEO]{1,2}|Nord|Sud|Est|Ouest|\d+)",
|
result["vent_dir"] = card_match.group(1).upper() if card_match else None
|
||||||
desc, re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if vent_dir_m:
|
|
||||||
val = vent_dir_m.group(1).strip()
|
|
||||||
if val.isdigit():
|
|
||||||
result["vent_dir"] = _direction_to_abbr(float(val))
|
|
||||||
else:
|
|
||||||
result["vent_dir"] = val[:2].upper()
|
|
||||||
else:
|
|
||||||
result["vent_dir"] = None
|
|
||||||
|
|
||||||
return result if any(v is not None for v in result.values()) else None
|
return result if any(v is not None for v in result.values()) else None
|
||||||
|
|
||||||
@@ -90,32 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_noaa_day_line(parts: list[str]) -> dict | None:
|
||||||
|
"""Parse une ligne de données journalières du fichier NOAA WeeWX.
|
||||||
|
|
||||||
|
Format standard : day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
|
||||||
|
"""
|
||||||
|
if not parts or not parts[0].isdigit():
|
||||||
|
return None
|
||||||
|
# Format complet avec timestamps hh:mm en positions 3 et 5
|
||||||
|
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
|
||||||
|
return {
|
||||||
|
"temp_ext": _safe_float(parts[1]),
|
||||||
|
"t_max": _safe_float(parts[2]),
|
||||||
|
"t_min": _safe_float(parts[4]),
|
||||||
|
"pluie_mm": _safe_float(parts[8]),
|
||||||
|
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
|
||||||
|
}
|
||||||
|
# Fallback générique (anciens formats sans hh:mm)
|
||||||
|
return {
|
||||||
|
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
|
||||||
|
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
|
||||||
|
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
|
||||||
|
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
|
||||||
|
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_month_summaries(year: int, month: int, base_url: str = STATION_URL) -> dict[int, dict]:
|
||||||
|
"""Récupère tous les résumés journaliers d'un mois depuis le fichier NOAA WeeWX.
|
||||||
|
|
||||||
|
Retourne un dict {numéro_jour: data_dict} pour chaque jour disponible du mois.
|
||||||
|
Un seul appel HTTP par mois — utilisé pour le backfill groupé.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year:04d}-{month:02d}.txt"
|
||||||
|
r = httpx.get(url, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
result: dict[int, dict] = {}
|
||||||
|
for line in r.text.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if not parts or not parts[0].isdigit():
|
||||||
|
continue
|
||||||
|
data = _parse_noaa_day_line(parts)
|
||||||
|
if data:
|
||||||
|
result[int(parts[0])] = data
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Station fetch_month_summaries({year}-{month:02d}) error: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
|
def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
|
||||||
"""Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX.
|
"""Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX.
|
||||||
|
|
||||||
Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None.
|
Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None.
|
||||||
"""
|
"""
|
||||||
yesterday = (datetime.now() - timedelta(days=1)).date()
|
yesterday = (datetime.now() - timedelta(days=1)).date()
|
||||||
year = yesterday.strftime("%Y")
|
month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
|
||||||
month = yesterday.strftime("%m")
|
return month_data.get(yesterday.day)
|
||||||
day = yesterday.day
|
|
||||||
|
|
||||||
try:
|
|
||||||
url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year}-{month}.txt"
|
|
||||||
r = httpx.get(url, timeout=15)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
for line in r.text.splitlines():
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 7 and parts[0].isdigit() and int(parts[0]) == day:
|
|
||||||
# Format NOAA : jour tmax tmin tmoy precip ...
|
|
||||||
return {
|
|
||||||
"t_max": _safe_float(parts[1]),
|
|
||||||
"t_min": _safe_float(parts[2]),
|
|
||||||
"temp_ext": _safe_float(parts[3]),
|
|
||||||
"pluie_mm": _safe_float(parts[5]),
|
|
||||||
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Station fetch_yesterday_summary error: {e}")
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -1,27 +1,47 @@
|
|||||||
import os
|
import os
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070")
|
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://ai-service:8070")
|
||||||
|
|
||||||
# Mapping class_name YOLO → nom commun français (partiel)
|
# Mapping complet class_name YOLO → Infos détaillées
|
||||||
_NOMS_FR = {
|
_DIAGNOSTICS = {
|
||||||
"Tomato___healthy": "Tomate (saine)",
|
"Tomato___healthy": {
|
||||||
"Tomato___Early_blight": "Tomate (mildiou précoce)",
|
"label": "Tomate (saine)",
|
||||||
"Tomato___Late_blight": "Tomate (mildiou tardif)",
|
"conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
|
||||||
"Pepper__bell___healthy": "Poivron (sain)",
|
"actions": ["Pailler le pied", "Vérifier les gourmands"]
|
||||||
"Apple___healthy": "Pommier (sain)",
|
},
|
||||||
"Potato___healthy": "Pomme de terre (saine)",
|
"Tomato___Early_blight": {
|
||||||
"Grape___healthy": "Vigne (saine)",
|
"label": "Tomate (Alternariose)",
|
||||||
"Corn_(maize)___healthy": "Maïs (sain)",
|
"conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
|
||||||
"Strawberry___healthy": "Fraisier (sain)",
|
"actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
|
||||||
"Peach___healthy": "Pêcher (sain)",
|
},
|
||||||
|
"Tomato___Late_blight": {
|
||||||
|
"label": "Tomate (Mildiou)",
|
||||||
|
"conseil": "Urgent : Le mildiou se propage vite avec l'humidité. Coupez les parties atteintes immédiatement.",
|
||||||
|
"actions": ["Couper parties infectées", "Traitement purin de prêle", "Abriter de la pluie"]
|
||||||
|
},
|
||||||
|
"Pepper__bell___healthy": {
|
||||||
|
"label": "Poivron (sain)",
|
||||||
|
"conseil": "Le poivron aime la chaleur et un sol riche.",
|
||||||
|
"actions": ["Apport de compost", "Arrosage régulier"]
|
||||||
|
},
|
||||||
|
"Potato___healthy": {
|
||||||
|
"label": "Pomme de terre (saine)",
|
||||||
|
"conseil": "Pensez à butter les pieds pour favoriser la production de tubercules.",
|
||||||
|
"actions": ["Butter les pieds"]
|
||||||
|
},
|
||||||
|
"Grape___healthy": {
|
||||||
|
"label": "Vigne (saine)",
|
||||||
|
"conseil": "Surveillez l'apparition d'oïdium si le temps est chaud et humide.",
|
||||||
|
"actions": ["Taille en vert", "Vérifier sous les feuilles"]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def identify(image_bytes: bytes) -> List[dict]:
|
async def identify(image_bytes: bytes) -> List[dict]:
|
||||||
"""Appelle l'ai-service interne et retourne les détections YOLO."""
|
"""Appelle l'ai-service interne et retourne les détections YOLO avec diagnostics."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
|
|||||||
results = []
|
results = []
|
||||||
for det in data[:3]:
|
for det in data[:3]:
|
||||||
cls = det.get("class_name", "")
|
cls = det.get("class_name", "")
|
||||||
|
diag = _DIAGNOSTICS.get(cls, {
|
||||||
|
"label": cls.replace("___", " — ").replace("_", " "),
|
||||||
|
"conseil": "Pas de diagnostic spécifique disponible pour cette espèce.",
|
||||||
|
"actions": []
|
||||||
|
})
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"species": cls.replace("___", " — ").replace("_", " "),
|
"species": cls,
|
||||||
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
|
"common_name": diag["label"],
|
||||||
"confidence": det.get("confidence", 0.0),
|
"confidence": det.get("confidence", 0.0),
|
||||||
|
"conseil": diag["conseil"],
|
||||||
|
"actions": diag["actions"],
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -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,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()
|
||||||
@@ -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()
|
||||||
@@ -1,29 +1,39 @@
|
|||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import SQLModel, create_engine, Session
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
from sqlmodel.pool import StaticPool
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("ENABLE_SCHEDULER", "0")
|
||||||
|
os.environ.setdefault("ENABLE_BOOTSTRAP", "0")
|
||||||
|
|
||||||
import app.models # noqa — force l'enregistrement des modèles
|
import app.models # noqa — force l'enregistrement des modèles
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="session")
|
@pytest.fixture(name="engine")
|
||||||
def session_fixture():
|
def engine_fixture():
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite://",
|
"sqlite://",
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="session")
|
||||||
|
def session_fixture(engine):
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="client")
|
@pytest.fixture(name="client")
|
||||||
def client_fixture(session: Session):
|
def client_fixture(engine):
|
||||||
def get_session_override():
|
def get_session_override():
|
||||||
yield session
|
with Session(engine) as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = get_session_override
|
app.dependency_overrides[get_session] = get_session_override
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tests des filtres catégorie/tag/mois du router astuces."""
|
||||||
|
|
||||||
|
from app.models.astuce import Astuce
|
||||||
|
from app.routers.astuces import list_astuces
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(session):
|
||||||
|
session.add(
|
||||||
|
Astuce(
|
||||||
|
titre="Tomate mildiou",
|
||||||
|
contenu="Surveiller humidité",
|
||||||
|
categorie="maladie",
|
||||||
|
tags='["tomate", "mildiou"]',
|
||||||
|
mois="[6,7,8]",
|
||||||
|
entity_type="plant",
|
||||||
|
entity_id=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Astuce(
|
||||||
|
titre="Semis salade",
|
||||||
|
contenu="Semer en ligne",
|
||||||
|
categorie="plante",
|
||||||
|
tags='["salade", "semis"]',
|
||||||
|
mois="[3,4,9]",
|
||||||
|
entity_type="plant",
|
||||||
|
entity_id=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Astuce(
|
||||||
|
titre="Paillage universel",
|
||||||
|
contenu="Proteger le sol",
|
||||||
|
categorie="jardin",
|
||||||
|
tags='["sol", "eau"]',
|
||||||
|
mois=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_categorie(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie="plante", tag=None, mois=None, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Semis salade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_tag(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie=None, tag="tomate", mois=None, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Tomate mildiou"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_mois_includes_all_year(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie=None, tag=None, mois=12, session=session)
|
||||||
|
titles = {a.titre for a in out}
|
||||||
|
assert "Paillage universel" in titles
|
||||||
|
assert "Tomate mildiou" not in titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_filters(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie="maladie", tag="mildiou", mois=7, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Tomate mildiou"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_entity_filters(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type="plant", entity_id=2, categorie=None, tag=None, mois=None, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Semis salade"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
def test_create_media_normalizes_english_entity_type(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/media",
|
||||||
|
json={
|
||||||
|
"entity_type": "plant",
|
||||||
|
"entity_id": 12,
|
||||||
|
"url": "/uploads/test.webp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["entity_type"] == "plante"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_media_accepts_alias_entity_type_filter(client):
|
||||||
|
client.post(
|
||||||
|
"/api/media",
|
||||||
|
json={
|
||||||
|
"entity_type": "plante",
|
||||||
|
"entity_id": 99,
|
||||||
|
"url": "/uploads/test2.webp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r = client.get("/api/media", params={"entity_type": "plant", "entity_id": 99})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == 1
|
||||||
|
assert r.json()[0]["entity_type"] == "plante"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Tests unitaires du service Open-Meteo enrichi."""
|
||||||
|
|
||||||
|
from datetime import date as real_date
|
||||||
|
|
||||||
|
import app.services.meteo as meteo
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyResponse:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_and_store_forecast_enriched(monkeypatch):
|
||||||
|
payload = {
|
||||||
|
"daily": {
|
||||||
|
"time": ["2026-02-21", "2026-02-22"],
|
||||||
|
"temperature_2m_min": [1.2, 2.3],
|
||||||
|
"temperature_2m_max": [8.4, 9.7],
|
||||||
|
"precipitation_sum": [0.5, 1.0],
|
||||||
|
"wind_speed_10m_max": [12.0, 15.0],
|
||||||
|
"weather_code": [3, 61],
|
||||||
|
"relative_humidity_2m_max": [88, 92],
|
||||||
|
"et0_fao_evapotranspiration": [0.9, 1.1],
|
||||||
|
},
|
||||||
|
"hourly": {
|
||||||
|
"time": [
|
||||||
|
"2026-02-21T00:00",
|
||||||
|
"2026-02-21T01:00",
|
||||||
|
"2026-02-22T00:00",
|
||||||
|
"2026-02-22T01:00",
|
||||||
|
],
|
||||||
|
"soil_temperature_0cm": [4.0, 6.0, 8.0, 10.0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fake_get(*_args, **_kwargs):
|
||||||
|
return _DummyResponse(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(meteo.httpx, "get", _fake_get)
|
||||||
|
|
||||||
|
rows = meteo.fetch_and_store_forecast(lat=45.1, lon=4.0)
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0]["date"] == "2026-02-21"
|
||||||
|
assert rows[0]["label"] == "Couvert"
|
||||||
|
assert rows[0]["sol_0cm"] == 5.0
|
||||||
|
assert rows[0]["etp_mm"] == 0.9
|
||||||
|
assert rows[1]["label"] == "Pluie légère"
|
||||||
|
assert rows[1]["sol_0cm"] == 9.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_and_store_forecast_handles_http_error(monkeypatch):
|
||||||
|
def _boom(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(meteo.httpx, "get", _boom)
|
||||||
|
|
||||||
|
rows = meteo.fetch_and_store_forecast()
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_forecast_filters_from_today(monkeypatch):
|
||||||
|
class _FakeDate(real_date):
|
||||||
|
@classmethod
|
||||||
|
def today(cls):
|
||||||
|
return cls(2026, 2, 22)
|
||||||
|
|
||||||
|
monkeypatch.setattr(meteo, "date", _FakeDate)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
meteo,
|
||||||
|
"fetch_and_store_forecast",
|
||||||
|
lambda *_args, **_kwargs: [
|
||||||
|
{"date": "2026-02-21", "x": 1},
|
||||||
|
{"date": "2026-02-22", "x": 2},
|
||||||
|
{"date": "2026-02-23", "x": 3},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
out = meteo.fetch_forecast(days=14)
|
||||||
|
|
||||||
|
assert [d["date"] for d in out["days"]] == ["2026-02-22", "2026-02-23"]
|
||||||
@@ -12,6 +12,32 @@ def test_list_plants(client):
|
|||||||
assert len(r.json()) == 2
|
assert len(r.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_plant_variety_crud(client):
|
||||||
|
# Créer une plante
|
||||||
|
r = client.post("/api/plants", json={"nom_commun": "Tomate"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
plant_id = r.json()["id"]
|
||||||
|
|
||||||
|
# Créer deux variétés
|
||||||
|
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):
|
||||||
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
|
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
|
||||||
id = r.json()["id"]
|
id = r.json()["id"]
|
||||||
|
|||||||
@@ -26,3 +26,13 @@ def test_update_task_statut(client):
|
|||||||
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["statut"] == "fait"
|
assert r2.json()["statut"] == "fait"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_tasks_by_planting_id(client):
|
||||||
|
client.post("/api/tasks", json={"titre": "Template arrosage", "statut": "template"})
|
||||||
|
client.post("/api/tasks", json={"titre": "Arroser rang 1", "statut": "a_faire", "planting_id": 10})
|
||||||
|
client.post("/api/tasks", json={"titre": "Arroser rang 2", "statut": "a_faire", "planting_id": 11})
|
||||||
|
r = client.get("/api/tasks?planting_id=10")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == 1
|
||||||
|
assert r.json()[0]["planting_id"] == 10
|
||||||
|
|||||||
@@ -16,3 +16,27 @@ def test_delete_tool(client):
|
|||||||
id = r.json()["id"]
|
id = r.json()["id"]
|
||||||
r2 = client.delete(f"/api/tools/{id}")
|
r2 = client.delete(f"/api/tools/{id}")
|
||||||
assert r2.status_code == 204
|
assert r2.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_with_video_url(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/tools",
|
||||||
|
json={
|
||||||
|
"nom": "Tarière",
|
||||||
|
"video_url": "/uploads/demo-outil.mp4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["video_url"] == "/uploads/demo-outil.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_with_notice_texte(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/tools",
|
||||||
|
json={
|
||||||
|
"nom": "Sécateur",
|
||||||
|
"notice_texte": "Aiguiser la lame tous les 3 mois.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["notice_texte"] == "Aiguiser la lame tous les 3 mois."
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mmdd(mmdd: str) -> tuple[int, int]:
|
||||||
|
if len(mmdd) != 4 or not mmdd.isdigit():
|
||||||
|
raise ValueError(f"MMDD invalide: {mmdd}")
|
||||||
|
month = int(mmdd[:2])
|
||||||
|
day = int(mmdd[2:])
|
||||||
|
if not (1 <= month <= 12 and 1 <= day <= 31):
|
||||||
|
raise ValueError(f"MMDD hors plage: {mmdd}")
|
||||||
|
return month, day
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: object) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
out: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
txt = str(item).strip()
|
||||||
|
if txt and txt not in out:
|
||||||
|
out.append(txt)
|
||||||
|
return out
|
||||||
|
txt = str(value).strip()
|
||||||
|
return [txt] if txt else []
|
||||||
|
|
||||||
|
|
||||||
|
def export_files(source_file: Path, saints_out: Path, dictons_out: Path) -> tuple[int, int]:
|
||||||
|
source = json.loads(source_file.read_text(encoding="utf-8"))
|
||||||
|
rows = source.get("data", [])
|
||||||
|
saints_rows: list[dict] = []
|
||||||
|
dictons_rows: list[dict] = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
mmdd = str(row.get("mmdd", "")).strip()
|
||||||
|
if not mmdd:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
month, day = _parse_mmdd(mmdd)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
saints = _as_list(row.get("saints"))
|
||||||
|
dictons = _as_list(row.get("dictons"))
|
||||||
|
source_url = row.get("source_url")
|
||||||
|
|
||||||
|
if saints:
|
||||||
|
saints_rows.append(
|
||||||
|
{
|
||||||
|
"mois": month,
|
||||||
|
"jour": day,
|
||||||
|
"saints": saints,
|
||||||
|
"source_url": source_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if dictons:
|
||||||
|
dictons_rows.append(
|
||||||
|
{
|
||||||
|
"mois": month,
|
||||||
|
"jour": day,
|
||||||
|
"dictons": dictons,
|
||||||
|
"source_url": source_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
saints_rows.sort(key=lambda r: (r["mois"], r["jour"]))
|
||||||
|
dictons_rows.sort(key=lambda r: (r["mois"], r["jour"]))
|
||||||
|
|
||||||
|
saints_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dictons_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
saints_out.write_text(json.dumps(saints_rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
dictons_out.write_text(json.dumps(dictons_rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return len(saints_rows), len(dictons_rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Génère saints_du_jour.json et dictons_du_jour.json depuis saints_YYYY.json."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_2026.json",
|
||||||
|
help="Source JSON issue du scraper annuel",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--saints-out",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
|
||||||
|
help="Fichier JSON de sortie pour les saints",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dictons-out",
|
||||||
|
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
|
||||||
|
help="Fichier JSON de sortie pour les dictons",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
source_file = Path(args.source)
|
||||||
|
saints_out = Path(args.saints_out)
|
||||||
|
dictons_out = Path(args.dictons_out)
|
||||||
|
saints_count, dictons_count = export_files(source_file, saints_out, dictons_out)
|
||||||
|
print(f"Saints exportés : {saints_count} jours -> {saints_out}")
|
||||||
|
print(f"Dictons exportés : {dictons_count} jours -> {dictons_out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS saint_du_jour (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER NOT NULL,
|
||||||
|
saints_json TEXT NOT NULL,
|
||||||
|
source_url TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(mois, jour)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS dicton (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER,
|
||||||
|
texte TEXT NOT NULL,
|
||||||
|
region TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton(mois, jour)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour ON saint_du_jour(mois, jour)")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_rows(path: Path, required_key: str) -> list[dict]:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(f"{path}: format JSON attendu = liste d'objets")
|
||||||
|
rows: list[dict] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if required_key not in item:
|
||||||
|
continue
|
||||||
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _import_saints(conn: sqlite3.Connection, saints_rows: list[dict], mode: str) -> int:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
if mode == "replace":
|
||||||
|
conn.execute("DELETE FROM saint_du_jour")
|
||||||
|
|
||||||
|
for row in saints_rows:
|
||||||
|
mois = int(row["mois"])
|
||||||
|
jour = int(row["jour"])
|
||||||
|
saints = row.get("saints") or []
|
||||||
|
if not isinstance(saints, list):
|
||||||
|
saints = [str(saints)]
|
||||||
|
saints = [str(x).strip() for x in saints if str(x).strip()]
|
||||||
|
if not saints:
|
||||||
|
continue
|
||||||
|
saints_json = json.dumps(saints, ensure_ascii=False)
|
||||||
|
source_url = row.get("source_url")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO saint_du_jour (mois, jour, saints_json, source_url, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(mois, jour)
|
||||||
|
DO UPDATE SET
|
||||||
|
saints_json = excluded.saints_json,
|
||||||
|
source_url = excluded.source_url,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(mois, jour, saints_json, source_url, now),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def _import_dictons(conn: sqlite3.Connection, dicton_rows: list[dict], mode: str, region: str | None) -> int:
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
if mode == "replace":
|
||||||
|
if region:
|
||||||
|
conn.execute("DELETE FROM dicton WHERE region = ?", (region,))
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM dicton")
|
||||||
|
|
||||||
|
for row in dicton_rows:
|
||||||
|
mois = int(row["mois"])
|
||||||
|
jour = int(row["jour"])
|
||||||
|
dictons = row.get("dictons") or []
|
||||||
|
if not isinstance(dictons, list):
|
||||||
|
dictons = [str(dictons)]
|
||||||
|
dictons = [str(x).strip() for x in dictons if str(x).strip()]
|
||||||
|
if not dictons:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for texte in dictons:
|
||||||
|
if mode == "append":
|
||||||
|
exists = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM dicton
|
||||||
|
WHERE mois = ? AND jour = ? AND texte = ? AND COALESCE(region, '') = COALESCE(?, '')
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(mois, jour, texte, region),
|
||||||
|
).fetchone()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?, ?, ?, ?)",
|
||||||
|
(mois, jour, texte, region),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Importe saints du jour + dictons dans SQLite (hors webapp)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
default="data/jardin.db",
|
||||||
|
help="Chemin SQLite cible",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--saints-json",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
|
||||||
|
help="JSON saints_du_jour",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dictons-json",
|
||||||
|
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
|
||||||
|
help="JSON dictons_du_jour",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["replace", "append"],
|
||||||
|
default="replace",
|
||||||
|
help="replace: purge puis recharge ; append: ajoute sans doublons",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region",
|
||||||
|
default="National",
|
||||||
|
help="Région stockée dans table dicton (vide pour NULL)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db)
|
||||||
|
saints_path = Path(args.saints_json)
|
||||||
|
dictons_path = Path(args.dictons_json)
|
||||||
|
region = args.region.strip() or None
|
||||||
|
|
||||||
|
saints_rows = _load_json_rows(saints_path, "saints")
|
||||||
|
dicton_rows = _load_json_rows(dictons_path, "dictons")
|
||||||
|
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
_ensure_schema(conn)
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
saints_count = _import_saints(conn, saints_rows, args.mode)
|
||||||
|
dictons_count = _import_dictons(conn, dicton_rows, args.mode, region)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"Import terminé ({args.mode})")
|
||||||
|
print(f" Saints importés: {saints_count}")
|
||||||
|
print(f" Dictons importés: {dictons_count} (region={region!r})")
|
||||||
|
print(f" Base: {db_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import des saints et dictons dans la base de données de la webapp Jardin.
|
||||||
|
|
||||||
|
Les données sont INDÉPENDANTES DE L'ANNÉE : stockées par (mois, jour) uniquement.
|
||||||
|
|
||||||
|
Sources JSON :
|
||||||
|
- saints_du_jour.json → table saint_du_jour
|
||||||
|
- dictons_du_jour.json → table dicton
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
# Import complet (remplace les données existantes)
|
||||||
|
python import_webapp_db.py
|
||||||
|
|
||||||
|
# Import vers une autre DB
|
||||||
|
python import_webapp_db.py --db /chemin/vers/jardin.db
|
||||||
|
|
||||||
|
# Mode append (ajoute sans supprimer les existants)
|
||||||
|
python import_webapp_db.py --mode append
|
||||||
|
|
||||||
|
# Seulement les saints ou seulement les dictons
|
||||||
|
python import_webapp_db.py --only saints
|
||||||
|
python import_webapp_db.py --only dictons
|
||||||
|
|
||||||
|
# Tagger les dictons avec une région
|
||||||
|
python import_webapp_db.py --region "Auvergne"
|
||||||
|
|
||||||
|
# Prévisualisation sans modification
|
||||||
|
python import_webapp_db.py --dry-run
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Chemins par défaut relatifs à ce script
|
||||||
|
_HERE = Path(__file__).parent
|
||||||
|
_REPO_ROOT = _HERE.parent.parent # racine du projet jardin/
|
||||||
|
DEFAULT_DB = _REPO_ROOT / "data" / "jardin.db"
|
||||||
|
DEFAULT_SAINTS_JSON = _HERE / "saints_du_jour.json"
|
||||||
|
DEFAULT_DICTONS_JSON = _HERE / "dictons_du_jour.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Schémas SQL attendus par la webapp
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
SQL_CREATE_SAINT_DU_JOUR = """
|
||||||
|
CREATE TABLE IF NOT EXISTS saint_du_jour (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER NOT NULL,
|
||||||
|
saints_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
source_url TEXT,
|
||||||
|
UNIQUE (mois, jour)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQL_CREATE_INDEX_SAINT = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour
|
||||||
|
ON saint_du_jour (mois, jour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# La table dicton existe déjà dans la webapp (SQLModel).
|
||||||
|
# Colonnes: id, mois, jour, texte, region
|
||||||
|
# On s'assure juste qu'elle existe (ne recrée pas si déjà présente).
|
||||||
|
SQL_CREATE_DICTON = """
|
||||||
|
CREATE TABLE IF NOT EXISTS dicton (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER,
|
||||||
|
texte TEXT NOT NULL,
|
||||||
|
region TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQL_CREATE_INDEX_DICTON = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton (mois, jour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Chargement JSON
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_json(path: Path, label: str) -> list:
|
||||||
|
if not path.exists():
|
||||||
|
print(f"[ERREUR] Fichier introuvable : {path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
print(f"[ERREUR] {label} : attendu une liste JSON, obtenu {type(data).__name__}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" {label} : {len(data)} entrées chargées depuis {path.name}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Import saints
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def import_saints(conn: sqlite3.Connection, data: list, mode: str, dry_run: bool) -> int:
|
||||||
|
"""Importe les saints dans saint_du_jour. Retourne le nombre d'entrées traitées."""
|
||||||
|
conn.execute(SQL_CREATE_SAINT_DU_JOUR)
|
||||||
|
conn.execute(SQL_CREATE_INDEX_SAINT)
|
||||||
|
|
||||||
|
if mode == "replace" and not dry_run:
|
||||||
|
conn.execute("DELETE FROM saint_du_jour")
|
||||||
|
print(" [replace] saint_du_jour vidée")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for entry in data:
|
||||||
|
mois = entry.get("mois")
|
||||||
|
jour = entry.get("jour")
|
||||||
|
saints = entry.get("saints", [])
|
||||||
|
source_url = entry.get("source_url")
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not isinstance(mois, int) or not isinstance(jour, int):
|
||||||
|
continue
|
||||||
|
if not (1 <= mois <= 12 and 1 <= jour <= 31):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normaliser en liste de strings non vides
|
||||||
|
if isinstance(saints, str):
|
||||||
|
saints = [saints]
|
||||||
|
saints = [s.strip() for s in saints if isinstance(s, str) and s.strip()]
|
||||||
|
saints_json = json.dumps(saints, ensure_ascii=False)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mode == "append":
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM saint_du_jour WHERE mois=? AND jour=?", (mois, jour)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO saint_du_jour (mois, jour, saints_json, source_url) VALUES (?,?,?,?)",
|
||||||
|
(mois, jour, saints_json, source_url),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if skipped:
|
||||||
|
print(f" saints ignorés (mode append, déjà présents) : {skipped}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Import dictons
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def import_dictons(conn: sqlite3.Connection, data: list, mode: str, region: str | None, dry_run: bool) -> int:
|
||||||
|
"""Importe les dictons dans la table dicton. Retourne le nombre d'entrées insérées."""
|
||||||
|
conn.execute(SQL_CREATE_DICTON)
|
||||||
|
conn.execute(SQL_CREATE_INDEX_DICTON)
|
||||||
|
|
||||||
|
if mode == "replace" and not dry_run:
|
||||||
|
conn.execute("DELETE FROM dicton")
|
||||||
|
print(" [replace] dicton vidée")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for entry in data:
|
||||||
|
mois = entry.get("mois")
|
||||||
|
jour = entry.get("jour") # peut être None
|
||||||
|
dictons = entry.get("dictons", [])
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not isinstance(mois, int):
|
||||||
|
continue
|
||||||
|
if not (1 <= mois <= 12):
|
||||||
|
continue
|
||||||
|
if jour is not None and not (1 <= jour <= 31):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normaliser
|
||||||
|
if isinstance(dictons, str):
|
||||||
|
dictons = [dictons]
|
||||||
|
dictons = [d.strip() for d in dictons if isinstance(d, str) and d.strip()]
|
||||||
|
|
||||||
|
for texte in dictons:
|
||||||
|
if dry_run:
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mode == "append":
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM dicton WHERE mois=? AND jour IS ? AND texte=?",
|
||||||
|
(mois, jour, texte),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?,?,?,?)",
|
||||||
|
(mois, jour, texte, region),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Point d'entrée
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Import saints et dictons dans la BDD webapp Jardin (indépendant de l'année)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--db", default=str(DEFAULT_DB),
|
||||||
|
help=f"Chemin vers jardin.db (défaut: {DEFAULT_DB})")
|
||||||
|
parser.add_argument("--saints-json", default=str(DEFAULT_SAINTS_JSON),
|
||||||
|
help=f"Fichier saints_du_jour.json (défaut: {DEFAULT_SAINTS_JSON.name})")
|
||||||
|
parser.add_argument("--dictons-json", default=str(DEFAULT_DICTONS_JSON),
|
||||||
|
help=f"Fichier dictons_du_jour.json (défaut: {DEFAULT_DICTONS_JSON.name})")
|
||||||
|
parser.add_argument("--mode", choices=["replace", "append"], default="replace",
|
||||||
|
help="replace = purge + recharge (défaut) | append = ajoute sans doublons")
|
||||||
|
parser.add_argument("--only", choices=["saints", "dictons", "both"], default="both",
|
||||||
|
help="Importer uniquement saints, dictons, ou les deux (défaut: both)")
|
||||||
|
parser.add_argument("--region", default=None,
|
||||||
|
help="Tag région pour les dictons (ex: 'Auvergne', 'National'). Vide = NULL")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Simulation : affiche les comptages sans modifier la BDD")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db)
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERREUR] Base de données introuvable : {db_path}", file=sys.stderr)
|
||||||
|
print(" → Vérifiez que la webapp a démarré au moins une fois pour créer la BDD.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
region = args.region if args.region else None
|
||||||
|
do_saints = args.only in ("saints", "both")
|
||||||
|
do_dictons = args.only in ("dictons", "both")
|
||||||
|
|
||||||
|
print(f"\n=== Import saints/dictons → {db_path} ===")
|
||||||
|
print(f" Mode : {args.mode}")
|
||||||
|
print(f" Scope : {args.only}")
|
||||||
|
if region:
|
||||||
|
print(f" Région: {region}")
|
||||||
|
if args.dry_run:
|
||||||
|
print(" *** DRY-RUN — aucune modification en BDD ***")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Charger les JSON nécessaires
|
||||||
|
saints_data = load_json(Path(args.saints_json), "saints_du_jour") if do_saints else []
|
||||||
|
dictons_data = load_json(Path(args.dictons_json), "dictons_du_jour") if do_dictons else []
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Connexion SQLite
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=OFF") # pas de cascade gestion manuelle
|
||||||
|
|
||||||
|
saints_count = 0
|
||||||
|
dictons_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
if do_saints:
|
||||||
|
saints_count = import_saints(conn, saints_data, args.mode, args.dry_run)
|
||||||
|
if do_dictons:
|
||||||
|
dictons_count = import_dictons(conn, dictons_data, args.mode, region, args.dry_run)
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"\n[ERREUR] Transaction annulée : {exc}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Résumé
|
||||||
|
print()
|
||||||
|
print("=== Résultat ===")
|
||||||
|
if do_saints:
|
||||||
|
prefix = "[DRY-RUN] " if args.dry_run else ""
|
||||||
|
print(f" {prefix}Saints importés : {saints_count} jours → table saint_du_jour")
|
||||||
|
if do_dictons:
|
||||||
|
prefix = "[DRY-RUN] " if args.dry_run else ""
|
||||||
|
print(f" {prefix}Dictons importés : {dictons_count} entrées → table dicton")
|
||||||
|
print(f" Base : {db_path}")
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n → Relancez sans --dry-run pour appliquer les changements.")
|
||||||
|
else:
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
print(f"\n Import terminé le {ts}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Codex - Elements développes
|
||||||
|
|
||||||
|
Ce document liste les éléments développés dans le projet `jardin`.
|
||||||
|
|
||||||
|
## 1) Calendrier lunaire
|
||||||
|
|
||||||
|
- Script principal: `calendrier_lunaire/lunar_calendar.py`
|
||||||
|
- Tests: `calendrier_lunaire/test_lunar_calendar.py`
|
||||||
|
- Sorties JSON générées:
|
||||||
|
- `calendrier_lunaire/calendrier_lunaire_2026.json`
|
||||||
|
- `calendrier_lunaire/calendrier_lunaire_2027.json`
|
||||||
|
- Données/ressources:
|
||||||
|
- `calendrier_lunaire/de421.bsp`
|
||||||
|
- `calendrier_lunaire/deep_search.md`
|
||||||
|
- `calendrier_lunaire/deep_search1.md`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Calcul des phases lunaires (nouvelle lune, quartiers, pleine lune)
|
||||||
|
- Génération annuelle en JSON
|
||||||
|
- Ajout des données saints du jour
|
||||||
|
- Ajout lever/coucher soleil et lune + durées
|
||||||
|
- Ajout transitions intra-journée (jour type / montante-descendante)
|
||||||
|
- Alignement zodiacal sidéral (constellations)
|
||||||
|
|
||||||
|
## 2) Saints et dictons
|
||||||
|
|
||||||
|
Dossier dédié: `calendrier_lunaire/saints_dictons/`
|
||||||
|
|
||||||
|
- Sources et consignes:
|
||||||
|
- `calendrier_lunaire/saints_dictons/consigne_scrap_saint_dictons.md`
|
||||||
|
- `calendrier_lunaire/saints_dictons/saints_france.json`
|
||||||
|
- Parsing:
|
||||||
|
- `calendrier_lunaire/saints_dictons/parse_saints_dictons.py`
|
||||||
|
- Scraping annuel:
|
||||||
|
- `calendrier_lunaire/saints_dictons/saint_dicton_year_scraper.py`
|
||||||
|
- Exemple de sortie:
|
||||||
|
- `calendrier_lunaire/saints_dictons/saints_2026.json`
|
||||||
|
- Exports JSON séparés:
|
||||||
|
- `calendrier_lunaire/saints_dictons/saints_du_jour.json`
|
||||||
|
- `calendrier_lunaire/saints_dictons/dictons_du_jour.json`
|
||||||
|
- Scripts hors webapp:
|
||||||
|
- `calendrier_lunaire/saints_dictons/export_saints_dictons_json.py`
|
||||||
|
- `calendrier_lunaire/saints_dictons/import_saints_dictons_db.py`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Format JSON cible: `date`, `saints[]`, `dictons[]`
|
||||||
|
- Support de formats de date multiples
|
||||||
|
- Ajout de logs de progression dans le scraper
|
||||||
|
- Enregistrement JSON (pas uniquement affichage terminal)
|
||||||
|
- Génération de 2 jeux de données dédiés (saints / dictons)
|
||||||
|
- Import automatisé en SQLite (`replace` ou `append`)
|
||||||
|
- Création table `saint_du_jour` si absente + alimentation table `dicton`
|
||||||
|
|
||||||
|
## 3) Prévisions météo Open-Meteo
|
||||||
|
|
||||||
|
- Script: `prevision meteo/open_meteo_garden_forecast.py`
|
||||||
|
- Consignes:
|
||||||
|
- `prevision meteo/consigne.md`
|
||||||
|
- `prevision meteo/consigne_open_meteo.md`
|
||||||
|
- Mapping WMO:
|
||||||
|
- `prevision meteo/wmo_code.json`
|
||||||
|
- Exemple de sortie:
|
||||||
|
- `prevision meteo/prevision meteo/output/forecast.json`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Appel Open-Meteo avec variables hourly/current étendues
|
||||||
|
- Intégration `past_days` + `forecast_days`
|
||||||
|
- Affichage tableau synthétique
|
||||||
|
- Export JSON complet
|
||||||
|
- Correction de sérialisation JSON
|
||||||
|
|
||||||
|
## 4) Station météo locale (WeeWX)
|
||||||
|
|
||||||
|
- Script: `station_meteo/local_station_weather.py`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Récupération des données actuelles (RSS)
|
||||||
|
- Récupération et parsing des résumés quotidiens
|
||||||
|
- Récupération de données journalières par date via option CLI
|
||||||
|
- Valeur par défaut: date de la veille si non fournie
|
||||||
|
- Normalisation des types (float/int)
|
||||||
|
- Structure JSON clarifiée: suppression de `yesterday`, ajout `day_data.date` (date complète)
|
||||||
|
- Enrichissement des blocs: `current`, `stats_today`, `astrology`, `station_info`
|
||||||
|
|
||||||
|
## 5) YOLO - Détection feuille/plante
|
||||||
|
|
||||||
|
Dossier: `test_yolo/`
|
||||||
|
|
||||||
|
- Script test: `test_yolo/test_yolo_leaf.py`
|
||||||
|
- Documentation: `test_yolo/README.md`
|
||||||
|
- Données images: `test_yolo/image/`
|
||||||
|
- Sorties:
|
||||||
|
- `test_yolo/test_yolo/output/detections.json`
|
||||||
|
- `test_yolo/test_yolo/output/annotated.jpg`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Migration vers `ultralytics` (sans `ultralyticsplus`)
|
||||||
|
- Support modèle local ou repo Hugging Face (`best.pt`)
|
||||||
|
- Sortie JSON des détections
|
||||||
|
- Génération image annotée
|
||||||
|
- Traduction des labels vers le français (`class_name_fr`)
|
||||||
|
|
||||||
|
## 6) Assets icônes
|
||||||
|
|
||||||
|
- Icônes lune: `icons/moon/*.svg`
|
||||||
|
- `new_moon.svg`, `waxing_crescent.svg`, `first_quarter.svg`, `waxing_gibbous.svg`, `full_moon.svg`, `waning_gibbous.svg`, `last_quarter.svg`, `waning_crescent.svg`
|
||||||
|
- Icônes météo: `icons/weather/*.svg`
|
||||||
|
- Codes WMO usuels + `risque_canicule.svg` + `risque_gèle.svg`
|
||||||
|
|
||||||
|
## 7) Notes de pilotage
|
||||||
|
|
||||||
|
- Plan d'amélioration: `amelioration.md`
|
||||||
|
- Plan météo/astuces: `avancement.md` (contient plan + logs de session)
|
||||||
|
|
||||||
|
## 8) Webapp - évolutions récentes
|
||||||
|
|
||||||
|
### Planning
|
||||||
|
- `frontend/src/views/PlanningView.vue`
|
||||||
|
- Passage en vue 4 semaines (28 jours)
|
||||||
|
- Navigation par période: `Prev`, `Today`, `Next`
|
||||||
|
- Sélection d'un jour avec panneau "Détail du jour"
|
||||||
|
- Marqueurs visuels par tâches non terminées (ronds colorés par priorité)
|
||||||
|
|
||||||
|
### Outils
|
||||||
|
- `frontend/src/views/OutilsView.vue`
|
||||||
|
- Le champ notice est désormais une zone de texte libre (`notice_texte`)
|
||||||
|
- Conserve compatibilité lecture des anciennes notices fichier (`notice_fichier_url`)
|
||||||
|
- Test backend ajouté: `backend/tests/test_tools.py::test_tool_with_notice_texte`
|
||||||
|
|
||||||
|
### Réglages
|
||||||
|
- `backend/app/routers/settings.py`
|
||||||
|
- `frontend/src/views/ReglagesView.vue`
|
||||||
|
- Sauvegarde ZIP téléchargeable (BDD + uploads + fichiers texte + manifeste)
|
||||||
|
- Liens rapides de test API backend:
|
||||||
|
- Swagger: `/docs`
|
||||||
|
- ReDoc: `/redoc`
|
||||||
|
- Santé: `/api/health`
|
||||||
@@ -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: 81 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 18 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: 21 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
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: 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 |