Compare commits

...

43 Commits

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:28:19 +01:00
gilles d4d104b2c2 fix(plantes): import_graines — idempotence plant_variety + media + import unicodedata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:08:32 +01:00
gilles 0f5ebd25be feat(plantes): script import graines + arbustre (JSON → plant_variety)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:04:56 +01:00
gilles 1b7a8b8f25 fix(db): activer PRAGMA foreign_keys=ON pour SQLite (ON DELETE CASCADE effectif)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:58:09 +01:00
gilles 1095edffdb feat(plantes): router plants — GET retourne varieties + CRUD /varieties 2026-03-08 18:54:30 +01:00
gilles 8edcf5fd8d feat(plantes): migrate.py — sections plant_variety + temp_germination/temps_levee_j 2026-03-08 18:52:48 +01:00
gilles 1d4708585e fix(plantes): script migration — try/except rollback + DB_PATH absolu + commentaires IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:51:50 +01:00
gilles 18ee6e1fbe feat(plantes): script migration one-shot plant_variety + fusion haricot grimpant 2026-03-08 17:36:11 +01:00
gilles 4a7ecffbb8 fix(plantes): PlantImage __tablename__ explicite + varieties Field(default_factory=list)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 17:34:55 +01:00
gilles b41a0f817c fix(plantes): PlantWithVarieties — ajouter created_at manquant 2026-03-08 14:11:35 +01:00
gilles de967141ba feat(plantes): modèle Plant épuré + PlantVariety + PlantWithVarieties 2026-03-08 14:10:12 +01:00
gilles 734c33a12e docs: plan implémentation plantes/variétés — 8 tâches 2026-03-08 14:06:21 +01:00
gilles e40351e0be docs: design plantes/variétés — Option B 2 tables + import graines 2026-03-08 14:01:42 +01:00
gilles f8e64d6a2c feat(intrants): IntratsView with Achats + Fabrications tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:24:26 +01:00
gilles 80173171b3 feat(intrants): add /intrants route + sidebar nav 2026-03-08 13:19:11 +01:00
gilles 8bf281a3fb feat(intrants): frontend API clients + Pinia stores 2026-03-08 10:10:10 +01:00
gilles d2f2f6d7d7 feat(intrants): register achats + fabrications routers 2026-03-08 10:09:23 +01:00
gilles 107640e561 feat(intrants): CRUD + statut router for fabrications 2026-03-08 10:08:27 +01:00
gilles a5c503e1f3 feat(intrants): CRUD router for achats 2026-03-08 10:08:14 +01:00
gilles 75f18c9eb8 feat(intrants): add migration for achat_intrant + fabrication tables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:07:28 +01:00
gilles faa469e688 feat(intrants): add AchatIntrant + Fabrication SQLModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 10:05:53 +01:00
gilles 14636bd58f 8 mars 2026-03-08 10:04:14 +01:00
gilles 7967f63fea avant 50 2026-03-01 07:21:46 +01:00
gilles 9db5cbf236 before gemiin 2026-02-22 22:18:32 +01:00
gilles fb33540bb0 refactor(settings): extraire UI_SIZE_DEFAULTS partagé + catch erreur saveUiSettings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 20:15:57 +01:00
gilles 155de270dc feat(settings): sliders taille texte/menu/icônes/miniatures + CSS vars globales
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 20:12:22 +01:00
gilles 0d3bf205b1 feat(saints-dictons): table saint_du_jour + API + import standalone 366j
- Nouveau modèle SaintDuJour (mois+jour+saints_json, indépendant de l'année)
- Router /api/saints et /api/saints/jour (mois+jour → liste de prénoms)
- Script standalone import_webapp_db.py : saints_du_jour.json → saint_du_jour,
  dictons_du_jour.json → dicton ; modes replace/append, --dry-run, --region
- Données JSON 366 jours : saints_du_jour.json + dictons_du_jour.json
- Scripts scraping/export calendrier_lunaire/saints_dictons/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 19:54:47 +01:00
gilles a9f0556d73 fix(router): guard OperationalError si tables météo inexistantes 2026-02-22 19:16:46 +01:00
260 changed files with 41082 additions and 5778 deletions
+13 -1
View File
@@ -65,7 +65,19 @@
"Bash(find:*)",
"Bash(.venv/bin/python:*)",
"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": [
"/home/gilles/Documents/vscode/jardin/frontend/src",
+8
View File
@@ -8,3 +8,11 @@ frontend/node_modules/
frontend/dist/
*.egg-info/
.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/
+90
View File
@@ -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`.
+65 -60
View File
@@ -1,84 +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
- ajout icones representatives
- [ ] ajout icones representatives dimensionnables
jardin :
- [ ] ajouter les caracteristiques pour un jardin: photo, geolocalisation, type de terre, ph, ensoleillement, exposition, dimension,surface, ...
-
- [ ] dans l'edition du jardin definir si carré potager avec dimension x;y en cm
- [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 :
- [ ] header : varietés => remplacer par plante ( pareil dans tous 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)
- [ ] plante du potager, fleur, arbre ou arbustre
- [ ] 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,
- [ ] association des plantes et plantes ne devant pas etre planté a proximite
- [x] header : varietés => remplacé par plante (partout dans le programme)
- [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, ...
- [x] plante du potager, fleur, arbre ou arbuste
- [x] liste de plantes courantes seedée : carotte, tomate, ail, oignon, haricot, petits pois, poireaux, pomme de terre, salade, fraise, framboise, persil, échalote, courgette, chou-fleur, chou boule, ...
- [x] 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:
- [ ] brainstorming pour preremplir la liste des taches courantes au jardin
- [ ] un tache peut etre unique ou avoir une frequence
- [ ] une tache peut utiliser un outils et s'applique a une platantion ( plantation: plantes dans une zone d'un jardin)
- [x] liste des tâches courantes au jardin pré-remplie (seed)
- [x] une tâche peut être unique ou avoir une fréquence (frequence_jours + date_prochaine)
- [x] une tâche peut utiliser un outil et s'applique à une plantation
outils:
- [ ] brainstorming pour ajouter des outils de jardinage
- [ ] liste dans le header
- [ ] créer une 1ere liste d'outils commun du jardin (grelinete, pelle, beche, pioche, sarcloir,....)
- [x] outils de jardinage : CRUD complet, catégories
- [x] liste dans le header (OutilsView)
- [x] re liste d'outils communs seedée (grelinette, pelle, bêche, pioche, sarcloir, ...)
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:
- [ ] renommer le header lunaire en calendrier ( lunaire, dictons, meteo, taches, )
- [ ] brainstorming
- [ ] calendrier lunaire avec icones et texte
- [ ] calendrier ajouter dictons courant ( brainstorming region france, auvergne, haute-loire, yssingeaux)
- analyse le dossier calendrier_lunaire
- [x] renommer le header lunaire en calendrier (Météo + Lunaire + Dictons + navigation)
- [x] calendrier lunaire avec icônes et texte (phases + types jours : racine/feuille/fleur/fruit)
- [x] dictons courants (région France, Auvergne, Haute-Loire, Yssingeaux)
- [x] dossier calendrier_lunaire analysé et intégré
meteo:
- [ ] brainstorming
- [ ] calendrier bi-hebdo ? avec prevision meteo
- 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)
- recupere les infos sur https://open-meteo.com/ une fois par heure pour les prevision ( brainstorming a partir des script d'essai)
- 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
- [x] station météo locale (WeeWX) : données veille 1×/jour + actuelles 1×/heure
- [x] open-meteo.com : prévisions 1×/heure
- [x] tableau journalier synthétique (passé/présent/futur, colonne station + open-meteo)
- [x] dossiers prevision_meteo et station_meteo analysés et intégrés
astuces :
- [x] astuces pour les plantes, le jardin, les tâches : CRUD + filtres catégorie/mois/tag
- [ ] "Astuce du jour" dans le dashboard
capteur:
- [ ] recuperation de capteur possible: ensoleillement, temperature ambiante, temperature du sol, humidite de l'air, humidite du sol, ph du sol,
- [ ] configuration via serveur mqtt ( topic et payload)
- [ ] brainstorming
- capteur exterieur et capteur serre
- [ ] récupération de capteurs : ensoleillement, température ambiante/sol, humidité air/sol, pH sol
- [ ] configuration via serveur MQTT (topic et payload)
- [ ] capteur extérieur et capteur serre
reglages :
- [ ] application en mode desktop et pour smartphone ( responsive ?)
- [ ] section pour chaque type: interface, jardin, plante, taches, calendrier, planning
- [ ] backup et restaure (toutes les données: bdd, photo, pdf, txt)
- [ ] 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)
- reglage url station meteo local et site distant
reglages :
- [x] application responsive desktop et smartphone
- [x] backup ZIP (DB + uploads) téléchargeable
- [ ] restauration depuis ZIP (upload + restore)
- [ ] sections réglages par type : interface, jardin, plante, tâches, calendrier, planning
- [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:
- [ ] ajouter possibiliter de saisir des quantites recoltés et a quelles dates ( brainstorming)
- [ ] ajouter la possibiliter de suivre des maladies (mildioux ), des traitement, des ravageurs: limaces, taupe, chenille, ...
recolte:
- [x] saisie des quantités récoltées avec dates (unités : kg/g/unités/litres/bottes)
- [x] suivi maladies (mildiou), traitements, ravageurs (limaces, taupe, chenille) via Observations
frontend :
- [ ] icones pour objet dimensionnable ds setting : jardin, plantes, tache, calendrier, meteo, outils
- [ ] icones pour plantes dimensionnable ds setting : tomate, pomme de terre, salade, carotte, ...
- [ ] mode editions pour pouvoir modifier les different element, plantes, jardin, taches, calendrier, ...
- [ ] ajouter des images depuis iphones ( appareil photo)
- [ ] ajouter des pdf ou des annotation, des url dvalable pour tous types d'objet: jardin, plantes,outils,
- verifier que l'application s'affiche correctement sur smartphone
- utilise le dossier icons pour le calendrier lunaire et la meteo ( icone svg adapter taille d'affichage dans setting)
frontend :
- [ ] icônes pour objets dimensionnables dans setting : jardin, plantes, tâche, calendrier, météo, outils
- [ ] icônes pour plantes dimensionnables dans setting : tomate, pomme de terre, salade, carotte, ...
- [x] mode édition pour les différents éléments (plantes, jardin, tâches, calendrier) via modales
- [ ] ajouter des images depuis iPhone (appareil photo natif)
- [x] PDF, annotations, URL pour tous types d'objets : jardin, plantes, outils (Attachments)
- [ ] vérifier affichage correct sur smartphone (à tester)
- [ ] utiliser le dossier icons pour le calendrier lunaire et la météo (icônes SVG adaptatifs dans settings)
bibliotehque 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)
- brainstorming local ai detection style yolo ( fichier consigne_yolo.md)
bibliotheque photo:
- [x] bibliothèque (plante, légume, fleur, arbres et arbrisseaux, adventices) + galerie lightbox
- [x] identification via Pl@ntNet API (api key configurée)
- [x] détection locale style YOLO (consigne_yolo.md intégrée)
backend :
- [ ] methode simple pour mettre a jours la base de donnée ; brainstorming
backend :
- [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)
- [ ] mise a jours bdd via api puis je peut ajouter des script dans mon openclaw]
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%
+40
View File
@@ -7457,3 +7457,43 @@ You've hit your limit · resets 7pm (Europe/Paris)
- 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
+8
View File
@@ -1,9 +1,17 @@
from sqlmodel import SQLModel, create_engine, Session
from sqlalchemy import event
from app.config import DATABASE_URL
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():
with Session(engine) as session:
yield session
+16 -2
View File
@@ -1,3 +1,4 @@
import asyncio
import os
from contextlib import asynccontextmanager
@@ -23,15 +24,22 @@ async def lifespan(app: FastAPI):
from app.seed import run_seed
run_seed()
if ENABLE_SCHEDULER:
from app.services.scheduler import 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
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
from app.services.scheduler import scheduler
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(
CORSMiddleware,
@@ -49,11 +57,14 @@ from app.routers import ( # noqa
media,
tools,
dictons,
saints,
astuces,
recoltes,
lunar,
meteo,
identify,
achats,
fabrications,
)
app.include_router(gardens.router, prefix="/api")
@@ -64,11 +75,14 @@ app.include_router(settings.router, prefix="/api")
app.include_router(media.router, prefix="/api")
app.include_router(tools.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(recoltes.router, prefix="/api")
app.include_router(lunar.router, prefix="/api")
app.include_router(meteo.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")
+45
View File
@@ -13,6 +13,21 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("maladies_courantes", "TEXT", None),
("astuces_culture", "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": [
("latitude", "REAL", None),
@@ -40,6 +55,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | 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),
@@ -50,6 +66,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | 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": [
# ancien nom de table → migration vers "plant" si présente
@@ -67,6 +84,34 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | 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),
],
}
+3 -1
View File
@@ -1,5 +1,5 @@
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.task import Task # 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.recolte import Recolte, Observation # noqa
from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa
from app.models.saint import SaintDuJour # noqa
from app.models.intrant import AchatIntrant, Fabrication # noqa
+57
View File
@@ -0,0 +1,57 @@
# backend/app/models/intrant.py
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import Column
from sqlalchemy import JSON as SA_JSON
from sqlmodel import Field, SQLModel
class AchatIntrant(SQLModel, table=True):
__tablename__ = "achat_intrant"
id: Optional[int] = Field(default=None, primary_key=True)
categorie: str # terreau | engrais | traitement | autre
nom: str
marque: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
prix: Optional[float] = None
poids: Optional[str] = None # "20L", "1kg", "500ml"
date_achat: Optional[str] = None # ISO date
dluo: Optional[str] = None # ISO date
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Ingredient(SQLModel):
"""Modèle pour un ingrédient de fabrication (non persisté seul)."""
nom: str
quantite: str # "1kg", "10L"
class Fabrication(SQLModel, table=True):
__tablename__ = "fabrication"
id: Optional[int] = Field(default=None, primary_key=True)
type: str # compost | decoction | purin | autre
nom: str
ingredients: Optional[List[dict]] = Field(
default=None,
sa_column=Column("ingredients", SA_JSON, nullable=True),
)
date_debut: Optional[str] = None # ISO date
date_fin_prevue: Optional[str] = None # ISO date
statut: str = "en_cours" # en_cours | pret | utilise | echec
quantite_produite: Optional[str] = None # "8L", "50kg"
notes: Optional[str] = None
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class FabricationStatutUpdate(SQLModel):
statut: str
+68 -5
View File
@@ -1,5 +1,8 @@
# backend/app/models/plant.py
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
@@ -9,20 +12,20 @@ class Plant(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
nom_commun: str
nom_botanique: Optional[str] = None
variete: Optional[str] = None
famille: Optional[str] = None
tags: Optional[str] = None # CSV
type_plante: Optional[str] = None # legume | fruit | aromatique | fleur
type_plante: Optional[str] = None
categorie: Optional[str] = None # potager|fleur|arbre|arbuste
besoin_eau: Optional[str] = None # faible | moyen | fort
besoin_soleil: Optional[str] = None
espacement_cm: Optional[int] = None
hauteur_cm: Optional[int] = None
temp_min_c: Optional[float] = None
temp_germination: Optional[str] = None # ex: "8-10°C"
temps_levee_j: Optional[str] = None # ex: "15-20 jours"
duree_culture_j: Optional[int] = None
profondeur_semis_cm: Optional[float] = None
sol_conseille: Optional[str] = None
semis_interieur_mois: Optional[str] = None # ex: "2,3"
semis_interieur_mois: Optional[str] = None # CSV ex: "2,3"
semis_exterieur_mois: Optional[str] = None
repiquage_mois: Optional[str] = None
plantation_mois: Optional[str] = None
@@ -31,10 +34,70 @@ class Plant(SQLModel, table=True):
astuces_culture: Optional[str] = None
url_reference: Optional[str] = None
notes: Optional[str] = None
associations_favorables: Optional[List[str]] = Field(
default=None,
sa_column=Column("associations_favorables", SA_JSON, nullable=True),
)
associations_defavorables: Optional[List[str]] = Field(
default=None,
sa_column=Column("associations_defavorables", SA_JSON, nullable=True),
)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PlantVariety(SQLModel, table=True):
__tablename__ = "plant_variety"
id: Optional[int] = Field(default=None, primary_key=True)
plant_id: int = Field(foreign_key="plant.id", index=True)
variete: Optional[str] = None
tags: Optional[str] = None
notes_variete: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
prix_achat: Optional[float] = None
date_achat: Optional[str] = None
poids: Optional[str] = None
dluo: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PlantWithVarieties(SQLModel):
"""Schéma de réponse API : plant + ses variétés (non persisté)."""
id: Optional[int] = None
nom_commun: str
nom_botanique: Optional[str] = None
famille: Optional[str] = None
type_plante: Optional[str] = None
categorie: Optional[str] = None
besoin_eau: Optional[str] = None
besoin_soleil: Optional[str] = None
espacement_cm: Optional[int] = None
hauteur_cm: Optional[int] = None
temp_min_c: Optional[float] = None
temp_germination: Optional[str] = None
temps_levee_j: Optional[str] = None
duree_culture_j: Optional[int] = None
profondeur_semis_cm: Optional[float] = None
sol_conseille: Optional[str] = None
semis_interieur_mois: Optional[str] = None
semis_exterieur_mois: Optional[str] = None
repiquage_mois: Optional[str] = None
plantation_mois: Optional[str] = None
recolte_mois: Optional[str] = None
maladies_courantes: Optional[str] = None
astuces_culture: Optional[str] = None
url_reference: Optional[str] = None
notes: Optional[str] = None
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):
__tablename__ = "plant_image"
id: Optional[int] = Field(default=None, primary_key=True)
plant_id: int = Field(foreign_key="plant.id", index=True)
filename: str
+8 -1
View File
@@ -1,5 +1,7 @@
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
@@ -7,6 +9,7 @@ class PlantingCreate(SQLModel):
garden_id: int
variety_id: int
cell_id: Optional[int] = None
cell_ids: Optional[List[int]] = None # multi-sélect zones
date_semis: Optional[date] = None
date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None
@@ -28,6 +31,10 @@ class Planting(SQLModel, table=True):
garden_id: int = Field(foreign_key="garden.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_ids: Optional[List[int]] = Field(
default=None,
sa_column=Column("cell_ids", SA_JSON, nullable=True),
)
date_semis: Optional[date] = None
date_plantation: Optional[date] = None
date_repiquage: Optional[date] = None
+14
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@ class Tool(SQLModel, table=True):
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
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
+60
View File
@@ -0,0 +1,60 @@
# backend/app/routers/achats.py
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.intrant import AchatIntrant
router = APIRouter(tags=["intrants"])
@router.get("/achats", response_model=List[AchatIntrant])
def list_achats(
categorie: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(AchatIntrant)
if categorie:
q = q.where(AchatIntrant.categorie == categorie)
if jardin_id:
q = q.where(AchatIntrant.jardin_id == jardin_id)
return session.exec(q.order_by(AchatIntrant.created_at.desc())).all()
@router.post("/achats", response_model=AchatIntrant, status_code=status.HTTP_201_CREATED)
def create_achat(a: AchatIntrant, session: Session = Depends(get_session)):
session.add(a)
session.commit()
session.refresh(a)
return a
@router.get("/achats/{id}", response_model=AchatIntrant)
def get_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
return a
@router.put("/achats/{id}", response_model=AchatIntrant)
def update_achat(id: int, data: AchatIntrant, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(a, k, v)
session.add(a)
session.commit()
session.refresh(a)
return a
@router.delete("/achats/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_achat(id: int, session: Session = Depends(get_session)):
a = session.get(AchatIntrant, id)
if not a:
raise HTTPException(404, "Achat introuvable")
session.delete(a)
session.commit()
+78
View File
@@ -0,0 +1,78 @@
# backend/app/routers/fabrications.py
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.intrant import Fabrication, FabricationStatutUpdate
router = APIRouter(tags=["intrants"])
@router.get("/fabrications", response_model=List[Fabrication])
def list_fabrications(
type: Optional[str] = Query(None),
statut: Optional[str] = Query(None),
jardin_id: Optional[int] = Query(None),
session: Session = Depends(get_session),
):
q = select(Fabrication)
if type:
q = q.where(Fabrication.type == type)
if statut:
q = q.where(Fabrication.statut == statut)
if jardin_id:
q = q.where(Fabrication.jardin_id == jardin_id)
return session.exec(q.order_by(Fabrication.created_at.desc())).all()
@router.post("/fabrications", response_model=Fabrication, status_code=status.HTTP_201_CREATED)
def create_fabrication(f: Fabrication, session: Session = Depends(get_session)):
session.add(f)
session.commit()
session.refresh(f)
return f
@router.get("/fabrications/{id}", response_model=Fabrication)
def get_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
return f
@router.put("/fabrications/{id}", response_model=Fabrication)
def update_fabrication(id: int, data: Fabrication, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
setattr(f, k, v)
session.add(f)
session.commit()
session.refresh(f)
return f
@router.patch("/fabrications/{id}/statut", response_model=Fabrication)
def update_statut(id: int, data: FabricationStatutUpdate, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
valid = {"en_cours", "pret", "utilise", "echec"}
if data.statut not in valid:
raise HTTPException(400, f"Statut invalide. Valeurs: {valid}")
f.statut = data.statut
session.add(f)
session.commit()
session.refresh(f)
return f
@router.delete("/fabrications/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_fabrication(id: int, session: Session = Depends(get_session)):
f = session.get(Fabrication, id)
if not f:
raise HTTPException(404, "Fabrication introuvable")
session.delete(f)
session.commit()
+13
View File
@@ -115,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
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])
def list_measurements(id: int, session: Session = Depends(get_session)):
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
+140 -15
View File
@@ -1,14 +1,16 @@
import os
import unicodedata
import uuid
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 sqlmodel import Session, select
from app.config import UPLOAD_DIR
from app.database import get_session
from app.models.media import Attachment, Media
from app.models.settings import UserSettings
class MediaPatch(BaseModel):
@@ -16,9 +18,105 @@ class MediaPatch(BaseModel):
entity_id: Optional[int] = 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"])
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:
try:
from PIL import Image
@@ -39,20 +137,36 @@ def _save_webp(data: bytes, max_px: int) -> str:
@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)
data = await file.read()
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)
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
else:
name = f"{uuid.uuid4()}_{file.filename}"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return {"url": f"/uploads/{name}", "thumbnail_url": None}
name = f"{uuid.uuid4()}_{file.filename}"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return {"url": f"/uploads/{name}", "thumbnail_url": None}
@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."""
q = select(Media).order_by(Media.created_at.desc())
if entity_type:
q = q.where(Media.entity_type == entity_type)
return session.exec(q).all()
q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type)))
rows = session.exec(q).all()
_canonicalize_rows(rows, session)
return rows
@router.get("/media", response_model=List[Media])
@@ -73,15 +189,19 @@ def list_media(
entity_id: int = Query(...),
session: Session = Depends(get_session),
):
return session.exec(
rows = session.exec(
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()
_canonicalize_rows(rows, session)
return rows
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
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.commit()
session.refresh(m)
@@ -93,7 +213,12 @@ def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_se
m = session.get(Media, id)
if not m:
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)
session.add(m)
session.commit()
+30 -20
View File
@@ -4,6 +4,7 @@ from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlmodel import Session
from app.database import get_session
@@ -13,13 +14,16 @@ router = APIRouter(tags=["météo"])
def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
"""Agrège les mesures horaires d'une journée en résumé."""
rows = session.exec(
text(
"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()
try:
rows = session.exec(
text(
"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()
except OperationalError:
return None
if not rows:
return None
@@ -46,12 +50,15 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
def _station_current_row(session: Session) -> Optional[dict]:
"""Dernière mesure station (max 2h d'ancienneté)."""
row = session.exec(
text(
"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()
try:
row = session.exec(
text(
"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()
except OperationalError:
return None
if not row:
return None
@@ -64,13 +71,16 @@ def _station_current_row(session: Session) -> Optional[dict]:
def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
row = session.exec(
text(
"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()
try:
row = session.exec(
text(
"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()
except OperationalError:
return None
if not row:
return None
+11 -2
View File
@@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)):
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
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.commit()
session.refresh(p)
@@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge
p = session.get(Planting, id)
if not p:
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)
p.updated_at = datetime.now(timezone.utc)
session.add(p)
+64 -10
View File
@@ -1,40 +1,50 @@
# backend/app/routers/plants.py
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.plant import Plant
from app.models.plant import Plant, PlantVariety, PlantWithVarieties
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(
categorie: Optional[str] = Query(None),
session: Session = Depends(get_session),
):
q = select(Plant)
q = select(Plant).order_by(Plant.nom_commun, Plant.id)
if 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)):
session.add(p)
session.commit()
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)):
p = session.get(Plant, id)
if not p:
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)):
p = session.get(Plant, id)
if not p:
@@ -44,7 +54,7 @@ def update_plant(id: int, data: Plant, session: Session = Depends(get_session)):
session.add(p)
session.commit()
session.refresh(p)
return p
return _with_varieties(p, session)
@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)
if not p:
raise HTTPException(404, "Plante introuvable")
for v in session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all():
session.delete(v)
session.delete(p)
session.commit()
# ---- CRUD Variétés ----
@router.get("/plants/{id}/varieties", response_model=List[PlantVariety])
def list_varieties(id: int, session: Session = Depends(get_session)):
if not session.get(Plant, id):
raise HTTPException(404, "Plante introuvable")
return session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all()
@router.post("/plants/{id}/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED)
def create_variety(id: int, v: PlantVariety, session: Session = Depends(get_session)):
if not session.get(Plant, id):
raise HTTPException(404, "Plante introuvable")
v.plant_id = id
session.add(v)
session.commit()
session.refresh(v)
return v
@router.put("/plants/{id}/varieties/{vid}", response_model=PlantVariety)
def update_variety(id: int, vid: int, data: PlantVariety, session: Session = Depends(get_session)):
v = session.get(PlantVariety, vid)
if not v or v.plant_id != id:
raise HTTPException(404, "Variété introuvable")
for k, val in data.model_dump(exclude_unset=True, exclude={"id", "plant_id", "created_at"}).items():
setattr(v, k, val)
session.add(v)
session.commit()
session.refresh(v)
return v
@router.delete("/plants/{id}/varieties/{vid}", status_code=status.HTTP_204_NO_CONTENT)
def delete_variety(id: int, vid: int, session: Session = Depends(get_session)):
v = session.get(PlantVariety, vid)
if not v or v.plant_id != id:
raise HTTPException(404, "Variété introuvable")
session.delete(v)
session.commit()
+57
View File
@@ -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,
}
+353 -2
View File
@@ -1,18 +1,29 @@
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
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 app.database import get_session
from app.models.settings import UserSettings
from app.config import UPLOAD_DIR
from app.config import DATABASE_URL, UPLOAD_DIR
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:
@@ -113,6 +124,68 @@ def _disk_stats() -> dict[str, Any]:
}
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")
def get_settings(session: Session = Depends(get_session)):
rows = session.exec(select(UserSettings)).all()
@@ -161,3 +234,281 @@ def get_debug_system_stats() -> dict[str, Any]:
"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))
+4
View File
@@ -12,6 +12,7 @@ router = APIRouter(tags=["tâches"])
def list_tasks(
statut: Optional[str] = None,
garden_id: Optional[int] = None,
planting_id: Optional[int] = None,
session: Session = Depends(get_session),
):
q = select(Task)
@@ -19,6 +20,9 @@ def list_tasks(
q = q.where(Task.statut == statut)
if 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()
+14 -1
View File
@@ -6,7 +6,7 @@ import app.models # noqa
def run_seed():
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.task import Task
from app.models.tool import Tool
@@ -131,11 +131,24 @@ def run_seed():
plantes = []
for data in plantes_data:
variete = data.pop('variete', None)
p = Plant(**data)
session.add(p)
plantes.append(p)
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]
courgette = plantes[1]
+64
View File
@@ -90,6 +90,70 @@ def _store_open_meteo() -> None:
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:
"""Configure et démarre le scheduler."""
scheduler.add_job(
+54 -36
View File
@@ -130,45 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | 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:
"""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.
"""
yesterday = (datetime.now() - timedelta(days=1)).date()
year = yesterday.strftime("%Y")
month = yesterday.strftime("%m")
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 not parts or not parts[0].isdigit() or int(parts[0]) != day:
continue
# Format WeeWX NOAA (fréquent) :
# day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
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)
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,
}
except Exception as e:
logger.warning(f"Station fetch_yesterday_summary error: {e}")
return None
month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
return month_data.get(yesterday.day)
+45 -17
View File
@@ -1,27 +1,47 @@
import os
from typing import List
from typing import List, Optional
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)
_NOMS_FR = {
"Tomato___healthy": "Tomate (saine)",
"Tomato___Early_blight": "Tomate (mildiou précoce)",
"Tomato___Late_blight": "Tomate (mildiou tardif)",
"Pepper__bell___healthy": "Poivron (sain)",
"Apple___healthy": "Pommier (sain)",
"Potato___healthy": "Pomme de terre (saine)",
"Grape___healthy": "Vigne (saine)",
"Corn_(maize)___healthy": "Maïs (sain)",
"Strawberry___healthy": "Fraisier (sain)",
"Peach___healthy": "Pêcher (sain)",
# Mapping complet class_name YOLO → Infos détaillées
_DIAGNOSTICS = {
"Tomato___healthy": {
"label": "Tomate (saine)",
"conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
"actions": ["Pailler le pied", "Vérifier les gourmands"]
},
"Tomato___Early_blight": {
"label": "Tomate (Alternariose)",
"conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
"actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
},
"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]:
"""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:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
results = []
for det in data[:3]:
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({
"species": cls.replace("___", "").replace("_", " "),
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
"species": cls,
"common_name": diag["label"],
"confidence": det.get("confidence", 0.0),
"conseil": diag["conseil"],
"actions": diag["actions"],
"image_url": "",
})
return results
View File
BIN
View File
Binary file not shown.
+2
View File
@@ -6,6 +6,8 @@ aiofiles==24.1.0
pytest==8.3.3
httpx==0.28.0
Pillow==11.1.0
pillow-heif==0.21.0
smbprotocol==1.15.0
skyfield==1.49
pytz==2025.1
numpy==2.2.3
View File
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""
Import one-shot : docs/graine/caracteristiques_plantation.json + docs/arbustre/caracteristiques_arbustre.json
Usage: cd /chemin/projet && python3 backend/scripts/import_graines.py
"""
import json
import shutil
import sqlite3
import unicodedata
import uuid
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
DB_PATH = ROOT / "data" / "jardin.db"
UPLOADS_DIR = ROOT / "data" / "uploads"
GRAINE_DIR = ROOT / "docs" / "graine"
ARBUSTRE_DIR = ROOT / "docs" / "arbustre"
ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6,
"VII": 7, "VIII": 8, "IX": 9, "X": 10, "XI": 11, "XII": 12}
# Mapping : mot-clé lowercase → nom_commun BDD
NOM_MAP = [
("oignon", "Oignon"),
("laitue pommee grosse", "Laitue"),
("laitue attraction", "Laitue"),
("laitue", "Laitue"),
("persil", "Persil"),
("courgette", "Courgette"),
("pois mangetout", "Pois"),
("pois a ecosser", "Pois"),
("pois", "Pois"),
("tomate cornue", "Tomate"),
("tomates moneymaker", "Tomate"),
("tomate", "Tomate"),
("poireau", "Poireau"),
("echalion", "Echalote"),
("courge", "Courge"),
("chou pomme", "Chou"),
("chou-fleur", "Chou-fleur"),
]
def roman_to_csv(s: str) -> str:
if not s:
return ""
s = s.strip()
# Handle "(selon sachet)" or other parenthetical notes
if "(" in s:
s = s.split("(")[0].strip()
parts = s.split("-")
if len(parts) == 2:
a = ROMAN.get(parts[0].strip(), 0)
b = ROMAN.get(parts[1].strip(), 0)
if a and b:
return ",".join(str(m) for m in range(a, b + 1))
single = ROMAN.get(s, 0)
return str(single) if single else ""
def extract_float(s: str) -> float | None:
if not s:
return None
try:
# Handle "2-3 cm" → take first number
first = s.split()[0].split("-")[0].replace(",", ".")
return float(first)
except Exception:
return None
def find_or_create_plant(conn: sqlite3.Connection, nom_commun: str, categorie: str = "potager") -> int:
row = conn.execute(
"SELECT id FROM plant WHERE LOWER(nom_commun) = LOWER(?)", (nom_commun,)
).fetchone()
if row:
return row[0]
conn.execute(
"INSERT INTO plant (nom_commun, categorie, created_at) VALUES (?, ?, ?)",
(nom_commun, categorie, datetime.now(timezone.utc).isoformat()),
)
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
def copy_image(src: Path, variety_id: int, conn: sqlite3.Connection) -> None:
if not src.exists():
print(f" WARNING image absente: {src}")
return
# Vérifier si cette image existe déjà dans media pour cette variété
existing_m = conn.execute(
"SELECT id FROM media WHERE entity_type = 'plant_variety' AND entity_id = ? AND url LIKE ?",
(variety_id, f"%{src.stem}%")
).fetchone()
if existing_m:
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
# Use UUID-based filename like the rest of the app
dest_name = f"{uuid.uuid4()}.jpg"
shutil.copy2(src, UPLOADS_DIR / dest_name)
url = f"/uploads/{dest_name}"
conn.execute("""
INSERT INTO media (entity_type, entity_id, url, created_at)
VALUES ('plant_variety', ?, ?, ?)
""", (variety_id, url, datetime.now(timezone.utc).isoformat()))
def normalize(s: str) -> str:
"""Normalise string: minuscules, supprime accents simples."""
return ''.join(c for c in unicodedata.normalize('NFD', s.lower()) if unicodedata.category(c) != 'Mn')
def resolve_nom(full_name: str) -> tuple[str, str]:
"""Retourne (nom_commun, variete) depuis le nom complet du sachet."""
norm = normalize(full_name)
for key, val in NOM_MAP:
norm_key = normalize(key)
if norm.startswith(norm_key):
variete = full_name[len(key):].strip().strip("'\"").title()
return val, variete or full_name
# Fallback : premier mot = nom_commun
parts = full_name.split()
return parts[0].title(), " ".join(parts[1:]).strip() or full_name
def import_graines(conn: sqlite3.Connection) -> None:
path = GRAINE_DIR / "caracteristiques_plantation.json"
if not path.exists():
print(f"WARNING fichier absent: {path}")
return
data = json.loads(path.read_text(encoding="utf-8"))
key = "plantes" if "plantes" in data else list(data.keys())[0]
entries = data[key]
for entry in entries:
full_name = entry.get("plante", "")
if not full_name:
continue
nom_commun, variete_name = resolve_nom(full_name)
carac = entry.get("caracteristiques_plantation", {})
detail = entry.get("detail", {})
texte = detail.get("texte_integral_visible", {}) if isinstance(detail, dict) else {}
plant_id = find_or_create_plant(conn, nom_commun)
# Enrichir plant (ne pas écraser si déjà rempli)
updates: dict = {}
semis = roman_to_csv(carac.get("periode_semis", ""))
recolte = roman_to_csv(carac.get("periode_recolte", ""))
profondeur = extract_float(carac.get("profondeur") or "")
espacement_raw = carac.get("espacement") or ""
espacement = extract_float(espacement_raw)
if semis:
updates["semis_exterieur_mois"] = semis
if recolte:
updates["recolte_mois"] = recolte
if profondeur:
updates["profondeur_semis_cm"] = profondeur
if espacement:
updates["espacement_cm"] = int(espacement)
if carac.get("exposition"):
updates["besoin_soleil"] = carac["exposition"]
if carac.get("temperature"):
updates["temp_germination"] = carac["temperature"]
if isinstance(texte, dict) and texte.get("arriere"):
updates["astuces_culture"] = texte["arriere"][:1000]
elif isinstance(texte, str) and texte:
updates["astuces_culture"] = texte[:1000]
if updates:
set_clause = ", ".join(f"{k} = ?" for k in updates)
conn.execute(
f"UPDATE plant SET {set_clause} WHERE id = ?",
(*updates.values(), plant_id),
)
# Vérifier si cette variété existe déjà pour cette plante
existing_v = conn.execute(
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
(plant_id, variete_name)
).fetchone()
if existing_v:
print(f"{nom_commun}{variete_name} (déjà importé)")
continue
# Créer plant_variety
conn.execute(
"INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)",
(plant_id, variete_name, datetime.now(timezone.utc).isoformat()),
)
vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
for img in entry.get("images", []):
copy_image(GRAINE_DIR / img, vid, conn)
print(f" OK {nom_commun} - {variete_name}")
def import_arbustre(conn: sqlite3.Connection) -> None:
path = ARBUSTRE_DIR / "caracteristiques_arbustre.json"
if not path.exists():
print(f"WARNING fichier absent: {path}")
return
data = json.loads(path.read_text(encoding="utf-8"))
key = "plantes" if "plantes" in data else list(data.keys())[0]
entries = data[key]
for entry in entries:
full_name = entry.get("plante", "")
if not full_name:
continue
nom_latin = entry.get("nom_latin", "") or ""
# Determine nom_commun from the plante field
if "Vitis" in full_name or "Vitis" in nom_latin:
nom_commun = "Vigne"
elif "Ribes nigrum" in full_name or "Ribes nigrum" in nom_latin:
nom_commun = "Cassissier"
elif "Rubus idaeus" in full_name or "Rubus idaeus" in nom_latin:
nom_commun = "Framboisier"
elif "'" in full_name:
nom_commun = full_name.split("'")[0].strip().title()
elif nom_latin:
parts = nom_latin.split()
nom_commun = (parts[0] + " " + parts[1]).title() if len(parts) > 1 else nom_latin.title()
else:
nom_commun = full_name.split()[0].title()
# variete_name: content inside quotes
if "'" in full_name:
variete_name = full_name.split("'")[1].strip()
else:
variete_name = full_name
plant_id = find_or_create_plant(conn, nom_commun, "arbuste")
carac = entry.get("caracteristiques_plantation", {})
arrosage = carac.get("arrosage")
exposition = carac.get("exposition")
if arrosage:
conn.execute("UPDATE plant SET besoin_eau = ? WHERE id = ?", (arrosage, plant_id))
if exposition:
conn.execute("UPDATE plant SET besoin_soleil = ? WHERE id = ?", (exposition, plant_id))
# Vérifier si cette variété existe déjà pour cette plante
existing_v = conn.execute(
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
(plant_id, variete_name)
).fetchone()
if existing_v:
print(f"{nom_commun}{variete_name} (déjà importé)")
continue
conn.execute(
"INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)",
(plant_id, variete_name, datetime.now(timezone.utc).isoformat()),
)
vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
for img in entry.get("images", []):
copy_image(ARBUSTRE_DIR / img, vid, conn)
print(f" OK {nom_commun} - {variete_name}")
def run() -> None:
if not DB_PATH.exists():
print(f"ERREUR : base de données introuvable : {DB_PATH}")
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
if "plant_variety" not in tables:
print("WARNING Exécutez d'abord migrate_plant_varieties.py")
return
print("=== Import graines ===")
import_graines(conn)
print("\n=== Import arbustre ===")
import_arbustre(conn)
conn.commit()
print("\nImport terminé.")
except Exception as e:
conn.rollback()
print(f"ERREUR - rollback : {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
run()
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Migration one-shot : crée plant_variety, migre données existantes, fusionne haricot grimpant.
À exécuter UNE SEULE FOIS depuis la racine du projet.
Usage: cd /chemin/projet && python3 backend/scripts/migrate_plant_varieties.py
"""
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "jardin.db"
def run():
if not DB_PATH.exists():
print(f"ERREUR : base de données introuvable : {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
# 1. Créer plant_variety
conn.execute("""
CREATE TABLE IF NOT EXISTS plant_variety (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plant_id INTEGER NOT NULL REFERENCES plant(id) ON DELETE CASCADE,
variete TEXT,
tags TEXT,
notes_variete TEXT,
boutique_nom TEXT,
boutique_url TEXT,
prix_achat REAL,
date_achat TEXT,
poids TEXT,
dluo TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
""")
print("✓ Table plant_variety créée")
# 2. Ajouter colonnes manquantes à plant
existing = [r[1] for r in conn.execute("PRAGMA table_info(plant)").fetchall()]
for col, typ in [("temp_germination", "TEXT"), ("temps_levee_j", "TEXT")]:
if col not in existing:
conn.execute(f"ALTER TABLE plant ADD COLUMN {col} {typ}")
print(f"✓ Colonne {col} ajoutée à plant")
# 3. Vérifier si déjà migré
count = conn.execute("SELECT COUNT(*) FROM plant_variety").fetchone()[0]
if count > 0:
print(f"⚠️ Migration déjà effectuée ({count} variétés). Abandon.")
return
# 4. Migrer chaque plante → plant_variety
plants = conn.execute(
"SELECT id, nom_commun, variete, tags, boutique_nom, boutique_url, "
"prix_achat, date_achat, poids, dluo FROM plant"
).fetchall()
for p in plants:
conn.execute("""
INSERT INTO plant_variety
(plant_id, variete, tags, boutique_nom, boutique_url,
prix_achat, date_achat, poids, dluo, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
p["id"], p["variete"], p["tags"], p["boutique_nom"], p["boutique_url"],
p["prix_achat"], p["date_achat"], p["poids"], p["dluo"],
datetime.now(timezone.utc).isoformat(),
))
print(f" → plant id={p['id']} {p['nom_commun']} : variété '{p['variete']}'")
# 5. Fusionner haricot grimpant (id=21) sous Haricot (id=7)
# IDs stables dans le seed de production : Haricot=7, haricot grimpant=21
hg = conn.execute("SELECT * FROM plant WHERE id = 21").fetchone()
if hg:
# Supprimer la plant_variety créée pour id=21 (on va la recréer sous id=7)
conn.execute("DELETE FROM plant_variety WHERE plant_id = 21")
# Créer variété sous Haricot (id=7)
conn.execute("""
INSERT INTO plant_variety (plant_id, variete, notes_variete, created_at)
VALUES (7, 'Grimpant Neckarkönigin', 'Fusionné depuis haricot grimpant', ?)
""", (datetime.now(timezone.utc).isoformat(),))
new_vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
print(f" → haricot grimpant fusionné sous Haricot (plant_variety id={new_vid})")
# Supprimer le plant haricot grimpant
conn.execute("DELETE FROM plant WHERE id = 21")
print(" → plant id=21 (haricot grimpant) supprimé")
conn.commit()
print("\nMigration terminée avec succès.")
except Exception as e:
conn.rollback()
print(f"ERREUR — rollback effectué : {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
run()
+26
View File
@@ -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"
+26
View File
@@ -12,6 +12,32 @@ def test_list_plants(client):
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):
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
id = r.json()["id"]
+10
View File
@@ -26,3 +26,13 @@ def test_update_task_statut(client):
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
assert r2.status_code == 200
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
+12
View File
@@ -28,3 +28,15 @@ def test_tool_with_video_url(client):
)
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."
File diff suppressed because it is too large Load Diff
@@ -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()
File diff suppressed because it is too large Load Diff
+32
View File
@@ -35,12 +35,21 @@ Dossier dédié: `calendrier_lunaire/saints_dictons/`
- `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
@@ -103,3 +112,26 @@ Dossier: `test_yolo/`
- 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`
Regular → Executable
View File
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
{"cached_at": "2026-02-22T12:59:49.373422+00:00", "days": [{"date": "2026-02-22", "t_max": 14.1, "t_min": 2.1, "pluie_mm": 0, "vent_kmh": 10.8, "code": 3, "label": "Couvert", "icone": "☁️"}, {"date": "2026-02-23", "t_max": 12.0, "t_min": 4.5, "pluie_mm": 0, "vent_kmh": 16.8, "code": 3, "label": "Couvert", "icone": "☁️"}, {"date": "2026-02-24", "t_max": 14.0, "t_min": 4.1, "pluie_mm": 0, "vent_kmh": 6.4, "code": 45, "label": "Brouillard", "icone": "🌫"}]}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

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