304 lines
No EOL
11 KiB
Python
304 lines
No EOL
11 KiB
Python
# locations_registry.py
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.exceptions import FieldDoesNotExist
|
|
from django.db.models import Q, Case, When, IntegerField, Value, CharField
|
|
from django.db.models.functions import Lower, Coalesce, Concat
|
|
from django.utils.translation import get_language, gettext as _
|
|
from django.views.decorators.http import require_GET
|
|
from django.http import JsonResponse
|
|
|
|
from common.utils import strip_accents_lower, Unaccent
|
|
|
|
from typing import Callable, Optional
|
|
|
|
from assets.models import StructureLocation
|
|
from assets.models import TrafficLightIntersection
|
|
from assets.models import PublicLightingStreet
|
|
from assets.models import NatureLocation, NatureRWIZ
|
|
from assets.models import ControlCenterLocation
|
|
from assets.models import SignStreet
|
|
from assets.models import RoadStreet
|
|
from assets.models import CleanLocation
|
|
|
|
|
|
LOCATION_REGISTRY = {
|
|
"structures_location": {"thematic": "structures", "model": StructureLocation, "label": _("Ouvrages"), "parent": {"attr": "structure"},},
|
|
"trafficlights_intersection": {"thematic": "trafficlights", "model": TrafficLightIntersection,"label": _("Feux")},
|
|
"publiclighting_street":{"thematic":"publiclighting", "model": PublicLightingStreet, "label": _("Éclairage")},
|
|
"green_section": {"thematic": "nature", "model": NatureLocation, "label": _("Nature")},
|
|
"naturerwiz": {"thematic": "water", "model": NatureRWIZ, "label": _("GIEP")},
|
|
"controlcenters_location": {"thematic": "controlcenters", "model": ControlCenterLocation, "label": _("Centres de contrôle"), "parent": {"attr": "control_center"},},
|
|
"sign_street":{"thematic":"sign", "model": SignStreet, "label": _("Signalisation")},
|
|
"roads_street":{"thematic":"roads", "model": RoadStreet, "label": _("Voirie")},
|
|
"clean_location":{"thematic":"clean", "model": CleanLocation, "label": _("Propreté")},
|
|
}
|
|
|
|
ASSETS_LOCATION_REGISTRY = ['location', 'intersection', 'street']
|
|
|
|
|
|
def registry_items_for_thematics(thematics: list[str] | None):
|
|
items = LOCATION_REGISTRY.items()
|
|
if thematics:
|
|
items = [(k,v) for (k,v) in items if v["thematic"] in thematics]
|
|
return items
|
|
|
|
def content_type_for_model(Model):
|
|
return ContentType.objects.get_for_model(Model)
|
|
|
|
|
|
|
|
SEARCH_LIMIT_PER_TYPE = 25
|
|
SEARCH_LIMIT_TOTAL = 50
|
|
|
|
|
|
def base_search_qs(Model, qn: str, is_nl: bool):
|
|
qs = (
|
|
Model.objects
|
|
.annotate(
|
|
code_u=Lower(Unaccent('code')),
|
|
name_fr_u=Lower(Unaccent('name_fr')),
|
|
name_nl_u=Lower(Unaccent('name_nl')),
|
|
)
|
|
.filter(
|
|
Q(code_u__contains=qn) |
|
|
Q(name_fr_u__contains=qn) |
|
|
Q(name_nl_u__contains=qn)
|
|
)
|
|
)
|
|
|
|
prio = Case(
|
|
# code exact / startswith d'abord
|
|
When(code_u=qn, then=0),
|
|
When(code_u__startswith=qn, then=1),
|
|
# puis startswith/contains dans la langue active avant l'autre
|
|
*((
|
|
When(name_nl_u__startswith=qn, then=2),
|
|
When(name_fr_u__startswith=qn, then=3),
|
|
When(code_u__contains=qn, then=4),
|
|
When(name_nl_u__contains=qn, then=5),
|
|
When(name_fr_u__contains=qn, then=6),
|
|
) if is_nl else (
|
|
When(name_fr_u__startswith=qn, then=2),
|
|
When(name_nl_u__startswith=qn, then=3),
|
|
When(code_u__contains=qn, then=4),
|
|
When(name_fr_u__contains=qn, then=5),
|
|
When(name_nl_u__contains=qn, then=6),
|
|
)),
|
|
default=7,
|
|
output_field=IntegerField(),
|
|
)
|
|
return qs.annotate(_prio=prio)
|
|
|
|
|
|
def search_locations(
|
|
raw_query: str,
|
|
thematics: list[str] | None = None,
|
|
label_builder: Optional[Callable[[str | None, str | None, dict], str]] = None,
|
|
prioritize: str | None = None,
|
|
):
|
|
"""
|
|
Retourne une liste d'items prêts pour l'UI:
|
|
{
|
|
"type": "...", "type_label": "...",
|
|
"ct": <content_type_id>, "obj": <pk>,
|
|
"name": <name_lang>, "code": <code>,
|
|
"label": "<name_lang> (<code> - )",
|
|
"lon": ..., "lat": ...
|
|
}
|
|
"""
|
|
qn = strip_accents_lower((raw_query or "").strip())
|
|
if len(qn) < 2:
|
|
return []
|
|
|
|
lang = (get_language() or "fr").split("-")[0]
|
|
is_nl = (lang == "nl")
|
|
|
|
# Label par défaut: "name_lang (code)" — surchargable via label_builder
|
|
def _default_label_builder(code: str | None, name_lang: str | None, ctx: dict) -> str:
|
|
name_lang = (name_lang or "").strip()
|
|
code = (code or "").strip()
|
|
return f"{name_lang} ({code})" if name_lang and code else (name_lang or code)
|
|
|
|
build_label = label_builder or _default_label_builder
|
|
|
|
items = registry_items_for_thematics(thematics)
|
|
if not items:
|
|
return []
|
|
|
|
ancestor_coords_cache: dict[tuple[str, int], tuple[float | None, float | None]] = {}
|
|
|
|
def _coords_from_ancestors(Model, location_id: int | None) -> tuple[float | None, float | None]:
|
|
"""Retourne (lon, lat) d'un parent (récursif) en cachant les lectures DB."""
|
|
if not location_id:
|
|
return (None, None)
|
|
|
|
cache_key = (Model._meta.label_lower, location_id)
|
|
if cache_key in ancestor_coords_cache:
|
|
return ancestor_coords_cache[cache_key]
|
|
|
|
data = (
|
|
Model.objects
|
|
.filter(pk=location_id)
|
|
.values("lon", "lat", "parent_location_id")
|
|
.first()
|
|
)
|
|
if not data:
|
|
ancestor_coords_cache[cache_key] = (None, None)
|
|
return (None, None)
|
|
|
|
lon, lat = data["lon"], data["lat"]
|
|
parent_id = data.get("parent_location_id")
|
|
if (lon is None or lat is None) and parent_id:
|
|
parent_lon, parent_lat = _coords_from_ancestors(Model, parent_id)
|
|
if lon is None:
|
|
lon = parent_lon
|
|
if lat is None:
|
|
lat = parent_lat
|
|
|
|
ancestor_coords_cache[cache_key] = (lon, lat)
|
|
return lon, lat
|
|
|
|
out = []
|
|
for type_key, meta in items:
|
|
Model = meta["model"]
|
|
ct = content_type_for_model(Model)
|
|
|
|
|
|
# --- champs dynamiques à récupérer ---
|
|
fields = ["id", "code", "name_fr", "name_nl", "lon", "lat"]
|
|
parent_attr = (meta.get("parent") or {}).get("attr")
|
|
if parent_attr:
|
|
fields += [
|
|
f"{parent_attr}_id",
|
|
f"{parent_attr}__name_fr",
|
|
f"{parent_attr}__name_nl",
|
|
f"{parent_attr}__code",
|
|
]
|
|
|
|
has_parent_location = False
|
|
try:
|
|
Model._meta.get_field("parent_location")
|
|
has_parent_location = True
|
|
fields.append("parent_location_id")
|
|
except FieldDoesNotExist:
|
|
has_parent_location = False
|
|
|
|
rows = (
|
|
base_search_qs(Model, qn, is_nl)
|
|
.order_by("_prio", "code")
|
|
.values(*fields)[:SEARCH_LIMIT_PER_TYPE]
|
|
)
|
|
for r in rows:
|
|
name_lang = r["name_nl"] if is_nl else r["name_fr"]
|
|
if not name_lang:
|
|
name_lang = r["name_fr"] or r["name_nl"] or ""
|
|
|
|
# --- parent de référence (optionnel) ---
|
|
parent_ref_id = None
|
|
parent_ref_label = None
|
|
if parent_attr:
|
|
pid = r.get(f"{parent_attr}_id")
|
|
if pid:
|
|
# nom du parent dans la langue active (+ fallback)
|
|
pname = r.get(f"{parent_attr}__name_nl") if is_nl else r.get(f"{parent_attr}__name_fr")
|
|
if not pname:
|
|
pname = r.get(f"{parent_attr}__name_fr") or r.get(f"{parent_attr}__name_nl") or ""
|
|
pcode = r.get(f"{parent_attr}__code")
|
|
parent_ref_id = pid
|
|
parent_ref_label = f"{pname} ({pcode})" if pcode else pname
|
|
|
|
lon = r["lon"]
|
|
lat = r["lat"]
|
|
if (lon is None or lat is None) and has_parent_location:
|
|
ancestor_lon, ancestor_lat = _coords_from_ancestors(Model, r.get("parent_location_id"))
|
|
if lon is None:
|
|
lon = ancestor_lon
|
|
if lat is None:
|
|
lat = ancestor_lat
|
|
|
|
ctx = {
|
|
"type": type_key,
|
|
"type_label": meta["label"],
|
|
"ct": ct.id,
|
|
"obj": r["id"],
|
|
"lon": lon,
|
|
"lat": lat,
|
|
}
|
|
|
|
out.append({
|
|
"type": type_key,
|
|
"type_label": meta["label"],
|
|
"ct": ct.id,
|
|
"obj": r["id"],
|
|
"name": name_lang,
|
|
"code": r["code"],
|
|
"label": build_label(r["code"], name_lang, ctx),
|
|
"lon": lon,
|
|
"lat": lat,
|
|
"thematic": meta.get("thematic"),
|
|
"parent_ref_id": parent_ref_id,
|
|
"parent_ref_label": parent_ref_label,
|
|
})
|
|
|
|
# Tri global léger par label (les per-type sont déjà ordonnés par priorité)
|
|
prioritize_type = None
|
|
if prioritize:
|
|
p_lower = prioritize.lower().strip()
|
|
if p_lower in LOCATION_REGISTRY:
|
|
prioritize_type = p_lower
|
|
elif "rwi" in p_lower or "rwiz" in p_lower or "giep" in p_lower:
|
|
prioritize_type = "naturerwiz"
|
|
else:
|
|
for key, meta in LOCATION_REGISTRY.items():
|
|
if p_lower in key.lower() or p_lower in meta["model"].__name__.lower():
|
|
prioritize_type = key
|
|
break
|
|
|
|
if prioritize_type:
|
|
out.sort(key=lambda r: (0 if r["type"] == prioritize_type else 1, r["label"] or ""))
|
|
else:
|
|
out.sort(key=lambda r: (r["label"] or ""))
|
|
return out[:SEARCH_LIMIT_TOTAL]
|
|
|
|
|
|
|
|
|
|
def _parse_thematics_param(request):
|
|
thematics = request.GET.getlist("thematic")
|
|
parsed_thematics = []
|
|
for t in thematics:
|
|
for item in t.split(","):
|
|
item_stripped = item.strip()
|
|
if item_stripped:
|
|
parsed_thematics.append(item_stripped)
|
|
return parsed_thematics or None
|
|
|
|
|
|
@require_GET
|
|
def locations_autocomplete(request):
|
|
q = (request.GET.get("q") or "").strip()
|
|
thematics = _parse_thematics_param(request)
|
|
prioritize = request.GET.get("prioritize")
|
|
|
|
def lbl(code, name, ctx):
|
|
base = f"{name} ({code})" if name and code else (name or code or "")
|
|
# return f"{base} — {ctx['type_label']}" if base else ctx["type_label"]
|
|
return base
|
|
|
|
results = search_locations(q, thematics=thematics, label_builder=lbl, prioritize=prioritize)
|
|
|
|
payload = [{
|
|
"label": d["label"], # ex. "Schuman (INT-001) — Feux"
|
|
"value": {"ct": d["ct"], "obj": d["obj"]}, # jQuery UI attend {label, value}
|
|
"type": d["type"],
|
|
"type_label": d["type_label"],
|
|
"lon": d["lon"], "lat": d["lat"],
|
|
"name": d.get("name"), "code": d.get("code"),
|
|
# (facultatif) utile pour purger côté UI quand les thématiques changent :
|
|
"thematic": d.get("thematic"),
|
|
# ↓ parent de référence envoyé au front
|
|
"parent_ref_id": d.get("parent_ref_id"),
|
|
"parent_ref_label": d.get("parent_ref_label"),
|
|
} for d in results]
|
|
|
|
return JsonResponse(payload, safe=False) |