Afficher l'IP client (x-forwarded-for) dans les logs des requêtes API
This commit is contained in:
@ -37,7 +37,7 @@
|
|||||||
### Module structure & data flow
|
### Module structure & data flow
|
||||||
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map)
|
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map)
|
||||||
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
|
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
|
||||||
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation).
|
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation). `/api/presets` (GET/POST/DELETE) shares layer presets between browsers (`output/.presets.json`, localStorage echo in the interface as fallback).
|
||||||
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress.
|
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress.
|
||||||
- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.
|
- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.
|
||||||
|
|
||||||
|
|||||||
@ -1389,6 +1389,11 @@ const STATS = {stats_json};
|
|||||||
_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */
|
_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Le color-scheme impose au navigateur le rendu sombre des contrôles
|
||||||
|
natifs (popup des <select>, barres de défilement) : sans cela, le
|
||||||
|
menu déroulant des préconfigurations s'affiche en thème clair sur
|
||||||
|
une interface sombre. */
|
||||||
|
color-scheme: dark;
|
||||||
--bg: #0b0e13;
|
--bg: #0b0e13;
|
||||||
--panel: rgba(16, 20, 27, 0.93);
|
--panel: rgba(16, 20, 27, 0.93);
|
||||||
--panel-border: rgba(255, 255, 255, 0.09);
|
--panel-border: rgba(255, 255, 255, 0.09);
|
||||||
@ -1465,6 +1470,10 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
|
|||||||
border: 1px solid var(--panel-border); border-radius: 6px;
|
border: 1px solid var(--panel-border); border-radius: 6px;
|
||||||
padding: 3px 6px; font-size: 12px; cursor: pointer;
|
padding: 3px 6px; font-size: 12px; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.gen-select:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
/* Options du menu déroulant : fallback pour les navigateurs sans
|
||||||
|
color-scheme (le popup natif hérite de ces couleurs). */
|
||||||
|
.gen-select option { background-color: #10141b; color: var(--text); }
|
||||||
|
|
||||||
/* --- Barre latérale (gauche, pleine hauteur) --- */
|
/* --- Barre latérale (gauche, pleine hauteur) --- */
|
||||||
#sidebar {
|
#sidebar {
|
||||||
@ -2246,10 +2255,49 @@ document.getElementById('osmRow').querySelector('.layer-name').title =
|
|||||||
// visibles, leurs niveaux de transparence (opacités des
|
// visibles, leurs niveaux de transparence (opacités des
|
||||||
// visualisations + fond de carte OSM) et l'ordre de pile
|
// visualisations + fond de carte OSM) et l'ordre de pile
|
||||||
// (bas → haut). Les presets intégrés couvrent les couches du
|
// (bas → haut). Les presets intégrés couvrent les couches du
|
||||||
// panneau ; les presets personnels (persistés en localStorage)
|
// panneau ; les presets personnels sont stockés sur le serveur
|
||||||
// se suppriment depuis la liste.
|
// (/api/presets, partagés entre tous les navigateurs connectés)
|
||||||
|
// et en écho dans le localStorage du navigateur (repli si l'interface
|
||||||
|
// est servie sans webapp).
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const PRESETS_LS = 'lidarPresets_v1';
|
const PRESETS_LS = 'lidarPresets_v1';
|
||||||
|
const PRESETS_URL = '/api/presets';
|
||||||
|
// Écho local des presets : s'il n'y a pas de webapp (serveur statique),
|
||||||
|
// ils ne vivent que dans le localStorage du navigateur.
|
||||||
|
let customPresets = (() => {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
|
||||||
|
return Array.isArray(p) ? p : [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
})();
|
||||||
|
function persistCustomPresets() {
|
||||||
|
try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les presets stockés sur le serveur sont partagés entre tous les
|
||||||
|
// navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux
|
||||||
|
// déjà en local (même id = la version serveur l'emporte). Webapp
|
||||||
|
// injoignable : la liste locale est conservée telle quelle.
|
||||||
|
function upsertCustomPreset(p) {
|
||||||
|
const i = customPresets.findIndex(x => x.id === p.id);
|
||||||
|
if (i === -1) customPresets.push(p); else customPresets[i] = p;
|
||||||
|
}
|
||||||
|
function removeCustomPreset(id) {
|
||||||
|
customPresets = customPresets.filter(p => p.id !== id);
|
||||||
|
}
|
||||||
|
async function syncCustomPresets() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(PRESETS_URL, { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
const server = Array.isArray(data.presets) ? data.presets : [];
|
||||||
|
const byId = new Map(customPresets.map(p => [p.id, p]));
|
||||||
|
for (const p of server) if (p && p.id) byId.set(p.id, p);
|
||||||
|
customPresets = [...byId.values()];
|
||||||
|
persistCustomPresets();
|
||||||
|
renderPresetSelect(presetSelectEl.value);
|
||||||
|
} catch (e) { /* pas de webapp : presets locaux uniquement */ }
|
||||||
|
}
|
||||||
// Les presets portent les niveaux de transparence : opacité de chaque
|
// Les presets portent les niveaux de transparence : opacité de chaque
|
||||||
// visualisation + fond de carte OSM (visibilité, opacité, mode sombre).
|
// visualisation + fond de carte OSM (visibilité, opacité, mode sombre).
|
||||||
const BUILTIN_PRESETS = [
|
const BUILTIN_PRESETS = [
|
||||||
@ -2270,16 +2318,6 @@ const BUILTIN_PRESETS = [
|
|||||||
osm: { on: true, opacity: 0.85, dark: true } },
|
osm: { on: true, opacity: 0.85, dark: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
function loadCustomPresets() {
|
|
||||||
try {
|
|
||||||
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
|
|
||||||
return Array.isArray(p) ? p : [];
|
|
||||||
} catch (e) { return []; }
|
|
||||||
}
|
|
||||||
function saveCustomPresets(list) {
|
|
||||||
try { localStorage.setItem(PRESETS_LS, JSON.stringify(list)); } catch (e) { /* privé */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Niveaux de transparence d'un preset, lisibles dans la liste
|
// Niveaux de transparence d'un preset, lisibles dans la liste
|
||||||
// (opacité de chaque visualisation + fond de carte OSM).
|
// (opacité de chaque visualisation + fond de carte OSM).
|
||||||
function presetDetail(p) {
|
function presetDetail(p) {
|
||||||
@ -2331,7 +2369,7 @@ function renderPresetSelect(selectedValue) {
|
|||||||
o.textContent = p.label + ' — ' + presetDetail(p);
|
o.textContent = p.label + ' — ' + presetDetail(p);
|
||||||
presetSelectEl.appendChild(o);
|
presetSelectEl.appendChild(o);
|
||||||
}
|
}
|
||||||
const customs = loadCustomPresets();
|
const customs = customPresets;
|
||||||
if (customs.length) {
|
if (customs.length) {
|
||||||
const g = document.createElement('optgroup');
|
const g = document.createElement('optgroup');
|
||||||
g.label = 'Mes presets';
|
g.label = 'Mes presets';
|
||||||
@ -2352,7 +2390,7 @@ presetSelectEl.addEventListener('change', () => {
|
|||||||
if (!v) return;
|
if (!v) return;
|
||||||
let p = null;
|
let p = null;
|
||||||
if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
|
if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
|
||||||
else if (v.startsWith('custom:')) p = loadCustomPresets().find(x => x.id === v.slice(7));
|
else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7));
|
||||||
if (p) applyPreset(p);
|
if (p) applyPreset(p);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2369,23 +2407,34 @@ presetSaveEl.addEventListener('click', () => {
|
|||||||
// Niveaux de transparence du fond de carte
|
// Niveaux de transparence du fond de carte
|
||||||
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
|
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
|
||||||
};
|
};
|
||||||
const customs = loadCustomPresets();
|
upsertCustomPreset(p);
|
||||||
customs.push(p);
|
persistCustomPresets();
|
||||||
saveCustomPresets(customs);
|
|
||||||
renderPresetSelect('custom:' + p.id);
|
renderPresetSelect('custom:' + p.id);
|
||||||
|
// Persistance serveur (partagée entre navigateurs) ; en échec
|
||||||
|
// (pas de webapp), le preset reste dans le stockage local.
|
||||||
|
fetch(PRESETS_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(p),
|
||||||
|
}).then(r => {
|
||||||
|
if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status);
|
||||||
|
}).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
presetDeleteEl.addEventListener('click', () => {
|
presetDeleteEl.addEventListener('click', () => {
|
||||||
const v = presetSelectEl.value;
|
const v = presetSelectEl.value;
|
||||||
if (!v.startsWith('custom:')) return;
|
if (!v.startsWith('custom:')) return;
|
||||||
if (!confirm('Supprimer ce preset ?')) return;
|
if (!confirm('Supprimer ce preset ?')) return;
|
||||||
saveCustomPresets(loadCustomPresets().filter(p => p.id !== v.slice(7)));
|
const id = v.slice(7);
|
||||||
|
removeCustomPreset(id);
|
||||||
|
persistCustomPresets();
|
||||||
renderPresetSelect('');
|
renderPresetSelect('');
|
||||||
|
fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
renderPresetSelect();
|
renderPresetSelect();
|
||||||
|
syncCustomPresets();
|
||||||
// Barre latérale : repli + onglets (Couches / Génération)
|
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
function setSidebarCollapsed(collapsed) {
|
function setSidebarCollapsed(collapsed) {
|
||||||
sidebar.classList.toggle('collapsed', collapsed);
|
sidebar.classList.toggle('collapsed', collapsed);
|
||||||
|
|||||||
@ -111,6 +111,45 @@ def test_preview_rejects_unknown_viz():
|
|||||||
assert "inconnu" in e.detail
|
assert "inconnu" in e.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_sanitize_and_upsert(tmp_path, monkeypatch):
|
||||||
|
"""Presets de couches : valeurs assainies, remplacées par identifiant."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json")
|
||||||
|
req = webapp.PresetRequest(id="Relief v2!", label=" Reliefs ",
|
||||||
|
on=["slope", "aspect"],
|
||||||
|
opacity={"slope": 0.5, "aspect": "0.8", "lrm": "x"},
|
||||||
|
osm={"on": True, "opacity": 1.5, "dark": True})
|
||||||
|
p = webapp._sanitize_preset(req)
|
||||||
|
assert p["id"] == "reliefv2"
|
||||||
|
assert p["label"] == "Reliefs"
|
||||||
|
assert p["opacity"] == {"slope": 0.5, "aspect": 0.8}
|
||||||
|
assert p["osm"] == {"on": True, "opacity": 1.0, "dark": True}
|
||||||
|
webapp.upsert_preset(req)
|
||||||
|
assert [x["label"] for x in webapp.list_presets()["presets"]] == ["Reliefs"]
|
||||||
|
# Même identifiant : remplacement, pas de doublon
|
||||||
|
webapp.upsert_preset(webapp.PresetRequest(id="reliefv2", label="Reliefs v2",
|
||||||
|
on=["slope"], opacity={}))
|
||||||
|
got = webapp.list_presets()["presets"]
|
||||||
|
assert len(got) == 1 and got[0]["label"] == "Reliefs v2"
|
||||||
|
# Suppression inconnue : 404
|
||||||
|
from fastapi import HTTPException
|
||||||
|
webapp.delete_preset(got[0]["id"])
|
||||||
|
assert webapp.list_presets()["presets"] == []
|
||||||
|
try:
|
||||||
|
webapp.delete_preset("reliefv2")
|
||||||
|
assert False, "une HTTPException était attendue"
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_file_corrupted_is_ignored(tmp_path, monkeypatch):
|
||||||
|
"""Un fichier de presets corrompu est ignoré (liste vide, pas d'exception)."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json")
|
||||||
|
webapp.PRESETS_FILE.write_text("{pas du json", encoding="utf-8")
|
||||||
|
assert webapp._load_presets() == []
|
||||||
|
|
||||||
|
|
||||||
def test_build_command_resolutions_match_completeness():
|
def test_build_command_resolutions_match_completeness():
|
||||||
"""La commande -r et la détection de complétude utilisent les mêmes résolutions."""
|
"""La commande -r et la détection de complétude utilisent les mêmes résolutions."""
|
||||||
from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command
|
from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command
|
||||||
|
|||||||
@ -37,6 +37,12 @@ le téléchargement IGN). Endpoints :
|
|||||||
fichier en téléchargement. Marche aussi sur la webapp
|
fichier en téléchargement. Marche aussi sur la webapp
|
||||||
légère : les tuiles viennent du cache local, aucune
|
légère : les tuiles viennent du cache local, aucune
|
||||||
délégation à la machine de traitement.
|
délégation à la machine de traitement.
|
||||||
|
GET /api/presets → presets de couches personnalisés (couches visibles,
|
||||||
|
opacités, ordre, fond OSM) stockés sur le serveur et
|
||||||
|
partagés par tous les navigateurs connectés
|
||||||
|
POST /api/presets → créer/remplacer un preset de couches (même identifiant
|
||||||
|
= remplacement)
|
||||||
|
DELETE /api/presets/{id} → supprimer un preset de couches
|
||||||
|
|
||||||
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
|
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
|
||||||
/visualisations, /DTM.
|
/visualisations, /DTM.
|
||||||
@ -128,6 +134,17 @@ else:
|
|||||||
logger.info("Backend de génération des tuiles : local — consultation "
|
logger.info("Backend de génération des tuiles : local — consultation "
|
||||||
"autonome du cache, sans LIDAR_GENERATION_URL")
|
"autonome du cache, sans LIDAR_GENERATION_URL")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _log_client_ip(request: Request, call_next):
|
||||||
|
xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||||
|
peer = request.client.host if request.client else None
|
||||||
|
ip = xff or peer
|
||||||
|
path = request.url.path
|
||||||
|
# Ne logguer que les requêtes API (pas les fichiers statiques)
|
||||||
|
if path.startswith("/api/") or path == "/":
|
||||||
|
logger.info(f"{request.method} {path} — client : {ip or '?'}")
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
# assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur
|
# assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur
|
||||||
# statique soit actif même avant la première génération de l'index.
|
# statique soit actif même avant la première génération de l'index.
|
||||||
_assets_dir = OUTPUT_DIR / "assets"
|
_assets_dir = OUTPUT_DIR / "assets"
|
||||||
@ -524,6 +541,99 @@ def _load_job_state():
|
|||||||
_load_job_state()
|
_load_job_state()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Presets de couches (partagés entre navigateurs) --------------------------
|
||||||
|
# Stockés dans OUTPUT_DIR/.presets.json : un preset est un jeu nommé de
|
||||||
|
# couches visibles + opacités + ordre de pile + fond OSM, créé depuis
|
||||||
|
# l'interface (bouton 💾) et partagé par tous les navigateurs connectés à
|
||||||
|
# cette webapp (y compris le Pi en mode deux machines). Le localStorage du
|
||||||
|
# navigateur reste le repli quand l'interface est servie sans webapp.
|
||||||
|
PRESETS_FILE = OUTPUT_DIR / ".presets.json"
|
||||||
|
_presets_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_presets():
|
||||||
|
"""Lis la liste des presets depuis disque (vide si absent/corrompu)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(PRESETS_FILE.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_presets(presets):
|
||||||
|
"""Écrit la liste des presets atomiquement (fichier temporaire + replace)."""
|
||||||
|
tmp = PRESETS_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(presets, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(tmp, PRESETS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
class PresetRequest(BaseModel):
|
||||||
|
id: str = Field(..., description="identifiant du preset (attribué par l'interface)")
|
||||||
|
label: str = Field(..., description="nom affiché dans l'interface")
|
||||||
|
on: list = Field(default_factory=list, description="couches visibles (clés, ordre de pile)")
|
||||||
|
opacity: dict = Field(default_factory=dict, description="opacité de chaque couche, 0–1")
|
||||||
|
osm: dict = Field(default_factory=dict, description="fond de carte OSM : on, opacity, dark")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_preset(req: PresetRequest) -> dict:
|
||||||
|
"""Preset à stocker : label assaini, opacités bornées entre 0 et 1."""
|
||||||
|
on = [str(k) for k in (req.on or []) if str(k).strip()]
|
||||||
|
opacity = {}
|
||||||
|
for k, v in (req.opacity or {}).items():
|
||||||
|
try:
|
||||||
|
opacity[str(k)] = min(1.0, max(0.0, float(v)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
osm_in = req.osm if isinstance(req.osm, dict) else {}
|
||||||
|
osm = {"on": bool(osm_in.get("on", False))}
|
||||||
|
try:
|
||||||
|
osm["opacity"] = min(1.0, max(0.0, float(osm_in["opacity"])))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if "dark" in osm_in:
|
||||||
|
osm["dark"] = bool(osm_in["dark"])
|
||||||
|
pid = re.sub(r"[^a-z0-9_-]", "", str(req.id).lower())
|
||||||
|
return {
|
||||||
|
"id": pid or "p" + str(int(time.time() * 1000)),
|
||||||
|
"label": (str(req.label).strip() or "Preset")[:80],
|
||||||
|
"on": on,
|
||||||
|
"opacity": opacity,
|
||||||
|
"osm": osm,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/presets")
|
||||||
|
def list_presets():
|
||||||
|
"""Presets de couches personnalisés (partagés entre tous les navigateurs)."""
|
||||||
|
with _presets_lock:
|
||||||
|
return {"presets": _load_presets()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/presets", dependencies=[Depends(_require_token)])
|
||||||
|
def upsert_preset(req: PresetRequest):
|
||||||
|
"""Crée ou remplace un preset de couches (même identifiant = remplacement)."""
|
||||||
|
p = _sanitize_preset(req)
|
||||||
|
with _presets_lock:
|
||||||
|
presets = [x for x in _load_presets() if x.get("id") != p["id"]]
|
||||||
|
presets.append(p)
|
||||||
|
_save_presets(presets)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/presets/{preset_id}", dependencies=[Depends(_require_token)])
|
||||||
|
def delete_preset(preset_id: str):
|
||||||
|
"""Supprime un preset de couches (404 s'il est inconnu)."""
|
||||||
|
with _presets_lock:
|
||||||
|
presets = _load_presets()
|
||||||
|
kept = [x for x in presets if x.get("id") != preset_id]
|
||||||
|
if len(kept) == len(presets):
|
||||||
|
raise HTTPException(404, "preset inconnu")
|
||||||
|
_save_presets(kept)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def bbox_to_cells(w, s, e, n):
|
def bbox_to_cells(w, s, e, n):
|
||||||
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.
|
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user