# 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°min T°max 💧mm T°min T°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" ```