1684 lines
53 KiB
Markdown
1684 lines
53 KiB
Markdown
# 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 <item> 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<StationCurrent | null>('/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<Astuce[]>('/api/astuces', { params }).then(r => r.data),
|
|
|
|
get: (id: number) =>
|
|
client.get<Astuce>(`/api/astuces/${id}`).then(r => r.data),
|
|
|
|
create: (a: Omit<Astuce, 'id' | 'created_at'>) =>
|
|
client.post<Astuce>('/api/astuces', a).then(r => r.data),
|
|
|
|
update: (id: number, a: Partial<Astuce>) =>
|
|
client.put<Astuce>(`/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<Astuce[]>([])
|
|
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<Astuce, 'id' | 'created_at'>) {
|
|
const created = await astucesApi.create(a)
|
|
astuces.value.unshift(created)
|
|
return created
|
|
}
|
|
|
|
async function update(id: number, data: Partial<Astuce>) {
|
|
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 `<!-- === MÉTÉO === -->` (lignes 87-103) est à remplacer par un tableau synthétique.
|
|
|
|
**Step 1: Remplacer le bloc `<div v-if="activeTab === 'meteo'">` (lignes 87-103)**
|
|
|
|
```html
|
|
<!-- === MÉTÉO === -->
|
|
<div v-if="activeTab === 'meteo'">
|
|
<!-- Widget "maintenant" -->
|
|
<div v-if="stationCurrent" class="bg-bg-soft rounded-xl p-4 border border-bg-hard mb-4 flex flex-wrap gap-4 items-center">
|
|
<div>
|
|
<div class="text-text-muted text-xs mb-1">Température extérieure</div>
|
|
<div class="text-text text-2xl font-bold">{{ stationCurrent.temp_ext?.toFixed(1) }}°C</div>
|
|
</div>
|
|
<div class="flex gap-4 text-sm">
|
|
<span v-if="stationCurrent.humidite" class="text-blue">💧{{ stationCurrent.humidite }}%</span>
|
|
<span v-if="stationCurrent.vent_kmh" class="text-text">💨{{ stationCurrent.vent_kmh }} km/h {{ stationCurrent.vent_dir || '' }}</span>
|
|
<span v-if="stationCurrent.pression" class="text-text-muted">⬛{{ stationCurrent.pression }} hPa</span>
|
|
</div>
|
|
<div v-if="stationCurrent.date_heure" class="text-text-muted text-xs ml-auto">
|
|
Relevé {{ stationCurrent.date_heure?.slice(11, 16) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tableau synthétique -->
|
|
<div v-if="loadingTableau" class="text-text-muted text-sm py-4">Chargement météo...</div>
|
|
<div v-else-if="!tableauRows.length" class="text-text-muted text-sm py-4">Pas de données météo.</div>
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr class="text-text-muted text-xs">
|
|
<th class="text-left py-2 px-2">Date</th>
|
|
<th class="text-center py-2 px-2 text-blue" colspan="3">📡 Station locale</th>
|
|
<th class="text-center py-2 px-2 text-green" colspan="4">🌐 Open-Meteo</th>
|
|
</tr>
|
|
<tr class="text-text-muted text-xs border-b border-bg-hard">
|
|
<th class="text-left py-1 px-2"></th>
|
|
<th class="text-right py-1 px-1">T°min</th>
|
|
<th class="text-right py-1 px-1">T°max</th>
|
|
<th class="text-right py-1 px-1">💧mm</th>
|
|
<th class="text-right py-1 px-1">T°min</th>
|
|
<th class="text-right py-1 px-1">T°max</th>
|
|
<th class="text-right py-1 px-1">💧mm</th>
|
|
<th class="text-left py-1 px-2">État</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in tableauRows" :key="row.date"
|
|
:class="['border-b border-bg-hard transition-colors',
|
|
row.type === 'aujourd_hui' ? 'border-green bg-green/5 font-semibold' : '',
|
|
row.type === 'passe' ? 'opacity-60' : '']">
|
|
<td class="py-2 px-2 text-text-muted text-xs whitespace-nowrap">
|
|
<span :class="row.type === 'aujourd_hui' ? 'text-green font-bold' : ''">
|
|
{{ formatDate(row.date) }}
|
|
</span>
|
|
</td>
|
|
<!-- Colonnes station -->
|
|
<td class="text-right px-1 text-blue text-xs">
|
|
{{ row.station && 't_min' in row.station && row.station.t_min != null ? row.station.t_min.toFixed(1) + '°' : '—' }}
|
|
</td>
|
|
<td class="text-right px-1 text-orange text-xs">
|
|
{{ 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.' : '—') }}
|
|
</td>
|
|
<td class="text-right px-1 text-blue text-xs">
|
|
{{ row.station && row.station.pluie_mm != null ? row.station.pluie_mm : '—' }}
|
|
</td>
|
|
<!-- Colonnes open-meteo -->
|
|
<td class="text-right px-1 text-blue text-xs">
|
|
{{ row.open_meteo?.t_min != null ? row.open_meteo.t_min.toFixed(1) + '°' : '—' }}
|
|
</td>
|
|
<td class="text-right px-1 text-orange text-xs">
|
|
{{ row.open_meteo?.t_max != null ? row.open_meteo.t_max.toFixed(1) + '°' : '—' }}
|
|
</td>
|
|
<td class="text-right px-1 text-blue text-xs">
|
|
{{ row.open_meteo?.pluie_mm != null ? row.open_meteo.pluie_mm : '—' }}
|
|
</td>
|
|
<td class="px-2">
|
|
<div class="flex items-center gap-1">
|
|
<img v-if="row.open_meteo?.wmo != null" :src="weatherIcon(row.open_meteo.wmo)" class="w-5 h-5" :alt="row.open_meteo.label" />
|
|
<span class="text-text-muted text-xs">{{ row.open_meteo?.label || '' }}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Step 2: Ajouter dans la section `<script setup lang="ts">` les nouvelles refs et fonctions**
|
|
|
|
Ajouter après `const loadingMeteo = ref(false)` (ligne 180 environ) :
|
|
|
|
```typescript
|
|
// Tableau synthétique
|
|
const tableauRows = ref<import('@/api/meteo').TableauRow[]>([])
|
|
const loadingTableau = ref(false)
|
|
const stationCurrent = ref<import('@/api/meteo').StationCurrent | null>(null)
|
|
|
|
async function loadTableau() {
|
|
loadingTableau.value = true
|
|
try {
|
|
const res = await meteoApi.getTableau()
|
|
tableauRows.value = res.rows || []
|
|
} catch { tableauRows.value = [] }
|
|
finally { loadingTableau.value = false }
|
|
}
|
|
|
|
async function loadStationCurrent() {
|
|
try {
|
|
stationCurrent.value = await meteoApi.getStationCurrent()
|
|
} catch { stationCurrent.value = null }
|
|
}
|
|
```
|
|
|
|
**Step 3: Mettre à jour les imports et le watch**
|
|
|
|
En haut du `<script setup>`, remplacer la ligne d'import meteo :
|
|
```typescript
|
|
import { meteoApi, type MeteoDay, type TableauRow, type StationCurrent } from '@/api/meteo'
|
|
```
|
|
|
|
Mettre à jour le watch et onMounted :
|
|
```typescript
|
|
watch(activeTab, (tab) => {
|
|
if (tab === 'lunaire' && !lunarDays.value.length) loadLunar()
|
|
if (tab === 'meteo' && !tableauRows.value.length) { loadTableau(); loadStationCurrent() }
|
|
if (tab === 'dictons' && !dictons.value.length) loadDictons()
|
|
})
|
|
|
|
onMounted(() => { loadLunar(); loadTableau(); loadStationCurrent() })
|
|
```
|
|
|
|
Supprimer les anciennes refs `meteoData` et `loadingMeteo` et la fonction `loadMeteo` (plus utilisées).
|
|
|
|
**Step 4: Vérifier que le build TypeScript compile**
|
|
|
|
```bash
|
|
cd frontend && npm run build 2>&1 | head -30
|
|
```
|
|
|
|
Expected: Build réussi sans erreur TypeScript
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/views/CalendrierView.vue
|
|
git commit -m "feat(frontend): CalendrierView météo — tableau synthétique station + open-meteo"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11 : Frontend — AstucessView.vue (nouvelle vue)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/views/AstucessView.vue`
|
|
- Modify: `frontend/src/router/index.ts`
|
|
- Modify: `frontend/src/components/AppDrawer.vue`
|
|
|
|
**Step 1: Créer frontend/src/views/AstucessView.vue**
|
|
|
|
```html
|
|
<template>
|
|
<div class="p-4 max-w-3xl mx-auto">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold text-yellow">💡 Astuces</h1>
|
|
<button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
|
+ Ajouter
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tabs catégories -->
|
|
<div class="flex gap-1 mb-4 bg-bg-soft rounded-lg p-1 w-fit flex-wrap">
|
|
<button v-for="cat in categories" :key="cat.val" @click="activeCat = cat.val"
|
|
:class="['px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
|
|
activeCat === cat.val ? 'bg-yellow text-bg' : 'text-text-muted hover:text-text']">
|
|
{{ cat.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Filtre mois courant -->
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<button @click="filterMoisActuel = !filterMoisActuel"
|
|
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors border',
|
|
filterMoisActuel ? 'bg-green/20 text-green border-green/40' : 'border-bg-hard text-text-muted']">
|
|
📅 Ce mois ({{ moisActuelLabel }})
|
|
</button>
|
|
<input v-model="filterTag" placeholder="Filtrer par tag..." class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow w-40" />
|
|
</div>
|
|
|
|
<div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
|
|
<div v-else-if="!store.astuces.length" class="text-text-muted text-sm py-8 text-center">
|
|
Aucune astuce pour ce filtre.
|
|
</div>
|
|
|
|
<!-- Liste astuces -->
|
|
<div v-for="a in store.astuces" :key="a.id"
|
|
class="bg-bg-soft rounded-xl p-4 mb-3 border border-bg-hard">
|
|
<div class="flex items-start justify-between gap-2 mb-2">
|
|
<div>
|
|
<span class="text-text font-semibold text-sm">{{ a.titre }}</span>
|
|
<span v-if="a.categorie" :class="['ml-2 text-xs px-2 py-0.5 rounded-full', catClass(a.categorie)]">
|
|
{{ a.categorie }}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-2 shrink-0">
|
|
<button @click="startEdit(a)" class="text-yellow text-xs hover:underline">Édit.</button>
|
|
<button @click="remove(a.id!)" class="text-text-muted hover:text-red text-xs">✕</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-text-muted text-sm mb-2">{{ a.contenu }}</p>
|
|
<div class="flex gap-1 flex-wrap">
|
|
<span v-for="tag in parseTags(a.tags)" :key="tag"
|
|
@click="filterTag = tag"
|
|
class="text-xs bg-bg border border-bg-hard rounded-full px-2 py-0.5 text-text-muted cursor-pointer hover:text-yellow hover:border-yellow transition-colors">
|
|
#{{ tag }}
|
|
</span>
|
|
<span v-if="parseMois(a.mois).length"
|
|
class="text-xs bg-green/10 text-green rounded-full px-2 py-0.5">
|
|
Mois : {{ parseMois(a.mois).join(', ') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal création / édition -->
|
|
<div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
|
|
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft max-h-[90vh] overflow-y-auto">
|
|
<h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier l\'astuce' : 'Nouvelle astuce' }}</h2>
|
|
<form @submit.prevent="submit" class="flex flex-col gap-3">
|
|
<div>
|
|
<label class="text-text-muted text-xs block mb-1">Titre *</label>
|
|
<input v-model="form.titre" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm outline-none focus:border-yellow" />
|
|
</div>
|
|
<div>
|
|
<label class="text-text-muted text-xs block mb-1">Contenu *</label>
|
|
<textarea v-model="form.contenu" required rows="4" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm outline-none focus:border-yellow resize-none" />
|
|
</div>
|
|
<div>
|
|
<label class="text-text-muted text-xs block mb-1">Catégorie</label>
|
|
<select v-model="form.categorie" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
|
|
<option value="">Toutes catégories</option>
|
|
<option v-for="c in categories.slice(1)" :key="c.val" :value="c.val">{{ c.label }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="text-text-muted text-xs block mb-1">Tags (séparés par virgule)</label>
|
|
<input v-model="tagsInput" placeholder="tomate, semis, printemps" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm outline-none focus:border-yellow" />
|
|
</div>
|
|
<div>
|
|
<label class="text-text-muted text-xs block mb-2">Mois concernés (0 = toute l'année)</label>
|
|
<div class="grid grid-cols-6 gap-1">
|
|
<label v-for="m in 12" :key="m" class="flex items-center gap-1 text-xs text-text-muted cursor-pointer">
|
|
<input type="checkbox" :value="m" v-model="moisSelected" class="accent-yellow" />
|
|
{{ ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][m-1] }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 justify-end mt-2">
|
|
<button type="button" @click="closeForm" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
|
|
<button type="submit" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
|
{{ editId ? 'Enregistrer' : 'Créer' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { useAstucesStore } from '@/stores/astuces'
|
|
import type { Astuce } from '@/api/astuces'
|
|
|
|
const store = useAstucesStore()
|
|
const showForm = ref(false)
|
|
const editId = ref<number | null>(null)
|
|
const activeCat = ref('')
|
|
const filterMoisActuel = ref(false)
|
|
const filterTag = ref('')
|
|
const tagsInput = ref('')
|
|
const moisSelected = ref<number[]>([])
|
|
|
|
const form = reactive({ titre: '', contenu: '', categorie: '', tags: '', mois: '' })
|
|
|
|
const categories = [
|
|
{ val: '', label: '🌿 Toutes' },
|
|
{ val: 'plante', label: '🌱 Plante' },
|
|
{ val: 'jardin', label: '🏡 Jardin' },
|
|
{ val: 'tache', label: '✅ Tâche' },
|
|
{ val: 'general', label: '📖 Général' },
|
|
{ val: 'ravageur', label: '🐛 Ravageur' },
|
|
{ val: 'maladie', label: '🍄 Maladie' },
|
|
]
|
|
|
|
const moisActuel = new Date().getMonth() + 1
|
|
const moisActuelLabel = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][moisActuel - 1]
|
|
|
|
function parseTags(tags?: string): string[] {
|
|
try { return tags ? JSON.parse(tags) : [] } catch { return [] }
|
|
}
|
|
function parseMois(mois?: string): number[] {
|
|
try { return mois ? JSON.parse(mois) : [] } catch { return [] }
|
|
}
|
|
|
|
const catClass = (cat: string) => ({
|
|
plante: 'bg-green/20 text-green',
|
|
jardin: 'bg-yellow/20 text-yellow',
|
|
tache: 'bg-blue/20 text-blue',
|
|
general: 'bg-text-muted/20 text-text-muted',
|
|
ravageur: 'bg-red/20 text-red',
|
|
maladie: 'bg-orange/20 text-orange',
|
|
}[cat] || 'bg-bg text-text-muted')
|
|
|
|
async function load() {
|
|
const params: Record<string, string | number> = {}
|
|
if (activeCat.value) params.categorie = activeCat.value
|
|
if (filterMoisActuel.value) params.mois = moisActuel
|
|
if (filterTag.value) params.tag = filterTag.value
|
|
await store.fetchAll(params)
|
|
}
|
|
|
|
watch([activeCat, filterMoisActuel, filterTag], load)
|
|
|
|
function openCreate() {
|
|
editId.value = null
|
|
Object.assign(form, { titre: '', contenu: '', categorie: '', tags: '', mois: '' })
|
|
tagsInput.value = ''
|
|
moisSelected.value = []
|
|
showForm.value = true
|
|
}
|
|
|
|
function startEdit(a: Astuce) {
|
|
editId.value = a.id!
|
|
Object.assign(form, { titre: a.titre, contenu: a.contenu, categorie: a.categorie || '', tags: a.tags || '', mois: a.mois || '' })
|
|
tagsInput.value = parseTags(a.tags).join(', ')
|
|
moisSelected.value = parseMois(a.mois)
|
|
showForm.value = true
|
|
}
|
|
|
|
function closeForm() { showForm.value = false; editId.value = null }
|
|
|
|
async function submit() {
|
|
const tagsArr = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
|
const payload = {
|
|
...form,
|
|
tags: tagsArr.length ? JSON.stringify(tagsArr) : undefined,
|
|
mois: moisSelected.value.length ? JSON.stringify(moisSelected.value) : undefined,
|
|
}
|
|
if (editId.value) {
|
|
await store.update(editId.value, payload)
|
|
} else {
|
|
await store.create(payload)
|
|
}
|
|
closeForm()
|
|
load()
|
|
}
|
|
|
|
async function remove(id: number) {
|
|
if (confirm('Supprimer cette astuce ?')) await store.remove(id)
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
```
|
|
|
|
**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"
|
|
```
|