1689 lines
No EOL
66 KiB
Python
1689 lines
No EOL
66 KiB
Python
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django.http import JsonResponse, Http404
|
||
from django.contrib.gis.db.models.functions import Transform
|
||
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
|
||
from django.contrib.gis.db.models.functions import Distance
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.utils.translation import get_language, gettext as _
|
||
from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value, Sum, Count
|
||
from django.db.models import Max
|
||
from django.db.models.functions import Lower
|
||
from django.views.decorators.http import require_GET
|
||
from django.urls import reverse
|
||
import uuid
|
||
import datetime
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
import unicodedata
|
||
|
||
from common.utils import check_thematic_access, measure_time
|
||
|
||
from common.models import UserThematics
|
||
from assets.models import NatureLocation, NatureGreenSurface, NatureAssetModel, NatureTree, NatureShrub, NatureBench, AssetCategory, NatureRWIZ, NatureRWIAsset
|
||
from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto
|
||
|
||
from interventions.models import Intervention, InterventionLocation, InterventionAsset
|
||
from interventions.views.drafts import InterventionDraftManager
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_index(request):
|
||
query_params = request.GET.copy()
|
||
if 'thematic' not in query_params:
|
||
query_params['thematic'] = 'nature'
|
||
return redirect(f"{reverse('assets:index')}?{query_params.urlencode()}")
|
||
|
||
|
||
|
||
def nature_locations(request):
|
||
return redirect(f"{reverse('assets:index')}?thematic=nature")
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_locations_detail(request, location_id):
|
||
section = get_object_or_404(NatureLocation, id=location_id)
|
||
content_type = ContentType.objects.get_for_model(section)
|
||
|
||
# 1. Direct assets linked to this location (evaluated once)
|
||
trees = list(section.nature_trees.select_related('model', 'category').all())
|
||
green_surfaces = list(section.nature_green_surfaces.select_related('model', 'category').all())
|
||
shrubs = list(section.nature_shrubs.select_related('model', 'category').all())
|
||
benches = list(section.nature_benches.select_related('model', 'category').all())
|
||
|
||
# 2. Key figures & metrics
|
||
trees_count = len(trees)
|
||
trees_active_count = sum(1 for t in trees if getattr(t, 'status', None) == 'active')
|
||
trees_remarkable_count = sum(1 for t in trees if getattr(t, 'is_remarkable', False))
|
||
|
||
green_surfaces_count = len(green_surfaces)
|
||
total_green_surface = sum((gs.surface_area or 0.0) for gs in green_surfaces)
|
||
|
||
def is_massif_arbore(gs):
|
||
if not getattr(gs, 'model', None):
|
||
return False
|
||
n_fr = (gs.model.name_fr or '').lower()
|
||
n_nl = (gs.model.name_nl or '').lower()
|
||
code = (gs.model.code or '').upper()
|
||
return (
|
||
'arboré' in n_fr or 'arbore' in n_fr or
|
||
'boom' in n_nl or 'hout' in n_nl or
|
||
'ARBORE' in code
|
||
)
|
||
|
||
def is_pre_fleuri(gs):
|
||
if not getattr(gs, 'model', None):
|
||
return False
|
||
n_fr = (gs.model.name_fr or '').lower()
|
||
n_nl = (gs.model.name_nl or '').lower()
|
||
code = (gs.model.code or '').upper()
|
||
return (
|
||
'fleuri' in n_fr or 'fleur' in n_fr or
|
||
'bloem' in n_nl or
|
||
'FLEURI' in code
|
||
)
|
||
|
||
surface_massif_arbore = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_massif_arbore(gs))
|
||
massif_arbore_count = sum(1 for gs in green_surfaces if is_massif_arbore(gs))
|
||
|
||
surface_pre_fleuri = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_pre_fleuri(gs))
|
||
pre_fleuri_count = sum(1 for gs in green_surfaces if is_pre_fleuri(gs))
|
||
|
||
# Breakdown by green surface typology
|
||
type_map = {}
|
||
for gs in green_surfaces:
|
||
m_id = gs.model_id or 0
|
||
if m_id not in type_map:
|
||
m_name = gs.model.get_name() if getattr(gs, 'model', None) else str(_("Autre / Non défini"))
|
||
m_color = (gs.model.color if (getattr(gs, 'model', None) and gs.model.color) else '#4caf50')
|
||
type_map[m_id] = {
|
||
'name': m_name,
|
||
'color': m_color,
|
||
'count': 0,
|
||
'total_area': 0.0,
|
||
}
|
||
type_map[m_id]['count'] += 1
|
||
type_map[m_id]['total_area'] += (gs.surface_area or 0.0)
|
||
|
||
surfaces_by_type = sorted(type_map.values(), key=lambda x: x['total_area'], reverse=True)
|
||
for item in surfaces_by_type:
|
||
item['percent'] = round((item['total_area'] / total_green_surface * 100), 1) if total_green_surface > 0 else 0
|
||
|
||
shrubs_count = len(shrubs)
|
||
benches_count = len(benches)
|
||
total_assets_count = trees_count + green_surfaces_count + shrubs_count + benches_count
|
||
|
||
# 3. Interventions linked to location + linked to assets on this location
|
||
location_intervention_ids = set(InterventionLocation.objects.filter(
|
||
content_type=content_type, object_id=section.id
|
||
).values_list('intervention_id', flat=True))
|
||
|
||
tree_ids = [t.id for t in trees]
|
||
gs_ids = [gs.id for gs in green_surfaces]
|
||
shrub_ids = [s.id for s in shrubs]
|
||
bench_ids = [b.id for b in benches]
|
||
|
||
asset_tuples = [
|
||
(ContentType.objects.get_for_model(NatureTree), tree_ids),
|
||
(ContentType.objects.get_for_model(NatureGreenSurface), gs_ids),
|
||
(ContentType.objects.get_for_model(NatureShrub), shrub_ids),
|
||
(ContentType.objects.get_for_model(NatureBench), bench_ids),
|
||
]
|
||
for ct, ids in asset_tuples:
|
||
if ids:
|
||
int_ids = InterventionAsset.objects.filter(
|
||
content_type=ct, object_id__in=ids
|
||
).values_list('intervention_id', flat=True)
|
||
location_intervention_ids.update(int_ids)
|
||
|
||
interventions = list(Intervention.objects.filter(
|
||
id__in=location_intervention_ids
|
||
).select_related('contract').order_by('-creation_time'))
|
||
|
||
# 4. Documents
|
||
documents = []
|
||
try:
|
||
from documents.models import DocumentAttachment
|
||
attachments = DocumentAttachment.objects.filter(
|
||
content_type=content_type, object_id=section.id
|
||
).select_related('document').prefetch_related('document__versions')
|
||
for att in attachments:
|
||
doc = att.document
|
||
latest_version = doc.latest_version if hasattr(doc, 'latest_version') else None
|
||
documents.append({
|
||
'title': doc.title or getattr(latest_version, 'filename', str(doc)),
|
||
'date': doc.created_at,
|
||
'tag': doc.tag.name_fr if (hasattr(doc, 'tag') and doc.tag) else '',
|
||
'url': latest_version.file.url if (latest_version and latest_version.file) else None,
|
||
'thumbnail_url': latest_version.thumbnail.url if (latest_version and getattr(latest_version, 'thumbnail', None)) else None,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
if hasattr(section, 'documents'):
|
||
for doc in section.documents.all():
|
||
documents.append({
|
||
'title': doc.file.name if hasattr(doc, 'file') and doc.file else str(doc),
|
||
'date': getattr(doc, 'upload_date', None),
|
||
'tag': '',
|
||
'url': doc.file.url if (hasattr(doc, 'file') and doc.file) else None,
|
||
'thumbnail_url': doc.thumbnail.url if (hasattr(doc, 'thumbnail') and doc.thumbnail) else None,
|
||
})
|
||
|
||
# 5. Serialized GeoJSON for map display
|
||
assets_features = []
|
||
|
||
for gs in green_surfaces:
|
||
geom = None
|
||
if gs.geojson:
|
||
try:
|
||
geom = json.loads(gs.geojson)
|
||
except Exception:
|
||
geom = None
|
||
elif gs.geom:
|
||
try:
|
||
geom = json.loads(gs.geom.transform(4326, clone=True).geojson)
|
||
except Exception:
|
||
geom = None
|
||
|
||
if geom:
|
||
assets_features.append({
|
||
"type": "Feature",
|
||
"id": f"gs_{gs.id}",
|
||
"geometry": geom,
|
||
"properties": {
|
||
"id": gs.id,
|
||
"code": gs.code,
|
||
"name": gs.get_name(),
|
||
"model": gs.model.get_name() if gs.model else '',
|
||
"asset_type": "naturegreensurface",
|
||
"area": f"{gs.surface_area:.1f} m²" if gs.surface_area else '',
|
||
"color": gs.model.color if (gs.model and gs.model.color) else '#4caf50',
|
||
"status": gs.get_status_display(),
|
||
"lon": gs.lon,
|
||
"lat": gs.lat,
|
||
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturegreensurface', 'asset_id': gs.id}),
|
||
}
|
||
})
|
||
|
||
for tree in trees:
|
||
geom = None
|
||
if tree.geojson:
|
||
try:
|
||
geom = json.loads(tree.geojson)
|
||
except Exception:
|
||
geom = None
|
||
elif tree.lon is not None and tree.lat is not None:
|
||
geom = {"type": "Point", "coordinates": [tree.lon, tree.lat]}
|
||
|
||
if geom:
|
||
assets_features.append({
|
||
"type": "Feature",
|
||
"id": f"tree_{tree.id}",
|
||
"geometry": geom,
|
||
"properties": {
|
||
"id": tree.id,
|
||
"code": tree.code,
|
||
"name": tree.get_name() or (tree.model.get_name() if tree.model else ''),
|
||
"model": tree.model.get_name() if tree.model else '',
|
||
"scientific_name": tree.model.scientific_name if (tree.model and tree.model.scientific_name) else '',
|
||
"asset_type": "naturetree",
|
||
"color": "#2e7d32",
|
||
"vitality": tree.get_vitality_display() if tree.vitality else '',
|
||
"stage": tree.get_development_stage_display() if tree.development_stage else '',
|
||
"height": f"{tree.height} m" if tree.height else '',
|
||
"status": tree.get_status_display(),
|
||
"is_remarkable": tree.is_remarkable,
|
||
"lon": tree.lon,
|
||
"lat": tree.lat,
|
||
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id': tree.id}),
|
||
}
|
||
})
|
||
|
||
for shrub in shrubs:
|
||
geom = None
|
||
if shrub.geojson:
|
||
try:
|
||
geom = json.loads(shrub.geojson)
|
||
except Exception:
|
||
geom = None
|
||
elif shrub.lon is not None and shrub.lat is not None:
|
||
geom = {"type": "Point", "coordinates": [shrub.lon, shrub.lat]}
|
||
|
||
if geom:
|
||
assets_features.append({
|
||
"type": "Feature",
|
||
"id": f"shrub_{shrub.id}",
|
||
"geometry": geom,
|
||
"properties": {
|
||
"id": shrub.id,
|
||
"code": shrub.code,
|
||
"model": shrub.model.get_name() if shrub.model else '',
|
||
"asset_type": "natureshrub",
|
||
"color": "#81c784",
|
||
"height": f"{shrub.height} m" if shrub.height else '',
|
||
"status": shrub.get_status_display(),
|
||
"lon": shrub.lon,
|
||
"lat": shrub.lat,
|
||
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'natureshrub', 'asset_id': shrub.id}),
|
||
}
|
||
})
|
||
|
||
for bench in benches:
|
||
geom = None
|
||
if bench.geojson:
|
||
try:
|
||
geom = json.loads(bench.geojson)
|
||
except Exception:
|
||
geom = None
|
||
elif bench.lon is not None and bench.lat is not None:
|
||
geom = {"type": "Point", "coordinates": [bench.lon, bench.lat]}
|
||
|
||
if geom:
|
||
assets_features.append({
|
||
"type": "Feature",
|
||
"id": f"bench_{bench.id}",
|
||
"geometry": geom,
|
||
"properties": {
|
||
"id": bench.id,
|
||
"code": bench.code,
|
||
"model": bench.model.get_name() if bench.model else '',
|
||
"asset_type": "naturebench",
|
||
"color": "#8d6e63",
|
||
"material": bench.material or '',
|
||
"status": bench.get_status_display(),
|
||
"lon": bench.lon,
|
||
"lat": bench.lat,
|
||
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturebench', 'asset_id': bench.id}),
|
||
}
|
||
})
|
||
|
||
assets_geojson = json.dumps({"type": "FeatureCollection", "features": assets_features})
|
||
|
||
# Location geometry for map
|
||
section_geojson = section.geojson
|
||
if not section_geojson and section.geom:
|
||
try:
|
||
section_geojson = section.geom.transform(4326, clone=True).geojson
|
||
except Exception:
|
||
section_geojson = None
|
||
|
||
feature_lon = section.lon
|
||
feature_lat = section.lat
|
||
if (feature_lon is None or feature_lat is None) and section.geom:
|
||
try:
|
||
pt = section.geom.transform(4326, clone=True).centroid
|
||
feature_lon = pt.x
|
||
feature_lat = pt.y
|
||
except Exception:
|
||
pass
|
||
|
||
context = {
|
||
'section': section,
|
||
'trees': trees,
|
||
'green_surfaces': green_surfaces,
|
||
'shrubs': shrubs,
|
||
'benches': benches,
|
||
'trees_count': trees_count,
|
||
'trees_active_count': trees_active_count,
|
||
'trees_remarkable_count': trees_remarkable_count,
|
||
'green_surfaces_count': green_surfaces_count,
|
||
'total_green_surface': total_green_surface,
|
||
'surface_massif_arbore': surface_massif_arbore,
|
||
'massif_arbore_count': massif_arbore_count,
|
||
'surface_pre_fleuri': surface_pre_fleuri,
|
||
'pre_fleuri_count': pre_fleuri_count,
|
||
'surfaces_by_type': surfaces_by_type,
|
||
'shrubs_count': shrubs_count,
|
||
'benches_count': benches_count,
|
||
'total_assets_count': total_assets_count,
|
||
'interventions': interventions,
|
||
'documents': documents,
|
||
'assets_geojson': assets_geojson,
|
||
'section_geojson': section_geojson,
|
||
'feature_lon': feature_lon,
|
||
'feature_lat': feature_lat,
|
||
}
|
||
|
||
return render(request, "assets/nature_locations_detail.html", context)
|
||
|
||
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_locations_geojson(request):
|
||
|
||
if request.method == "POST":
|
||
try:
|
||
data = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||
|
||
highway = data.get('highway') == "1"
|
||
location_type = data.get('location_type', '')
|
||
|
||
# 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:
|
||
highway = request.GET.get('highway') == "1"
|
||
location_type = request.GET.get('location_type', '')
|
||
filter_geom = None
|
||
|
||
|
||
from assets.permissions import get_user_accessible_municipalities
|
||
user = request.user
|
||
user_config = getattr(user, 'config', None)
|
||
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||
is_external = (user_config is None) or (not user_config.is_intern)
|
||
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
|
||
|
||
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
|
||
|
||
include_naturelocation = location_type in ('', 'naturelocation')
|
||
|
||
nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none()
|
||
|
||
if not is_admin and must_limit:
|
||
accessible_munis = get_user_accessible_municipalities(user)
|
||
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
|
||
if municipalities:
|
||
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
|
||
nature_locations = nature_locations.filter(municipality_id__in=valid_munis)
|
||
else:
|
||
nature_locations = nature_locations.filter(municipality_id__in=accessible_muni_ids)
|
||
elif municipalities:
|
||
nature_locations = nature_locations.filter(municipality_id__in=municipalities)
|
||
|
||
if highway:
|
||
nature_locations = nature_locations.filter(is_highway=highway)
|
||
|
||
if filter_geom:
|
||
nature_locations = nature_locations.filter(geom__intersects=filter_geom)
|
||
|
||
# Recherche plein texte
|
||
if request.method == 'POST':
|
||
from assets.views.core import apply_search
|
||
nature_locations = apply_search(nature_locations, data)
|
||
|
||
from assets.views.core import get_element_type_label
|
||
def obj_to_feature(obj):
|
||
geometry = json.loads(obj.geojson) if obj.geojson else None
|
||
return {
|
||
"type": "Feature",
|
||
"id": obj.id,
|
||
"geometry": geometry,
|
||
"properties": {
|
||
"code": obj.code,
|
||
"name": obj.get_name(),
|
||
"highway": getattr(obj, 'is_highway', False),
|
||
"location_type": obj.__class__.__name__,
|
||
"lon": obj.lon,
|
||
"lat": obj.lat,
|
||
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||
"element_type": get_element_type_label(obj.__class__.__name__.lower()),
|
||
"thematic": "nature",
|
||
"is_location": True,
|
||
}
|
||
}
|
||
|
||
features = []
|
||
|
||
for section in nature_locations:
|
||
features.append(obj_to_feature(section))
|
||
|
||
geojson_data = {
|
||
"type": "FeatureCollection",
|
||
"features": features,
|
||
}
|
||
|
||
return JsonResponse(geojson_data, safe=False)
|
||
|
||
|
||
|
||
def nature_assets(request):
|
||
pass
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_assets_detail(request, asset_model, asset_id):
|
||
content_type = ContentType.objects.filter(app_label='assets', model=asset_model.lower()).first() or ContentType.objects.filter(model=asset_model.lower()).first()
|
||
if not content_type:
|
||
raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model})
|
||
model_class = content_type.model_class()
|
||
if not model_class:
|
||
raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model})
|
||
|
||
# Récupérer l’objet asset
|
||
asset = get_object_or_404(model_class, id=asset_id)
|
||
|
||
# Choisir dynamiquement le template à utiliser
|
||
template_name = f"assets/{asset_model}_detail.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 nature
|
||
context['thematic_code'] = 'nature'
|
||
|
||
return render(request, template.template.name, context)
|
||
|
||
|
||
|
||
def ensure_list(value):
|
||
if isinstance(value, list):
|
||
return value
|
||
if value is None:
|
||
return []
|
||
return [value]
|
||
|
||
|
||
MAX_ASSET_FEATURES = 10000 # limite imposée
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_assets_geojson(request):
|
||
lang = get_language() if get_language() in ('fr', 'nl') else 'fr'
|
||
|
||
if request.method == "POST":
|
||
try:
|
||
data = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||
|
||
sections_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'))
|
||
highway = data.get('highway') == "1"
|
||
location_type = data.get('location_type', '')
|
||
management_types = ensure_list(data.get('management_type'))
|
||
|
||
# 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)
|
||
except Exception as e:
|
||
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
|
||
else:
|
||
filter_geom = None
|
||
|
||
else:
|
||
section_id = request.GET.get('location')
|
||
sections_ids = [section_id] if section_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', [])
|
||
highway = request.GET.get('highway') == "1"
|
||
location_type = request.GET.get('location_type', '')
|
||
management_types = request.GET.getlist('management_type', [])
|
||
filter_geom = None
|
||
|
||
|
||
_search_q = (data.get('q') or '').strip() if request.method == 'POST' else ''
|
||
if not request.GET.get('unified') and not sections_ids and not categories and not models and not filter_geom and not _search_q and not highway and not location_type and not management_types: # return empty geojson if no param with section_id
|
||
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.get('geojson'):
|
||
try:
|
||
geometry = json.loads(obj['geojson'])
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
geometry = None
|
||
|
||
lon = obj.get('lon')
|
||
lat = obj.get('lat')
|
||
|
||
if lon is None or lat is None:
|
||
if lon is None:
|
||
lon = obj.get('location_lon')
|
||
if lat is None:
|
||
lat = obj.get('location_lat')
|
||
|
||
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)
|
||
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
|
||
model_name = obj.get('model_name') or ''
|
||
code = obj.get('code') or ''
|
||
is_planter = asset_type == 'naturebench' and (
|
||
any(w in model_name.lower() for w in ('bac', 'jardin', 'bloem', 'plante', 'fleur'))
|
||
or code.startswith('CSE-BAC')
|
||
)
|
||
element_type_label = _('Bac à fleurs') if is_planter else get_element_type_label(asset_type or '')
|
||
return {
|
||
"type": "Feature",
|
||
"id": obj['id'],
|
||
"geometry": geometry,
|
||
"properties": {
|
||
"code": obj['code'],
|
||
"name": obj['name'],
|
||
"category": obj['category_name'],
|
||
"model": obj['model_name'],
|
||
"category_id": obj.get('category_id'),
|
||
"model_id": obj.get('model_id'),
|
||
"status": obj['status'],
|
||
"validation_status": obj.get('validation_status'),
|
||
"lon": lon,
|
||
"lat": lat,
|
||
"asset_type": asset_type,
|
||
"asset_sub_type": "planter" if is_planter else ("bench" if asset_type == "naturebench" else ""),
|
||
"area": obj.get('surface_area', ''),
|
||
"color": obj['color'] if obj['color'] else '#ccc',
|
||
"detail_url": detail_url,
|
||
"bulb_types": obj.get('bulb_types') or [],
|
||
"element_type": element_type_label,
|
||
"thematic": "nature",
|
||
"is_location": False,
|
||
"structure_type": obj.get('structure_type', ''),
|
||
"managing_authority": obj.get('managing_authority') or '',
|
||
"management_type": obj.get('management_type') or '',
|
||
"is_remarkable": obj.get('is_remarkable', False),
|
||
"scientific_name": obj.get('scientific_name') or '',
|
||
"vernacular_name": obj.get('vernacular_name') or '',
|
||
"is_dead": bool(obj.get('is_dead') or obj.get('vitality') == 'dead' or obj.get('status') in ('dead', 'archived')),
|
||
"vitality": obj.get('vitality') or '',
|
||
}
|
||
}
|
||
|
||
|
||
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
|
||
|
||
|
||
naturegreensurface_qs = (
|
||
NatureGreenSurface.objects.all()
|
||
)
|
||
naturetree_qs = (
|
||
NatureTree.objects.all()
|
||
)
|
||
natureshrub_qs = (
|
||
NatureShrub.objects.all()
|
||
)
|
||
naturebench_qs = (
|
||
NatureBench.objects.all()
|
||
)
|
||
|
||
from assets.permissions import get_user_accessible_municipalities
|
||
user = request.user
|
||
user_config = getattr(user, 'config', None)
|
||
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||
is_external = (user_config is None) or (not user_config.is_intern)
|
||
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
|
||
|
||
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
|
||
|
||
if not is_admin and must_limit:
|
||
accessible_munis = get_user_accessible_municipalities(user)
|
||
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
|
||
if municipalities:
|
||
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=valid_munis)
|
||
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=valid_munis)
|
||
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=valid_munis)
|
||
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=valid_munis)
|
||
else:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=accessible_muni_ids)
|
||
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=accessible_muni_ids)
|
||
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=accessible_muni_ids)
|
||
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=accessible_muni_ids)
|
||
elif municipalities:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=municipalities)
|
||
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=municipalities)
|
||
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=municipalities)
|
||
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=municipalities)
|
||
|
||
include_naturelocation = location_type in ('', 'naturelocation')
|
||
|
||
if not include_naturelocation:
|
||
naturegreensurface_qs = naturegreensurface_qs.none()
|
||
naturetree_qs = naturetree_qs.none()
|
||
natureshrub_qs = natureshrub_qs.none()
|
||
naturebench_qs = naturebench_qs.none()
|
||
|
||
if sections_ids:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(location__id__in=sections_ids)
|
||
naturetree_qs = naturetree_qs.filter(location__id__in=sections_ids)
|
||
natureshrub_qs = natureshrub_qs.filter(location__id__in=sections_ids)
|
||
naturebench_qs = naturebench_qs.filter(location__id__in=sections_ids)
|
||
|
||
if highway:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(location__is_highway=True)
|
||
naturetree_qs = naturetree_qs.filter(location__is_highway=True)
|
||
natureshrub_qs = natureshrub_qs.filter(location__is_highway=True)
|
||
naturebench_qs = naturebench_qs.filter(location__is_highway=True)
|
||
if models:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(model__id__in=models)
|
||
naturetree_qs = naturetree_qs.filter(model__id__in=models)
|
||
natureshrub_qs = natureshrub_qs.filter(model__id__in=models)
|
||
naturebench_qs = naturebench_qs.filter(model__id__in=models)
|
||
|
||
if management_types:
|
||
q_mgmt = Q()
|
||
for val in management_types:
|
||
val_str = str(val).strip()
|
||
if val_str == 'municipal':
|
||
q_mgmt |= Q(management_type='municipal')
|
||
elif val_str == 'regional':
|
||
q_mgmt |= Q(management_type='regional')
|
||
elif val_str == 'regional_bruxelles':
|
||
q_mgmt |= Q(management_type='regional') & (
|
||
Q(location__municipality__niscode__startswith='21') |
|
||
Q(location__municipality__name_fr__icontains='Bruxelles') |
|
||
Q(location__municipality__name_fr__icontains='Watermael') |
|
||
Q(location__municipality__name_fr__icontains='Berchem')
|
||
)
|
||
elif val_str == 'regional_wallonie':
|
||
q_mgmt |= Q(management_type='regional') & (
|
||
Q(location__municipality__niscode__startswith=('25', '5', '6', '8', '9')) |
|
||
Q(location__municipality__name_fr__icontains='Court-Saint')
|
||
)
|
||
elif val_str == 'regional_flandre':
|
||
q_mgmt |= Q(management_type='regional') & (
|
||
Q(location__municipality__niscode__startswith=('1', '3', '4', '7', '23', '24'))
|
||
)
|
||
elif val_str.startswith('municipal_'):
|
||
m_id = val_str.split('_', 1)[1]
|
||
if m_id.isdigit():
|
||
q_mgmt |= Q(management_type='municipal', location__municipality_id=int(m_id))
|
||
elif val_str.startswith('authority:'):
|
||
auth_name = val_str.split(':', 1)[1]
|
||
q_mgmt |= Q(managing_authority__iexact=auth_name)
|
||
else:
|
||
q_mgmt |= Q(managing_authority__icontains=val_str) | Q(management_type=val_str)
|
||
|
||
naturetree_qs = naturetree_qs.filter(q_mgmt)
|
||
naturegreensurface_qs = naturegreensurface_qs.none()
|
||
natureshrub_qs = natureshrub_qs.none()
|
||
naturebench_qs = naturebench_qs.none()
|
||
if categories:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(category__id__in=categories)
|
||
naturetree_qs = naturetree_qs.filter(category__id__in=categories)
|
||
natureshrub_qs = natureshrub_qs.filter(category__id__in=categories)
|
||
naturebench_qs = naturebench_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
filter_geom_3812 = filter_geom.transform(3812, clone=True)
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(
|
||
Q(geom__intersects=filter_geom_3812) |
|
||
Q(geom__isnull=True, location__geom__intersects=filter_geom_3812)
|
||
)
|
||
naturetree_qs = naturetree_qs.filter(
|
||
Q(geom__intersects=filter_geom_3812) |
|
||
Q(geom__isnull=True, location__geom__intersects=filter_geom_3812)
|
||
)
|
||
natureshrub_qs = natureshrub_qs.filter(
|
||
Q(geom__intersects=filter_geom_3812) |
|
||
Q(geom__isnull=True, location__geom__intersects=filter_geom_3812)
|
||
)
|
||
naturebench_qs = naturebench_qs.filter(
|
||
Q(geom__intersects=filter_geom_3812) |
|
||
Q(geom__isnull=True, location__geom__intersects=filter_geom_3812)
|
||
)
|
||
|
||
if statuses:
|
||
naturegreensurface_qs = naturegreensurface_qs.filter(status__in=statuses)
|
||
naturetree_qs = naturetree_qs.filter(status__in=statuses)
|
||
natureshrub_qs = natureshrub_qs.filter(status__in=statuses)
|
||
naturebench_qs = naturebench_qs.filter(status__in=statuses)
|
||
else:
|
||
# Default behavior: exclude removed assets
|
||
naturegreensurface_qs = naturegreensurface_qs.exclude(status='removed')
|
||
naturetree_qs = naturetree_qs.exclude(status='removed')
|
||
natureshrub_qs = natureshrub_qs.exclude(status='removed')
|
||
naturebench_qs = naturebench_qs.exclude(status='removed')
|
||
|
||
# Recherche plein texte
|
||
if request.method == 'POST':
|
||
from assets.views.core import apply_search
|
||
naturegreensurface_qs = apply_search(naturegreensurface_qs, data)
|
||
naturetree_qs = apply_search(naturetree_qs, data)
|
||
natureshrub_qs = apply_search(natureshrub_qs, data)
|
||
naturebench_qs = apply_search(naturebench_qs, data)
|
||
|
||
if (naturegreensurface_qs.count() + naturetree_qs.count() + natureshrub_qs.count() + naturebench_qs.count()) > MAX_ASSET_FEATURES:
|
||
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
|
||
|
||
naturegreensurface_qs = (naturegreensurface_qs.annotate(
|
||
name=F(f'name_{lang}'),
|
||
category_name=F(f'category__name_{lang}'),
|
||
model_name=F(f'model__name_{lang}'),
|
||
color = F('model__color'),
|
||
location_lon=F('location__lon'),
|
||
location_lat=F('location__lat'),
|
||
)
|
||
.values( # on limite strictement les colonnes
|
||
'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat', 'surface_area',
|
||
'category_name', 'model_name', 'color', 'geojson',
|
||
'category_id', 'model_id', 'bulb_types', 'location_lon', 'location_lat'
|
||
)
|
||
.order_by('code')
|
||
)[:MAX_ASSET_FEATURES + 1]
|
||
|
||
|
||
naturetree_qs = (naturetree_qs.annotate(
|
||
name=F(f'name_{lang}'),
|
||
category_name=F(f'category__name_{lang}'),
|
||
model_name=F(f'model__name_{lang}'),
|
||
color = F('model__color'),
|
||
location_lon=F('location__lon'),
|
||
location_lat=F('location__lat'),
|
||
)
|
||
.values( # on limite strictement les colonnes
|
||
'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat',
|
||
'category_name', 'model_name', 'color', 'geojson',
|
||
'category_id', 'model_id', 'location_lon', 'location_lat',
|
||
'managing_authority', 'management_type', 'is_remarkable',
|
||
'scientific_name', 'vernacular_name',
|
||
'is_dead', 'vitality'
|
||
)
|
||
.order_by('code')
|
||
)[:MAX_ASSET_FEATURES + 1]
|
||
|
||
natureshrub_qs = (natureshrub_qs.annotate(
|
||
name=F(f'name_{lang}'),
|
||
category_name=F(f'category__name_{lang}'),
|
||
model_name=F(f'model__name_{lang}'),
|
||
color=F('model__color'),
|
||
location_lon=F('location__lon'),
|
||
location_lat=F('location__lat'),
|
||
)
|
||
.values(
|
||
'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat',
|
||
'category_name', 'model_name', 'color', 'geojson',
|
||
'category_id', 'model_id', 'location_lon', 'location_lat'
|
||
)
|
||
.order_by('code')
|
||
)[:MAX_ASSET_FEATURES + 1]
|
||
|
||
naturebench_qs = (naturebench_qs.annotate(
|
||
name=F(f'name_{lang}'),
|
||
category_name=F(f'category__name_{lang}'),
|
||
model_name=F(f'model__name_{lang}'),
|
||
color=F('model__color'),
|
||
location_lon=F('location__lon'),
|
||
location_lat=F('location__lat'),
|
||
)
|
||
.values(
|
||
'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat',
|
||
'category_name', 'model_name', 'color', 'geojson',
|
||
'category_id', 'model_id', 'location_lon', 'location_lat'
|
||
)
|
||
.order_by('code')
|
||
)[:MAX_ASSET_FEATURES + 1]
|
||
|
||
|
||
features = []
|
||
feature_count = 0
|
||
|
||
for qs, asset_type in [
|
||
(naturegreensurface_qs, "naturegreensurface"),
|
||
(naturetree_qs, "naturetree"),
|
||
(natureshrub_qs, "natureshrub"),
|
||
(naturebench_qs, "naturebench"),
|
||
]:
|
||
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)
|
||
|
||
|
||
|
||
@require_GET
|
||
def nature_locations_autocomplete(request):
|
||
raw_query = (request.GET.get('q') or '').strip()
|
||
if len(raw_query) < 2:
|
||
return JsonResponse([], safe=False)
|
||
|
||
user = request.user
|
||
user_config = getattr(user, 'config', None)
|
||
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||
is_external = (user_config is None) or (not user_config.is_intern)
|
||
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
|
||
|
||
# Normalise la requête (équivalent de unaccent+lower côté Python)
|
||
qn = strip_accents_lower(raw_query)
|
||
|
||
# Prépare des colonnes "recherchables" unaccentées + minuscules
|
||
qs = NatureLocation.objects.all()
|
||
if not is_admin and must_limit:
|
||
from assets.permissions import get_user_accessible_municipalities
|
||
accessible_munis = get_user_accessible_municipalities(user)
|
||
qs = qs.filter(municipality__in=accessible_munis)
|
||
|
||
qs = (
|
||
qs
|
||
.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) | # __contains car on a déjà des champs en lower
|
||
Q(name_fr_u__contains=qn) |
|
||
Q(name_nl_u__contains=qn)
|
||
)
|
||
)
|
||
|
||
# Priorité : exact > startswith > nom startswith > contient
|
||
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')[:50]
|
||
|
||
data = [{
|
||
"label": f"{loc.code} - {loc.get_name()}",
|
||
"location_id": loc.id,
|
||
"location_type": loc.__class__.__name__,
|
||
"location_label": f"{loc.code} - {loc.get_name()}",
|
||
"lon": loc.lon,
|
||
"lat": loc.lat
|
||
} for loc in list(qs)]
|
||
|
||
data.sort(key=lambda x: x["label"])
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
|
||
|
||
|
||
@require_GET
|
||
def nature_assets_autocomplete(request):
|
||
raw_query_original = (request.GET.get('q') or '')
|
||
raw_query = raw_query_original.strip()
|
||
|
||
qn = strip_accents_lower(raw_query)
|
||
has_search_term = len(qn) > 0
|
||
|
||
location_id = request.GET.get('location_id') # = id de la rue
|
||
category_id = request.GET.get('asset_category')
|
||
try:
|
||
category_id = int(category_id)
|
||
except (TypeError, ValueError):
|
||
category_id = None
|
||
|
||
# Prépare filtre "dans cette rue uniquement" si location_id fourni
|
||
location_filter = {}
|
||
if location_id:
|
||
location_filter = {'location_id': location_id}
|
||
|
||
|
||
user = request.user
|
||
user_config = getattr(user, 'config', None)
|
||
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||
is_external = (user_config is None) or (not user_config.is_intern)
|
||
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
|
||
|
||
def base_qs(model, extra_filter=None):
|
||
ann = {
|
||
'code_u': Lower(Unaccent('code')),
|
||
'name_fr_u': Lower(Unaccent('name_fr')),
|
||
'name_nl_u': Lower(Unaccent('name_nl')),
|
||
}
|
||
q = model.objects.all()
|
||
if not is_admin and must_limit:
|
||
from assets.permissions import get_user_accessible_municipalities
|
||
accessible_munis = get_user_accessible_municipalities(user)
|
||
q = q.filter(location__municipality__in=accessible_munis)
|
||
|
||
q = q.annotate(**ann)
|
||
|
||
# Si un terme de recherche est fourni, filtrer dessus
|
||
if has_search_term:
|
||
q = q.filter(
|
||
Q(code_u__contains=qn) |
|
||
Q(name_fr_u__contains=qn) |
|
||
Q(name_nl_u__contains=qn)
|
||
)
|
||
|
||
# Filtrer uniquement les non supprimés
|
||
q = q.exclude(status='removed')
|
||
|
||
if extra_filter:
|
||
q = q.filter(**extra_filter)
|
||
|
||
if category_id is not None:
|
||
q = q.filter(
|
||
Q(category_id=category_id) |
|
||
Q(category__isnull=True, model__category_id=category_id)
|
||
)
|
||
|
||
q = q.select_related('location', 'model')
|
||
|
||
return q
|
||
|
||
# Récupère QS pour chaque type (limités grossièrement pour éviter de charger trop, puis triés/merge côté Python)
|
||
trees_qs = base_qs(NatureTree, extra_filter=location_filter)[:100]
|
||
greensurfaces_qs = base_qs(NatureGreenSurface, extra_filter=location_filter)[:100]
|
||
|
||
# Concatène en mémoire (on priorise ensuite)
|
||
items = []
|
||
for a in list(trees_qs) + list(greensurfaces_qs):
|
||
# priorisation locale (recalcule ici car on merge des modèles différents)
|
||
code_u = getattr(a, 'code_u', None)
|
||
name_fr_u = getattr(a, 'name_fr_u', None)
|
||
name_nl_u = getattr(a, 'name_nl_u', None)
|
||
|
||
prio = 7
|
||
# Priorisation uniquement si un terme de recherche est fourni
|
||
if has_search_term:
|
||
if code_u == qn:
|
||
prio = 0
|
||
elif code_u and code_u.startswith(qn):
|
||
prio = 1
|
||
elif (name_fr_u and name_fr_u.startswith(qn)) or (name_nl_u and name_nl_u.startswith(qn)):
|
||
prio = 2
|
||
elif (code_u and qn in code_u) or (name_fr_u and qn in name_fr_u) or (name_nl_u and qn in name_nl_u):
|
||
prio = 4
|
||
else:
|
||
# Sans terme de recherche, on trie par code
|
||
prio = 5
|
||
|
||
location = a.location if hasattr(a, 'location') else None
|
||
equipment_type = a.model.get_name() if hasattr(a, 'model') else None
|
||
parts = [a.code, a.get_name()]
|
||
equipment_label = " - ".join(p for p in parts if p)
|
||
code = getattr(a, 'code', '') or ''
|
||
label = equipment_label
|
||
lon, lat = [getattr(a, 'lon', None), getattr(a, 'lat', None)]
|
||
|
||
location_id_val = location.id if location else None
|
||
location_label = (f"{location.code} - {location.get_name()}" if location else None)
|
||
|
||
equipment_ct_id = ContentType.objects.get_for_model(a).id
|
||
location_ct_id = ContentType.objects.get_for_model(location).id if location else None
|
||
|
||
items.append({
|
||
"prio": prio,
|
||
"code": code or "",
|
||
"id": getattr(a, 'id', None),
|
||
"label": label,
|
||
|
||
# attendus par ton autocomplete équipements
|
||
"equipment_id": getattr(a, 'id', None),
|
||
"equipment_label": equipment_label,
|
||
"equipment_type": equipment_type,
|
||
"equipment_ct": equipment_ct_id,
|
||
|
||
"location_id": location_id_val,
|
||
"location_label": location_label,
|
||
"location_ct": location_ct_id,
|
||
|
||
"lon": lon,
|
||
"lat": lat,
|
||
})
|
||
|
||
# Tri global par priorité puis code, coupe à 100
|
||
items.sort(key=lambda x: (x["prio"], x["code"]))
|
||
data = items[:100]
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
|
||
|
||
@require_GET
|
||
def get_nearest_nature_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)
|
||
|
||
nearest = (
|
||
NatureLocation.objects
|
||
.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', user_point))
|
||
.filter(distance__lte=50) # filtre à 50 mètres
|
||
.order_by('distance')
|
||
.first()
|
||
)
|
||
|
||
location_ct_id = ContentType.objects.get_for_model(nearest).id
|
||
|
||
if nearest:
|
||
return JsonResponse({
|
||
"location_id": nearest.id,
|
||
"location_ct": location_ct_id,
|
||
"location_label": f"{nearest.code} - {nearest.get_name()}",
|
||
"lon": nearest.lon, "lat": nearest.lat
|
||
})
|
||
else:
|
||
return JsonResponse({"error": _("Aucune section trouvée")})
|
||
|
||
|
||
|
||
|
||
# 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()
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_bulk_update_assets(request):
|
||
"""Bulk update category and/or model for selected nature assets."""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'POST method required'}, status=405)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
asset_ids = data.get('asset_ids', [])
|
||
category_id = data.get('category_id')
|
||
model_id = data.get('model_id')
|
||
bulb_types = data.get('bulb_types') # list or None
|
||
|
||
if not asset_ids:
|
||
return JsonResponse({'error': _('Aucun asset sélectionné')}, status=400)
|
||
|
||
updated_count = 0
|
||
|
||
# Update green surfaces
|
||
green_surfaces = NatureGreenSurface.objects.filter(id__in=asset_ids)
|
||
if category_id:
|
||
green_surfaces.update(category_id=category_id)
|
||
if model_id:
|
||
green_surfaces.update(model_id=model_id)
|
||
if bulb_types is not None:
|
||
green_surfaces.update(bulb_types=bulb_types)
|
||
updated_count += green_surfaces.count()
|
||
|
||
# Update trees (if any in selection)
|
||
trees = NatureTree.objects.filter(id__in=asset_ids)
|
||
if category_id:
|
||
trees.update(category_id=category_id)
|
||
if model_id:
|
||
trees.update(model_id=model_id)
|
||
updated_count += trees.count()
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'updated_count': updated_count
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_bulk_delete_assets(request):
|
||
"""Bulk delete (archive) selected nature assets."""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'POST method required'}, status=405)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
asset_ids = data.get('asset_ids', [])
|
||
|
||
if not asset_ids:
|
||
return JsonResponse({'error': _('Aucun asset sélectionné')}, status=400)
|
||
|
||
deleted_count = 0
|
||
|
||
# Archive green surfaces
|
||
green_surfaces = NatureGreenSurface.objects.filter(id__in=asset_ids)
|
||
green_surfaces.update(status='removed')
|
||
deleted_count += green_surfaces.count()
|
||
|
||
# Archive trees
|
||
trees = NatureTree.objects.filter(id__in=asset_ids)
|
||
trees.update(status='removed')
|
||
deleted_count += trees.count()
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'deleted_count': deleted_count
|
||
})
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_undo_action(request):
|
||
"""
|
||
Undo the last action (simplified version).
|
||
|
||
NOTE: This is a basic implementation for demonstration purposes.
|
||
In a production environment, you would need to:
|
||
1. Store the previous state of modified assets before changes
|
||
2. Implement a proper undo stack with complete state management
|
||
3. Handle more complex undo scenarios (geometry changes, creations, etc.)
|
||
"""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'POST method required'}, status=405)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
action = data.get('action')
|
||
|
||
# Simplified undo - currently only supports undoing delete actions
|
||
if action == 'delete':
|
||
asset_ids = data.get('assets', [])
|
||
# Restore archived assets
|
||
NatureGreenSurface.objects.filter(id__in=asset_ids).update(status='active')
|
||
NatureTree.objects.filter(id__in=asset_ids).update(status='active')
|
||
elif action == 'bulk-edit':
|
||
# For bulk edit, you'd need to restore the old values
|
||
# This would require storing them before the edit
|
||
pass
|
||
|
||
return JsonResponse({'success': True})
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def nature_save_geometries(request):
|
||
"""
|
||
Save geometry modifications for nature assets.
|
||
|
||
Expects a list of modifications with asset_id, asset_type, and geometry.
|
||
Updates the geom field for each asset with the new geometry.
|
||
"""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'POST method required'}, status=405)
|
||
|
||
try:
|
||
data = json.loads(request.body)
|
||
modifications = data.get('modifications', [])
|
||
deletions = data.get('deletions', []) # New: handle deletions for merged assets
|
||
|
||
if not modifications and not deletions:
|
||
return JsonResponse({'error': _('Aucune modification fournie')}, status=400)
|
||
|
||
updated_count = 0
|
||
deleted_count = 0
|
||
id_mapping = {}
|
||
|
||
# Handle deletions first (for merged assets)
|
||
for deletion in deletions:
|
||
asset_id = deletion.get('asset_id')
|
||
asset_type = deletion.get('asset_type')
|
||
|
||
if not all([asset_id, asset_type]):
|
||
continue
|
||
|
||
try:
|
||
if asset_type == 'naturegreensurface':
|
||
asset = NatureGreenSurface.objects.filter(id=asset_id).first()
|
||
if asset:
|
||
asset.delete()
|
||
deleted_count += 1
|
||
elif asset_type == 'naturetree':
|
||
asset = NatureTree.objects.filter(id=asset_id).first()
|
||
if asset:
|
||
asset.delete()
|
||
deleted_count += 1
|
||
except Exception as e:
|
||
print(f"Error deleting asset {asset_id}: {str(e)}")
|
||
continue
|
||
|
||
# Handle modifications
|
||
for mod in modifications:
|
||
asset_id = mod.get('asset_id')
|
||
asset_type = mod.get('asset_type')
|
||
geometry_geojson = mod.get('geometry')
|
||
category_id = mod.get('category_id')
|
||
model_id = mod.get('model_id')
|
||
bulb_types = mod.get('bulb_types') # list or None
|
||
|
||
if not all([asset_id, asset_type, geometry_geojson]):
|
||
continue
|
||
|
||
# Check if this is a new asset
|
||
is_new = str(asset_id).startswith('new_')
|
||
|
||
# Convert GeoJSON to GEOS geometry
|
||
try:
|
||
# The geometry comes as GeoJSON, convert to GEOS
|
||
geom = GEOSGeometry(json.dumps(geometry_geojson), srid=4326)
|
||
# Transform to the storage SRID (3812)
|
||
geom.transform(3812)
|
||
|
||
# Update the appropriate model
|
||
if asset_type == 'naturegreensurface':
|
||
asset = None
|
||
if is_new:
|
||
# Generate a unique code for the new asset
|
||
code = f"SURF-{uuid.uuid4().hex[:8].upper()}"
|
||
asset = NatureGreenSurface(code=code, status='active')
|
||
else:
|
||
asset = NatureGreenSurface.objects.filter(id=asset_id).first()
|
||
|
||
if asset:
|
||
# Force multipolygon for green surfaces
|
||
if geom.geom_type == 'Polygon':
|
||
geom = MultiPolygon(geom)
|
||
asset.geom = geom
|
||
# Update centroid coordinates
|
||
centroid = geom.centroid
|
||
centroid.transform(4326)
|
||
asset.lon = centroid.x
|
||
asset.lat = centroid.y
|
||
geom_4326 = geom.transform(4326, clone=True)
|
||
asset.geojson = geom_4326.geojson
|
||
# Recalculate area for polygons
|
||
if hasattr(geom, 'area'):
|
||
asset.surface_area = geom.area # in square meters
|
||
|
||
# Update attributes if provided
|
||
if category_id is not None:
|
||
if category_id:
|
||
try:
|
||
category = AssetCategory.objects.get(id=category_id)
|
||
asset.category = category
|
||
except AssetCategory.DoesNotExist:
|
||
pass
|
||
else:
|
||
if not is_new: # Don't clear category on creation if not provided (though defaults are null)
|
||
asset.category = None
|
||
|
||
if model_id is not None:
|
||
if model_id:
|
||
try:
|
||
model = NatureAssetModel.objects.get(id=model_id)
|
||
asset.model = model
|
||
# If model has a category and no category was provided, use model's category
|
||
if not asset.category and model.category:
|
||
asset.category = model.category
|
||
except NatureAssetModel.DoesNotExist:
|
||
pass
|
||
else:
|
||
if not is_new:
|
||
asset.model = None
|
||
|
||
if bulb_types is not None:
|
||
asset.bulb_types = bulb_types
|
||
|
||
asset.save()
|
||
updated_count += 1
|
||
if is_new:
|
||
id_mapping[asset_id] = asset.id
|
||
|
||
elif asset_type == 'naturetree':
|
||
asset = NatureTree.objects.filter(id=asset_id).first()
|
||
if asset:
|
||
asset.geom = geom
|
||
# Update coordinates
|
||
centroid = geom.centroid
|
||
centroid.transform(4326)
|
||
asset.lon = centroid.x
|
||
asset.lat = centroid.y
|
||
asset.save()
|
||
updated_count += 1
|
||
|
||
except Exception as e:
|
||
# Log the error but continue with other modifications
|
||
print(f"Error updating geometry for asset {asset_id}: {str(e)}")
|
||
continue
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'updated_count': updated_count,
|
||
'deleted_count': deleted_count,
|
||
'id_mapping': id_mapping
|
||
})
|
||
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
# ============================================================================
|
||
# Green Surfaces Comments API
|
||
# ============================================================================
|
||
|
||
@check_thematic_access('nature')
|
||
def green_surfaces_comments_geojson(request):
|
||
"""
|
||
Retrieve geolocated comments as GeoJSON.
|
||
Optionally filter by geographic bounds.
|
||
"""
|
||
if request.method == 'POST':
|
||
try:
|
||
data = json.loads(request.body)
|
||
filter_geom_data = data.get('filter_geom')
|
||
|
||
comments = GreenSurfacesComment.objects.select_related('created_by').prefetch_related('photos')
|
||
|
||
# Apply geographic filter if provided
|
||
if filter_geom_data:
|
||
filter_geom = GEOSGeometry(json.dumps(filter_geom_data['geometry']))
|
||
filter_geom.srid = 4326
|
||
filter_geom.transform(3812)
|
||
comments = comments.filter(geom__intersects=filter_geom)
|
||
|
||
# Build GeoJSON features
|
||
features = []
|
||
for comment in comments:
|
||
if comment.geom:
|
||
geom_4326 = comment.geom.transform(4326, clone=True)
|
||
feature = {
|
||
'type': 'Feature',
|
||
'id': comment.id,
|
||
'geometry': json.loads(geom_4326.geojson),
|
||
'properties': {
|
||
'id': comment.id,
|
||
'comment': comment.comment,
|
||
'status': comment.status,
|
||
'address': comment.address or '',
|
||
'created_by': comment.created_by.username if comment.created_by else '',
|
||
'created_at': comment.created_at.isoformat(),
|
||
'photo_count': comment.photos.count(),
|
||
}
|
||
}
|
||
features.append(feature)
|
||
|
||
return JsonResponse({
|
||
'type': 'FeatureCollection',
|
||
'features': features
|
||
})
|
||
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def green_surfaces_comment_detail(request, comment_id):
|
||
"""
|
||
Retrieve detailed information about a specific comment including photos.
|
||
"""
|
||
from common.private_files.helpers import build_private_url
|
||
|
||
try:
|
||
comment = GreenSurfacesComment.objects.select_related('created_by').prefetch_related('photos').get(id=comment_id)
|
||
|
||
photos = []
|
||
for photo in comment.photos.all():
|
||
photos.append({
|
||
'id': photo.id,
|
||
'url': build_private_url('assets', 'GreenSurfacesCommentPhoto', photo.pk, 'photo'),
|
||
'heading': photo.heading,
|
||
'order': photo.order,
|
||
'created_at': photo.created_at.isoformat(),
|
||
})
|
||
|
||
# Check if user can edit (owner or staff with edit permission)
|
||
can_edit = comment.created_by == request.user or request.user.is_staff
|
||
|
||
data = {
|
||
'id': comment.id,
|
||
'comment': comment.comment,
|
||
'status': comment.status,
|
||
'latitude': comment.latitude,
|
||
'longitude': comment.longitude,
|
||
'address': comment.address or '',
|
||
'created_by': comment.created_by.username if comment.created_by else '',
|
||
'created_at': comment.created_at.isoformat(),
|
||
'updated_at': comment.updated_at.isoformat(),
|
||
'photos': photos,
|
||
'can_edit': can_edit,
|
||
}
|
||
|
||
return JsonResponse({'success': True, 'data': data})
|
||
|
||
except GreenSurfacesComment.DoesNotExist:
|
||
return JsonResponse({'error': 'Comment not found'}, status=404)
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def green_surfaces_comment_save(request):
|
||
"""
|
||
Create or update a geolocated comment with photos.
|
||
"""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||
|
||
try:
|
||
comment_id = request.POST.get('comment_id')
|
||
comment_text = request.POST.get('comment', '').strip()
|
||
status = request.POST.get('status', 'memo') # Default to 'memo'
|
||
latitude = request.POST.get('latitude')
|
||
longitude = request.POST.get('longitude')
|
||
address = request.POST.get('address', '')
|
||
heading = request.POST.get('heading') # Device compass heading when photos were taken
|
||
photos_to_delete = request.POST.getlist('photos_to_delete') # Photos to delete during edit
|
||
|
||
# Validate status
|
||
valid_statuses = ['memo', 'attention', 'todo', 'ok', 'problem']
|
||
if status not in valid_statuses:
|
||
status = 'memo'
|
||
|
||
# Comment text is now optional
|
||
|
||
if not latitude or not longitude:
|
||
return JsonResponse({'error': 'Location is required'}, status=400)
|
||
|
||
try:
|
||
latitude = float(latitude)
|
||
longitude = float(longitude)
|
||
except ValueError:
|
||
return JsonResponse({'error': 'Invalid coordinates'}, status=400)
|
||
|
||
# Create or update comment
|
||
if comment_id:
|
||
comment = GreenSurfacesComment.objects.get(id=comment_id)
|
||
|
||
# Check permission for editing
|
||
if comment.created_by != request.user and not request.user.is_staff:
|
||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||
|
||
comment.comment = comment_text
|
||
comment.status = status
|
||
comment.latitude = latitude
|
||
comment.longitude = longitude
|
||
comment.address = address
|
||
else:
|
||
comment = GreenSurfacesComment(
|
||
comment=comment_text,
|
||
status=status,
|
||
latitude=latitude,
|
||
longitude=longitude,
|
||
address=address,
|
||
created_by=request.user
|
||
)
|
||
|
||
# Create geometry
|
||
point = Point(longitude, latitude, srid=4326)
|
||
point.transform(3812)
|
||
comment.geom = point
|
||
comment.save()
|
||
|
||
# Delete photos if editing
|
||
if comment_id and photos_to_delete:
|
||
GreenSurfacesCommentPhoto.objects.filter(
|
||
id__in=photos_to_delete,
|
||
comment=comment
|
||
).delete()
|
||
|
||
# Handle photo uploads
|
||
photos = request.FILES.getlist('photos')
|
||
photo_headings = request.POST.getlist('photo_headings') # Individual headings for each photo
|
||
|
||
# Get current max order for photos
|
||
max_order = comment.photos.aggregate(Max('order'))['order__max'] or 0
|
||
|
||
for i, photo_file in enumerate(photos):
|
||
# Use individual photo heading if provided, otherwise use global heading
|
||
photo_heading = None
|
||
if i < len(photo_headings) and photo_headings[i]:
|
||
try:
|
||
photo_heading = float(photo_headings[i])
|
||
except ValueError:
|
||
pass
|
||
elif heading:
|
||
try:
|
||
photo_heading = float(heading)
|
||
except ValueError:
|
||
pass
|
||
|
||
GreenSurfacesCommentPhoto.objects.create(
|
||
comment=comment,
|
||
photo=photo_file,
|
||
heading=photo_heading,
|
||
order=max_order + i + 1
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'comment_id': comment.id,
|
||
'message': _('Commentaire enregistré avec succès')
|
||
})
|
||
|
||
except GreenSurfacesComment.DoesNotExist:
|
||
return JsonResponse({'error': 'Comment not found'}, status=404)
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def green_surfaces_comment_delete(request, comment_id):
|
||
"""
|
||
Delete a geolocated comment and all its associated photos.
|
||
"""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||
|
||
try:
|
||
comment = GreenSurfacesComment.objects.get(id=comment_id)
|
||
|
||
# Check if user owns the comment or has admin rights
|
||
if comment.created_by != request.user and not request.user.is_staff:
|
||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||
|
||
comment.delete() # Cascade delete will remove photos
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': _('Commentaire supprimé avec succès')
|
||
})
|
||
|
||
except GreenSurfacesComment.DoesNotExist:
|
||
return JsonResponse({'error': 'Comment not found'}, status=404)
|
||
|
||
|
||
@check_thematic_access('nature')
|
||
def green_surfaces_comment_add_photos(request, comment_id):
|
||
"""
|
||
Add photos to an existing comment.
|
||
"""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||
|
||
try:
|
||
comment = GreenSurfacesComment.objects.get(id=comment_id)
|
||
|
||
# Check if user owns the comment or has admin rights
|
||
if comment.created_by != request.user and not request.user.is_staff:
|
||
return JsonResponse({'error': 'Permission denied'}, status=403)
|
||
|
||
# Handle photo uploads
|
||
photos = request.FILES.getlist('photos')
|
||
photo_headings = request.POST.getlist('photo_headings')
|
||
|
||
if not photos:
|
||
return JsonResponse({'error': 'No photos provided'}, status=400)
|
||
|
||
# Get current max order
|
||
max_order = comment.photos.aggregate(Max('order'))['order__max'] or 0
|
||
|
||
# Save each photo with heading
|
||
for i, photo in enumerate(photos):
|
||
heading_str = photo_headings[i] if i < len(photo_headings) else ''
|
||
heading = None
|
||
if heading_str and heading_str.strip():
|
||
try:
|
||
heading = float(heading_str)
|
||
except ValueError:
|
||
pass
|
||
|
||
GreenSurfacesCommentPhoto.objects.create(
|
||
comment=comment,
|
||
photo=photo,
|
||
heading=heading,
|
||
order=max_order + i + 1
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': _('Photos ajoutées avec succès'),
|
||
'comment_id': comment.id
|
||
})
|
||
|
||
except GreenSurfacesComment.DoesNotExist:
|
||
return JsonResponse({'error': 'Comment not found'}, status=404)
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500)
|
||
except Exception as e:
|
||
return JsonResponse({'error': str(e)}, status=500) |