maj via codex
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
75
backend/tests/test_astuces_filters.py
Normal file
75
backend/tests/test_astuces_filters.py
Normal 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"
|
||||
87
backend/tests/test_meteo_service.py
Normal file
87
backend/tests/test_meteo_service.py
Normal 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"]
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user