diff --git a/docs/plans/2026-02-22-meteo-astuces.md b/docs/plans/2026-02-22-meteo-astuces.md new file mode 100644 index 0000000..4a98737 --- /dev/null +++ b/docs/plans/2026-02-22-meteo-astuces.md @@ -0,0 +1,1683 @@ +# Météo + Astuces — Plan d'implémentation + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Intégrer la station météo WeeWX locale + Open-Meteo dans FastAPI via APScheduler, afficher un tableau synthétique dans le Calendrier, et ajouter une vue Astuces avec catégories/tags. + +**Architecture:** APScheduler tourne dans le process FastAPI (lifespan). Deux tables SQLModel stockent les données : `meteostation` (WeeWX hourly) et `meteoopenmeteo` (prévisions journalières). Le tableau synthétique fusionne 7j passé (station) + J0 + 7j futur (open-meteo). Les astuces sont une bibliothèque tag-based indépendante. + +**Tech Stack:** FastAPI, SQLModel, APScheduler 3.x, httpx, xml.etree.ElementTree (stdlib), Vue 3 + TypeScript + Pinia + +--- + +## Contexte du projet + +- Backend FastAPI : `backend/` — port 8060 +- Frontend Vue 3 : `frontend/` — port 8061 +- SQLite : `/data/jardin.db` (volume Docker) +- Thème Gruvbox Dark — palette dans `tailwind.config.js` +- Tests : `cd backend && pytest tests/test_X.py -v` +- Station WeeWX : `http://10.0.0.8:8081/` (configurable via env `STATION_URL`) +- Lat/lon par défaut : 45.14 / 4.12 (configurable via `METEO_LAT` / `METEO_LON`) +- Modèles existants : tous dans `backend/app/models/` — importés dans `models/__init__.py` +- Migration colonnes : `backend/app/migrate.py` — pattern `EXPECTED_COLUMNS` +- `Astuce` modèle existant : a `entity_type`, `entity_id`, `titre`, `contenu`, `source` — on ajoute `categorie`, `tags`, `mois` + +--- + +## Task 1 : Dépendances + configuration + +**Files:** +- Modify: `backend/requirements.txt` +- Modify: `backend/app/config.py` +- Modify: `.env.example` + +**Step 1: Ajouter apscheduler à requirements.txt** + +``` +# Ajouter après redis==5.2.1 : +apscheduler==3.10.4 +``` + +Fichier final : +``` +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +sqlmodel==0.0.22 +python-multipart==0.0.12 +aiofiles==24.1.0 +pytest==8.3.3 +httpx==0.28.0 +Pillow==11.1.0 +skyfield==1.49 +pytz==2025.1 +numpy==2.2.3 +redis==5.2.1 +apscheduler==3.10.4 +``` + +**Step 2: Ajouter STATION_URL, METEO_LAT, METEO_LON dans config.py** + +```python +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./jardin.db") +UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads") +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")) +``` + +**Step 3: Ajouter dans .env.example** + +``` +STATION_URL=http://10.0.0.8:8081/ +METEO_LAT=45.14 +METEO_LON=4.12 +``` + +**Step 4: Vérifier que l'import fonctionne** + +```bash +cd backend && python -c "from app.config import STATION_URL, METEO_LAT, METEO_LON; print(STATION_URL, METEO_LAT, METEO_LON)" +``` + +Expected: `http://10.0.0.8:8081/ 45.14 4.12` + +**Step 5: Commit** + +```bash +git add backend/requirements.txt backend/app/config.py .env.example +git commit -m "feat(config): ajout STATION_URL, METEO_LAT/LON + apscheduler dep" +``` + +--- + +## Task 2 : Modèles SQLModel pour les tables météo + +**Files:** +- Create: `backend/app/models/meteo.py` +- Modify: `backend/app/models/__init__.py` + +**Step 1: Créer backend/app/models/meteo.py** + +```python +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 + 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 +``` + +**Step 2: Ajouter les imports dans backend/app/models/__init__.py** + +Ajouter à la fin du fichier : +```python +from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa +``` + +**Step 3: Vérifier que les tables sont créées** + +```bash +cd backend && python -c " +import app.models +from app.database import create_db_and_tables, engine +from sqlmodel import SQLModel +SQLModel.metadata.create_all(engine) +from sqlalchemy import text +with engine.connect() as c: + print([r[0] for r in c.execute(text(\"SELECT name FROM sqlite_master WHERE type='table'\")).fetchall()]) +" +``` + +Expected: liste incluant `meteostation` et `meteoopenmeteo` + +**Step 4: Commit** + +```bash +git add backend/app/models/meteo.py backend/app/models/__init__.py +git commit -m "feat(models): tables MeteoStation + MeteoOpenMeteo (SQLModel)" +``` + +--- + +## Task 3 : Refonte du modèle Astuce (ajout categorie/tags/mois) + +**Files:** +- Modify: `backend/app/models/astuce.py` +- Modify: `backend/app/migrate.py` + +**Step 1: Mettre à jour le modèle Astuce** + +Remplacer le contenu de `backend/app/models/astuce.py` : + +```python +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 + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +``` + +**Step 2: Ajouter la migration des nouvelles colonnes dans migrate.py** + +Ajouter dans le dict `EXPECTED_COLUMNS` (après `"media"`) : + +```python + "astuce": [ + ("categorie", "TEXT", None), + ("tags", "TEXT", None), + ("mois", "TEXT", None), + ], +``` + +**Step 3: Vérifier la migration** + +```bash +cd backend && python -c " +from app.migrate import run_migrations +run_migrations() +print('Migration OK') +" +``` + +Expected: `Migration OK` (sans erreur) + +**Step 4: Commit** + +```bash +git add backend/app/models/astuce.py backend/app/migrate.py +git commit -m "feat(astuce): ajout colonnes categorie/tags/mois + migration" +``` + +--- + +## Task 4 : Service station météo (scraper WeeWX) + +**Files:** +- Create: `backend/app/services/station.py` + +**Step 1: Créer backend/app/services/station.py** + +```python +"""Service de collecte des données de la station météo locale WeeWX.""" +import logging +import xml.etree.ElementTree as ET +from datetime import datetime, 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", " km/h", " W/m²", "°C", "%", "hPa"]: + cleaned = cleaned.replace(unit, "") + return float(cleaned.strip()) + except (ValueError, AttributeError): + return None + + +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) + ns = {"w": "http://www.w3.org/2005/Atom"} + + # WeeWX RSS : le premier contient les conditions actuelles + channel = root.find("channel") + if channel is None: + return None + + item = channel.find("item") + if item is None: + return None + + desc = item.findtext("description") or "" + + # Parser la description HTML simplifiée (format WeeWX standard) + result: dict = {} + + # Extraire les valeurs depuis le texte description + # Format typique : "Outside Temp: 6.2°C | Humidity: 71%..." + import re + + 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+)?)", + } + + for key, pattern in patterns.items(): + m = re.search(pattern, desc, re.IGNORECASE) + result[key] = _safe_float(m.group(1)) if m else None + + # Direction vent (texte ou degrés) + 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 + + 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 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. + """ + from datetime import timedelta + import re + + yesterday = (datetime.now() - timedelta(days=1)).date() + year = yesterday.strftime("%Y") + month = yesterday.strftime("%m") + day = yesterday.day + + try: + url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year}-{month}.txt" + r = httpx.get(url, timeout=15) + r.raise_for_status() + + for line in r.text.splitlines(): + parts = line.split() + if len(parts) >= 7 and parts[0].isdigit() and int(parts[0]) == day: + # Format NOAA : jour tmax tmin tmoy precip ... + return { + "t_max": _safe_float(parts[1]), + "t_min": _safe_float(parts[2]), + "temp_ext": _safe_float(parts[3]), + "pluie_mm": _safe_float(parts[5]), + "vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None, + } + except Exception as e: + logger.warning(f"Station fetch_yesterday_summary error: {e}") + return None +``` + +**Step 2: Test rapide (sans pytest)** + +```bash +cd backend && python -c " +from app.services.station import fetch_current +result = fetch_current() +print('Station current:', result) +" +``` + +Expected: dict avec données (ou None si station non accessible depuis cet environnement) + +**Step 3: Commit** + +```bash +git add backend/app/services/station.py +git commit -m "feat(service): scraper station WeeWX (RSS current + NOAA yesterday)" +``` + +--- + +## Task 5 : Service Open-Meteo enrichi (remplace l'ancien) + +**Files:** +- Modify: `backend/app/services/meteo.py` + +**Step 1: Écrire le test d'abord** + +Créer `backend/tests/test_meteo.py` : + +```python +"""Tests du service météo et des endpoints.""" +from unittest.mock import patch, MagicMock + + +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 +``` + +**Step 2: Lancer le test pour vérifier qu'il échoue** + +```bash +cd backend && pytest tests/test_meteo.py -v +``` + +Expected: FAIL — `test_meteo_tableau_vide` échoue car l'endpoint n'existe pas encore. + +**Step 3: Remplacer backend/app/services/meteo.py** + +```python +"""Service Open-Meteo — enrichi avec sol, ETP, humidité, données passées.""" +import logging +from datetime import datetime, date, timedelta, timezone +from typing import Any + +import httpx + +from app.config import METEO_LAT, METEO_LON + +logger = logging.getLogger(__name__) + +WMO_LABELS = { + 0: "Ensoleillé", 1: "Principalement ensoleillé", 2: "Partiellement nuageux", + 3: "Couvert", 45: "Brouillard", 48: "Brouillard givrant", + 51: "Bruine légère", 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", +} + + +def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]: + """Appelle Open-Meteo et stocke les résultats en base. + + Retourne la liste des jours insérés/mis à jour. + """ + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": lat, + "longitude": lon, + "daily": ",".join([ + "temperature_2m_max", "temperature_2m_min", + "precipitation_sum", "windspeed_10m_max", "weathercode", + "relative_humidity_2m_max", + "soil_temperature_0cm", + "et0_fao_evapotranspiration", + ]), + "past_days": 7, + "forecast_days": 8, + "timezone": "Europe/Paris", + } + try: + r = httpx.get(url, params=params, timeout=15) + r.raise_for_status() + raw = r.json() + except Exception as e: + logger.error(f"Open-Meteo fetch error: {e}") + return [] + + daily = raw.get("daily", {}) + dates = daily.get("time", []) + now_iso = datetime.now(timezone.utc).isoformat() + rows = [] + + for i, d in enumerate(dates): + code = int(daily.get("weathercode", [0] * len(dates))[i] 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("windspeed_10m_max", [0] * len(dates))[i] 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": daily.get("soil_temperature_0cm", [None] * len(dates))[i], + "etp_mm": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i], + "fetched_at": now_iso, + } + rows.append(row) + + return rows + + +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 + today = date.today().isoformat() + future = [r for r in rows if r["date"] >= today][:days] + return {"days": future} +``` + +**Step 4: Commit (le service seulement)** + +```bash +git add backend/app/services/meteo.py +git commit -m "feat(service): open-meteo enrichi (sol, ETP, past_days, humidité)" +``` + +--- + +## Task 6 : Scheduler APScheduler + +**Files:** +- Create: `backend/app/services/scheduler.py` +- Modify: `backend/app/main.py` + +**Step 1: Créer backend/app/services/scheduler.py** + +```python +"""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 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)") +``` + +**Step 2: Intégrer dans le lifespan de main.py** + +Modifier `backend/app/main.py` — remplacer le bloc lifespan : + +```python +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import CORS_ORIGINS, UPLOAD_DIR +from app.database import create_db_and_tables + + +@asynccontextmanager +async def lifespan(app: FastAPI): + os.makedirs(UPLOAD_DIR, exist_ok=True) + try: + 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() + yield + # Arrêter le scheduler + from app.services.scheduler import scheduler + scheduler.shutdown(wait=False) + + +app = FastAPI(title="Jardin API", lifespan=lifespan) +# ... reste inchangé +``` + +**Step 3: Vérifier que le serveur démarre sans erreur** + +```bash +cd backend && python -c " +import asyncio +from app.main import app +print('Import OK:', app.title) +" +``` + +Expected: `Import OK: Jardin API` + +**Step 4: Commit** + +```bash +git add backend/app/services/scheduler.py backend/app/main.py +git commit -m "feat(scheduler): APScheduler 3 jobs météo dans FastAPI lifespan" +``` + +--- + +## Task 7 : Endpoints météo (tableau synthétique) + +**Files:** +- Modify: `backend/app/routers/meteo.py` + +**Step 1: Remplacer backend/app/routers/meteo.py** + +```python +"""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, Query +from sqlalchemy import text + +from app.database import engine + +router = APIRouter(tags=["météo"]) + + +def _station_daily_summary(iso_date: str) -> Optional[dict]: + """Agrège les mesures horaires d'une journée en résumé.""" + with engine.connect() as conn: + rows = conn.execute( + text("SELECT temp_ext, pluie_mm, vent_kmh, humidite FROM meteostation WHERE date(date_heure) = :d"), + {"d": iso_date}, + ).fetchall() + + if not rows: + 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] + + 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, + "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() -> Optional[dict]: + """Dernière mesure station (max 2h d'ancienneté).""" + with engine.connect() as conn: + row = conn.execute( + 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() + + 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(iso_date: str) -> Optional[dict]: + with engine.connect() as conn: + row = conn.execute( + text("SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm " + "FROM meteoopenmeteo WHERE date = :d"), + {"d": iso_date}, + ).fetchone() + + 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() -> dict[str, Any]: + """Tableau synthétique : 7j passé + J0 + 7j futur.""" + today = date.today() + rows = [] + + for delta in range(-7, 8): + d = today + timedelta(days=delta) + iso = d.isoformat() + + if delta < 0: + row_type = "passe" + station = _station_daily_summary(iso) + om = None # Pas de prévision pour le passé + elif delta == 0: + row_type = "aujourd_hui" + station = _station_current_row() + om = _open_meteo_day(iso) + else: + row_type = "futur" + station = None + om = _open_meteo_day(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() -> Optional[dict]: + return _station_current_row() + + +@router.get("/meteo/station/history") +def get_station_history(days: int = Query(7, ge=1, le=30)) -> 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(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)) -> 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(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 + for job_id in ["station_current", "station_veille", "open_meteo"]: + job = scheduler.get_job(job_id) + if job: + job.modify(next_run_time=__import__("datetime").datetime.now()) + return {"status": "refresh planifié"} +``` + +**Step 2: Lancer les tests** + +```bash +cd backend && pytest tests/test_meteo.py -v +``` + +Expected: tous les tests passent (4/4) + +**Step 3: Commit** + +```bash +git add backend/app/routers/meteo.py backend/tests/test_meteo.py +git commit -m "feat(router): endpoints météo tableau/station/previsions + tests" +``` + +--- + +## Task 8 : Router astuces — filtres categorie/tags/mois + +**Files:** +- Modify: `backend/app/routers/astuces.py` + +**Step 1: Écrire le test d'abord** + +Créer `backend/tests/test_astuces.py` : + +```python +"""Tests CRUD astuces avec filtres categorie/tags/mois.""" +import json + + +def test_create_astuce(client): + r = client.post("/api/astuces", json={ + "titre": "Arrosage tomate", + "contenu": "Arroser au pied, jamais sur les feuilles.", + "categorie": "plante", + "tags": json.dumps(["tomate", "arrosage"]), + "mois": json.dumps([5, 6, 7, 8]), + }) + assert r.status_code == 201 + data = r.json() + assert data["titre"] == "Arrosage tomate" + assert data["categorie"] == "plante" + + +def test_list_astuces(client): + client.post("/api/astuces", json={"titre": "T1", "contenu": "C1", "categorie": "jardin"}) + client.post("/api/astuces", json={"titre": "T2", "contenu": "C2", "categorie": "plante"}) + r = client.get("/api/astuces") + assert r.status_code == 200 + assert len(r.json()) >= 2 + + +def test_filter_categorie(client): + client.post("/api/astuces", json={"titre": "A", "contenu": "A", "categorie": "ravageur"}) + client.post("/api/astuces", json={"titre": "B", "contenu": "B", "categorie": "plante"}) + r = client.get("/api/astuces?categorie=ravageur") + assert r.status_code == 200 + assert all(a["categorie"] == "ravageur" for a in r.json()) + + +def test_filter_mois(client): + client.post("/api/astuces", json={ + "titre": "Printemps", "contenu": "X", "mois": json.dumps([3, 4, 5]) + }) + r = client.get("/api/astuces?mois=3") + assert r.status_code == 200 + # Au moins une astuce contient le mois 3 + assert any("3" in (a.get("mois") or "") for a in r.json()) + + +def test_delete_astuce(client): + r = client.post("/api/astuces", json={"titre": "À suppr", "contenu": "X"}) + id_ = r.json()["id"] + assert client.delete(f"/api/astuces/{id_}").status_code == 204 + assert client.get(f"/api/astuces/{id_}").status_code == 404 +``` + +**Step 2: Lancer pour vérifier l'échec** + +```bash +cd backend && pytest tests/test_astuces.py -v +``` + +Expected: certains tests échouent (le filtre mois n'est pas encore implémenté) + +**Step 3: Mettre à jour backend/app/routers/astuces.py** + +```python +"""Router astuces — CRUD + filtres categorie/tags/mois.""" +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"]) + + +@router.get("/astuces", response_model=List[Astuce]) +def list_astuces( + categorie: Optional[str] = Query(None), + mois: Optional[int] = Query(None, ge=1, le=12), + tag: Optional[str] = Query(None), + session: Session = Depends(get_session), +): + q = select(Astuce) + if categorie: + q = q.where(Astuce.categorie == categorie) + astuces = session.exec(q).all() + + # Filtres post-requête (JSON arrays stockés en TEXT) + if mois is not None: + astuces = [ + a for a in astuces + if a.mois is None or str(mois) in (a.mois or "") + ] + if tag: + astuces = [ + a for a in astuces + if tag.lower() in (a.tags or "").lower() + ] + return astuces + + +@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() +``` + +**Step 4: Lancer les tests** + +```bash +cd backend && pytest tests/test_astuces.py -v +``` + +Expected: 5/5 PASS + +**Step 5: Lancer tous les tests backend** + +```bash +cd backend && pytest -v +``` + +Expected: tous passent (aucune régression) + +**Step 6: Commit** + +```bash +git add backend/app/routers/astuces.py backend/tests/test_astuces.py +git commit -m "feat(astuces): filtres categorie/mois/tag + tests CRUD complet" +``` + +--- + +## Task 9 : Frontend API météo + store astuces + +**Files:** +- Modify: `frontend/src/api/meteo.ts` +- Create: `frontend/src/api/astuces.ts` +- Create: `frontend/src/stores/astuces.ts` + +**Step 1: Mettre à jour frontend/src/api/meteo.ts** + +```typescript +import client from './client' + +export interface MeteoDay { + date: string + t_max?: number + t_min?: number + pluie_mm: number + vent_kmh: number + code: number + label: string + icone: string +} + +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 { + 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 +} + +export const meteoApi = { + getForecast: (days = 14) => + client.get<{ days: MeteoDay[] }>('/api/meteo', { params: { days } }).then(r => r.data), + + getTableau: () => + client.get<{ rows: TableauRow[] }>('/api/meteo/tableau').then(r => r.data), + + getStationCurrent: () => + client.get('/api/meteo/station/current').then(r => r.data), + + getPrevisions: (days = 7) => + client.get<{ days: OpenMeteoDay[] }>('/api/meteo/previsions', { params: { days } }).then(r => r.data), + + refresh: () => + client.post('/api/meteo/refresh').then(r => r.data), +} +``` + +**Step 2: Créer frontend/src/api/astuces.ts** + +```typescript +import client from './client' + +export interface Astuce { + id?: number + titre: string + contenu: string + categorie?: string + tags?: string // JSON string: '["tomate","semis"]' + mois?: string // JSON string: '[3,4,5]' + 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}`), +} +``` + +**Step 3: Créer frontend/src/stores/astuces.ts** + +```typescript +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { astucesApi, type Astuce } from '@/api/astuces' + +export const useAstucesStore = defineStore('astuces', () => { + const astuces = ref([]) + const loading = ref(false) + + async function fetchAll(params?: { categorie?: string; mois?: number; tag?: string }) { + loading.value = true + try { + astuces.value = await astucesApi.list(params) + } finally { + loading.value = false + } + } + + async function create(a: Omit) { + const created = await astucesApi.create(a) + astuces.value.unshift(created) + return created + } + + async function update(id: number, data: Partial) { + const updated = await astucesApi.update(id, data) + const idx = astuces.value.findIndex(x => x.id === id) + if (idx !== -1) astuces.value[idx] = updated + return updated + } + + async function remove(id: number) { + await astucesApi.remove(id) + astuces.value = astuces.value.filter(a => a.id !== id) + } + + return { astuces, loading, fetchAll, create, update, remove } +}) +``` + +**Step 4: Commit** + +```bash +git add frontend/src/api/meteo.ts frontend/src/api/astuces.ts frontend/src/stores/astuces.ts +git commit -m "feat(frontend): API météo enrichie + api/stores astuces" +``` + +--- + +## Task 10 : Frontend — CalendrierView.vue (refonte onglet météo) + +**Files:** +- Modify: `frontend/src/views/CalendrierView.vue` + +La section `` (lignes 87-103) est à remplacer par un tableau synthétique. + +**Step 1: Remplacer le bloc `
` (lignes 87-103)** + +```html + +
+ +
+
+
Température extérieure
+
{{ stationCurrent.temp_ext?.toFixed(1) }}°C
+
+
+ 💧{{ stationCurrent.humidite }}% + 💨{{ stationCurrent.vent_kmh }} km/h {{ stationCurrent.vent_dir || '' }} + ⬛{{ stationCurrent.pression }} hPa +
+
+ Relevé {{ stationCurrent.date_heure?.slice(11, 16) }} +
+
+ + +
Chargement météo...
+
Pas de données météo.
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date📡 Station locale🌐 Open-Meteo
T°minT°max💧mmT°minT°max💧mmÉtat
+ + {{ formatDate(row.date) }} + + + {{ row.station && 't_min' in row.station && row.station.t_min != null ? row.station.t_min.toFixed(1) + '°' : '—' }} + + {{ row.station && 't_max' in row.station && row.station.t_max != null ? row.station.t_max.toFixed(1) + '°' : (row.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null ? row.station.temp_ext.toFixed(1) + '° act.' : '—') }} + + {{ row.station && row.station.pluie_mm != null ? row.station.pluie_mm : '—' }} + + {{ row.open_meteo?.t_min != null ? row.open_meteo.t_min.toFixed(1) + '°' : '—' }} + + {{ row.open_meteo?.t_max != null ? row.open_meteo.t_max.toFixed(1) + '°' : '—' }} + + {{ row.open_meteo?.pluie_mm != null ? row.open_meteo.pluie_mm : '—' }} + +
+ + {{ row.open_meteo?.label || '' }} +
+
+
+
+``` + +**Step 2: Ajouter dans la section ` +``` + +**Step 2: Ajouter la route dans frontend/src/router/index.ts** + +Ajouter après la ligne `/calendrier` : +```typescript +{ path: '/astuces', component: () => import('@/views/AstucessView.vue') }, +``` + +**Step 3: Ajouter l'entrée dans frontend/src/components/AppDrawer.vue** + +Dans le tableau `links`, ajouter après `{ to: '/calendrier', label: 'Calendrier' }` : +```typescript +{ to: '/astuces', label: '💡 Astuces' }, +``` + +**Step 4: Vérifier le build** + +```bash +cd frontend && npm run build 2>&1 | head -40 +``` + +Expected: Build sans erreur TypeScript + +**Step 5: Commit** + +```bash +git add frontend/src/views/AstucessView.vue frontend/src/router/index.ts frontend/src/components/AppDrawer.vue +git commit -m "feat(frontend): AstucessView + route /astuces + drawer" +``` + +--- + +## Task 12 : Vérification finale + +**Step 1: Tests backend complets** + +```bash +cd backend && pytest -v +``` + +Expected: tous les tests passent + +**Step 2: Build frontend** + +```bash +cd frontend && npm run build +``` + +Expected: Build réussi + +**Step 3: Démarrage local (optionnel)** + +```bash +cd backend && uvicorn app.main:app --reload --port 8060 +``` + +Vérifier dans les logs : +- `Scheduler météo démarré (3 jobs)` +- `Station current stockée` (ou warning si station inaccessible) +- `Open-Meteo stocké : N jours` + +**Step 4: Commit de clôture** + +```bash +git add -A +git commit -m "feat: météo + astuces — APScheduler + SQLite + tableau synthétique + AstucessView" +```