loko/streetup/assets/views/its.py
2026-07-22 14:48:40 +02:00

863 lines
No EOL
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ---------------------
# ITS Views
# ---------------------
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, Http404
from django.urls import reverse
from django.views.decorators.http import require_GET, require_POST
from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import GEOSGeometry, Point
from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, Case, When, IntegerField, CharField
from django.db.models.functions import Lower
from django.db.models import Func, Prefetch
from django.db import connection
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext as _
import unicodedata
import json
from collections import defaultdict
from common.utils import check_thematic_access
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics
from assets.models import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory
from assets.permissions import can_edit_location_for_thematic, get_allowed_actions_for_thematic
from assets.forms import ITSLocationDocumentForm
from interventions.models import InterventionLocation
from interventions.views.drafts import InterventionDraftManager
@check_thematic_access('its')
def its_index(request):
thematic = 'its'
lang = get_language()
# Get filters from URL
statuses = request.GET.getlist('status')
selected_is_main_location = request.GET.get('is_main_location')
selected_categories = request.GET.getlist('category')
selected_models = request.GET.getlist('model')
# Get preventive draft ID (if any) for selecting assets
preventive_draft_id = request.GET.get('preventive_draft_id')
if preventive_draft_id:
drafts = InterventionDraftManager(request.session)
draft_data = drafts.get(preventive_draft_id)
form_data = draft_data.get('form_data', {})
preventive_element_type = form_data.get('type_of_selected_elements')
if preventive_element_type == 'assets':
selected_assets = draft_data.get('assets', []) or []
else:
selected_locations_ids = draft_data.get('locations', [])
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=request.user.config, can_view_assets=True)
.select_related('thematic')
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
)
thematics = [tc.thematic for tc in thematic_choices_qs]
selected_thematic = {
"code": thematic,
"name": next((t.name for t in thematics if t.code == thematic), None),
"icon": next((t.icon for t in thematics if t.code == thematic), None),
}
categories = list(AssetCategory.objects.filter(thematic__code = 'its'))
categories.sort(key=lambda m: m.get_name().lower())
category_choices = [(m.id, m.get_name()) for m in categories]
models = list(ITSAssetModel.objects.all())
models.sort(key=lambda m: (m.get_name() or "").lower())
model_choices = [(m.id, m.get_name()) for m in models]
category_model_map = defaultdict(list)
models = ITSAssetModel.objects.all()
for model in models:
if model.category:
display_name = model.get_name()
category_model_map[str(model.category_id)].append({
"key": str(model.id),
"label": display_name,
})
context = {
'thematics': thematics,
'category_choices': category_choices,
'model_choices': model_choices,
'category_model_map': dict(category_model_map),
'status_choices': ITSGeoAsset._meta.get_field('status').choices,
'selected_thematic': selected_thematic,
'selected_is_main_location': selected_is_main_location,
'selected_categories': selected_categories,
'selected_models': selected_models,
'selected_statuses': statuses,
'preventive_draft_id': preventive_draft_id,
'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '',
'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [],
"selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [],
'locations_tab_title': _("Localisations"),
'locations_assets_title': "Assets",
'can_create_location': can_edit_location_for_thematic(request.user, 'its'),
'location_type': 'itslocation',
'thematic_code': 'its',
'can_create_asset': 'create' in get_allowed_actions_for_thematic(
request.user, Thematic.objects.filter(code='its').first()
),
'its_categories': categories,
}
return render(request, "assets/its_index.html", context)
def its_locations(request):
pass
def its_assets(request):
pass
def ensure_list(value):
if isinstance(value, list):
return value
if value is None:
return []
return [value]
@check_thematic_access('its')
def its_locations_geojson(request):
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
is_main_location = data.get('is_main_location')
filter_geom_raw = data.get("filter_geom")
if filter_geom_raw:
try:
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
filter_geom.transform(3812)
except Exception as e:
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
else:
filter_geom = None
else:
is_main_location = request.GET.get('is_main_location')
filter_geom = None
# --------- Point d'entrée du queryset
its_locations = ITSLocation.objects.all()
# --------- Filtre par localisation principale
if is_main_location:
val = is_main_location.lower()
if val == 'true':
its_locations = its_locations.filter(is_main_location=True)
elif val == 'false':
its_locations = its_locations.filter(is_main_location=False)
# --------- Filtre géométrique
if filter_geom:
its_locations = its_locations.filter(geom__intersects=filter_geom)
# --------- Limitation pour utilisateurs externes
user = request.user
if user.is_authenticated:
try:
user_config = user.config # OneToOne UserConfig
except UserConfig.DoesNotExist:
user_config = None
# externe = pas interne ; on tient aussi compte du flag limit_assets_to_contracts
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
if must_limit:
# contrats accessibles à l'utilisateur (idéalement avec droit "assets")
accessible_contract_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_view_assets=True # champ prévu dans la table d'accès contrat
).values_list('contract_id', flat=True)
# Ne garder que les locations dont le centre de contrôle a un contrat actif lié à ces contrats
its_locations = its_locations.filter(
its_contracts__status='active', # ITSContract.status
its_contracts__contract_id__in=accessible_contract_ids # FK contract
).distinct()
else:
# Non authentifié : ne rien exposer (option sûre)
its_locations = its_locations.none()
# Recherche plein texte
if request.method == 'POST':
from assets.views.core import apply_search
its_locations = apply_search(its_locations, data)
# --------- Conversion en GeoJSON
def obj_to_feature(obj):
geometry = json.loads(obj.geojson) if obj.geojson else None # AbstractLocation.geojson # :contentReference[oaicite:16]{index=16}
return {
"type": "Feature",
"id": obj.id,
"geometry": geometry,
"properties": {
"code": obj.code,
"name": obj.get_name(),
"lon": obj.lon,
"lat": obj.lat,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": "Zone ITS",
"thematic": "its",
"is_location": True,
}
}
features = []
for location in its_locations.order_by('code'):
features.append(obj_to_feature(location))
geojson_data = {
"type": "FeatureCollection",
"features": features,
}
return JsonResponse(geojson_data, safe=False)
MAX_ASSET_FEATURES = 5000 # limite imposée
@check_thematic_access('its')
def its_assets_geojson(request):
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
its_location_ids = data.get('locations_ids', [])
statuses = data.get('asset_status', [])
begin_date = data.get('asset_begin_date')
categories = ensure_list(data.get('category'))
models = ensure_list(data.get('model'))
# Récupération et parsing de la géométrie
filter_geom_raw = data.get("filter_geom")
if filter_geom_raw:
try:
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
filter_geom.transform(3812)
except Exception as e:
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
else:
filter_geom = None
else:
its_location_id = request.GET.get('location')
its_location_ids = [its_location_id] if its_location_id else []
statuses = request.GET.getlist('asset_status')
begin_date = request.GET.get('asset_begin_date')
categories = request.GET.getlist('category', [])
models = request.GET.getlist('model', [])
filter_geom = None
# Pas de paramètres = pas de carte (comportement existant conservé)
_search_q = (data.get('q') or '').strip() if request.method == 'POST' else ''
if not request.GET.get('unified') and not its_location_ids and not categories and not models and not filter_geom and not _search_q:
geojson_data = {"type": "FeatureCollection", "features": []}
return JsonResponse(geojson_data)
from assets.views.core import get_element_type_label
def build_geometry_and_coords(obj):
geometry = None
if obj.geojson:
try:
geometry = json.loads(obj.geojson)
except (TypeError, ValueError, json.JSONDecodeError):
geometry = None
lon = obj.lon
lat = obj.lat
if lon is None or lat is None:
location = getattr(obj, 'location', None)
if location is not None:
if lon is None:
lon = getattr(location, 'lon', None)
if lat is None:
lat = getattr(location, 'lat', None)
if geometry is None and lon is not None and lat is not None:
geometry = {
"type": "Point",
"coordinates": [lon, lat],
}
return geometry, lon, lat
def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj)
return {
"type": "Feature",
"id": obj.id,
"geometry": geometry,
"properties": {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
},
}
def add_assets_to_features(queryset, asset_type, features, feature_count):
for obj in queryset:
if feature_count >= MAX_ASSET_FEATURES:
return feature_count, True # early stop
features.append(obj_to_feature(obj, asset_type=asset_type))
feature_count += 1
return feature_count, False
# --------- Query de base + optimisations
its_asset_qs = (
ITSGeoAsset.objects
.select_related('location', 'category', 'model')
.all()
)
# --------- Filtres métier existants
if its_location_ids:
its_asset_qs = its_asset_qs.filter(location__id__in=its_location_ids)
if models:
its_asset_qs = its_asset_qs.filter(model__id__in=models)
if categories:
its_asset_qs = its_asset_qs.filter(category__id__in=categories)
if filter_geom:
its_asset_qs = its_asset_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True)
)
# Recherche plein texte
if request.method == 'POST':
from assets.views.core import apply_search
its_asset_qs = apply_search(its_asset_qs, data)
# --------- Limitation pour utilisateurs externes
user = request.user
if user.is_authenticated:
try:
user_config = user.config # OneToOne
except UserConfig.DoesNotExist:
user_config = None
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
if must_limit:
accessible_contract_ids = UserContractAccess.objects.filter(
user_config=user_config,
can_view_assets=True
).values_list('contract_id', flat=True)
# Ne garder que les assets qui ont un contrat actif
# auquel l'utilisateur a accès
its_asset_qs = its_asset_qs.filter(
its_assets_contracts__status='active',
its_assets_contracts__contract_id__in=accessible_contract_ids
).distinct()
else:
# Non authentifié : ne rien exposer (plus sûr)
its_asset_qs = its_asset_qs.none()
# --------- Cap de sécurité
its_asset_qs = its_asset_qs[0:MAX_ASSET_FEATURES+1]
if len(its_asset_qs) >= MAX_ASSET_FEATURES:
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
# --------- Construction GeoJSON
features = []
feature_count = 0
for qs, asset_type in [
(its_asset_qs, "itsgeoasset"),
]:
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
if limit_reached:
return JsonResponse({
"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES}). {_("Limitez votre sélection.")}"
}, status=413)
features = sorted(features, key=lambda f: f["properties"]["code"])
geojson_data = {
"type": "FeatureCollection",
"features": features,
}
return JsonResponse(geojson_data, safe=False)
@check_thematic_access('its')
def its_locations_detail(request, location_id):
# Préfetch (réduction N+1) : ASSETS + enfants (2 niveaux)
location = (
ITSLocation.objects
.prefetch_related(
Prefetch(
'assets',
queryset=ITSGeoAsset.objects.filter(status='active').order_by('code')
),
'child_locations__assets',
'child_locations__child_locations',
)
.get(pk=location_id)
)
# 1) Construire la liste des ids (location + enfants)
def collect_ids(loc):
ids = [loc.id]
for child in loc.child_locations.all().order_by('code'):
ids.extend(collect_ids(child))
return ids
all_loc_ids = collect_ids(location)
# 2) Récupérer en bloc tous les liens GFK pour ces ids
ct_sl = ContentType.objects.get_for_model(ITSLocation)
links = (
InterventionLocation.objects
.filter(content_type=ct_sl, object_id__in=all_loc_ids)
.select_related('intervention')
)
# 3) Indexer par location_id → [interventions...]
by_loc = defaultdict(list)
for link in links:
by_loc[link.object_id].append(link.intervention)
# 4) Arbre
def build_tree(loc):
return {
'loc': loc,
'assets': list(loc.assets.all().order_by('code')),
'interventions': by_loc.get(loc.id, []),
'children': [build_tree(child) for child in loc.child_locations.all().order_by('code')],
}
tree = build_tree(location)
# 5) Aplatir les interventions de l’arbre (sans doublons) + tri global desc. par code
def flatten_interventions(node):
seen = set()
out = []
def walk(n):
for itv in n['interventions']:
if itv.pk not in seen:
seen.add(itv.pk)
out.append(itv)
for c in n['children']:
walk(c)
walk(node)
return out
interventions = sorted(
flatten_interventions(tree),
key=lambda itv: itv.creation_time,
reverse=True
)
# 6) Assets + documents
assets = location.get_active_assets()
docs = list(location.documents.filter(status='active').order_by('-id'))
for doc in docs:
doc._delete_url = reverse('assets:its_location_delete_document', args=[doc.id])
# 7) Géométrie 4326 (main_location fallback)
if location.geom:
location_geom_4326 = location.geom.transform(4326, clone=True)
else:
location_geom_4326 = None
geom = json.loads(location_geom_4326.geojson) if location_geom_4326 else None
return render(request, "assets/its_location_detail.html", {
"location": location,
"interventions": interventions, # toutes les interventions de l’arbre
"assets": assets,
"documents": docs,
"add_document_form": ITSLocationDocumentForm(),
"geom": geom,
"location_tree": tree, # contient interventions par nœud
})
@check_thematic_access('its')
def its_assets_detail(request, asset_model, asset_id):
try:
# Récupérer le ContentType pour le modèle fourni
content_type = ContentType.objects.get(model=asset_model)
model_class = content_type.model_class()
except ContentType.DoesNotExist:
raise Http404(f"Modèle '{asset_model}' inconnu.")
# Récupérer l’objet asset
asset = get_object_or_404(model_class, id=asset_id)
# Choisir dynamiquement le template à utiliser
template_name = f"its/detail_{asset_model}.html"
# Si le template spécifique n’existe pas, tu peux fallback vers un template générique :
from django.template.loader import select_template
try:
template = select_template([template_name, "assets/detail_generic.html"])
except:
raise Http404(_("Aucun template trouvé pour ce type d’asset."))
# Get edit context
from assets.views.core import get_asset_edit_context
context = {
"asset": asset,
"content_type": content_type,
}
context.update(get_asset_edit_context(request, asset, content_type))
# Override thematic_code for its
context['thematic_code'] = 'its'
return render(request, template.template.name, context)
# Appelle la fonction PostgreSQL unaccent(...)
class Unaccent(Func):
function = 'public.unaccent_immutable'
output_field = CharField()
def strip_accents_lower(s: str) -> str:
if not s:
return ''
s = ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
return s.lower()
def get_descendant_location_ids(root_location_id: int) -> list[int]:
"""
Récupère l'id de la localisation racine + tous ses descendants (FK parent_location) via un CTE récursif.
Nécessite PostgreSQL.
"""
# Récupérer dynamiquement le nom de la table depuis le modèle Django
table_name = ITSLocation._meta.db_table
with connection.cursor() as cur:
cur.execute(f"""
WITH RECURSIVE loc_tree AS (
SELECT id
FROM {table_name}
WHERE id = %s
UNION ALL
SELECT sl.id
FROM {table_name} sl
JOIN loc_tree lt ON sl.parent_location_id = lt.id
)
SELECT id FROM loc_tree;
""", [root_location_id])
rows = cur.fetchall()
return [r[0] for r in rows]
@require_GET
def its_assets_autocomplete(request):
raw_query_original = (request.GET.get('q') or '')
raw_query = raw_query_original.strip()
# CT ids (calculés une fois)
equipment_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id
location_ct_id = ContentType.objects.get_for_model(ITSLocation).id
qn = strip_accents_lower(raw_query)
has_search_term = len(qn) > 0
# filtres optionnels
location_id = request.GET.get('location_id')
category_id = request.GET.get('asset_category')
try:
category_id = int(category_id)
except (TypeError, ValueError):
category_id = None
# si location fournie, calcule l'ensemble {location + descendants}
location_ids = None
if location_id:
try:
location_ids = get_descendant_location_ids(int(location_id))
except Exception:
# en cas d'ID invalide ou autre, on ignore le filtre
location_ids = None
# Base queryset accent-insensible + actifs uniquement
qs = (
ITSGeoAsset.objects
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
name_nl_u=Lower(Unaccent('name_nl')),
)
)
# Si un terme de recherche est fourni, filtrer dessus
if has_search_term:
qs = qs.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
qs = qs.exclude(status='removed') # <-- exclure les supprimés
qs = qs.select_related(
'location',
'model',
'category',
)
if location_ids:
qs = qs.filter(location_id__in=location_ids)
if category_id is not None:
qs = qs.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
# Priorité : exact > code startswith > nom startswith > contient
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
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(),
)
qs = qs.annotate(_prio=priority).order_by('_prio', 'code')[:100]
else:
# Sans terme de recherche, tri par code uniquement
qs = qs.order_by('code')[:100]
data = []
for a in qs:
# equipment_type depuis model/category
equipment_type = None
if getattr(a, 'model', None):
equipment_type = getattr(a.model, 'name', None) or getattr(a.model, 'code', None)
if not equipment_type and getattr(a, 'category', None):
equipment_type = getattr(a.category, 'name', None)
# coordonnées: asset > geom(4326) > location
lon = a.lon
lat = a.lat
if (lon is None or lat is None) and getattr(a, 'geom', None):
try:
g = a.geom
if g.srid and g.srid != 4326:
g = a.geom.transform(4326, clone=True)
coords = getattr(g, 'coords', None)
if coords:
if lon is None: lon = coords[0]
if lat is None: lat = coords[1]
except Exception:
pass
if (lon is None or lat is None) and getattr(a, 'location', None):
if lon is None and getattr(a.location, 'lon', None) is not None:
lon = a.location.lon
if lat is None and getattr(a.location, 'lat', None) is not None:
lat = a.location.lat
equipment_label = f"{a.get_name()} ({a.code})" if a.code else a.get_name()
label = f"{a.code} - {equipment_label}" if a.code else equipment_label
location_label = (
f"{a.location.get_name()} ({a.location.code})"
if getattr(a, "location", None) else None
)
data.append({
"id": a.id,
"label": label,
"equipment_id": a.id,
"equipment_label": equipment_label,
"equipment_type": equipment_type,
"equipment_ct": equipment_ct_id,
"location_id": a.location.id if getattr(a, "location", None) else None,
"location_label": location_label,
"location_ct": location_ct_id,
"lon": lon,
"lat": lat,
})
return JsonResponse(data, safe=False)
@check_thematic_access('its', edit_permission_required=True)
@require_POST
def its_location_add_document(request, location_id):
location = get_object_or_404(ITSLocation, pk=location_id)
count = int(request.POST.get('file_count', 0))
files = request.FILES.getlist('files')
for i in range(count):
file = files[i]
document_type = request.POST.get(f'document_type_{i}', 'other')
ITSLocationDocument.objects.create(
location=location,
file=file,
document_type=document_type,
uploaded_by = request.user
)
return redirect('assets:its_locations_detail', location_id=location.id)
@check_thematic_access('its', edit_permission_required=True)
@require_POST
def its_location_delete_document(request, document_id):
document = get_object_or_404(ITSLocationDocument, id=document_id)
location_id = document.location.id
# Suppression du fichier si nécessaire (en plus de l'objet DB)
if document.file:
document.file.delete(save=False)
if document.thumbnail:
document.thumbnail.delete(save=False)
document.delete()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({'success': True})
return redirect('assets:its_locations_detail', location_id=location_id)
def _compute_depth(loc, cache):
"""Nombre d'ancêtres via parent_location (racine => profondeur 0)."""
if loc is None:
return -1
if loc.id in cache:
return cache[loc.id]
depth = 0
cur = loc
# NB: select_related('parent_location') limite les accès DB au 1er parent,
# mais on mémorise au fur et à mesure pour éviter les recalculs.
while cur.parent_location_id:
if cur.parent_location_id in cache:
depth += (cache[cur.parent_location_id] + 1)
break
depth += 1
cur = cur.parent_location
cache[loc.id] = depth
return depth
def _pick_deepest(candidates):
"""Retourne l'objet avec profondeur max; tie-break sur .distance si présent."""
if not candidates:
return None
depth_cache = {}
best = None
best_key = None # (depth, -distance) car on veut depth max et distance min
for obj in candidates:
d = _compute_depth(obj, depth_cache)
# distance peut ne pas exister (cas 'covers'): on met None => tie-break neutre
dist_val = getattr(obj, 'distance', None)
# On inverse la distance pour que 'min' devienne un tie-break correct
key = (d, -float(dist_val.m) if dist_val is not None else 0.0)
if best is None or key > best_key:
best, best_key = obj, key
return best
@require_GET
def get_nearest_its_location(request):
try:
lon = float(request.GET.get('lon'))
lat = float(request.GET.get('lat'))
except (TypeError, ValueError):
return JsonResponse({"error": "Coordonnées invalides"}, status=400)
user_point = Point(lon, lat, srid=4326)
user_point.transform(3812)
# 1) Candidats qui couvrent le point (frontière incluse)
covering_qs = (
ITSLocation.objects
.filter(geom__isnull=False, geom__covers=user_point)
.select_related('parent_location') # aide pour la 1ère montée de parent
)
covering = list(covering_qs)
chosen = _pick_deepest(covering)
# 2) Fallback: candidats dans un rayon de 50 m
if chosen is None:
nearby_qs = (
ITSLocation.objects
.filter(geom__isnull=False)
.annotate(distance=Distance('geom', user_point))
.filter(distance__lte=50)
.select_related('parent_location')
)
nearby = list(nearby_qs)
chosen = _pick_deepest(nearby)
if not chosen:
return JsonResponse({"error": _("Aucune localisation trouvée")}, status=404)
location_ct_id = ContentType.objects.get_for_model(chosen).id
return JsonResponse({
"location_id": chosen.id,
"location_ct": location_ct_id,
"location_label": f"{chosen.code} - {chosen.get_name()}",
"lon": chosen.lon,
"lat": chosen.lat,
})