maj via codex
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user