from django.shortcuts import render, get_object_or_404 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 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 InterventionLocation from interventions.views.drafts import InterventionDraftManager @check_thematic_access('nature') def nature_index(request): thematic = 'nature' lang = get_language() # Get filters from URL statuses = request.GET.getlist('status') highway = request.GET.get('highway') == "1" selected_categories = request.GET.getlist('category') selected_models = request.GET.getlist('model') selected_location_type = request.GET.get('location_type', '') # 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 = 'nature')) categories.sort(key=lambda m: m.get_name().lower()) category_choices = [(m.id, m.get_name()) for m in categories] models = list(NatureAssetModel.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 = NatureAssetModel.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), 'selected_thematic': selected_thematic, 'selected_statuses': statuses, 'selected_categories': selected_categories, 'selected_models': selected_models, 'highway': highway, 'selected_location_type': selected_location_type, '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': _("Voiries"), 'locations_assets_title': "Assets", } return render(request, "assets/nature_index.html", context) def nature_locations(request): pass @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) interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=content_type, object_id=section.id).select_related('intervention').order_by('-intervention__creation_time')] context = { 'section': section, 'interventions': interventions, } 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 include_naturelocation = location_type in ('', 'naturelocation') nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none() 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): 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(_("Modèle %(asset_model)s inconnu.") % (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 = 5000 # 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', '') # 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', '') 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: # 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 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, "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": get_element_type_label(asset_type or ''), "thematic": "nature", "is_location": False, "structure_type": obj.get('structure_type', ''), } } 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() ) 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 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' ) .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) # 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 .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} 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.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)