diff --git a/.env.example b/.env.example index 768c7b7..1faa9b8 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,5 @@ 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 diff --git a/README.md b/README.md index 10ef753..1137aee 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,10 @@ cp data/jardin.db data/jardin_backup_$(date +%Y%m%d).db ## 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 : diff --git a/amelioration.md b/amelioration.md index be56a8e..a250424 100644 --- a/amelioration.md +++ b/amelioration.md @@ -6,6 +6,8 @@ jardin : - [ ] ajouter les caracteristiques pour un jardin: photo, geolocalisation, type de terre, ph, ensoleillement, exposition, dimension,surface, ... - +- [ ] dans l'edition du jardin definir si carré potager avec dimension x;y en cm + plante : - [ ] header : varietés => remplacer par plante ( pareil dans tous le programme) - [ ] pour une plante, ajouter des caracteristiques : photo, nom, varités, famille, resistance au froid , maladie commune et astuces , methode de semis et de plantation, ... (brainstorming) @@ -77,4 +79,6 @@ bibliotehque photo: - brainstorming local ai detection style yolo ( fichier consigne_yolo.md) backend : - - [ ] methode simple pour mettre a jours la base de donnée ; brainstorming \ No newline at end of file + - [ ] methode simple pour mettre a jours la base de donnée ; brainstorming + + - [ ] mise a jours bdd via api puis je peut ajouter des script dans mon openclaw] \ No newline at end of file diff --git a/avancement.md b/avancement.md index 493a936..c2d1f58 100644 --- a/avancement.md +++ b/avancement.md @@ -7282,3 +7282,178 @@ naive tzinfo: None aware tzinfo: UTC You've hit your limit · resets 7pm (Europe/Paris) + +## Mise a jour Codex - 2026-02-22 + +### Termine +- Task 5 (Service Open-Meteo enrichi): + - `backend/app/services/meteo.py` + - ajout aggregation journaliere `sol_0cm` depuis `hourly.soil_temperature_0cm` + - parsing defensif des valeurs numeriques + - tests: `backend/tests/test_meteo_service.py` (3 passes) + +- Task 8 (Router Astuces filtres): + - `backend/app/routers/astuces.py` + - nouveaux filtres `categorie`, `tag`, `mois` + - compatibilite filtres existants `entity_type`, `entity_id` + - tests: `backend/tests/test_astuces_filters.py` (5 passes) + +### Stabilisation tests +- Ajout de flags de runtime backend: + - `ENABLE_SCHEDULER` et `ENABLE_BOOTSTRAP` dans `backend/app/config.py` + - documentes dans `.env.example` +- `backend/app/main.py` respecte ces flags dans le lifespan +- `backend/tests/conftest.py` desactive scheduler/bootstrap pour les tests +- `conftest` fournit une session SQLModel par requete TestClient pour eviter les blocages thread/session + +### Point restant +- `backend/tests/test_meteo.py::test_meteo_tableau_vide` reste bloquant dans ce contexte (timeout), malgre la desactivation scheduler/bootstrap. +- Les nouveaux tests unitaire meteo/astuces passent. + +## Mise a jour Codex - Frontend (Tasks 9, 10, 11) + +### Task 9 termine +- `frontend/src/api/meteo.ts` enrichi: + - `getTableau`, `getStationCurrent`, `getPrevisions`, `refresh` + - types `TableauRow`, `StationCurrent`, `OpenMeteoDay` +- `frontend/src/api/astuces.ts` cree +- `frontend/src/stores/astuces.ts` cree + +### Task 10 termine +- `frontend/src/views/CalendrierView.vue`: + - onglet meteo refondu en tableau synthetique station + open-meteo + - widget station actuelle + - suppression ancien bloc `meteoData` + - ajout `loadTableau` + `loadStationCurrent` + +### Task 11 termine +- `frontend/src/views/AstucesView.vue` cree (filtres + CRUD) +- route ajoutee: `/astuces` dans `frontend/src/router/index.ts` +- menu mobile: `frontend/src/components/AppDrawer.vue` +- menu desktop: `frontend/src/App.vue` + +### Validation frontend +- `npm run lint` -> OK +- `npm run build` -> OK + +## Mise a jour Codex - 2026-02-22 (Meteo, Jardin, UI) + +### Migration executee (OK) +- Migration lancee dans le conteneur backend: + - `docker compose exec backend python -c "from app.migrate import run_migrations; run_migrations()"` +- Colonnes ajoutees en base SQLite: + - table `garden`: `carre_potager`, `carre_x_cm`, `carre_y_cm` + - table `astuce`: `photos`, `videos` + +### Jardin: carre potager +- Backend: + - `backend/app/models/garden.py` ajoute les champs `carre_potager`, `carre_x_cm`, `carre_y_cm` + - `backend/app/migrate.py` mis a jour pour ces colonnes +- Frontend: + - `frontend/src/views/JardinsView.vue` + - ajout checkbox "Carre potager" + dimensions X/Y en cm + - conversion automatique cm -> m pour `longueur_m` / `largeur_m` + - surface calculee automatiquement si absente + +### Popup edition plante responsive +- `frontend/src/views/PlantesView.vue` +- modal edition: + - smartphone: 1 colonne + - laptop/desktop: 2 colonnes (`lg:grid-cols-2`) + - notes + actions en largeur complete + +### Meteo: vue unique + navigation temporelle +- Backend: + - `backend/app/routers/meteo.py` + - endpoint `/api/meteo/tableau` accepte desormais: + - `center_date=YYYY-MM-DD` + - `span` (jours avant/apres) +- Frontend: + - `frontend/src/views/CalendrierView.vue` refondu en vue meteo unique + - suppression des onglets `lunaire/meteo/taches/dictons` + - boutons navigation: `Prev`, `Today`, `Next` + - fenetre active sur +/- 15 jours autour de la date centrale + - detail a droite conserve (station, open-meteo, lunaire, dictons, saint) + +### Navigation application +- Route principale renommee: + - `/meteo` -> `CalendrierView` +- Redirections conservees: + - `/calendrier` -> `/meteo` + - `/lunaire` -> `/meteo` +- Menus renommes en "Meteo": + - `frontend/src/App.vue` / `frontend/src/App.vue.js` + - `frontend/src/components/AppDrawer.vue` / `frontend/src/components/AppDrawer.vue.js` + +### Validation +- Backend: compilation Python OK sur fichiers modifies +- Frontend: build OK (`npm --prefix frontend run build`) + +## Mise a jour Codex - 2026-02-22 (Station/Open-Meteo, Dashboard, Outils video) + +### Base de donnees meteo: mises a jour executees +- Script station locale execute avec succes: + - `python3 station_meteo/update_station_db.py` + - ecriture confirmee en base (`meteostation`): + - `current`: `2026-02-22T17:00` (pression `922.5`, vent `3.2`) + - `veille`: `2026-02-21T00:00` +- Backfill station locale (NOAA) execute: + - plage `2026-01-01` -> `2026-02-22` + - `53` jours traites, `53` upserts, `0` erreur +- Script historique Open-Meteo cree: + - `station_meteo/update_openmeteo_history_db.py` + - options: `--start-date`, `--end-date`, `--lat`, `--lon`, `--chunk-days`, `--dry-run` + - source: endpoint archive Open-Meteo + - cible: table `meteoopenmeteo` (upsert par date) +- Backfill Open-Meteo execute: + - `python3 station_meteo/update_openmeteo_history_db.py --start-date 2026-01-01 --end-date 2026-02-22` + - resultat: `53` lignes recuperees et mises a jour en base + +### Dashboard / Meteo: ergonomie et visuel +- `frontend/src/views/DashboardView.vue` + - ajout d'un bloc "Condition actuelle" (icone meteo + libelle + temperature station + heure releve) + - affichage prevision sur 7 jours avec icones meteo + - suppression du scroll horizontal des cartes meteo: + - passage d'un layout `flex overflow-x-auto` a une grille responsive + - conteneur elargi (`max-w-6xl`) pour une meilleure lisibilite laptop +- `frontend/src/views/CalendrierView.vue` + - icone pression plus lisible dans le bandeau station: + - `⬛` remplace par `🧭` + +### Navigation responsive +- `frontend/src/components/AppDrawer.vue` + - correction ouverture menu en fenetre reduite laptop/tablette: + - `md:hidden` -> `lg:hidden` + - le drawer est maintenant disponible sur toutes les largeurs < `lg` + +### Jardins: popup edition responsive +- `frontend/src/views/JardinsView.vue` + - popup `Nouveau/Modifier jardin` passe en responsive: + - smartphone: `1` colonne + - laptop/desktop: `2` colonnes + - modal elargi (`max-w-4xl`) + - sections longues en pleine largeur (`lg:col-span-2`) + +### Outils: ajout du champ video +- Backend: + - `backend/app/models/tool.py`: ajout `video_url` + - `backend/app/migrate.py`: ajout migration auto colonne `tool.video_url` + - test ajoute: `backend/tests/test_tools.py::test_tool_with_video_url` +- Frontend: + - `frontend/src/api/tools.ts`: type `video_url?: string` + - `frontend/src/views/OutilsView.vue`: + - upload video (`accept="video/*"`) + - preview video dans le formulaire + - enregistrement `video_url` via endpoint upload + - affichage video et lien "🎬 Video" sur les cartes outils +- Base locale: + - colonne `video_url` ajoutee et verifiee dans `data/jardin.db` + +### Validation technique +- Frontend builds: + - `npm --prefix frontend run build` -> OK (plusieurs executions apres changements) +- Python compilation: + - `python3 -m py_compile` sur scripts/modeles modifies -> OK +- Note tests backend: + - `pytest backend/tests/test_tools.py` reste bloque dans ce contexte d'execution, + mais les changements de schema/code compilent et la colonne DB est presente. diff --git a/backend/app/config.py b/backend/app/config.py index 8e48b2f..de5d279 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,3 +6,5 @@ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",") STATION_URL = os.getenv("STATION_URL", "http://10.0.0.8:8081/") 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"} diff --git a/backend/app/main.py b/backend/app/main.py index 9233a39..12a1601 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI 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 @@ -15,19 +15,20 @@ async def lifespan(app: FastAPI): os.makedirs("/data/skyfield", exist_ok=True) except OSError: pass - import app.models # noqa — enregistre tous les modèles avant create_all - from app.migrate import run_migrations - run_migrations() - create_db_and_tables() - from app.seed import run_seed - run_seed() - # Démarrer le scheduler météo - from app.services.scheduler import setup_scheduler - setup_scheduler() + if ENABLE_BOOTSTRAP: + import app.models # noqa — enregistre tous les modèles avant create_all + from app.migrate import run_migrations + run_migrations() + create_db_and_tables() + from app.seed import run_seed + run_seed() + if ENABLE_SCHEDULER: + from app.services.scheduler import setup_scheduler + setup_scheduler() yield - # Arrêter le scheduler - from app.services.scheduler import scheduler - scheduler.shutdown(wait=False) + if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER: + from app.services.scheduler import scheduler + scheduler.shutdown(wait=False) app = FastAPI(title="Jardin API", lifespan=lifespan) diff --git a/backend/app/migrate.py b/backend/app/migrate.py index e5b2040..1309615 100644 --- a/backend/app/migrate.py +++ b/backend/app/migrate.py @@ -15,7 +15,17 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = { ("url_reference", "TEXT", None), ], "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), + ("carre_potager", "INTEGER", "0"), + ("carre_x_cm", "INTEGER", None), + ("carre_y_cm", "INTEGER", None), + ("photo_parcelle", "TEXT", None), ("ensoleillement", "TEXT", None), ], "task": [ @@ -23,6 +33,24 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = { ("date_prochaine", "TEXT", None), ("outil_id", "INTEGER", None), ], + "meteostation": [ + ("t_min", "REAL", None), + ("t_max", "REAL", None), + ], + "tool": [ + ("photo_url", "TEXT", None), + ("video_url", "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), + ], "plantvariety": [ # ancien nom de table → migration vers "plant" si présente ], @@ -36,6 +64,8 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = { ("categorie", "TEXT", None), ("tags", "TEXT", None), ("mois", "TEXT", None), + ("photos", "TEXT", None), + ("videos", "TEXT", None), ], } diff --git a/backend/app/models/astuce.py b/backend/app/models/astuce.py index fc1e8bc..4518092 100644 --- a/backend/app/models/astuce.py +++ b/backend/app/models/astuce.py @@ -16,4 +16,6 @@ class Astuce(SQLModel, table=True): 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)) diff --git a/backend/app/models/garden.py b/backend/app/models/garden.py index 2b29f56..f853d47 100644 --- a/backend/app/models/garden.py +++ b/backend/app/models/garden.py @@ -16,7 +16,13 @@ class Garden(SQLModel, table=True): ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil sol_type: Optional[str] = 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_hauteur: int = 4 diff --git a/backend/app/models/media.py b/backend/app/models/media.py index 49a4db4..31ef886 100644 --- a/backend/app/models/media.py +++ b/backend/app/models/media.py @@ -5,7 +5,7 @@ from sqlmodel import Field, SQLModel class Media(SQLModel, table=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 url: str thumbnail_url: Optional[str] = None diff --git a/backend/app/models/meteo.py b/backend/app/models/meteo.py index 82bba65..ca1e1bb 100644 --- a/backend/app/models/meteo.py +++ b/backend/app/models/meteo.py @@ -9,6 +9,8 @@ class MeteoStation(SQLModel, table=True): 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 diff --git a/backend/app/models/planting.py b/backend/app/models/planting.py index 8d3633f..7619706 100644 --- a/backend/app/models/planting.py +++ b/backend/app/models/planting.py @@ -12,6 +12,10 @@ class PlantingCreate(SQLModel): date_repiquage: Optional[date] = None quantite: int = 1 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_fin: Optional[date] = None rendement_estime: Optional[float] = None @@ -29,6 +33,10 @@ class Planting(SQLModel, table=True): 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 diff --git a/backend/app/models/tool.py b/backend/app/models/tool.py index 1a87a39..f23a09b 100644 --- a/backend/app/models/tool.py +++ b/backend/app/models/tool.py @@ -9,4 +9,9 @@ class Tool(SQLModel, table=True): 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_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)) diff --git a/backend/app/routers/astuces.py b/backend/app/routers/astuces.py index 5a22309..fe1d325 100644 --- a/backend/app/routers/astuces.py +++ b/backend/app/routers/astuces.py @@ -1,3 +1,4 @@ +import json from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import Session, select @@ -7,10 +8,45 @@ 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) @@ -18,7 +54,21 @@ def list_astuces( q = q.where(Astuce.entity_type == entity_type) if entity_id is not None: q = q.where(Astuce.entity_id == entity_id) - return session.exec(q).all() + + if categorie: + q = q.where(Astuce.categorie == categorie) + + items = session.exec(q).all() + + if tag: + wanted = tag.strip().lower() + items = [a for a in items if wanted in _decode_tags(a.tags)] + + if mois is not None: + # mois null/empty = astuce valable toute l'année + items = [a for a in items if not _decode_mois(a.mois) or mois in _decode_mois(a.mois)] + + return items @router.post("/astuces", response_model=Astuce, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/routers/gardens.py b/backend/app/routers/gardens.py index 38e37ae..56a3c77 100644 --- a/backend/app/routers/gardens.py +++ b/backend/app/routers/gardens.py @@ -1,15 +1,37 @@ +import os +import uuid from datetime import datetime, timezone 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 app.config import UPLOAD_DIR from app.database import get_session from app.models.garden import Garden, GardenCell, GardenImage, Measurement 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]) def list_gardens(session: Session = Depends(get_session)): return session.exec(select(Garden)).all() @@ -31,6 +53,31 @@ def get_garden(id: int, session: Session = Depends(get_session)): 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) def update_garden(id: int, data: Garden, session: Session = Depends(get_session)): g = session.get(Garden, id) diff --git a/backend/app/routers/meteo.py b/backend/app/routers/meteo.py index e339b0d..a4f1e6e 100644 --- a/backend/app/routers/meteo.py +++ b/backend/app/routers/meteo.py @@ -2,7 +2,7 @@ from datetime import date, timedelta from typing import Any, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import text from sqlmodel import Session @@ -15,7 +15,7 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]: """Agrège les mesures horaires d'une journée en résumé.""" rows = session.exec( text( - "SELECT temp_ext, pluie_mm, vent_kmh, humidite " + "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}, @@ -25,14 +25,20 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]: return None temps = [r[0] for r in rows if r[0] is not None] - pluies = [r[1] for r in rows if r[1] is not None] - vents = [r[2] for r in rows if r[2] is not None] - hums = [r[3] for r in rows if r[3] is not None] + 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(temps), 1) if temps else None, - "t_max": round(max(temps), 1) if temps else None, - "pluie_mm": round(sum(pluies), 1) if pluies else 0.0, + "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, } @@ -77,22 +83,36 @@ def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]: @router.get("/meteo/tableau") -def get_tableau(session: Session = Depends(get_session)) -> dict[str, Any]: - """Tableau synthétique : 7j passé + J0 + 7j futur.""" +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(-7, 8): - d = today + timedelta(days=delta) + for delta in range(-span, span + 1): + d = center + timedelta(days=delta) iso = d.isoformat() + delta_today = (d - today).days - if delta < 0: + if delta_today < 0: row_type = "passe" station = _station_daily_summary(session, iso) - om = None # Pas de prévision pour le passé - elif delta == 0: + om = _open_meteo_day(session, iso) + elif delta_today == 0: row_type = "aujourd_hui" - station = _station_current_row(session) + station_current = _station_current_row(session) or {} + station_daily = _station_daily_summary(session, iso) or {} + station = {**station_daily, **station_current} or None om = _open_meteo_day(session, iso) else: row_type = "futur" diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 8cb6736..6be1a92 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -1,10 +1,117 @@ +import os +import shutil +import time +from typing import Any + from fastapi import APIRouter, Depends from sqlmodel import Session, select from app.database import get_session from app.models.settings import UserSettings +from app.config import UPLOAD_DIR router = APIRouter(tags=["réglages"]) +_PREV_CPU_USAGE_USEC: int | None = None +_PREV_CPU_TS: float | None = None + + +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, + } + @router.get("/settings") def get_settings(session: Session = Depends(get_session)): @@ -23,3 +130,34 @@ def update_settings(data: dict, session: Session = Depends(get_session)): session.add(row) session.commit() return {"ok": True} + + +@router.get("/settings/debug/system") +def get_debug_system_stats() -> dict[str, Any]: + """Stats runtime du conteneur (utile pour affichage debug UI).""" + global _PREV_CPU_USAGE_USEC, _PREV_CPU_TS + + now = time.monotonic() + usage_usec = _read_cgroup_cpu_usage_usec() + quota_cores = _cpu_quota_cores() + cpu_pct = None + + if usage_usec is not None and _PREV_CPU_USAGE_USEC is not None and _PREV_CPU_TS is not None: + delta_usage = usage_usec - _PREV_CPU_USAGE_USEC + delta_time_usec = (now - _PREV_CPU_TS) * 1_000_000 + if delta_time_usec > 0 and quota_cores and quota_cores > 0: + cpu_pct = round((delta_usage / (delta_time_usec * quota_cores)) * 100, 1) + + _PREV_CPU_USAGE_USEC = usage_usec + _PREV_CPU_TS = now + + return { + "source": "container-cgroup", + "cpu": { + "usage_usec_total": usage_usec, + "quota_cores": quota_cores, + "used_pct": cpu_pct, + }, + "memory": _memory_stats(), + "disk": _disk_stats(), + } diff --git a/backend/app/services/lunar.py b/backend/app/services/lunar.py index 4776cee..7d25b45 100644 --- a/backend/app/services/lunar.py +++ b/backend/app/services/lunar.py @@ -19,6 +19,17 @@ SIGN_TO_TYPE = { "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 class DayInfo: @@ -29,6 +40,7 @@ class DayInfo: montante_descendante: str signe: str type_jour: str + saint_du_jour: str perigee: bool apogee: bool noeud_lunaire: bool @@ -126,6 +138,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]: lat, lon, dist = v_moon.ecliptic_latlon() signe = zodiac_sign_from_lon(lon.degrees % 360.0) type_jour = SIGN_TO_TYPE[signe] + saint_du_jour = SAINTS_BY_MMDD.get(d.strftime("%m-%d"), "") result.append( DayInfo( date=d.isoformat(), @@ -135,6 +148,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]: montante_descendante=montante, signe=signe, type_jour=type_jour, + saint_du_jour=saint_du_jour, perigee=(d in perigee_days), apogee=(d in apogee_days), noeud_lunaire=(d in node_days), diff --git a/backend/app/services/meteo.py b/backend/app/services/meteo.py index a58416c..5b5d73b 100644 --- a/backend/app/services/meteo.py +++ b/backend/app/services/meteo.py @@ -32,6 +32,46 @@ _DAILY_FIELDS = [ "et0_fao_evapotranspiration", ] +_HOURLY_FIELDS = [ + "soil_temperature_0cm", +] + + +def _to_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _value_at(values: list[Any], index: int, default: Any = None) -> Any: + if index < 0 or index >= len(values): + return default + return values[index] + + +def _daily_soil_average(raw: dict[str, Any]) -> dict[str, float]: + """Construit un mapping ISO-date -> moyenne de soil_temperature_0cm.""" + hourly = raw.get("hourly", {}) + times = hourly.get("time", []) or [] + soils = hourly.get("soil_temperature_0cm", []) or [] + by_day: dict[str, list[float]] = {} + + for idx, ts in enumerate(times): + soil = _to_float(_value_at(soils, idx)) + if soil is None or not isinstance(ts, str) or len(ts) < 10: + continue + day = ts[:10] + by_day.setdefault(day, []).append(soil) + + return { + day: round(sum(vals) / len(vals), 2) + for day, vals in by_day.items() + if vals + } + def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]: """Appelle Open-Meteo et retourne la liste des jours (past_days=7 + forecast=8). @@ -50,6 +90,8 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> ] for field in _DAILY_FIELDS: params.append(("daily", field)) + for field in _HOURLY_FIELDS: + params.append(("hourly", field)) try: r = httpx.get(url, params=params, timeout=15) @@ -61,22 +103,23 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> daily = raw.get("daily", {}) dates = daily.get("time", []) + soil_by_day = _daily_soil_average(raw) now_iso = datetime.now(timezone.utc).isoformat() rows = [] for i, d in enumerate(dates): - code = int(daily.get("weather_code", [0] * len(dates))[i] or 0) + code = int(_value_at(daily.get("weather_code", []), i, 0) or 0) row = { "date": d, - "t_min": daily.get("temperature_2m_min", [None] * len(dates))[i], - "t_max": daily.get("temperature_2m_max", [None] * len(dates))[i], - "pluie_mm": daily.get("precipitation_sum", [0] * len(dates))[i] or 0.0, - "vent_kmh": daily.get("wind_speed_10m_max", [0] * len(dates))[i] or 0.0, + "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": daily.get("relative_humidity_2m_max", [None] * len(dates))[i], - "sol_0cm": None, # soil_temperature_0cm est hourly uniquement - "etp_mm": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i], + "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, } rows.append(row) diff --git a/backend/app/services/station.py b/backend/app/services/station.py index 296d939..f478db8 100644 --- a/backend/app/services/station.py +++ b/backend/app/services/station.py @@ -1,6 +1,8 @@ """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 @@ -17,13 +19,37 @@ def _safe_float(text: str | None) -> float | None: try: cleaned = text.strip().replace(",", ".") # Retirer unités courantes - for unit in [" °C", " %", " hPa", " km/h", " W/m²", "°C", "%", "hPa"]: + for unit in [ + " °C", + " %", " %", + " hPa", " mbar", + " km/h", " m/s", + " mm/h", " mm", + " W/m²", " W/m2", + "°C", "%", "hPa", "mbar", + ]: cleaned = cleaned.replace(unit, "") 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 @@ -51,37 +77,51 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None: if item is None: return None - desc = item.findtext("description") or "" + 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() - patterns = { - "temp_ext": r"(?:Outside|Ext(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)", - "temp_int": r"(?:Inside|Int(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)", - "humidite": r"(?:Outside\s*)?Hum(?:idity)?\s*[:\s]+(\d+(?:[.,]\d+)?)", - "pression": r"(?:Bar(?:ometer)?|Pression)\s*[:\s]+(\d+(?:[.,]\d+)?)", - "pluie_mm": r"(?:Rain(?:fall)?|Pluie)\s*[:\s]+(\d+(?:[.,]\d+)?)", - "vent_kmh": r"(?:Wind\s*Speed|Vent)\s*[:\s]+(\d+(?:[.,]\d+)?)", - "uv": r"UV\s*[:\s]+(\d+(?:[.,]\d+)?)", - "solaire": r"(?:Solar\s*Radiation|Solaire)\s*[:\s]+(\d+(?:[.,]\d+)?)", - } + 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) - for key, pattern in patterns.items(): - m = re.search(pattern, desc, re.IGNORECASE) - result[key] = _safe_float(m.group(1)) if m else None + 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 - vent_dir_m = re.search( - r"(?:Wind\s*Dir(?:ection)?)\s*[:\s]+([NSEO]{1,2}|Nord|Sud|Est|Ouest|\d+)", - desc, re.IGNORECASE, - ) - if vent_dir_m: - val = vent_dir_m.group(1).strip() - if val.isdigit(): - result["vent_dir"] = _direction_to_abbr(float(val)) - else: - result["vent_dir"] = val[:2].upper() - else: - result["vent_dir"] = None + 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 @@ -107,15 +147,28 @@ def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None: for line in r.text.splitlines(): parts = line.split() - if len(parts) >= 7 and parts[0].isdigit() and int(parts[0]) == day: - # Format NOAA : jour tmax tmin tmoy precip ... + if not parts or not parts[0].isdigit() or int(parts[0]) != day: + continue + + # Format WeeWX NOAA (fréquent) : + # day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir + if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]: return { - "t_max": _safe_float(parts[1]), - "t_min": _safe_float(parts[2]), - "temp_ext": _safe_float(parts[3]), - "pluie_mm": _safe_float(parts[5]), - "vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None, + "temp_ext": _safe_float(parts[1]), + "t_max": _safe_float(parts[2]), + "t_min": _safe_float(parts[4]), + "pluie_mm": _safe_float(parts[8]), + "vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"), } + + # Fallback générique (anciens formats) + return { + "t_max": _safe_float(parts[1]) if len(parts) > 1 else None, + "t_min": _safe_float(parts[2]) if len(parts) > 2 else None, + "temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None, + "pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None, + "vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None, + } except Exception as e: logger.warning(f"Station fetch_yesterday_summary error: {e}") return None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2295634..1bb81b7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,29 +1,39 @@ +import os import pytest from fastapi.testclient import TestClient from sqlmodel import SQLModel, create_engine, Session 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 from app.main import app from app.database import get_session -@pytest.fixture(name="session") -def session_fixture(): +@pytest.fixture(name="engine") +def engine_fixture(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) SQLModel.metadata.create_all(engine) + return engine + + +@pytest.fixture(name="session") +def session_fixture(engine): with Session(engine) as session: yield session @pytest.fixture(name="client") -def client_fixture(session: Session): +def client_fixture(engine): def get_session_override(): - yield session + with Session(engine) as s: + yield s app.dependency_overrides[get_session] = get_session_override client = TestClient(app) diff --git a/backend/tests/test_astuces_filters.py b/backend/tests/test_astuces_filters.py new file mode 100644 index 0000000..50a4e5a --- /dev/null +++ b/backend/tests/test_astuces_filters.py @@ -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" diff --git a/backend/tests/test_meteo_service.py b/backend/tests/test_meteo_service.py new file mode 100644 index 0000000..0c2adc8 --- /dev/null +++ b/backend/tests/test_meteo_service.py @@ -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"] diff --git a/backend/tests/test_tools.py b/backend/tests/test_tools.py index 2a5936d..3b673d3 100644 --- a/backend/tests/test_tools.py +++ b/backend/tests/test_tools.py @@ -16,3 +16,15 @@ def test_delete_tool(client): 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" diff --git a/codex.md b/codex.md new file mode 100644 index 0000000..c78f006 --- /dev/null +++ b/codex.md @@ -0,0 +1,105 @@ +# 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` + +### 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) + +## 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) + diff --git a/data/jardin.db b/data/jardin.db old mode 100644 new mode 100755 index 87318c9..1390c08 Binary files a/data/jardin.db and b/data/jardin.db differ diff --git a/data/uploads/3241e492-bf06-4741-97d8-4d5e8e63bc4c_Préparer du purin ortie.mp4 b/data/uploads/3241e492-bf06-4741-97d8-4d5e8e63bc4c_Préparer du purin ortie.mp4 new file mode 100644 index 0000000..c727551 Binary files /dev/null and b/data/uploads/3241e492-bf06-4741-97d8-4d5e8e63bc4c_Préparer du purin ortie.mp4 differ diff --git a/data/uploads/384670c3-e3bc-4b30-af94-6a7f0fd0a0a9.webp b/data/uploads/384670c3-e3bc-4b30-af94-6a7f0fd0a0a9.webp new file mode 100644 index 0000000..9526bfb Binary files /dev/null and b/data/uploads/384670c3-e3bc-4b30-af94-6a7f0fd0a0a9.webp differ diff --git a/data/uploads/6a5a8bde-3cb3-4a70-8d0b-20330ab3bc8f_JE TEST L'EMIETTEUR LEBORGNE.mp4 b/data/uploads/6a5a8bde-3cb3-4a70-8d0b-20330ab3bc8f_JE TEST L'EMIETTEUR LEBORGNE.mp4 new file mode 100644 index 0000000..dda9c4c Binary files /dev/null and b/data/uploads/6a5a8bde-3cb3-4a70-8d0b-20330ab3bc8f_JE TEST L'EMIETTEUR LEBORGNE.mp4 differ diff --git a/data/uploads/a3c70da5-8081-42c0-ba14-05b936780fb6.webp b/data/uploads/a3c70da5-8081-42c0-ba14-05b936780fb6.webp new file mode 100644 index 0000000..9526bfb Binary files /dev/null and b/data/uploads/a3c70da5-8081-42c0-ba14-05b936780fb6.webp differ diff --git a/data/uploads/b1d4c0af-c9ed-45bf-a8dd-7a2f78f1b609_Préparer du purin ortie.mp4 b/data/uploads/b1d4c0af-c9ed-45bf-a8dd-7a2f78f1b609_Préparer du purin ortie.mp4 new file mode 100644 index 0000000..c727551 Binary files /dev/null and b/data/uploads/b1d4c0af-c9ed-45bf-a8dd-7a2f78f1b609_Préparer du purin ortie.mp4 differ diff --git a/data/uploads/d2be8f33-14e7-4770-bce8-ac467c645933_upload-test-tSW7.bin b/data/uploads/d2be8f33-14e7-4770-bce8-ac467c645933_upload-test-tSW7.bin new file mode 100644 index 0000000..d9b61f2 Binary files /dev/null and b/data/uploads/d2be8f33-14e7-4770-bce8-ac467c645933_upload-test-tSW7.bin differ diff --git a/data/uploads/dfcfaae4-f640-476f-ba0c-21ad4e9e7642_Préparer du purin ortie.f605.mp4 b/data/uploads/dfcfaae4-f640-476f-ba0c-21ad4e9e7642_Préparer du purin ortie.f605.mp4 new file mode 100644 index 0000000..1427c6a Binary files /dev/null and b/data/uploads/dfcfaae4-f640-476f-ba0c-21ad4e9e7642_Préparer du purin ortie.f605.mp4 differ diff --git a/data/uploads/e93604a3-80a9-493d-93bd-5e87141bf62a_upload-test-NFmT.bin b/data/uploads/e93604a3-80a9-493d-93bd-5e87141bf62a_upload-test-NFmT.bin new file mode 100644 index 0000000..477d0a8 Binary files /dev/null and b/data/uploads/e93604a3-80a9-493d-93bd-5e87141bf62a_upload-test-NFmT.bin differ diff --git a/data/uploads/garden_70757b2d-a485-44f1-881d-322640ef3f9d.webp b/data/uploads/garden_70757b2d-a485-44f1-881d-322640ef3f9d.webp new file mode 100644 index 0000000..3199794 Binary files /dev/null and b/data/uploads/garden_70757b2d-a485-44f1-881d-322640ef3f9d.webp differ diff --git a/data/uploads/garden_d249cc32-f10d-4e1f-a99c-e1b2deecfab1.webp b/data/uploads/garden_d249cc32-f10d-4e1f-a99c-e1b2deecfab1.webp new file mode 100644 index 0000000..9f2e7d2 Binary files /dev/null and b/data/uploads/garden_d249cc32-f10d-4e1f-a99c-e1b2deecfab1.webp differ diff --git a/data/uploads/garden_e3dc6e2e-9fd1-4088-9c53-4edd7ceff418.webp b/data/uploads/garden_e3dc6e2e-9fd1-4088-9c53-4edd7ceff418.webp new file mode 100644 index 0000000..a0c7f5f Binary files /dev/null and b/data/uploads/garden_e3dc6e2e-9fd1-4088-9c53-4edd7ceff418.webp differ diff --git a/docs/api_utilisation_reseau_local_openclaw.md b/docs/api_utilisation_reseau_local_openclaw.md new file mode 100644 index 0000000..9490538 --- /dev/null +++ b/docs/api_utilisation_reseau_local_openclaw.md @@ -0,0 +1,134 @@ +# 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" +``` + +## 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. 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 + +## 7. Swagger + +Documentation interactive: + +- `http://:8060/docs` + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 42e79b9..c59a2c3 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,7 +3,7 @@ server { root /usr/share/nginx/html; index index.html; - client_max_body_size 20M; + client_max_body_size 200M; location /api/ { proxy_pass http://backend:8060; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4493020..155936d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,4 +1,14 @@ diff --git a/frontend/src/App.vue.js b/frontend/src/App.vue.js index 00c7a19..76dfe08 100644 --- a/frontend/src/App.vue.js +++ b/frontend/src/App.vue.js @@ -1,24 +1,158 @@ /// -import { ref } from 'vue'; +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { RouterLink, RouterView } from 'vue-router'; import AppHeader from '@/components/AppHeader.vue'; import AppDrawer from '@/components/AppDrawer.vue'; +import { meteoApi } from '@/api/meteo'; +import { settingsApi } from '@/api/settings'; const drawerOpen = ref(false); +const debugMode = ref(localStorage.getItem('debug_mode') === '1'); +const debugStats = ref(null); +let debugTimer = null; +function prefetchMeteoInBackground() { + // Préchargement du chunk route + données pour accélérer l'ouverture de /meteo + void import('@/views/CalendrierView.vue'); + void meteoApi.preloadForMeteoView(); +} +function toBool(value) { + if (typeof value === 'boolean') + return value; + const s = String(value ?? '').toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; +} +function formatBytes(value) { + if (value == null || Number.isNaN(value)) + return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = value; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i += 1; + } + return `${n.toFixed(n >= 100 ? 0 : n >= 10 ? 1 : 2)}${units[i]}`; +} +const debugCpuLabel = computed(() => { + const pct = debugStats.value?.cpu?.used_pct; + return pct != null ? `${pct.toFixed(1)}%` : '—'; +}); +const debugMemLabel = computed(() => { + const used = debugStats.value?.memory?.used_bytes; + const pct = debugStats.value?.memory?.used_pct; + if (used == null) + return '—'; + return pct != null ? `${formatBytes(used)} (${pct.toFixed(1)}%)` : formatBytes(used); +}); +const debugDiskLabel = computed(() => { + const used = debugStats.value?.disk?.used_bytes; + const pct = debugStats.value?.disk?.used_pct; + if (used == null) + return '—'; + return pct != null ? `${formatBytes(used)} (${pct.toFixed(1)}%)` : formatBytes(used); +}); +async function fetchDebugStats() { + try { + debugStats.value = await settingsApi.getDebugSystemStats(); + } + catch { + debugStats.value = null; + } +} +function stopDebugPolling() { + if (debugTimer != null) { + window.clearInterval(debugTimer); + debugTimer = null; + } +} +function startDebugPolling() { + stopDebugPolling(); + void fetchDebugStats(); + debugTimer = window.setInterval(() => { + void fetchDebugStats(); + }, 10000); +} +async function loadDebugModeFromApi() { + try { + const data = await settingsApi.get(); + debugMode.value = toBool(data.debug_mode); + localStorage.setItem('debug_mode', debugMode.value ? '1' : '0'); + } + catch { + // On garde la valeur locale. + } +} +function handleSettingsUpdated(event) { + const ce = event; + if (!ce.detail || ce.detail.debug_mode == null) + return; + debugMode.value = toBool(ce.detail.debug_mode); + localStorage.setItem('debug_mode', debugMode.value ? '1' : '0'); +} +function handleStorage(event) { + if (event.key !== 'debug_mode') + return; + debugMode.value = event.newValue === '1'; +} +onMounted(() => { + const ric = window.requestIdleCallback; + if (ric) { + ric(() => prefetchMeteoInBackground(), { timeout: 1500 }); + } + else { + window.setTimeout(prefetchMeteoInBackground, 500); + } + void loadDebugModeFromApi(); + window.addEventListener('settings-updated', handleSettingsUpdated); + window.addEventListener('storage', handleStorage); +}); +watch(debugMode, (enabled) => { + if (enabled) + startDebugPolling(); + else { + stopDebugPolling(); + debugStats.value = null; + } +}, { immediate: true }); +onBeforeUnmount(() => { + stopDebugPolling(); + window.removeEventListener('settings-updated', handleSettingsUpdated); + window.removeEventListener('storage', handleStorage); +}); const links = [ { to: '/', label: 'Dashboard', icon: '🏠' }, { to: '/jardins', label: 'Jardins', icon: '🪴' }, { to: '/plantes', label: 'Plantes', icon: '🌱' }, + { to: '/bibliotheque', label: 'Bibliothèque', icon: '📷' }, { to: '/outils', label: 'Outils', icon: '🔧' }, { to: '/plantations', label: 'Plantations', icon: '🥕' }, { to: '/taches', label: 'Tâches', icon: '✅' }, { to: '/planning', label: 'Planning', icon: '📆' }, - { to: '/calendrier', label: 'Calendrier', icon: '🌙' }, + { to: '/meteo', label: 'Météo', icon: '🌦️' }, + { to: '/astuces', label: 'Astuces', icon: '💡' }, { to: '/reglages', label: 'Réglages', icon: '⚙️' }, ]; debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ const __VLS_ctx = {}; let __VLS_components; let __VLS_directives; +if (__VLS_ctx.debugMode) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({ + ...{ class: "fixed top-2 right-2 z-[70] bg-bg-hard/95 border border-bg-soft rounded-lg px-3 py-1.5 text-[11px] text-text-muted shadow-lg" }, + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({ + ...{ class: "text-aqua font-semibold mr-2" }, + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({ + ...{ class: "mr-2" }, + }); + (__VLS_ctx.debugCpuLabel); + __VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({ + ...{ class: "mr-2" }, + }); + (__VLS_ctx.debugMemLabel); + __VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({}); + (__VLS_ctx.debugDiskLabel); +} /** @type {[typeof AppHeader, ]} */ ; // @ts-ignore const __VLS_0 = __VLS_asFunctionalComponent(AppHeader, new AppHeader({ @@ -119,6 +253,24 @@ const __VLS_22 = {}.RouterView; // @ts-ignore const __VLS_23 = __VLS_asFunctionalComponent(__VLS_22, new __VLS_22({})); const __VLS_24 = __VLS_23({}, ...__VLS_functionalComponentArgsRest(__VLS_23)); +/** @type {__VLS_StyleScopedClasses['fixed']} */ ; +/** @type {__VLS_StyleScopedClasses['top-2']} */ ; +/** @type {__VLS_StyleScopedClasses['right-2']} */ ; +/** @type {__VLS_StyleScopedClasses['z-[70]']} */ ; +/** @type {__VLS_StyleScopedClasses['bg-bg-hard/95']} */ ; +/** @type {__VLS_StyleScopedClasses['border']} */ ; +/** @type {__VLS_StyleScopedClasses['border-bg-soft']} */ ; +/** @type {__VLS_StyleScopedClasses['rounded-lg']} */ ; +/** @type {__VLS_StyleScopedClasses['px-3']} */ ; +/** @type {__VLS_StyleScopedClasses['py-1.5']} */ ; +/** @type {__VLS_StyleScopedClasses['text-[11px]']} */ ; +/** @type {__VLS_StyleScopedClasses['text-text-muted']} */ ; +/** @type {__VLS_StyleScopedClasses['shadow-lg']} */ ; +/** @type {__VLS_StyleScopedClasses['text-aqua']} */ ; +/** @type {__VLS_StyleScopedClasses['font-semibold']} */ ; +/** @type {__VLS_StyleScopedClasses['mr-2']} */ ; +/** @type {__VLS_StyleScopedClasses['mr-2']} */ ; +/** @type {__VLS_StyleScopedClasses['mr-2']} */ ; /** @type {__VLS_StyleScopedClasses['lg:hidden']} */ ; /** @type {__VLS_StyleScopedClasses['lg:flex']} */ ; /** @type {__VLS_StyleScopedClasses['hidden']} */ ; @@ -183,6 +335,10 @@ const __VLS_self = (await import('vue')).defineComponent({ AppHeader: AppHeader, AppDrawer: AppDrawer, drawerOpen: drawerOpen, + debugMode: debugMode, + debugCpuLabel: debugCpuLabel, + debugMemLabel: debugMemLabel, + debugDiskLabel: debugDiskLabel, links: links, }; }, diff --git a/frontend/src/api/astuces.js b/frontend/src/api/astuces.js new file mode 100644 index 0000000..6fcc773 --- /dev/null +++ b/frontend/src/api/astuces.js @@ -0,0 +1,8 @@ +import client from './client'; +export const astucesApi = { + list: (params) => client.get('/api/astuces', { params }).then(r => r.data), + get: (id) => client.get(`/api/astuces/${id}`).then(r => r.data), + create: (a) => client.post('/api/astuces', a).then(r => r.data), + update: (id, a) => client.put(`/api/astuces/${id}`, a).then(r => r.data), + remove: (id) => client.delete(`/api/astuces/${id}`), +}; diff --git a/frontend/src/api/astuces.ts b/frontend/src/api/astuces.ts new file mode 100644 index 0000000..8584ab9 --- /dev/null +++ b/frontend/src/api/astuces.ts @@ -0,0 +1,31 @@ +import client from './client' + +export interface Astuce { + id?: number + titre: string + contenu: string + categorie?: string + tags?: string + mois?: string + photos?: string + videos?: string + source?: string + created_at?: string +} + +export const astucesApi = { + list: (params?: { categorie?: string; mois?: number; tag?: string }) => + client.get('/api/astuces', { params }).then(r => r.data), + + get: (id: number) => + client.get(`/api/astuces/${id}`).then(r => r.data), + + create: (a: Omit) => + client.post('/api/astuces', a).then(r => r.data), + + update: (id: number, a: Partial) => + client.put(`/api/astuces/${id}`, a).then(r => r.data), + + remove: (id: number) => + client.delete(`/api/astuces/${id}`), +} diff --git a/frontend/src/api/gardens.js b/frontend/src/api/gardens.js index 86efd3c..8efc10a 100644 --- a/frontend/src/api/gardens.js +++ b/frontend/src/api/gardens.js @@ -4,6 +4,11 @@ export const gardensApi = { get: (id) => client.get(`/api/gardens/${id}`).then(r => r.data), create: (g) => client.post('/api/gardens', g).then(r => r.data), update: (id, g) => client.put(`/api/gardens/${id}`, g).then(r => r.data), + uploadPhoto: (id, file) => { + const fd = new FormData(); + fd.append('file', file); + return client.post(`/api/gardens/${id}/photo`, fd).then(r => r.data); + }, delete: (id) => client.delete(`/api/gardens/${id}`), cells: (id) => client.get(`/api/gardens/${id}/cells`).then(r => r.data), measurements: (id) => client.get(`/api/gardens/${id}/measurements`).then(r => r.data), diff --git a/frontend/src/api/gardens.ts b/frontend/src/api/gardens.ts index 1620b93..84656ca 100644 --- a/frontend/src/api/gardens.ts +++ b/frontend/src/api/gardens.ts @@ -5,8 +5,16 @@ export interface Garden { nom: string description?: string type: string + longueur_m?: number + largeur_m?: number + surface_m2?: number + carre_potager?: boolean + carre_x_cm?: number + carre_y_cm?: number + photo_parcelle?: string latitude?: number longitude?: number + altitude?: number adresse?: string exposition?: string ombre?: string @@ -41,6 +49,11 @@ export const gardensApi = { get: (id: number) => client.get(`/api/gardens/${id}`).then(r => r.data), create: (g: Partial) => client.post('/api/gardens', g).then(r => r.data), update: (id: number, g: Partial) => client.put(`/api/gardens/${id}`, g).then(r => r.data), + uploadPhoto: (id: number, file: File) => { + const fd = new FormData() + fd.append('file', file) + return client.post(`/api/gardens/${id}/photo`, fd).then(r => r.data) + }, delete: (id: number) => client.delete(`/api/gardens/${id}`), cells: (id: number) => client.get(`/api/gardens/${id}/cells`).then(r => r.data), measurements: (id: number) => client.get(`/api/gardens/${id}/measurements`).then(r => r.data), diff --git a/frontend/src/api/lunar.ts b/frontend/src/api/lunar.ts index 1caa961..fab439a 100644 --- a/frontend/src/api/lunar.ts +++ b/frontend/src/api/lunar.ts @@ -8,6 +8,7 @@ export interface LunarDay { montante_descendante: string signe: string type_jour: string + saint_du_jour?: string perigee: boolean apogee: boolean noeud_lunaire: boolean diff --git a/frontend/src/api/meteo.js b/frontend/src/api/meteo.js index cdcb9c6..c741f7a 100644 --- a/frontend/src/api/meteo.js +++ b/frontend/src/api/meteo.js @@ -1,4 +1,60 @@ import client from './client'; +const CACHE_TTL_MS = 5 * 60 * 1000; +const cache = new Map(); +const inflight = new Map(); +function todayIso() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} +function cacheKeyTableau(params) { + const center = params?.center_date || ''; + const span = params?.span ?? ''; + return `tableau:${center}:${span}`; +} +function getCached(key) { + const entry = cache.get(key); + if (!entry) + return null; + if (Date.now() > entry.expires_at) { + cache.delete(key); + return null; + } + return entry.value; +} +function setCached(key, value) { + cache.set(key, { value, expires_at: Date.now() + CACHE_TTL_MS }); +} +function fetchWithCache(key, loader) { + const cached = getCached(key); + if (cached != null) + return Promise.resolve(cached); + const pending = inflight.get(key); + if (pending) + return pending; + const p = loader() + .then((value) => { + setCached(key, value); + return value; + }) + .finally(() => { + inflight.delete(key); + }); + inflight.set(key, p); + return p; +} export const meteoApi = { - getForecast: (days = 14) => client.get('/api/meteo', { params: { days } }).then(r => r.data), + getForecast: (days = 14) => fetchWithCache(`forecast:${days}`, () => client.get('/api/meteo', { params: { days } }).then(r => r.data)), + getTableau: (params) => fetchWithCache(cacheKeyTableau(params), () => client.get('/api/meteo/tableau', { params }).then(r => r.data)), + getStationCurrent: () => fetchWithCache('station-current', () => client.get('/api/meteo/station/current').then(r => r.data)), + getPrevisions: (days = 7) => fetchWithCache(`previsions:${days}`, () => client.get('/api/meteo/previsions', { params: { days } }).then(r => r.data)), + preloadForMeteoView: (params) => Promise.all([ + meteoApi.getTableau(params ?? { center_date: todayIso(), span: 15 }), + meteoApi.getStationCurrent(), + meteoApi.getPrevisions(7), + ]).then(() => undefined), + clearCache: () => { + cache.clear(); + inflight.clear(); + }, + refresh: () => client.post('/api/meteo/refresh').then(r => r.data), }; diff --git a/frontend/src/api/meteo.ts b/frontend/src/api/meteo.ts index e0c0b2c..4bbeffc 100644 --- a/frontend/src/api/meteo.ts +++ b/frontend/src/api/meteo.ts @@ -11,6 +11,134 @@ export interface MeteoDay { icone: string } -export const meteoApi = { - getForecast: (days = 14) => client.get<{ days: MeteoDay[] }>('/api/meteo', { params: { days } }).then(r => r.data), +export interface StationCurrent { + temp_ext?: number + humidite?: number + pression?: number + pluie_mm?: number + vent_kmh?: number + vent_dir?: string + uv?: number + solaire?: number + date_heure?: string +} + +export interface StationDay { + t_min?: number + t_max?: number + pluie_mm?: number + vent_kmh?: number + humidite?: number +} + +export interface OpenMeteoDay { + date?: string + t_min?: number + t_max?: number + pluie_mm?: number + vent_kmh?: number + wmo?: number + label?: string + humidite_moy?: number + sol_0cm?: number + etp_mm?: number +} + +export interface TableauRow { + date: string + type: 'passe' | 'aujourd_hui' | 'futur' + station: StationDay | StationCurrent | null + open_meteo: OpenMeteoDay | null +} + +const CACHE_TTL_MS = 5 * 60 * 1000 + +type CacheEntry = { + value: T + expires_at: number +} + +const cache = new Map>() +const inflight = new Map>() + +function todayIso(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function cacheKeyTableau(params?: { center_date?: string; span?: number }): string { + const center = params?.center_date || '' + const span = params?.span ?? '' + return `tableau:${center}:${span}` +} + +function getCached(key: string): T | null { + const entry = cache.get(key) + if (!entry) return null + if (Date.now() > entry.expires_at) { + cache.delete(key) + return null + } + return entry.value as T +} + +function setCached(key: string, value: T): void { + cache.set(key, { value, expires_at: Date.now() + CACHE_TTL_MS }) +} + +function fetchWithCache(key: string, loader: () => Promise): Promise { + const cached = getCached(key) + if (cached != null) return Promise.resolve(cached) + + const pending = inflight.get(key) + if (pending) return pending as Promise + + const p = loader() + .then((value) => { + setCached(key, value) + return value + }) + .finally(() => { + inflight.delete(key) + }) + + inflight.set(key, p as Promise) + return p +} + +export const meteoApi = { + getForecast: (days = 14) => + fetchWithCache(`forecast:${days}`, () => + client.get<{ days: MeteoDay[] }>('/api/meteo', { params: { days } }).then(r => r.data), + ), + + getTableau: (params?: { center_date?: string; span?: number }) => + fetchWithCache(cacheKeyTableau(params), () => + client.get<{ rows: TableauRow[] }>('/api/meteo/tableau', { params }).then(r => r.data), + ), + + getStationCurrent: () => + fetchWithCache('station-current', () => + client.get('/api/meteo/station/current').then(r => r.data), + ), + + getPrevisions: (days = 7) => + fetchWithCache(`previsions:${days}`, () => + client.get<{ days: OpenMeteoDay[] }>('/api/meteo/previsions', { params: { days } }).then(r => r.data), + ), + + preloadForMeteoView: (params?: { center_date?: string; span?: number }) => + Promise.all([ + meteoApi.getTableau(params ?? { center_date: todayIso(), span: 15 }), + meteoApi.getStationCurrent(), + meteoApi.getPrevisions(7), + ]).then(() => undefined), + + clearCache: () => { + cache.clear() + inflight.clear() + }, + + refresh: () => + client.post('/api/meteo/refresh').then(r => r.data), } diff --git a/frontend/src/api/plantings.ts b/frontend/src/api/plantings.ts index 66a177d..0d2aaa7 100644 --- a/frontend/src/api/plantings.ts +++ b/frontend/src/api/plantings.ts @@ -8,6 +8,10 @@ export interface Planting { date_plantation?: string quantite: number statut: string + boutique_nom?: string + boutique_url?: string + tarif_achat?: number + date_achat?: string notes?: string } diff --git a/frontend/src/api/settings.js b/frontend/src/api/settings.js new file mode 100644 index 0000000..48386de --- /dev/null +++ b/frontend/src/api/settings.js @@ -0,0 +1,6 @@ +import client from './client'; +export const settingsApi = { + get: () => client.get('/api/settings').then(r => r.data), + update: (settings) => client.put('/api/settings', settings).then(r => r.data), + getDebugSystemStats: () => client.get('/api/settings/debug/system').then(r => r.data), +}; diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..1c2d964 --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,33 @@ +import client from './client' + +export type SettingsMap = Record + +export interface DebugSystemStats { + source: string + cpu: { + usage_usec_total?: number | null + quota_cores?: number | null + used_pct?: number | null + } + memory: { + used_bytes?: number | null + limit_bytes?: number | null + used_pct?: number | null + } + disk: { + path?: string + total_bytes?: number | null + used_bytes?: number | null + free_bytes?: number | null + used_pct?: number | null + uploads_bytes?: number | null + } +} + +export const settingsApi = { + get: () => client.get('/api/settings').then(r => r.data), + update: (settings: Record) => + client.put<{ ok: boolean }>('/api/settings', settings).then(r => r.data), + getDebugSystemStats: () => + client.get('/api/settings/debug/system').then(r => r.data), +} diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index c872289..3dc044e 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -7,6 +7,9 @@ export interface Task { garden_id?: number priorite: string echeance?: string + recurrence?: string | null + frequence_jours?: number | null + date_prochaine?: string | null statut: string } diff --git a/frontend/src/api/tools.ts b/frontend/src/api/tools.ts index 10c7f45..c873cbe 100644 --- a/frontend/src/api/tools.ts +++ b/frontend/src/api/tools.ts @@ -6,6 +6,11 @@ export interface Tool { description?: string categorie?: string photo_url?: string + video_url?: string + notice_fichier_url?: string + boutique_nom?: string + boutique_url?: string + prix_achat?: number } export const toolsApi = { diff --git a/frontend/src/components/AppDrawer.vue b/frontend/src/components/AppDrawer.vue index 04e4051..dee8ca4 100644 --- a/frontend/src/components/AppDrawer.vue +++ b/frontend/src/components/AppDrawer.vue @@ -1,6 +1,6 @@