feat(templates): refonte complète des templates façon Notion + vues/agents-skills
Templates (v7.71.x) : - registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler) - sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document - 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée - récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates - correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title - tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8) Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""FlowDeck — Geocoding Service (vue Map §13.4).
|
||||
|
||||
Interface de fournisseur unique : search / resolve / reverse.
|
||||
Implementations : ``none`` (defaut — saisie manuelle uniquement, aucun
|
||||
appel externe), ``nominatim`` (OpenStreetMap, User-Agent identifie,
|
||||
<= 1 req/s, cache obligatoire), ``custom`` (endpoint compatible Nominatim).
|
||||
|
||||
Le cache SQLite (migration 45, table ``geocode_cache``) est consulte
|
||||
avant tout appel. Les journaux ne contiennent jamais d'adresses en clair
|
||||
au-dela de compteurs (le texte reste dans le cache, purgeable).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_last_call_ts = 0.0
|
||||
|
||||
|
||||
def _norm(query: str) -> str:
|
||||
return " ".join((query or "").lower().split())
|
||||
|
||||
|
||||
def query_hash(query: str, provider: str) -> str:
|
||||
return hashlib.sha256(f"{provider}:{_norm(query)}".encode()).hexdigest()
|
||||
|
||||
|
||||
def get_settings(conn=None) -> dict:
|
||||
"""Lit la config geocodeur (instance). Defauts surs : provider=none."""
|
||||
try:
|
||||
from app.config import settings as _s # type: ignore
|
||||
provider = getattr(_s, "GEOCODER_PROVIDER", None) or "none"
|
||||
endpoint = getattr(_s, "GEOCODER_ENDPOINT", None) or "https://nominatim.openstreetmap.org"
|
||||
return {"provider": provider, "endpoint": endpoint.rstrip("/")}
|
||||
except Exception:
|
||||
return {"provider": "none", "endpoint": "https://nominatim.openstreetmap.org"}
|
||||
|
||||
|
||||
def cache_get(conn, qhash: str) -> dict | None:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT result_json, lat, lng, hit_count FROM geocode_cache WHERE query_hash=?",
|
||||
(qhash,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
conn.execute("UPDATE geocode_cache SET hit_count = hit_count + 1 WHERE query_hash=?", (qhash,))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data = json.loads(row["result_json"]) if row["result_json"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data = {}
|
||||
data.setdefault("lat", row["lat"])
|
||||
data.setdefault("lng", row["lng"])
|
||||
return data
|
||||
|
||||
|
||||
def cache_put(conn, qhash: str, provider: str, query_text: str, result: dict) -> None:
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO geocode_cache
|
||||
(query_hash, provider, query_text, result_json, lat, lng, created_at, hit_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), COALESCE(
|
||||
(SELECT hit_count FROM geocode_cache WHERE query_hash=?), 0))""",
|
||||
(qhash, provider, query_text[:300], json.dumps(result),
|
||||
result.get("lat"), result.get("lng"), qhash),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _http_json(url: str, params: dict, timeout: float = 3.0) -> Any:
|
||||
global _last_call_ts
|
||||
# Politesse Nominatim : <= 1 req/s.
|
||||
wait = 1.0 - (time.time() - _last_call_ts)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
full = url + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(full, headers={
|
||||
"User-Agent": "FlowDeck/1.0 (self-hosted; contact: admin)",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
_last_call_ts = time.time()
|
||||
return json.loads(resp.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def search(conn, query: str, limit: int = 5) -> list[dict]:
|
||||
"""Autocompletion d'adresses. Vide quand provider=none."""
|
||||
cfg = get_settings(conn)
|
||||
provider = cfg["provider"]
|
||||
if not query or len(query.strip()) < 3 or provider == "none":
|
||||
return []
|
||||
qhash = query_hash(f"search:{query}:{limit}", provider)
|
||||
cached = cache_get(conn, qhash)
|
||||
if cached and isinstance(cached.get("suggestions"), list):
|
||||
return cached["suggestions"][:limit]
|
||||
base = cfg["endpoint"]
|
||||
try:
|
||||
if provider in ("nominatim", "custom"):
|
||||
data = _http_json(f"{base}/search", {
|
||||
"q": query, "format": "jsonv2", "limit": limit,
|
||||
"addressdetails": 1,
|
||||
})
|
||||
suggestions = [{
|
||||
"name": (d.get("display_name") or "").split(",")[0].strip(),
|
||||
"address": d.get("display_name", ""),
|
||||
"lat": float(d["lat"]), "lng": float(d["lon"]),
|
||||
"provider": provider, "place_id": str(d.get("place_id", "")),
|
||||
} for d in (data or []) if d.get("lat") and d.get("lon")]
|
||||
else:
|
||||
suggestions = []
|
||||
except Exception as exc:
|
||||
logger.warning("geocode search failed: %s", type(exc).__name__)
|
||||
return []
|
||||
try:
|
||||
cache_put(conn, qhash, provider, query, {"suggestions": suggestions})
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return suggestions
|
||||
|
||||
|
||||
def resolve(conn, query: str) -> dict | None:
|
||||
"""Adresse -> {lat, lng, ...}. Cache d'abord, None si echec."""
|
||||
cfg = get_settings(conn)
|
||||
provider = cfg["provider"]
|
||||
if not query or provider == "none":
|
||||
return None
|
||||
qhash = query_hash(f"resolve:{query}", provider)
|
||||
cached = cache_get(conn, qhash)
|
||||
if cached and cached.get("lat") is not None:
|
||||
return cached
|
||||
results = search(conn, query, limit=1)
|
||||
if not results:
|
||||
return None
|
||||
first = dict(results[0])
|
||||
try:
|
||||
cache_put(conn, qhash, provider, query, first)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return first
|
||||
Reference in New Issue
Block a user