maj via codex

This commit is contained in:
2026-02-22 18:34:50 +01:00
parent 20af00d653
commit 55387f4b0e
90 changed files with 9902 additions and 1251 deletions

View File

@@ -6,3 +6,5 @@ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
STATION_URL = os.getenv("STATION_URL", "http://10.0.0.8:8081/")
METEO_LAT = float(os.getenv("METEO_LAT", "45.14"))
METEO_LON = float(os.getenv("METEO_LON", "4.12"))
ENABLE_SCHEDULER = os.getenv("ENABLE_SCHEDULER", "1").lower() in {"1", "true", "yes", "on"}
ENABLE_BOOTSTRAP = os.getenv("ENABLE_BOOTSTRAP", "1").lower() in {"1", "true", "yes", "on"}

View File

@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import CORS_ORIGINS, UPLOAD_DIR
from app.config import CORS_ORIGINS, ENABLE_BOOTSTRAP, ENABLE_SCHEDULER, UPLOAD_DIR
from app.database import create_db_and_tables
@@ -15,19 +15,20 @@ async def lifespan(app: FastAPI):
os.makedirs("/data/skyfield", exist_ok=True)
except OSError:
pass
import app.models # noqa — enregistre tous les modèles avant create_all
from app.migrate import run_migrations
run_migrations()
create_db_and_tables()
from app.seed import run_seed
run_seed()
# Démarrer le scheduler météo
from app.services.scheduler import setup_scheduler
setup_scheduler()
if ENABLE_BOOTSTRAP:
import app.models # noqa — enregistre tous les modèles avant create_all
from app.migrate import run_migrations
run_migrations()
create_db_and_tables()
from app.seed import run_seed
run_seed()
if ENABLE_SCHEDULER:
from app.services.scheduler import setup_scheduler
setup_scheduler()
yield
# Arrêter le scheduler
from app.services.scheduler import scheduler
scheduler.shutdown(wait=False)
if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER:
from app.services.scheduler import scheduler
scheduler.shutdown(wait=False)
app = FastAPI(title="Jardin API", lifespan=lifespan)

View File

@@ -15,7 +15,17 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("url_reference", "TEXT", None),
],
"garden": [
("latitude", "REAL", None),
("longitude", "REAL", None),
("altitude", "REAL", None),
("adresse", "TEXT", None),
("longueur_m", "REAL", None),
("largeur_m", "REAL", None),
("surface_m2", "REAL", None),
("carre_potager", "INTEGER", "0"),
("carre_x_cm", "INTEGER", None),
("carre_y_cm", "INTEGER", None),
("photo_parcelle", "TEXT", None),
("ensoleillement", "TEXT", None),
],
"task": [
@@ -23,6 +33,24 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("date_prochaine", "TEXT", None),
("outil_id", "INTEGER", None),
],
"meteostation": [
("t_min", "REAL", None),
("t_max", "REAL", None),
],
"tool": [
("photo_url", "TEXT", None),
("video_url", "TEXT", None),
("notice_fichier_url", "TEXT", None),
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
("prix_achat", "REAL", None),
],
"planting": [
("boutique_nom", "TEXT", None),
("boutique_url", "TEXT", None),
("tarif_achat", "REAL", None),
("date_achat", "TEXT", None),
],
"plantvariety": [
# ancien nom de table → migration vers "plant" si présente
],
@@ -36,6 +64,8 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
("categorie", "TEXT", None),
("tags", "TEXT", None),
("mois", "TEXT", None),
("photos", "TEXT", None),
("videos", "TEXT", None),
],
}

View File

@@ -16,4 +16,6 @@ class Astuce(SQLModel, table=True):
categorie: Optional[str] = None # "plante"|"jardin"|"tache"|"general"|"ravageur"|"maladie"
tags: Optional[str] = None # JSON array string: '["tomate","semis"]'
mois: Optional[str] = None # JSON array string: '[3,4,5]' ou null = toute l'année
photos: Optional[str] = None # JSON array string: '["/uploads/a.webp"]'
videos: Optional[str] = None # JSON array string: '["/uploads/b.mp4"]'
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -16,7 +16,13 @@ class Garden(SQLModel, table=True):
ombre: Optional[str] = None # ombre | mi-ombre | plein_soleil
sol_type: Optional[str] = None
sol_ph: Optional[float] = None
longueur_m: Optional[float] = None
largeur_m: Optional[float] = None
surface_m2: Optional[float] = None
carre_potager: bool = False
carre_x_cm: Optional[int] = None
carre_y_cm: Optional[int] = None
photo_parcelle: Optional[str] = None
ensoleillement: Optional[str] = None
grille_largeur: int = 6
grille_hauteur: int = 4

