Compare commits
54 Commits
326158f680
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 412a06be2c | |||
| 8ddfe545d9 | |||
| 76d0984b06 | |||
| 4fca4b9278 | |||
| d9512248df | |||
| a070b9c499 | |||
| a30e83a724 | |||
| 7afca6ed04 | |||
| 2043a1b8b5 | |||
| 2d5e5a05a2 | |||
| 4c279c387c | |||
| 149d8caa06 | |||
| 672ac529e7 | |||
| 174ed9c25d | |||
| 05b2ddc27c | |||
| 32c7781d14 | |||
| d4d104b2c2 | |||
| 0f5ebd25be | |||
| 1b7a8b8f25 | |||
| 1095edffdb | |||
| 8edcf5fd8d | |||
| 1d4708585e | |||
| 18ee6e1fbe | |||
| 4a7ecffbb8 | |||
| b41a0f817c | |||
| de967141ba | |||
| 734c33a12e | |||
| e40351e0be | |||
| f8e64d6a2c | |||
| 80173171b3 | |||
| 8bf281a3fb | |||
| d2f2f6d7d7 | |||
| 107640e561 | |||
| a5c503e1f3 | |||
| 75f18c9eb8 | |||
| faa469e688 | |||
| 14636bd58f | |||
| 7967f63fea | |||
| 9db5cbf236 | |||
| fb33540bb0 | |||
| 155de270dc | |||
| 0d3bf205b1 | |||
| a9f0556d73 | |||
| 55387f4b0e | |||
| 20af00d653 | |||
| fed449c784 | |||
| 2ca8281b0a | |||
| 3b1601a07b | |||
| 8a7a2c7c6d | |||
| cc69d0d5ad | |||
| 17d2c5ac18 | |||
| 29e2f18e98 | |||
| f1f4c97dc6 | |||
| 3032751d16 |
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(git -C:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/stores/gardens.ts:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/stores/varieties.ts:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/stores/plantings.ts:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/stores/tasks.ts:*)",
|
||||||
|
"Bash(npm run lint:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/tsconfig.json:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/router/index.ts:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/DashboardView.vue:*)",
|
||||||
|
"Bash(__NEW_LINE_c59ff40ee569d295__ cat)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/JardinsView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/JardinDetailView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/VarietesView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/PlantationsView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/PlanningView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/TachesView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/LunaireView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/views/ReglagesView.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/components/AppHeader.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/components/AppDrawer.vue:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/App.vue:*)",
|
||||||
|
"Bash(__NEW_LINE_a74f82e515f33f67__ cat)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/README.md:*)",
|
||||||
|
"Bash(docker compose up:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(python -m pytest:*)",
|
||||||
|
"Read(//usr/bin/**)",
|
||||||
|
"Bash(pip3 install:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(lsof:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(npm run dev:*)",
|
||||||
|
"Bash(ss:*)",
|
||||||
|
"Bash(docker ps:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/migrate.py:*)",
|
||||||
|
"Bash(docker compose stop:*)",
|
||||||
|
"Bash(docker compose start:*)",
|
||||||
|
"Bash(docker stop:*)",
|
||||||
|
"Bash(docker start:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/models/planting.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/routers/plantings.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/models/task.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/routers/tasks.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/ai-service/requirements.txt:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/ai-service/main.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/ai-service/Dockerfile:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/docker-compose.yml:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/services/plantnet.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/services/yolo_service.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/.env.example:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/app/models/media.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/backend/tests/test_identify.py:*)",
|
||||||
|
"Bash(/home/gilles/Documents/vscode/jardin/frontend/src/components/PhotoIdentifyModal.vue:*)",
|
||||||
|
"Bash(pipx install:*)",
|
||||||
|
"Read(//tmp/jardin_screenshots/**)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"Read(//home/gilles/.claude/plugins/cache/claude-plugins-official/superpowers/4.3.1/skills/subagent-driven-development/**)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(sqlite3:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(.venv/bin/python:*)",
|
||||||
|
"Bash(.venv/bin/pip install:*)",
|
||||||
|
"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",
|
||||||
|
"/home/gilles/Documents/vscode/jardin/frontend/src/router",
|
||||||
|
"/home/gilles/Documents/vscode/jardin/frontend/src/api",
|
||||||
|
"/home/gilles/Documents/vscode/jardin",
|
||||||
|
"/home/gilles/Documents/vscode/jardin/backend/app/models",
|
||||||
|
"/home/gilles/Documents/vscode/jardin/backend/app/routers",
|
||||||
|
"/home/gilles/Documents/vscode/jardin/frontend/src/views",
|
||||||
|
"/home/gilles/Documents/vscode/jardin/frontend/src/components",
|
||||||
|
"/home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/memory"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,8 @@ VITE_API_URL=http://localhost:8060
|
|||||||
PLANTNET_API_KEY=2b1088cHCJ4c7Cn2Vqq67xfve
|
PLANTNET_API_KEY=2b1088cHCJ4c7Cn2Vqq67xfve
|
||||||
AI_SERVICE_URL=http://ai-service:8070
|
AI_SERVICE_URL=http://ai-service:8070
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
|
STATION_URL=http://10.0.0.8:8081/
|
||||||
|
METEO_LAT=45.14
|
||||||
|
METEO_LON=4.12
|
||||||
|
ENABLE_SCHEDULER=1
|
||||||
|
ENABLE_BOOTSTRAP=1
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
**/*.pyo
|
**/*.pyo
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
data/*.db
|
|
||||||
data/uploads/
|
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Données runtime — ne pas versionner (BDD, uploads, cache météo)
|
||||||
|
data/jardin.db
|
||||||
|
data/jardin.db-shm
|
||||||
|
data/jardin.db-wal
|
||||||
|
data/meteo_cache.json
|
||||||
|
data/uploads/
|
||||||
|
data/skyfield/
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
Application web de **gestion de jardins** (potager, serre, plein air), self-hosted, mobile-first, entièrement **en français**. Thème visuel : **Gruvbox Dark "seventies"** (vintage, chaleureux, contrasté).
|
||||||
|
|
||||||
|
## Architecture cible
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Python FastAPI** + SQLModel (SQLAlchemy en dessous)
|
||||||
|
- **SQLite** par défaut (volume Docker persistant), migration future vers PostgreSQL prévue
|
||||||
|
- Stockage images : local `/data/uploads` + métadonnées en DB
|
||||||
|
- API REST documentée (OpenAPI auto-générée par FastAPI)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Vue 3 + Vite** (alternative React acceptable)
|
||||||
|
- **Tailwind CSS** avec thème Gruvbox Dark personnalisé
|
||||||
|
- Mobile-first, PWA en phase 2
|
||||||
|
|
||||||
|
### Déploiement
|
||||||
|
- **Docker Compose** : service `backend` + service `frontend` (static) + volumes `db` et `uploads`
|
||||||
|
|
||||||
|
## Commandes de développement
|
||||||
|
|
||||||
|
Une fois le projet créé, les commandes attendues seront :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer tout l'environnement
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Backend seul (développement)
|
||||||
|
cd backend && uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Frontend seul (développement)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Tests backend
|
||||||
|
cd backend && pytest
|
||||||
|
|
||||||
|
# Tests un seul fichier
|
||||||
|
cd backend && pytest tests/test_gardens.py -v
|
||||||
|
|
||||||
|
# Lint backend
|
||||||
|
cd backend && ruff check . && mypy .
|
||||||
|
|
||||||
|
# Lint frontend
|
||||||
|
cd frontend && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modèle de données (tables MVP)
|
||||||
|
|
||||||
|
| Table | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `gardens` | Jardins (nom, type, coordonnées, exposition, sol) |
|
||||||
|
| `garden_cells` | Cases de la grille 2D du jardin |
|
||||||
|
| `garden_images` | Photos associées à un jardin |
|
||||||
|
| `measurements` | Relevés temp/humidité (air + sol) |
|
||||||
|
| `plant_varieties` | Catalogue de variétés (référence) |
|
||||||
|
| `plant_images` | Photos de variétés |
|
||||||
|
| `plantings` | Instance : variété X dans jardin Y à case Z |
|
||||||
|
| `planting_events` | Historique arrosage/taille/traitement/observation |
|
||||||
|
| `tasks` | Tâches (ponctuelles ou récurrentes) |
|
||||||
|
| `lunar_calendar_entries` | Cache/dataset calendrier lunaire |
|
||||||
|
| `user_settings` | Préférences locales |
|
||||||
|
|
||||||
|
## Endpoints API principaux
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
GET/POST /api/gardens
|
||||||
|
GET/PUT/DELETE /api/gardens/{id}
|
||||||
|
GET/POST /api/gardens/{id}/cells
|
||||||
|
GET/POST /api/varieties
|
||||||
|
GET/POST /api/plantings
|
||||||
|
GET/POST /api/tasks
|
||||||
|
GET/POST /api/measurements
|
||||||
|
GET /api/lunar?month=YYYY-MM
|
||||||
|
POST /api/export
|
||||||
|
POST /api/import
|
||||||
|
```
|
||||||
|
|
||||||
|
## Thème Gruvbox Dark
|
||||||
|
|
||||||
|
Palette CSS à respecter partout :
|
||||||
|
|
||||||
|
```
|
||||||
|
Background principal : #282828
|
||||||
|
Background secondaire : #3c3836
|
||||||
|
Texte principal : #ebdbb2
|
||||||
|
Texte secondaire : #a89984
|
||||||
|
Accent vert : #b8bb26
|
||||||
|
Accent jaune : #fabd2f
|
||||||
|
Accent bleu : #83a598
|
||||||
|
Accent orange : #fe8019
|
||||||
|
Erreur rouge : #fb4934
|
||||||
|
```
|
||||||
|
|
||||||
|
Typo : `Fira Code` ou `Courier New` pour le côté rétro.
|
||||||
|
|
||||||
|
## Fonctionnalités MVP (ordre d'implémentation)
|
||||||
|
|
||||||
|
1. Modèle DB + CRUD jardins / variétés / plantations / tâches
|
||||||
|
2. Upload images + galerie
|
||||||
|
3. Vue grille jardin (2D) + placement des plantations
|
||||||
|
4. Planning calendrier (semaine/mois) + vues filtrées
|
||||||
|
5. Calendrier lunaire (phases + jours racine/feuille/fleur/fruit)
|
||||||
|
6. Dashboard + export/import JSON
|
||||||
|
7. Polissage UI mobile + README final
|
||||||
|
|
||||||
|
## Pages de l'interface
|
||||||
|
|
||||||
|
1. **Dashboard** — tâches du jour, mesures récentes, plantations actives
|
||||||
|
2. **Jardins** — liste, création, fiche jardin
|
||||||
|
3. **Grille jardin** — vue cases, détails par case
|
||||||
|
4. **Catalogue variétés** — liste, fiche variété
|
||||||
|
5. **Plantations** — liste filtrable, création, fiche
|
||||||
|
6. **Planning** — calendrier mois/semaine + actions
|
||||||
|
7. **Tâches** — Kanban simple ou liste
|
||||||
|
8. **Calendrier lunaire** — vue mois + détails jour
|
||||||
|
9. **Réglages** — unités, localisation, export/import, sauvegarde
|
||||||
|
|
||||||
|
## Qualité & conventions
|
||||||
|
|
||||||
|
- Validation stricte Pydantic côté backend
|
||||||
|
- Logs structurés (JSON) côté backend
|
||||||
|
- Tests CRUD + filtres pour chaque ressource API
|
||||||
|
- Variables d'environnement côté backend uniquement (pas de secrets dans le frontend)
|
||||||
|
- Données de démo (seed) : 1 jardin + quelques variétés + plantations + tâches
|
||||||
@@ -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`.
|
||||||
@@ -16,8 +16,8 @@ cp .env.example .env
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- Application : http://localhost
|
- Application : http://localhost:8061
|
||||||
- API (docs Swagger) : http://localhost:8000/docs
|
- API (docs Swagger) : http://localhost:8060/docs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,7 +99,10 @@ cp data/jardin.db data/jardin_backup_$(date +%Y%m%d).db
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Documentation interactive disponible sur http://localhost:8000/docs (Swagger UI).
|
Documentation interactive disponible sur http://localhost:8060/docs (Swagger UI).
|
||||||
|
|
||||||
|
Guide reseau local / VM / OpenClaw:
|
||||||
|
- `docs/api_utilisation_reseau_local_openclaw.md`
|
||||||
|
|
||||||
Endpoints principaux :
|
Endpoints principaux :
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
- [x] photo : upload + conversion WebP + thumbnail automatique
|
||||||
|
- couleur predominante : plantes: vert; jardin : marron; arrosage : bleu; outils: jaune
|
||||||
|
- [ ] ajout icones representatives dimensionnables
|
||||||
|
|
||||||
|
jardin :
|
||||||
|
- [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 :
|
||||||
|
- [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:
|
||||||
|
- [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:
|
||||||
|
- [x] outils de jardinage : CRUD complet, catégories
|
||||||
|
- [x] liste dans le header (OutilsView)
|
||||||
|
- [x] 1ère liste d'outils communs seedée (grelinette, pelle, bêche, pioche, sarcloir, ...)
|
||||||
|
|
||||||
|
planning:
|
||||||
|
- [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:
|
||||||
|
- [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:
|
||||||
|
- [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:
|
||||||
|
- [ ] 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 :
|
||||||
|
- [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:
|
||||||
|
- [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 :
|
||||||
|
- [ ] 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)
|
||||||
|
|
||||||
|
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 :
|
||||||
|
- [x] migration automatique BDD (migrate.py : ALTER TABLE ADD COLUMN sans perte de données)
|
||||||
|
- [x] mise à jour BDD via API REST
|
||||||
|
- [ ] ajouter des étoiles 1 à 5 (satisfaction plante)
|
||||||
|
|
||||||
|
divers :
|
||||||
|
- [ ] page 404 catch-all (route Vue manquante)
|
||||||
|
- [ ] export/import JSON complet
|
||||||
|
- [ ] observations dans PlantationsView (backend prêt, UI manquante)
|
||||||
|
- [ ] tests backend : couverture ~60% → objectif 80%
|
||||||
@@ -4,4 +4,4 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p /data/uploads
|
RUN mkdir -p /data/uploads
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8060"]
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ import os
|
|||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./jardin.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./jardin.db")
|
||||||
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads")
|
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads")
|
||||||
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||||
|
STATION_URL = os.getenv("STATION_URL", "http://10.0.0.8:8081/")
|
||||||
|
METEO_LAT = float(os.getenv("METEO_LAT", "45.14"))
|
||||||
|
METEO_LON = float(os.getenv("METEO_LON", "4.12"))
|
||||||
|
ENABLE_SCHEDULER = os.getenv("ENABLE_SCHEDULER", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
ENABLE_BOOTSTRAP = os.getenv("ENABLE_BOOTSTRAP", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
from sqlmodel import SQLModel, create_engine, Session
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
|
from sqlalchemy import event
|
||||||
from app.config import DATABASE_URL
|
from app.config import DATABASE_URL
|
||||||
|
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import CORS_ORIGINS, UPLOAD_DIR
|
from app.config import CORS_ORIGINS, ENABLE_BOOTSTRAP, ENABLE_SCHEDULER, UPLOAD_DIR
|
||||||
from app.database import create_db_and_tables
|
from app.database import create_db_and_tables
|
||||||
|
|
||||||
|
|
||||||
@@ -15,16 +16,30 @@ async def lifespan(app: FastAPI):
|
|||||||
os.makedirs("/data/skyfield", exist_ok=True)
|
os.makedirs("/data/skyfield", exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if ENABLE_BOOTSTRAP:
|
||||||
import app.models # noqa — enregistre tous les modèles avant create_all
|
import app.models # noqa — enregistre tous les modèles avant create_all
|
||||||
from app.migrate import run_migrations
|
from app.migrate import run_migrations
|
||||||
run_migrations()
|
run_migrations()
|
||||||
create_db_and_tables()
|
create_db_and_tables()
|
||||||
from app.seed import run_seed
|
from app.seed import run_seed
|
||||||
run_seed()
|
run_seed()
|
||||||
|
if ENABLE_SCHEDULER:
|
||||||
|
from app.services.scheduler import setup_scheduler, backfill_station_missing_dates
|
||||||
|
setup_scheduler()
|
||||||
|
# Backfill des dates manquantes en arrière-plan (ne bloque pas le démarrage)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.run_in_executor(None, backfill_station_missing_dates)
|
||||||
yield
|
yield
|
||||||
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -42,11 +57,14 @@ from app.routers import ( # noqa
|
|||||||
media,
|
media,
|
||||||
tools,
|
tools,
|
||||||
dictons,
|
dictons,
|
||||||
|
saints,
|
||||||
astuces,
|
astuces,
|
||||||
recoltes,
|
recoltes,
|
||||||
lunar,
|
lunar,
|
||||||
meteo,
|
meteo,
|
||||||
identify,
|
identify,
|
||||||
|
achats,
|
||||||
|
fabrications,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(gardens.router, prefix="/api")
|
app.include_router(gardens.router, prefix="/api")
|
||||||
@@ -57,11 +75,14 @@ app.include_router(settings.router, prefix="/api")
|
|||||||
app.include_router(media.router, prefix="/api")
|
app.include_router(media.router, prefix="/api")
|
||||||
app.include_router(tools.router, prefix="/api")
|
app.include_router(tools.router, prefix="/api")
|
||||||
app.include_router(dictons.router, prefix="/api")
|
app.include_router(dictons.router, prefix="/api")
|
||||||
|
app.include_router(saints.router, prefix="/api")
|
||||||
app.include_router(astuces.router, prefix="/api")
|
app.include_router(astuces.router, prefix="/api")
|
||||||
app.include_router(recoltes.router, prefix="/api")
|
app.include_router(recoltes.router, prefix="/api")
|
||||||
app.include_router(lunar.router, prefix="/api")
|
app.include_router(lunar.router, prefix="/api")
|
||||||
app.include_router(meteo.router, prefix="/api")
|
app.include_router(meteo.router, prefix="/api")
|
||||||
app.include_router(identify.router, prefix="/api")
|
app.include_router(identify.router, prefix="/api")
|
||||||
|
app.include_router(achats.router, prefix="/api")
|
||||||
|
app.include_router(fabrications.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -13,9 +13,34 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
("maladies_courantes", "TEXT", None),
|
("maladies_courantes", "TEXT", None),
|
||||||
("astuces_culture", "TEXT", None),
|
("astuces_culture", "TEXT", None),
|
||||||
("url_reference", "TEXT", None),
|
("url_reference", "TEXT", None),
|
||||||
|
("associations_favorables", "TEXT", None), # JSON list[str]
|
||||||
|
("associations_defavorables", "TEXT", None), # JSON list[str]
|
||||||
|
("temp_germination", "TEXT", None),
|
||||||
|
("temps_levee_j", "TEXT", None),
|
||||||
|
],
|
||||||
|
"plant_variety": [
|
||||||
|
("variete", "TEXT", None),
|
||||||
|
("tags", "TEXT", None),
|
||||||
|
("notes_variete", "TEXT", None),
|
||||||
|
("boutique_nom", "TEXT", None),
|
||||||
|
("boutique_url", "TEXT", None),
|
||||||
|
("prix_achat", "REAL", None),
|
||||||
|
("date_achat", "TEXT", None),
|
||||||
|
("poids", "TEXT", None),
|
||||||
|
("dluo", "TEXT", None),
|
||||||
],
|
],
|
||||||
"garden": [
|
"garden": [
|
||||||
|
("latitude", "REAL", None),
|
||||||
|
("longitude", "REAL", None),
|
||||||
|
("altitude", "REAL", None),
|
||||||
|
("adresse", "TEXT", None),
|
||||||
|
("longueur_m", "REAL", None),
|
||||||
|
("largeur_m", "REAL", None),
|
||||||
("surface_m2", "REAL", None),
|
("surface_m2", "REAL", None),
|
||||||
|
("carre_potager", "INTEGER", "0"),
|
||||||
|
("carre_x_cm", "INTEGER", None),
|
||||||
|
("carre_y_cm", "INTEGER", None),
|
||||||
|
("photo_parcelle", "TEXT", None),
|
||||||
("ensoleillement", "TEXT", None),
|
("ensoleillement", "TEXT", None),
|
||||||
],
|
],
|
||||||
"task": [
|
"task": [
|
||||||
@@ -23,6 +48,26 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
("date_prochaine", "TEXT", None),
|
("date_prochaine", "TEXT", None),
|
||||||
("outil_id", "INTEGER", None),
|
("outil_id", "INTEGER", None),
|
||||||
],
|
],
|
||||||
|
"meteostation": [
|
||||||
|
("t_min", "REAL", None),
|
||||||
|
("t_max", "REAL", None),
|
||||||
|
],
|
||||||
|
"tool": [
|
||||||
|
("photo_url", "TEXT", None),
|
||||||
|
("video_url", "TEXT", None),
|
||||||
|
("notice_texte", "TEXT", None),
|
||||||
|
("notice_fichier_url", "TEXT", None),
|
||||||
|
("boutique_nom", "TEXT", None),
|
||||||
|
("boutique_url", "TEXT", None),
|
||||||
|
("prix_achat", "REAL", None),
|
||||||
|
],
|
||||||
|
"planting": [
|
||||||
|
("boutique_nom", "TEXT", None),
|
||||||
|
("boutique_url", "TEXT", None),
|
||||||
|
("tarif_achat", "REAL", None),
|
||||||
|
("date_achat", "TEXT", None),
|
||||||
|
("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect)
|
||||||
|
],
|
||||||
"plantvariety": [
|
"plantvariety": [
|
||||||
# ancien nom de table → migration vers "plant" si présente
|
# ancien nom de table → migration vers "plant" si présente
|
||||||
],
|
],
|
||||||
@@ -32,6 +77,41 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
("identified_confidence", "REAL", None),
|
("identified_confidence", "REAL", None),
|
||||||
("identified_source", "TEXT", None),
|
("identified_source", "TEXT", None),
|
||||||
],
|
],
|
||||||
|
"astuce": [
|
||||||
|
("categorie", "TEXT", None),
|
||||||
|
("tags", "TEXT", None),
|
||||||
|
("mois", "TEXT", None),
|
||||||
|
("photos", "TEXT", None),
|
||||||
|
("videos", "TEXT", None),
|
||||||
|
],
|
||||||
|
"achat_intrant": [
|
||||||
|
("categorie", "TEXT", None),
|
||||||
|
("nom", "TEXT", None),
|
||||||
|
("marque", "TEXT", None),
|
||||||
|
("boutique_nom", "TEXT", None),
|
||||||
|
("boutique_url", "TEXT", None),
|
||||||
|
("prix", "REAL", None),
|
||||||
|
("poids", "TEXT", None),
|
||||||
|
("date_achat", "TEXT", None),
|
||||||
|
("dluo", "TEXT", None),
|
||||||
|
("notes", "TEXT", None),
|
||||||
|
("jardin_id", "INTEGER", None),
|
||||||
|
("plantation_id", "INTEGER", None),
|
||||||
|
("tache_id", "INTEGER", None),
|
||||||
|
],
|
||||||
|
"fabrication": [
|
||||||
|
("type", "TEXT", None),
|
||||||
|
("nom", "TEXT", None),
|
||||||
|
("ingredients", "TEXT", None),
|
||||||
|
("date_debut", "TEXT", None),
|
||||||
|
("date_fin_prevue", "TEXT", None),
|
||||||
|
("statut", "TEXT", "'en_cours'"),
|
||||||
|
("quantite_produite", "TEXT", None),
|
||||||
|
("notes", "TEXT", None),
|
||||||
|
("jardin_id", "INTEGER", None),
|
||||||
|
("plantation_id", "INTEGER", None),
|
||||||
|
("tache_id", "INTEGER", None),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa
|
from app.models.garden import Garden, GardenCell, GardenImage, Measurement # noqa
|
||||||
from app.models.plant import PlantVariety, PlantImage # noqa
|
from app.models.plant import Plant, PlantImage, PlantVariety, PlantWithVarieties # noqa
|
||||||
from app.models.planting import Planting, PlantingEvent # noqa
|
from app.models.planting import Planting, PlantingEvent # noqa
|
||||||
from app.models.task import Task # noqa
|
from app.models.task import Task # noqa
|
||||||
from app.models.settings import UserSettings, LunarCalendarEntry # noqa
|
from app.models.settings import UserSettings, LunarCalendarEntry # noqa
|
||||||
|
from app.models.tool import Tool # noqa
|
||||||
|
from app.models.media import Media, Attachment # noqa
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Astuce(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
# Anciens champs conservés (colonnes existantes en DB)
|
||||||
|
entity_type: Optional[str] = None
|
||||||
|
entity_id: Optional[int] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
# Champs principaux
|
||||||
|
titre: str
|
||||||
|
contenu: str
|
||||||
|
# Nouveaux champs bibliothèque
|
||||||
|
categorie: Optional[str] = None # "plante"|"jardin"|"tache"|"general"|"ravageur"|"maladie"
|
||||||
|
tags: Optional[str] = None # JSON array string: '["tomate","semis"]'
|
||||||
|
mois: Optional[str] = None # JSON array string: '[3,4,5]' ou null = toute l'année
|
||||||
|
photos: Optional[str] = None # JSON array string: '["/uploads/a.webp"]'
|
||||||
|
videos: Optional[str] = None # JSON array string: '["/uploads/b.mp4"]'
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Dicton(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
mois: int # 1-12
|
||||||
|
jour: Optional[int] = None
|
||||||
|
texte: str
|
||||||
|
region: Optional[str] = None # Auvergne|Haute-Loire|National
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
@@ -16,10 +16,18 @@ class Garden(SQLModel, table=True):
|
|||||||
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
|
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
|
||||||
sol_type: Optional[str] = None
|
sol_type: Optional[str] = None
|
||||||
sol_ph: Optional[float] = None
|
sol_ph: Optional[float] = None
|
||||||
|
longueur_m: Optional[float] = None
|
||||||
|
largeur_m: Optional[float] = None
|
||||||
|
surface_m2: Optional[float] = None
|
||||||
|
carre_potager: bool = False
|
||||||
|
carre_x_cm: Optional[int] = None
|
||||||
|
carre_y_cm: Optional[int] = None
|
||||||
|
photo_parcelle: Optional[str] = None
|
||||||
|
ensoleillement: Optional[str] = None
|
||||||
grille_largeur: int = 6
|
grille_largeur: int = 6
|
||||||
grille_hauteur: int = 4
|
grille_hauteur: int = 4
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class GardenCell(SQLModel, table=True):
|
class GardenCell(SQLModel, table=True):
|
||||||
@@ -39,7 +47,7 @@ class GardenImage(SQLModel, table=True):
|
|||||||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
garden_id: int = Field(foreign_key="garden.id", index=True)
|
||||||
filename: str
|
filename: str
|
||||||
caption: Optional[str] = None
|
caption: Optional[str] = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class Measurement(SQLModel, table=True):
|
class Measurement(SQLModel, table=True):
|
||||||
@@ -50,4 +58,4 @@ class Measurement(SQLModel, table=True):
|
|||||||
humidite_air: Optional[float] = None
|
humidite_air: Optional[float] = None
|
||||||
humidite_sol: Optional[float] = None
|
humidite_sol: Optional[float] = None
|
||||||
source: str = "manuel" # manuel | capteur
|
source: str = "manuel" # manuel | capteur
|
||||||
ts: datetime = Field(default_factory=datetime.utcnow)
|
ts: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# backend/app/models/intrant.py
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import JSON as SA_JSON
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class AchatIntrant(SQLModel, table=True):
|
||||||
|
__tablename__ = "achat_intrant"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
categorie: str # terreau | engrais | traitement | autre
|
||||||
|
nom: str
|
||||||
|
marque: Optional[str] = None
|
||||||
|
boutique_nom: Optional[str] = None
|
||||||
|
boutique_url: Optional[str] = None
|
||||||
|
prix: Optional[float] = None
|
||||||
|
poids: Optional[str] = None # "20L", "1kg", "500ml"
|
||||||
|
date_achat: Optional[str] = None # ISO date
|
||||||
|
dluo: Optional[str] = None # ISO date
|
||||||
|
notes: Optional[str] = None
|
||||||
|
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
|
||||||
|
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
|
||||||
|
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class Ingredient(SQLModel):
|
||||||
|
"""Modèle pour un ingrédient de fabrication (non persisté seul)."""
|
||||||
|
nom: str
|
||||||
|
quantite: str # "1kg", "10L"
|
||||||
|
|
||||||
|
|
||||||
|
class Fabrication(SQLModel, table=True):
|
||||||
|
__tablename__ = "fabrication"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
type: str # compost | decoction | purin | autre
|
||||||
|
nom: str
|
||||||
|
ingredients: Optional[List[dict]] = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column("ingredients", SA_JSON, nullable=True),
|
||||||
|
)
|
||||||
|
date_debut: Optional[str] = None # ISO date
|
||||||
|
date_fin_prevue: Optional[str] = None # ISO date
|
||||||
|
statut: str = "en_cours" # en_cours | pret | utilise | echec
|
||||||
|
quantite_produite: Optional[str] = None # "8L", "50kg"
|
||||||
|
notes: Optional[str] = None
|
||||||
|
jardin_id: Optional[int] = Field(default=None, foreign_key="garden.id")
|
||||||
|
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
|
||||||
|
tache_id: Optional[int] = Field(default=None, foreign_key="task.id")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class FabricationStatutUpdate(SQLModel):
|
||||||
|
statut: str
|
||||||
@@ -5,7 +5,7 @@ from sqlmodel import Field, SQLModel
|
|||||||
|
|
||||||
class Media(SQLModel, table=True):
|
class Media(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
entity_type: str # jardin|plante|outil|plantation
|
entity_type: str # jardin|plante|adventice|outil|plantation|bibliotheque
|
||||||
entity_id: int
|
entity_id: int
|
||||||
url: str
|
url: str
|
||||||
thumbnail_url: Optional[str] = None
|
thumbnail_url: Optional[str] = None
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class MeteoStation(SQLModel, table=True):
|
||||||
|
"""Données collectées depuis la station WeeWX locale."""
|
||||||
|
__tablename__ = "meteostation"
|
||||||
|
|
||||||
|
date_heure: str = Field(primary_key=True) # "2026-02-22T14:00"
|
||||||
|
type: str = "current" # "current" | "veille"
|
||||||
|
temp_ext: Optional[float] = None # °C extérieur
|
||||||
|
t_min: Optional[float] = None # résumé journée (NOAA)
|
||||||
|
t_max: Optional[float] = None # résumé journée (NOAA)
|
||||||
|
temp_int: Optional[float] = None # °C intérieur (serre)
|
||||||
|
humidite: Optional[float] = None # %
|
||||||
|
pression: Optional[float] = None # hPa
|
||||||
|
pluie_mm: Optional[float] = None # précipitations
|
||||||
|
vent_kmh: Optional[float] = None
|
||||||
|
vent_dir: Optional[str] = None # N/NE/E/SE/S/SO/O/NO
|
||||||
|
uv: Optional[float] = None
|
||||||
|
solaire: Optional[float] = None # W/m²
|
||||||
|
|
||||||
|
|
||||||
|
class MeteoOpenMeteo(SQLModel, table=True):
|
||||||
|
"""Prévisions journalières Open-Meteo."""
|
||||||
|
__tablename__ = "meteoopenmeteo"
|
||||||
|
|
||||||
|
date: str = Field(primary_key=True) # "2026-02-22"
|
||||||
|
t_min: Optional[float] = None
|
||||||
|
t_max: Optional[float] = None
|
||||||
|
pluie_mm: Optional[float] = None
|
||||||
|
vent_kmh: Optional[float] = None
|
||||||
|
wmo: Optional[int] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
humidite_moy: Optional[float] = None
|
||||||
|
sol_0cm: Optional[float] = None # temp sol surface
|
||||||
|
etp_mm: Optional[float] = None # évapotranspiration
|
||||||
|
fetched_at: Optional[str] = None
|
||||||
@@ -1,35 +1,105 @@
|
|||||||
from datetime import datetime
|
# backend/app/models/plant.py
|
||||||
from typing import Optional
|
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
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
class PlantVariety(SQLModel, table=True):
|
class Plant(SQLModel, table=True):
|
||||||
|
__tablename__ = "plant"
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
nom_commun: str
|
nom_commun: str
|
||||||
nom_botanique: Optional[str] = None
|
nom_botanique: Optional[str] = None
|
||||||
variete: Optional[str] = None
|
|
||||||
famille: Optional[str] = None
|
famille: Optional[str] = None
|
||||||
tags: Optional[str] = None # CSV
|
type_plante: Optional[str] = None
|
||||||
type_plante: Optional[str] = None # legume | fruit | aromatique | fleur
|
categorie: Optional[str] = None # potager|fleur|arbre|arbuste
|
||||||
besoin_eau: Optional[str] = None # faible | moyen | fort
|
besoin_eau: Optional[str] = None # faible | moyen | fort
|
||||||
besoin_soleil: Optional[str] = None
|
besoin_soleil: Optional[str] = None
|
||||||
espacement_cm: Optional[int] = None
|
espacement_cm: Optional[int] = None
|
||||||
|
hauteur_cm: Optional[int] = None
|
||||||
temp_min_c: Optional[float] = None
|
temp_min_c: Optional[float] = None
|
||||||
|
temp_germination: Optional[str] = None # ex: "8-10°C"
|
||||||
|
temps_levee_j: Optional[str] = None # ex: "15-20 jours"
|
||||||
duree_culture_j: Optional[int] = None
|
duree_culture_j: Optional[int] = None
|
||||||
profondeur_semis_cm: Optional[float] = None
|
profondeur_semis_cm: Optional[float] = None
|
||||||
sol_conseille: Optional[str] = None
|
sol_conseille: Optional[str] = None
|
||||||
semis_interieur_mois: Optional[str] = None # ex: "2,3"
|
semis_interieur_mois: Optional[str] = None # CSV ex: "2,3"
|
||||||
semis_exterieur_mois: Optional[str] = None
|
semis_exterieur_mois: Optional[str] = None
|
||||||
repiquage_mois: Optional[str] = None
|
repiquage_mois: Optional[str] = None
|
||||||
plantation_mois: Optional[str] = None
|
plantation_mois: Optional[str] = None
|
||||||
recolte_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
|
notes: Optional[str] = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
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):
|
class PlantImage(SQLModel, table=True):
|
||||||
|
__tablename__ = "plant_image"
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
variety_id: int = Field(foreign_key="plantvariety.id", index=True)
|
plant_id: int = Field(foreign_key="plant.id", index=True)
|
||||||
filename: str
|
filename: str
|
||||||
caption: Optional[str] = None
|
caption: Optional[str] = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -1,25 +1,56 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import JSON as SA_JSON
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
class Planting(SQLModel, table=True):
|
class PlantingCreate(SQLModel):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
garden_id: int
|
||||||
garden_id: int = Field(foreign_key="garden.id", index=True)
|
variety_id: int
|
||||||
variety_id: int = Field(foreign_key="plantvariety.id", index=True)
|
cell_id: Optional[int] = None
|
||||||
cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
|
cell_ids: Optional[List[int]] = None # multi-sélect zones
|
||||||
date_semis: Optional[date] = None
|
date_semis: Optional[date] = None
|
||||||
date_plantation: Optional[date] = None
|
date_plantation: Optional[date] = None
|
||||||
date_repiquage: Optional[date] = None
|
date_repiquage: Optional[date] = None
|
||||||
quantite: int = 1
|
quantite: int = 1
|
||||||
statut: str = "prevu" # prevu | en_cours | termine | echoue
|
statut: str = "prevu"
|
||||||
|
boutique_nom: Optional[str] = None
|
||||||
|
boutique_url: Optional[str] = None
|
||||||
|
tarif_achat: Optional[float] = None
|
||||||
|
date_achat: Optional[date] = None
|
||||||
date_recolte_debut: Optional[date] = None
|
date_recolte_debut: Optional[date] = None
|
||||||
date_recolte_fin: Optional[date] = None
|
date_recolte_fin: Optional[date] = None
|
||||||
rendement_estime: Optional[float] = None
|
rendement_estime: Optional[float] = None
|
||||||
rendement_reel: Optional[float] = None
|
rendement_reel: Optional[float] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
|
class Planting(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=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
|
||||||
|
quantite: int = 1
|
||||||
|
statut: str = "prevu" # prevu | en_cours | termine | echoue
|
||||||
|
boutique_nom: Optional[str] = None
|
||||||
|
boutique_url: Optional[str] = None
|
||||||
|
tarif_achat: Optional[float] = None
|
||||||
|
date_achat: Optional[date] = None
|
||||||
|
date_recolte_debut: Optional[date] = None
|
||||||
|
date_recolte_fin: Optional[date] = None
|
||||||
|
rendement_estime: Optional[float] = None
|
||||||
|
rendement_reel: Optional[float] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
class PlantingEvent(SQLModel, table=True):
|
class PlantingEvent(SQLModel, table=True):
|
||||||
@@ -27,4 +58,4 @@ class PlantingEvent(SQLModel, table=True):
|
|||||||
planting_id: int = Field(foreign_key="planting.id", index=True)
|
planting_id: int = Field(foreign_key="planting.id", index=True)
|
||||||
type: str # arrosage | taille | traitement | observation | autre
|
type: str # arrosage | taille | traitement | observation | autre
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
ts: datetime = Field(default_factory=datetime.utcnow)
|
ts: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class RecolteCreate(SQLModel):
|
||||||
|
quantite: float
|
||||||
|
unite: str = "kg" # kg|g|unites|litres|bottes
|
||||||
|
date_recolte: date
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Recolte(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
plantation_id: int = Field(foreign_key="planting.id", index=True)
|
||||||
|
quantite: float
|
||||||
|
unite: str = "kg"
|
||||||
|
date_recolte: date
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class ObservationCreate(SQLModel):
|
||||||
|
type: str # maladie|ravageur|traitement|note
|
||||||
|
titre: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
date: date
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
plantation_id: Optional[int] = None
|
||||||
|
garden_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Observation(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
type: str
|
||||||
|
titre: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
date: date
|
||||||
|
photo_url: Optional[str] = None
|
||||||
|
plantation_id: Optional[int] = Field(default=None, foreign_key="planting.id")
|
||||||
|
garden_id: Optional[int] = Field(default=None, foreign_key="garden.id")
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
@@ -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,17 +1,34 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(SQLModel):
|
||||||
|
titre: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
garden_id: Optional[int] = None
|
||||||
|
planting_id: Optional[int] = None
|
||||||
|
outil_id: Optional[int] = None
|
||||||
|
priorite: str = "normale" # basse | normale | haute
|
||||||
|
echeance: Optional[date] = None
|
||||||
|
recurrence: Optional[str] = None
|
||||||
|
frequence_jours: Optional[int] = None
|
||||||
|
date_prochaine: Optional[date] = None
|
||||||
|
statut: str = "a_faire" # a_faire | en_cours | fait | annule
|
||||||
|
|
||||||
|
|
||||||
class Task(SQLModel, table=True):
|
class Task(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
titre: str
|
titre: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
garden_id: Optional[int] = Field(default=None, foreign_key="garden.id")
|
garden_id: Optional[int] = Field(default=None, foreign_key="garden.id")
|
||||||
planting_id: Optional[int] = Field(default=None, foreign_key="planting.id")
|
planting_id: Optional[int] = Field(default=None, foreign_key="planting.id")
|
||||||
priorite: str = "normale" # basse | normale | haute
|
outil_id: Optional[int] = Field(default=None, foreign_key="tool.id")
|
||||||
|
priorite: str = "normale"
|
||||||
echeance: Optional[date] = None
|
echeance: Optional[date] = None
|
||||||
recurrence: Optional[str] = None # quotidien | hebdomadaire | mensuel
|
recurrence: Optional[str] = None
|
||||||
statut: str = "a_faire" # a_faire | en_cours | fait | annule
|
frequence_jours: Optional[int] = None
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
date_prochaine: Optional[date] = None
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
statut: str = "a_faire"
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Tool(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
nom: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
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
|
||||||
|
prix_achat: Optional[float] = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# backend/app/routers/achats.py
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.intrant import AchatIntrant
|
||||||
|
|
||||||
|
router = APIRouter(tags=["intrants"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/achats", response_model=List[AchatIntrant])
|
||||||
|
def list_achats(
|
||||||
|
categorie: Optional[str] = Query(None),
|
||||||
|
jardin_id: Optional[int] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(AchatIntrant)
|
||||||
|
if categorie:
|
||||||
|
q = q.where(AchatIntrant.categorie == categorie)
|
||||||
|
if jardin_id:
|
||||||
|
q = q.where(AchatIntrant.jardin_id == jardin_id)
|
||||||
|
return session.exec(q.order_by(AchatIntrant.created_at.desc())).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/achats", response_model=AchatIntrant, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_achat(a: AchatIntrant, session: Session = Depends(get_session)):
|
||||||
|
session.add(a)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/achats/{id}", response_model=AchatIntrant)
|
||||||
|
def get_achat(id: int, session: Session = Depends(get_session)):
|
||||||
|
a = session.get(AchatIntrant, id)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(404, "Achat introuvable")
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/achats/{id}", response_model=AchatIntrant)
|
||||||
|
def update_achat(id: int, data: AchatIntrant, session: Session = Depends(get_session)):
|
||||||
|
a = session.get(AchatIntrant, id)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(404, "Achat introuvable")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||||||
|
setattr(a, k, v)
|
||||||
|
session.add(a)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/achats/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_achat(id: int, session: Session = Depends(get_session)):
|
||||||
|
a = session.get(AchatIntrant, id)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(404, "Achat introuvable")
|
||||||
|
session.delete(a)
|
||||||
|
session.commit()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import json
|
||||||
|
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.astuce import Astuce
|
||||||
|
|
||||||
|
router = APIRouter(tags=["astuces"])
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_tags(raw: Optional[str]) -> list[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = [p.strip() for p in raw.split(",") if p.strip()]
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
return [str(x).strip().lower() for x in parsed if str(x).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_mois(raw: Optional[str]) -> list[int]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = [p.strip() for p in raw.split(",") if p.strip()]
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
result: list[int] = []
|
||||||
|
for x in parsed:
|
||||||
|
try:
|
||||||
|
month = int(x)
|
||||||
|
if 1 <= month <= 12:
|
||||||
|
result.append(month)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/astuces", response_model=List[Astuce])
|
||||||
|
def list_astuces(
|
||||||
|
entity_type: Optional[str] = Query(None),
|
||||||
|
entity_id: Optional[int] = Query(None),
|
||||||
|
categorie: Optional[str] = Query(None),
|
||||||
|
tag: Optional[str] = Query(None),
|
||||||
|
mois: Optional[int] = Query(None, ge=1, le=12),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(Astuce)
|
||||||
|
if entity_type:
|
||||||
|
q = q.where(Astuce.entity_type == entity_type)
|
||||||
|
if entity_id is not None:
|
||||||
|
q = q.where(Astuce.entity_id == entity_id)
|
||||||
|
|
||||||
|
if categorie:
|
||||||
|
q = q.where(Astuce.categorie == categorie)
|
||||||
|
|
||||||
|
items = session.exec(q).all()
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
wanted = tag.strip().lower()
|
||||||
|
items = [a for a in items if wanted in _decode_tags(a.tags)]
|
||||||
|
|
||||||
|
if mois is not None:
|
||||||
|
# mois null/empty = astuce valable toute l'année
|
||||||
|
items = [a for a in items if not _decode_mois(a.mois) or mois in _decode_mois(a.mois)]
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/astuces", response_model=Astuce, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_astuce(a: Astuce, session: Session = Depends(get_session)):
|
||||||
|
session.add(a)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/astuces/{id}", response_model=Astuce)
|
||||||
|
def get_astuce(id: int, session: Session = Depends(get_session)):
|
||||||
|
a = session.get(Astuce, id)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(404, "Astuce introuvable")
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/astuces/{id}", response_model=Astuce)
|
||||||
|
def update_astuce(id: int, data: Astuce, session: Session = Depends(get_session)):
|
||||||
|
a = session.get(Astuce, id)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(404, "Astuce 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("/astuces/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_astuce(id: int, session: Session = Depends(get_session)):
|
||||||
|
a = session.get(Astuce, id)
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(404, "Astuce introuvable")
|
||||||
|
session.delete(a)
|
||||||
|
session.commit()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.dicton import Dicton
|
||||||
|
|
||||||
|
router = APIRouter(tags=["dictons"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dictons", response_model=List[Dicton])
|
||||||
|
def list_dictons(
|
||||||
|
mois: Optional[int] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(Dicton)
|
||||||
|
if mois:
|
||||||
|
q = q.where(Dicton.mois == mois)
|
||||||
|
return session.exec(q).all()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# backend/app/routers/fabrications.py
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.intrant import Fabrication, FabricationStatutUpdate
|
||||||
|
|
||||||
|
router = APIRouter(tags=["intrants"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fabrications", response_model=List[Fabrication])
|
||||||
|
def list_fabrications(
|
||||||
|
type: Optional[str] = Query(None),
|
||||||
|
statut: Optional[str] = Query(None),
|
||||||
|
jardin_id: Optional[int] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(Fabrication)
|
||||||
|
if type:
|
||||||
|
q = q.where(Fabrication.type == type)
|
||||||
|
if statut:
|
||||||
|
q = q.where(Fabrication.statut == statut)
|
||||||
|
if jardin_id:
|
||||||
|
q = q.where(Fabrication.jardin_id == jardin_id)
|
||||||
|
return session.exec(q.order_by(Fabrication.created_at.desc())).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fabrications", response_model=Fabrication, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_fabrication(f: Fabrication, session: Session = Depends(get_session)):
|
||||||
|
session.add(f)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(f)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fabrications/{id}", response_model=Fabrication)
|
||||||
|
def get_fabrication(id: int, session: Session = Depends(get_session)):
|
||||||
|
f = session.get(Fabrication, id)
|
||||||
|
if not f:
|
||||||
|
raise HTTPException(404, "Fabrication introuvable")
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fabrications/{id}", response_model=Fabrication)
|
||||||
|
def update_fabrication(id: int, data: Fabrication, session: Session = Depends(get_session)):
|
||||||
|
f = session.get(Fabrication, id)
|
||||||
|
if not f:
|
||||||
|
raise HTTPException(404, "Fabrication introuvable")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||||||
|
setattr(f, k, v)
|
||||||
|
session.add(f)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(f)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/fabrications/{id}/statut", response_model=Fabrication)
|
||||||
|
def update_statut(id: int, data: FabricationStatutUpdate, session: Session = Depends(get_session)):
|
||||||
|
f = session.get(Fabrication, id)
|
||||||
|
if not f:
|
||||||
|
raise HTTPException(404, "Fabrication introuvable")
|
||||||
|
valid = {"en_cours", "pret", "utilise", "echec"}
|
||||||
|
if data.statut not in valid:
|
||||||
|
raise HTTPException(400, f"Statut invalide. Valeurs: {valid}")
|
||||||
|
f.statut = data.statut
|
||||||
|
session.add(f)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(f)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fabrications/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_fabrication(id: int, session: Session = Depends(get_session)):
|
||||||
|
f = session.get(Fabrication, id)
|
||||||
|
if not f:
|
||||||
|
raise HTTPException(404, "Fabrication introuvable")
|
||||||
|
session.delete(f)
|
||||||
|
session.commit()
|
||||||
@@ -1,15 +1,37 @@
|
|||||||
from datetime import datetime
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.config import UPLOAD_DIR
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
|
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
|
||||||
|
|
||||||
router = APIRouter(tags=["jardins"])
|
router = APIRouter(tags=["jardins"])
|
||||||
|
|
||||||
|
|
||||||
|
def _save_garden_photo(data: bytes) -> str:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
|
img.thumbnail((1800, 1800))
|
||||||
|
name = f"garden_{uuid.uuid4()}.webp"
|
||||||
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
|
img.save(path, "WEBP", quality=88)
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
name = f"garden_{uuid.uuid4()}.bin"
|
||||||
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gardens", response_model=List[Garden])
|
@router.get("/gardens", response_model=List[Garden])
|
||||||
def list_gardens(session: Session = Depends(get_session)):
|
def list_gardens(session: Session = Depends(get_session)):
|
||||||
return session.exec(select(Garden)).all()
|
return session.exec(select(Garden)).all()
|
||||||
@@ -31,6 +53,31 @@ def get_garden(id: int, session: Session = Depends(get_session)):
|
|||||||
return g
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gardens/{id}/photo", response_model=Garden)
|
||||||
|
async def upload_garden_photo(
|
||||||
|
id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
g = session.get(Garden, id)
|
||||||
|
if not g:
|
||||||
|
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
||||||
|
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
raise HTTPException(status_code=400, detail="Le fichier doit être une image")
|
||||||
|
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
data = await file.read()
|
||||||
|
filename = _save_garden_photo(data)
|
||||||
|
g.photo_parcelle = f"/uploads/{filename}"
|
||||||
|
g.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(g)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(g)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
@router.put("/gardens/{id}", response_model=Garden)
|
@router.put("/gardens/{id}", response_model=Garden)
|
||||||
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
|
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
|
||||||
g = session.get(Garden, id)
|
g = session.get(Garden, id)
|
||||||
@@ -38,7 +85,7 @@ def update_garden(id: int, data: Garden, session: Session = Depends(get_session)
|
|||||||
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
raise HTTPException(status_code=404, detail="Jardin introuvable")
|
||||||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||||||
setattr(g, k, v)
|
setattr(g, k, v)
|
||||||
g.updated_at = datetime.utcnow()
|
g.updated_at = datetime.now(timezone.utc)
|
||||||
session.add(g)
|
session.add(g)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(g)
|
session.refresh(g)
|
||||||
@@ -68,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio
|
|||||||
return cell
|
return cell
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/gardens/{id}/cells/{cell_id}", response_model=GardenCell)
|
||||||
|
def update_cell(id: int, cell_id: int, data: GardenCell, session: Session = Depends(get_session)):
|
||||||
|
c = session.get(GardenCell, cell_id)
|
||||||
|
if not c or c.garden_id != id:
|
||||||
|
raise HTTPException(status_code=404, detail="Case introuvable")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "garden_id"}).items():
|
||||||
|
setattr(c, k, v)
|
||||||
|
session.add(c)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
|
@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
|
||||||
def list_measurements(id: int, session: Session = Depends(get_session)):
|
def list_measurements(id: int, session: Session = Depends(get_session)):
|
||||||
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
router = APIRouter(tags=["lunaire"])
|
||||||
|
|
||||||
|
# Cache en mémoire : {mois_str: list[dict]}
|
||||||
|
_CACHE: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lunar")
|
||||||
|
def get_lunar(
|
||||||
|
month: str = Query(..., description="Format YYYY-MM"),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if month in _CACHE:
|
||||||
|
return _CACHE[month]
|
||||||
|
try:
|
||||||
|
year, mon = int(month[:4]), int(month[5:7])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise HTTPException(400, "Format attendu : YYYY-MM")
|
||||||
|
last_day = calendar.monthrange(year, mon)[1]
|
||||||
|
start = date(year, mon, 1)
|
||||||
|
end = date(year, mon, last_day)
|
||||||
|
try:
|
||||||
|
from app.services.lunar import build_calendar
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
result = [asdict(d) for d in build_calendar(start, end)]
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(503, "Service lunaire non disponible (skyfield non installé)")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Erreur calcul lunaire : {e}")
|
||||||
|
_CACHE[month] = result
|
||||||
|
return result
|
||||||
@@ -1,17 +1,122 @@
|
|||||||
import os
|
import os
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, 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 sqlmodel import Session, select
|
||||||
|
|
||||||
from app.config import UPLOAD_DIR
|
from app.config import UPLOAD_DIR
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.media import Attachment, Media
|
from app.models.media import Attachment, Media
|
||||||
|
from app.models.settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPatch(BaseModel):
|
||||||
|
entity_type: Optional[str] = None
|
||||||
|
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"])
|
router = APIRouter(tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_token(value: str) -> str:
|
||||||
|
token = (value or "").strip().lower()
|
||||||
|
token = unicodedata.normalize("NFKD", token).encode("ascii", "ignore").decode("ascii")
|
||||||
|
return token.replace("-", "_").replace(" ", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_entity_type(value: str, *, strict: bool = True) -> str:
|
||||||
|
token = _normalize_token(value)
|
||||||
|
canonical = ENTITY_TYPE_ALIASES.get(token, token)
|
||||||
|
if canonical in CANONICAL_ENTITY_TYPES:
|
||||||
|
return canonical
|
||||||
|
if strict:
|
||||||
|
allowed = ", ".join(sorted(CANONICAL_ENTITY_TYPES))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"entity_type invalide: '{value}'. Valeurs autorisees: {allowed}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_type_candidates(value: str) -> set[str]:
|
||||||
|
canonical = _normalize_entity_type(value, strict=True)
|
||||||
|
candidates = {canonical}
|
||||||
|
for alias, target in ENTITY_TYPE_ALIASES.items():
|
||||||
|
if target == canonical:
|
||||||
|
candidates.add(alias)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize_rows(rows: List[Media], session: Session) -> None:
|
||||||
|
changed = False
|
||||||
|
for media in rows:
|
||||||
|
normalized = _normalize_entity_type(media.entity_type, strict=False)
|
||||||
|
if normalized in CANONICAL_ENTITY_TYPES and normalized != media.entity_type:
|
||||||
|
media.entity_type = normalized
|
||||||
|
session.add(media)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pillow_heif
|
||||||
|
pillow_heif.register_heif_opener()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _is_heic(content_type: str, filename: str) -> bool:
|
||||||
|
if content_type.lower() in ("image/heic", "image/heif"):
|
||||||
|
return True
|
||||||
|
fn = (filename or "").lower()
|
||||||
|
return fn.endswith(".heic") or fn.endswith(".heif")
|
||||||
|
|
||||||
|
|
||||||
def _save_webp(data: bytes, max_px: int) -> str:
|
def _save_webp(data: bytes, max_px: int) -> str:
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -32,15 +137,31 @@ def _save_webp(data: bytes, max_px: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_file(file: UploadFile = File(...)):
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
ct = file.content_type or ""
|
ct = file.content_type or ""
|
||||||
if ct.startswith("image/"):
|
|
||||||
name = _save_webp(data, 1200)
|
# Lire la largeur max configurée (défaut 1200, 0 = taille originale)
|
||||||
|
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
|
||||||
|
max_px = 1200
|
||||||
|
if setting:
|
||||||
|
try:
|
||||||
|
max_px = int(setting.valeur)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if max_px <= 0:
|
||||||
|
max_px = 99999
|
||||||
|
|
||||||
|
heic = _is_heic(ct, file.filename or "")
|
||||||
|
if heic or ct.startswith("image/"):
|
||||||
|
name = _save_webp(data, max_px)
|
||||||
thumb = _save_webp(data, 300)
|
thumb = _save_webp(data, 300)
|
||||||
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
||||||
else:
|
|
||||||
name = f"{uuid.uuid4()}_{file.filename}"
|
name = f"{uuid.uuid4()}_{file.filename}"
|
||||||
path = os.path.join(UPLOAD_DIR, name)
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
@@ -56,8 +177,10 @@ def list_all_media(
|
|||||||
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
|
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
|
||||||
q = select(Media).order_by(Media.created_at.desc())
|
q = select(Media).order_by(Media.created_at.desc())
|
||||||
if entity_type:
|
if entity_type:
|
||||||
q = q.where(Media.entity_type == entity_type)
|
q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type)))
|
||||||
return session.exec(q).all()
|
rows = session.exec(q).all()
|
||||||
|
_canonicalize_rows(rows, session)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media", response_model=List[Media])
|
@router.get("/media", response_model=List[Media])
|
||||||
@@ -66,15 +189,37 @@ def list_media(
|
|||||||
entity_id: int = Query(...),
|
entity_id: int = Query(...),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
return session.exec(
|
rows = session.exec(
|
||||||
select(Media).where(
|
select(Media).where(
|
||||||
Media.entity_type == entity_type, Media.entity_id == entity_id
|
Media.entity_type.in_(_entity_type_candidates(entity_type)),
|
||||||
|
Media.entity_id == entity_id,
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
_canonicalize_rows(rows, session)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
||||||
def create_media(m: Media, session: Session = Depends(get_session)):
|
def create_media(m: Media, session: Session = Depends(get_session)):
|
||||||
|
m.entity_type = _normalize_entity_type(m.entity_type, strict=True)
|
||||||
|
session.add(m)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/media/{id}", response_model=Media)
|
||||||
|
def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_session)):
|
||||||
|
m = session.get(Media, id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "Media introuvable")
|
||||||
|
|
||||||
|
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.add(m)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(m)
|
session.refresh(m)
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Router météo — station WeeWX + Open-Meteo + tableau synthétique."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
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
|
||||||
|
|
||||||
|
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é."""
|
||||||
|
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
|
||||||
|
|
||||||
|
temps = [r[0] for r in rows if r[0] is not None]
|
||||||
|
t_mins = [r[1] for r in rows if r[1] is not None]
|
||||||
|
t_maxs = [r[2] for r in rows if r[2] is not None]
|
||||||
|
pluies = [r[3] for r in rows if r[3] is not None]
|
||||||
|
vents = [r[4] for r in rows if r[4] is not None]
|
||||||
|
hums = [r[5] for r in rows if r[5] is not None]
|
||||||
|
|
||||||
|
min_candidates = temps + t_mins
|
||||||
|
max_candidates = temps + t_maxs
|
||||||
|
|
||||||
|
return {
|
||||||
|
"t_min": round(min(min_candidates), 1) if min_candidates else None,
|
||||||
|
"t_max": round(max(max_candidates), 1) if max_candidates else None,
|
||||||
|
# WeeWX RSS expose souvent une pluie cumulée journalière.
|
||||||
|
"pluie_mm": round(max(pluies), 1) if pluies else 0.0,
|
||||||
|
"vent_kmh": round(max(vents), 1) if vents else None,
|
||||||
|
"humidite": round(sum(hums) / len(hums), 0) if hums else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _station_current_row(session: Session) -> Optional[dict]:
|
||||||
|
"""Dernière mesure station (max 2h d'ancienneté)."""
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
"temp_ext": row[0], "humidite": row[1], "pression": row[2],
|
||||||
|
"pluie_mm": row[3], "vent_kmh": row[4], "vent_dir": row[5],
|
||||||
|
"uv": row[6], "solaire": row[7], "date_heure": row[8],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
"t_min": row[0], "t_max": row[1], "pluie_mm": row[2],
|
||||||
|
"vent_kmh": row[3], "wmo": row[4], "label": row[5],
|
||||||
|
"humidite_moy": row[6], "sol_0cm": row[7], "etp_mm": row[8],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meteo/tableau")
|
||||||
|
def get_tableau(
|
||||||
|
center_date: Optional[str] = Query(None, description="Date centrale YYYY-MM-DD"),
|
||||||
|
span: int = Query(7, ge=1, le=31, description="Nombre de jours avant/après la date centrale"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Tableau synthétique centré sur une date, avec historique + prévision."""
|
||||||
|
today = date.today()
|
||||||
|
center = today
|
||||||
|
if center_date:
|
||||||
|
try:
|
||||||
|
center = date.fromisoformat(center_date)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="center_date invalide (format YYYY-MM-DD)") from exc
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for delta in range(-span, span + 1):
|
||||||
|
d = center + timedelta(days=delta)
|
||||||
|
iso = d.isoformat()
|
||||||
|
delta_today = (d - today).days
|
||||||
|
|
||||||
|
if delta_today < 0:
|
||||||
|
row_type = "passe"
|
||||||
|
station = _station_daily_summary(session, iso)
|
||||||
|
om = _open_meteo_day(session, iso)
|
||||||
|
elif delta_today == 0:
|
||||||
|
row_type = "aujourd_hui"
|
||||||
|
station_current = _station_current_row(session) or {}
|
||||||
|
station_daily = _station_daily_summary(session, iso) or {}
|
||||||
|
station = {**station_daily, **station_current} or None
|
||||||
|
om = _open_meteo_day(session, iso)
|
||||||
|
else:
|
||||||
|
row_type = "futur"
|
||||||
|
station = None
|
||||||
|
om = _open_meteo_day(session, iso)
|
||||||
|
|
||||||
|
rows.append({"date": iso, "type": row_type, "station": station, "open_meteo": om})
|
||||||
|
|
||||||
|
return {"rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meteo/station/current")
|
||||||
|
def get_station_current(session: Session = Depends(get_session)) -> Optional[dict]:
|
||||||
|
return _station_current_row(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meteo/station/history")
|
||||||
|
def get_station_history(
|
||||||
|
days: int = Query(7, ge=1, le=30),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
today = date.today()
|
||||||
|
result = []
|
||||||
|
for delta in range(-days, 0):
|
||||||
|
d = today + timedelta(days=delta)
|
||||||
|
iso = d.isoformat()
|
||||||
|
summary = _station_daily_summary(session, iso)
|
||||||
|
result.append({"date": iso, "station": summary})
|
||||||
|
return {"days": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meteo/previsions")
|
||||||
|
def get_previsions(
|
||||||
|
days: int = Query(7, ge=1, le=14),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
today = date.today()
|
||||||
|
result = []
|
||||||
|
for delta in range(0, days + 1):
|
||||||
|
d = today + timedelta(days=delta)
|
||||||
|
iso = d.isoformat()
|
||||||
|
om = _open_meteo_day(session, iso)
|
||||||
|
if om:
|
||||||
|
result.append({"date": iso, **om})
|
||||||
|
return {"days": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meteo")
|
||||||
|
def get_meteo_legacy(
|
||||||
|
days: int = Query(14, ge=1, le=16),
|
||||||
|
lat: float = Query(45.14),
|
||||||
|
lon: float = Query(4.12),
|
||||||
|
):
|
||||||
|
"""Compatibilité ascendante avec l'ancien endpoint."""
|
||||||
|
from app.services.meteo import fetch_forecast
|
||||||
|
return fetch_forecast(lat=lat, lon=lon, days=days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meteo/refresh")
|
||||||
|
def refresh_meteo() -> dict[str, str]:
|
||||||
|
"""Force le rafraîchissement immédiat des 3 jobs."""
|
||||||
|
from app.services.scheduler import scheduler
|
||||||
|
import datetime as dt
|
||||||
|
for job_id in ["station_current", "station_veille", "open_meteo"]:
|
||||||
|
job = scheduler.get_job(job_id)
|
||||||
|
if job:
|
||||||
|
job.modify(next_run_time=dt.datetime.now())
|
||||||
|
return {"status": "refresh planifié"}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.planting import Planting, PlantingEvent
|
from app.models.planting import Planting, PlantingCreate, PlantingEvent
|
||||||
|
|
||||||
router = APIRouter(tags=["plantations"])
|
router = APIRouter(tags=["plantations"])
|
||||||
|
|
||||||
@@ -14,7 +14,12 @@ def list_plantings(session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
|
||||||
def create_planting(p: Planting, session: Session = Depends(get_session)):
|
def create_planting(data: PlantingCreate, session: Session = Depends(get_session)):
|
||||||
|
d = data.model_dump()
|
||||||
|
# Rétro-compatibilité : cell_id = première zone sélectionnée
|
||||||
|
if d.get("cell_ids") and not d.get("cell_id"):
|
||||||
|
d["cell_id"] = d["cell_ids"][0]
|
||||||
|
p = Planting(**d)
|
||||||
session.add(p)
|
session.add(p)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(p)
|
session.refresh(p)
|
||||||
@@ -30,13 +35,18 @@ def get_planting(id: int, session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/plantings/{id}", response_model=Planting)
|
@router.put("/plantings/{id}", response_model=Planting)
|
||||||
def update_planting(id: int, data: Planting, session: Session = Depends(get_session)):
|
def update_planting(id: int, data: PlantingCreate, session: Session = Depends(get_session)):
|
||||||
p = session.get(Planting, id)
|
p = session.get(Planting, id)
|
||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
raise HTTPException(status_code=404, detail="Plantation introuvable")
|
||||||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
d = data.model_dump(exclude_unset=True)
|
||||||
|
# Rétro-compatibilité : cell_id = première zone sélectionnée
|
||||||
|
if "cell_ids" in d:
|
||||||
|
ids = d["cell_ids"] or []
|
||||||
|
d["cell_id"] = ids[0] if ids else None
|
||||||
|
for k, v in d.items():
|
||||||
setattr(p, k, v)
|
setattr(p, k, v)
|
||||||
p.updated_at = datetime.utcnow()
|
p.updated_at = datetime.now(timezone.utc)
|
||||||
session.add(p)
|
session.add(p)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(p)
|
session.refresh(p)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# backend/app/routers/plants.py
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.plant import Plant, PlantVariety, PlantWithVarieties
|
||||||
|
|
||||||
|
router = APIRouter(tags=["plantes"])
|
||||||
|
|
||||||
|
|
||||||
|
def _with_varieties(p: Plant, session: Session) -> PlantWithVarieties:
|
||||||
|
varieties = session.exec(
|
||||||
|
select(PlantVariety).where(PlantVariety.plant_id == p.id)
|
||||||
|
).all()
|
||||||
|
data = p.model_dump()
|
||||||
|
data["varieties"] = [v.model_dump() for v in varieties]
|
||||||
|
return PlantWithVarieties(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plants", response_model=List[PlantWithVarieties])
|
||||||
|
def list_plants(
|
||||||
|
categorie: Optional[str] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(Plant).order_by(Plant.nom_commun, Plant.id)
|
||||||
|
if categorie:
|
||||||
|
q = q.where(Plant.categorie == categorie)
|
||||||
|
return [_with_varieties(p, session) for p in session.exec(q).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/plants", response_model=PlantWithVarieties, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_plant(p: Plant, session: Session = Depends(get_session)):
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(p)
|
||||||
|
return _with_varieties(p, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plants/{id}", response_model=PlantWithVarieties)
|
||||||
|
def get_plant(id: int, session: Session = Depends(get_session)):
|
||||||
|
p = session.get(Plant, id)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Plante introuvable")
|
||||||
|
return _with_varieties(p, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/plants/{id}", response_model=PlantWithVarieties)
|
||||||
|
def update_plant(id: int, data: Plant, session: Session = Depends(get_session)):
|
||||||
|
p = session.get(Plant, id)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Plante introuvable")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(p)
|
||||||
|
return _with_varieties(p, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/plants/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_plant(id: int, session: Session = Depends(get_session)):
|
||||||
|
p = session.get(Plant, id)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Plante introuvable")
|
||||||
|
for v in session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all():
|
||||||
|
session.delete(v)
|
||||||
|
session.delete(p)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- CRUD Variétés ----
|
||||||
|
|
||||||
|
@router.get("/plants/{id}/varieties", response_model=List[PlantVariety])
|
||||||
|
def list_varieties(id: int, session: Session = Depends(get_session)):
|
||||||
|
if not session.get(Plant, id):
|
||||||
|
raise HTTPException(404, "Plante introuvable")
|
||||||
|
return session.exec(select(PlantVariety).where(PlantVariety.plant_id == id)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/plants/{id}/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_variety(id: int, v: PlantVariety, session: Session = Depends(get_session)):
|
||||||
|
if not session.get(Plant, id):
|
||||||
|
raise HTTPException(404, "Plante introuvable")
|
||||||
|
v.plant_id = id
|
||||||
|
session.add(v)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/plants/{id}/varieties/{vid}", response_model=PlantVariety)
|
||||||
|
def update_variety(id: int, vid: int, data: PlantVariety, session: Session = Depends(get_session)):
|
||||||
|
v = session.get(PlantVariety, vid)
|
||||||
|
if not v or v.plant_id != id:
|
||||||
|
raise HTTPException(404, "Variété introuvable")
|
||||||
|
for k, val in data.model_dump(exclude_unset=True, exclude={"id", "plant_id", "created_at"}).items():
|
||||||
|
setattr(v, k, val)
|
||||||
|
session.add(v)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/plants/{id}/varieties/{vid}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_variety(id: int, vid: int, session: Session = Depends(get_session)):
|
||||||
|
v = session.get(PlantVariety, vid)
|
||||||
|
if not v or v.plant_id != id:
|
||||||
|
raise HTTPException(404, "Variété introuvable")
|
||||||
|
session.delete(v)
|
||||||
|
session.commit()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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.recolte import Observation, ObservationCreate, Recolte, RecolteCreate
|
||||||
|
|
||||||
|
router = APIRouter(tags=["récoltes"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Récoltes (nested sous plantings) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/plantings/{planting_id}/recoltes", response_model=List[Recolte])
|
||||||
|
def list_recoltes(planting_id: int, session: Session = Depends(get_session)):
|
||||||
|
return session.exec(
|
||||||
|
select(Recolte).where(Recolte.plantation_id == planting_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/plantings/{planting_id}/recoltes",
|
||||||
|
response_model=Recolte,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_recolte(
|
||||||
|
planting_id: int,
|
||||||
|
data: RecolteCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
r = Recolte(plantation_id=planting_id, **data.model_dump())
|
||||||
|
session.add(r)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/recoltes/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_recolte(id: int, session: Session = Depends(get_session)):
|
||||||
|
r = session.get(Recolte, id)
|
||||||
|
if not r:
|
||||||
|
raise HTTPException(404, "Récolte introuvable")
|
||||||
|
session.delete(r)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Observations ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/observations", response_model=List[Observation])
|
||||||
|
def list_observations(
|
||||||
|
plantation_id: Optional[int] = Query(None),
|
||||||
|
garden_id: Optional[int] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(Observation)
|
||||||
|
if plantation_id is not None:
|
||||||
|
q = q.where(Observation.plantation_id == plantation_id)
|
||||||
|
if garden_id is not None:
|
||||||
|
q = q.where(Observation.garden_id == garden_id)
|
||||||
|
return session.exec(q).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/observations", response_model=Observation, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_observation(data: ObservationCreate, session: Session = Depends(get_session)):
|
||||||
|
o = Observation(**data.model_dump())
|
||||||
|
session.add(o)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(o)
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/observations/{id}", response_model=Observation)
|
||||||
|
def get_observation(id: int, session: Session = Depends(get_session)):
|
||||||
|
o = session.get(Observation, id)
|
||||||
|
if not o:
|
||||||
|
raise HTTPException(404, "Observation introuvable")
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/observations/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_observation(id: int, session: Session = Depends(get_session)):
|
||||||
|
o = session.get(Observation, id)
|
||||||
|
if not o:
|
||||||
|
raise HTTPException(404, "Observation introuvable")
|
||||||
|
session.delete(o)
|
||||||
|
session.commit()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Router saints — consultation des saints du jour (indépendant de l'année)."""
|
||||||
|
import json
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models.saint import SaintDuJour
|
||||||
|
|
||||||
|
router = APIRouter(tags=["saints"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saints", response_model=List[SaintDuJour])
|
||||||
|
def list_saints(
|
||||||
|
mois: Optional[int] = Query(None, ge=1, le=12),
|
||||||
|
jour: Optional[int] = Query(None, ge=1, le=31),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Liste les saints. Filtrer par mois et/ou jour."""
|
||||||
|
q = select(SaintDuJour)
|
||||||
|
if mois is not None:
|
||||||
|
q = q.where(SaintDuJour.mois == mois)
|
||||||
|
if jour is not None:
|
||||||
|
q = q.where(SaintDuJour.jour == jour)
|
||||||
|
q = q.order_by(SaintDuJour.mois, SaintDuJour.jour)
|
||||||
|
return session.exec(q).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/saints/jour", response_model=dict)
|
||||||
|
def get_saints_du_jour(
|
||||||
|
mois: int = Query(..., ge=1, le=12),
|
||||||
|
jour: int = Query(..., ge=1, le=31),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retourne les saints et leur liste parsée pour un jour précis."""
|
||||||
|
row = session.exec(
|
||||||
|
select(SaintDuJour).where(
|
||||||
|
SaintDuJour.mois == mois,
|
||||||
|
SaintDuJour.jour == jour,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return {"mois": mois, "jour": jour, "saints": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
saints_list = json.loads(row.saints_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
saints_list = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mois": row.mois,
|
||||||
|
"jour": row.jour,
|
||||||
|
"saints": saints_list,
|
||||||
|
"source_url": row.source_url,
|
||||||
|
}
|
||||||
@@ -1,11 +1,190 @@
|
|||||||
from datetime import date
|
import os
|
||||||
from fastapi import APIRouter, Depends
|
import shutil
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.settings import UserSettings, LunarCalendarEntry
|
from app.models.settings import UserSettings
|
||||||
|
from app.config import DATABASE_URL, UPLOAD_DIR
|
||||||
|
|
||||||
router = APIRouter(tags=["réglages"])
|
router = APIRouter(tags=["réglages"])
|
||||||
|
|
||||||
|
_PREV_CPU_USAGE_USEC: int | None = None
|
||||||
|
_PREV_CPU_TS: float | None = None
|
||||||
|
_TEXT_EXTENSIONS = {
|
||||||
|
".txt", ".md", ".markdown", ".json", ".csv", ".log", ".ini", ".yaml", ".yml", ".xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_int_from_paths(paths: list[str]) -> int | None:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read().strip().split()[0]
|
||||||
|
return int(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cgroup_cpu_usage_usec() -> int | None:
|
||||||
|
# cgroup v2
|
||||||
|
try:
|
||||||
|
with open("/sys/fs/cgroup/cpu.stat", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("usage_usec "):
|
||||||
|
return int(line.split()[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# cgroup v1
|
||||||
|
ns = _read_int_from_paths(["/sys/fs/cgroup/cpuacct/cpuacct.usage"])
|
||||||
|
if ns is not None:
|
||||||
|
return ns // 1000
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cpu_quota_cores() -> float | None:
|
||||||
|
# cgroup v2
|
||||||
|
try:
|
||||||
|
with open("/sys/fs/cgroup/cpu.max", "r", encoding="utf-8") as f:
|
||||||
|
quota, period = f.read().strip().split()[:2]
|
||||||
|
if quota == "max":
|
||||||
|
return float(os.cpu_count() or 1)
|
||||||
|
q, p = int(quota), int(period)
|
||||||
|
if p > 0:
|
||||||
|
return max(q / p, 0.01)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# cgroup v1
|
||||||
|
quota = _read_int_from_paths(["/sys/fs/cgroup/cpu/cpu.cfs_quota_us"])
|
||||||
|
period = _read_int_from_paths(["/sys/fs/cgroup/cpu/cpu.cfs_period_us"])
|
||||||
|
if quota is not None and period is not None and quota > 0 and period > 0:
|
||||||
|
return max(quota / period, 0.01)
|
||||||
|
|
||||||
|
return float(os.cpu_count() or 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_stats() -> dict[str, Any]:
|
||||||
|
used = _read_int_from_paths(
|
||||||
|
[
|
||||||
|
"/sys/fs/cgroup/memory.current", # cgroup v2
|
||||||
|
"/sys/fs/cgroup/memory/memory.usage_in_bytes", # cgroup v1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
limit = _read_int_from_paths(
|
||||||
|
[
|
||||||
|
"/sys/fs/cgroup/memory.max", # cgroup v2
|
||||||
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Certaines limites cgroup valent "max" ou des sentinelles tres grandes.
|
||||||
|
if limit is not None and limit >= 9_000_000_000_000_000_000:
|
||||||
|
limit = None
|
||||||
|
|
||||||
|
pct = None
|
||||||
|
if used is not None and limit and limit > 0:
|
||||||
|
pct = round((used / limit) * 100, 1)
|
||||||
|
|
||||||
|
return {"used_bytes": used, "limit_bytes": limit, "used_pct": pct}
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_stats() -> dict[str, Any]:
|
||||||
|
target = "/data" if os.path.isdir("/data") else "/"
|
||||||
|
total, used, free = shutil.disk_usage(target)
|
||||||
|
uploads_size = None
|
||||||
|
if os.path.isdir(UPLOAD_DIR):
|
||||||
|
try:
|
||||||
|
uploads_size = sum(
|
||||||
|
os.path.getsize(os.path.join(root, name))
|
||||||
|
for root, _, files in os.walk(UPLOAD_DIR)
|
||||||
|
for name in files
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
uploads_size = None
|
||||||
|
return {
|
||||||
|
"path": target,
|
||||||
|
"total_bytes": total,
|
||||||
|
"used_bytes": used,
|
||||||
|
"free_bytes": free,
|
||||||
|
"used_pct": round((used / total) * 100, 1) if total else None,
|
||||||
|
"uploads_bytes": uploads_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_remove(path: str) -> None:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sqlite_db_path() -> Path | None:
|
||||||
|
prefix = "sqlite:///"
|
||||||
|
if not DATABASE_URL.startswith(prefix):
|
||||||
|
return None
|
||||||
|
raw = DATABASE_URL[len(prefix):]
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
db_path = Path(raw)
|
||||||
|
if db_path.is_absolute():
|
||||||
|
return db_path
|
||||||
|
return (Path.cwd() / db_path).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_directory(zipf: zipfile.ZipFile, source_dir: Path, arc_prefix: str) -> int:
|
||||||
|
count = 0
|
||||||
|
if not source_dir.is_dir():
|
||||||
|
return count
|
||||||
|
for root, _, files in os.walk(source_dir):
|
||||||
|
root_path = Path(root)
|
||||||
|
for name in files:
|
||||||
|
file_path = root_path / name
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
rel = file_path.relative_to(source_dir)
|
||||||
|
arcname = str(Path(arc_prefix) / rel)
|
||||||
|
zipf.write(file_path, arcname=arcname)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_data_text_files(
|
||||||
|
zipf: zipfile.ZipFile,
|
||||||
|
data_root: Path,
|
||||||
|
db_path: Path | None,
|
||||||
|
uploads_dir: Path,
|
||||||
|
) -> int:
|
||||||
|
count = 0
|
||||||
|
if not data_root.is_dir():
|
||||||
|
return count
|
||||||
|
for root, _, files in os.walk(data_root):
|
||||||
|
root_path = Path(root)
|
||||||
|
for name in files:
|
||||||
|
file_path = root_path / name
|
||||||
|
if db_path and file_path == db_path:
|
||||||
|
continue
|
||||||
|
if uploads_dir in file_path.parents:
|
||||||
|
continue
|
||||||
|
if file_path.suffix.lower() not in _TEXT_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
rel = file_path.relative_to(data_root)
|
||||||
|
zipf.write(file_path, arcname=str(Path("data_text") / rel))
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
def get_settings(session: Session = Depends(get_session)):
|
def get_settings(session: Session = Depends(get_session)):
|
||||||
@@ -26,14 +205,310 @@ def update_settings(data: dict, session: Session = Depends(get_session)):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lunar")
|
@router.get("/settings/debug/system")
|
||||||
def get_lunar(month: str, session: Session = Depends(get_session)):
|
def get_debug_system_stats() -> dict[str, Any]:
|
||||||
year, m = map(int, month.split("-"))
|
"""Stats runtime du conteneur (utile pour affichage debug UI)."""
|
||||||
first = date(year, m, 1)
|
global _PREV_CPU_USAGE_USEC, _PREV_CPU_TS
|
||||||
last_m, last_y = (m + 1, year) if m < 12 else (1, year + 1)
|
|
||||||
last = date(last_y, last_m, 1)
|
now = time.monotonic()
|
||||||
return session.exec(
|
usage_usec = _read_cgroup_cpu_usage_usec()
|
||||||
select(LunarCalendarEntry)
|
quota_cores = _cpu_quota_cores()
|
||||||
.where(LunarCalendarEntry.jour >= first)
|
cpu_pct = None
|
||||||
.where(LunarCalendarEntry.jour < last)
|
|
||||||
).all()
|
if usage_usec is not None and _PREV_CPU_USAGE_USEC is not None and _PREV_CPU_TS is not None:
|
||||||
|
delta_usage = usage_usec - _PREV_CPU_USAGE_USEC
|
||||||
|
delta_time_usec = (now - _PREV_CPU_TS) * 1_000_000
|
||||||
|
if delta_time_usec > 0 and quota_cores and quota_cores > 0:
|
||||||
|
cpu_pct = round((delta_usage / (delta_time_usec * quota_cores)) * 100, 1)
|
||||||
|
|
||||||
|
_PREV_CPU_USAGE_USEC = usage_usec
|
||||||
|
_PREV_CPU_TS = now
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source": "container-cgroup",
|
||||||
|
"cpu": {
|
||||||
|
"usage_usec_total": usage_usec,
|
||||||
|
"quota_cores": quota_cores,
|
||||||
|
"used_pct": cpu_pct,
|
||||||
|
},
|
||||||
|
"memory": _memory_stats(),
|
||||||
|
"disk": _disk_stats(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_backup_zip() -> tuple[Path, str]:
|
||||||
|
"""Crée l'archive ZIP de sauvegarde. Retourne (chemin_tmp, nom_fichier)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ts = now.strftime("%Y%m%d_%H%M%S")
|
||||||
|
db_path = _resolve_sqlite_db_path()
|
||||||
|
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||||
|
data_root = db_path.parent if db_path else uploads_dir.parent
|
||||||
|
|
||||||
|
fd, tmp_zip_path = tempfile.mkstemp(prefix=f"jardin_backup_{ts}_", suffix=".zip")
|
||||||
|
os.close(fd)
|
||||||
|
tmp_zip = Path(tmp_zip_path)
|
||||||
|
|
||||||
|
stats = {"database_files": 0, "upload_files": 0, "text_files": 0}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
||||||
|
if db_path and db_path.is_file():
|
||||||
|
zipf.write(db_path, arcname=f"db/{db_path.name}")
|
||||||
|
stats["database_files"] = 1
|
||||||
|
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
|
||||||
|
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"generated_at_utc": now.isoformat(),
|
||||||
|
"database_url": DATABASE_URL,
|
||||||
|
"paths": {
|
||||||
|
"database_path": str(db_path) if db_path else None,
|
||||||
|
"uploads_path": str(uploads_dir),
|
||||||
|
"data_root": str(data_root),
|
||||||
|
},
|
||||||
|
"included": stats,
|
||||||
|
"text_extensions": sorted(_TEXT_EXTENSIONS),
|
||||||
|
}
|
||||||
|
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
return tmp_zip, f"jardin_backup_{ts}.zip"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/backup/download")
|
||||||
|
def download_backup_zip() -> FileResponse:
|
||||||
|
tmp_zip, download_name = _create_backup_zip()
|
||||||
|
return FileResponse(
|
||||||
|
path=str(tmp_zip),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=download_name,
|
||||||
|
background=BackgroundTask(_safe_remove, str(tmp_zip)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_db_add_only(backup_db_path: Path, current_db_path: Path) -> dict[str, int]:
|
||||||
|
"""Insère dans la BDD courante les lignes absentes de la BDD de sauvegarde (INSERT OR IGNORE)."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
stats = {"rows_added": 0, "rows_skipped": 0}
|
||||||
|
backup_conn = sqlite3.connect(str(backup_db_path))
|
||||||
|
current_conn = sqlite3.connect(str(current_db_path))
|
||||||
|
current_conn.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tables = backup_conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for (table,) in tables:
|
||||||
|
try:
|
||||||
|
cur = backup_conn.execute(f'SELECT * FROM "{table}"')
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
col_names = ", ".join(f'"{c}"' for c in cols)
|
||||||
|
placeholders = ", ".join(["?"] * len(cols))
|
||||||
|
before = current_conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||||
|
current_conn.executemany(
|
||||||
|
f'INSERT OR IGNORE INTO "{table}" ({col_names}) VALUES ({placeholders})',
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
after = current_conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||||
|
added = after - before
|
||||||
|
stats["rows_added"] += added
|
||||||
|
stats["rows_skipped"] += len(rows) - added
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_conn.commit()
|
||||||
|
finally:
|
||||||
|
backup_conn.close()
|
||||||
|
current_conn.close()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/backup/restore")
|
||||||
|
async def restore_backup(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
overwrite: bool = Form(default=True),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Restaure une sauvegarde ZIP (DB + uploads). overwrite=true écrase, false ajoute uniquement."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
db_path = _resolve_sqlite_db_path()
|
||||||
|
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||||
|
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) < 4 or data[:2] != b'PK':
|
||||||
|
raise HTTPException(400, "Le fichier n'est pas une archive ZIP valide.")
|
||||||
|
|
||||||
|
fd, tmp_zip_path = tempfile.mkstemp(suffix=".zip")
|
||||||
|
os.close(fd)
|
||||||
|
tmp_zip = Path(tmp_zip_path)
|
||||||
|
tmp_extract = Path(tempfile.mkdtemp(prefix="jardin_restore_"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmp_zip.write_bytes(data)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(tmp_zip, "r") as zipf:
|
||||||
|
zipf.extractall(str(tmp_extract))
|
||||||
|
|
||||||
|
stats: dict[str, Any] = {
|
||||||
|
"uploads_copies": 0,
|
||||||
|
"uploads_ignores": 0,
|
||||||
|
"db_restauree": False,
|
||||||
|
"db_lignes_ajoutees": 0,
|
||||||
|
"erreurs": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Uploads ---
|
||||||
|
backup_uploads = tmp_extract / "uploads"
|
||||||
|
if backup_uploads.is_dir():
|
||||||
|
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for src in backup_uploads.rglob("*"):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
dst = uploads_dir / src.relative_to(backup_uploads)
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if overwrite or not dst.exists():
|
||||||
|
try:
|
||||||
|
shutil.copy2(str(src), str(dst))
|
||||||
|
stats["uploads_copies"] += 1
|
||||||
|
except Exception:
|
||||||
|
stats["erreurs"] += 1
|
||||||
|
else:
|
||||||
|
stats["uploads_ignores"] += 1
|
||||||
|
|
||||||
|
# --- Base de données ---
|
||||||
|
backup_db_dir = tmp_extract / "db"
|
||||||
|
db_files = sorted(backup_db_dir.glob("*.db")) if backup_db_dir.is_dir() else []
|
||||||
|
|
||||||
|
if db_files and db_path:
|
||||||
|
backup_db_file = db_files[0]
|
||||||
|
|
||||||
|
if overwrite:
|
||||||
|
from app.database import engine
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
engine.dispose()
|
||||||
|
shutil.copy2(str(backup_db_file), str(db_path))
|
||||||
|
stats["db_restauree"] = True
|
||||||
|
else:
|
||||||
|
merge = _merge_db_add_only(backup_db_file, db_path)
|
||||||
|
stats["db_lignes_ajoutees"] = merge["rows_added"]
|
||||||
|
stats["db_restauree"] = True
|
||||||
|
|
||||||
|
return {"ok": True, **stats}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"Erreur lors de la restauration : {exc}") from exc
|
||||||
|
finally:
|
||||||
|
_safe_remove(str(tmp_zip))
|
||||||
|
shutil.rmtree(str(tmp_extract), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/images/resize-all")
|
||||||
|
def resize_all_images(session: Session = Depends(get_session)) -> dict[str, Any]:
|
||||||
|
"""Redimensionne les images pleine taille de la bibliothèque dont la largeur dépasse le paramètre configuré."""
|
||||||
|
from PIL import Image
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
setting = session.exec(select(UserSettings).where(UserSettings.cle == "image_max_width")).first()
|
||||||
|
max_px = 1200
|
||||||
|
if setting:
|
||||||
|
try:
|
||||||
|
max_px = int(setting.valeur)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if max_px <= 0:
|
||||||
|
return {"ok": True, "redimensionnees": 0, "ignorees": 0, "erreurs": 0,
|
||||||
|
"message": "Taille originale configurée — aucune modification."}
|
||||||
|
|
||||||
|
from app.models.media import Media as MediaModel
|
||||||
|
urls = session.exec(select(MediaModel.url)).all()
|
||||||
|
|
||||||
|
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||||
|
redimensionnees = 0
|
||||||
|
ignorees = 0
|
||||||
|
erreurs = 0
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
# /uploads/filename.webp → data/uploads/filename.webp
|
||||||
|
filename = url.lstrip("/").removeprefix("uploads/")
|
||||||
|
file_path = uploads_dir / filename
|
||||||
|
if not file_path.is_file():
|
||||||
|
ignorees += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
w, h = img.size
|
||||||
|
if w <= max_px and h <= max_px:
|
||||||
|
ignorees += 1
|
||||||
|
continue
|
||||||
|
img_copy = img.copy()
|
||||||
|
img_copy.thumbnail((max_px, max_px), Image.LANCZOS)
|
||||||
|
img_copy.save(file_path, "WEBP", quality=85)
|
||||||
|
redimensionnees += 1
|
||||||
|
except Exception:
|
||||||
|
erreurs += 1
|
||||||
|
|
||||||
|
return {"ok": True, "redimensionnees": redimensionnees, "ignorees": ignorees, "erreurs": erreurs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/backup/samba")
|
||||||
|
def backup_to_samba(session: Session = Depends(get_session)) -> dict[str, Any]:
|
||||||
|
"""Envoie une sauvegarde ZIP vers un partage Samba/CIFS."""
|
||||||
|
|
||||||
|
def _get(key: str, default: str = "") -> str:
|
||||||
|
row = session.exec(select(UserSettings).where(UserSettings.cle == key)).first()
|
||||||
|
return row.valeur if row else default
|
||||||
|
|
||||||
|
server = _get("samba_serveur").strip()
|
||||||
|
share = _get("samba_partage").strip()
|
||||||
|
username = _get("samba_utilisateur").strip()
|
||||||
|
password = _get("samba_motdepasse")
|
||||||
|
subfolder = _get("samba_sous_dossier").strip().strip("/\\")
|
||||||
|
|
||||||
|
if not server or not share:
|
||||||
|
raise HTTPException(400, "Configuration Samba incomplète : serveur et partage requis.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import smbclient # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(500, "Module smbprotocol non installé dans l'environnement.")
|
||||||
|
|
||||||
|
tmp_zip, filename = _create_backup_zip()
|
||||||
|
try:
|
||||||
|
smbclient.register_session(server, username=username or None, password=password or None)
|
||||||
|
|
||||||
|
remote_dir = f"\\\\{server}\\{share}"
|
||||||
|
if subfolder:
|
||||||
|
remote_dir = f"{remote_dir}\\{subfolder}"
|
||||||
|
try:
|
||||||
|
smbclient.makedirs(remote_dir, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
remote_path = f"{remote_dir}\\{filename}"
|
||||||
|
|
||||||
|
with open(tmp_zip, "rb") as local_f:
|
||||||
|
data = local_f.read()
|
||||||
|
with smbclient.open_file(remote_path, mode="wb") as smb_f:
|
||||||
|
smb_f.write(data)
|
||||||
|
|
||||||
|
return {"ok": True, "fichier": filename, "chemin": remote_path}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"Erreur Samba : {exc}") from exc
|
||||||
|
finally:
|
||||||
|
_safe_remove(str(tmp_zip))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.task import Task
|
from app.models.task import Task, TaskCreate
|
||||||
|
|
||||||
router = APIRouter(tags=["tâches"])
|
router = APIRouter(tags=["tâches"])
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ router = APIRouter(tags=["tâches"])
|
|||||||
def list_tasks(
|
def list_tasks(
|
||||||
statut: Optional[str] = None,
|
statut: Optional[str] = None,
|
||||||
garden_id: Optional[int] = None,
|
garden_id: Optional[int] = None,
|
||||||
|
planting_id: Optional[int] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
q = select(Task)
|
q = select(Task)
|
||||||
@@ -19,11 +20,15 @@ def list_tasks(
|
|||||||
q = q.where(Task.statut == statut)
|
q = q.where(Task.statut == statut)
|
||||||
if garden_id:
|
if garden_id:
|
||||||
q = q.where(Task.garden_id == garden_id)
|
q = q.where(Task.garden_id == garden_id)
|
||||||
|
if planting_id:
|
||||||
|
q = q.where(Task.planting_id == planting_id)
|
||||||
|
q = q.order_by(Task.echeance, Task.created_at.desc())
|
||||||
return session.exec(q).all()
|
return session.exec(q).all()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
|
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||||
def create_task(t: Task, session: Session = Depends(get_session)):
|
def create_task(data: TaskCreate, session: Session = Depends(get_session)):
|
||||||
|
t = Task(**data.model_dump())
|
||||||
session.add(t)
|
session.add(t)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(t)
|
session.refresh(t)
|
||||||
@@ -39,13 +44,26 @@ def get_task(id: int, session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/tasks/{id}", response_model=Task)
|
@router.put("/tasks/{id}", response_model=Task)
|
||||||
def update_task(id: int, data: Task, session: Session = Depends(get_session)):
|
def update_task(id: int, data: TaskCreate, session: Session = Depends(get_session)):
|
||||||
t = session.get(Task, id)
|
t = session.get(Task, id)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||||||
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
setattr(t, k, v)
|
setattr(t, k, v)
|
||||||
t.updated_at = datetime.utcnow()
|
t.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(t)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tasks/{id}/statut", response_model=Task)
|
||||||
|
def update_statut(id: int, statut: str, session: Session = Depends(get_session)):
|
||||||
|
t = session.get(Task, id)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||||||
|
t.statut = statut
|
||||||
|
t.updated_at = datetime.now(timezone.utc)
|
||||||
session.add(t)
|
session.add(t)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(t)
|
session.refresh(t)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.tool import Tool
|
||||||
|
|
||||||
|
router = APIRouter(tags=["outils"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools", response_model=List[Tool])
|
||||||
|
def list_tools(
|
||||||
|
categorie: Optional[str] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
q = select(Tool)
|
||||||
|
if categorie:
|
||||||
|
q = q.where(Tool.categorie == categorie)
|
||||||
|
return session.exec(q).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tools", response_model=Tool, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_tool(t: Tool, session: Session = Depends(get_session)):
|
||||||
|
session.add(t)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools/{id}", response_model=Tool)
|
||||||
|
def get_tool(id: int, session: Session = Depends(get_session)):
|
||||||
|
t = session.get(Tool, id)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Outil introuvable")
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tools/{id}", response_model=Tool)
|
||||||
|
def update_tool(id: int, data: Tool, session: Session = Depends(get_session)):
|
||||||
|
t = session.get(Tool, id)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Outil introuvable")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
|
||||||
|
setattr(t, k, v)
|
||||||
|
session.add(t)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tools/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_tool(id: int, session: Session = Depends(get_session)):
|
||||||
|
t = session.get(Tool, id)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Outil introuvable")
|
||||||
|
session.delete(t)
|
||||||
|
session.commit()
|
||||||
@@ -6,14 +6,18 @@ import app.models # noqa
|
|||||||
|
|
||||||
def run_seed():
|
def run_seed():
|
||||||
from app.models.garden import Garden, GardenCell, Measurement
|
from app.models.garden import Garden, GardenCell, Measurement
|
||||||
from app.models.plant import PlantVariety
|
from app.models.plant import Plant, PlantVariety
|
||||||
from app.models.planting import Planting, PlantingEvent
|
from app.models.planting import Planting, PlantingEvent
|
||||||
from app.models.task import Task
|
from app.models.task import Task
|
||||||
|
from app.models.tool import Tool
|
||||||
|
from app.models.dicton import Dicton
|
||||||
|
from app.models.astuce import Astuce
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
if session.exec(select(Garden)).first():
|
already_seeded = session.exec(select(Garden)).first() is not None
|
||||||
return # déjà seedé
|
|
||||||
|
|
||||||
|
if not already_seeded:
|
||||||
|
# ── Jardin ────────────────────────────────────────────────────────────
|
||||||
jardin = Garden(
|
jardin = Garden(
|
||||||
nom="Mon potager",
|
nom="Mon potager",
|
||||||
description="Potager principal plein sud",
|
description="Potager principal plein sud",
|
||||||
@@ -21,6 +25,7 @@ def run_seed():
|
|||||||
exposition="S",
|
exposition="S",
|
||||||
ombre="plein_soleil",
|
ombre="plein_soleil",
|
||||||
sol_type="limoneux",
|
sol_type="limoneux",
|
||||||
|
surface_m2=24.0,
|
||||||
grille_largeur=6,
|
grille_largeur=6,
|
||||||
grille_hauteur=4,
|
grille_hauteur=4,
|
||||||
)
|
)
|
||||||
@@ -29,50 +34,223 @@ def run_seed():
|
|||||||
|
|
||||||
for row in range(4):
|
for row in range(4):
|
||||||
for col in range(6):
|
for col in range(6):
|
||||||
session.add(GardenCell(
|
session.add(
|
||||||
|
GardenCell(
|
||||||
garden_id=jardin.id,
|
garden_id=jardin.id,
|
||||||
col=col, row=row,
|
col=col,
|
||||||
|
row=row,
|
||||||
libelle=f"{chr(65 + row)}{col + 1}",
|
libelle=f"{chr(65 + row)}{col + 1}",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
session.add(Measurement(garden_id=jardin.id, temp_air=18.0, humidite_air=65.0))
|
session.add(Measurement(garden_id=jardin.id, temp_air=18.0, humidite_air=65.0))
|
||||||
|
|
||||||
tomate = PlantVariety(
|
# ── 20 Plantes ────────────────────────────────────────────────────────
|
||||||
nom_commun="Tomate", variete="Andine Cornue",
|
plantes_data = [
|
||||||
famille="Solanacées", type_plante="legume",
|
dict(nom_commun="Tomate", variete="Andine Cornue", famille="Solanacées",
|
||||||
besoin_eau="fort", espacement_cm=60,
|
categorie="potager", type_plante="legume", besoin_eau="fort",
|
||||||
plantation_mois="4,5", recolte_mois="7,8,9",
|
espacement_cm=60, plantation_mois="4,5", recolte_mois="7,8,9",
|
||||||
)
|
semis_interieur_mois="2,3"),
|
||||||
courgette = PlantVariety(
|
dict(nom_commun="Courgette", variete="Verte", famille="Cucurbitacées",
|
||||||
nom_commun="Courgette", variete="Verte",
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
famille="Cucurbitacées", type_plante="legume",
|
espacement_cm=80, plantation_mois="5,6", recolte_mois="7,8",
|
||||||
besoin_eau="moyen", espacement_cm=80,
|
semis_interieur_mois="4"),
|
||||||
plantation_mois="5,6", recolte_mois="7,8",
|
dict(nom_commun="Carotte", famille="Apiacées",
|
||||||
)
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
salade = PlantVariety(
|
espacement_cm=8, semis_exterieur_mois="3,4,5,6",
|
||||||
nom_commun="Laitue", variete="Batavia",
|
recolte_mois="6,7,8,9,10"),
|
||||||
famille="Astéracées", type_plante="legume",
|
dict(nom_commun="Laitue", variete="Batavia", famille="Astéracées",
|
||||||
besoin_eau="moyen", espacement_cm=25,
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
)
|
espacement_cm=25, plantation_mois="3,4,5,8,9",
|
||||||
session.add_all([tomate, courgette, salade])
|
recolte_mois="5,6,7,10"),
|
||||||
|
dict(nom_commun="Ail", famille="Amaryllidacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="faible",
|
||||||
|
espacement_cm=15, plantation_mois="10,11",
|
||||||
|
recolte_mois="6,7"),
|
||||||
|
dict(nom_commun="Oignon", famille="Amaryllidacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="faible",
|
||||||
|
espacement_cm=10, semis_interieur_mois="2,3",
|
||||||
|
plantation_mois="4,5", recolte_mois="7,8"),
|
||||||
|
dict(nom_commun="Haricot", variete="Nain", famille="Fabacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=15, semis_exterieur_mois="5,6",
|
||||||
|
recolte_mois="7,8,9"),
|
||||||
|
dict(nom_commun="Pois", variete="Mange-tout", famille="Fabacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=10, semis_exterieur_mois="3,4",
|
||||||
|
recolte_mois="6,7"),
|
||||||
|
dict(nom_commun="Poireau", famille="Amaryllidacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=15, semis_interieur_mois="2,3",
|
||||||
|
plantation_mois="6,7", recolte_mois="10,11,12,1,2"),
|
||||||
|
dict(nom_commun="Pomme de terre", famille="Solanacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=35, plantation_mois="3,4,5",
|
||||||
|
recolte_mois="7,8,9,10"),
|
||||||
|
dict(nom_commun="Fraise", famille="Rosacées",
|
||||||
|
categorie="potager", type_plante="fruit", besoin_eau="moyen",
|
||||||
|
espacement_cm=30, plantation_mois="3,4,9,10",
|
||||||
|
recolte_mois="5,6,7"),
|
||||||
|
dict(nom_commun="Framboise", famille="Rosacées",
|
||||||
|
categorie="arbuste", type_plante="fruit", besoin_eau="moyen",
|
||||||
|
espacement_cm=60, plantation_mois="11,12,2,3",
|
||||||
|
recolte_mois="7,8,9"),
|
||||||
|
dict(nom_commun="Persil", famille="Apiacées",
|
||||||
|
categorie="potager", type_plante="aromatique", besoin_eau="moyen",
|
||||||
|
espacement_cm=20, semis_exterieur_mois="3,4,5,8",
|
||||||
|
recolte_mois="4,5,6,7,8,9,10"),
|
||||||
|
dict(nom_commun="Échalote", famille="Amaryllidacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="faible",
|
||||||
|
espacement_cm=15, plantation_mois="2,3",
|
||||||
|
recolte_mois="7,8"),
|
||||||
|
dict(nom_commun="Chou-fleur", famille="Brassicacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="fort",
|
||||||
|
espacement_cm=60, semis_interieur_mois="3,4",
|
||||||
|
plantation_mois="5,6", recolte_mois="9,10,11"),
|
||||||
|
dict(nom_commun="Chou", variete="Milan", famille="Brassicacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=50, semis_interieur_mois="3,4",
|
||||||
|
plantation_mois="5,6", recolte_mois="10,11,12"),
|
||||||
|
dict(nom_commun="Betterave", famille="Amaranthacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=15, semis_exterieur_mois="4,5,6",
|
||||||
|
recolte_mois="8,9,10"),
|
||||||
|
dict(nom_commun="Radis", famille="Brassicacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=5, semis_exterieur_mois="3,4,5,8,9",
|
||||||
|
recolte_mois="4,5,6,9,10"),
|
||||||
|
dict(nom_commun="Épinard", famille="Amaranthacées",
|
||||||
|
categorie="potager", type_plante="legume", besoin_eau="moyen",
|
||||||
|
espacement_cm=15, semis_exterieur_mois="3,4,8,9",
|
||||||
|
recolte_mois="5,6,10,11"),
|
||||||
|
dict(nom_commun="Basilic", famille="Lamiacées",
|
||||||
|
categorie="potager", type_plante="aromatique", besoin_eau="moyen",
|
||||||
|
espacement_cm=20, semis_interieur_mois="3,4",
|
||||||
|
plantation_mois="5,6", recolte_mois="6,7,8,9"),
|
||||||
|
]
|
||||||
|
|
||||||
|
plantes = []
|
||||||
|
for data in plantes_data:
|
||||||
|
variete = data.pop('variete', None)
|
||||||
|
p = Plant(**data)
|
||||||
|
session.add(p)
|
||||||
|
plantes.append(p)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
# Créer les variétés pour les plantes qui en avaient une
|
||||||
|
plantes_varietes = [
|
||||||
|
("Andine Cornue", 0), # Tomate
|
||||||
|
("Verte", 1), # Courgette
|
||||||
|
("Batavia", 3), # Laitue
|
||||||
|
("Nain", 6), # Haricot
|
||||||
|
("Mange-tout", 7), # Pois
|
||||||
|
("Milan", 15), # Chou
|
||||||
|
]
|
||||||
|
for variete_nom, idx in plantes_varietes:
|
||||||
|
session.add(PlantVariety(plant_id=plantes[idx].id, variete=variete_nom))
|
||||||
|
|
||||||
|
tomate = plantes[0]
|
||||||
|
courgette = plantes[1]
|
||||||
|
|
||||||
|
# ── Plantings ──────────────────────────────────────────────────────────
|
||||||
p1 = Planting(
|
p1 = Planting(
|
||||||
garden_id=jardin.id, variety_id=tomate.id,
|
garden_id=jardin.id,
|
||||||
date_plantation=date(2026, 5, 1), quantite=6, statut="en_cours",
|
variety_id=tomate.id,
|
||||||
|
date_plantation=date(2026, 5, 1),
|
||||||
|
quantite=6,
|
||||||
|
statut="en_cours",
|
||||||
)
|
)
|
||||||
p2 = Planting(
|
p2 = Planting(
|
||||||
garden_id=jardin.id, variety_id=courgette.id,
|
garden_id=jardin.id,
|
||||||
date_plantation=date(2026, 5, 15), quantite=3, statut="prevu",
|
variety_id=courgette.id,
|
||||||
|
date_plantation=date(2026, 5, 15),
|
||||||
|
quantite=3,
|
||||||
|
statut="prevu",
|
||||||
)
|
)
|
||||||
session.add_all([p1, p2])
|
session.add_all([p1, p2])
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
session.add(PlantingEvent(planting_id=p1.id, type="arrosage", note="Arrosage du matin"))
|
session.add(PlantingEvent(planting_id=p1.id, type="arrosage", note="Arrosage du matin"))
|
||||||
|
|
||||||
|
# ── Tâches ────────────────────────────────────────────────────────────
|
||||||
session.add(Task(titre="Arroser les tomates", priorite="haute",
|
session.add(Task(titre="Arroser les tomates", priorite="haute",
|
||||||
statut="a_faire", garden_id=jardin.id))
|
statut="a_faire", garden_id=jardin.id))
|
||||||
session.add(Task(titre="Traiter contre les pucerons", priorite="normale", statut="a_faire"))
|
session.add(Task(titre="Traiter contre les pucerons", priorite="normale", statut="a_faire"))
|
||||||
session.add(Task(titre="Préparer le compost", priorite="basse", statut="en_cours"))
|
session.add(Task(titre="Préparer le compost", priorite="basse", statut="en_cours"))
|
||||||
|
|
||||||
|
# ── Outils (indépendant du jardin) ────────────────────────────────────────
|
||||||
|
if not session.exec(select(Tool)).first():
|
||||||
|
outils_data = [
|
||||||
|
dict(nom="Bêche", categorie="beche",
|
||||||
|
description="Bêche acier forgé, manche bois 110 cm"),
|
||||||
|
dict(nom="Fourche-bêche", categorie="fourche",
|
||||||
|
description="Fourche à bêcher 4 dents inox"),
|
||||||
|
dict(nom="Grelinette", categorie="fourche",
|
||||||
|
description="Aérateur bi-fourche ergonomique"),
|
||||||
|
dict(nom="Pioche", categorie="beche",
|
||||||
|
description="Pioche légère pour travaux de surface"),
|
||||||
|
dict(nom="Sarcloir", categorie="griffe",
|
||||||
|
description="Sarcloir oscillant pour désherber entre les rangs"),
|
||||||
|
dict(nom="Râteau", categorie="griffe",
|
||||||
|
description="Râteau métallique 14 dents"),
|
||||||
|
dict(nom="Binette", categorie="griffe",
|
||||||
|
description="Binette pour ameublir et désherber"),
|
||||||
|
dict(nom="Transplantoir", categorie="taille",
|
||||||
|
description="Transplantoir inox gradué"),
|
||||||
|
dict(nom="Arrosoir", categorie="arrosage",
|
||||||
|
description="Arrosoir 10L avec pomme amovible"),
|
||||||
|
dict(nom="Sécateur", categorie="taille",
|
||||||
|
description="Sécateur de précision bypass"),
|
||||||
|
]
|
||||||
|
for data in outils_data:
|
||||||
|
session.add(Tool(**data))
|
||||||
|
|
||||||
|
# ── Dictons (indépendant du jardin) ──────────────────────────────────────
|
||||||
|
if not session.exec(select(Dicton)).first():
|
||||||
|
dictons_data = [
|
||||||
|
dict(mois=1, texte="En janvier, la neige au potager réjouit le jardinier.", region="National"),
|
||||||
|
dict(mois=2, texte="À la Chandeleur, l'hiver reste ou reprend vigueur.", region="National"),
|
||||||
|
dict(mois=3, texte="Mars venteux, avril pluvieux, font mai fleureux.", region="National"),
|
||||||
|
dict(mois=3, texte="Quand mars se déguise en été, avril se déguise en hiver.", region="Auvergne"),
|
||||||
|
dict(mois=4, texte="Avril ne te découvre pas d'un fil.", region="National"),
|
||||||
|
dict(mois=4, texte="Pluie d'avril, fleurs à l'infini.", region="Haute-Loire"),
|
||||||
|
dict(mois=5, texte="Gelées de mai, misère chez le jardinier.", region="Haute-Loire"),
|
||||||
|
dict(mois=5, jour=11, texte="Saints de glace : Mamert, Pancrace et Gervais.", region="National"),
|
||||||
|
dict(mois=6, texte="Juin sec, juillet pluvieux ; juillet sec, grains savoureux.", region="Auvergne"),
|
||||||
|
dict(mois=7, texte="Pluie de juillet remplit greniers et cuves.", region="National"),
|
||||||
|
dict(mois=8, texte="Août chaud, vin bon.", region="Auvergne"),
|
||||||
|
dict(mois=9, texte="En septembre, qui sème du blé en fait son profit.", region="National"),
|
||||||
|
dict(mois=10, texte="En octobre, glands à foison, bon hiver selon la raison.", region="Haute-Loire"),
|
||||||
|
dict(mois=11, texte="À la Saint-Martin, bois ton vin.", region="National"),
|
||||||
|
dict(mois=12, texte="Noël au balcon, Pâques aux tisons.", region="Auvergne"),
|
||||||
|
]
|
||||||
|
for data in dictons_data:
|
||||||
|
session.add(Dicton(**data))
|
||||||
|
|
||||||
|
# ── Astuces (indépendant du jardin) ──────────────────────────────────────
|
||||||
|
if not session.exec(select(Astuce)).first():
|
||||||
|
astuces_data = [
|
||||||
|
dict(titre="Rotation des cultures", entity_type="general",
|
||||||
|
contenu="Changez chaque année la famille de légumes sur chaque parcelle pour éviter l'épuisement du sol et les maladies."),
|
||||||
|
dict(titre="Compagnonnage tomate-basilic", entity_type="plante",
|
||||||
|
contenu="Plantez du basilic au pied des tomates : il éloigne les pucerons et améliore le goût des fruits."),
|
||||||
|
dict(titre="Paillage économise l'eau", entity_type="jardin",
|
||||||
|
contenu="Un paillage de 5 à 10 cm (paille, BRF, tontes) réduit les arrosages de moitié et limite les mauvaises herbes."),
|
||||||
|
dict(titre="Arrosage au pied le matin", entity_type="general",
|
||||||
|
contenu="Arrosez toujours au pied des plantes le matin pour éviter les maladies cryptogamiques et la brûlure des feuilles."),
|
||||||
|
dict(titre="Purin d'ortie maison", entity_type="general",
|
||||||
|
contenu="Faites macérer 1 kg d'orties dans 10 L d'eau pendant 10 jours. Diluez à 10 % et arrosez le sol pour stimuler la croissance."),
|
||||||
|
dict(titre="Buttage des pommes de terre", entity_type="plante",
|
||||||
|
contenu="Buttez régulièrement les pommes de terre quand les fanes atteignent 20 cm pour favoriser la tubérisation."),
|
||||||
|
dict(titre="Semis de carottes en gel", entity_type="plante",
|
||||||
|
contenu="Mélangez les graines de carottes avec du sable fin pour un semis homogène et clairsemé."),
|
||||||
|
dict(titre="Récupération d'eau de pluie", entity_type="jardin",
|
||||||
|
contenu="Installez une cuve de récupération d'eau de pluie : une maison avec 100 m² de toiture collecte 60 000 L/an."),
|
||||||
|
dict(titre="Calendrier lunaire", entity_type="general",
|
||||||
|
contenu="Semez les légumes-feuilles en lune montante, les légumes-racines en lune descendante pour de meilleurs résultats."),
|
||||||
|
dict(titre="Taille en vert des tomates", entity_type="plante",
|
||||||
|
contenu="Pincez les gourmands (tiges secondaires entre tige principale et feuille) pour concentrer l'énergie sur les fruits."),
|
||||||
|
]
|
||||||
|
for data in astuces_data:
|
||||||
|
session.add(Astuce(**data))
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ SIGN_TO_TYPE = {
|
|||||||
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
|
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SAINTS_BY_MMDD = {
|
||||||
|
"04-23": "Saint Georges",
|
||||||
|
"04-25": "Saint Marc",
|
||||||
|
"05-11": "Saint Mamert",
|
||||||
|
"05-12": "Saint Pancrace",
|
||||||
|
"05-13": "Saint Servais",
|
||||||
|
"05-14": "Saint Boniface",
|
||||||
|
"05-19": "Saint Yves",
|
||||||
|
"05-25": "Saint Urbain",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DayInfo:
|
class DayInfo:
|
||||||
@@ -29,6 +40,7 @@ class DayInfo:
|
|||||||
montante_descendante: str
|
montante_descendante: str
|
||||||
signe: str
|
signe: str
|
||||||
type_jour: str
|
type_jour: str
|
||||||
|
saint_du_jour: str
|
||||||
perigee: bool
|
perigee: bool
|
||||||
apogee: bool
|
apogee: bool
|
||||||
noeud_lunaire: bool
|
noeud_lunaire: bool
|
||||||
@@ -126,6 +138,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
|
|||||||
lat, lon, dist = v_moon.ecliptic_latlon()
|
lat, lon, dist = v_moon.ecliptic_latlon()
|
||||||
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
|
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
|
||||||
type_jour = SIGN_TO_TYPE[signe]
|
type_jour = SIGN_TO_TYPE[signe]
|
||||||
|
saint_du_jour = SAINTS_BY_MMDD.get(d.strftime("%m-%d"), "")
|
||||||
result.append(
|
result.append(
|
||||||
DayInfo(
|
DayInfo(
|
||||||
date=d.isoformat(),
|
date=d.isoformat(),
|
||||||
@@ -135,6 +148,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
|
|||||||
montante_descendante=montante,
|
montante_descendante=montante,
|
||||||
signe=signe,
|
signe=signe,
|
||||||
type_jour=type_jour,
|
type_jour=type_jour,
|
||||||
|
saint_du_jour=saint_du_jour,
|
||||||
perigee=(d in perigee_days),
|
perigee=(d in perigee_days),
|
||||||
apogee=(d in apogee_days),
|
apogee=(d in apogee_days),
|
||||||
noeud_lunaire=(d in node_days),
|
noeud_lunaire=(d in node_days),
|
||||||
|
|||||||
@@ -1,127 +1,136 @@
|
|||||||
"""Client Open-Meteo (gratuit, sans clé API)."""
|
"""Service Open-Meteo — enrichi avec sol, ETP, humidité, données passées."""
|
||||||
import json
|
import logging
|
||||||
import os
|
from datetime import datetime, date, timezone
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
CACHE_PATH = Path(os.environ.get("UPLOAD_DIR", "/data")).parent / "meteo_cache.json"
|
from app.config import METEO_LAT, METEO_LON
|
||||||
CACHE_TTL_SECONDS = 3 * 3600 # 3h
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
WMO_LABELS = {
|
WMO_LABELS = {
|
||||||
0: "Ensoleillé",
|
0: "Ensoleillé", 1: "Principalement ensoleillé", 2: "Partiellement nuageux",
|
||||||
1: "Principalement ensoleillé",
|
3: "Couvert", 45: "Brouillard", 48: "Brouillard givrant",
|
||||||
2: "Partiellement nuageux",
|
51: "Bruine légère", 53: "Bruine modérée", 55: "Bruine dense",
|
||||||
3: "Couvert",
|
61: "Pluie légère", 63: "Pluie modérée", 65: "Pluie forte",
|
||||||
45: "Brouillard",
|
71: "Neige légère", 73: "Neige modérée", 75: "Neige forte",
|
||||||
48: "Brouillard givrant",
|
80: "Averses légères", 81: "Averses modérées", 82: "Averses violentes",
|
||||||
51: "Bruine légère",
|
85: "Averses de neige", 95: "Orage", 96: "Orage avec grêle", 99: "Orage violent",
|
||||||
53: "Bruine modérée",
|
|
||||||
55: "Bruine dense",
|
|
||||||
61: "Pluie légère",
|
|
||||||
63: "Pluie modérée",
|
|
||||||
65: "Pluie forte",
|
|
||||||
71: "Neige légère",
|
|
||||||
73: "Neige modérée",
|
|
||||||
75: "Neige forte",
|
|
||||||
80: "Averses légères",
|
|
||||||
81: "Averses modérées",
|
|
||||||
82: "Averses violentes",
|
|
||||||
85: "Averses de neige",
|
|
||||||
95: "Orage",
|
|
||||||
96: "Orage avec grêle",
|
|
||||||
99: "Orage violent",
|
|
||||||
}
|
|
||||||
WMO_ICONS = {
|
|
||||||
0: "☀️",
|
|
||||||
1: "🌤",
|
|
||||||
2: "⛅",
|
|
||||||
3: "☁️",
|
|
||||||
45: "🌫",
|
|
||||||
48: "🌫",
|
|
||||||
51: "🌦",
|
|
||||||
53: "🌦",
|
|
||||||
55: "🌧",
|
|
||||||
61: "🌦",
|
|
||||||
63: "🌧",
|
|
||||||
65: "🌧",
|
|
||||||
71: "🌨",
|
|
||||||
73: "🌨",
|
|
||||||
75: "❄️",
|
|
||||||
80: "🌦",
|
|
||||||
81: "🌧",
|
|
||||||
82: "⛈",
|
|
||||||
85: "🌨",
|
|
||||||
95: "⛈",
|
|
||||||
96: "⛈",
|
|
||||||
99: "⛈",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Champs daily disponibles (noms v1 actuels de l'API Open-Meteo)
|
||||||
|
# Nota : soil_temperature_0cm est hourly uniquement ; windspeed_10m_max et
|
||||||
|
# weathercode ont été renommés en wind_speed_10m_max et weather_code.
|
||||||
|
_DAILY_FIELDS = [
|
||||||
|
"temperature_2m_max",
|
||||||
|
"temperature_2m_min",
|
||||||
|
"precipitation_sum",
|
||||||
|
"wind_speed_10m_max",
|
||||||
|
"weather_code",
|
||||||
|
"relative_humidity_2m_max",
|
||||||
|
"et0_fao_evapotranspiration",
|
||||||
|
]
|
||||||
|
|
||||||
def _cache_fresh() -> dict | None:
|
_HOURLY_FIELDS = [
|
||||||
if not CACHE_PATH.exists():
|
"soil_temperature_0cm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: Any) -> float | None:
|
||||||
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = json.loads(CACHE_PATH.read_text())
|
return float(value)
|
||||||
cached_at = datetime.fromisoformat(
|
except (TypeError, ValueError):
|
||||||
data.get("cached_at", "2000-01-01T00:00:00+00:00")
|
|
||||||
)
|
|
||||||
if (datetime.now(timezone.utc) - cached_at).total_seconds() < CACHE_TTL_SECONDS:
|
|
||||||
return data
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fetch_forecast(
|
def _value_at(values: list[Any], index: int, default: Any = None) -> Any:
|
||||||
lat: float = 45.14, lon: float = 4.12, days: int = 14
|
if index < 0 or index >= len(values):
|
||||||
) -> dict[str, Any]:
|
return default
|
||||||
cached = _cache_fresh()
|
return values[index]
|
||||||
if cached:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
url = "https://api.open-meteo.com/v1/forecast"
|
|
||||||
params = {
|
def _daily_soil_average(raw: dict[str, Any]) -> dict[str, float]:
|
||||||
"latitude": lat,
|
"""Construit un mapping ISO-date -> moyenne de soil_temperature_0cm."""
|
||||||
"longitude": lon,
|
hourly = raw.get("hourly", {})
|
||||||
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max,weathercode",
|
times = hourly.get("time", []) or []
|
||||||
"timezone": "Europe/Paris",
|
soils = hourly.get("soil_temperature_0cm", []) or []
|
||||||
"forecast_days": min(days, 16),
|
by_day: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
for idx, ts in enumerate(times):
|
||||||
|
soil = _to_float(_value_at(soils, idx))
|
||||||
|
if soil is None or not isinstance(ts, str) or len(ts) < 10:
|
||||||
|
continue
|
||||||
|
day = ts[:10]
|
||||||
|
by_day.setdefault(day, []).append(soil)
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: round(sum(vals) / len(vals), 2)
|
||||||
|
for day, vals in by_day.items()
|
||||||
|
if vals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]:
|
||||||
|
"""Appelle Open-Meteo et retourne la liste des jours (past_days=7 + forecast=8).
|
||||||
|
|
||||||
|
Retourne la liste des jours pour être stockée en base par le scheduler.
|
||||||
|
"""
|
||||||
|
url = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
# Passer chaque champ séparément (liste de tuples) pour éviter l'encodage
|
||||||
|
# d'une chaîne CSV qui est rejetée par certaines versions de l'API.
|
||||||
|
params: list[tuple[str, Any]] = [
|
||||||
|
("latitude", lat),
|
||||||
|
("longitude", lon),
|
||||||
|
("past_days", 7),
|
||||||
|
("forecast_days", 8),
|
||||||
|
("timezone", "Europe/Paris"),
|
||||||
|
]
|
||||||
|
for field in _DAILY_FIELDS:
|
||||||
|
params.append(("daily", field))
|
||||||
|
for field in _HOURLY_FIELDS:
|
||||||
|
params.append(("hourly", field))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, params=params, timeout=10)
|
r = httpx.get(url, params=params, timeout=15)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
raw = r.json()
|
raw = r.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e), "days": []}
|
logger.error(f"Open-Meteo fetch error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
daily = raw.get("daily", {})
|
daily = raw.get("daily", {})
|
||||||
dates = daily.get("time", [])
|
dates = daily.get("time", [])
|
||||||
result_days = []
|
soil_by_day = _daily_soil_average(raw)
|
||||||
for i, d in enumerate(dates):
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
code = int(daily.get("weathercode", [0] * len(dates))[i] or 0)
|
rows = []
|
||||||
result_days.append(
|
|
||||||
{
|
|
||||||
"date": d,
|
|
||||||
"t_max": daily.get("temperature_2m_max", [None] * len(dates))[i],
|
|
||||||
"t_min": daily.get("temperature_2m_min", [None] * len(dates))[i],
|
|
||||||
"pluie_mm": daily.get("precipitation_sum", [0] * len(dates))[i] or 0,
|
|
||||||
"vent_kmh": daily.get("windspeed_10m_max", [0] * len(dates))[i] or 0,
|
|
||||||
"code": code,
|
|
||||||
"label": WMO_LABELS.get(code, "Inconnu"),
|
|
||||||
"icone": WMO_ICONS.get(code, "🌡"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
for i, d in enumerate(dates):
|
||||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
code = int(_value_at(daily.get("weather_code", []), i, 0) or 0)
|
||||||
"days": result_days,
|
row = {
|
||||||
|
"date": d,
|
||||||
|
"t_min": _to_float(_value_at(daily.get("temperature_2m_min", []), i)),
|
||||||
|
"t_max": _to_float(_value_at(daily.get("temperature_2m_max", []), i)),
|
||||||
|
"pluie_mm": _to_float(_value_at(daily.get("precipitation_sum", []), i, 0.0)) or 0.0,
|
||||||
|
"vent_kmh": _to_float(_value_at(daily.get("wind_speed_10m_max", []), i, 0.0)) or 0.0,
|
||||||
|
"wmo": code,
|
||||||
|
"label": WMO_LABELS.get(code, f"Code {code}"),
|
||||||
|
"humidite_moy": _to_float(_value_at(daily.get("relative_humidity_2m_max", []), i)),
|
||||||
|
"sol_0cm": soil_by_day.get(d),
|
||||||
|
"etp_mm": _to_float(_value_at(daily.get("et0_fao_evapotranspiration", []), i)),
|
||||||
|
"fetched_at": now_iso,
|
||||||
}
|
}
|
||||||
try:
|
rows.append(row)
|
||||||
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
CACHE_PATH.write_text(json.dumps(data, ensure_ascii=False))
|
return rows
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return data
|
def fetch_forecast(lat: float = METEO_LAT, lon: float = METEO_LON, days: int = 14) -> dict[str, Any]:
|
||||||
|
"""Compatibilité ascendante avec l'ancien endpoint GET /api/meteo."""
|
||||||
|
rows = fetch_and_store_forecast(lat, lon)
|
||||||
|
# Filtrer seulement les jours futurs (à partir d'aujourd'hui)
|
||||||
|
today = date.today().isoformat()
|
||||||
|
future = [r for r in rows if r["date"] >= today][:days]
|
||||||
|
return {"days": future}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Scheduler APScheduler — 3 jobs de collecte météo."""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler(timezone="Europe/Paris")
|
||||||
|
|
||||||
|
|
||||||
|
def _store_station_current() -> None:
|
||||||
|
"""Collecte et stocke les données actuelles de la station."""
|
||||||
|
from app.services.station import fetch_current
|
||||||
|
from app.models.meteo import MeteoStation
|
||||||
|
from app.database import engine
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
data = fetch_current()
|
||||||
|
if not data:
|
||||||
|
logger.warning("Station current: aucune donnée collectée")
|
||||||
|
return
|
||||||
|
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%dT%H:00")
|
||||||
|
entry = MeteoStation(date_heure=now_str, type="current", **data)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
existing = session.get(MeteoStation, now_str)
|
||||||
|
if existing:
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(existing, k, v)
|
||||||
|
session.add(existing)
|
||||||
|
else:
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
logger.info(f"Station current stockée : {now_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def _store_station_veille() -> None:
|
||||||
|
"""Collecte et stocke le résumé de la veille (NOAA)."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from app.services.station import fetch_yesterday_summary
|
||||||
|
from app.models.meteo import MeteoStation
|
||||||
|
from app.database import engine
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
data = fetch_yesterday_summary()
|
||||||
|
if not data:
|
||||||
|
logger.warning("Station veille: aucune donnée collectée")
|
||||||
|
return
|
||||||
|
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%dT00:00")
|
||||||
|
entry = MeteoStation(date_heure=yesterday, type="veille", **data)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
existing = session.get(MeteoStation, yesterday)
|
||||||
|
if existing:
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(existing, k, v)
|
||||||
|
session.add(existing)
|
||||||
|
else:
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
logger.info(f"Station veille stockée : {yesterday}")
|
||||||
|
|
||||||
|
|
||||||
|
def _store_open_meteo() -> None:
|
||||||
|
"""Collecte et stocke les prévisions Open-Meteo."""
|
||||||
|
from app.services.meteo import fetch_and_store_forecast
|
||||||
|
from app.models.meteo import MeteoOpenMeteo
|
||||||
|
from app.database import engine
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
rows = fetch_and_store_forecast()
|
||||||
|
if not rows:
|
||||||
|
logger.warning("Open-Meteo: aucune donnée collectée")
|
||||||
|
return
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
for row in rows:
|
||||||
|
existing = session.get(MeteoOpenMeteo, row["date"])
|
||||||
|
if existing:
|
||||||
|
for k, v in row.items():
|
||||||
|
if k != "date":
|
||||||
|
setattr(existing, k, v)
|
||||||
|
session.add(existing)
|
||||||
|
else:
|
||||||
|
session.add(MeteoOpenMeteo(**row))
|
||||||
|
session.commit()
|
||||||
|
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(
|
||||||
|
_store_station_current, "interval", hours=1,
|
||||||
|
next_run_time=datetime.now(), id="station_current", replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_store_station_veille, "cron", hour=6, minute=0,
|
||||||
|
next_run_time=datetime.now(), id="station_veille", replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_store_open_meteo, "interval", hours=1,
|
||||||
|
next_run_time=datetime.now(), id="open_meteo", replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler météo démarré (3 jobs)")
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Service de collecte des données de la station météo locale WeeWX."""
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import STATION_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(text: str | None) -> float | None:
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cleaned = text.strip().replace(",", ".")
|
||||||
|
# Retirer unités courantes
|
||||||
|
for unit in [
|
||||||
|
" °C",
|
||||||
|
" %", " %",
|
||||||
|
" hPa", " mbar",
|
||||||
|
" km/h", " m/s",
|
||||||
|
" mm/h", " mm",
|
||||||
|
" W/m²", " W/m2",
|
||||||
|
"°C", "%", "hPa", "mbar",
|
||||||
|
]:
|
||||||
|
cleaned = cleaned.replace(unit, "")
|
||||||
|
return float(cleaned.strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
text = unicodedata.normalize("NFKD", text)
|
||||||
|
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||||
|
text = text.lower()
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_kmh(value: float | None, unit: str | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
u = (unit or "").strip().lower()
|
||||||
|
if u == "m/s":
|
||||||
|
return round(value * 3.6, 1)
|
||||||
|
return round(value, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_to_abbr(deg: float | None) -> str | None:
|
||||||
|
if deg is None:
|
||||||
|
return None
|
||||||
|
dirs = ["N", "NE", "E", "SE", "S", "SO", "O", "NO"]
|
||||||
|
return dirs[round(deg / 45) % 8]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current(base_url: str = STATION_URL) -> dict | None:
|
||||||
|
"""Scrape les données actuelles depuis le RSS de la station WeeWX.
|
||||||
|
|
||||||
|
Retourne un dict avec les clés : temp_ext, humidite, pression,
|
||||||
|
pluie_mm, vent_kmh, vent_dir, uv, solaire — ou None si indisponible.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = base_url.rstrip("/") + "/rss.xml"
|
||||||
|
r = httpx.get(url, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
root = ET.fromstring(r.text)
|
||||||
|
|
||||||
|
channel = root.find("channel")
|
||||||
|
if channel is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item = channel.find("item")
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
desc = html.unescape(item.findtext("description") or "")
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
segments = [seg.strip() for seg in desc.split(";") if seg.strip()]
|
||||||
|
for seg in segments:
|
||||||
|
if ":" not in seg:
|
||||||
|
continue
|
||||||
|
raw_key, raw_value = seg.split(":", 1)
|
||||||
|
key = _normalize(raw_key)
|
||||||
|
value = raw_value.strip()
|
||||||
|
|
||||||
|
if "temperature exterieure" in key or "outside temperature" in key:
|
||||||
|
result["temp_ext"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "temperature interieure" in key or "inside temperature" in key:
|
||||||
|
result["temp_int"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "hygrometrie exterieure" in key or "outside humidity" in key:
|
||||||
|
result["humidite"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "pression atmospherique" in key or "barometer" in key:
|
||||||
|
result["pression"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "precipitations" in key and "taux" not in key and "rate" not in key:
|
||||||
|
result["pluie_mm"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if key in {"uv", "ultra-violet"} or "ultra violet" in key:
|
||||||
|
result["uv"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if "rayonnement solaire" in key or "solar radiation" in key:
|
||||||
|
result["solaire"] = _safe_float(value)
|
||||||
|
continue
|
||||||
|
if key == "vent" or "wind" in key:
|
||||||
|
speed_match = re.search(r"(-?\d+(?:[.,]\d+)?)\s*(m/s|km/h)?", value, re.IGNORECASE)
|
||||||
|
speed_val = _safe_float(speed_match.group(1)) if speed_match else None
|
||||||
|
speed_unit = speed_match.group(2) if speed_match else None
|
||||||
|
result["vent_kmh"] = _to_kmh(speed_val, speed_unit)
|
||||||
|
|
||||||
|
deg_match = re.search(r"(\d{1,3}(?:[.,]\d+)?)\s*°", value)
|
||||||
|
if deg_match:
|
||||||
|
result["vent_dir"] = _direction_to_abbr(_safe_float(deg_match.group(1)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
card_match = re.search(r"\b(N|NE|E|SE|S|SO|O|NO|NNE|ENE|ESE|SSE|SSO|OSO|ONO|NNO)\b", value, re.IGNORECASE)
|
||||||
|
result["vent_dir"] = card_match.group(1).upper() if card_match else None
|
||||||
|
|
||||||
|
return result if any(v is not None for v in result.values()) else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Station fetch_current error: {e}")
|
||||||
|
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()
|
||||||
|
month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url)
|
||||||
|
return month_data.get(yesterday.day)
|
||||||
@@ -1,27 +1,47 @@
|
|||||||
import os
|
import os
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070")
|
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://ai-service:8070")
|
||||||
|
|
||||||
# Mapping class_name YOLO → nom commun français (partiel)
|
# Mapping complet class_name YOLO → Infos détaillées
|
||||||
_NOMS_FR = {
|
_DIAGNOSTICS = {
|
||||||
"Tomato___healthy": "Tomate (saine)",
|
"Tomato___healthy": {
|
||||||
"Tomato___Early_blight": "Tomate (mildiou précoce)",
|
"label": "Tomate (saine)",
|
||||||
"Tomato___Late_blight": "Tomate (mildiou tardif)",
|
"conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.",
|
||||||
"Pepper__bell___healthy": "Poivron (sain)",
|
"actions": ["Pailler le pied", "Vérifier les gourmands"]
|
||||||
"Apple___healthy": "Pommier (sain)",
|
},
|
||||||
"Potato___healthy": "Pomme de terre (saine)",
|
"Tomato___Early_blight": {
|
||||||
"Grape___healthy": "Vigne (saine)",
|
"label": "Tomate (Alternariose)",
|
||||||
"Corn_(maize)___healthy": "Maïs (sain)",
|
"conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.",
|
||||||
"Strawberry___healthy": "Fraisier (sain)",
|
"actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"]
|
||||||
"Peach___healthy": "Pêcher (sain)",
|
},
|
||||||
|
"Tomato___Late_blight": {
|
||||||
|
"label": "Tomate (Mildiou)",
|
||||||
|
"conseil": "Urgent : Le mildiou se propage vite avec l'humidité. Coupez les parties atteintes immédiatement.",
|
||||||
|
"actions": ["Couper parties infectées", "Traitement purin de prêle", "Abriter de la pluie"]
|
||||||
|
},
|
||||||
|
"Pepper__bell___healthy": {
|
||||||
|
"label": "Poivron (sain)",
|
||||||
|
"conseil": "Le poivron aime la chaleur et un sol riche.",
|
||||||
|
"actions": ["Apport de compost", "Arrosage régulier"]
|
||||||
|
},
|
||||||
|
"Potato___healthy": {
|
||||||
|
"label": "Pomme de terre (saine)",
|
||||||
|
"conseil": "Pensez à butter les pieds pour favoriser la production de tubercules.",
|
||||||
|
"actions": ["Butter les pieds"]
|
||||||
|
},
|
||||||
|
"Grape___healthy": {
|
||||||
|
"label": "Vigne (saine)",
|
||||||
|
"conseil": "Surveillez l'apparition d'oïdium si le temps est chaud et humide.",
|
||||||
|
"actions": ["Taille en vert", "Vérifier sous les feuilles"]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def identify(image_bytes: bytes) -> List[dict]:
|
async def identify(image_bytes: bytes) -> List[dict]:
|
||||||
"""Appelle l'ai-service interne et retourne les détections YOLO."""
|
"""Appelle l'ai-service interne et retourne les détections YOLO avec diagnostics."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]:
|
|||||||
results = []
|
results = []
|
||||||
for det in data[:3]:
|
for det in data[:3]:
|
||||||
cls = det.get("class_name", "")
|
cls = det.get("class_name", "")
|
||||||
|
diag = _DIAGNOSTICS.get(cls, {
|
||||||
|
"label": cls.replace("___", " — ").replace("_", " "),
|
||||||
|
"conseil": "Pas de diagnostic spécifique disponible pour cette espèce.",
|
||||||
|
"actions": []
|
||||||
|
})
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"species": cls.replace("___", " — ").replace("_", " "),
|
"species": cls,
|
||||||
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
|
"common_name": diag["label"],
|
||||||
"confidence": det.get("confidence", 0.0),
|
"confidence": det.get("confidence", 0.0),
|
||||||
|
"conseil": diag["conseil"],
|
||||||
|
"actions": diag["actions"],
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ aiofiles==24.1.0
|
|||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
httpx==0.28.0
|
httpx==0.28.0
|
||||||
Pillow==11.1.0
|
Pillow==11.1.0
|
||||||
|
pillow-heif==0.21.0
|
||||||
|
smbprotocol==1.15.0
|
||||||
skyfield==1.49
|
skyfield==1.49
|
||||||
pytz==2025.1
|
pytz==2025.1
|
||||||
numpy==2.2.3
|
numpy==2.2.3
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
|
apscheduler==3.10.4
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import one-shot : docs/graine/caracteristiques_plantation.json + docs/arbustre/caracteristiques_arbustre.json
|
||||||
|
Usage: cd /chemin/projet && python3 backend/scripts/import_graines.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import unicodedata
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
DB_PATH = ROOT / "data" / "jardin.db"
|
||||||
|
UPLOADS_DIR = ROOT / "data" / "uploads"
|
||||||
|
GRAINE_DIR = ROOT / "docs" / "graine"
|
||||||
|
ARBUSTRE_DIR = ROOT / "docs" / "arbustre"
|
||||||
|
|
||||||
|
ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6,
|
||||||
|
"VII": 7, "VIII": 8, "IX": 9, "X": 10, "XI": 11, "XII": 12}
|
||||||
|
|
||||||
|
# Mapping : mot-clé lowercase → nom_commun BDD
|
||||||
|
NOM_MAP = [
|
||||||
|
("oignon", "Oignon"),
|
||||||
|
("laitue pommee grosse", "Laitue"),
|
||||||
|
("laitue attraction", "Laitue"),
|
||||||
|
("laitue", "Laitue"),
|
||||||
|
("persil", "Persil"),
|
||||||
|
("courgette", "Courgette"),
|
||||||
|
("pois mangetout", "Pois"),
|
||||||
|
("pois a ecosser", "Pois"),
|
||||||
|
("pois", "Pois"),
|
||||||
|
("tomate cornue", "Tomate"),
|
||||||
|
("tomates moneymaker", "Tomate"),
|
||||||
|
("tomate", "Tomate"),
|
||||||
|
("poireau", "Poireau"),
|
||||||
|
("echalion", "Echalote"),
|
||||||
|
("courge", "Courge"),
|
||||||
|
("chou pomme", "Chou"),
|
||||||
|
("chou-fleur", "Chou-fleur"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def roman_to_csv(s: str) -> str:
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
s = s.strip()
|
||||||
|
# Handle "(selon sachet)" or other parenthetical notes
|
||||||
|
if "(" in s:
|
||||||
|
s = s.split("(")[0].strip()
|
||||||
|
parts = s.split("-")
|
||||||
|
if len(parts) == 2:
|
||||||
|
a = ROMAN.get(parts[0].strip(), 0)
|
||||||
|
b = ROMAN.get(parts[1].strip(), 0)
|
||||||
|
if a and b:
|
||||||
|
return ",".join(str(m) for m in range(a, b + 1))
|
||||||
|
single = ROMAN.get(s, 0)
|
||||||
|
return str(single) if single else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_float(s: str) -> float | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Handle "2-3 cm" → take first number
|
||||||
|
first = s.split()[0].split("-")[0].replace(",", ".")
|
||||||
|
return float(first)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_plant(conn: sqlite3.Connection, nom_commun: str, categorie: str = "potager") -> int:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM plant WHERE LOWER(nom_commun) = LOWER(?)", (nom_commun,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO plant (nom_commun, categorie, created_at) VALUES (?, ?, ?)",
|
||||||
|
(nom_commun, categorie, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def copy_image(src: Path, variety_id: int, conn: sqlite3.Connection) -> None:
|
||||||
|
if not src.exists():
|
||||||
|
print(f" WARNING image absente: {src}")
|
||||||
|
return
|
||||||
|
# Vérifier si cette image existe déjà dans media pour cette variété
|
||||||
|
existing_m = conn.execute(
|
||||||
|
"SELECT id FROM media WHERE entity_type = 'plant_variety' AND entity_id = ? AND url LIKE ?",
|
||||||
|
(variety_id, f"%{src.stem}%")
|
||||||
|
).fetchone()
|
||||||
|
if existing_m:
|
||||||
|
return
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Use UUID-based filename like the rest of the app
|
||||||
|
dest_name = f"{uuid.uuid4()}.jpg"
|
||||||
|
shutil.copy2(src, UPLOADS_DIR / dest_name)
|
||||||
|
url = f"/uploads/{dest_name}"
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO media (entity_type, entity_id, url, created_at)
|
||||||
|
VALUES ('plant_variety', ?, ?, ?)
|
||||||
|
""", (variety_id, url, datetime.now(timezone.utc).isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(s: str) -> str:
|
||||||
|
"""Normalise string: minuscules, supprime accents simples."""
|
||||||
|
return ''.join(c for c in unicodedata.normalize('NFD', s.lower()) if unicodedata.category(c) != 'Mn')
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_nom(full_name: str) -> tuple[str, str]:
|
||||||
|
"""Retourne (nom_commun, variete) depuis le nom complet du sachet."""
|
||||||
|
norm = normalize(full_name)
|
||||||
|
for key, val in NOM_MAP:
|
||||||
|
norm_key = normalize(key)
|
||||||
|
if norm.startswith(norm_key):
|
||||||
|
variete = full_name[len(key):].strip().strip("'\"").title()
|
||||||
|
return val, variete or full_name
|
||||||
|
# Fallback : premier mot = nom_commun
|
||||||
|
parts = full_name.split()
|
||||||
|
return parts[0].title(), " ".join(parts[1:]).strip() or full_name
|
||||||
|
|
||||||
|
|
||||||
|
def import_graines(conn: sqlite3.Connection) -> None:
|
||||||
|
path = GRAINE_DIR / "caracteristiques_plantation.json"
|
||||||
|
if not path.exists():
|
||||||
|
print(f"WARNING fichier absent: {path}")
|
||||||
|
return
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
key = "plantes" if "plantes" in data else list(data.keys())[0]
|
||||||
|
entries = data[key]
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
full_name = entry.get("plante", "")
|
||||||
|
if not full_name:
|
||||||
|
continue
|
||||||
|
nom_commun, variete_name = resolve_nom(full_name)
|
||||||
|
carac = entry.get("caracteristiques_plantation", {})
|
||||||
|
detail = entry.get("detail", {})
|
||||||
|
texte = detail.get("texte_integral_visible", {}) if isinstance(detail, dict) else {}
|
||||||
|
|
||||||
|
plant_id = find_or_create_plant(conn, nom_commun)
|
||||||
|
|
||||||
|
# Enrichir plant (ne pas écraser si déjà rempli)
|
||||||
|
updates: dict = {}
|
||||||
|
semis = roman_to_csv(carac.get("periode_semis", ""))
|
||||||
|
recolte = roman_to_csv(carac.get("periode_recolte", ""))
|
||||||
|
profondeur = extract_float(carac.get("profondeur") or "")
|
||||||
|
espacement_raw = carac.get("espacement") or ""
|
||||||
|
espacement = extract_float(espacement_raw)
|
||||||
|
|
||||||
|
if semis:
|
||||||
|
updates["semis_exterieur_mois"] = semis
|
||||||
|
if recolte:
|
||||||
|
updates["recolte_mois"] = recolte
|
||||||
|
if profondeur:
|
||||||
|
updates["profondeur_semis_cm"] = profondeur
|
||||||
|
if espacement:
|
||||||
|
updates["espacement_cm"] = int(espacement)
|
||||||
|
if carac.get("exposition"):
|
||||||
|
updates["besoin_soleil"] = carac["exposition"]
|
||||||
|
if carac.get("temperature"):
|
||||||
|
updates["temp_germination"] = carac["temperature"]
|
||||||
|
if isinstance(texte, dict) and texte.get("arriere"):
|
||||||
|
updates["astuces_culture"] = texte["arriere"][:1000]
|
||||||
|
elif isinstance(texte, str) and texte:
|
||||||
|
updates["astuces_culture"] = texte[:1000]
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE plant SET {set_clause} WHERE id = ?",
|
||||||
|
(*updates.values(), plant_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vérifier si cette variété existe déjà pour cette plante
|
||||||
|
existing_v = conn.execute(
|
||||||
|
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
|
||||||
|
(plant_id, variete_name)
|
||||||
|
).fetchone()
|
||||||
|
if existing_v:
|
||||||
|
print(f" ↷ {nom_commun} — {variete_name} (déjà importé)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Créer plant_variety
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)",
|
||||||
|
(plant_id, variete_name, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
for img in entry.get("images", []):
|
||||||
|
copy_image(GRAINE_DIR / img, vid, conn)
|
||||||
|
|
||||||
|
print(f" OK {nom_commun} - {variete_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def import_arbustre(conn: sqlite3.Connection) -> None:
|
||||||
|
path = ARBUSTRE_DIR / "caracteristiques_arbustre.json"
|
||||||
|
if not path.exists():
|
||||||
|
print(f"WARNING fichier absent: {path}")
|
||||||
|
return
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
key = "plantes" if "plantes" in data else list(data.keys())[0]
|
||||||
|
entries = data[key]
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
full_name = entry.get("plante", "")
|
||||||
|
if not full_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
nom_latin = entry.get("nom_latin", "") or ""
|
||||||
|
# Determine nom_commun from the plante field
|
||||||
|
if "Vitis" in full_name or "Vitis" in nom_latin:
|
||||||
|
nom_commun = "Vigne"
|
||||||
|
elif "Ribes nigrum" in full_name or "Ribes nigrum" in nom_latin:
|
||||||
|
nom_commun = "Cassissier"
|
||||||
|
elif "Rubus idaeus" in full_name or "Rubus idaeus" in nom_latin:
|
||||||
|
nom_commun = "Framboisier"
|
||||||
|
elif "'" in full_name:
|
||||||
|
nom_commun = full_name.split("'")[0].strip().title()
|
||||||
|
elif nom_latin:
|
||||||
|
parts = nom_latin.split()
|
||||||
|
nom_commun = (parts[0] + " " + parts[1]).title() if len(parts) > 1 else nom_latin.title()
|
||||||
|
else:
|
||||||
|
nom_commun = full_name.split()[0].title()
|
||||||
|
|
||||||
|
# variete_name: content inside quotes
|
||||||
|
if "'" in full_name:
|
||||||
|
variete_name = full_name.split("'")[1].strip()
|
||||||
|
else:
|
||||||
|
variete_name = full_name
|
||||||
|
|
||||||
|
plant_id = find_or_create_plant(conn, nom_commun, "arbuste")
|
||||||
|
|
||||||
|
carac = entry.get("caracteristiques_plantation", {})
|
||||||
|
arrosage = carac.get("arrosage")
|
||||||
|
exposition = carac.get("exposition")
|
||||||
|
if arrosage:
|
||||||
|
conn.execute("UPDATE plant SET besoin_eau = ? WHERE id = ?", (arrosage, plant_id))
|
||||||
|
if exposition:
|
||||||
|
conn.execute("UPDATE plant SET besoin_soleil = ? WHERE id = ?", (exposition, plant_id))
|
||||||
|
|
||||||
|
# Vérifier si cette variété existe déjà pour cette plante
|
||||||
|
existing_v = conn.execute(
|
||||||
|
"SELECT id FROM plant_variety WHERE plant_id = ? AND LOWER(variete) = LOWER(?)",
|
||||||
|
(plant_id, variete_name)
|
||||||
|
).fetchone()
|
||||||
|
if existing_v:
|
||||||
|
print(f" ↷ {nom_commun} — {variete_name} (déjà importé)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO plant_variety (plant_id, variete, created_at) VALUES (?, ?, ?)",
|
||||||
|
(plant_id, variete_name, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
for img in entry.get("images", []):
|
||||||
|
copy_image(ARBUSTRE_DIR / img, vid, conn)
|
||||||
|
|
||||||
|
print(f" OK {nom_commun} - {variete_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"ERREUR : base de données introuvable : {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
try:
|
||||||
|
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
||||||
|
if "plant_variety" not in tables:
|
||||||
|
print("WARNING Exécutez d'abord migrate_plant_varieties.py")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("=== Import graines ===")
|
||||||
|
import_graines(conn)
|
||||||
|
print("\n=== Import arbustre ===")
|
||||||
|
import_arbustre(conn)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nImport terminé.")
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"ERREUR - rollback : {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration one-shot : crée plant_variety, migre données existantes, fusionne haricot grimpant.
|
||||||
|
À exécuter UNE SEULE FOIS depuis la racine du projet.
|
||||||
|
Usage: cd /chemin/projet && python3 backend/scripts/migrate_plant_varieties.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "jardin.db"
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"ERREUR : base de données introuvable : {DB_PATH}")
|
||||||
|
return
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
# 1. Créer plant_variety
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS plant_variety (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plant_id INTEGER NOT NULL REFERENCES plant(id) ON DELETE CASCADE,
|
||||||
|
variete TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
notes_variete TEXT,
|
||||||
|
boutique_nom TEXT,
|
||||||
|
boutique_url TEXT,
|
||||||
|
prix_achat REAL,
|
||||||
|
date_achat TEXT,
|
||||||
|
poids TEXT,
|
||||||
|
dluo TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print("✓ Table plant_variety créée")
|
||||||
|
|
||||||
|
# 2. Ajouter colonnes manquantes à plant
|
||||||
|
existing = [r[1] for r in conn.execute("PRAGMA table_info(plant)").fetchall()]
|
||||||
|
for col, typ in [("temp_germination", "TEXT"), ("temps_levee_j", "TEXT")]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(f"ALTER TABLE plant ADD COLUMN {col} {typ}")
|
||||||
|
print(f"✓ Colonne {col} ajoutée à plant")
|
||||||
|
|
||||||
|
# 3. Vérifier si déjà migré
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM plant_variety").fetchone()[0]
|
||||||
|
if count > 0:
|
||||||
|
print(f"⚠️ Migration déjà effectuée ({count} variétés). Abandon.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Migrer chaque plante → plant_variety
|
||||||
|
plants = conn.execute(
|
||||||
|
"SELECT id, nom_commun, variete, tags, boutique_nom, boutique_url, "
|
||||||
|
"prix_achat, date_achat, poids, dluo FROM plant"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for p in plants:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO plant_variety
|
||||||
|
(plant_id, variete, tags, boutique_nom, boutique_url,
|
||||||
|
prix_achat, date_achat, poids, dluo, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
p["id"], p["variete"], p["tags"], p["boutique_nom"], p["boutique_url"],
|
||||||
|
p["prix_achat"], p["date_achat"], p["poids"], p["dluo"],
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
))
|
||||||
|
print(f" → plant id={p['id']} {p['nom_commun']} : variété '{p['variete']}'")
|
||||||
|
|
||||||
|
# 5. Fusionner haricot grimpant (id=21) sous Haricot (id=7)
|
||||||
|
# IDs stables dans le seed de production : Haricot=7, haricot grimpant=21
|
||||||
|
hg = conn.execute("SELECT * FROM plant WHERE id = 21").fetchone()
|
||||||
|
if hg:
|
||||||
|
# Supprimer la plant_variety créée pour id=21 (on va la recréer sous id=7)
|
||||||
|
conn.execute("DELETE FROM plant_variety WHERE plant_id = 21")
|
||||||
|
# Créer variété sous Haricot (id=7)
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO plant_variety (plant_id, variete, notes_variete, created_at)
|
||||||
|
VALUES (7, 'Grimpant Neckarkönigin', 'Fusionné depuis haricot grimpant', ?)
|
||||||
|
""", (datetime.now(timezone.utc).isoformat(),))
|
||||||
|
new_vid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
print(f" → haricot grimpant fusionné sous Haricot (plant_variety id={new_vid})")
|
||||||
|
# Supprimer le plant haricot grimpant
|
||||||
|
conn.execute("DELETE FROM plant WHERE id = 21")
|
||||||
|
print(" → plant id=21 (haricot grimpant) supprimé")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration terminée avec succès.")
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"ERREUR — rollback effectué : {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -1,29 +1,39 @@
|
|||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import SQLModel, create_engine, Session
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
from sqlmodel.pool import StaticPool
|
from sqlmodel.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("ENABLE_SCHEDULER", "0")
|
||||||
|
os.environ.setdefault("ENABLE_BOOTSTRAP", "0")
|
||||||
|
|
||||||
import app.models # noqa — force l'enregistrement des modèles
|
import app.models # noqa — force l'enregistrement des modèles
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="session")
|
@pytest.fixture(name="engine")
|
||||||
def session_fixture():
|
def engine_fixture():
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite://",
|
"sqlite://",
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="session")
|
||||||
|
def session_fixture(engine):
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="client")
|
@pytest.fixture(name="client")
|
||||||
def client_fixture(session: Session):
|
def client_fixture(engine):
|
||||||
def get_session_override():
|
def get_session_override():
|
||||||
yield session
|
with Session(engine) as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = get_session_override
|
app.dependency_overrides[get_session] = get_session_override
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tests des filtres catégorie/tag/mois du router astuces."""
|
||||||
|
|
||||||
|
from app.models.astuce import Astuce
|
||||||
|
from app.routers.astuces import list_astuces
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(session):
|
||||||
|
session.add(
|
||||||
|
Astuce(
|
||||||
|
titre="Tomate mildiou",
|
||||||
|
contenu="Surveiller humidité",
|
||||||
|
categorie="maladie",
|
||||||
|
tags='["tomate", "mildiou"]',
|
||||||
|
mois="[6,7,8]",
|
||||||
|
entity_type="plant",
|
||||||
|
entity_id=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Astuce(
|
||||||
|
titre="Semis salade",
|
||||||
|
contenu="Semer en ligne",
|
||||||
|
categorie="plante",
|
||||||
|
tags='["salade", "semis"]',
|
||||||
|
mois="[3,4,9]",
|
||||||
|
entity_type="plant",
|
||||||
|
entity_id=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Astuce(
|
||||||
|
titre="Paillage universel",
|
||||||
|
contenu="Proteger le sol",
|
||||||
|
categorie="jardin",
|
||||||
|
tags='["sol", "eau"]',
|
||||||
|
mois=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_categorie(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie="plante", tag=None, mois=None, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Semis salade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_tag(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie=None, tag="tomate", mois=None, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Tomate mildiou"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_by_mois_includes_all_year(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie=None, tag=None, mois=12, session=session)
|
||||||
|
titles = {a.titre for a in out}
|
||||||
|
assert "Paillage universel" in titles
|
||||||
|
assert "Tomate mildiou" not in titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_filters(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type=None, entity_id=None, categorie="maladie", tag="mildiou", mois=7, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Tomate mildiou"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_entity_filters(session):
|
||||||
|
_seed(session)
|
||||||
|
out = list_astuces(entity_type="plant", entity_id=2, categorie=None, tag=None, mois=None, session=session)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].titre == "Semis salade"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
def test_create_media_normalizes_english_entity_type(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/media",
|
||||||
|
json={
|
||||||
|
"entity_type": "plant",
|
||||||
|
"entity_id": 12,
|
||||||
|
"url": "/uploads/test.webp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["entity_type"] == "plante"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_media_accepts_alias_entity_type_filter(client):
|
||||||
|
client.post(
|
||||||
|
"/api/media",
|
||||||
|
json={
|
||||||
|
"entity_type": "plante",
|
||||||
|
"entity_id": 99,
|
||||||
|
"url": "/uploads/test2.webp",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r = client.get("/api/media", params={"entity_type": "plant", "entity_id": 99})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == 1
|
||||||
|
assert r.json()[0]["entity_type"] == "plante"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Tests du service météo et des endpoints."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(client):
|
||||||
|
r = client.get("/api/health")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_meteo_tableau_vide(client):
|
||||||
|
"""Le tableau fonctionne même si les tables sont vides."""
|
||||||
|
r = client.get("/api/meteo/tableau")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert "rows" in data
|
||||||
|
assert isinstance(data["rows"], list)
|
||||||
|
# 15 lignes attendues (7 passé + J0 + 7 futur)
|
||||||
|
assert len(data["rows"]) == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_meteo_station_current_vide(client):
|
||||||
|
"""Retourne null si aucune donnée station."""
|
||||||
|
r = client.get("/api/meteo/station/current")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Peut être null ou un objet
|
||||||
|
assert r.json() is None or isinstance(r.json(), dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_meteo_previsions(client):
|
||||||
|
"""Retourne une liste de jours de prévisions."""
|
||||||
|
r = client.get("/api/meteo/previsions")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert "days" in data
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Tests unitaires du service Open-Meteo enrichi."""
|
||||||
|
|
||||||
|
from datetime import date as real_date
|
||||||
|
|
||||||
|
import app.services.meteo as meteo
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyResponse:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_and_store_forecast_enriched(monkeypatch):
|
||||||
|
payload = {
|
||||||
|
"daily": {
|
||||||
|
"time": ["2026-02-21", "2026-02-22"],
|
||||||
|
"temperature_2m_min": [1.2, 2.3],
|
||||||
|
"temperature_2m_max": [8.4, 9.7],
|
||||||
|
"precipitation_sum": [0.5, 1.0],
|
||||||
|
"wind_speed_10m_max": [12.0, 15.0],
|
||||||
|
"weather_code": [3, 61],
|
||||||
|
"relative_humidity_2m_max": [88, 92],
|
||||||
|
"et0_fao_evapotranspiration": [0.9, 1.1],
|
||||||
|
},
|
||||||
|
"hourly": {
|
||||||
|
"time": [
|
||||||
|
"2026-02-21T00:00",
|
||||||
|
"2026-02-21T01:00",
|
||||||
|
"2026-02-22T00:00",
|
||||||
|
"2026-02-22T01:00",
|
||||||
|
],
|
||||||
|
"soil_temperature_0cm": [4.0, 6.0, 8.0, 10.0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fake_get(*_args, **_kwargs):
|
||||||
|
return _DummyResponse(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(meteo.httpx, "get", _fake_get)
|
||||||
|
|
||||||
|
rows = meteo.fetch_and_store_forecast(lat=45.1, lon=4.0)
|
||||||
|
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0]["date"] == "2026-02-21"
|
||||||
|
assert rows[0]["label"] == "Couvert"
|
||||||
|
assert rows[0]["sol_0cm"] == 5.0
|
||||||
|
assert rows[0]["etp_mm"] == 0.9
|
||||||
|
assert rows[1]["label"] == "Pluie légère"
|
||||||
|
assert rows[1]["sol_0cm"] == 9.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_and_store_forecast_handles_http_error(monkeypatch):
|
||||||
|
def _boom(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(meteo.httpx, "get", _boom)
|
||||||
|
|
||||||
|
rows = meteo.fetch_and_store_forecast()
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_forecast_filters_from_today(monkeypatch):
|
||||||
|
class _FakeDate(real_date):
|
||||||
|
@classmethod
|
||||||
|
def today(cls):
|
||||||
|
return cls(2026, 2, 22)
|
||||||
|
|
||||||
|
monkeypatch.setattr(meteo, "date", _FakeDate)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
meteo,
|
||||||
|
"fetch_and_store_forecast",
|
||||||
|
lambda *_args, **_kwargs: [
|
||||||
|
{"date": "2026-02-21", "x": 1},
|
||||||
|
{"date": "2026-02-22", "x": 2},
|
||||||
|
{"date": "2026-02-23", "x": 3},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
out = meteo.fetch_forecast(days=14)
|
||||||
|
|
||||||
|
assert [d["date"] for d in out["days"]] == ["2026-02-22", "2026-02-23"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
def test_create_planting(client):
|
def test_create_planting(client):
|
||||||
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
||||||
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
|
v = client.post("/api/plants", json={"nom_commun": "Tomate"}).json()
|
||||||
r = client.post("/api/plantings", json={
|
r = client.post("/api/plantings", json={
|
||||||
"garden_id": g["id"], "variety_id": v["id"], "quantite": 3
|
"garden_id": g["id"], "variety_id": v["id"], "quantite": 3
|
||||||
})
|
})
|
||||||
@@ -10,7 +10,7 @@ def test_create_planting(client):
|
|||||||
|
|
||||||
def test_list_plantings(client):
|
def test_list_plantings(client):
|
||||||
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
||||||
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
|
v = client.post("/api/plants", json={"nom_commun": "Tomate"}).json()
|
||||||
client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]})
|
client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]})
|
||||||
r = client.get("/api/plantings")
|
r = client.get("/api/plantings")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -19,7 +19,7 @@ def test_list_plantings(client):
|
|||||||
|
|
||||||
def test_add_planting_event(client):
|
def test_add_planting_event(client):
|
||||||
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
|
||||||
v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
|
v = client.post("/api/plants", json={"nom_commun": "Tomate"}).json()
|
||||||
p = client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]}).json()
|
p = client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]}).json()
|
||||||
r = client.post(f"/api/plantings/{p['id']}/events", json={"type": "arrosage", "note": "Bien arrosé"})
|
r = client.post(f"/api/plantings/{p['id']}/events", json={"type": "arrosage", "note": "Bien arrosé"})
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
def test_create_plant(client):
|
||||||
|
r = client.post("/api/plants", json={"nom_commun": "Tomate", "famille": "Solanacées"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["nom_commun"] == "Tomate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_plants(client):
|
||||||
|
client.post("/api/plants", json={"nom_commun": "Tomate"})
|
||||||
|
client.post("/api/plants", json={"nom_commun": "Courgette"})
|
||||||
|
r = client.get("/api/plants")
|
||||||
|
assert r.status_code == 200
|
||||||
|
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"]
|
||||||
|
r2 = client.get(f"/api/plants/{id}")
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_plant(client):
|
||||||
|
r = client.post("/api/plants", json={"nom_commun": "Test"})
|
||||||
|
id = r.json()["id"]
|
||||||
|
r2 = client.delete(f"/api/plants/{id}")
|
||||||
|
assert r2.status_code == 204
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
def test_create_recolte(client):
|
||||||
|
# créer jardin + plante + plantation d'abord
|
||||||
|
g = client.post(
|
||||||
|
"/api/gardens",
|
||||||
|
json={"nom": "J", "grille_largeur": 2, "grille_hauteur": 2, "type": "plein_air"},
|
||||||
|
).json()
|
||||||
|
p = client.post("/api/plants", json={"nom_commun": "Tomate"}).json()
|
||||||
|
pl = client.post(
|
||||||
|
"/api/plantings",
|
||||||
|
json={"garden_id": g["id"], "variety_id": p["id"], "quantite": 1, "statut": "en_cours"},
|
||||||
|
).json()
|
||||||
|
r = client.post(
|
||||||
|
f"/api/plantings/{pl['id']}/recoltes",
|
||||||
|
json={"quantite": 2.5, "unite": "kg", "date_recolte": "2026-08-01"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["quantite"] == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_recoltes(client):
|
||||||
|
g = client.post(
|
||||||
|
"/api/gardens",
|
||||||
|
json={"nom": "J", "grille_largeur": 2, "grille_hauteur": 2, "type": "plein_air"},
|
||||||
|
).json()
|
||||||
|
p = client.post("/api/plants", json={"nom_commun": "Tomate"}).json()
|
||||||
|
pl = client.post(
|
||||||
|
"/api/plantings",
|
||||||
|
json={"garden_id": g["id"], "variety_id": p["id"], "quantite": 1, "statut": "en_cours"},
|
||||||
|
).json()
|
||||||
|
client.post(
|
||||||
|
f"/api/plantings/{pl['id']}/recoltes",
|
||||||
|
json={"quantite": 1, "unite": "kg", "date_recolte": "2026-08-01"},
|
||||||
|
)
|
||||||
|
r = client.get(f"/api/plantings/{pl['id']}/recoltes")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == 1
|
||||||
@@ -26,3 +26,13 @@ def test_update_task_statut(client):
|
|||||||
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["statut"] == "fait"
|
assert r2.json()["statut"] == "fait"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_tasks_by_planting_id(client):
|
||||||
|
client.post("/api/tasks", json={"titre": "Template arrosage", "statut": "template"})
|
||||||
|
client.post("/api/tasks", json={"titre": "Arroser rang 1", "statut": "a_faire", "planting_id": 10})
|
||||||
|
client.post("/api/tasks", json={"titre": "Arroser rang 2", "statut": "a_faire", "planting_id": 11})
|
||||||
|
r = client.get("/api/tasks?planting_id=10")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) == 1
|
||||||
|
assert r.json()[0]["planting_id"] == 10
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
def test_create_tool(client):
|
||||||
|
r = client.post("/api/tools", json={"nom": "Bêche", "categorie": "beche"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["nom"] == "Bêche"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tools(client):
|
||||||
|
client.post("/api/tools", json={"nom": "Outil1"})
|
||||||
|
r = client.get("/api/tools")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_tool(client):
|
||||||
|
r = client.post("/api/tools", json={"nom": "Test"})
|
||||||
|
id = r.json()["id"]
|
||||||
|
r2 = client.delete(f"/api/tools/{id}")
|
||||||
|
assert r2.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_with_video_url(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/tools",
|
||||||
|
json={
|
||||||
|
"nom": "Tarière",
|
||||||
|
"video_url": "/uploads/demo-outil.mp4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["video_url"] == "/uploads/demo-outil.mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_with_notice_texte(client):
|
||||||
|
r = client.post(
|
||||||
|
"/api/tools",
|
||||||
|
json={
|
||||||
|
"nom": "Sécateur",
|
||||||
|
"notice_texte": "Aiguiser la lame tous les 3 mois.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()["notice_texte"] == "Aiguiser la lame tous les 3 mois."
|
||||||
@@ -1,26 +1,29 @@
|
|||||||
|
"""Tests de l'ancien endpoint /api/varieties — maintenant redirigé vers /api/plants."""
|
||||||
|
|
||||||
|
|
||||||
def test_create_variety(client):
|
def test_create_variety(client):
|
||||||
r = client.post("/api/varieties", json={"nom_commun": "Tomate", "famille": "Solanacées"})
|
r = client.post("/api/plants", json={"nom_commun": "Tomate", "famille": "Solanacées"})
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert r.json()["nom_commun"] == "Tomate"
|
assert r.json()["nom_commun"] == "Tomate"
|
||||||
|
|
||||||
|
|
||||||
def test_list_varieties(client):
|
def test_list_varieties(client):
|
||||||
client.post("/api/varieties", json={"nom_commun": "Tomate"})
|
client.post("/api/plants", json={"nom_commun": "Tomate"})
|
||||||
client.post("/api/varieties", json={"nom_commun": "Courgette"})
|
client.post("/api/plants", json={"nom_commun": "Courgette"})
|
||||||
r = client.get("/api/varieties")
|
r = client.get("/api/plants")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert len(r.json()) == 2
|
assert len(r.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_get_variety(client):
|
def test_get_variety(client):
|
||||||
r = client.post("/api/varieties", json={"nom_commun": "Basilic"})
|
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
|
||||||
id = r.json()["id"]
|
id = r.json()["id"]
|
||||||
r2 = client.get(f"/api/varieties/{id}")
|
r2 = client.get(f"/api/plants/{id}")
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_delete_variety(client):
|
def test_delete_variety(client):
|
||||||
r = client.post("/api/varieties", json={"nom_commun": "Test"})
|
r = client.post("/api/plants", json={"nom_commun": "Test"})
|
||||||
id = r.json()["id"]
|
id = r.json()["id"]
|
||||||
r2 = client.delete(f"/api/varieties/{id}")
|
r2 = client.delete(f"/api/plants/{id}")
|
||||||
assert r2.status_code == 204
|
assert r2.status_code == 204
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Résumé (executive summary)
|
||||||
|
Le **calendrier lunaire de jardinage** se base sur les cycles astronomiques de la Lune pour répartir les travaux (semis, plantations, récoltes) en «jours racine/feuille/fleur/fruit» et selon la **montée/descente de la sève**. Cette méthode traditionnelle, reprise par l’agriculture biodynamique, prétend optimiser la croissance. Toutefois, des sources scientifiques avertissent qu’**aucune influence directe mesurable** n’a été démontrée【6†L373-L381】【5†L108-L116】. Malgré tout, le calendrier lunaire sert souvent de repère pratique pour planifier les travaux. Ce document explique les concepts astronomiques (phases, illumination, etc.), leur calcul en Python (avec *skyfield*), et leur traduction en règles de jardinage, tout en restant critique et rigoureux.
|
||||||
|
|
||||||
|
## Concepts astronomiques clés
|
||||||
|
1. **Phases de la Lune** : La phase lunaire se définit par l’angle entre la Lune et le Soleil vus depuis la Terre, mesuré le long de l’écliptique. En pratique, on calcule la différence de longitude écliptique Soleil–Lune【14†L123-L131】. Ce résultat vaut 0° (Nouvelle Lune), ≈90° (Premier Quartier), ≈180° (Pleine Lune) ou ≈270° (Dernier Quartier) modulo 360°【14†L123-L131】.
|
||||||
|
La **fraction illuminée** (illumination) peut être obtenue à partir de l’angle de phase : si θ est la séparation géocentrique Soleil–Lune (en radians), alors l’illumination ≈ (1–cosθ)/2. (Skyfield propose aussi `m.fraction_illuminated(sun)` pour obtenir directement ce pourcentage.)
|
||||||
|
|
||||||
|
2. **Lune montante / descendante** : Traditionnellement, on dit «lune montante» quand la Lune «monte» dans le ciel, c’est-à-dire que sa déclinaison géocentrique augmente au fil du jour. En pratique, on calcule la déclinaison (angle au‑dessus du plan équatorial) pour midi d’un jour et du lendemain : si la déclinaison augmente, on est en **période montante**, sinon descendante. La montée (ou descente) de la Lune coïncide grosso modo avec le flux de sève vers le haut (ou le bas) dans les plantes.
|
||||||
|
|
||||||
|
3. **Longitude écliptique et signes zodiacaux** : La position de la Lune dans le **zodiaque** (son signe astrologique) se déduit de sa longitude écliptique : on divise l’écliptique (360°) en 12 signes de 30°. Par exemple, Taureau (30°–60°), Gémeaux (60°–90°), etc. Chaque signe est associé à un élément (Terre, Eau, Air, Feu) selon la tradition agricole. On définit alors les **jours «racine/feuille/fleur/fruit»** :
|
||||||
|
- **Racine** : signes de Terre (Taureau, Vierge, Capricorne) – favorise légumes racines.
|
||||||
|
- **Feuille** : signes d’Eau (Cancer, Scorpion, Poissons) – favorise feuillage (salades, épinards).
|
||||||
|
- **Fleur** : signes d’Air (Gémeaux, Balance, Verseau) – favorise floraison et plantes ornementales.
|
||||||
|
- **Fruit** : signes de Feu (Bélier, Lion, Sagittaire) – favorise légumes-fruits (tomates, haricots)【24†L44-L53】.
|
||||||
|
|
||||||
|
4. **Périgée et apogée** : La Lune suit une orbite elliptique (période anomalistique ≈ 27.55 j). Le **périgée** est le point où la Lune est la plus proche de la Terre, l’**apogée** le plus éloigné【18†L108-L112】. Chaque lunaison comporte un périgée et un apogée. On peut les repérer en cherchant les minima/maxima locaux de la distance Terre–Lune jour par jour. La tradition conseille d’**éviter** ces jours (trop d’énergie au périgée, croissance ralentie à l’apogée)【24†L66-L73】.
|
||||||
|
|
||||||
|
5. **Nœuds lunaires** : Ce sont les deux points où l’orbite lunaire coupe l’écliptique (plan de l’orbite terrestre)【16†L155-L163】. Les nœuds correspondent aux périodes d’éclipses (noeud ascendant/descendant). Traditionnellement, on considère ces jours comme «perturbés» et on déconseille le jardinage【24†L66-L73】.
|
||||||
|
|
||||||
|
**Calculs utilisés (Skyfield)** : Le script Python charge l’éphéméride NASA DE421 pour Soleil, Terre, Lune. Il utilise `almanac.find_discrete` pour les **phases exactes** et les **nœuds**. Pour chaque jour (heure de référence = midi locale Europe/Paris), on calcule : l’illumination (via la séparation Soleil-Lune), la variation de déclinaison (montante/descendante), la longitude écliptique (pour le signe). Le code exemple montre :
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Phases exactes
|
||||||
|
f_phase = almanac.moon_phases(eph)
|
||||||
|
times, events = almanac.find_discrete(t0, t1, f_phase)
|
||||||
|
for t, ev in zip(times, events):
|
||||||
|
local_day = t.utc_datetime().astimezone(TZ).date()
|
||||||
|
phase_by_day[local_day] = ["Nouvelle Lune","1er Quart.","Pleine L.","Dernier Quart."][ev]
|
||||||
|
```
|
||||||
|
Cela enregistre l’événement de phase (le jour local de NL/Ple/Q1/Q3).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Illumination et montante/descendante
|
||||||
|
e = earth.at(t); v_sun = e.observe(sun).apparent(); v_moon = e.observe(moon).apparent()
|
||||||
|
sep = v_sun.separation_from(v_moon).radians
|
||||||
|
illum = (1 - math.cos(sep)) / 2 # fraction éclairée
|
||||||
|
illum2 = (1 - math.cos(v_sun2.separation_from(v_moon2).radians)) / 2
|
||||||
|
croissante = "Croissante" if illum2 >= illum else "Décroissante"
|
||||||
|
|
||||||
|
dec = v_moon.radec()[1].degrees
|
||||||
|
dec2 = v_moon2.radec()[1].degrees
|
||||||
|
montante = "Montante" if dec2 >= dec else "Descendante"
|
||||||
|
```
|
||||||
|
On compare l’illumination et la déclinaison d’un jour à ceux du lendemain pour décider «croissante/décroissante» et «montante/descendante».
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Signe zodiacal (longitude écliptique)
|
||||||
|
lat, lon, dist = v_moon.ecliptic_latlon()
|
||||||
|
signe = SIGN_NAMES[int(lon.degrees // 30)]
|
||||||
|
type_jour = SIGN_TO_TYPE[signe] # "Racine"/"Feuille"/"Fleur"/"Fruit"
|
||||||
|
```
|
||||||
|
Cela associe chaque jour à un «type de jour» agricole selon le signe (Taureau→Racine, etc【24†L44-L53】).
|
||||||
|
|
||||||
|
Le calcul du **périgée/apogée** se fait manuellement : on mesure la distance Terre-Lune à midi chaque jour, puis on repère les minima/maxima locaux (via comparaison avec le jour précédent/suivant) pour marquer périgée et apogée. Ce choix manuel évite l’API `almanac.moon_distance` obsolète, tout en restant suffisant pour le jardinage (un point extrême par lunaison).
|
||||||
|
|
||||||
|
## Liens avec le jardinage
|
||||||
|
Les cycles ci-dessus se traduisent en règles traditionnelles (non vérifiées scientifiquement【6†L373-L381】) :
|
||||||
|
|
||||||
|
- **Lune montante** : la sève monte, donc on **sème et récolte** (plantes aériennes, légumes-fruits)【7†L208-L214】【20†L24-L32】.
|
||||||
|
- **Lune descendante** : la sève descend, donc on **plante, repique, taille, travaille le sol** (consolidation racinaire)【7†L208-L214】【20†L24-L32】.
|
||||||
|
- **Croissante vs décroissante** : complément à montante/descendante. La lune croissante favorise les tiges/feuilles/fruits, la décroissante les racines【7†L208-L214】【20†L24-L32】.
|
||||||
|
- **Jours racine/feuille/fleur/fruit** : selon le signe zodiacal, on privilégie les cultures correspondantes【24†L44-L53】 (par ex. Taureau/Vierge/Capricorne = légumes racines, Cancer/Scorpion/Poissons = salades et choux, Gémeaux/Balance/Verseau = fleurs, Bélier/Lion/Sagittaire = tomates et haricots).
|
||||||
|
- **Éviter certains jours** : tradition recommande de ne rien faire lors des **nœuds lunaires, du périgée et de l’apogée**【24†L66-L73】. Par exemple, on évite semer au périgée (supposé trop d’énergie) et en période d’éclipse (nœuds)【22†L66-L73】.
|
||||||
|
|
||||||
|
En résumé, on obtiendrait un tableau synthétique :
|
||||||
|
|
||||||
|
| **Cycle lunaire** | **Action jardin** | **Exemple** |
|
||||||
|
|---------------------------|------------------------------------------|----------------------|
|
||||||
|
| Phase croissante | Développement aérien (semis, greffe) | Semis de tomates au 1er quartier【7†L208-L214】 |
|
||||||
|
| Phase décroissante | Consolidation racinaire (plantation) | Repiquage en lune descendante【7†L208-L214】 |
|
||||||
|
| Lune montante (ascendante)| Semis et récoltes (au-dessus du sol) | Semer haricots en lune montante【7†L208-L214】 |
|
||||||
|
| Lune descendante | Planter, tailler, travailler le sol | Planter pommes de terre en lune descendante【7†L208-L214】 |
|
||||||
|
| Jour **Racine** (signe Terre) | Légumes-racines (carottes, betteraves) | Semez carottes (Lune en Vierge)【24†L44-L53】 |
|
||||||
|
| Jour **Feuille** (signe Eau) | Feuillage (salades, épinards) | Semez laitues (Lune en Cancer)【24†L44-L53】 |
|
||||||
|
| Jour **Fleur** (signe Air) | Fleurs, plantes ornementales | Repiquer vivaces (Lune en Gémeaux)【24†L50-L53】 |
|
||||||
|
| Jour **Fruit** (signe Feu) | Légumes-fruits (tomates, haricots) | Semis tomates (Lune en Lion)【24†L52-L55】 |
|
||||||
|
| **Nœuds lunaires** | **Éviter le jardinage** (énergies perturbées) | 18+ mars (noeuds)【22†L66-L73】 |
|
||||||
|
| **Périgée / Apogée** | **Éviter ou prudence** (croissance ralentie ou maladies) | Ex.: 25 mars (périgée)【22†L69-L73】 |
|
||||||
|
|
||||||
|
Ces recommandations proviennent de la tradition jardinière et sont souvent condensées dans des calendriers lunaires grand public【24†L44-L53】【22†L66-L73】.
|
||||||
|
|
||||||
|
## Script Python : structure et explications
|
||||||
|
Le script `lunar_calendar.py` donné utilise **Skyfield** pour la précision astronomique. Principaux points techniques :
|
||||||
|
- **Dépendances** : `skyfield`, `pytz`, `numpy`. Le fichier d’éphémérides `de421.bsp` (NASA) couvre jusqu’en 2050 environ.
|
||||||
|
- **Fuseau horaire** : on fixe l’heure locale «midi à Paris» pour chaque jour, afin de ne pas rater un changement de date à l’UTC. On convertit en UTC pour l’analyse Skyfield (voir `TZ.localize(datetime(...)).astimezone(pytz.utc)`).
|
||||||
|
- **Phases exactes** : on utilise `almanac.moon_phases(eph)` et `find_discrete(t0,t1,f_phase)` pour obtenir les instants (UTC) des quatre phases principales. On associe ensuite la date locale correspondante :
|
||||||
|
|
||||||
|
```python
|
||||||
|
f_phase = almanac.moon_phases(eph)
|
||||||
|
phase_times, phase_events = almanac.find_discrete(t0, t1, f_phase)
|
||||||
|
phase_by_day = {}
|
||||||
|
for t, ev in zip(phase_times, phase_events):
|
||||||
|
local_day = t.utc_datetime().astimezone(TZ).date()
|
||||||
|
phase_by_day[local_day] = ["Nouvelle Lune","Premier Quartier","Pleine Lune","Dernier Quartier"][int(ev)]
|
||||||
|
```
|
||||||
|
Cette méthode assure la précision astronomique des phases (décalage horaire et lieux pris en compte).
|
||||||
|
|
||||||
|
- **Illumination (pour «croissante/décroissante»)** : on calcule à midi la fraction illuminée de la Lune par rapport au Soleil vu de la Terre. La séparation angulaire géocentrique Lune–Soleil (radians) donne l’illumination via `(1 - cos(sep))/2`. On compare cette fraction au jour suivant pour déterminer si la Lune croît ou décroît.
|
||||||
|
|
||||||
|
- **Montante/descendante** : on récupère la déclinaison géocentrique de la Lune (`v_moon.radec()[1].degrees`) pour deux jours consécutifs. Si elle augmente, on est en période «montante», sinon «descendante».
|
||||||
|
|
||||||
|
- **Longitude écliptique (signe)** : Skyfield fournit la longitude écliptique (`v_moon.ecliptic_latlon()`). La division par 30° détermine le signe zodiacal (0=Bélier, 30=Taureau, etc.). On mappe ensuite le signe au type de jour (racine/feuille/fleur/fruit) via une table (comme vu ci-dessus【24†L44-L53】).
|
||||||
|
|
||||||
|
- **Périgée/Apogée manuels** : comme la fonction `almanac.moon_distance` n’existait plus, on calcule la distance Terre–Lune chaque jour à midi. On parcourt ce tableau de distances pour repérer les minima locaux (périgée) et maxima locaux (apogée). C’est une approximation suffisante pour marquer environ un périgée et un apogée par mois.
|
||||||
|
|
||||||
|
**Limites et précisions** :
|
||||||
|
- L’algorithme suppose un calcul quotidien à midi : il peut ne pas être précis à l’heure, mais on obtient les bons jours. Pour une précision à l’heure près (rarement nécessaire pour le jardinage), on pourrait affiner la recherche d’événements.
|
||||||
|
- Le fuseau Europe/Paris est appliqué partout pour obtenir la date locale. En hiver comme en été, on fixe à midi (CET ou CEST).
|
||||||
|
- Les **jours racine/feuille/fleur/fruit** sont purement conventionnels (astrologiques)【24†L44-L53】. Le choix des signes et l’attribution aux «éléments» viennent de la tradition, pas de l’astronomie. D’autres écoles pourraient varier légèrement ces mappings.
|
||||||
|
- Le script génère un JSON/CSV qui inclut, pour chaque date : phase, %illumination, status croissante/descroissante et montante/descendante, signe zodiacal et type de jour, périgée/apogée, nœud lunaire.
|
||||||
|
|
||||||
|
Extrait du fichier JSON produit (format JSON compatible API) :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-03-14",
|
||||||
|
"phase": "",
|
||||||
|
"illumination": 67.34,
|
||||||
|
"croissante_decroissante": "Croissante",
|
||||||
|
"montante_descendante": "Montante",
|
||||||
|
"signe": "Taureau",
|
||||||
|
"type_jour": "Racine",
|
||||||
|
"perigee": false,
|
||||||
|
"apogee": false,
|
||||||
|
"noeud_lunaire": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation et test
|
||||||
|
1. **Prérequis** : Python 3.9+ installé (nous avons testé sur Python 3.13). Ouvrir un terminal.
|
||||||
|
2. **Environnement virtuel** (recommandé) :
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
3. **Installer dépendances** :
|
||||||
|
```bash
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install skyfield numpy pytz
|
||||||
|
```
|
||||||
|
4. **Vérifier** que `lunar_calendar.py` se trouve dans le dossier de travail.
|
||||||
|
5. **Lancer le script** :
|
||||||
|
```bash
|
||||||
|
python lunar_calendar.py
|
||||||
|
```
|
||||||
|
– Au premier lancement, Skyfield télécharge `de421.bsp`.
|
||||||
|
– Le script affiche «Calendrier lunaire généré» et crée `calendrier_lunaire_2026.json`.
|
||||||
|
6. **Vérifier le contenu** :
|
||||||
|
```bash
|
||||||
|
head -n 5 calendrier_lunaire_2026.json
|
||||||
|
```
|
||||||
|
ou
|
||||||
|
```bash
|
||||||
|
cat calendrier_lunaire_2026.json | jq . # (avec jq pour formatage)
|
||||||
|
```
|
||||||
|
|
||||||
|
Si une erreur survient (par ex. `ModuleNotFoundError`), vérifier l’environnement virtuel et l’installation des librairies.
|
||||||
|
|
||||||
|
## Exemples d’usage et formats de sortie
|
||||||
|
- Le script, en l’état, génère un **JSON** (tableau d’objets journaliers) et peut être modifié pour produire du CSV.
|
||||||
|
- Exemple CSV attendu (point-virgule séparateur) :
|
||||||
|
```
|
||||||
|
date;phase_exacte;croissante_décroissante;montante_descendante;signe;jour_plante
|
||||||
|
2026-03-14;;Croissante;Montante;Taureau;Racine
|
||||||
|
2026-03-15;Dernier Quartier;Décroissante;Descendante;Bélier;Fruit
|
||||||
|
...
|
||||||
|
```
|
||||||
|
- Ce fichier JSON/CSV peut être importé dans une base (SQLite) ou exposé via une API (FastAPI) pour alimenter un frontend.
|
||||||
|
|
||||||
|
## Améliorations possibles et pièges à éviter
|
||||||
|
- **CLI ou paramètres** : ajouter des arguments (`--start YEAR-MON-DAY --end ...`) et `--output` pour rendre le script plus flexible.
|
||||||
|
- **FastAPI / Backend** : intégrer le calcul dans un endpoint (par ex. `/api/lune/{year}`) pour générer le calendrier à la demande ou en consulter un pré-calculé.
|
||||||
|
- **Base de données** : pré-calculer 5-10 ans et stocker dans SQLite pour accès rapide (partition par année).
|
||||||
|
- **Gestion du fuseau et locales** : tester en CET/CEST pour prendre en compte DST. Éviter l’heure d’hiver/été mal appliquée.
|
||||||
|
- **Front-end** : colorer le calendrier (ex. style Gruvbox : orangé=Racine, vert=Feuille, violet=Fleur, jaune=Fruit, rouge discret=Nœud). Rendre responsive (mobile/tablette).
|
||||||
|
- **Documentation** : ajouter un README dans le dépôt GitHub, expliquer les conventions (zodiaque, type de jour) et référencer les sources.
|
||||||
|
- **Précision** : pour du calcul horaire fin, on pourrait itérer en minutes autour de l’heure approximative, mais pour le jardinage, le jour suffit.
|
||||||
|
|
||||||
|
## Références et lectures suggérées
|
||||||
|
- **Documentation Skyfield** – exemples de calcul d’angles et phases【14†L123-L131】.
|
||||||
|
- **Science et scepticisme** – Détecteur de rumeurs SciencePresse (2022) et SNHF (2020) concluent à l’absence d’effet mesurable de la Lune sur les plantes【5†L108-L116】【6†L373-L381】.
|
||||||
|
- **Guides en français** – Semencemag (2025) explique l’usage pratique (jours racine/feuille/fleur/fruit, nœuds, apogée, périgée)【24†L44-L53】【22†L66-L73】. Rustica/Gerbeaud publient chaque mois des calendriers lunaires détaillés (ex. Gerbeaud, semis en «jour feuille, lune montante»【7†L93-L101】).
|
||||||
|
- **Éphémérides officielles** – US Naval Observatory (phases et fraction illuminée)【10†L86-L94】, NASA HORIZONS, etc.
|
||||||
|
- **Recherche astronomique** – pour approfondir : littérature sur l’orbite lunaire, astronomie du calendrier, mais aussi le rapport SNHF «Jardiner avec la lune : mythe ou réalité» pour le contexte.
|
||||||
|
|
||||||
|
Ce document vise à guider à la fois les développeurs (algorithmes, code) et les jardiniers (règles pratiques). Il reste essentiel d’expérimenter et d’adapter les recommandations à son jardin : un bon sol, de l’eau et du soleil restent les facteurs clés du succès, plus que toute influence lunaire【6†L390-L394】【22†L75-L84】.
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Calendrier lunaire de jardinage – Guide complet
|
||||||
|
|
||||||
|
## Résumé exécutif
|
||||||
|
Le **calendrier lunaire** de jardinage exploite la position et le cycle de la Lune (phases, déclinaison, périgée/apogée, nœuds) pour rythmer semis, plantations et récoltes. Cette approche traditionnelle, popularisée depuis le b.a.-ba de l’agriculture biodynamique, associe chaque jour lunaire à un type de culture (racine/feuille/fleur/fruit) et tient compte de la Lune montante ou descendante. **Attention toutefois**: la science moderne ne confirme aucune influence directe significative de la Lune sur la croissance des plantes【6†L373-L381】【5†L108-L116】. Néanmoins, beaucoup de jardiniers l’utilisent comme repère complémentaire. Ce document explique les notions astronomiques (phases, illumination, déclinaison, signes zodiacaux, périgée/apogée, nœuds), leur calcul en Python, les règles de jardinage associées, ainsi que le fonctionnement du script fourni (algorithme, limites, sortie). Des exemples de configuration (JSON/CSV) et des conseils d’amélioration (CLI, API, base, front-end) sont détaillés, ainsi qu’une section sur les dictons français du jardinage et les « saints de glace ».
|
||||||
|
|
||||||
|
## Concepts astronomiques du calendrier lunaire
|
||||||
|
|
||||||
|
- **Phases lunaires** : La phase se définit par l’angle Lunaire-Solaire autour de la Terre. Concrètement, on calcule la différence de longitude écliptique entre la Lune et le Soleil【14†L123-L131】. Cette différence vaut 0° pour la Nouvelle Lune, 90° pour le Premier Quartier, 180° pour la Pleine Lune, 270° pour le Dernier Quartier (modulo 360°)【14†L123-L131】. En Python (Skyfield), on utilise `almanac.find_discrete(ts0,ts1, almanac.moon_phases(eph))` pour trouver les instants précis (UTC) de chaque phase.
|
||||||
|
|
||||||
|
- **Illumination de la Lune** : Le pourcentage du disque lunaire éclairé se calcule par la géométrie Soleil–Terre–Lune. Si θ est la séparation angulaire (en radians) entre la Lune et le Soleil vue de la Terre, alors la fraction illuminée = (1–cos θ)/2. En code, `sep = v_sun.separation_from(v_moon).radians; illum = (1 - math.cos(sep))/2`. Skyfield offre aussi `moon.fraction_illuminated(sun)`, mais la formule ci-dessus est équivalente. Le script compare l’illumination d’un jour au lendemain pour déterminer si la Lune croît ou décroît.
|
||||||
|
|
||||||
|
- **Lune montante / descendante** : On dit « Lune montante » si la déclinaison géocentrique de la Lune (angle par rapport à l’équateur céleste) augmente d’un jour sur l’autre. Sinon elle est « descendante ». Dans le script on calcule la déclinaison (`v_moon.radec()[1].degrees`) à midi un jour et le jour suivant. Exemple :
|
||||||
|
```python
|
||||||
|
dec = v_moon.radec()[1].degrees
|
||||||
|
dec2 = v_moon2.radec()[1].degrees
|
||||||
|
montante = dec2 >= dec # True si Lune "montante"
|
||||||
|
```
|
||||||
|
La lune montante est traditionnellement favorable aux travaux aériens (semis, récoltes), la descendante aux travaux racinaires (plantation, taille).
|
||||||
|
|
||||||
|
- **Signe zodiacal (longitude écliptique)** : La position de la Lune devant le zodiaque sert à définir le type de jour (racine/feuille/fleur/fruit). On calcule la longitude écliptique lunaire (0°–360°) via Skyfield (`v_moon.ecliptic_latlon()`). Le signe astrologique = int(longitude/30) (0=Bélier, 1=Taureau, …). Par convention :
|
||||||
|
- **Terre (Taureau, Vierge, Capricorne)** → *Jour Racine* (légumes-racines)【24†L44-L53】.
|
||||||
|
- **Eau (Cancer, Scorpion, Poissons)** → *Jour Feuille* (plantes feuillues)【24†L44-L53】.
|
||||||
|
- **Air (Gémeaux, Balance, Verseau)** → *Jour Fleur* (fleurs, choux-fleurs)【24†L50-L53】.
|
||||||
|
- **Feu (Bélier, Lion, Sagittaire)** → *Jour Fruit* (légumes-fruits)【24†L52-L55】.
|
||||||
|
Ces correspondances sont purement traditionnelles. Le script possède une table Python :
|
||||||
|
```python
|
||||||
|
SIGN_NAMES = ["Bélier","Taureau",…,"Poissons"]
|
||||||
|
SIGN_TO_TYPE = {
|
||||||
|
"Taureau":"Racine","Vierge":"Racine","Capricorne":"Racine",
|
||||||
|
"Cancer":"Feuille","Scorpion":"Feuille","Poissons":"Feuille",
|
||||||
|
"Gémeaux":"Fleur","Balance":"Fleur","Verseau":"Fleur",
|
||||||
|
"Bélier":"Fruit","Lion":"Fruit","Sagittaire":"Fruit"
|
||||||
|
}
|
||||||
|
signe = SIGN_NAMES[int(lon.degrees//30)]
|
||||||
|
type_jour = SIGN_TO_TYPE[signe]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Périgée / Apogée de la Lune** : L’orbite lunaire est elliptique. *Périgée* = point le plus proche de la Terre, *apogée* = point le plus éloigné【18†L108-L112】. Chaque lunaison comporte un périgée et un apogée. Skyfield n’a plus `almanac.moon_distance`, donc on calcule la distance Terre–Lune à midi chaque jour :
|
||||||
|
```python
|
||||||
|
dist = earth.at(ts.utc(date)).observe(moon).distance().km
|
||||||
|
```
|
||||||
|
On repère les minima locaux (périgée) et maxima locaux (apogée) dans la liste journalière. Ex.:
|
||||||
|
```python
|
||||||
|
distances = [earth.at(ts.utc(d.year,d.month,d.day,12,0,0)).observe(moon).distance().km for d in days]
|
||||||
|
# repérer indices i tels que dist[i] < dist[i±1] → périgée
|
||||||
|
```
|
||||||
|
Traditionnellement, on **évite de jardiner** durant ces jours (le périgée apporterait «trop d’énergie», l’apogée «ralentissement de croissance»)【24†L66-L73】.
|
||||||
|
|
||||||
|
- **Nœuds lunaires** : Ce sont les deux points où l’orbite de la Lune coupe l’écliptique【16†L155-L163】 (juste avant/après éclipses). On peut utiliser `almanac.moon_nodes(eph)` et `find_discrete` pour obtenir ces dates. Dans la pratique, les jours de nœuds sont considérés «perturbés» et déconseillés au jardinage【24†L66-L73】.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
dateFormat YYYY-MM-DD
|
||||||
|
title Phases lunaires (Mars 2026)
|
||||||
|
section Phases
|
||||||
|
Nouvelle lune : 2026-03-03, 1d
|
||||||
|
Premier quartier : 2026-03-10, 1d
|
||||||
|
Pleine lune : 2026-03-18, 1d
|
||||||
|
Dernier quartier : 2026-03-25, 1d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Liens vers les pratiques de jardinage
|
||||||
|
Les astronomes jardiniers ont formulé ces règles pratiques (purement empiriques)【7†L208-L214】【20†L24-L32】 :
|
||||||
|
- **Lune croissante** (montante) – *moment d’action au-dessus du sol* : semis de légumes-fruits, greffage, récolte. Exemple : on sème haricots/tomates le premier quartier【7†L208-L214】.
|
||||||
|
- **Lune décroissante** (descendante) – *moment d’action sur racines/sol* : plantations, repiquages, binage, taille. Ex.: planter pommes de terre en lune descendante【7†L208-L214】.
|
||||||
|
- **Jour “Racine”** (signe de Terre) : planter légumes-racines (carottes, betteraves)【24†L44-L53】.
|
||||||
|
- **Jour “Feuille”** (signe d’Eau) : semer feuilles et aromatiques (laitues, épinards)【24†L44-L53】.
|
||||||
|
- **Jour “Fleur”** (signe d’Air) : greffer et soigner fleurs/ornementales (brocolis, roses)【24†L50-L53】.
|
||||||
|
- **Jour “Fruit”** (signe de Feu) : semer/planter légumes-fruits (tomates, courgettes, arbres fruitiers)【24†L52-L55】.
|
||||||
|
|
||||||
|
Un tableau synthétique :
|
||||||
|
|
||||||
|
| **Phase / Jour lunaire** | **Action jardin** | **Exemple** |
|
||||||
|
|-------------------------------|-----------------------------------|-----------------------------------------|
|
||||||
|
| Croissante (Nouvelle→Pleine) | Développement aérien – semis/greffe| Semer tomates au premier quartier【7†L208-L214】 |
|
||||||
|
| Décroissante (Pleine→Nouvelle)| Consolidation – planter, tailler | Planter pommes de terre en lune descendante【7†L208-L214】 |
|
||||||
|
| Lune montante | Semis/engrais/ récoltes | Récolter herbes aromatiques【7†L208-L214】 |
|
||||||
|
| Lune descendante | Planter/tailler/travailler le sol | Repiquer laitues, tailler rosiers【7†L208-L214】 |
|
||||||
|
| Jour **Racine** (Terre) | Légumes-racines (oignons, navets) | Semer carottes (Lune en Taureau)【24†L44-L53】 |
|
||||||
|
| Jour **Feuille** (Eau) | Laitues, choux, épinards | Semer épinards (Lune en Cancer)【24†L44-L53】 |
|
||||||
|
| Jour **Fleur** (Air) | Fleurs, brocolis, vivaces | Planter choux-fleurs (Lune en Balance)【24†L50-L53】 |
|
||||||
|
| Jour **Fruit** (Feu) | Tomates, haricots, pois | Semer tomates (Lune en Lion)【24†L52-L55】 |
|
||||||
|
| Nœuds lunaires | *Éviter tout travail* | (période d’éclipse, jours “perturbés”)【24†L66-L73】 |
|
||||||
|
| Périgée / Apogée | *Éviter/attention* | Récoltes précoces, éviter tailes risquées【24†L66-L73】 |
|
||||||
|
|
||||||
|
En pratique, on imprime souvent un calendrier lunaire annuel (papier ou appli mobile) pour suivre ces repères【24†L54-L60】【22†L66-L73】. À titre d’exemple, voici la correspondance **signes zodiacaux → type de jour**, sous forme tabulaire :
|
||||||
|
|
||||||
|
| Signe zodiacal | Élément | Type de jour | Exemples de cultures |
|
||||||
|
|-----------------------------|---------|--------------|--------------------------------------|
|
||||||
|
| Bélier, Lion, Sagittaire | Feu | Fruit | Tomates, poivrons, arbres fruitiers |
|
||||||
|
| Taureau, Vierge, Capricorne | Terre | Racine | Carottes, pommes de terre, oignons |
|
||||||
|
| Gémeaux, Balance, Verseau | Air | Fleur | Fleurs, choux-fleurs, aromatiques |
|
||||||
|
| Cancer, Scorpion, Poissons | Eau | Feuille | Laitues, épinards, choux, salades |
|
||||||
|
|
||||||
|
Ces associations sont présentées par exemple dans Semencemag【24†L44-L53】.
|
||||||
|
|
||||||
|
## Le script Python : description technique
|
||||||
|
|
||||||
|
Le script `lunar_calendar.py` (Python 3.9+) génère un calendrier lunaire sur une période donnée. Points clés du fonctionnement :
|
||||||
|
|
||||||
|
- **Dépendances** : `skyfield` (pour l’astronomie), `pytz` (timezones), `numpy`. `de421.bsp` est téléchargé automatiquement (éphéméride NASA).
|
||||||
|
- **Période de calcul** : par défaut un an (Jan→Déc). On peut modifier `start` et `end` dans la section `__main__`.
|
||||||
|
- **Fuseau horaire** : Europe/Paris. On prend l’heure locale *midi* pour éviter les transitions de date, puis on convertit en UTC pour Skyfield :
|
||||||
|
```python
|
||||||
|
TZ = pytz.timezone("Europe/Paris")
|
||||||
|
local_noon = TZ.localize(datetime(year,month,day,12))
|
||||||
|
t = ts.utc(local_noon.astimezone(pytz.utc))
|
||||||
|
```
|
||||||
|
Ceci garantit que chaque date du calendrier correspond bien au jour solaire local.
|
||||||
|
|
||||||
|
- **Phases exactes** :
|
||||||
|
```python
|
||||||
|
f_phase = almanac.moon_phases(eph)
|
||||||
|
phase_times, phase_events = almanac.find_discrete(t0, t1, f_phase)
|
||||||
|
phase_by_day = {}
|
||||||
|
for t, ev in zip(phase_times, phase_events):
|
||||||
|
local_day = t.utc_datetime().astimezone(TZ).date()
|
||||||
|
phase_by_day[local_day] = ["Nouvelle Lune","Premier Quartier","Pleine Lune","Dernier Quartier"][int(ev)]
|
||||||
|
```
|
||||||
|
On récupère ainsi les jours (par date locale) où surviennent exactement la NL, PQ, PL, DQ. Ces étiquettes sont stockées dans `phase_by_day`.
|
||||||
|
|
||||||
|
- **Illumination et montante/descendante** : Pour chaque jour `d`, on calcule :
|
||||||
|
```python
|
||||||
|
e = earth.at(t) # position de la Terre à midi UTC
|
||||||
|
v_sun = e.observe(sun).apparent()
|
||||||
|
v_moon = e.observe(moon).apparent()
|
||||||
|
sep = v_sun.separation_from(v_moon).radians
|
||||||
|
illum = (1 - math.cos(sep)) / 2 # fraction (0..1) éclairée
|
||||||
|
```
|
||||||
|
Puis pour le lendemain, même calcul (`illum2`, `dec2`). On définit :
|
||||||
|
```python
|
||||||
|
croissante = "Croissante" if illum2 >= illum else "Décroissante"
|
||||||
|
dec = v_moon.radec()[1].degrees
|
||||||
|
dec2 = v_moon2.radec()[1].degrees
|
||||||
|
montante = "Montante" if dec2 >= dec else "Descendante"
|
||||||
|
```
|
||||||
|
C’est-à-dire la Lune est “montante” si sa déclinaison augmente.
|
||||||
|
|
||||||
|
- **Signe zodiacal → type de jour** : Toujours à midi, on récupère la longitude écliptique :
|
||||||
|
```python
|
||||||
|
lat, lon, dist = v_moon.ecliptic_latlon()
|
||||||
|
signe = SIGN_NAMES[int(lon.degrees // 30) % 12]
|
||||||
|
type_jour = SIGN_TO_TYPE[signe]
|
||||||
|
```
|
||||||
|
Ainsi on remplit `signe` (ex. “Taureau”) et `type_jour` (“Racine”, etc) pour chaque date.
|
||||||
|
|
||||||
|
- **Périgée/Apogée manuel** : Après avoir construit une liste quotidienne de distances (voir ci-dessus), on parcourt les valeurs : si `dist[i] < dist[i-1]` et `< dist[i+1]`, c’est un **périgée** (jour local minimal). Inversement pour un **apogée**. Ce repérage simple identifie un périgée et un apogée par lunaison. Exemple :
|
||||||
|
```python
|
||||||
|
if distances[i] < distances[i-1] and distances[i] < distances[i+1]:
|
||||||
|
perigee_days.add(all_days[i])
|
||||||
|
if distances[i] > distances[i-1] and distances[i] > distances[i+1]:
|
||||||
|
apogee_days.add(all_days[i])
|
||||||
|
```
|
||||||
|
Ces jours sont marqués dans l’export pour information.
|
||||||
|
|
||||||
|
- **Nœuds lunaires** : On utilise directement `almanac.moon_nodes(eph)` et `find_discrete` entre `t0` et `t1`. On convertit chaque instant en date locale pour obtenir `node_days`.
|
||||||
|
|
||||||
|
**Limitations et précision** :
|
||||||
|
- Le calcul se fait au pas d’un jour (midi). Il n’est donc pas d’une précision horaire au-delà du jour (assez pour un calendrier de plantation).
|
||||||
|
- Le passage entre heures d’été/hiver est géré par `pytz`.
|
||||||
|
- Les assignations *racine/feuille/fleur/fruit* reposent sur des conventions astrologiques. Elles sont cohérentes avec la littérature francophone (Semencemag【24†L44-L53】, Rustica, etc.) mais non scientifiques.
|
||||||
|
- Les algorithmes Skyfield sont précautionneusement utilisés pour donner des résultats très fiables sur plusieurs décennies.
|
||||||
|
|
||||||
|
## Installation et tests
|
||||||
|
|
||||||
|
1. **Python 3.9+** : Vérifier (`python3 --version`).
|
||||||
|
2. **Environnement virtuel** (optionnel mais recommandé) :
|
||||||
|
```bash
|
||||||
|
cd /path/to/projet
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate # prompt indique (.venv)
|
||||||
|
```
|
||||||
|
3. **Installer les dépendances** :
|
||||||
|
```bash
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install skyfield numpy pytz
|
||||||
|
```
|
||||||
|
Vérifier : `pip list` doit lister `skyfield`, `numpy`, `pytz`.
|
||||||
|
4. **Lancer le script** (`lunar_calendar.py`) :
|
||||||
|
```bash
|
||||||
|
python lunar_calendar.py
|
||||||
|
```
|
||||||
|
– Au premier lancement, `skyfield` télécharge automatiquement `de421.bsp`.
|
||||||
|
– Un message “Calendrier lunaire généré” doit s’afficher. Le fichier `calendrier_lunaire_2026.json` (ou défini dans le script) est créé.
|
||||||
|
5. **Vérifier le résultat** :
|
||||||
|
```bash
|
||||||
|
head -n 5 calendrier_lunaire_2026.json
|
||||||
|
```
|
||||||
|
ou
|
||||||
|
```bash
|
||||||
|
cat calendrier_lunaire_2026.json | jq .
|
||||||
|
```
|
||||||
|
Exemple de ligne JSON produite :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-03-14",
|
||||||
|
"phase": "",
|
||||||
|
"illumination": 67.34,
|
||||||
|
"croissante_decroissante": "Croissante",
|
||||||
|
"montante_descendante": "Montante",
|
||||||
|
"signe": "Taureau",
|
||||||
|
"type_jour": "Racine",
|
||||||
|
"perigee": false,
|
||||||
|
"apogee": false,
|
||||||
|
"noeud_lunaire": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Cette ligne indique qu’au 14/03/2026, la Lune est en *Taureau* (Jour Racine), croissante et montante, sans phase particulière ni événement spécial.
|
||||||
|
|
||||||
|
**Commandes utiles** :
|
||||||
|
- Tester l’import Skyfield : `python -c "from skyfield.api import load; print('Skyfield OK')"`
|
||||||
|
- Debug : ajouter `print` pour les valeurs (illumination, décli, etc.) si nécessaire.
|
||||||
|
|
||||||
|
## Exemples d’usage et sorties
|
||||||
|
|
||||||
|
Le script génère un **JSON** (tableau d’objets quotidiens). On peut facilement adapter pour un **CSV**. Par exemple, le module Python `csv` est prêt à l’emploi (démontré dans le code source). Les champs exportés sont : date, phase, « croissante/décroissante », « montante/descendante », signe, type de jour, booleans périgée/apogée/nœud.
|
||||||
|
|
||||||
|
Un exemple de format CSV (séparateur `;`) :
|
||||||
|
```csv
|
||||||
|
date;phase_exacte;croissante_décroissante;montante_descendante;signe;type_jour;perigee;apogee;noeud
|
||||||
|
2026-03-14;;Croissante;Montante;Taureau;Racine;0;0;0
|
||||||
|
2026-03-15;Dernier Quartier;Décroissante;Descendante;Bélier;Fruit;0;0;0
|
||||||
|
...
|
||||||
|
```
|
||||||
|
Ce fichier peut être importé en base de données (SQLite) ou servi via une API (FastAPI) pour alimenter une interface web/mobile.
|
||||||
|
|
||||||
|
## Améliorations possibles et pièges à éviter
|
||||||
|
|
||||||
|
- **Arguments en ligne de commande** : utiliser `argparse` pour accepter `--start`, `--end`, `--format` (JSON/CSV).
|
||||||
|
- **FastAPI ou Flask** : créer un endpoint `/api/lune/{year}` qui lit le JSON pré-calculé ou exécute dynamiquement le calcul. Attention à la latence du calcul si fait à la volée (mieux pré-calculer).
|
||||||
|
- **Base de données** : pré-calculer plusieurs années (5–10 ans) et stocker en SQLite avec une table indexée sur date. Permet d’interroger rapidement pour une date donnée.
|
||||||
|
- **Timezones** : toujours utiliser `pytz` et `ASTimezone` pour éviter les décalages DST erronés. Tester en hiver/été.
|
||||||
|
- **Précision** : le calcul d’événements précis (phase à l’heure près) est assuré par Skyfield. Pour le quotidien, on s’en tient au repère “jour où l’événement tombe (UTC→local)”.
|
||||||
|
- **Interface graphique** : ajouter un calendrier réactif (HTML/CSS/JS) coloré par type de jour (ex. : orange racine, vert feuille, violet fleur, jaune fruit), marquer les événements spéciaux (noeuds en rouge discret, périgée/apogée en gris). Gruvbox ou autre thème sombre/contrasté pour développeurs.
|
||||||
|
- **Stockage des données** : suggestions JSON/CSV ci-dessus, ou génération de JSON à partir de SQLite. Ex:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE lune (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
phase TEXT, lumiere REAL,
|
||||||
|
croiss_dec TEXT, mont_dec TEXT,
|
||||||
|
signe TEXT, type_jour TEXT,
|
||||||
|
perigee INTEGER, apogee INTEGER, noeud INTEGER
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- **Documentation** : ajouter des tests unitaires, du logging, et un README (vous êtes ici !).
|
||||||
|
|
||||||
|
## Dictons et proverbes populaires du jardinage
|
||||||
|
|
||||||
|
La tradition française regorge de **dictons et proverbes** relatifs aux saisons et au jardinage. En voici quelques exemples :
|
||||||
|
|
||||||
|
- « À chaque plante son temps, à chaque temps sa plante » – on plante/sème selon la saison appropriée【32†L118-L121】.
|
||||||
|
- « Tel est le jardinier, tel est le jardin » – l’état du potager reflète les soins du jardinier【32†L105-L109】.
|
||||||
|
- « En avril, ne te découvre pas d’un fil » – prudence contre les dernières gelées tardives.
|
||||||
|
- « Jamais trop tôt pour semer, jamais trop tard pour récolter » – planter semis précoces et récolter tardivement.
|
||||||
|
- « La patience est la mère des jardiniers » – la réussite vient avec l’observation et le temps【32†L158-L161】.
|
||||||
|
|
||||||
|
Ces dictons reflètent l’observation empirique. Aucune librairie Python spécifique aux proverbes français n’est connue. On peut les stocker dans un fichier JSON ou CSV pour usage interne. Par exemple, un format JSON possible :
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dicton": "En avril, ne te découvre pas d'un fil",
|
||||||
|
"signification": "Ne pas ôter les protections trop tôt car les gelées peuvent revenir tardivement.",
|
||||||
|
"source": "Proverbe populaire"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dicton": "À chaque plante son temps, à chaque temps sa plante",
|
||||||
|
"signification": "Chaque semis/plantation doit se faire en fonction de la saison appropriée.",
|
||||||
|
"source": "Santamaria Motoculture【32†L118-L121】"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
On ajoutera «source» ou «conseil associé» selon besoins. Si besoin de proverbes automatiques, on utilisera plutôt une API publique de citations (ex. «Proverbes français» non automatique) plutôt qu’une librairie locale.
|
||||||
|
|
||||||
|
## Calendrier des saints de glace (France)
|
||||||
|
|
||||||
|
En France, de nombreux dictons s’appuient sur le **calendrier des saints**. Les plus célèbres pour le jardinage sont les **Saints de Glace** (traditionnellement 11, 12, 13 mai – Mamert, Pancrace, Servais) et les saints qui les prolongent (Yves 19/5, Urbain 25/5). Ces dates marquent la fin présumée des gelées printanières. Exemples de dictons associés【39†L155-L164】【42†L209-L212】 :
|
||||||
|
|
||||||
|
- **11 mai (St Mamert), 12 mai (St Pancrace), 13 mai (St Servais)** : *« Avant Saint-Servais, point d’été ; après Saint-Servais, plus de gelée. »*【39†L155-L164】 conseille d’attendre la mi-mai.
|
||||||
|
- **Saint-Urbain (25 mai)** : *« Quand la Saint-Urbain est passée, le vigneron est rassuré. »*【42†L209-L212】 (fin définitive du risque de gel).
|
||||||
|
- Variante : *« Mamert, Pancrace, Servais sont trois saints de glace, mais Saint-Urbain les tient tous dans sa main. »*【42†L209-L212】.
|
||||||
|
- **Saint-Pancrace (12/5), St-Servais (13/5), St-Boniface (14/5)** : *« Saints Pancrace, Servais et Boniface apportent souvent de la glace. »*【42†L209-L212】.
|
||||||
|
|
||||||
|
Le *calendrier des saints* est large : on trouve par région d’autres saints réputés «glaçants» en avril (Georges 23/4, Marc 25/4, etc.). Mais pour la France métropolitaine, c’est la période mi-mai qui domine ces dictons. En résumé : mieux vaut repousser l’installation des cultures sensibles au froid (tomates, etc.) jusqu’à fin mai【39†L155-L164】【40†L81-L88】.
|
||||||
|
|
||||||
|
## Références et lectures suggérées
|
||||||
|
|
||||||
|
- **Skyfield API** – Exemples de calculs astronomiques (phases, positions)【14†L123-L131】.
|
||||||
|
- **Documentation SO/USNO** – Éphémérides officielles pour la Lune (phases, illumination)【18†L108-L112】.
|
||||||
|
- **Journaux et blogs FR** – Articles de vulgarisation : Semencemag (juin 2025) sur l’usage du calendrier lunaire【24†L44-L53】, Rustica, Gerbeaud.
|
||||||
|
- **Sources historiques** – Dictons et fêtes des saints : «Les saints de glace» sur le Potager Permacole【42†L209-L212】, revue Science et Vie (SNHF) pour le scepticisme scientifique【6†L373-L381】.
|
||||||
|
- **Ressources additionnelles** : RFC et documentation FastAPI, tutoriels Skyfield (rhodesmill.org), bases de données open (p. ex. base de dictons BotAccess).
|
||||||
|
|
||||||
|
Ce README est prêt à être sauvegardé comme document `README.md`. Il offre un point de départ complet pour un projet de **webapp jardinage** incorporant un calendrier lunaire.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install skyfield numpy pytz
|
||||||
|
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, asdict, field
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import math
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from skyfield.api import load, wgs84, load_constellation_map
|
||||||
|
from skyfield import almanac
|
||||||
|
|
||||||
|
TZ = pytz.timezone("Europe/Paris")
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
LATITUDE = 48.8566
|
||||||
|
LONGITUDE = 2.3522
|
||||||
|
|
||||||
|
# --- Mapping "jour racine/feuille/fleur/fruit" ---
|
||||||
|
# We align with a sidereal approach using the Moon's constellation.
|
||||||
|
CONSTELLATION_TO_SIGN = {
|
||||||
|
"Ari": "Bélier",
|
||||||
|
"Tau": "Taureau",
|
||||||
|
"Gem": "Gémeaux",
|
||||||
|
"Cnc": "Cancer",
|
||||||
|
"Leo": "Lion",
|
||||||
|
"Vir": "Vierge",
|
||||||
|
"Lib": "Balance",
|
||||||
|
"Sco": "Scorpion",
|
||||||
|
"Sgr": "Sagittaire",
|
||||||
|
"Cap": "Capricorne",
|
||||||
|
"Aqr": "Verseau",
|
||||||
|
"Psc": "Poissons",
|
||||||
|
# The Moon can cross Ophiuchus in official IAU boundaries.
|
||||||
|
# We map it to Scorpion for gardening day continuity.
|
||||||
|
"Oph": "Scorpion",
|
||||||
|
}
|
||||||
|
|
||||||
|
SIGN_TO_TYPE = {
|
||||||
|
"Taureau": "Racine", "Vierge": "Racine", "Capricorne": "Racine",
|
||||||
|
"Cancer": "Feuille", "Scorpion": "Feuille", "Poissons": "Feuille",
|
||||||
|
"Gémeaux": "Fleur", "Balance": "Fleur", "Verseau": "Fleur",
|
||||||
|
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DayInfo:
|
||||||
|
date: str
|
||||||
|
phase: str
|
||||||
|
illumination: float
|
||||||
|
croissante_decroissante: str
|
||||||
|
montante_descendante: str
|
||||||
|
signe: str
|
||||||
|
type_jour: str
|
||||||
|
soleil_lever: str
|
||||||
|
soleil_coucher: str
|
||||||
|
duree_jour: str
|
||||||
|
lune_lever: str
|
||||||
|
lune_coucher: str
|
||||||
|
duree_presence_lune: str
|
||||||
|
saint_du_jour: str
|
||||||
|
saint_de_glace: bool
|
||||||
|
perigee: bool
|
||||||
|
apogee: bool
|
||||||
|
noeud_lunaire: bool
|
||||||
|
transitions_type_jour: list[dict[str, str]] = field(default_factory=list)
|
||||||
|
transitions_montante_descendante: list[dict[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _zodiac_sign_from_constellation(constellation_at, position) -> str:
|
||||||
|
abbr = constellation_at(position)
|
||||||
|
return CONSTELLATION_TO_SIGN.get(abbr, "Scorpion")
|
||||||
|
|
||||||
|
|
||||||
|
def _local_noon(d: date) -> datetime:
|
||||||
|
return TZ.localize(datetime(d.year, d.month, d.day, 12, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _default_saints_france() -> dict[str, str]:
|
||||||
|
# Core gardening references in France; full calendar can be provided via saints_france.json.
|
||||||
|
return {
|
||||||
|
"04-23": "Saint Georges",
|
||||||
|
"04-25": "Saint Marc",
|
||||||
|
"05-11": "Saint Mamert",
|
||||||
|
"05-12": "Saint Pancrace",
|
||||||
|
"05-13": "Saint Servais",
|
||||||
|
"05-14": "Saint Boniface",
|
||||||
|
"05-19": "Saint Yves",
|
||||||
|
"05-25": "Saint Urbain",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_saints_france() -> dict[str, str]:
|
||||||
|
path = SCRIPT_DIR / "saints_dictons" / "saints_france.json"
|
||||||
|
if not path.exists():
|
||||||
|
return _default_saints_france()
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
saints: dict[str, str] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(key, str) and isinstance(value, str):
|
||||||
|
saints[key] = value.strip()
|
||||||
|
return saints
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_perigee_apogee_days(ts, earth, moon, start: date, end: date) -> tuple[set[date], set[date]]:
|
||||||
|
# Hourly sampling + one-day padding on each side gives stable local extrema detection.
|
||||||
|
sample_start = datetime.combine(start - timedelta(days=1), datetime.min.time())
|
||||||
|
sample_end = datetime.combine(end + timedelta(days=1), datetime.max.time().replace(microsecond=0))
|
||||||
|
|
||||||
|
samples: list[tuple[date, float]] = []
|
||||||
|
current = TZ.localize(sample_start)
|
||||||
|
end_local = TZ.localize(sample_end)
|
||||||
|
step = timedelta(hours=1)
|
||||||
|
|
||||||
|
while current <= end_local:
|
||||||
|
t = ts.utc(current.astimezone(pytz.utc))
|
||||||
|
dist_km = earth.at(t).observe(moon).distance().km
|
||||||
|
samples.append((current.date(), dist_km))
|
||||||
|
current += step
|
||||||
|
|
||||||
|
perigee_days: set[date] = set()
|
||||||
|
apogee_days: set[date] = set()
|
||||||
|
|
||||||
|
for i in range(1, len(samples) - 1):
|
||||||
|
day, dist = samples[i]
|
||||||
|
if not (start <= day <= end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev_dist = samples[i - 1][1]
|
||||||
|
next_dist = samples[i + 1][1]
|
||||||
|
if dist < prev_dist and dist < next_dist:
|
||||||
|
perigee_days.add(day)
|
||||||
|
if dist > prev_dist and dist > next_dist:
|
||||||
|
apogee_days.add(day)
|
||||||
|
|
||||||
|
return perigee_days, apogee_days
|
||||||
|
|
||||||
|
|
||||||
|
def _to_local_dt(t) -> datetime:
|
||||||
|
return t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_first_event_within_window(
|
||||||
|
ts,
|
||||||
|
observer,
|
||||||
|
target,
|
||||||
|
start_local: datetime,
|
||||||
|
end_local: datetime,
|
||||||
|
event_kind: str,
|
||||||
|
) -> tuple[datetime | None, int | None]:
|
||||||
|
if event_kind == "rise":
|
||||||
|
event_func = almanac.find_risings
|
||||||
|
else:
|
||||||
|
event_func = almanac.find_settings
|
||||||
|
|
||||||
|
t0 = ts.utc(start_local.astimezone(pytz.utc))
|
||||||
|
t1 = ts.utc(end_local.astimezone(pytz.utc))
|
||||||
|
times, flags = event_func(observer, target, t0, t1)
|
||||||
|
|
||||||
|
for t, ok in zip(times, flags):
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
dt_local = _to_local_dt(t)
|
||||||
|
if start_local <= dt_local < end_local:
|
||||||
|
day_offset = (dt_local.date() - start_local.date()).days
|
||||||
|
return dt_local, day_offset
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(dt_local: datetime | None, day_offset: int | None) -> str:
|
||||||
|
if dt_local is None:
|
||||||
|
return ""
|
||||||
|
base = dt_local.strftime("%H:%M")
|
||||||
|
if day_offset and day_offset > 0:
|
||||||
|
return f"{base} (+{day_offset}j)"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(start_dt: datetime | None, end_dt: datetime | None) -> str:
|
||||||
|
if start_dt is None or end_dt is None:
|
||||||
|
return ""
|
||||||
|
delta = end_dt - start_dt
|
||||||
|
if delta.total_seconds() < 0:
|
||||||
|
return ""
|
||||||
|
total_minutes = int(round(delta.total_seconds() / 60))
|
||||||
|
hours, minutes = divmod(total_minutes, 60)
|
||||||
|
return f"{hours:02d}h{minutes:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _moon_type_jour_at(ts, earth, moon, constellation_at, local_dt: datetime) -> str:
|
||||||
|
t = ts.utc(local_dt.astimezone(pytz.utc))
|
||||||
|
v_moon = earth.at(t).observe(moon).apparent()
|
||||||
|
signe = _zodiac_sign_from_constellation(constellation_at, v_moon)
|
||||||
|
return SIGN_TO_TYPE[signe]
|
||||||
|
|
||||||
|
|
||||||
|
def _moon_montante_descendante_at(ts, earth, moon, local_dt: datetime) -> str:
|
||||||
|
t = ts.utc(local_dt.astimezone(pytz.utc))
|
||||||
|
t2 = ts.utc((local_dt + timedelta(minutes=30)).astimezone(pytz.utc))
|
||||||
|
v_moon = earth.at(t).observe(moon).apparent()
|
||||||
|
v_moon2 = earth.at(t2).observe(moon).apparent()
|
||||||
|
dec = v_moon.radec()[1].degrees
|
||||||
|
dec2 = v_moon2.radec()[1].degrees
|
||||||
|
return "Montante" if dec2 >= dec else "Descendante"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_transition_time(
|
||||||
|
value_at,
|
||||||
|
left_dt: datetime,
|
||||||
|
right_dt: datetime,
|
||||||
|
left_value: str,
|
||||||
|
) -> datetime:
|
||||||
|
# Binary search at minute precision for the first instant where value changes.
|
||||||
|
while (right_dt - left_dt) > timedelta(minutes=1):
|
||||||
|
mid = left_dt + (right_dt - left_dt) / 2
|
||||||
|
if value_at(mid) == left_value:
|
||||||
|
left_dt = mid
|
||||||
|
else:
|
||||||
|
right_dt = mid
|
||||||
|
return right_dt.replace(second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_daily_transitions(
|
||||||
|
value_at,
|
||||||
|
day_start: datetime,
|
||||||
|
day_end: datetime,
|
||||||
|
step_minutes: int = 20,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
transitions: list[dict[str, str]] = []
|
||||||
|
step = timedelta(minutes=step_minutes)
|
||||||
|
|
||||||
|
t = day_start
|
||||||
|
current_value = value_at(t)
|
||||||
|
|
||||||
|
while t < day_end:
|
||||||
|
probe = min(t + step, day_end)
|
||||||
|
probe_value = value_at(probe)
|
||||||
|
if probe_value != current_value:
|
||||||
|
transition_dt = _find_transition_time(value_at, t, probe, current_value)
|
||||||
|
transitions.append(
|
||||||
|
{
|
||||||
|
"heure": transition_dt.strftime("%H:%M"),
|
||||||
|
"avant": current_value,
|
||||||
|
"apres": probe_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current_value = probe_value
|
||||||
|
t = probe
|
||||||
|
|
||||||
|
return transitions
|
||||||
|
|
||||||
|
|
||||||
|
def build_calendar(start: date, end: date) -> list[DayInfo]:
|
||||||
|
if end < start:
|
||||||
|
raise ValueError(f"Invalid date range: start ({start}) is after end ({end}).")
|
||||||
|
|
||||||
|
ts = load.timescale()
|
||||||
|
eph = load("de421.bsp")
|
||||||
|
constellation_at = load_constellation_map()
|
||||||
|
saints_by_mmdd = _load_saints_france()
|
||||||
|
saints_de_glace = {"05-11", "05-12", "05-13", "05-14", "05-25"}
|
||||||
|
|
||||||
|
earth, moon, sun = eph["earth"], eph["moon"], eph["sun"]
|
||||||
|
observer = earth + wgs84.latlon(LATITUDE, LONGITUDE)
|
||||||
|
|
||||||
|
t0 = ts.utc(start.year, start.month, start.day)
|
||||||
|
t1 = ts.utc(end.year, end.month, end.day + 1)
|
||||||
|
|
||||||
|
# --- Phases exactes ---
|
||||||
|
f_phase = almanac.moon_phases(eph)
|
||||||
|
phase_times, phase_events = almanac.find_discrete(t0, t1, f_phase)
|
||||||
|
|
||||||
|
phase_by_day: dict[date, str] = {}
|
||||||
|
for t, ev in zip(phase_times, phase_events):
|
||||||
|
local_day = t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
|
||||||
|
phase_by_day[local_day] = ["Nouvelle Lune", "Premier Quartier",
|
||||||
|
"Pleine Lune", "Dernier Quartier"][int(ev)]
|
||||||
|
|
||||||
|
# --- Nœuds lunaires (instants) ---
|
||||||
|
f_nodes = almanac.moon_nodes(eph)
|
||||||
|
node_times, _ = almanac.find_discrete(t0, t1, f_nodes)
|
||||||
|
|
||||||
|
node_days: set[date] = set()
|
||||||
|
for t in node_times:
|
||||||
|
local_day = t.utc_datetime().replace(tzinfo=pytz.utc).astimezone(TZ).date()
|
||||||
|
node_days.add(local_day)
|
||||||
|
|
||||||
|
# --- Périgée / apogée : calcul manuel via distance Terre->Lune (min/max locaux) ---
|
||||||
|
perigee_days, apogee_days = _compute_perigee_apogee_days(ts, earth, moon, start, end)
|
||||||
|
|
||||||
|
# --- Boucle jour par jour ---
|
||||||
|
result: list[DayInfo] = []
|
||||||
|
d = start
|
||||||
|
|
||||||
|
while d <= end:
|
||||||
|
# midi local : stabilise signe du jour + évite bascules UTC
|
||||||
|
local_noon = _local_noon(d)
|
||||||
|
local_day_start = TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
|
||||||
|
local_day_end = local_day_start + timedelta(days=1)
|
||||||
|
local_moon_window_end = local_day_start + timedelta(days=2)
|
||||||
|
t = ts.utc(local_noon.astimezone(pytz.utc))
|
||||||
|
|
||||||
|
e = earth.at(t)
|
||||||
|
v_sun = e.observe(sun).apparent()
|
||||||
|
v_moon = e.observe(moon).apparent()
|
||||||
|
|
||||||
|
# illumination (0..1) via séparation soleil-lune
|
||||||
|
sep = v_sun.separation_from(v_moon).radians
|
||||||
|
illum = (1 - math.cos(sep)) / 2
|
||||||
|
|
||||||
|
# lendemain (pour croissante/décroissante + montante/descendante)
|
||||||
|
d2 = d + timedelta(days=1)
|
||||||
|
local_noon2 = _local_noon(d2)
|
||||||
|
t2 = ts.utc(local_noon2.astimezone(pytz.utc))
|
||||||
|
|
||||||
|
e2 = earth.at(t2)
|
||||||
|
v_sun2 = e2.observe(sun).apparent()
|
||||||
|
v_moon2 = e2.observe(moon).apparent()
|
||||||
|
|
||||||
|
sep2 = v_sun2.separation_from(v_moon2).radians
|
||||||
|
illum2 = (1 - math.cos(sep2)) / 2
|
||||||
|
|
||||||
|
croissante = "Croissante" if illum2 >= illum else "Décroissante"
|
||||||
|
|
||||||
|
dec = v_moon.radec()[1].degrees
|
||||||
|
dec2 = v_moon2.radec()[1].degrees
|
||||||
|
montante = "Montante" if dec2 >= dec else "Descendante"
|
||||||
|
|
||||||
|
# sidereal sign via Moon constellation
|
||||||
|
signe = _zodiac_sign_from_constellation(constellation_at, v_moon)
|
||||||
|
type_jour = SIGN_TO_TYPE[signe]
|
||||||
|
mmdd = f"{d.month:02d}-{d.day:02d}"
|
||||||
|
|
||||||
|
sun_rise_dt, sun_rise_offset = _pick_first_event_within_window(
|
||||||
|
ts, observer, sun, local_day_start, local_day_end, "rise"
|
||||||
|
)
|
||||||
|
sun_set_dt, sun_set_offset = _pick_first_event_within_window(
|
||||||
|
ts, observer, sun, local_day_start, local_day_end, "set"
|
||||||
|
)
|
||||||
|
moon_rise_dt, moon_rise_offset = _pick_first_event_within_window(
|
||||||
|
ts, observer, moon, local_day_start, local_moon_window_end, "rise"
|
||||||
|
)
|
||||||
|
moon_set_dt, moon_set_offset = _pick_first_event_within_window(
|
||||||
|
ts, observer, moon, local_day_start, local_moon_window_end, "set"
|
||||||
|
)
|
||||||
|
transitions_type_jour = _compute_daily_transitions(
|
||||||
|
lambda dt: _moon_type_jour_at(ts, earth, moon, constellation_at, dt),
|
||||||
|
local_day_start,
|
||||||
|
local_day_end,
|
||||||
|
)
|
||||||
|
transitions_montante_descendante = _compute_daily_transitions(
|
||||||
|
lambda dt: _moon_montante_descendante_at(ts, earth, moon, dt),
|
||||||
|
local_day_start,
|
||||||
|
local_day_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(DayInfo(
|
||||||
|
date=d.isoformat(),
|
||||||
|
phase=phase_by_day.get(d, ""),
|
||||||
|
illumination=round(illum * 100.0, 2), # %
|
||||||
|
croissante_decroissante=croissante,
|
||||||
|
montante_descendante=montante,
|
||||||
|
signe=signe,
|
||||||
|
type_jour=type_jour,
|
||||||
|
soleil_lever=_format_time(sun_rise_dt, sun_rise_offset),
|
||||||
|
soleil_coucher=_format_time(sun_set_dt, sun_set_offset),
|
||||||
|
duree_jour=_format_duration(sun_rise_dt, sun_set_dt),
|
||||||
|
lune_lever=_format_time(moon_rise_dt, moon_rise_offset),
|
||||||
|
lune_coucher=_format_time(moon_set_dt, moon_set_offset),
|
||||||
|
duree_presence_lune=_format_duration(moon_rise_dt, moon_set_dt),
|
||||||
|
transitions_type_jour=transitions_type_jour,
|
||||||
|
transitions_montante_descendante=transitions_montante_descendante,
|
||||||
|
saint_du_jour=saints_by_mmdd.get(mmdd, ""),
|
||||||
|
saint_de_glace=(mmdd in saints_de_glace),
|
||||||
|
perigee=(d in perigee_days),
|
||||||
|
apogee=(d in apogee_days),
|
||||||
|
noeud_lunaire=(d in node_days),
|
||||||
|
))
|
||||||
|
|
||||||
|
d += timedelta(days=1)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
data = build_calendar(date(2026, 1, 1), date(2026, 12, 31))
|
||||||
|
out_path = Path(__file__).with_name("calendrier_lunaire_2026.json")
|
||||||
|
|
||||||
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump([asdict(x) for x in data], f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"Calendrier lunaire généré : {out_path}")
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
Voici des éléments structurés essentiels pour écrire un tutoriel de scraping Python à partir du site saint-dicton.com, en particulier pour la page d’un jour précis (par exemple https://www.saint-dicton.com/0222.html) :
|
||||||
|
|
||||||
|
📌 Structure observée du site Saint-Dicton
|
||||||
|
|
||||||
|
Un exemple de page date contient :
|
||||||
|
|
||||||
|
Liste des saints fêtés ce jour
|
||||||
|
Exemples : “St-Sulpice Sévère”, “St-Valère”, etc.
|
||||||
|
Ce bloc est présenté sous forme de texte HTML listé en paragraphes ou sections.
|
||||||
|
|
||||||
|
Phase de la lune
|
||||||
|
Peut être présente mais n’est pas structurée pour le scraping des saints.
|
||||||
|
|
||||||
|
Dicton du jour
|
||||||
|
Un bloc intitulé “## Dicton du jour” suivi d’un ou plusieurs dictons.
|
||||||
|
|
||||||
|
Prénoms à fêter
|
||||||
|
Peut contenir une liste ou être vide.
|
||||||
|
|
||||||
|
🧠 Analyse de la pagination annuelle
|
||||||
|
|
||||||
|
Le site propose également une navigation jour par jour :
|
||||||
|
|
||||||
|
L’URL pour une date donnée prend la forme https://www.saint-dicton.com/MMDD.html où MM est le mois sur deux chiffres et DD le jour sur deux chiffres (par exemple 0222 pour 22 février).
|
||||||
|
|
||||||
|
Il existe une page par mois avec tous les saints par ordre alphabétique (ex. SaintsA.html).
|
||||||
|
|
||||||
|
Ceci permet de parcourir toutes les dates de l’année systématiquement.
|
||||||
|
|
||||||
|
📘 Sources externes utiles
|
||||||
|
|
||||||
|
Pour enrichir ton scraping :
|
||||||
|
|
||||||
|
Nominis propose une base de plusieurs milliers de saints et fêtes liturgiques (utile pour comparer).
|
||||||
|
|
||||||
|
Des sites comme Éphéméride ou Wiki des proverbes donnent des dictons et proverbes météo/agricoles si besoin de compléter la base. (source non spécifique, généraliste).
|
||||||
|
|
||||||
|
🧪 Extrait de page (exemple réel)
|
||||||
|
|
||||||
|
Dans la page 0222.html, on observe :
|
||||||
|
|
||||||
|
## Dicton du jour
|
||||||
|
|
||||||
|
S'il gèle à la Saint-Sulpice,
|
||||||
|
Le printemps sera propice
|
||||||
|
|
||||||
|
Ici :
|
||||||
|
|
||||||
|
“Dicton du jour” est un titre h2
|
||||||
|
|
||||||
|
Le dicton est une ligne ou plusieurs lignes de texte juste après ce titre.
|
||||||
|
|
||||||
|
📌 Tutoriel ciblé pour écrire scrap_saint.md
|
||||||
|
|
||||||
|
Dans ce fichier .md, tu pourras expliquer :
|
||||||
|
|
||||||
|
Objectif du scraping
|
||||||
|
Extraire pour chaque date :
|
||||||
|
|
||||||
|
date (MM-DD)
|
||||||
|
|
||||||
|
nom du saint (ou saints)
|
||||||
|
|
||||||
|
dictons du jour
|
||||||
|
|
||||||
|
éventuellement prénoms associés
|
||||||
|
|
||||||
|
Analyse HTML du site
|
||||||
|
|
||||||
|
Les pages individuelles des dates
|
||||||
|
|
||||||
|
Les sections à scraper
|
||||||
|
|
||||||
|
Comment repérer les titres HTML des dictons
|
||||||
|
|
||||||
|
Étapes du script Python
|
||||||
|
|
||||||
|
itérer sur toutes les dates de l’année
|
||||||
|
|
||||||
|
fetch des pages MMDD.html
|
||||||
|
|
||||||
|
parser l’HTML avec BeautifulSoup
|
||||||
|
|
||||||
|
extraire listes de saints
|
||||||
|
|
||||||
|
extraire bloc dictons
|
||||||
|
|
||||||
|
stocker dans une structure CSV ou JSON
|
||||||
|
|
||||||
|
Exemple de structure de sortie
|
||||||
|
|
||||||
|
{
|
||||||
|
"date": "0222",
|
||||||
|
"saints": ["St-Sulpice Sévère", "St-Valère"],
|
||||||
|
"dictons": ["S'il gèle à la Saint-Sulpice, …"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Conseils pour éviter les pièges
|
||||||
|
|
||||||
|
certaines dates ont plusieurs dictons
|
||||||
|
|
||||||
|
structure HTML parfois variable
|
||||||
|
|
||||||
|
gérer les accents et encodage UTF-8
|
||||||
|
|
||||||
|
Si tu veux, je peux te générer directement le contenu complet de ce fichier scrap_saint.md avec un script Python détaillé (requests + BeautifulSoup) prêt à copier.
|
||||||
|
Dis-moi si tu veux aussi inclure le scraping du contenu de la page alphabétique SaintsA.html pour récupérer la liste complète des noms de saints.
|
||||||
|
|
||||||
|
genere scrap.md
|
||||||
|
|
||||||
|
Voici un tutoriel complet scrap_saint.md pour t’aider à scraper les pages de saints et leurs dictons à partir du site https://www.saint-dicton.com/0222.html
|
||||||
|
et des pages annuelles correspondantes. Ce fichier explique l’analyse de la structure, un exemple concret de script Python, et des conseils pour boucler sur toute l’année.
|
||||||
|
|
||||||
|
# scrap_saint.md
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ce tutoriel explique comment *scraper automatiquement* les informations suivantes pour chaque jour de l’année à partir du site **saint-dicton.com** :
|
||||||
|
|
||||||
|
- la **date** format MMDD (ex : *0222*)
|
||||||
|
- le(s) **nom(s) du saint** fêté(s)
|
||||||
|
- le ou les **dictons associés au jour**
|
||||||
|
|
||||||
|
L’objectif est de générer une base de données annuelle (CSV/JSON) des saints et des dictons, utilisable ensuite dans un projet Python par exemple.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse de la structure du site
|
||||||
|
|
||||||
|
Le site propose une **page par date** selon le format d’URL :
|
||||||
|
|
||||||
|
|
||||||
|
https://www.saint-dicton.com/MMDD.html
|
||||||
|
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- https://www.saint-dicton.com/0222.html → *Saint-Sulpice Sévère* + dicton du jour :contentReference[oaicite:0]{index=0}
|
||||||
|
- https://www.saint-dicton.com/0208.html → *dicton de la Saint-Jean* :contentReference[oaicite:1]{index=1}
|
||||||
|
|
||||||
|
Chaque page contient typiquement :
|
||||||
|
|
||||||
|
1. **Fêtes du jour**
|
||||||
|
Une section listant un ou plusieurs saints (ex : *St-Sulpice Sévère*, *St-Valère*) :contentReference[oaicite:2]{index=2}
|
||||||
|
|
||||||
|
2. **Bloc Dicton du jour**
|
||||||
|
Titre suivi du texte du dicton (souvent une ou plusieurs lignes) :contentReference[oaicite:3]{index=3}
|
||||||
|
|
||||||
|
3. **(Optionnel) Prénoms à fêter**
|
||||||
|
Liste de prénoms associés à la date :contentReference[oaicite:4]{index=4}
|
||||||
|
|
||||||
|
> Le site propose aussi une page d’accueil et des pages alphabétiques, mais pour extraire une *base annuelle*, le patron `MMDD.html` est utile pour itérer sur chaque jour de l’année. :contentReference[oaicite:5]{index=5}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pré-requis
|
||||||
|
|
||||||
|
Installer les dépendances Python nécessaires :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install requests beautifulsoup4
|
||||||
|
|
||||||
|
Optionnel mais recommandé :
|
||||||
|
|
||||||
|
pip install lxml
|
||||||
|
Exemple de script Python (scraper)
|
||||||
|
|
||||||
|
Ce script parcourt toutes les dates de l’année, récupère chaque page, analyse l’HTML et enregistre les données dans un fichier CSV.
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import csv
|
||||||
|
import time
|
||||||
|
|
||||||
|
BASE_URL = "https://www.saint-dicton.com/{:02d}{:02d}.html"
|
||||||
|
|
||||||
|
def scrape_day(month: int, day: int):
|
||||||
|
url = BASE_URL.format(month, day)
|
||||||
|
resp = requests.get(url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
soup = BeautifulSoup(resp.text, "lxml")
|
||||||
|
|
||||||
|
# Extraire les noms de saints
|
||||||
|
saints = []
|
||||||
|
h1 = soup.find("h1")
|
||||||
|
if h1:
|
||||||
|
# souvent la liste des saints est en texte dans la partie principale
|
||||||
|
for line in h1.text.split("\n"):
|
||||||
|
if line.strip():
|
||||||
|
saints.append(line.strip())
|
||||||
|
|
||||||
|
# trouver le div ou section contenant "Dicton du jour"
|
||||||
|
dicton_data = ""
|
||||||
|
target = soup.find(text="Dicton du jour")
|
||||||
|
if target:
|
||||||
|
parent = target.find_parent()
|
||||||
|
if parent:
|
||||||
|
# juste après ce bloc, prendre les lignes de dicton
|
||||||
|
for p in parent.find_next_siblings():
|
||||||
|
text = p.get_text(strip=True)
|
||||||
|
if text:
|
||||||
|
dicton_data += text + " "
|
||||||
|
|
||||||
|
return {
|
||||||
|
"month": month,
|
||||||
|
"day": day,
|
||||||
|
"saints": saints,
|
||||||
|
"dicton": dicton_data.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Boucle sur toute l'année
|
||||||
|
with open("saints_dictons.csv", "w", newline="", encoding="utf-8") as csvfile:
|
||||||
|
fieldnames = ["month", "day", "saints", "dicton"]
|
||||||
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, delimiter=";")
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for m in range(1, 13):
|
||||||
|
for d in range(1, 32):
|
||||||
|
data = scrape_day(m, d)
|
||||||
|
if data:
|
||||||
|
writer.writerow({
|
||||||
|
"month": data["month"],
|
||||||
|
"day": data["day"],
|
||||||
|
"saints": "|".join(data["saints"]),
|
||||||
|
"dicton": data["dicton"]
|
||||||
|
})
|
||||||
|
print(f"Scraped {m:02d}-{d:02d}")
|
||||||
|
time.sleep(1) # pause pour éviter surcharge
|
||||||
|
Bonnes pratiques & conseils
|
||||||
|
💡 Respect et performance
|
||||||
|
|
||||||
|
Respecte les règles d’utilisation du site. Le scraping est légitime sur du contenu public, mais il faut éviter une surcharge du serveur.
|
||||||
|
|
||||||
|
Met une pause (time.sleep(1)) entre les requêtes pour ne pas déclencher de blocage ou DDoS.
|
||||||
|
|
||||||
|
🧠 Inspection du HTML
|
||||||
|
|
||||||
|
Avant d’extraire le texte, inspecte la page dans un navigateur (clic droit → Inspecter) pour confirmer la structure des balises.
|
||||||
|
|
||||||
|
Le site n’utilise pas d’API JSON, donc BeautifulSoup est adapté.
|
||||||
|
|
||||||
|
⚠️ Variations possibles
|
||||||
|
|
||||||
|
La structure peut varier légèrement d’une date à l’autre (par ex., plusieurs saints listés en texte, parfois des paragraphes additionnels).
|
||||||
|
|
||||||
|
Adaptation des sélecteurs (find, find_all, classes ou IDs) peut être nécessaire selon les pages.
|
||||||
|
|
||||||
|
Sorties possibles
|
||||||
|
|
||||||
|
À partir du script ci-dessus, tu peux générer :
|
||||||
|
|
||||||
|
un CSV (comme ci-dessous)
|
||||||
|
|
||||||
|
un JSON structuré
|
||||||
|
|
||||||
|
un SQLite pour intégration dans ton application jardinage
|
||||||
|
|
||||||
|
Exemple d’enregistrement CSV :
|
||||||
|
|
||||||
|
month;day;saints;dicton
|
||||||
|
2;22;St-Sulpice Sévère|St-Valère;"S'il gèle à la Saint-Sulpice, Le printemps sera propice"
|
||||||
|
Conclusion
|
||||||
|
|
||||||
|
En parcourant les URL de MMDD.html et en analysant l’HTML, tu peux extraire automatiquement pour toute l’année la liste des saints et leurs dictons associés.
|
||||||
|
Ce fichier scrap_saint.md te fournit une base de départ claire avec un script Python prêt à l’emploi.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mmdd(mmdd: str) -> tuple[int, int]:
|
||||||
|
if len(mmdd) != 4 or not mmdd.isdigit():
|
||||||
|
raise ValueError(f"MMDD invalide: {mmdd}")
|
||||||
|
month = int(mmdd[:2])
|
||||||
|
day = int(mmdd[2:])
|
||||||
|
if not (1 <= month <= 12 and 1 <= day <= 31):
|
||||||
|
raise ValueError(f"MMDD hors plage: {mmdd}")
|
||||||
|
return month, day
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: object) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
out: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
txt = str(item).strip()
|
||||||
|
if txt and txt not in out:
|
||||||
|
out.append(txt)
|
||||||
|
return out
|
||||||
|
txt = str(value).strip()
|
||||||
|
return [txt] if txt else []
|
||||||
|
|
||||||
|
|
||||||
|
def export_files(source_file: Path, saints_out: Path, dictons_out: Path) -> tuple[int, int]:
|
||||||
|
source = json.loads(source_file.read_text(encoding="utf-8"))
|
||||||
|
rows = source.get("data", [])
|
||||||
|
saints_rows: list[dict] = []
|
||||||
|
dictons_rows: list[dict] = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
mmdd = str(row.get("mmdd", "")).strip()
|
||||||
|
if not mmdd:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
month, day = _parse_mmdd(mmdd)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
saints = _as_list(row.get("saints"))
|
||||||
|
dictons = _as_list(row.get("dictons"))
|
||||||
|
source_url = row.get("source_url")
|
||||||
|
|
||||||
|
if saints:
|
||||||
|
saints_rows.append(
|
||||||
|
{
|
||||||
|
"mois": month,
|
||||||
|
"jour": day,
|
||||||
|
"saints": saints,
|
||||||
|
"source_url": source_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if dictons:
|
||||||
|
dictons_rows.append(
|
||||||
|
{
|
||||||
|
"mois": month,
|
||||||
|
"jour": day,
|
||||||
|
"dictons": dictons,
|
||||||
|
"source_url": source_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
saints_rows.sort(key=lambda r: (r["mois"], r["jour"]))
|
||||||
|
dictons_rows.sort(key=lambda r: (r["mois"], r["jour"]))
|
||||||
|
|
||||||
|
saints_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dictons_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
saints_out.write_text(json.dumps(saints_rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
dictons_out.write_text(json.dumps(dictons_rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return len(saints_rows), len(dictons_rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Génère saints_du_jour.json et dictons_du_jour.json depuis saints_YYYY.json."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_2026.json",
|
||||||
|
help="Source JSON issue du scraper annuel",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--saints-out",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
|
||||||
|
help="Fichier JSON de sortie pour les saints",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dictons-out",
|
||||||
|
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
|
||||||
|
help="Fichier JSON de sortie pour les dictons",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
source_file = Path(args.source)
|
||||||
|
saints_out = Path(args.saints_out)
|
||||||
|
dictons_out = Path(args.dictons_out)
|
||||||
|
saints_count, dictons_count = export_files(source_file, saints_out, dictons_out)
|
||||||
|
print(f"Saints exportés : {saints_count} jours -> {saints_out}")
|
||||||
|
print(f"Dictons exportés : {dictons_count} jours -> {dictons_out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS saint_du_jour (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER NOT NULL,
|
||||||
|
saints_json TEXT NOT NULL,
|
||||||
|
source_url TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(mois, jour)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS dicton (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER,
|
||||||
|
texte TEXT NOT NULL,
|
||||||
|
region TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton(mois, jour)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour ON saint_du_jour(mois, jour)")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_rows(path: Path, required_key: str) -> list[dict]:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(f"{path}: format JSON attendu = liste d'objets")
|
||||||
|
rows: list[dict] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if required_key not in item:
|
||||||
|
continue
|
||||||
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _import_saints(conn: sqlite3.Connection, saints_rows: list[dict], mode: str) -> int:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
if mode == "replace":
|
||||||
|
conn.execute("DELETE FROM saint_du_jour")
|
||||||
|
|
||||||
|
for row in saints_rows:
|
||||||
|
mois = int(row["mois"])
|
||||||
|
jour = int(row["jour"])
|
||||||
|
saints = row.get("saints") or []
|
||||||
|
if not isinstance(saints, list):
|
||||||
|
saints = [str(saints)]
|
||||||
|
saints = [str(x).strip() for x in saints if str(x).strip()]
|
||||||
|
if not saints:
|
||||||
|
continue
|
||||||
|
saints_json = json.dumps(saints, ensure_ascii=False)
|
||||||
|
source_url = row.get("source_url")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO saint_du_jour (mois, jour, saints_json, source_url, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(mois, jour)
|
||||||
|
DO UPDATE SET
|
||||||
|
saints_json = excluded.saints_json,
|
||||||
|
source_url = excluded.source_url,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(mois, jour, saints_json, source_url, now),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def _import_dictons(conn: sqlite3.Connection, dicton_rows: list[dict], mode: str, region: str | None) -> int:
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
if mode == "replace":
|
||||||
|
if region:
|
||||||
|
conn.execute("DELETE FROM dicton WHERE region = ?", (region,))
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM dicton")
|
||||||
|
|
||||||
|
for row in dicton_rows:
|
||||||
|
mois = int(row["mois"])
|
||||||
|
jour = int(row["jour"])
|
||||||
|
dictons = row.get("dictons") or []
|
||||||
|
if not isinstance(dictons, list):
|
||||||
|
dictons = [str(dictons)]
|
||||||
|
dictons = [str(x).strip() for x in dictons if str(x).strip()]
|
||||||
|
if not dictons:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for texte in dictons:
|
||||||
|
if mode == "append":
|
||||||
|
exists = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM dicton
|
||||||
|
WHERE mois = ? AND jour = ? AND texte = ? AND COALESCE(region, '') = COALESCE(?, '')
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(mois, jour, texte, region),
|
||||||
|
).fetchone()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?, ?, ?, ?)",
|
||||||
|
(mois, jour, texte, region),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Importe saints du jour + dictons dans SQLite (hors webapp)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
default="data/jardin.db",
|
||||||
|
help="Chemin SQLite cible",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--saints-json",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
|
||||||
|
help="JSON saints_du_jour",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dictons-json",
|
||||||
|
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
|
||||||
|
help="JSON dictons_du_jour",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["replace", "append"],
|
||||||
|
default="replace",
|
||||||
|
help="replace: purge puis recharge ; append: ajoute sans doublons",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--region",
|
||||||
|
default="National",
|
||||||
|
help="Région stockée dans table dicton (vide pour NULL)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db)
|
||||||
|
saints_path = Path(args.saints_json)
|
||||||
|
dictons_path = Path(args.dictons_json)
|
||||||
|
region = args.region.strip() or None
|
||||||
|
|
||||||
|
saints_rows = _load_json_rows(saints_path, "saints")
|
||||||
|
dicton_rows = _load_json_rows(dictons_path, "dictons")
|
||||||
|
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
_ensure_schema(conn)
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
saints_count = _import_saints(conn, saints_rows, args.mode)
|
||||||
|
dictons_count = _import_dictons(conn, dicton_rows, args.mode, region)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"Import terminé ({args.mode})")
|
||||||
|
print(f" Saints importés: {saints_count}")
|
||||||
|
print(f" Dictons importés: {dictons_count} (region={region!r})")
|
||||||
|
print(f" Base: {db_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import des saints et dictons dans la base de données de la webapp Jardin.
|
||||||
|
|
||||||
|
Les données sont INDÉPENDANTES DE L'ANNÉE : stockées par (mois, jour) uniquement.
|
||||||
|
|
||||||
|
Sources JSON :
|
||||||
|
- saints_du_jour.json → table saint_du_jour
|
||||||
|
- dictons_du_jour.json → table dicton
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
# Import complet (remplace les données existantes)
|
||||||
|
python import_webapp_db.py
|
||||||
|
|
||||||
|
# Import vers une autre DB
|
||||||
|
python import_webapp_db.py --db /chemin/vers/jardin.db
|
||||||
|
|
||||||
|
# Mode append (ajoute sans supprimer les existants)
|
||||||
|
python import_webapp_db.py --mode append
|
||||||
|
|
||||||
|
# Seulement les saints ou seulement les dictons
|
||||||
|
python import_webapp_db.py --only saints
|
||||||
|
python import_webapp_db.py --only dictons
|
||||||
|
|
||||||
|
# Tagger les dictons avec une région
|
||||||
|
python import_webapp_db.py --region "Auvergne"
|
||||||
|
|
||||||
|
# Prévisualisation sans modification
|
||||||
|
python import_webapp_db.py --dry-run
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Chemins par défaut relatifs à ce script
|
||||||
|
_HERE = Path(__file__).parent
|
||||||
|
_REPO_ROOT = _HERE.parent.parent # racine du projet jardin/
|
||||||
|
DEFAULT_DB = _REPO_ROOT / "data" / "jardin.db"
|
||||||
|
DEFAULT_SAINTS_JSON = _HERE / "saints_du_jour.json"
|
||||||
|
DEFAULT_DICTONS_JSON = _HERE / "dictons_du_jour.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Schémas SQL attendus par la webapp
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
SQL_CREATE_SAINT_DU_JOUR = """
|
||||||
|
CREATE TABLE IF NOT EXISTS saint_du_jour (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER NOT NULL,
|
||||||
|
saints_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
source_url TEXT,
|
||||||
|
UNIQUE (mois, jour)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQL_CREATE_INDEX_SAINT = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour
|
||||||
|
ON saint_du_jour (mois, jour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# La table dicton existe déjà dans la webapp (SQLModel).
|
||||||
|
# Colonnes: id, mois, jour, texte, region
|
||||||
|
# On s'assure juste qu'elle existe (ne recrée pas si déjà présente).
|
||||||
|
SQL_CREATE_DICTON = """
|
||||||
|
CREATE TABLE IF NOT EXISTS dicton (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mois INTEGER NOT NULL,
|
||||||
|
jour INTEGER,
|
||||||
|
texte TEXT NOT NULL,
|
||||||
|
region TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQL_CREATE_INDEX_DICTON = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton (mois, jour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Chargement JSON
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_json(path: Path, label: str) -> list:
|
||||||
|
if not path.exists():
|
||||||
|
print(f"[ERREUR] Fichier introuvable : {path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
print(f"[ERREUR] {label} : attendu une liste JSON, obtenu {type(data).__name__}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" {label} : {len(data)} entrées chargées depuis {path.name}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Import saints
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def import_saints(conn: sqlite3.Connection, data: list, mode: str, dry_run: bool) -> int:
|
||||||
|
"""Importe les saints dans saint_du_jour. Retourne le nombre d'entrées traitées."""
|
||||||
|
conn.execute(SQL_CREATE_SAINT_DU_JOUR)
|
||||||
|
conn.execute(SQL_CREATE_INDEX_SAINT)
|
||||||
|
|
||||||
|
if mode == "replace" and not dry_run:
|
||||||
|
conn.execute("DELETE FROM saint_du_jour")
|
||||||
|
print(" [replace] saint_du_jour vidée")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for entry in data:
|
||||||
|
mois = entry.get("mois")
|
||||||
|
jour = entry.get("jour")
|
||||||
|
saints = entry.get("saints", [])
|
||||||
|
source_url = entry.get("source_url")
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not isinstance(mois, int) or not isinstance(jour, int):
|
||||||
|
continue
|
||||||
|
if not (1 <= mois <= 12 and 1 <= jour <= 31):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normaliser en liste de strings non vides
|
||||||
|
if isinstance(saints, str):
|
||||||
|
saints = [saints]
|
||||||
|
saints = [s.strip() for s in saints if isinstance(s, str) and s.strip()]
|
||||||
|
saints_json = json.dumps(saints, ensure_ascii=False)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mode == "append":
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM saint_du_jour WHERE mois=? AND jour=?", (mois, jour)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO saint_du_jour (mois, jour, saints_json, source_url) VALUES (?,?,?,?)",
|
||||||
|
(mois, jour, saints_json, source_url),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if skipped:
|
||||||
|
print(f" saints ignorés (mode append, déjà présents) : {skipped}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Import dictons
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def import_dictons(conn: sqlite3.Connection, data: list, mode: str, region: str | None, dry_run: bool) -> int:
|
||||||
|
"""Importe les dictons dans la table dicton. Retourne le nombre d'entrées insérées."""
|
||||||
|
conn.execute(SQL_CREATE_DICTON)
|
||||||
|
conn.execute(SQL_CREATE_INDEX_DICTON)
|
||||||
|
|
||||||
|
if mode == "replace" and not dry_run:
|
||||||
|
conn.execute("DELETE FROM dicton")
|
||||||
|
print(" [replace] dicton vidée")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for entry in data:
|
||||||
|
mois = entry.get("mois")
|
||||||
|
jour = entry.get("jour") # peut être None
|
||||||
|
dictons = entry.get("dictons", [])
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not isinstance(mois, int):
|
||||||
|
continue
|
||||||
|
if not (1 <= mois <= 12):
|
||||||
|
continue
|
||||||
|
if jour is not None and not (1 <= jour <= 31):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normaliser
|
||||||
|
if isinstance(dictons, str):
|
||||||
|
dictons = [dictons]
|
||||||
|
dictons = [d.strip() for d in dictons if isinstance(d, str) and d.strip()]
|
||||||
|
|
||||||
|
for texte in dictons:
|
||||||
|
if dry_run:
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mode == "append":
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM dicton WHERE mois=? AND jour IS ? AND texte=?",
|
||||||
|
(mois, jour, texte),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?,?,?,?)",
|
||||||
|
(mois, jour, texte, region),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Point d'entrée
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Import saints et dictons dans la BDD webapp Jardin (indépendant de l'année)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--db", default=str(DEFAULT_DB),
|
||||||
|
help=f"Chemin vers jardin.db (défaut: {DEFAULT_DB})")
|
||||||
|
parser.add_argument("--saints-json", default=str(DEFAULT_SAINTS_JSON),
|
||||||
|
help=f"Fichier saints_du_jour.json (défaut: {DEFAULT_SAINTS_JSON.name})")
|
||||||
|
parser.add_argument("--dictons-json", default=str(DEFAULT_DICTONS_JSON),
|
||||||
|
help=f"Fichier dictons_du_jour.json (défaut: {DEFAULT_DICTONS_JSON.name})")
|
||||||
|
parser.add_argument("--mode", choices=["replace", "append"], default="replace",
|
||||||
|
help="replace = purge + recharge (défaut) | append = ajoute sans doublons")
|
||||||
|
parser.add_argument("--only", choices=["saints", "dictons", "both"], default="both",
|
||||||
|
help="Importer uniquement saints, dictons, ou les deux (défaut: both)")
|
||||||
|
parser.add_argument("--region", default=None,
|
||||||
|
help="Tag région pour les dictons (ex: 'Auvergne', 'National'). Vide = NULL")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Simulation : affiche les comptages sans modifier la BDD")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db)
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"[ERREUR] Base de données introuvable : {db_path}", file=sys.stderr)
|
||||||
|
print(" → Vérifiez que la webapp a démarré au moins une fois pour créer la BDD.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
region = args.region if args.region else None
|
||||||
|
do_saints = args.only in ("saints", "both")
|
||||||
|
do_dictons = args.only in ("dictons", "both")
|
||||||
|
|
||||||
|
print(f"\n=== Import saints/dictons → {db_path} ===")
|
||||||
|
print(f" Mode : {args.mode}")
|
||||||
|
print(f" Scope : {args.only}")
|
||||||
|
if region:
|
||||||
|
print(f" Région: {region}")
|
||||||
|
if args.dry_run:
|
||||||
|
print(" *** DRY-RUN — aucune modification en BDD ***")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Charger les JSON nécessaires
|
||||||
|
saints_data = load_json(Path(args.saints_json), "saints_du_jour") if do_saints else []
|
||||||
|
dictons_data = load_json(Path(args.dictons_json), "dictons_du_jour") if do_dictons else []
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Connexion SQLite
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=OFF") # pas de cascade gestion manuelle
|
||||||
|
|
||||||
|
saints_count = 0
|
||||||
|
dictons_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
if do_saints:
|
||||||
|
saints_count = import_saints(conn, saints_data, args.mode, args.dry_run)
|
||||||
|
if do_dictons:
|
||||||
|
dictons_count = import_dictons(conn, dictons_data, args.mode, region, args.dry_run)
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"\n[ERREUR] Transaction annulée : {exc}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Résumé
|
||||||
|
print()
|
||||||
|
print("=== Résultat ===")
|
||||||
|
if do_saints:
|
||||||
|
prefix = "[DRY-RUN] " if args.dry_run else ""
|
||||||
|
print(f" {prefix}Saints importés : {saints_count} jours → table saint_du_jour")
|
||||||
|
if do_dictons:
|
||||||
|
prefix = "[DRY-RUN] " if args.dry_run else ""
|
||||||
|
print(f" {prefix}Dictons importés : {dictons_count} entrées → table dicton")
|
||||||
|
print(f" Base : {db_path}")
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n → Relancez sans --dry-run pour appliquer les changements.")
|
||||||
|
else:
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
print(f"\n Import terminé le {ts}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DATE_HEADING_RE = re.compile(
|
||||||
|
r"^\s{0,3}(?:#{1,6}\s*)?((?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01])|(?:0[1-9]|[12][0-9]|3[01])[-/](?:0[1-9]|1[0-2])|(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01]))\s*$"
|
||||||
|
)
|
||||||
|
INLINE_DATE_DIC_RE = re.compile(
|
||||||
|
r"^\s*((?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01])|(?:0[1-9]|[12][0-9]|3[01])[-/](?:0[1-9]|1[0-2])|(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01]))\s*[:\-]\s*(.+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_mmdd(token: str) -> str | None:
|
||||||
|
token = token.strip()
|
||||||
|
if re.fullmatch(r"(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])", token):
|
||||||
|
month = token[:2]
|
||||||
|
day = token[2:]
|
||||||
|
elif re.fullmatch(r"(0[1-9]|1[0-2])[-/](0[1-9]|[12][0-9]|3[01])", token):
|
||||||
|
month, day = re.split(r"[-/]", token)
|
||||||
|
elif re.fullmatch(r"(0[1-9]|[12][0-9]|3[01])[-/](0[1-9]|1[0-2])", token):
|
||||||
|
day, month = re.split(r"[-/]", token)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return f"{month}-{day}"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(values: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
v = value.strip()
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
if v not in seen:
|
||||||
|
seen.add(v)
|
||||||
|
out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _split_saints(text: str) -> list[str]:
|
||||||
|
# Normalize separators and preserve saint labels.
|
||||||
|
cleaned = text.strip().strip(".")
|
||||||
|
cleaned = re.sub(r"^(saints?\s*[:\-]\s*)", "", cleaned, flags=re.I).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
parts = re.split(r"\s*(?:,|;|\||\set\s)\s*", cleaned, flags=re.I)
|
||||||
|
return _unique(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_dictons(text: str) -> list[str]:
|
||||||
|
cleaned = text.strip()
|
||||||
|
cleaned = re.sub(r"^(dictons?\s*[:\-]\s*)", "", cleaned, flags=re.I).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
# Keep sentences readable; split on explicit separators first.
|
||||||
|
if "|" in cleaned or ";" in cleaned:
|
||||||
|
parts = re.split(r"\s*(?:\||;)\s*", cleaned)
|
||||||
|
else:
|
||||||
|
parts = [cleaned]
|
||||||
|
return _unique(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def load_saints(path: Path) -> dict[str, list[str]]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for mmdd, saint_value in raw.items():
|
||||||
|
key = _normalize_mmdd(mmdd)
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
if isinstance(saint_value, list):
|
||||||
|
saints = [str(x).strip() for x in saint_value]
|
||||||
|
else:
|
||||||
|
saints = _split_saints(str(saint_value))
|
||||||
|
out[key] = _unique(saints)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dictons_text(path: Path) -> dict[str, list[str]]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
current_date: str | None = None
|
||||||
|
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Date heading block
|
||||||
|
m_head = DATE_HEADING_RE.match(line)
|
||||||
|
if m_head:
|
||||||
|
current_date = _normalize_mmdd(m_head.group(1))
|
||||||
|
if current_date and current_date not in out:
|
||||||
|
out[current_date] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inline date + dicton
|
||||||
|
m_inline = INLINE_DATE_DIC_RE.match(line)
|
||||||
|
if m_inline:
|
||||||
|
mmdd = _normalize_mmdd(m_inline.group(1))
|
||||||
|
if mmdd:
|
||||||
|
out.setdefault(mmdd, []).extend(_split_dictons(m_inline.group(2)))
|
||||||
|
current_date = mmdd
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Bullets or plain lines inside current date block
|
||||||
|
if current_date:
|
||||||
|
line = re.sub(r"^\s*[-*]\s*", "", line).strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if re.match(r"^saints?\s*[:\-]", line, flags=re.I):
|
||||||
|
# Saints line is ignored here; saints come from saints_json.
|
||||||
|
continue
|
||||||
|
out.setdefault(current_date, []).extend(_split_dictons(line))
|
||||||
|
|
||||||
|
return {k: _unique(v) for k, v in out.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_iso(year: int, mmdd: str) -> str:
|
||||||
|
month, day = mmdd.split("-")
|
||||||
|
return date(year, int(month), int(day)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def build_output(
|
||||||
|
saints_by_date: dict[str, list[str]],
|
||||||
|
dictons_by_date: dict[str, list[str]],
|
||||||
|
year: int | None,
|
||||||
|
) -> list[dict]:
|
||||||
|
all_dates = sorted(set(saints_by_date) | set(dictons_by_date))
|
||||||
|
rows: list[dict] = []
|
||||||
|
for mmdd in all_dates:
|
||||||
|
row = {
|
||||||
|
"date": _as_iso(year, mmdd) if year else mmdd,
|
||||||
|
"saints": saints_by_date.get(mmdd, []),
|
||||||
|
"dictons": dictons_by_date.get(mmdd, []),
|
||||||
|
}
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Génère un JSON saints+dictons: date, saints[], dictons[]"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--saints-json",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_france.json",
|
||||||
|
help="Fichier JSON des saints (clé MM-DD)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dictons-file",
|
||||||
|
required=True,
|
||||||
|
help="Fichier texte/markdown des dictons (avec dates MM-DD, DD/MM ou MMDD)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="calendrier_lunaire/saints_dictons/saints_dictons.json",
|
||||||
|
help="Fichier JSON de sortie",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--year",
|
||||||
|
type=int,
|
||||||
|
help="Optionnel: convertit MM-DD en YYYY-MM-DD",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
saints_path = Path(args.saints_json)
|
||||||
|
dictons_path = Path(args.dictons_file)
|
||||||
|
output_path = Path(args.output)
|
||||||
|
|
||||||
|
saints_by_date = load_saints(saints_path)
|
||||||
|
dictons_by_date = parse_dictons_text(dictons_path)
|
||||||
|
rows = build_output(saints_by_date, dictons_by_date, args.year)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f"JSON généré: {output_path} ({len(rows)} dates)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from html import unescape
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
MONTHS_FR = {
|
||||||
|
1: "janvier", 2: "février", 3: "mars", 4: "avril", 5: "mai", 6: "juin",
|
||||||
|
7: "juillet", 8: "août", 9: "septembre", 10: "octobre", 11: "novembre", 12: "décembre",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_html(url: str) -> str:
|
||||||
|
req = Request(url, headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"})
|
||||||
|
with urlopen(req, timeout=25) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
charset = (resp.headers.get_content_charset() or "utf-8").lower()
|
||||||
|
try:
|
||||||
|
return raw.decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_html_text(s: str) -> str:
|
||||||
|
s = re.sub(r"<br\s*/?>", " ", s, flags=re.I)
|
||||||
|
s = re.sub(r"<[^>]+>", "", s)
|
||||||
|
s = unescape(s)
|
||||||
|
s = s.replace("\xa0", " ")
|
||||||
|
return re.sub(r"\s+", " ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_saints(html: str) -> list[str]:
|
||||||
|
rows = re.findall(r'<p[^>]*class="sd-name"[^>]*>(.*?)</p>', html, flags=re.I | re.S)
|
||||||
|
out, seen = [], set()
|
||||||
|
for row in rows:
|
||||||
|
txt = clean_html_text(row)
|
||||||
|
if txt and txt not in seen:
|
||||||
|
out.append(txt)
|
||||||
|
seen.add(txt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dictons(html: str) -> list[str]:
|
||||||
|
rows = re.findall(r'<p[^>]*class="dict"[^>]*>(.*?)</p>', html, flags=re.I | re.S)
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
txt = clean_html_text(row)
|
||||||
|
if txt:
|
||||||
|
out.append(txt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_prenoms(html: str) -> list[str]:
|
||||||
|
block = re.search(
|
||||||
|
r'<h2[^>]*>[^<]*Pr[^<]*noms[^<]*f[^<]*ter[^<]*</h2>.*?<ul[^>]*>(.*?)</ul>',
|
||||||
|
html,
|
||||||
|
flags=re.I | re.S,
|
||||||
|
)
|
||||||
|
target = block.group(1) if block else ""
|
||||||
|
rows = re.findall(r'<li[^>]*>(.*?)</li>', target, flags=re.I | re.S)
|
||||||
|
out, seen = [], set()
|
||||||
|
for row in rows:
|
||||||
|
txt = clean_html_text(row)
|
||||||
|
if txt and txt not in seen:
|
||||||
|
out.append(txt)
|
||||||
|
seen.add(txt)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def iter_mmdd_full_year(year: int):
|
||||||
|
d = date(year, 1, 1)
|
||||||
|
end = date(year, 12, 31)
|
||||||
|
while d <= end:
|
||||||
|
yield d.strftime("%m%d"), d
|
||||||
|
d += timedelta(days=1)
|
||||||
|
# assure 29 février même année non bissextile
|
||||||
|
if year % 4 != 0 or (year % 100 == 0 and year % 400 != 0):
|
||||||
|
yield "0229", None
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_day(base_url: str, mmdd: str, d: date | None) -> dict:
|
||||||
|
url = f"{base_url.rstrip('/')}/{mmdd}.html"
|
||||||
|
html = fetch_html(url)
|
||||||
|
if d:
|
||||||
|
label = f"{d.day:02d} {MONTHS_FR[d.month]}"
|
||||||
|
iso = d.isoformat()
|
||||||
|
else:
|
||||||
|
label = "29 février"
|
||||||
|
iso = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": label,
|
||||||
|
"date_iso": iso,
|
||||||
|
"mmdd": mmdd,
|
||||||
|
"saints": parse_saints(html),
|
||||||
|
"dictons": parse_dictons(html),
|
||||||
|
"prenoms_a_feter": parse_prenoms(html),
|
||||||
|
"source_url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ts() -> str:
|
||||||
|
return datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message: str, enabled: bool) -> None:
|
||||||
|
if enabled:
|
||||||
|
print(f"[{_ts()}] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Scrape saints/dictons pour toute une année (inclut 29 février)")
|
||||||
|
ap.add_argument("--year", type=int, default=date.today().year)
|
||||||
|
ap.add_argument("--base", default="https://www.saint-dicton.com")
|
||||||
|
ap.add_argument("--sleep-ms", type=int, default=150, help="Pause entre requêtes")
|
||||||
|
ap.add_argument("--limit", type=int, default=0, help="Limiter le nb de jours (test rapide)")
|
||||||
|
ap.add_argument("--out", default="", help="Fichier de sortie JSON (sinon stdout)")
|
||||||
|
ap.add_argument("--log-every", type=int, default=10, help="Affiche un log de progression tous les N jours")
|
||||||
|
ap.add_argument("--quiet", action="store_true", help="Réduit les logs")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
count = 0
|
||||||
|
verbose = not args.quiet
|
||||||
|
log_every = max(1, args.log_every)
|
||||||
|
_log(f"Démarrage scrape année={args.year}, base={args.base}", verbose)
|
||||||
|
for mmdd, d in iter_mmdd_full_year(args.year):
|
||||||
|
url = f"{args.base.rstrip('/')}/{mmdd}.html"
|
||||||
|
_log(f"[{count + 1}] fetch {mmdd} -> {url}", verbose)
|
||||||
|
try:
|
||||||
|
results.append(scrape_day(args.base, mmdd, d))
|
||||||
|
_log(f"[{count + 1}] ok {mmdd}", verbose and ((count + 1) % log_every == 0 or count == 0))
|
||||||
|
except Exception as e:
|
||||||
|
results.append({
|
||||||
|
"mmdd": mmdd,
|
||||||
|
"date_iso": d.isoformat() if d else None,
|
||||||
|
"error": str(e),
|
||||||
|
"source_url": url,
|
||||||
|
})
|
||||||
|
_log(f"[{count + 1}] erreur {mmdd}: {e}", True)
|
||||||
|
count += 1
|
||||||
|
if args.limit and count >= args.limit:
|
||||||
|
_log(f"Arrêt par --limit={args.limit}", verbose)
|
||||||
|
break
|
||||||
|
if args.sleep_ms > 0:
|
||||||
|
time.sleep(args.sleep_ms / 1000)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"year": args.year,
|
||||||
|
"count": len(results),
|
||||||
|
"includes_feb29": any(r.get("mmdd") == "0229" for r in results),
|
||||||
|
"data": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
txt = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
|
if args.out:
|
||||||
|
with open(args.out, "w", encoding="utf-8") as f:
|
||||||
|
f.write(txt)
|
||||||
|
_log(f"Fichier écrit: {args.out}", True)
|
||||||
|
else:
|
||||||
|
print(txt)
|
||||||
|
_log(f"Terminé: {len(results)} jours", verbose)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"04-23": "Saint Georges",
|
||||||
|
"04-25": "Saint Marc",
|
||||||
|
"05-11": "Saint Mamert",
|
||||||
|
"05-12": "Saint Pancrace",
|
||||||
|
"05-13": "Saint Servais",
|
||||||
|
"05-14": "Saint Boniface",
|
||||||
|
"05-19": "Saint Yves",
|
||||||
|
"05-25": "Saint Urbain"
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lunar_calendar import DayInfo, build_calendar
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_calendar_rejects_invalid_range() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
build_calendar(date(2026, 1, 10), date(2026, 1, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_calendar_returns_expected_day_count() -> None:
|
||||||
|
rows = build_calendar(date(2026, 1, 1), date(2026, 1, 3))
|
||||||
|
assert len(rows) == 3
|
||||||
|
assert rows[0].date == "2026-01-01"
|
||||||
|
assert rows[-1].date == "2026-01-03"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dayinfo_fields_are_well_typed() -> None:
|
||||||
|
row = build_calendar(date(2026, 1, 1), date(2026, 1, 1))[0]
|
||||||
|
assert isinstance(row, DayInfo)
|
||||||
|
assert isinstance(row.date, str)
|
||||||
|
assert isinstance(row.phase, str)
|
||||||
|
assert isinstance(row.illumination, float)
|
||||||
|
assert isinstance(row.croissante_decroissante, str)
|
||||||
|
assert isinstance(row.montante_descendante, str)
|
||||||
|
assert isinstance(row.signe, str)
|
||||||
|
assert isinstance(row.type_jour, str)
|
||||||
|
assert isinstance(row.soleil_lever, str)
|
||||||
|
assert isinstance(row.soleil_coucher, str)
|
||||||
|
assert isinstance(row.duree_jour, str)
|
||||||
|
assert isinstance(row.lune_lever, str)
|
||||||
|
assert isinstance(row.lune_coucher, str)
|
||||||
|
assert isinstance(row.duree_presence_lune, str)
|
||||||
|
assert isinstance(row.saint_du_jour, str)
|
||||||
|
assert isinstance(row.saint_de_glace, bool)
|
||||||
|
assert isinstance(row.perigee, bool)
|
||||||
|
assert isinstance(row.apogee, bool)
|
||||||
|
assert isinstance(row.noeud_lunaire, bool)
|
||||||
|
assert isinstance(row.transitions_type_jour, list)
|
||||||
|
assert isinstance(row.transitions_montante_descendante, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_saints_de_glace_are_exposed() -> None:
|
||||||
|
rows = build_calendar(date(2026, 5, 11), date(2026, 5, 13))
|
||||||
|
assert rows[0].saint_du_jour == "Saint Mamert"
|
||||||
|
assert rows[1].saint_du_jour == "Saint Pancrace"
|
||||||
|
assert rows[2].saint_du_jour == "Saint Servais"
|
||||||
|
assert all(r.saint_de_glace for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rise_set_fields_are_present() -> None:
|
||||||
|
row = build_calendar(date(2026, 2, 22), date(2026, 2, 22))[0]
|
||||||
|
assert row.soleil_lever != ""
|
||||||
|
assert row.soleil_coucher != ""
|
||||||
|
assert row.duree_jour != ""
|
||||||
|
assert row.lune_lever != ""
|
||||||
|
assert row.lune_coucher != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_items_have_expected_shape() -> None:
|
||||||
|
row = build_calendar(date(2026, 2, 22), date(2026, 2, 22))[0]
|
||||||
|
for item in row.transitions_type_jour + row.transitions_montante_descendante:
|
||||||
|
assert set(item.keys()) == {"heure", "avant", "apres"}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Codex - Elements développes
|
||||||
|
|
||||||
|
Ce document liste les éléments développés dans le projet `jardin`.
|
||||||
|
|
||||||
|
## 1) Calendrier lunaire
|
||||||
|
|
||||||
|
- Script principal: `calendrier_lunaire/lunar_calendar.py`
|
||||||
|
- Tests: `calendrier_lunaire/test_lunar_calendar.py`
|
||||||
|
- Sorties JSON générées:
|
||||||
|
- `calendrier_lunaire/calendrier_lunaire_2026.json`
|
||||||
|
- `calendrier_lunaire/calendrier_lunaire_2027.json`
|
||||||
|
- Données/ressources:
|
||||||
|
- `calendrier_lunaire/de421.bsp`
|
||||||
|
- `calendrier_lunaire/deep_search.md`
|
||||||
|
- `calendrier_lunaire/deep_search1.md`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Calcul des phases lunaires (nouvelle lune, quartiers, pleine lune)
|
||||||
|
- Génération annuelle en JSON
|
||||||
|
- Ajout des données saints du jour
|
||||||
|
- Ajout lever/coucher soleil et lune + durées
|
||||||
|
- Ajout transitions intra-journée (jour type / montante-descendante)
|
||||||
|
- Alignement zodiacal sidéral (constellations)
|
||||||
|
|
||||||
|
## 2) Saints et dictons
|
||||||
|
|
||||||
|
Dossier dédié: `calendrier_lunaire/saints_dictons/`
|
||||||
|
|
||||||
|
- Sources et consignes:
|
||||||
|
- `calendrier_lunaire/saints_dictons/consigne_scrap_saint_dictons.md`
|
||||||
|
- `calendrier_lunaire/saints_dictons/saints_france.json`
|
||||||
|
- Parsing:
|
||||||
|
- `calendrier_lunaire/saints_dictons/parse_saints_dictons.py`
|
||||||
|
- Scraping annuel:
|
||||||
|
- `calendrier_lunaire/saints_dictons/saint_dicton_year_scraper.py`
|
||||||
|
- Exemple de sortie:
|
||||||
|
- `calendrier_lunaire/saints_dictons/saints_2026.json`
|
||||||
|
- Exports JSON séparés:
|
||||||
|
- `calendrier_lunaire/saints_dictons/saints_du_jour.json`
|
||||||
|
- `calendrier_lunaire/saints_dictons/dictons_du_jour.json`
|
||||||
|
- Scripts hors webapp:
|
||||||
|
- `calendrier_lunaire/saints_dictons/export_saints_dictons_json.py`
|
||||||
|
- `calendrier_lunaire/saints_dictons/import_saints_dictons_db.py`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Format JSON cible: `date`, `saints[]`, `dictons[]`
|
||||||
|
- Support de formats de date multiples
|
||||||
|
- Ajout de logs de progression dans le scraper
|
||||||
|
- Enregistrement JSON (pas uniquement affichage terminal)
|
||||||
|
- Génération de 2 jeux de données dédiés (saints / dictons)
|
||||||
|
- Import automatisé en SQLite (`replace` ou `append`)
|
||||||
|
- Création table `saint_du_jour` si absente + alimentation table `dicton`
|
||||||
|
|
||||||
|
## 3) Prévisions météo Open-Meteo
|
||||||
|
|
||||||
|
- Script: `prevision meteo/open_meteo_garden_forecast.py`
|
||||||
|
- Consignes:
|
||||||
|
- `prevision meteo/consigne.md`
|
||||||
|
- `prevision meteo/consigne_open_meteo.md`
|
||||||
|
- Mapping WMO:
|
||||||
|
- `prevision meteo/wmo_code.json`
|
||||||
|
- Exemple de sortie:
|
||||||
|
- `prevision meteo/prevision meteo/output/forecast.json`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Appel Open-Meteo avec variables hourly/current étendues
|
||||||
|
- Intégration `past_days` + `forecast_days`
|
||||||
|
- Affichage tableau synthétique
|
||||||
|
- Export JSON complet
|
||||||
|
- Correction de sérialisation JSON
|
||||||
|
|
||||||
|
## 4) Station météo locale (WeeWX)
|
||||||
|
|
||||||
|
- Script: `station_meteo/local_station_weather.py`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Récupération des données actuelles (RSS)
|
||||||
|
- Récupération et parsing des résumés quotidiens
|
||||||
|
- Récupération de données journalières par date via option CLI
|
||||||
|
- Valeur par défaut: date de la veille si non fournie
|
||||||
|
- Normalisation des types (float/int)
|
||||||
|
- Structure JSON clarifiée: suppression de `yesterday`, ajout `day_data.date` (date complète)
|
||||||
|
- Enrichissement des blocs: `current`, `stats_today`, `astrology`, `station_info`
|
||||||
|
|
||||||
|
## 5) YOLO - Détection feuille/plante
|
||||||
|
|
||||||
|
Dossier: `test_yolo/`
|
||||||
|
|
||||||
|
- Script test: `test_yolo/test_yolo_leaf.py`
|
||||||
|
- Documentation: `test_yolo/README.md`
|
||||||
|
- Données images: `test_yolo/image/`
|
||||||
|
- Sorties:
|
||||||
|
- `test_yolo/test_yolo/output/detections.json`
|
||||||
|
- `test_yolo/test_yolo/output/annotated.jpg`
|
||||||
|
|
||||||
|
### Fonctions/évolutions intégrées
|
||||||
|
- Migration vers `ultralytics` (sans `ultralyticsplus`)
|
||||||
|
- Support modèle local ou repo Hugging Face (`best.pt`)
|
||||||
|
- Sortie JSON des détections
|
||||||
|
- Génération image annotée
|
||||||
|
- Traduction des labels vers le français (`class_name_fr`)
|
||||||
|
|
||||||
|
## 6) Assets icônes
|
||||||
|
|
||||||
|
- Icônes lune: `icons/moon/*.svg`
|
||||||
|
- `new_moon.svg`, `waxing_crescent.svg`, `first_quarter.svg`, `waxing_gibbous.svg`, `full_moon.svg`, `waning_gibbous.svg`, `last_quarter.svg`, `waning_crescent.svg`
|
||||||
|
- Icônes météo: `icons/weather/*.svg`
|
||||||
|
- Codes WMO usuels + `risque_canicule.svg` + `risque_gèle.svg`
|
||||||
|
|
||||||
|
## 7) Notes de pilotage
|
||||||
|
|
||||||
|
- Plan d'amélioration: `amelioration.md`
|
||||||
|
- Plan météo/astuces: `avancement.md` (contient plan + logs de session)
|
||||||
|
|
||||||
|
## 8) Webapp - évolutions récentes
|
||||||
|
|
||||||
|
### Planning
|
||||||
|
- `frontend/src/views/PlanningView.vue`
|
||||||
|
- Passage en vue 4 semaines (28 jours)
|
||||||
|
- Navigation par période: `Prev`, `Today`, `Next`
|
||||||
|
- Sélection d'un jour avec panneau "Détail du jour"
|
||||||
|
- Marqueurs visuels par tâches non terminées (ronds colorés par priorité)
|
||||||
|
|
||||||
|
### Outils
|
||||||
|
- `frontend/src/views/OutilsView.vue`
|
||||||
|
- Le champ notice est désormais une zone de texte libre (`notice_texte`)
|
||||||
|
- Conserve compatibilité lecture des anciennes notices fichier (`notice_fichier_url`)
|
||||||
|
- Test backend ajouté: `backend/tests/test_tools.py::test_tool_with_notice_texte`
|
||||||
|
|
||||||
|
### Réglages
|
||||||
|
- `backend/app/routers/settings.py`
|
||||||
|
- `frontend/src/views/ReglagesView.vue`
|
||||||
|
- Sauvegarde ZIP téléchargeable (BDD + uploads + fichiers texte + manifeste)
|
||||||
|
- Liens rapides de test API backend:
|
||||||
|
- Swagger: `/docs`
|
||||||
|
- ReDoc: `/redoc`
|
||||||
|
- Santé: `/api/health`
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# 🌿 **Consigne de Développement : Application de Gestion de Jardin**
|
||||||
|
**Thème visuel** : *Gruvbox Dark Seventies* (inspiré des années 70 avec des tons chauds et sombres)
|
||||||
|
**Langue** : Français
|
||||||
|
**Plateformes** : Web (responsive) + Mobile (compatibilité smartphone)
|
||||||
|
**Technologies suggérées** : React.js (frontend) + Node.js/Express (backend) + Firebase/PostgreSQL (base de
|
||||||
|
données) + Mapbox/Leaflet (cartographie) + TensorFlow.js (détection d'espèces via photo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **📌 Table des Matières**
|
||||||
|
1. [Introduction](#1-introduction)
|
||||||
|
2. [Fonctionnalités Principales](#2-fonctionnalités-principales)
|
||||||
|
- [Gestion des Jardins](#21-gestion-des-jardins)
|
||||||
|
- [Gestion des Plantes](#22-gestion-des-plantes)
|
||||||
|
- [Calendrier Lunaire](#23-calendrier-lunaire)
|
||||||
|
- [Planning et Tâches](#24-planning-et-tâches)
|
||||||
|
- [Géolocalisation et Cartographie](#25-géolocalisation-et-cartographie)
|
||||||
|
- [Améliorations Avancées](#26-améliorations-avancées)
|
||||||
|
3. [Architecture Technique](#3-architecture-technique)
|
||||||
|
4. [Design & UI/UX](#4-design-uiux)
|
||||||
|
5. [Roadmap & Brainstorming](#5-roadmap-et-brainstorming)
|
||||||
|
6. [Exigences Techniques](#6-exigences-techniques)
|
||||||
|
7. [Livrables](#7-livrables)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **1. Introduction**
|
||||||
|
**Objectif** :
|
||||||
|
Créer une application web/mobile intuitive pour gérer un ou plusieurs jardins (plein air ou serre), avec des
|
||||||
|
fonctionnalités avancées de suivi des plantes, du climat, et des conseils basés sur le calendrier lunaire.
|
||||||
|
L’interface doit être **responsive**, **esthétique** (thème *Gruvbox Dark Seventies*), et optimisée pour les
|
||||||
|
smartphones.
|
||||||
|
|
||||||
|
**Cibles** :
|
||||||
|
- Jardiniers amateurs et professionnels.
|
||||||
|
- Utilisateurs souhaitant automatiser la gestion des cultures (arrosage, plantation, récolte).
|
||||||
|
- Intégration de données géolocalisées et météo en temps réel.
|
||||||
|
|
||||||
|
**Inspirations visuelles** :
|
||||||
|
- Palette de couleurs : [Gruvbox Dark](https://github.com/morhetz/gruvbox) (rouge foncé, vert mousse, beige,
|
||||||
|
noir).
|
||||||
|
- Style : Retro-futuriste années 70 (boutons arrondis, ombres douces, typographie épurée comme *Courier New* ou
|
||||||
|
*Fira Code*).
|
||||||
|
- Exemple de palette :
|
||||||
|
```plaintext
|
||||||
|
#064E3B (vert foncé) | #CCA066 (beige chaud) | #D65D0E (orange rouille) | #282828 (noir profond)
|
||||||
|
|
||||||
|
---
|
||||||
|
2. Fonctionnalités Principales
|
||||||
|
|
||||||
|
2.1 Gestion des Jardins
|
||||||
|
|
||||||
|
Fonctionnalité: Création/Modification
|
||||||
|
Description: Ajout d’un jardin avec :
|
||||||
|
Exemple UI:
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Nom, description.
|
||||||
|
Exemple UI:
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Type : Plein air / Serre.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/CCA066/000?text=Serre
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Coordonnées géographiques (latitude/longitude) + carte interactive (Mapbox/Leaflet).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/064E3B/000?text=📍
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Exposition (Nord/Sud/Est/Ouest) + angle d’inclinaison.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/D65D0E/000?text=⚡
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Dimensions : Longueur × Largeur (en m²) + géométrie (grille de cases pour planter).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/282828/CCA066?text=📐
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Images
|
||||||
|
Description: Upload de photos du jardin (avec géotagging).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/064E3B/CCA066?text=📸
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Climat
|
||||||
|
Description: Suivi des paramètres :
|
||||||
|
Exemple UI:
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Température du sol (capteur ou saisie manuelle).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/D65D0E/000?text=🌡️
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Température de l’air (API OpenWeatherMap).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/CCA066/000?text=☀️
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Humidité (capteur ou % manuel).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/064E3B/000?text=💧
|
||||||
|
|
||||||
|
---
|
||||||
|
2.2 Gestion des Plantes
|
||||||
|
|
||||||
|
Fonctionnalité: Fiche Plante
|
||||||
|
Description: Ajout/modification avec :
|
||||||
|
Exemple UI:
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Nom scientifique et commun.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/CCA066/000?text=Tomate
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Famille botanique.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/064E3B/000?text=Solanacées
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Type : Arbuste/Arbre/Légume/Fleur.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/D65D0E/000?text=🌱
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Cycle de vie : Annuel/Bisannuel/Pérenne.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/282828/CCA066?text=⏳
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Exigences : Lumière (soleil/mi-ombre/ombre), pH du sol, drainage.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/064E3B/000?text=💧☀️
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Planning de Plantation
|
||||||
|
Description: Calendrier avec :
|
||||||
|
Exemple UI:
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Date de plantation/semis.
|
||||||
|
Exemple UI: https://via.placeholder.com/30/D65D0E/000?text=📅
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Espacement entre plants (cm).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/CCA066/000?text=30cm
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité:
|
||||||
|
Description: - Conseils : Période lunaire idéale (intégré au calendrier lunaire).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/064E3B/CCA066?text=🌕
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Suivi de Croissance
|
||||||
|
Description: Photos + notes sur l’état (maladies, croissance).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/282828/000?text=📊
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Récolte
|
||||||
|
Description: Date de récolte + rendement estimé (kg/m²).
|
||||||
|
Exemple UI: https://via.placeholder.com/30/D65D0E/000?text=🍅
|
||||||
|
|
||||||
|
---
|
||||||
|
2.3 Calendrier Lunaire
|
||||||
|
|
||||||
|
- Intégration d’une API comme AstroAPI pour afficher :
|
||||||
|
- Phases de la lune (croissante/décroissante).
|
||||||
|
- Jours favorables/défavorables pour planter/repirer.
|
||||||
|
- Exemple de notification :
|
||||||
|
"Aujourd’hui est un jour favorable pour planter des légumes-feuilles (ex : laitue). Évitez les racines."
|
||||||
|
|
||||||
|
---2.4 Planning et Tâches
|
||||||
|
|
||||||
|
- Liste de tâches (type Todo) avec :
|
||||||
|
- Arrosage (fréquence + volume).
|
||||||
|
- Taille/Engrais.
|
||||||
|
- Lutte contre les parasites.
|
||||||
|
- Rappels push (notifications mobiles).
|
||||||
|
- Exemple :
|
||||||
|
"Arroser les tomates tous les 2 jours (1L/plant). → [✅ Terminé] / [📅 15/06]."
|
||||||
|
|
||||||
|
---2.5 Géolocalisation et Cartographie
|
||||||
|
|
||||||
|
- Carte interactive (Mapbox/Leaflet) :
|
||||||
|
- Affichage des jardins avec leurs cases de plantation.
|
||||||
|
- Superposition des données météo (température, pluie).
|
||||||
|
- Exemple :
|
||||||
|
https://via.placeholder.com/400x300/282828/CCA066?text=🌍+Jardins+🌱
|
||||||
|
|
||||||
|
---2.6 Améliorations Avancées
|
||||||
|
|
||||||
|
Fonctionnalité: Détection d’espèces via photo
|
||||||
|
Description: Upload d’une photo d’une plante → reconnaissance via TensorFlow.js (modèle pré-entraîné comme
|
||||||
|
PlantNet).
|
||||||
|
Technologie Sugérée: TensorFlow.js + Firebase Storage
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Capteurs IoT
|
||||||
|
Description: Intégration de capteurs (température/humidité) via Raspberry Pi + MQTT.
|
||||||
|
Technologie Sugérée: Node-RED + MQTT Broker
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Recommandations IA
|
||||||
|
Description: Suggestions personnalisées (ex : "Votre sol est trop sec, ajoutez du compost").
|
||||||
|
Technologie Sugérée: Python (Flask) + Scikit-learn
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Partage communautaire
|
||||||
|
Description: Forum pour échanger des conseils entre utilisateurs.
|
||||||
|
Technologie Sugérée: Firebase Auth + Forums (Discourse)
|
||||||
|
────────────────────────────────────────
|
||||||
|
Fonctionnalité: Analyse des données
|
||||||
|
Description: Graphiques de croissance (ex : courbe de température vs rendement).
|
||||||
|
Technologie Sugérée: Chart.js + D3.js
|
||||||
|
|
||||||
|
---
|
||||||
|
3. Architecture Technique
|
||||||
|
|
||||||
|
Frontend
|
||||||
|
|
||||||
|
- Framework : React.js (avec TypeScript pour la typage).
|
||||||
|
- UI Library : Material-UI (thème personnalisé Gruvbox) ou Tailwind CSS.
|
||||||
|
- Responsive : Breakpoints pour mobile/tablette/desktop.
|
||||||
|
- Cartographie : Mapbox GL JS ou Leaflet.
|
||||||
|
|
||||||
|
Backend
|
||||||
|
|
||||||
|
- Langage : Node.js + Express.
|
||||||
|
- Base de données :
|
||||||
|
- SQL : PostgreSQL (pour les relations complexes).
|
||||||
|
- NoSQL : Firebase (pour les données utilisateurs et temps réel).
|
||||||
|
- APIs externes :
|
||||||
|
- OpenWeatherMap (météo).
|
||||||
|
- AstroAPI (calendrier lunaire).
|
||||||
|
- Google Maps API (géolocalisation).
|
||||||
|
|
||||||
|
Mobile
|
||||||
|
|
||||||
|
- Hybride : React Native (si besoin d’une app dédiée).
|
||||||
|
- PWA : Progressive Web App pour une expérience offline possible.
|
||||||
|
|
||||||
|
Hébergement
|
||||||
|
|
||||||
|
- Frontend : Vercel/Netlify.
|
||||||
|
- Backend : Render/Heroku.
|
||||||
|
- Base de données : Supabase (PostgreSQL) ou Firebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
4. Design & UI/UX
|
||||||
|
|
||||||
|
Thème Gruvbox Dark Seventies
|
||||||
|
|
||||||
|
- Couleurs :
|
||||||
|
- Fond : #282828 (noir profond).
|
||||||
|
- Accents : #D65D0E (orange rouille), #CCA066 (beige).
|
||||||
|
- Texte : #A89984 (beige clair).
|
||||||
|
- Typographie :
|
||||||
|
- Police : Fira Code (monospace) ou Courier New pour un côté rétro.
|
||||||
|
- Taille : 16px (corps) / 24px (titres).
|
||||||
|
- Icônes : Feather Icons ou Material Icons.
|
||||||
|
- Animations :
|
||||||
|
- Effets subtils (hover sur les boutons).
|
||||||
|
- Loading spinner en forme de lune croissante.
|
||||||
|
|
||||||
|
Maquettes
|
||||||
|
|
||||||
|
- Figma/Adobe XD : Créer des wireframes pour :
|
||||||
|
- Page d’accueil (tableau de bord).
|
||||||
|
- Fiche jardin.
|
||||||
|
- Planning des tâches.
|
||||||
|
- Carte interactive.
|
||||||
|
|
||||||
|
---5. Roadmap & Brainstorming
|
||||||
|
|
||||||
|
Phase 1 (MVP - 4 semaines)
|
||||||
|
|
||||||
|
┌──────────────────────────────┬──────────┬──────────────────────────────────────────────────────┐
|
||||||
|
│ Tâche │ Priorité │ Description │
|
||||||
|
├──────────────────────────────┼──────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ Authentification utilisateur │ ⭐⭐⭐ │ Firebase Auth (email/password + Google). │
|
||||||
|
├──────────────────────────────┼──────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ Gestion des jardins │ ⭐⭐⭐ │ CRUD (Create/Read/Update/Delete) avec carte Leaflet. │
|
||||||
|
├──────────────────────────────┼──────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ Plantes (fiches) │ ⭐⭐⭐ │ Base de données avec images uploadées. │
|
||||||
|
├──────────────────────────────┼──────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ Calendrier lunaire │ ⭐⭐ │ Intégration API AstroAPI + affichage des phases. │
|
||||||
|
├──────────────────────────────┼──────────┼──────────────────────────────────────────────────────┤
|
||||||
|
│ Planning tâches │ ⭐⭐ │ Liste avec rappels (notifications locales). │
|
||||||
|
└──────────────────────────────┴──────────┴──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Phase 2 (Améliorations - 6 semaines)
|
||||||
|
|
||||||
|
┌─────────────────────────────┬──────────┬─────────────────────────────────────────────────────┐
|
||||||
|
│ Tâche │ Priorité │ Description │
|
||||||
|
├─────────────────────────────┼──────────┼─────────────────────────────────────────────────────┤
|
||||||
|
│ Détection d’espèces (photo) │ ⭐⭐⭐ │ Modèle TensorFlow.js + Firebase Storage. │
|
||||||
|
├─────────────────────────────┼──────────┼─────────────────────────────────────────────────────┤
|
||||||
|
│ Capteurs IoT (optionnel) │ ⭐⭐ │ Raspberry Pi + MQTT pour les données en temps réel. │
|
||||||
|
├─────────────────────────────┼──────────┼─────────────────────────────────────────────────────┤
|
||||||
|
│ Analyse de données │ ⭐⭐ │ Graphiques de croissance (Chart.js). │
|
||||||
|
├─────────────────────────────┼──────────┼─────────────────────────────────────────────────────┤
|
||||||
|
│ Partage communautaire │ ⭐ │ Forum intégré (Discourse ou Firebase Forum). │
|
||||||
|
└─────────────────────────────┴──────────┴─────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Brainstorming Futur
|
||||||
|
|
||||||
|
- AR : Filtre réalité augmentée pour visualiser les plantes dans son jardin.
|
||||||
|
- Drone : Intégration de photos aériennes (via API DroneKit).
|
||||||
|
- Marketplace : Vente/achat de graines/plantes entre utilisateurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
6. Exigences Techniques
|
||||||
|
|
||||||
|
┌───────────────┬───────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Catégorie │ Détails │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Compatibilité │ - Navigateurs : Chrome, Firefox, Safari (mobile/desktop). │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ - Résolution : Adapté à 320px (mobile) à 1920px (desktop). │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Performance │ - Temps de chargement < 2s (optimisation images + lazy loading). │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Sécurité │ - HTTPS obligatoire. │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ - Chiffrement des données utilisateurs (Firebase Security Rules). │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Accessibilité │ - Conforme WCAG (contrastes, sous-titres pour vidéos). │
|
||||||
|
├───────────────┼───────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tests │ - Tests unitaires (Jest) + tests E2E (Cypress). │
|
||||||
|
└───────────────┴───────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
---
|
||||||
|
7. Livrables
|
||||||
|
|
||||||
|
1. Code source :
|
||||||
|
- Repository GitHub/GitLab avec README détaillé.
|
||||||
|
- Documentation technique (API, installation).
|
||||||
|
2. Maquettes :
|
||||||
|
- Fichiers Figma/Adobe XD pour le design.
|
||||||
|
3. Base de données :
|
||||||
|
- Schema PostgreSQL + données d’exemple.
|
||||||
|
4. Démonstration :
|
||||||
|
- Vidéo Loom (10 min) montrant les fonctionnalités clés.
|
||||||
|
5. Documentation utilisateur :
|
||||||
|
- Guide PDF avec captures d’écran (ex : "Comment ajouter un jardin ?").
|
||||||
|
|
||||||
|
---
|
||||||
|
📌 Notes Supplémentaires
|
||||||
|
|
||||||
|
- Noms de variables : Utiliser des noms explicites (ex : userJardins au lieu de j).
|
||||||
|
- Internationalisation : Prévoir un système i18n (ex : français/anglais) via react-i18next.
|
||||||
|
- Feedback : Intégrer un système de feedback (ex : "Cette fonctionnalité est-elle utile ?").
|
||||||
|
|
||||||
|
---🚀 Prêt à commencer !
|
||||||
|
Merci de suivre cette consigne pour livrer une application fonctionnelle, esthétique et scalable. Pour les
|
||||||
|
ajustements, priorisez toujours l’UX et la performance.
|
||||||
|
|
||||||
|
---Inspiré par : Gruvbox, PlantNet, et les jardins potagers des années 70.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Consigne ClaudeCode — Développement d’une Web App de gestion de jardins
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Concevoir et développer une **web app hébergée**, en **français**, **responsive mobile-first** (compatible smartphone), permettant de gérer un ou plusieurs jardins (plein air et serre), leurs cultures, les plants, les tâches et la planification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contraintes globales
|
||||||
|
|
||||||
|
- **Langue UI** : Français.
|
||||||
|
- **Plateformes** : Web (desktop + smartphone), PWA souhaitée.
|
||||||
|
- **Hébergement** : application auto-hébergeable (Docker recommandé).
|
||||||
|
- **Design** : thème **Gruvbox Dark – seventies**.
|
||||||
|
- **Sécurité** : authentification utilisateur, permissions minimales, sauvegardes.
|
||||||
|
- **Architecture** : API + frontend séparés (ou monolithe propre), documentation incluse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vision produit
|
||||||
|
|
||||||
|
L’application doit centraliser :
|
||||||
|
|
||||||
|
1. La gestion des jardins (zones, caractéristiques, météo locale).
|
||||||
|
2. Le suivi des plants (variétés, stades, actions culturales).
|
||||||
|
3. Le planning (plantation, entretien, récolte, tâches).
|
||||||
|
4. Une aide à la décision (saisons, calendrier lunaire, alertes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnalités attendues
|
||||||
|
|
||||||
|
## 1) Gestion des jardins
|
||||||
|
|
||||||
|
Pour chaque jardin :
|
||||||
|
|
||||||
|
- Nom, description.
|
||||||
|
- Type : **plein air** ou **serre**.
|
||||||
|
- Coordonnées géographiques (lat/lon).
|
||||||
|
- Adresse facultative.
|
||||||
|
- Photos/images.
|
||||||
|
- Exposition (N, NE, E, SE, S, SO, O, NO + heures d’ensoleillement).
|
||||||
|
- Température du sol (manuel + capteur possible).
|
||||||
|
- Température de l’air (manuel + capteur possible).
|
||||||
|
- Humidité (air et/ou sol si dispo).
|
||||||
|
|
||||||
|
### Géométrie du jardin (mode “cases”)
|
||||||
|
|
||||||
|
- Représentation en grille (cases).
|
||||||
|
- Dimensions configurables (ex: 10x20 cases).
|
||||||
|
- Chaque case peut avoir : culture en place, état, historique, notes.
|
||||||
|
- Vue visuelle couleur par culture/stade/occupation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Gestion des plants et cultures
|
||||||
|
|
||||||
|
Pour chaque variété/plant :
|
||||||
|
|
||||||
|
- Nom commun + nom botanique.
|
||||||
|
- Type (légume, fruit, aromatique, fleur, etc.).
|
||||||
|
- Variété/cultivar.
|
||||||
|
- Durée de germination estimée.
|
||||||
|
- Besoins (eau, température, ensoleillement, espacement).
|
||||||
|
- Compatibilités/incompatibilités de culture.
|
||||||
|
- Périodes recommandées (semis, repiquage, récolte).
|
||||||
|
|
||||||
|
### Suivi cycle de vie
|
||||||
|
|
||||||
|
- Semis
|
||||||
|
- Repiquage
|
||||||
|
- Croissance
|
||||||
|
- Floraison/fructification
|
||||||
|
- Récolte
|
||||||
|
- Fin de culture
|
||||||
|
|
||||||
|
Historique horodaté des événements par plant/zone/case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Planning & tâches
|
||||||
|
|
||||||
|
- Création de tâches (ponctuelles/récurrentes).
|
||||||
|
- Catégories : semis, arrosage, taille, traitement, récolte, observation, maintenance serre.
|
||||||
|
- Priorités, échéances, rappels.
|
||||||
|
- Vue liste + kanban + agenda.
|
||||||
|
- Liaison tâche ↔ jardin/zone/plant/case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Calendrier cultural + calendrier lunaire
|
||||||
|
|
||||||
|
- Calendrier mensuel des actions recommandées.
|
||||||
|
- Intégration d’un **calendrier lunaire** (jours racine/feuille/fleur/fruit, etc.).
|
||||||
|
- Suggestion d’actions selon type de culture + phase lunaire.
|
||||||
|
- Paramétrable (activer/désactiver influence lunaire).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Tableaux de bord
|
||||||
|
|
||||||
|
- Vue “Aujourd’hui” : tâches du jour, alertes, actions à faire.
|
||||||
|
- Vue “Jardin” : état d’occupation des cases, cultures en cours.
|
||||||
|
- Vue “Récoltes” : prévisions et historique.
|
||||||
|
- Indicateurs : taux d’occupation, tâches en retard, rendement estimé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Média & observations
|
||||||
|
|
||||||
|
- Upload photos par jardin/plant/tâche.
|
||||||
|
- Galerie filtrable.
|
||||||
|
- Notes libres datées (journal de culture).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Brainstorming d’améliorations (roadmap)
|
||||||
|
|
||||||
|
- Détection de variétés par photo (IA).
|
||||||
|
- Détection maladies/carences via photo.
|
||||||
|
- OCR d’étiquettes de semences.
|
||||||
|
- Connexion capteurs (temp sol/air, humidité, météo locale).
|
||||||
|
- Alertes intelligentes (gel, stress hydrique, canicule).
|
||||||
|
- Suggestions automatiques de rotation des cultures.
|
||||||
|
- Gestion de stock (graines, substrats, engrais).
|
||||||
|
- Export PDF/CSV des plannings et historiques.
|
||||||
|
- Multi-utilisateurs / partage familial.
|
||||||
|
- Mode hors-ligne (PWA) + synchro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exigences UX/UI
|
||||||
|
|
||||||
|
- Mobile-first (navigation simple au pouce).
|
||||||
|
- Performances correctes sur smartphone milieu de gamme.
|
||||||
|
- Accessibilité (contraste, taille police, focus clavier).
|
||||||
|
- Thème visuel : **Gruvbox Dark seventies** (palette cohérente sur toute l’app).
|
||||||
|
|
||||||
|
### Référence thème (indicative)
|
||||||
|
|
||||||
|
- Background principal: `#282828`
|
||||||
|
- Background secondaire: `#3c3836`
|
||||||
|
- Texte principal: `#ebdbb2`
|
||||||
|
- Texte secondaire: `#a89984`
|
||||||
|
- Accent vert: `#b8bb26`
|
||||||
|
- Accent jaune: `#fabd2f`
|
||||||
|
- Accent bleu: `#83a598`
|
||||||
|
- Accent orange: `#fe8019`
|
||||||
|
- Erreur rouge: `#fb4934`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exigences techniques
|
||||||
|
|
||||||
|
- API documentée (OpenAPI souhaité).
|
||||||
|
- Base de données relationnelle (PostgreSQL recommandé).
|
||||||
|
- Stockage images local ou S3-compatible.
|
||||||
|
- Auth sécurisée (session ou JWT), gestion des rôles.
|
||||||
|
- Logs, monitoring, sauvegarde/restauration.
|
||||||
|
- Déploiement Docker Compose.
|
||||||
|
- Tests minimaux (unitaires + parcours critiques).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Livrables attendus
|
||||||
|
|
||||||
|
1. Cahier d’architecture technique.
|
||||||
|
2. Schéma de données (ERD).
|
||||||
|
3. Maquettes principales (mobile + desktop).
|
||||||
|
4. MVP fonctionnel déployable.
|
||||||
|
5. Documentation d’installation/exploitation.
|
||||||
|
6. Backlog priorisé (MVP / V2 / V3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorisation MVP (ordre conseillé)
|
||||||
|
|
||||||
|
1. Auth + gestion des jardins.
|
||||||
|
2. Grille des cases + cultures.
|
||||||
|
3. Fiches plants.
|
||||||
|
4. Tâches + planning.
|
||||||
|
5. Journal + photos.
|
||||||
|
6. Calendrier lunaire simple.
|
||||||
|
7. Tableau de bord.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères de réussite
|
||||||
|
|
||||||
|
- Utilisable à 100% depuis smartphone.
|
||||||
|
- Suivi complet d’un cycle cultural réel.
|
||||||
|
- Planification claire des tâches et récoltes.
|
||||||
|
- Interface stable, rapide, compréhensible.
|
||||||
|
- Base saine pour extensions IA/capteurs.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# CONSIGNE — Claude Code — Webapp “Gestion Jardin” (Self-hosted, mobile-friendly)
|
||||||
|
|
||||||
|
## 0) Objectif
|
||||||
|
Développer une webapp self-hosted (Docker) **en français**, **compatible smartphone**, permettant de gérer un ou plusieurs jardins (extérieur/serre) et la gestion complète des plants : fiche plante, planning (plantation, entretien, culture, récolte), tâches, calendrier (dont lunaire), et données environnementales (températures, humidité). UI : **Gruvbox Dark “seventies”** (vintage, contrasté, très lisible).
|
||||||
|
|
||||||
|
## 1) Contraintes générales
|
||||||
|
- Déploiement : **Docker Compose** (service backend + frontend + DB).
|
||||||
|
- Stockage : **SQLite par défaut** (volume persistant). Prévoir migration future vers Postgres.
|
||||||
|
- Accès : application locale (LAN) via reverse proxy possible mais non obligatoire.
|
||||||
|
- Auth : MVP sans auth complexe (optionnel). Prévoir un futur module “auth”.
|
||||||
|
- Données : possibilité d’import/export (JSON) et sauvegarde DB.
|
||||||
|
- Responsive : **mobile-first** + desktop ok.
|
||||||
|
- Performance : chargements rapides, pagination/lazy-loading images.
|
||||||
|
|
||||||
|
## 2) Périmètre fonctionnel MVP (Phase 1)
|
||||||
|
### 2.1 Gestion des jardins
|
||||||
|
Un jardin est une “zone cultivée” décrite par :
|
||||||
|
- Nom, description, type : **plein air / serre / tunnel**
|
||||||
|
- Coordonnées : latitude/longitude, altitude (optionnel)
|
||||||
|
- Adresse/lieu (optionnel)
|
||||||
|
- Exposition : nord/sud/est/ouest + ombre/mi-ombre/plein soleil
|
||||||
|
- Sol : type (argileux, sableux, limoneux, humifère…), pH (optionnel), amendements (optionnel)
|
||||||
|
- **Images** (galerie)
|
||||||
|
- Capteurs (valeurs manuelles MVP) :
|
||||||
|
- température sol
|
||||||
|
- température air
|
||||||
|
- humidité air
|
||||||
|
- humidité sol (optionnel)
|
||||||
|
- date/heure de mesure, source (manuel/capteur)
|
||||||
|
- Géométrie du jardin en “cases” (grille) : voir 2.3
|
||||||
|
|
||||||
|
Fonctions :
|
||||||
|
- CRUD jardins
|
||||||
|
- Fiche jardin
|
||||||
|
- Galerie photos
|
||||||
|
- Saisie rapide “mesure du jour” (temp/humidité)
|
||||||
|
|
||||||
|
### 2.2 Gestion des plants (plantes/cultures)
|
||||||
|
Deux concepts :
|
||||||
|
1) **Variété** (catalogue) : “Tomate Andine Cornue”, “Courgette Verte…”
|
||||||
|
2) **Plantation** (instance) : variété X plantée dans jardin Y à une date et une case/grille.
|
||||||
|
|
||||||
|
Champs “Variété” (catalogue) :
|
||||||
|
- Nom commun, variété, famille (Solanacées…), tags
|
||||||
|
- Périodes conseillées : semis intérieur, semis extérieur, repiquage, plantation, récolte (fenêtres)
|
||||||
|
- Besoins : eau (faible/moyen/fort), soleil, espacement, température min, durée de culture
|
||||||
|
- Profondeur semis, type de sol conseillé
|
||||||
|
- Notes personnelles, photos
|
||||||
|
|
||||||
|
Champs “Plantation” (instance) :
|
||||||
|
- Jardin, zone/case, date semis/plantation/repiquage
|
||||||
|
- Quantité (nb plants), statut (prévu/en cours/terminé/échoué)
|
||||||
|
- Historique des actions (arrosage, taille, traitement, etc.)
|
||||||
|
- Dates réelles (récolte début/fin), rendement estimé/réel (optionnel MVP)
|
||||||
|
- Observations et photos
|
||||||
|
|
||||||
|
Fonctions :
|
||||||
|
- CRUD variété
|
||||||
|
- CRUD plantation (avec placement sur grille)
|
||||||
|
- Vue “planning” (par semaine/mois) des actions à venir
|
||||||
|
|
||||||
|
### 2.3 Géométrie du jardin (cases / grille)
|
||||||
|
MVP : représentation en **grille 2D** configurable (ex: 6×4).
|
||||||
|
- Chaque case peut avoir :
|
||||||
|
- un libellé (A1, A2…)
|
||||||
|
- des dimensions (optionnel)
|
||||||
|
- un état (libre/occupée)
|
||||||
|
- des plantations associées (actives + historiques)
|
||||||
|
- Interaction :
|
||||||
|
- tap/clic sur case → détails + actions (ajouter plantation, marquer libre, notes)
|
||||||
|
|
||||||
|
### 2.4 Gestion des tâches et planning
|
||||||
|
- Tâches :
|
||||||
|
- titre, description, jardin, plantation liée (optionnel), priorité, échéance, récurrence simple
|
||||||
|
- statut : à faire / en cours / fait / annulé
|
||||||
|
- Vues :
|
||||||
|
- “Aujourd’hui”
|
||||||
|
- “Semaine”
|
||||||
|
- “Backlog”
|
||||||
|
- Notifications : hors-scope MVP (préparer hooks)
|
||||||
|
|
||||||
|
### 2.5 Calendrier lunaire (MVP simple)
|
||||||
|
MVP : afficher pour chaque jour :
|
||||||
|
- phase (nouvelle lune, 1er quartier, pleine lune, dernier quartier)
|
||||||
|
- indicateur “lune montante/descendante” si source disponible
|
||||||
|
- filtres “jours racines/feuilles/fleurs/fruits” : optionnel
|
||||||
|
|
||||||
|
Implémentation :
|
||||||
|
- Soit calcul astronomique via lib (si fiable),
|
||||||
|
- soit dataset embarqué (année en cours + suivante) importable.
|
||||||
|
|
||||||
|
## 3) Brainstorming d’améliorations (Phase 2+)
|
||||||
|
### 3.1 “Smart features”
|
||||||
|
- Détection photo (mobile) :
|
||||||
|
- reconnaissance variété / espèce (suggestion, pas décision)
|
||||||
|
- détection maladies / carences (suggestion)
|
||||||
|
- suivi de croissance (comparaison de photos)
|
||||||
|
- Suggestions automatiques :
|
||||||
|
- alertes gel / canicule selon localisation + météo
|
||||||
|
- arrosage estimé selon température/humidité/historique
|
||||||
|
- rotation des cultures et associations bénéfiques
|
||||||
|
- Import/export :
|
||||||
|
- import semences / catalogue depuis CSV
|
||||||
|
- export journal des récoltes
|
||||||
|
|
||||||
|
### 3.2 Capteurs réels (futur)
|
||||||
|
- Intégration Home Assistant / MQTT (module)
|
||||||
|
- Courbes de température/humidité
|
||||||
|
- Tableau de bord “serre” temps réel
|
||||||
|
|
||||||
|
### 3.3 Multi-jardin / multi-site
|
||||||
|
- Gestion de plusieurs lieux (ex: maison / potager secondaire)
|
||||||
|
- Synchronisation & sauvegardes
|
||||||
|
|
||||||
|
## 4) UX / UI (obligatoire)
|
||||||
|
### 4.1 Thème visuel
|
||||||
|
- Style : **Gruvbox Dark** + “seventies” (vintage, chaleureux, lisible)
|
||||||
|
- Contraintes :
|
||||||
|
- contrastes élevés, gros boutons mobile
|
||||||
|
- cartes (cards) avec bord arrondi, ombres légères
|
||||||
|
- typographie simple, lisibilité prioritaire
|
||||||
|
- Composants récurrents :
|
||||||
|
- Header fixe avec navigation (Jardins / Plants / Planning / Tâches / Calendrier lunaire / Settings)
|
||||||
|
- Drawer mobile (menu burger)
|
||||||
|
- Panneau filtre/tri sur listes
|
||||||
|
|
||||||
|
### 4.2 Pages MVP
|
||||||
|
1) Dashboard : résumé (tâches du jour, mesures récentes, plantations actives)
|
||||||
|
2) Jardins : liste + création + fiche jardin
|
||||||
|
3) Grille jardin : vue cases + détails
|
||||||
|
4) Catalogue variétés : liste + fiche
|
||||||
|
5) Plantations : liste (filtrable) + création + fiche
|
||||||
|
6) Planning : calendrier (mois/semaine) + actions
|
||||||
|
7) Tâches : Kanban simple ou liste
|
||||||
|
8) Calendrier lunaire : vue mois + détails jour
|
||||||
|
9) Settings : unités, localisation par défaut, export/import, sauvegarde
|
||||||
|
|
||||||
|
### 4.3 Filtres “judicieux” (brainstorming)
|
||||||
|
- Jardins : type, exposition, serre/extérieur, tags, dernier relevé capteur
|
||||||
|
- Variétés : famille, saison, besoin eau, soleil, durée culture, tags
|
||||||
|
- Plantations : jardin, case, statut, période (en cours/à venir/terminé), “à récolter”
|
||||||
|
- Tâches : priorité, échéance, jardin, plantation liée, statut, récurrence
|
||||||
|
|
||||||
|
## 5) Architecture technique (choix par défaut)
|
||||||
|
### Backend
|
||||||
|
- Python **FastAPI**
|
||||||
|
- ORM : SQLModel (ou SQLAlchemy)
|
||||||
|
- SQLite par défaut
|
||||||
|
- Gestion uploads images : stockage local `/data/uploads` + métadonnées DB
|
||||||
|
- API REST :
|
||||||
|
- CRUD jardins, cases, variétés, plantations, tâches, mesures
|
||||||
|
- endpoints de recherche + filtres
|
||||||
|
- export/import JSON
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Vue 3 + Vite (ou React si préféré)
|
||||||
|
- UI kit minimal (ou Tailwind) en respectant le thème Gruvbox
|
||||||
|
- Mobile-first, PWA optionnelle (phase 2)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `docker-compose.yml` :
|
||||||
|
- backend
|
||||||
|
- frontend (static)
|
||||||
|
- volume DB + uploads
|
||||||
|
|
||||||
|
## 6) Modèle de données (MVP — tables)
|
||||||
|
- gardens
|
||||||
|
- garden_cells
|
||||||
|
- garden_images
|
||||||
|
- measurements (air_temp, soil_temp, humidity_air, humidity_soil, ts, garden_id)
|
||||||
|
- plant_varieties
|
||||||
|
- plant_images
|
||||||
|
- plantings
|
||||||
|
- planting_events (arrosage, taille, traitement, observation)
|
||||||
|
- tasks
|
||||||
|
- lunar_calendar_entries (dataset) OU table “computed cache”
|
||||||
|
- user_settings (local)
|
||||||
|
|
||||||
|
## 7) API (MVP — exemples d’endpoints)
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET/POST /api/gardens`
|
||||||
|
- `GET/PUT/DELETE /api/gardens/{id}`
|
||||||
|
- `GET/POST /api/gardens/{id}/cells`
|
||||||
|
- `GET/POST /api/varieties`
|
||||||
|
- `GET/POST /api/plantings`
|
||||||
|
- `GET/POST /api/tasks`
|
||||||
|
- `GET/POST /api/measurements`
|
||||||
|
- `GET /api/lunar?month=YYYY-MM`
|
||||||
|
- `POST /api/export`
|
||||||
|
- `POST /api/import`
|
||||||
|
|
||||||
|
## 8) Règles qualité
|
||||||
|
- Validation stricte des champs (pydantic)
|
||||||
|
- Gestion erreurs claire côté UI
|
||||||
|
- Tests basiques backend (CRUD + filtres)
|
||||||
|
- Logs structurés backend
|
||||||
|
- Pas de secrets dans le frontend (variables d’env côté backend)
|
||||||
|
|
||||||
|
## 9) Livrables attendus
|
||||||
|
- Arborescence complète projet
|
||||||
|
- `README.md` (install, run, backup)
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- Backend FastAPI prêt
|
||||||
|
- Frontend complet pages MVP
|
||||||
|
- Thème gruvbox dark seventies appliqué partout
|
||||||
|
- Données de démo (seed) : 1 jardin + quelques variétés + plantations + tâches
|
||||||
|
|
||||||
|
## 10) Ordre de réalisation imposé
|
||||||
|
1) Modèle DB + CRUD jardins/variétés/plantations/tâches
|
||||||
|
2) Upload images + galerie
|
||||||
|
3) Vue grille jardin + placement plantations
|
||||||
|
4) Planning calendrier + vues filtrées
|
||||||
|
5) Calendrier lunaire (dataset ou calcul)
|
||||||
|
6) Dashboard + export/import
|
||||||
|
7) Polissage UI mobile + perf + README final
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
Modèle prêt à l’emploi pour plantes
|
||||||
|
|
||||||
|
Un modèle YOLOv8s “Leaf Detection & Classification” est disponible sur Hugging Face.
|
||||||
|
Il peut détecter et classer différents types de feuilles de plantes directement, sans entraînement préalable.
|
||||||
|
|
||||||
|
1) Prérequis / Installation
|
||||||
|
|
||||||
|
Ouvre un terminal et installe ces dépendances :
|
||||||
|
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pip install ultralyticsplus==0.0.28 ultralytics==8.0.43 opencv-python matplotlib
|
||||||
|
|
||||||
|
ultralytics : bibliothèque YOLOv8
|
||||||
|
|
||||||
|
ultralyticsplus : extension recommandée
|
||||||
|
|
||||||
|
opencv-python + matplotlib : affichage images
|
||||||
|
|
||||||
|
2) Exemple de script Python detect_plants.py
|
||||||
|
|
||||||
|
Crée un fichier detect_plants.py :
|
||||||
|
|
||||||
|
from ultralyticsplus import YOLO, render_result
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# 1) Charger le modèle
|
||||||
|
model = YOLO("foduucom/plant-leaf-detection-and-classification")
|
||||||
|
|
||||||
|
# 2) Paramètres de détection
|
||||||
|
model.overrides['conf'] = 0.25
|
||||||
|
model.overrides['iou'] = 0.45
|
||||||
|
model.overrides['max_det'] = 1000
|
||||||
|
|
||||||
|
# 3) Chargement d’une image
|
||||||
|
image_path = "ma_plante.jpg"
|
||||||
|
|
||||||
|
# 4) Prédiction (détection + classification de feuilles)
|
||||||
|
results = model.predict(image_path)
|
||||||
|
|
||||||
|
# 5) Récupération des boîtes détectées
|
||||||
|
boxes = results[0].boxes
|
||||||
|
class_ids = results[0].boxes.cls
|
||||||
|
scores = results[0].boxes.conf
|
||||||
|
|
||||||
|
print("Détections :", len(boxes))
|
||||||
|
for i, box in enumerate(boxes):
|
||||||
|
print(f"- Classe {class_ids[i]}, score {scores[i]:.2f}")
|
||||||
|
|
||||||
|
# 6) Annoter l’image
|
||||||
|
annotated = render_result(model=model, image=image_path, result=results[0])
|
||||||
|
|
||||||
|
# 7) Afficher l’image annotée
|
||||||
|
annotated.show()
|
||||||
|
|
||||||
|
Points clés :
|
||||||
|
|
||||||
|
YOLO("foduucom/...") charge le modèle leaf detection YOLOv8.
|
||||||
|
|
||||||
|
predict(image_path) exécute l’inférence sur l’image locale.
|
||||||
|
|
||||||
|
3) Mode batch / dossier complet
|
||||||
|
|
||||||
|
Si tu veux traiter plusieurs images dans un dossier :
|
||||||
|
|
||||||
|
import glob
|
||||||
|
|
||||||
|
for file in glob.glob("images/*.jpg"):
|
||||||
|
results = model.predict(file)
|
||||||
|
|
||||||
|
print(f"== Résultats pour {file} ==")
|
||||||
|
for box in results[0].boxes:
|
||||||
|
print(box.cls, box.conf)
|
||||||
|
|
||||||
|
render_result(model=model, image=file, result=results[0]).show()
|
||||||
|
4) Comment adapter au “type de plante”
|
||||||
|
|
||||||
|
Le modèle d’origine peut classifier 46 classes de feuilles courantes (pommes, tomates, blé…) avec étiquette.
|
||||||
|
|
||||||
|
Si tu as ton propre dataset spécifique (autres plantes, fleurs, fruits) → il faut :
|
||||||
|
|
||||||
|
créer un dataset annoté en format YOLO
|
||||||
|
|
||||||
|
fine-tuner YOLOv8 dessus
|
||||||
|
|
||||||
|
utiliser train() de la bibliothèque ultralytics
|
||||||
|
|
||||||
|
📌 Feuille de consignes (Checklist)
|
||||||
|
📌 A) Préparation
|
||||||
|
|
||||||
|
Python 3.10+ installé
|
||||||
|
|
||||||
|
GPU disponible si possible (beaucoup + rapide)
|
||||||
|
|
||||||
|
Images bien cadrées (feuilles principales visibles)
|
||||||
|
|
||||||
|
📌 B) Installation
|
||||||
|
pip install ultralyticsplus ultralytics opencv-python matplotlib
|
||||||
|
📌 C) Tester une image
|
||||||
|
|
||||||
|
Place ma_plante.jpg dans le même dossier
|
||||||
|
|
||||||
|
Lance :
|
||||||
|
|
||||||
|
python detect_plants.py
|
||||||
|
📌 D) Résultats
|
||||||
|
|
||||||
|
Vérifier :
|
||||||
|
|
||||||
|
nombre de feuilles détectées
|
||||||
|
|
||||||
|
classes attribuées
|
||||||
|
|
||||||
|
score de confiance
|
||||||
|
|
||||||
|
📌 E) Prochaine étape (facultative)
|
||||||
|
|
||||||
|
Si tu veux augmenter la précision :
|
||||||
|
|
||||||
|
🔹 collecter plus d’images
|
||||||
|
🔹 annoter avec LabelImg ou Roboflow
|
||||||
|
🔹 entraîner/fine-tuner YOLOv8
|
||||||
|
🔹 tester sur mobile/web
|
||||||
|
|
||||||
|
Limitations à connaître
|
||||||
|
|
||||||
|
ce modèle est spécifique aux feuilles (pas fleurs/fruit complets).
|
||||||
|
|
||||||
|
performances variables selon qualité photo.
|
||||||
|
|
||||||
|
nécessite parfois GPU pour performance fluide.
|
||||||
|
|
||||||
|
Si tu veux, je peux aussi te fournir une version prête à l’emploi pour entraîner toi-même sur tes propres photos (annotation automatique + scripts d’entraînement YOLO).
|
||||||
|
Modèle prêt à l’emploi pour plantes
|
||||||
|
|
||||||
|
Un modèle YOLOv8s “Leaf Detection & Classification” est disponible sur Hugging Face.
|
||||||
|
Il peut détecter et classer différents types de feuilles de plantes directement, sans entraînement préalable.
|
||||||
|
|
||||||
|
1) Prérequis / Installation
|
||||||
|
|
||||||
|
Ouvre un terminal et installe ces dépendances :
|
||||||
|
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pip install ultralyticsplus==0.0.28 ultralytics==8.0.43 opencv-python matplotlib
|
||||||
|
|
||||||
|
ultralytics : bibliothèque YOLOv8
|
||||||
|
|
||||||
|
ultralyticsplus : extension recommandée
|
||||||
|
|
||||||
|
opencv-python + matplotlib : affichage images
|
||||||
|
|
||||||
|
2) Exemple de script Python detect_plants.py
|
||||||
|
|
||||||
|
Crée un fichier detect_plants.py :
|
||||||
|
|
||||||
|
from ultralyticsplus import YOLO, render_result
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# 1) Charger le modèle
|
||||||
|
model = YOLO("foduucom/plant-leaf-detection-and-classification")
|
||||||
|
|
||||||
|
# 2) Paramètres de détection
|
||||||
|
model.overrides['conf'] = 0.25
|
||||||
|
model.overrides['iou'] = 0.45
|
||||||
|
model.overrides['max_det'] = 1000
|
||||||
|
|
||||||
|
# 3) Chargement d’une image
|
||||||
|
image_path = "ma_plante.jpg"
|
||||||
|
|
||||||
|
# 4) Prédiction (détection + classification de feuilles)
|
||||||
|
results = model.predict(image_path)
|
||||||
|
|
||||||
|
# 5) Récupération des boîtes détectées
|
||||||
|
boxes = results[0].boxes
|
||||||
|
class_ids = results[0].boxes.cls
|
||||||
|
scores = results[0].boxes.conf
|
||||||
|
|
||||||
|
print("Détections :", len(boxes))
|
||||||
|
for i, box in enumerate(boxes):
|
||||||
|
print(f"- Classe {class_ids[i]}, score {scores[i]:.2f}")
|
||||||
|
|
||||||
|
# 6) Annoter l’image
|
||||||
|
annotated = render_result(model=model, image=image_path, result=results[0])
|
||||||
|
|
||||||
|
# 7) Afficher l’image annotée
|
||||||
|
annotated.show()
|
||||||
|
|
||||||
|
Points clés :
|
||||||
|
|
||||||
|
YOLO("foduucom/...") charge le modèle leaf detection YOLOv8.
|
||||||
|
|
||||||
|
predict(image_path) exécute l’inférence sur l’image locale.
|
||||||
|
|
||||||
|
3) Mode batch / dossier complet
|
||||||
|
|
||||||
|
Si tu veux traiter plusieurs images dans un dossier :
|
||||||
|
|
||||||
|
import glob
|
||||||
|
|
||||||
|
for file in glob.glob("images/*.jpg"):
|
||||||
|
results = model.predict(file)
|
||||||
|
|
||||||
|
print(f"== Résultats pour {file} ==")
|
||||||
|
for box in results[0].boxes:
|
||||||
|
print(box.cls, box.conf)
|
||||||
|
|
||||||
|
render_result(model=model, image=file, result=results[0]).show()
|
||||||
|
4) Comment adapter au “type de plante”
|
||||||
|
|
||||||
|
Le modèle d’origine peut classifier 46 classes de feuilles courantes (pommes, tomates, blé…) avec étiquette.
|
||||||
|
|
||||||
|
Si tu as ton propre dataset spécifique (autres plantes, fleurs, fruits) → il faut :
|
||||||
|
|
||||||
|
créer un dataset annoté en format YOLO
|
||||||
|
|
||||||
|
fine-tuner YOLOv8 dessus
|
||||||
|
|
||||||
|
utiliser train() de la bibliothèque ultralytics
|
||||||
|
|
||||||
|
📌 Feuille de consignes (Checklist)
|
||||||
|
📌 A) Préparation
|
||||||
|
|
||||||
|
Python 3.10+ installé
|
||||||
|
|
||||||
|
GPU disponible si possible (beaucoup + rapide)
|
||||||
|
|
||||||
|
Images bien cadrées (feuilles principales visibles)
|
||||||
|
|
||||||
|
📌 B) Installation
|
||||||
|
pip install ultralyticsplus ultralytics opencv-python matplotlib
|
||||||
|
📌 C) Tester une image
|
||||||
|
|
||||||
|
Place ma_plante.jpg dans le même dossier
|
||||||
|
|
||||||
|
Lance :
|
||||||
|
|
||||||
|
python detect_plants.py
|
||||||
|
📌 D) Résultats
|
||||||
|
|
||||||
|
Vérifier :
|
||||||
|
|
||||||
|
nombre de feuilles détectées
|
||||||
|
|
||||||
|
classes attribuées
|
||||||
|
|
||||||
|
score de confiance
|
||||||
|
|
||||||
|
📌 E) Prochaine étape (facultative)
|
||||||
|
|
||||||
|
Si tu veux augmenter la précision :
|
||||||
|
|
||||||
|
🔹 collecter plus d’images
|
||||||
|
🔹 annoter avec LabelImg ou Roboflow
|
||||||
|
🔹 entraîner/fine-tuner YOLOv8
|
||||||
|
🔹 tester sur mobile/web
|
||||||
|
|
||||||
|
Limitations à connaître
|
||||||
|
|
||||||
|
ce modèle est spécifique aux feuilles (pas fleurs/fruit complets).
|
||||||
|
|
||||||
|
performances variables selon qualité photo.
|
||||||
|
|
||||||
|
nécessite parfois GPU pour performance fluide.
|
||||||
|
|
||||||
|
Si tu veux, je peux aussi te fournir une version prête à l’emploi pour entraîner toi-même sur tes propres photos (annotation automatique + scripts d’entraînement YOLO).
|
||||||
|
Voici des outils et méthodes open-source que tu peux utiliser pour annoter automatiquement ou semi-automatiquement un dataset d’images (pour entraîner YOLOv8 ou tout autre modèle de détection), y compris pour des plantes, feuilles, fleurs, etc. :
|
||||||
|
|
||||||
|
🧩 Outils d’annotation (semi-automatique et automatique)
|
||||||
|
📌 1) Roboflow Annotate
|
||||||
|
|
||||||
|
Plateforme web qui propose des outils d’assistance par IA pour accélérer l’annotation :
|
||||||
|
|
||||||
|
Possibilité d’importer tes images et de générer des annotations avec de l’assistance IA (Label Assist / Auto Label).
|
||||||
|
|
||||||
|
Permet d’exporter les annotations en formats compatibles YOLO.
|
||||||
|
|
||||||
|
Gratuit jusqu’à certaines limites, fonctionne via interface web.
|
||||||
|
|
||||||
|
Pas besoin d’héberger toi-même un serveur.
|
||||||
|
|
||||||
|
📌 2) Auto-Annotation avec Autodistill + Grounding DINO
|
||||||
|
|
||||||
|
Une approche plus avancée pour annoter automatiquement des images :
|
||||||
|
|
||||||
|
Utilise des modèles de type Grounding DINO pour détecter des objets selon un texte (ex : “feuille”, “fleur”, “fruit”).
|
||||||
|
|
||||||
|
Génère ensuite automatiquement des fichiers d’annotation cachés au format YOLO.
|
||||||
|
|
||||||
|
Cette méthode diminue le travail humain car elle produit d’abord des étiquettes automatiques que l’on peut ensuite corriger.
|
||||||
|
|
||||||
|
C’est pratique si tu as beaucoup d’images brutes à annoter.
|
||||||
|
|
||||||
|
📌 3) CVAT (Computer Vision Annotation Tool)
|
||||||
|
|
||||||
|
Outil open source complet pour annotations :
|
||||||
|
|
||||||
|
Interface web pour annoter images et vidéos.
|
||||||
|
|
||||||
|
Supporte la pré-annotation automatique à partir de modèles pré-entraînés (tu peux importer un modèle YOLO).
|
||||||
|
|
||||||
|
Permet d’économiser du temps en générant des boîtes automatiquement puis en les corrigeant.
|
||||||
|
|
||||||
|
Convient bien pour des datasets de plantes.
|
||||||
|
|
||||||
|
👉 CVAT est l’un des outils les plus utilisés dans la communauté CV pour annotation manuelle assistée par IA ou modèles pré-entraînés.
|
||||||
|
|
||||||
|
📌 4) Outils open-source d’annotation classiques
|
||||||
|
|
||||||
|
Si tu veux annoter manuellement ou semi-manuellement :
|
||||||
|
|
||||||
|
LabelImg – outil graphique pour créer des boîtes et exporter en YOLO.
|
||||||
|
|
||||||
|
Label Studio – annotation flexible multi-tâches (mais souvent plus généraliste).
|
||||||
|
|
||||||
|
Autres outils listés (Yolo_Label, Make Sense, Scalabel, etc.) pour différents formats et workflows.
|
||||||
|
|
||||||
|
🧠 Méthodes d’annotation automatique (workflow avancé)
|
||||||
|
|
||||||
|
Voici comment automatiser ton annotation avant d’entraîner YOLO :
|
||||||
|
|
||||||
|
1. Pré-annotation automatique avec modèle existant
|
||||||
|
|
||||||
|
Charge un modèle de vision comme YOLOv8 ou un autre détecteur pré-entraîné.
|
||||||
|
|
||||||
|
Passe tes images au modèle pour générer des boîtes et labels provisoires.
|
||||||
|
|
||||||
|
Convertis ces détections en fichiers .txt YOLO.
|
||||||
|
|
||||||
|
Révise / corrige les erreurs ensuite (important car l’auto-annotation n’est pas parfaite).
|
||||||
|
|
||||||
|
2. Utiliser Segment Anything / Autodistill
|
||||||
|
|
||||||
|
Combine Segment Anything (SAM) avec un modèle de détection (ex : Grounding DINO).
|
||||||
|
|
||||||
|
Segment Anything fournit des masques / régions intéressantes.
|
||||||
|
|
||||||
|
Grounding DINO relie ces régions à des classes par texte.
|
||||||
|
|
||||||
|
Autodistill convertit ensuite ce résultat en étiquettes format YOLO.
|
||||||
|
|
||||||
|
Utile pour réduire le travail manuel et créer rapidement un dataset.
|
||||||
|
|
||||||
|
🔎 Résumé des possibilités
|
||||||
|
Outil / Méthode Automatique possible Semi-Automatique Export YOLO
|
||||||
|
Roboflow Annotate ✔️ (modèles IA intégrés) ✔️ ✔️
|
||||||
|
Autodistill + DINO ✔️ ⬜ ✔️
|
||||||
|
CVAT ✔️ (pré-annotation) ✔️ ✔️
|
||||||
|
LabelImg / Label Studio ⬜ ✔️ ✔️
|
||||||
|
⚠️ Conseils importants
|
||||||
|
|
||||||
|
Auto-annotation n’est jamais parfaite — vérifie et corrige toujours les annotations manuellement pour que le modèle que tu entraine apprenne bien.
|
||||||
|
|
||||||
|
L’annotation est souvent le goulot d’étranglement des projets CV : mieux les étiquettes sont, meilleur sera ton modèle.
|
||||||
|
|
||||||
|
Si tu veux, je peux te générer un script Python qui intègre Autodistill + Grounding DINO pour auto-annoter ton dossier d’images plantes en format YOLO. Veux-tu un tel script prêt à l’emploi ? (Si oui, dis-moi le système d’exploitation que tu utilises : Linux, Windows ou macOS.)
|
||||||
|
|
||||||
|
Voici un script Python auto-hôte complet pour annoter automatiquement ton propre dataset d’images (par exemple feuilles/fleurs/plantes) au format YOLO, en utilisant Autodistill + un modèle de type Grounding DINO pour générer automatiquement les fichiers d’annotation avant entraînement YOLOv8.
|
||||||
|
|
||||||
|
Ce workflow utilise l’outil Autodistill, qui combine des « base models » (par exemple GroundedSAM/Grounding DINO) pour auto-labeler des images et génère ensuite un dataset prêt à l’entraînement YOLOv8 — sans annotation manuelle préalable.
|
||||||
|
|
||||||
|
✅ 1) Installation des dépendances
|
||||||
|
|
||||||
|
Dans un terminal, crée un environnement Python propre et installe les librairies nécessaires :
|
||||||
|
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pip install autodistill autodistill-grounded-sam autodistill-yolov8 ultralytics opencv-python
|
||||||
|
|
||||||
|
autodistill : framework principal pour auto-annotation
|
||||||
|
|
||||||
|
autodistill-grounded-sam : combine Grounding DINO + SAM pour auto-labeling
|
||||||
|
|
||||||
|
autodistill-yolov8 : plugin pour entraîner YOLOv8 après génération
|
||||||
|
|
||||||
|
ultralytics, opencv-python : pour l’entraînement et le test YOLOv8
|
||||||
|
|
||||||
|
📁 2) Organisation du dataset
|
||||||
|
|
||||||
|
Place toutes tes images non annotées dans un dossier unique, par exemple :
|
||||||
|
|
||||||
|
dataset/
|
||||||
|
raw_images/
|
||||||
|
photo1.jpg
|
||||||
|
photo2.jpg
|
||||||
|
...
|
||||||
|
🧠 3) Script Python d’auto-annotation + préparation YOLO
|
||||||
|
|
||||||
|
Crée un fichier auto_annotate_and_export.py :
|
||||||
|
|
||||||
|
from autodistill_grounded_sam import GroundedSAM
|
||||||
|
from autodistill.detection import CaptionOntology
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 1) Définir l'ontologie
|
||||||
|
# =========================
|
||||||
|
# Chaque clé est une **description textuelle** que le modèle tentera
|
||||||
|
# de repérer dans l'image. La valeur est l'étiquette qui sera utilisée.
|
||||||
|
# Tu peux ajouter autant de classes que nécessaire :
|
||||||
|
ontology_map = {
|
||||||
|
"leaf of a plant": "leaf",
|
||||||
|
"flower of a plant": "flower",
|
||||||
|
"fruit of a plant": "fruit"
|
||||||
|
}
|
||||||
|
|
||||||
|
base_model = GroundedSAM(ontology=CaptionOntology(ontology_map))
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 2) Folder paths
|
||||||
|
# =========================
|
||||||
|
INPUT_FOLDER = "./dataset/raw_images"
|
||||||
|
OUTPUT_FOLDER = "./dataset/auto_labeled"
|
||||||
|
|
||||||
|
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# 3) Auto-label all images
|
||||||
|
# =========================
|
||||||
|
print("⏳ Auto-labeling images with GroundedSAM (Grounding DINO + SAM)...")
|
||||||
|
base_model.label_folder(
|
||||||
|
input_folder=INPUT_FOLDER,
|
||||||
|
output_folder=OUTPUT_FOLDER
|
||||||
|
)
|
||||||
|
|
||||||
|
print("🟢 Auto-labeling terminé !")
|
||||||
|
print(f"Annotations générées dans : {OUTPUT_FOLDER}")
|
||||||
|
📌 Ce que fait le script
|
||||||
|
|
||||||
|
Il lance GroundedSAM (basé sur Grounding DINO + SAM) pour analyser chaque image.
|
||||||
|
|
||||||
|
Il utilise les prompts textuels que tu définis dans la variable ontology_map pour associer chaque objet détecté à une classe.
|
||||||
|
|
||||||
|
Il écrit automatiquement les fichiers d’annotation au format YOLO dans le dossier auto_labeled.
|
||||||
|
|
||||||
|
Le résultat est un dataset prêt à l’entraînement YOLOv8.
|
||||||
|
|
||||||
|
📝 4) Vérifier les résultats
|
||||||
|
|
||||||
|
Après ce script, ton dossier dataset/auto_labeled doit contenir :
|
||||||
|
|
||||||
|
dataset/auto_labeled/
|
||||||
|
images/
|
||||||
|
photo1.jpg
|
||||||
|
photo2.jpg
|
||||||
|
...
|
||||||
|
labels/
|
||||||
|
photo1.txt
|
||||||
|
photo2.txt
|
||||||
|
...
|
||||||
|
|
||||||
|
Chaque .txt contient des lignes du type :
|
||||||
|
|
||||||
|
0 0.4532 0.3124 0.1824 0.2763
|
||||||
|
|
||||||
|
(correspondant à <class_id> <x_center> <y_center> <w> <h> au format YOLO)
|
||||||
|
|
||||||
|
🚀 5) Entraînement YOLOv8 sur les données générées
|
||||||
|
|
||||||
|
Ensuite, tu peux entraîner YOLOv8 avec ces annotations :
|
||||||
|
|
||||||
|
✍ Crée un fichier data.yaml :
|
||||||
|
path: dataset/auto_labeled
|
||||||
|
train: images
|
||||||
|
val: images
|
||||||
|
|
||||||
|
names:
|
||||||
|
0: leaf
|
||||||
|
1: flower
|
||||||
|
2: fruit
|
||||||
|
|
||||||
|
Assure-toi que l’ordre des noms correspond à l’ordre utilisé dans ontology_map.
|
||||||
|
|
||||||
|
🎓 Lance l’entraînement
|
||||||
|
|
||||||
|
Dans ton terminal :
|
||||||
|
|
||||||
|
yolo task=detect mode=train model=yolov8n.pt data=data.yaml epochs=50 imgsz=640
|
||||||
|
|
||||||
|
model=yolov8n.pt : YOLOv8-nano (léger, rapide)
|
||||||
|
|
||||||
|
epochs=50 : nombre d’itérations d’entraînement
|
||||||
|
|
||||||
|
imgsz=640 : taille des images
|
||||||
|
|
||||||
|
📌 6) Tester ton modèle
|
||||||
|
|
||||||
|
Après entraînement, test :
|
||||||
|
|
||||||
|
yolo task=detect mode=predict model=runs/detect/train/weights/best.pt source="test_images/"
|
||||||
|
|
||||||
|
Les images annotées seront enregistrées sous runs/detect/predict.
|
||||||
|
|
||||||
|
🧾 Résumé des étapes
|
||||||
|
Étape Description
|
||||||
|
1 Installer Autodistill & plugins
|
||||||
|
2 Mettre toutes les images brutes dans un dossier
|
||||||
|
3 Lancer le script d’auto-annotation
|
||||||
|
4 Vérifier images + .txt générés
|
||||||
|
5 Entraîner YOLOv8 avec ces données
|
||||||
|
6 Tester le modèle entraîné
|
||||||
|
⚠️ Conseils & limitations
|
||||||
|
|
||||||
|
L’auto-annotation n’est pas parfaite : certains objets peuvent être mal classés ou mal localisés — revise au besoin.
|
||||||
|
|
||||||
|
Les prompts textuels (ontology_map) doivent être précis et adaptés à ce que tu veux détecter (feuilles, fleurs, etc.).
|
||||||
|
|
||||||
|
Un GPU rend l’entraînement YOLOv8 bien plus rapide.
|
||||||
|
|
||||||
|
Si tu veux, je peux générer une version complète du data.yaml prête à l’emploi ou adapter ce pipeline à des situations spécifiques (ex : dataset grand public, interface graphique simple, intégration mobile). Dis-moi juste le cas d’usage que tu vises.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Jardin App — Améliorations récentes
|
||||||
|
|
||||||
|
> Date : **2026-03-22**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Conversion HEIC → PNG automatique à l'upload
|
||||||
|
|
||||||
|
**Problème :** Les photos prises avec un iPhone sont au format HEIC, non reconnu par les navigateurs web.
|
||||||
|
|
||||||
|
**Solution :** Lors de tout upload de média (route `POST /api/media/upload`), le backend détecte automatiquement le format HEIC/HEIF et le convertit en WEBP avant stockage.
|
||||||
|
|
||||||
|
**Fichiers modifiés :**
|
||||||
|
- `backend/app/routers/media.py` — détection par magic bytes + conversion via `pillow-heif`
|
||||||
|
- `backend/requirements.txt` — ajout de `pillow-heif`
|
||||||
|
- `frontend/src/views/*` — inputs file acceptent désormais `.heic,.HEIC`
|
||||||
|
|
||||||
|
**Comportement :**
|
||||||
|
- Fichier `.heic` reçu → converti en WEBP (même qualité que les autres imports)
|
||||||
|
- Miniature 300 px générée normalement
|
||||||
|
- Nom de fichier stocké en `.webp` dans la base de données
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Redimensionnement des images de la bibliothèque
|
||||||
|
|
||||||
|
**Contexte :** Le paramètre "Largeur max des photos" dans Réglages s'appliquait uniquement aux nouvelles photos. Les photos déjà importées restaient à leur taille d'origine.
|
||||||
|
|
||||||
|
**Ajout :** Un bouton **"Appliquer à la bibliothèque"** dans la section Images des Réglages permet de redimensionner rétroactivement toutes les photos existantes.
|
||||||
|
|
||||||
|
**Règle appliquée :** Si la largeur d'une photo est **inférieure ou égale** au paramètre configuré, elle n'est **pas modifiée** (pas d'agrandissement).
|
||||||
|
|
||||||
|
**Fichiers modifiés :**
|
||||||
|
- `backend/app/routers/settings.py` — endpoint `POST /api/settings/images/resize-all`
|
||||||
|
- `frontend/src/api/settings.ts` — méthode `resizeAllImages()`
|
||||||
|
- `frontend/src/views/ReglagesView.vue` — bouton + feedback avec stats (redimensionnées / ignorées / erreurs)
|
||||||
|
|
||||||
|
**Endpoint retourne :**
|
||||||
|
```json
|
||||||
|
{ "ok": true, "redimensionnees": 42, "ignorees": 128, "erreurs": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sauvegarde Samba (backup réseau)
|
||||||
|
|
||||||
|
**Contexte :** La sauvegarde ZIP existante ne proposait qu'un téléchargement local.
|
||||||
|
|
||||||
|
**Ajout :** Section **"Sauvegarde Samba"** dans les Réglages permettant d'envoyer automatiquement la sauvegarde vers un partage réseau Windows/NAS.
|
||||||
|
|
||||||
|
**Paramètres configurables :**
|
||||||
|
| Champ | Exemple |
|
||||||
|
|-------|---------|
|
||||||
|
| Adresse IP / nom du serveur | `192.168.1.10` ou `nas.local` |
|
||||||
|
| Partage | `Sauvegardes` |
|
||||||
|
| Sous-dossier | `jardin/backups` |
|
||||||
|
| Utilisateur | `gilles` |
|
||||||
|
| Mot de passe | *(masqué)* |
|
||||||
|
|
||||||
|
**Fichiers modifiés :**
|
||||||
|
- `backend/app/routers/settings.py` — endpoint `POST /api/settings/backup/samba` (génère le ZIP, copie via `smbclient` ou `pysmb`)
|
||||||
|
- `backend/app/models/user_settings.py` — clés `samba_*` persistées en base
|
||||||
|
- `frontend/src/api/settings.ts` — méthodes `getSambaSettings()`, `saveSambaSettings()`, `backupToSamba()`
|
||||||
|
- `frontend/src/views/ReglagesView.vue` — formulaire Samba + bouton "Envoyer la sauvegarde" + feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Restauration de sauvegarde
|
||||||
|
|
||||||
|
**Contexte :** Il était possible de créer un backup ZIP mais pas de le restaurer depuis l'interface.
|
||||||
|
|
||||||
|
**Ajout :** Bouton **"Restaurer une sauvegarde"** dans la section Sauvegarde des Réglages.
|
||||||
|
|
||||||
|
**Deux modes de restauration :**
|
||||||
|
|
||||||
|
| Mode | Comportement |
|
||||||
|
|------|-------------|
|
||||||
|
| **Écraser tout** (toggle activé, rouge) | Remplace la BDD entière + écrase tous les fichiers uploads |
|
||||||
|
| **Ajouter uniquement** (toggle désactivé, jaune) | Insère les lignes absentes (`INSERT OR IGNORE`) + copie uniquement les fichiers manquants |
|
||||||
|
|
||||||
|
**Sécurité :**
|
||||||
|
- Confirmation obligatoire avec `window.confirm()` décrivant le mode sélectionné
|
||||||
|
- Message d'avertissement "irréversible" affiché avant action
|
||||||
|
|
||||||
|
**Technique (mode écraser) :**
|
||||||
|
1. `engine.dispose()` — libère les connexions SQLAlchemy
|
||||||
|
2. `PRAGMA wal_checkpoint(TRUNCATE)` — vide le WAL SQLite
|
||||||
|
3. Copie directe du fichier `.db` depuis le ZIP
|
||||||
|
|
||||||
|
**Technique (mode ajouter) :**
|
||||||
|
- `sqlite3` natif — `INSERT OR IGNORE` table par table depuis la BDD du ZIP
|
||||||
|
- `foreign_keys=OFF` pendant la fusion pour éviter les conflits de contraintes
|
||||||
|
|
||||||
|
**Fichiers modifiés :**
|
||||||
|
- `backend/app/routers/settings.py` — helper `_merge_db_add_only()` + endpoint `POST /api/settings/backup/restore`
|
||||||
|
- `frontend/src/api/settings.ts` — méthode `restoreBackup(file, overwrite)`
|
||||||
|
- `frontend/src/views/ReglagesView.vue` — sélecteur ZIP + toggle mode + bouton ♻️ + feedback coloré
|
||||||
|
|
||||||
|
**Endpoint retourne :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"uploads_copies": 27,
|
||||||
|
"uploads_ignores": 0,
|
||||||
|
"db_restauree": true,
|
||||||
|
"db_lignes_ajoutees": 0,
|
||||||
|
"erreurs": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé des endpoints ajoutés
|
||||||
|
|
||||||
|
| Méthode | Route | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `POST` | `/api/settings/images/resize-all` | Redimensionne toutes les images de la bibliothèque |
|
||||||
|
| `POST` | `/api/settings/backup/samba` | Envoie la sauvegarde ZIP vers un partage Samba |
|
||||||
|
| `POST` | `/api/settings/backup/restore` | Restaure un backup ZIP (écraser ou ajouter) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dette technique restante
|
||||||
|
|
||||||
|
- Le redimensionnement "bibliothèque" tourne de façon synchrone — pour de très grandes bibliothèques (> 500 photos), envisager une tâche en arrière-plan avec progression SSE.
|
||||||
|
- La connexion Samba utilise `subprocess` + `smbclient` ou `pysmb` : tester la compatibilité avec les NAS Synology / QNAP.
|
||||||
|
- Après une restauration "écraser", les sessions utilisateurs actives travaillent toujours sur l'ancienne BDD jusqu'au prochain cycle de connexion SQLAlchemy. Un rechargement de page suffit.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Utilisation API en Réseau Local (VM / OpenClaw)
|
||||||
|
|
||||||
|
Ce guide explique comment exposer et consommer l'API backend Jardin depuis des machines VM de votre réseau local, y compris un poste équipé d'OpenClaw.
|
||||||
|
|
||||||
|
## 1. Vérifier l'accessibilité API sur le LAN
|
||||||
|
|
||||||
|
Le backend écoute sur le port `8060` via Docker Compose.
|
||||||
|
|
||||||
|
Depuis la machine hôte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8060/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Depuis une VM du réseau local (remplacez `192.168.1.50`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50:8060/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Réponse attendue:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si ça ne répond pas:
|
||||||
|
- vérifier que Docker est démarré
|
||||||
|
- vérifier que le pare-feu autorise `8060/tcp`
|
||||||
|
- vérifier l'IP LAN de l'hôte
|
||||||
|
|
||||||
|
## 2. CORS pour clients web distants (OpenClaw UI navigateur)
|
||||||
|
|
||||||
|
Si l'appel API est fait côté navigateur depuis une autre origine (ex: UI OpenClaw sur une VM), il faut autoriser cette origine dans `CORS_ORIGINS`.
|
||||||
|
|
||||||
|
Exemple `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8061,http://192.168.1.80:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis redémarrer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- appel serveur-à-serveur: CORS non requis
|
||||||
|
- appel navigateur: CORS requis
|
||||||
|
|
||||||
|
## 3. Endpoints utiles pour automatisation VM/OpenClaw
|
||||||
|
|
||||||
|
Santé:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50:8060/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Météo tableau:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://192.168.1.50:8060/api/meteo/tableau?center_date=2026-02-22&span=15"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rafraîchir jobs météo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50:8060/api/meteo/refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
Lire réglages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50:8060/api/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Activer debug UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://192.168.1.50:8060/api/settings \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"debug_mode":"1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Stats debug backend (CPU/RAM/disque conteneur):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://192.168.1.50:8060/api/settings/debug/system
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload fichier:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://192.168.1.50:8060/api/upload \
|
||||||
|
-F "file=@/chemin/fichier.mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
Télécharger une sauvegarde ZIP (BDD + uploads + fichiers texte):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -L "http://192.168.1.50:8060/api/settings/backup/download" \
|
||||||
|
-o "jardin_backup.zip"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Scripts de mise à jour BDD (hors webapp)
|
||||||
|
|
||||||
|
Station locale -> DB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 station_meteo/update_station_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Historique Open-Meteo -> DB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 station_meteo/update_openmeteo_history_db.py --start-date 2026-01-01 --end-date 2026-02-22
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Paramètres recommandés pour OpenClaw
|
||||||
|
|
||||||
|
- Base URL API: `http://192.168.1.50:8060`
|
||||||
|
- Endpoint test: `/api/health`
|
||||||
|
- Timeout conseillé: `15-30s`
|
||||||
|
- Corps JSON: UTF-8
|
||||||
|
- Pour upload: `multipart/form-data`
|
||||||
|
|
||||||
|
## 6. Skill OpenClaw recommandé
|
||||||
|
|
||||||
|
Le skill à utiliser s'appelle désormais: `jardin-api-backend`.
|
||||||
|
|
||||||
|
Emplacement dans le repo:
|
||||||
|
|
||||||
|
- `skills/jardin-api-backend/SKILL.md`
|
||||||
|
- `skills/jardin-api-backend/references/backend-api-recipes.md`
|
||||||
|
- `skills/jardin-api-backend/consigne.md`
|
||||||
|
|
||||||
|
Installation rapide (exemple):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.codex/skills
|
||||||
|
cp -R skills/jardin-api-backend ~/.codex/skills/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensuite, relancer OpenClaw et vérifier que le skill **Jardin API Backend** apparaît.
|
||||||
|
|
||||||
|
## 7. Sécurité (important)
|
||||||
|
|
||||||
|
L'API actuelle est sans authentification. Sur réseau local, minimum recommandé:
|
||||||
|
- segmenter le réseau (VLAN/VM dédiées)
|
||||||
|
- filtrer par IP source (pare-feu hôte)
|
||||||
|
- ne pas exposer directement sur Internet
|
||||||
|
- idéalement: reverse proxy + authentification (Basic/Auth token) ou VPN
|
||||||
|
|
||||||
|
## 8. Swagger
|
||||||
|
|
||||||
|
Documentation interactive:
|
||||||
|
|
||||||
|
- `http://<IP_HOTE>:8060/docs`
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,219 @@
|
|||||||
|
{
|
||||||
|
"source_dossier": "docs/arbustre",
|
||||||
|
"regle_groupement": "2 images consecutives = 1 plante",
|
||||||
|
"plantes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"plante": "Vitis vinifera 'Marechal Foch'",
|
||||||
|
"nom_latin": "Vitis vinifera",
|
||||||
|
"images": ["IMG_4808.jpg", "IMG_4809.jpg"],
|
||||||
|
"texte_extrait": {
|
||||||
|
"avant": "Vitis vinifera 'Marechal Foch' (code 191599).",
|
||||||
|
"arriere": "Pictogrammes de culture et informations plant passport/recyclage."
|
||||||
|
},
|
||||||
|
"caracteristiques_plantation": {
|
||||||
|
"type": "Plant en pot (arbuste fruitier)",
|
||||||
|
"periode_plantation": null,
|
||||||
|
"periode_recolte": null,
|
||||||
|
"exposition": "Soleil / mi-ombre (selon pictogrammes)",
|
||||||
|
"arrosage": "Regulier (pictogramme arrosoir)",
|
||||||
|
"espacement": null,
|
||||||
|
"remarques": "Pictogramme maison (culture proche d'un support/mur, interpretation)."
|
||||||
|
},
|
||||||
|
"icones_significatives": [
|
||||||
|
{
|
||||||
|
"icone": "soleil",
|
||||||
|
"signification": "Exposition",
|
||||||
|
"valeur": "Soleil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "demi-soleil",
|
||||||
|
"signification": "Exposition",
|
||||||
|
"valeur": "Mi-ombre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "arrosoir",
|
||||||
|
"signification": "Arrosage",
|
||||||
|
"valeur": "Regulier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "maison",
|
||||||
|
"signification": "Emplacement",
|
||||||
|
"valeur": "Zone abritee/proche habitation (a confirmer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "couverts barres",
|
||||||
|
"signification": "Usage alimentaire",
|
||||||
|
"valeur": "Interpretation incertaine"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"detail": {
|
||||||
|
"texte_integral_visible": {
|
||||||
|
"avant": "Vitis\nvinifera 'Marechal Foch'\n191599",
|
||||||
|
"arriere": "Pictogrammes visibles: soleil, demi-soleil, maison, arrosoir, couverts barres.\nRaccolta differenziata - Etichetta: Carta - Verifica le disposizioni del tuo Comune.\nPlant Passport (bloc lisible partiellement): A Vitis vinifera; B CZ-...; C 02574/...; D 25.\nGGN: 4063061211323."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"plante": "Ribes nigrum 'Titania'",
|
||||||
|
"nom_latin": "Ribes nigrum",
|
||||||
|
"images": ["IMG_4810.jpg", "IMG_4811.jpg"],
|
||||||
|
"texte_extrait": {
|
||||||
|
"avant": "Ribes nigrum 'Titania' (code 58329).",
|
||||||
|
"arriere": "Pictogrammes: soleil, arrosage, 150 cm, recolte VI-VII."
|
||||||
|
},
|
||||||
|
"caracteristiques_plantation": {
|
||||||
|
"type": "Plant en pot (arbuste fruitier)",
|
||||||
|
"periode_plantation": null,
|
||||||
|
"periode_recolte": "VI-VII",
|
||||||
|
"exposition": "Soleil",
|
||||||
|
"arrosage": "Regulier",
|
||||||
|
"espacement": "150 cm",
|
||||||
|
"taille_entretien": "Pictogrammes de taille/suppression du bois (interpretation)"
|
||||||
|
},
|
||||||
|
"icones_significatives": [
|
||||||
|
{
|
||||||
|
"icone": "soleil",
|
||||||
|
"signification": "Exposition",
|
||||||
|
"valeur": "Soleil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "arrosoir",
|
||||||
|
"signification": "Arrosage",
|
||||||
|
"valeur": "Regulier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "fleche verticale 150 cm",
|
||||||
|
"signification": "Espacement/distance",
|
||||||
|
"valeur": "150 cm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "main + VI-VII",
|
||||||
|
"signification": "Periode de recolte",
|
||||||
|
"valeur": "Juin-Juillet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "branche/fruits + fleches",
|
||||||
|
"signification": "Taille/renouvellement",
|
||||||
|
"valeur": "Interpretation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "flocon barre + fleches",
|
||||||
|
"signification": "Gestion hivernale",
|
||||||
|
"valeur": "Interpretation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"detail": {
|
||||||
|
"texte_integral_visible": {
|
||||||
|
"avant": "Ribes nigrum\n'Titania'\n58329",
|
||||||
|
"arriere": "Ribes nigrum\nPictogrammes: soleil; arrosoir; 150 cm; main + VI-VII; deux pictogrammes de taille/entretien.\nPlant Passport: A Ribes nigrum; B NL-...; C 4421PP; D NL.\nCode-barres: 4014157115032."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"plante": "Rubus idaeus 'Schoenemann'",
|
||||||
|
"nom_latin": "Rubus idaeus",
|
||||||
|
"images": ["IMG_4813.jpg", "IMG_4814.jpg"],
|
||||||
|
"texte_extrait": {
|
||||||
|
"avant": "Rubus idaeus 'Schoenemann' (code 58329).",
|
||||||
|
"arriere": "Pictogrammes: soleil, arrosage, 150 cm, recolte VII-XI."
|
||||||
|
},
|
||||||
|
"caracteristiques_plantation": {
|
||||||
|
"type": "Plant en pot (arbuste fruitier)",
|
||||||
|
"periode_plantation": null,
|
||||||
|
"periode_recolte": "VII-XI",
|
||||||
|
"exposition": "Soleil",
|
||||||
|
"arrosage": "Regulier",
|
||||||
|
"espacement": "150 cm",
|
||||||
|
"taille_entretien": "Pictogrammes de taille/suppression du bois (interpretation)"
|
||||||
|
},
|
||||||
|
"icones_significatives": [
|
||||||
|
{
|
||||||
|
"icone": "soleil",
|
||||||
|
"signification": "Exposition",
|
||||||
|
"valeur": "Soleil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "arrosoir",
|
||||||
|
"signification": "Arrosage",
|
||||||
|
"valeur": "Regulier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "fleche verticale 150 cm",
|
||||||
|
"signification": "Espacement/distance",
|
||||||
|
"valeur": "150 cm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "main + VII-XI",
|
||||||
|
"signification": "Periode de recolte",
|
||||||
|
"valeur": "Juillet-Novembre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "branche/fruits + fleches",
|
||||||
|
"signification": "Taille/renouvellement",
|
||||||
|
"valeur": "Interpretation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "flocon barre + fleches",
|
||||||
|
"signification": "Gestion hivernale",
|
||||||
|
"valeur": "Interpretation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"detail": {
|
||||||
|
"texte_integral_visible": {
|
||||||
|
"avant": "Rubus idaeus\n'Schoenemann'\n58329",
|
||||||
|
"arriere": "Rubus idaeus\nPictogrammes: soleil; arrosoir; 150 cm; main + VII-XI; deux pictogrammes de taille/entretien.\nPlant Passport: A Rubus idaeus; B NL-...; C 4421PP; D NL.\nCode-barres: 4014157115087."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"plante": "Framboisier remontant 'Paris'",
|
||||||
|
"nom_latin": "Rubus idaeus",
|
||||||
|
"images": ["IMG_4815.jpg", "IMG_4816.jpg"],
|
||||||
|
"texte_extrait": {
|
||||||
|
"avant": "F.A.N Fruits Au Naturel - Framboisier remontant 'Paris'. Recolte: juin-juillet puis septembre-octobre.",
|
||||||
|
"arriere": "Conseils complets de plantation/entretien + recette 'Palets a la confiture de framboises'."
|
||||||
|
},
|
||||||
|
"caracteristiques_plantation": {
|
||||||
|
"type": "Plant en pot 1,6 litre",
|
||||||
|
"periode_plantation": "Fevrier a juin et aout a novembre",
|
||||||
|
"periode_recolte": "Juin-juillet puis septembre-octobre",
|
||||||
|
"exposition": "Soleil a mi-ombre",
|
||||||
|
"arrosage": "Arroser a la plantation puis regulier",
|
||||||
|
"espacement": "0,50 m a 0,70 m entre chaque plant",
|
||||||
|
"sol": "Tout type de sol sauf humide",
|
||||||
|
"fertilisation": "Engrais organique a l'automne"
|
||||||
|
},
|
||||||
|
"icones_significatives": [
|
||||||
|
{
|
||||||
|
"icone": "suite etapes 1-5",
|
||||||
|
"signification": "Methode de plantation",
|
||||||
|
"valeur": "Ameublir, trou, retirer le pot, planter/tasser, arroser"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "recolte en 2 periodes",
|
||||||
|
"signification": "Calendrier de production",
|
||||||
|
"valeur": "Juin-juillet puis septembre-octobre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icone": "mes reperes usage/gout",
|
||||||
|
"signification": "Usages culinaires et profil gustatif",
|
||||||
|
"valeur": "Patisserie, confiture, fruits frais, smoothies"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"detail": {
|
||||||
|
"texte_integral_visible": {
|
||||||
|
"avant": "F.A.N Fruits Au Naturel\nFramboisier remontant\n'Paris'\nRecolte: juin-juillet puis septembre-octobre\nmes reperes: glaces/sorbets, patisserie/confiture, fruits frais/smoothies\nproduction locale\nPot 1,6 litre",
|
||||||
|
"arriere": "1 Travaillez le sol pour l'ameublir.\n2 Faites un trou a la taille de la motte.\n3 Retirez le pot.\n4 Plantez et tassez la terre autour de la motte.\n5 Arrosez.\ninfos et conseils: www.vivaplante.fr\n\nFRAMBOISIER REMONTANT 'Paris' (Rubus idaeus)\n\nFramboisier remontant 'Paris'\nSol: tout type de sol sauf humide.\nPlantation: planter toute la motte sous terre. Arroser.\nFertilisation: apporter un engrais organique a l'automne.\nTaille:\n- non remontant (de saison): chaque hiver, enlever les tiges dessechees. Palisser les cannes conservees pour l'annee suivante.\n- remontant: l'hiver, enlever les tiges dessechees et celles trop freles. Ne laisser que 4 ou 5 belles cannes qui produiront en juin. La deuxieme production des septembre aura lieu sur les pousses de l'annee.\n\nPalets a la confiture de framboises\n250 g de farine - 125 g de beurre - 150 g de sucre en poudre - confiture de framboise - 1 oeuf - vanille en poudre\nBattez ensemble le beurre, l'oeuf et 125 g de sucre.\nAjoutez la farine lorsque la preparation mousse, une cuiller a cafe de vanille et une pincee de sel.\nPetrissez la pate et placez-la au refrigerateur pendant 20 minutes.\nFaites des noix de pate que vous roulez dans le reste de sucre. Aplatissez un peu ces noix avant de les poser sur la plaque du four a 210 C pendant 5 minutes.\nFormez un creux au centre de chaque palet a l'aide d'une cuiller a cafe.\nVersez-y la confiture de framboise et placez-les de nouveau au four ou laissez-les dorer.\n\nPlantation: fevrier a juin et aout a nov.\nRecolte: juin-juillet puis septembre-octobre.\nExposition: soleil-mi-ombre.\nEspacement des plants: 0,50 m a 0,70 m entre chaque plant.\n\nPasseport Phytosanitaire / Plant Passport: A Rubus idaeus; B FR-RH00491; C 765883; D FR."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
"Extraction manuelle depuis photos.",
|
||||||
|
"Certaines lignes de passeport ou pictogrammes sont partiellement lisibles et marquees comme interpretation quand necessaire."
|
||||||
|
]
|
||||||
|
}
|
||||||