View File

@@ -5,7 +5,7 @@ from sqlmodel import Field, SQLModel
class Media(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
entity_type: str # jardin|plante|outil|plantation
entity_type: str # jardin|plante|adventice|outil|plantation|bibliotheque
entity_id: int
url: str
thumbnail_url: Optional[str] = None

View File

@@ -9,6 +9,8 @@ class MeteoStation(SQLModel, table=True):
date_heure: str = Field(primary_key=True) # "2026-02-22T14:00"
type: str = "current" # "current" | "veille"
temp_ext: Optional[float] = None # °C extérieur
t_min: Optional[float] = None # résumé journée (NOAA)
t_max: Optional[float] = None # résumé journée (NOAA)
temp_int: Optional[float] = None # °C intérieur (serre)
humidite: Optional[float] = None # %
pression: Optional[float] = None # hPa

View File

@@ -12,6 +12,10 @@ class PlantingCreate(SQLModel):
date_repiquage: Optional[date] = None
quantite: int = 1
statut: str = "prevu"
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
tarif_achat: Optional[float] = None
date_achat: Optional[date] = None
date_recolte_debut: Optional[date] = None
date_recolte_fin: Optional[date] = None
rendement_estime: Optional[float] = None
@@ -29,6 +33,10 @@ class Planting(SQLModel, table=True):
date_repiquage: Optional[date] = None
quantite: int = 1
statut: str = "prevu" # prevu | en_cours | termine | echoue
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
tarif_achat: Optional[float] = None
date_achat: Optional[date] = None
date_recolte_debut: Optional[date] = None
date_recolte_fin: Optional[date] = None
rendement_estime: Optional[float] = None

View File

@@ -9,4 +9,9 @@ class Tool(SQLModel, table=True):
description: Optional[str] = None
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
photo_url: Optional[str] = None
video_url: Optional[str] = None
notice_fichier_url: Optional[str] = None
boutique_nom: Optional[str] = None
boutique_url: Optional[str] = None
prix_achat: Optional[float] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -1,3 +1,4 @@
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
@@ -7,10 +8,45 @@ from app.models.astuce import Astuce
router = APIRouter(tags=["astuces"])
def _decode_tags(raw: Optional[str]) -> list[str]:
if not raw:
return []
try:
parsed = json.loads(raw)
except Exception:
parsed = [p.strip() for p in raw.split(",") if p.strip()]
if not isinstance(parsed, list):
return []
return [str(x).strip().lower() for x in parsed if str(x).strip()]
def _decode_mois(raw: Optional[str]) -> list[int]:
if not raw:
return []
try:
parsed = json.loads(raw)
except Exception:
parsed = [p.strip() for p in raw.split(",") if p.strip()]
if not isinstance(parsed, list):
return []
result: list[int] = []
for x in parsed:
try:
month = int(x)
if 1 <= month <= 12:
result.append(month)
except (TypeError, ValueError):
continue
return result
@router.get("/astuces", response_model=List[Astuce])
def list_astuces(
entity_type: Optional[str] = Query(None),
entity_id: Optional[int] = Query(None),
categorie: Optional[str] = Query(None),
tag: Optional[str] = Query(None),
mois: Optional[int] = Query(None, ge=1, le=12),
session: Session = Depends(get_session),
):
q = select(Astuce)
@@ -18,7 +54,21 @@ def list_astuces(
q = q.where(Astuce.entity_type == entity_type)
if entity_id is not None:
q = q.where(Astuce.entity_id == entity_id)
return session.exec(q).all()
if categorie:
q = q.where(Astuce.categorie == categorie)
items = session.exec(q).all()
if tag:
wanted = tag.strip().lower()
items = [a for a in items if wanted in _decode_tags(a.tags)]
if mois is not None:
# mois null/empty = astuce valable toute l'année
items = [a for a in items if not _decode_mois(a.mois) or mois in _decode_mois(a.mois)]
return items
@router.post("/astuces", response_model=Astuce, status_code=status.HTTP_201_CREATED)

View File

@@ -1,15 +1,37 @@
import os
import uuid
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlmodel import Session, select
from app.config import UPLOAD_DIR
from app.database import get_session
from app.models.garden import Garden, GardenCell, GardenImage, Measurement
router = APIRouter(tags=["jardins"])
def _save_garden_photo(data: bytes) -> str:
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(data)).convert("RGB")
img.thumbnail((1800, 1800))
name = f"garden_{uuid.uuid4()}.webp"
path = os.path.join(UPLOAD_DIR, name)
img.save(path, "WEBP", quality=88)
return name
except Exception:
name = f"garden_{uuid.uuid4()}.bin"
path = os.path.join(UPLOAD_DIR, name)
with open(path, "wb") as f:
f.write(data)
return name
@router.get("/gardens", response_model=List[Garden])
def list_gardens(session: Session = Depends(get_session)):
return session.exec(select(Garden)).all()
@@ -31,6 +53,31 @@ def get_garden(id: int, session: Session = Depends(get_session)):
return g
@router.post("/gardens/{id}/photo", response_model=Garden)
async def upload_garden_photo(
id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
g = session.get(Garden, id)
if not g:
raise HTTPException(status_code=404, detail="Jardin introuvable")
content_type = file.content_type or ""
if not content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Le fichier doit être une image")
os.makedirs(UPLOAD_DIR, exist_ok=True)
data = await file.read()
filename = _save_garden_photo(data)
g.photo_parcelle = f"/uploads/{filename}"
g.updated_at = datetime.now(timezone.utc)
session.add(g)
session.commit()
session.refresh(g)
return g
@router.put("/gardens/{id}", response_model=Garden)
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
g = session.get(Garden, id)

View File

@@ -2,7 +2,7 @@
from datetime import date, timedelta
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlmodel import Session
@@ -15,7 +15,7 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
"""Agrège les mesures horaires d'une journée en résumé."""
rows = session.exec(
text(
"SELECT temp_ext, pluie_mm, vent_kmh, humidite "
"SELECT temp_ext, t_min, t_max, pluie_mm, vent_kmh, humidite "
"FROM meteostation WHERE substr(date_heure, 1, 10) = :d"
),
params={"d": iso_date},
@@ -25,14 +25,20 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
return None
temps = [r[0] for r in rows if r[0] is not None]
pluies = [r[1] for r in rows if r[1] is not None]
vents = [r[2] for r in rows if r[2] is not None]
hums = [r[3] for r in rows if r[3] is not None]
t_mins = [r[1] for r in rows if r[1] is not None]
t_maxs = [r[2] for r in rows if r[2] is not None]
pluies = [r[3] for r in rows if r[3] is not None]
vents = [r[4] for r in rows if r[4] is not None]
hums = [r[5] for r in rows if r[5] is not None]
min_candidates = temps + t_mins
max_candidates = temps + t_maxs
return {
"t_min": round(min(temps), 1) if temps else None,
"t_max": round(max(temps), 1) if temps else None,
"pluie_mm": round(sum(pluies), 1) if pluies else 0.0,
"t_min": round(min(min_candidates), 1) if min_candidates else None,
"t_max": round(max(max_candidates), 1) if max_candidates else None,
# WeeWX RSS expose souvent une pluie cumulée journalière.
"pluie_mm": round(max(pluies), 1) if pluies else 0.0,
"vent_kmh": round(max(vents), 1) if vents else None,
"humidite": round(sum(hums) / len(hums), 0) if hums else None,
}
@@ -77,22 +83,36 @@ def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
@router.get("/meteo/tableau")
def get_tableau(session: Session = Depends(get_session)) -> dict[str, Any]:
"""Tableau synthétique : 7j passé + J0 + 7j futur."""
def get_tableau(
center_date: Optional[str] = Query(None, description="Date centrale YYYY-MM-DD"),
span: int = Query(7, ge=1, le=31, description="Nombre de jours avant/après la date centrale"),
session: Session = Depends(get_session),
) -> dict[str, Any]:
"""Tableau synthétique centré sur une date, avec historique + prévision."""
today = date.today()
center = today
if center_date:
try:
center = date.fromisoformat(center_date)
except ValueError as exc:
raise HTTPException(status_code=400, detail="center_date invalide (format YYYY-MM-DD)") from exc
rows = []
for delta in range(-7, 8):
d = today + timedelta(days=delta)
for delta in range(-span, span + 1):
d = center + timedelta(days=delta)
iso = d.isoformat()
delta_today = (d - today).days
if delta < 0:
if delta_today < 0:
row_type = "passe"
station = _station_daily_summary(session, iso)
om = None # Pas de prévision pour le passé
elif delta == 0:
om = _open_meteo_day(session, iso)
elif delta_today == 0:
row_type = "aujourd_hui"
station = _station_current_row(session)
station_current = _station_current_row(session) or {}
station_daily = _station_daily_summary(session, iso) or {}
station = {**station_daily, **station_current} or None
om = _open_meteo_day(session, iso)
else:
row_type = "futur"

View File

@@ -1,10 +1,117 @@
import os
import shutil
import time
from typing import Any
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.database import get_session
from app.models.settings import UserSettings
from app.config import UPLOAD_DIR
router = APIRouter(tags=["réglages"])
_PREV_CPU_USAGE_USEC: int | None = None
_PREV_CPU_TS: float | None = None
def _read_int_from_paths(paths: list[str]) -> int | None:
for path in paths:
try:
with open(path, "r", encoding="utf-8") as f:
raw = f.read().strip().split()[0]
return int(raw)
except Exception:
continue
return None
def _read_cgroup_cpu_usage_usec() -> int | None:
# cgroup v2
try:
with open("/sys/fs/cgroup/cpu.stat", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("usage_usec "):
return int(line.split()[1])
except Exception:
pass
# cgroup v1
ns = _read_int_from_paths(["/sys/fs/cgroup/cpuacct/cpuacct.usage"])
if ns is not None:
return ns // 1000
return None
def _cpu_quota_cores() -> float | None:
# cgroup v2
try:
with open("/sys/fs/cgroup/cpu.max", "r", encoding="utf-8") as f:
quota, period = f.read().strip().split()[:2]
if quota == "max":
return float(os.cpu_count() or 1)
q, p = int(quota), int(period)
if p > 0:
return max(q / p, 0.01)
except Exception:
pass
# cgroup v1
quota = _read_int_from_paths(["/sys/fs/cgroup/cpu/cpu.cfs_quota_us"])
period = _read_int_from_paths(["/sys/fs/cgroup/cpu/cpu.cfs_period_us"])
if quota is not None and period is not None and quota > 0 and period > 0:
return max(quota / period, 0.01)
return float(os.cpu_count() or 1)
def _memory_stats() -> dict[str, Any]:
used = _read_int_from_paths(
[
"/sys/fs/cgroup/memory.current", # cgroup v2
"/sys/fs/cgroup/memory/memory.usage_in_bytes", # cgroup v1
]
)
limit = _read_int_from_paths(
[
"/sys/fs/cgroup/memory.max", # cgroup v2
"/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
]
)
# Certaines limites cgroup valent "max" ou des sentinelles tres grandes.
if limit is not None and limit >= 9_000_000_000_000_000_000:
limit = None
pct = None
if used is not None and limit and limit > 0:
pct = round((used / limit) * 100, 1)
return {"used_bytes": used, "limit_bytes": limit, "used_pct": pct}
def _disk_stats() -> dict[str, Any]:
target = "/data" if os.path.isdir("/data") else "/"
total, used, free = shutil.disk_usage(target)
uploads_size = None
if os.path.isdir(UPLOAD_DIR):
try:
uploads_size = sum(
os.path.getsize(os.path.join(root, name))
for root, _, files in os.walk(UPLOAD_DIR)
for name in files
)
except Exception:
uploads_size = None
return {
"path": target,
"total_bytes": total,
"used_bytes": used,
"free_bytes": free,
"used_pct": round((used / total) * 100, 1) if total else None,
"uploads_bytes": uploads_size,
}
@router.get("/settings")
def get_settings(session: Session = Depends(get_session)):
@@ -23,3 +130,34 @@ def update_settings(data: dict, session: Session = Depends(get_session)):
session.add(row)
session.commit()
return {"ok": True}
@router.get("/settings/debug/system")
def get_debug_system_stats() -> dict[str, Any]:
"""Stats runtime du conteneur (utile pour affichage debug UI)."""
global _PREV_CPU_USAGE_USEC, _PREV_CPU_TS
now = time.monotonic()
usage_usec = _read_cgroup_cpu_usage_usec()
quota_cores = _cpu_quota_cores()
cpu_pct = None
if usage_usec is not None and _PREV_CPU_USAGE_USEC is not None and _PREV_CPU_TS is not None:
delta_usage = usage_usec - _PREV_CPU_USAGE_USEC
delta_time_usec = (now - _PREV_CPU_TS) * 1_000_000
if delta_time_usec > 0 and quota_cores and quota_cores > 0:
cpu_pct = round((delta_usage / (delta_time_usec * quota_cores)) * 100, 1)
_PREV_CPU_USAGE_USEC = usage_usec
_PREV_CPU_TS = now
return {
"source": "container-cgroup",
"cpu": {
"usage_usec_total": usage_usec,
"quota_cores": quota_cores,
"used_pct": cpu_pct,
},
"memory": _memory_stats(),
"disk": _disk_stats(),
}

View File

@@ -19,6 +19,17 @@ SIGN_TO_TYPE = {
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
}
SAINTS_BY_MMDD = {
"04-23": "Saint Georges",
"04-25": "Saint Marc",
"05-11": "Saint Mamert",
"05-12": "Saint Pancrace",
"05-13": "Saint Servais",
"05-14": "Saint Boniface",
"05-19": "Saint Yves",
"05-25": "Saint Urbain",
}
@dataclass
class DayInfo:
@@ -29,6 +40,7 @@ class DayInfo:
montante_descendante: str
signe: str
type_jour: str
saint_du_jour: str
perigee: bool
apogee: bool
noeud_lunaire: bool
@@ -126,6 +138,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
lat, lon, dist = v_moon.ecliptic_latlon()
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
type_jour = SIGN_TO_TYPE[signe]
saint_du_jour = SAINTS_BY_MMDD.get(d.strftime("%m-%d"), "")
result.append(
DayInfo(
date=d.isoformat(),
@@ -135,6 +148,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
montante_descendante=montante,
signe=signe,
type_jour=type_jour,
saint_du_jour=saint_du_jour,
perigee=(d in perigee_days),
apogee=(d in apogee_days),
noeud_lunaire=(d in node_days),

View File

@@ -32,6 +32,46 @@ _DAILY_FIELDS = [
"et0_fao_evapotranspiration",
]
_HOURLY_FIELDS = [
"soil_temperature_0cm",
]
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _value_at(values: list[Any], index: int, default: Any = None) -> Any:
if index < 0 or index >= len(values):
return default
return values[index]
def _daily_soil_average(raw: dict[str, Any]) -> dict[str, float]:
"""Construit un mapping ISO-date -> moyenne de soil_temperature_0cm."""
hourly = raw.get("hourly", {})
times = hourly.get("time", []) or []
soils = hourly.get("soil_temperature_0cm", []) or []
by_day: dict[str, list[float]] = {}
for idx, ts in enumerate(times):
soil = _to_float(_value_at(soils, idx))
if soil is None or not isinstance(ts, str) or len(ts) < 10:
continue
day = ts[:10]
by_day.setdefault(day, []).append(soil)
return {
day: round(sum(vals) / len(vals), 2)
for day, vals in by_day.items()
if vals
}
def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]:
"""Appelle Open-Meteo et retourne la liste des jours (past_days=7 + forecast=8).
@@ -50,6 +90,8 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) ->
]
for field in _DAILY_FIELDS:
params.append(("daily", field))
for field in _HOURLY_FIELDS:
params.append(("hourly", field))
try:
r = httpx.get(url, params=params, timeout=15)
@@ -61,22 +103,23 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) ->
daily = raw.get("daily", {})
dates = daily.get("time", [])
soil_by_day = _daily_soil_average(raw)
now_iso = datetime.now(timezone.utc).isoformat()
rows = []
for i, d in enumerate(dates):
code = int(daily.get("weather_code", [0] * len(dates))[i] or 0)
code = int(_value_at(daily.get("weather_code", []), i, 0) or 0)
row = {
"date": d,
"t_min": daily.get("temperature_2m_min", [None] * len(dates))[i],
"t_max": daily.get("temperature_2m_max", [None] * len(dates))[i],
"pluie_mm": daily.get("precipitation_sum", [0] * len(dates))[i] or 0.0,
"vent_kmh": daily.get("wind_speed_10m_max", [0] * len(dates))[i] or 0.0,
"t_min": _to_float(_value_at(daily.get("temperature_2m_min", []), i)),
"t_max": _to_float(_value_at(daily.get("temperature_2m_max", []), i)),
"pluie_mm": _to_float(_value_at(daily.get("precipitation_sum", []), i, 0.0)) or 0.0,
"vent_kmh": _to_float(_value_at(daily.get("wind_speed_10m_max", []), i, 0.0)) or 0.0,
"wmo": code,
"label": WMO_LABELS.get(code, f"Code {code}"),
"humidite_moy": daily.get("relative_humidity_2m_max", [None] * len(dates))[i],
"sol_0cm": None, # soil_temperature_0cm est hourly uniquement
"etp_mm": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i],
"humidite_moy": _to_float(_value_at(daily.get("relative_humidity_2m_max", []), i)),
"sol_0cm": soil_by_day.get(d),
"etp_mm": _to_float(_value_at(daily.get("et0_fao_evapotranspiration", []), i)),
"fetched_at": now_iso,
}
rows.append(row)

View File

@@ -1,6 +1,8 @@
"""Service de collecte des données de la station météo locale WeeWX."""
import html
import logging
import re
import unicodedata
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
@@ -17,13 +19,37 @@ def _safe_float(text: str | None) -> float | None:
try:
cleaned = text.strip().replace(",", ".")
# Retirer unités courantes
for unit in [" °C", " %", " hPa", " km/h", " W/m²", "°C", "%", "hPa"]:
for unit in [
" °C",
" %", " %",
" hPa", " mbar",
" km/h", " m/s",
" mm/h", " mm",
" W/m²", " W/m2",
"°C", "%", "hPa", "mbar",
]:
cleaned = cleaned.replace(unit, "")
return float(cleaned.strip())
except (ValueError, AttributeError):
return None
def _normalize(text: str) -> str:
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = text.lower()
return re.sub(r"\s+", " ", text).strip()
def _to_kmh(value: float | None, unit: str | None) -> float | None:
if value is None:
return None
u = (unit or "").strip().lower()
if u == "m/s":
return round(value * 3.6, 1)
return round(value, 1)
def _direction_to_abbr(deg: float | None) -> str | None:
if deg is None:
return None
@@ -51,37 +77,51 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
if item is None:
return None
desc = item.findtext("description") or ""
desc = html.unescape(item.findtext("description") or "")
result: dict = {}
segments = [seg.strip() for seg in desc.split(";") if seg.strip()]
for seg in segments:
if ":" not in seg:
continue
raw_key, raw_value = seg.split(":", 1)
key = _normalize(raw_key)
value = raw_value.strip()
patterns = {
"temp_ext": r"(?:Outside|Ext(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
"temp_int": r"(?:Inside|Int(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
"humidite": r"(?:Outside\s*)?Hum(?:idity)?\s*[:\s]+(\d+(?:[.,]\d+)?)",
"pression": r"(?:Bar(?:ometer)?|Pression)\s*[:\s]+(\d+(?:[.,]\d+)?)",
"pluie_mm": r"(?:Rain(?:fall)?|Pluie)\s*[:\s]+(\d+(?:[.,]\d+)?)",
"vent_kmh": r"(?:Wind\s*Speed|Vent)\s*[:\s]+(\d+(?:[.,]\d+)?)",
"uv": r"UV\s*[:\s]+(\d+(?:[.,]\d+)?)",
"solaire": r"(?:Solar\s*Radiation|Solaire)\s*[:\s]+(\d+(?:[.,]\d+)?)",
}
if "temperature exterieure" in key or "outside temperature" in key:
result["temp_ext"] = _safe_float(value)
continue
if "temperature interieure" in key or "inside temperature" in key:
result["temp_int"] = _safe_float(value)
continue
if "hygrometrie exterieure" in key or "outside humidity" in key:
result["humidite"] = _safe_float(value)
continue
if "pression atmospherique" in key or "barometer" in key:
result["pression"] = _safe_float(value)
continue
if "precipitations" in key and "taux" not in key and "rate" not in key:
result["pluie_mm"] = _safe_float(value)
continue
if key in {"uv", "ultra-violet"} or "ultra violet" in key:
result["uv"] = _safe_float(value)
continue
if "rayonnement solaire" in key or "solar radiation" in key:
result["solaire"] = _safe_float(value)
continue
if key == "vent" or "wind" in key:
speed_match = re.search(r"(-?\d+(?:[.,]\d+)?)\s*(m/s|km/h)?", value, re.IGNORECASE)
speed_val = _safe_float(speed_match.group(1)) if speed_match else None
speed_unit = speed_match.group(2) if speed_match else None
result["vent_kmh"] = _to_kmh(speed_val, speed_unit)
for key, pattern in patterns.items():
m = re.search(pattern, desc, re.IGNORECASE)
result[key] = _safe_float(m.group(1)) if m else None
deg_match = re.search(r"(\d{1,3}(?:[.,]\d+)?)\s*°", value)
if deg_match:
result["vent_dir"] = _direction_to_abbr(_safe_float(deg_match.group(1)))
continue
vent_dir_m = re.search(
r"(?:Wind\s*Dir(?:ection)?)\s*[:\s]+([NSEO]{1,2}|Nord|Sud|Est|Ouest|\d+)",
desc, re.IGNORECASE,
)
if vent_dir_m:
val = vent_dir_m.group(1).strip()
if val.isdigit():
result["vent_dir"] = _direction_to_abbr(float(val))
else:
result["vent_dir"] = val[:2].upper()
else:
result["vent_dir"] = None
card_match = re.search(r"\b(N|NE|E|SE|S|SO|O|NO|NNE|ENE|ESE|SSE|SSO|OSO|ONO|NNO)\b", value, re.IGNORECASE)
result["vent_dir"] = card_match.group(1).upper() if card_match else None
return result if any(v is not None for v in result.values()) else None
@@ -107,15 +147,28 @@ def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
for line in r.text.splitlines():
parts = line.split()
if len(parts) >= 7 and parts[0].isdigit() and int(parts[0]) == day:
# Format NOAA : jour tmax tmin tmoy precip ...
if not parts or not parts[0].isdigit() or int(parts[0]) != day:
continue
# Format WeeWX NOAA (fréquent) :
# day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
return {
"t_max": _safe_float(parts[1]),
"t_min": _safe_float(parts[2]),
"temp_ext": _safe_float(parts[3]),
"pluie_mm": _safe_float(parts[5]),
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
"temp_ext": _safe_float(parts[1]),
"t_max": _safe_float(parts[2]),
"t_min": _safe_float(parts[4]),
"pluie_mm": _safe_float(parts[8]),
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
}
# Fallback générique (anciens formats)
return {
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
}
except Exception as e:
logger.warning(f"Station fetch_yesterday_summary error: {e}")
return None

View File

@@ -1,29 +1,39 @@
import os
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from sqlmodel.pool import StaticPool
os.environ.setdefault("ENABLE_SCHEDULER", "0")
os.environ.setdefault("ENABLE_BOOTSTRAP", "0")
import app.models # noqa — force l'enregistrement des modèles
from app.main import app
from app.database import get_session
@pytest.fixture(name="session")
def session_fixture():
@pytest.fixture(name="engine")
def engine_fixture():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
return engine
@pytest.fixture(name="session")
def session_fixture(engine):
with Session(engine) as session:
yield session
@pytest.fixture(name="client")
def client_fixture(session: Session):
def client_fixture(engine):
def get_session_override():
yield session
with Session(engine) as s:
yield s
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)

View File

@@ -0,0 +1,75 @@
"""Tests des filtres catégorie/tag/mois du router astuces."""
from app.models.astuce import Astuce
from app.routers.astuces import list_astuces
def _seed(session):
session.add(
Astuce(
titre="Tomate mildiou",
contenu="Surveiller humidité",
categorie="maladie",
tags='["tomate", "mildiou"]',
mois="[6,7,8]",
entity_type="plant",
entity_id=1,
)
)
session.add(
Astuce(
titre="Semis salade",
contenu="Semer en ligne",
categorie="plante",
tags='["salade", "semis"]',
mois="[3,4,9]",
entity_type="plant",
entity_id=2,
)
)
session.add(
Astuce(
titre="Paillage universel",
contenu="Proteger le sol",
categorie="jardin",
tags='["sol", "eau"]',
mois=None,
)
)
session.commit()
def test_filter_by_categorie(session):
_seed(session)
out = list_astuces(entity_type=None, entity_id=None, categorie="plante", tag=None, mois=None, session=session)
assert len(out) == 1
assert out[0].titre == "Semis salade"
def test_filter_by_tag(session):
_seed(session)
out = list_astuces(entity_type=None, entity_id=None, categorie=None, tag="tomate", mois=None, session=session)
assert len(out) == 1
assert out[0].titre == "Tomate mildiou"
def test_filter_by_mois_includes_all_year(session):
_seed(session)
out = list_astuces(entity_type=None, entity_id=None, categorie=None, tag=None, mois=12, session=session)
titles = {a.titre for a in out}
assert "Paillage universel" in titles
assert "Tomate mildiou" not in titles
def test_combined_filters(session):
_seed(session)
out = list_astuces(entity_type=None, entity_id=None, categorie="maladie", tag="mildiou", mois=7, session=session)
assert len(out) == 1
assert out[0].titre == "Tomate mildiou"
def test_legacy_entity_filters(session):
_seed(session)
out = list_astuces(entity_type="plant", entity_id=2, categorie=None, tag=None, mois=None, session=session)
assert len(out) == 1
assert out[0].titre == "Semis salade"

View File

@@ -0,0 +1,87 @@
"""Tests unitaires du service Open-Meteo enrichi."""
from datetime import date as real_date
import app.services.meteo as meteo
class _DummyResponse:
def __init__(self, payload: dict):
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return self._payload
def test_fetch_and_store_forecast_enriched(monkeypatch):
payload = {
"daily": {
"time": ["2026-02-21", "2026-02-22"],
"temperature_2m_min": [1.2, 2.3],
"temperature_2m_max": [8.4, 9.7],
"precipitation_sum": [0.5, 1.0],
"wind_speed_10m_max": [12.0, 15.0],
"weather_code": [3, 61],
"relative_humidity_2m_max": [88, 92],
"et0_fao_evapotranspiration": [0.9, 1.1],
},
"hourly": {
"time": [
"2026-02-21T00:00",
"2026-02-21T01:00",
"2026-02-22T00:00",
"2026-02-22T01:00",
],
"soil_temperature_0cm": [4.0, 6.0, 8.0, 10.0],
},
}
def _fake_get(*_args, **_kwargs):
return _DummyResponse(payload)
monkeypatch.setattr(meteo.httpx, "get", _fake_get)
rows = meteo.fetch_and_store_forecast(lat=45.1, lon=4.0)
assert len(rows) == 2
assert rows[0]["date"] == "2026-02-21"
assert rows[0]["label"] == "Couvert"
assert rows[0]["sol_0cm"] == 5.0
assert rows[0]["etp_mm"] == 0.9
assert rows[1]["label"] == "Pluie légère"
assert rows[1]["sol_0cm"] == 9.0
def test_fetch_and_store_forecast_handles_http_error(monkeypatch):
def _boom(*_args, **_kwargs):
raise RuntimeError("network down")
monkeypatch.setattr(meteo.httpx, "get", _boom)
rows = meteo.fetch_and_store_forecast()
assert rows == []
def test_fetch_forecast_filters_from_today(monkeypatch):
class _FakeDate(real_date):
@classmethod
def today(cls):
return cls(2026, 2, 22)
monkeypatch.setattr(meteo, "date", _FakeDate)
monkeypatch.setattr(
meteo,
"fetch_and_store_forecast",
lambda *_args, **_kwargs: [
{"date": "2026-02-21", "x": 1},
{"date": "2026-02-22", "x": 2},
{"date": "2026-02-23", "x": 3},
],
)
out = meteo.fetch_forecast(days=14)
assert [d["date"] for d in out["days"]] == ["2026-02-22", "2026-02-23"]

View File

@@ -16,3 +16,15 @@ def test_delete_tool(client):
id = r.json()["id"]
r2 = client.delete(f"/api/tools/{id}")
assert r2.status_code == 204
def test_tool_with_video_url(client):
r = client.post(
"/api/tools",
json={
"nom": "Tarière",
"video_url": "/uploads/demo-outil.mp4",
},
)
assert r.status_code == 201
assert r.json()["video_url"] == "/uploads/demo-outil.mp4"