from django.shortcuts import render, get_object_or_404 from django.http import JsonResponse, Http404 from django.views.decorators.http import require_POST from django.db import transaction from django.utils import timezone from django.utils.dateparse import parse_datetime from django.contrib.gis.geos import GEOSGeometry, Point from django.contrib.gis.db.models.functions import Distance from django.contrib.contenttypes.models import ContentType from django.utils.translation import get_language, gettext as _ from django.db.models import F, Q from django.db.models.functions import Lower from django.urls import reverse import json import unicodedata from collections import defaultdict from common.utils import check_thematic_access from common.models import UserThematics from assets.models import CleanLocation, CleanLitterBin, CleanLitterBinModel, CleanLitterBinObservation, CleanGlassContainer, CleanGlassContainerModel, AssetCategory from assets.permissions import can_edit_asset from interventions.models import InterventionLocation from interventions.views.drafts import InterventionDraftManager MAX_ASSET_FEATURES = 5000 # Ordered list of (keyword_variants, canonical_color_name). # The canonical name must match the keys used in the front-end 'match' expression. _COLOR_KEYWORDS = [ (['anthracite'], 'gris anthracite'), (['bordeaux'], 'bordeaux'), (['violet', 'violette'], 'violet'), (['beige'], 'beige'), (['marron', 'brun', 'brune'], 'marron'), (['orange'], 'orange'), (['jaune'], 'jaune'), (['rouge'], 'rouge'), (['bleu', 'bleue'], 'bleu'), (['vert', 'verte'], 'vert'), (['noir', 'noire'], 'noir'), (['blanc', 'blanche'], 'blanc'), (['gris', 'grise'], 'gris'), ] def _color_from_text(text: str | None) -> str: """ Return a canonical lowercase colour name by scanning *text* for colour keywords (word-boundary aware). Returns '' if nothing is recognised. """ import re import unicodedata if not text: return '' # Normalise to ASCII-folded lowercase so accents don't matter normalised = unicodedata.normalize('NFD', text.lower()) normalised = ''.join(c for c in normalised if unicodedata.category(c) != 'Mn') for variants, canonical in _COLOR_KEYWORDS: for kw in variants: if re.search(r'(? 0 location_id = request.GET.get('location_id') category_id = request.GET.get('asset_category') try: category_id = int(category_id) except (TypeError, ValueError): category_id = None def base_qs(model_class): qs = model_class.objects.annotate( code_u=Lower('code'), name_fr_u=Lower('name_fr'), name_nl_u=Lower('name_nl'), ).filter(status='active') if location_id: qs = qs.filter(location_id=location_id) if category_id is not None: qs = qs.filter( Q(category_id=category_id) | Q(category__isnull=True, model__category_id=category_id) ) if has_search_term: qs = qs.filter( Q(code_u__contains=qn) | Q(name_fr_u__contains=qn) | Q(name_nl_u__contains=qn) ) return qs.select_related('location', 'model')[:100] items = [] for model_class in (CleanLitterBin, CleanGlassContainer): for a in base_qs(model_class): code_u = getattr(a, 'code_u', '') or '' name_fr_u = getattr(a, 'name_fr_u', '') or '' name_nl_u = getattr(a, 'name_nl_u', '') or '' if has_search_term: if code_u == qn: prio = 0 elif code_u.startswith(qn): prio = 1 elif name_fr_u.startswith(qn) or name_nl_u.startswith(qn): prio = 2 elif qn in code_u or qn in name_fr_u or qn in name_nl_u: prio = 4 else: prio = 7 else: prio = 5 location = a.location label = " - ".join(p for p in [a.code, a.get_name()] if p) 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": a.code or "", "id": a.id, "label": label, "equipment_id": a.id, "equipment_label": label, "equipment_type": a.model.get_name() if a.model else "", "equipment_ct": equipment_ct_id, "location_id": location.id if location else None, "location_label": f"{location.code} - {location.get_name()}" if location else None, "location_ct": location_ct_id, "lon": a.lon, "lat": a.lat, }) items.sort(key=lambda x: (x["prio"], x["code"])) return JsonResponse(items[:100], safe=False) # --------------------- # Street detail # --------------------- @check_thematic_access('clean') def clean_location_detail(request, location_id): location = get_object_or_404(CleanLocation, pk=location_id) ct = ContentType.objects.get_for_model(CleanLocation) interventions = [ obj.intervention for obj in InterventionLocation.objects.filter(content_type=ct, object_id=location.id).select_related('intervention').order_by('-intervention__creation_time') ] assets = location.get_active_assets() all_assets = location.get_all_assets() geom = None if location.geom: geom = json.loads(location.geom.transform(4326, clone=True).geojson) return render(request, "assets/clean_location_detail.html", { "location": location, "interventions": interventions, "assets": assets, "all_assets": all_assets, "geom": geom, }) # --------------------- # Index # --------------------- @check_thematic_access('clean') def clean_index(request): thematic = 'clean' lang = get_language() statuses = request.GET.getlist('status') selected_categories = request.GET.getlist('category') selected_models = request.GET.getlist('model') preventive_draft_id = request.GET.get('preventive_draft_id') preventive_element_type = '' selected_assets = [] selected_locations_ids = [] 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 = sorted( AssetCategory.objects.filter(thematic__code='clean'), key=lambda m: m.get_name().lower() ) category_choices = [(m.id, m.get_name()) for m in categories] models = sorted( list(CleanLitterBinModel.objects.all()) + list(CleanGlassContainerModel.objects.all()), key=lambda m: (m.get_name() or '').lower() ) model_choices = [(m.id, m.get_name()) for m in models] category_model_map = defaultdict(list) for model in CleanLitterBinModel.objects.all(): if model.category_id: category_model_map[str(model.category_id)].append({"key": str(model.id), "label": model.get_name()}) for model in CleanGlassContainerModel.objects.all(): if model.category_id: category_model_map[str(model.category_id)].append({"key": str(model.id), "label": model.get_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, 'preventive_draft_id': preventive_draft_id, 'preventive_element_type': preventive_element_type, 'selected_locations_ids': selected_locations_ids, 'selected_assets_json': json.dumps(selected_assets), 'locations_tab_title': _("Locations"), 'locations_assets_title': _("Assets"), } return render(request, "assets/clean_index.html", context) # --------------------- # Locations GeoJSON # --------------------- @check_thematic_access('clean') def clean_locations_geojson(request): if request.method == "POST": try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON"}, status=400) 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: filter_geom = None qs = CleanLocation.objects.all() if filter_geom: qs = qs.filter(geom__intersects=filter_geom) # Recherche plein texte if request.method == 'POST': from assets.views.core import apply_search qs = apply_search(qs, data) 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(), "lon": obj.lon, "lat": obj.lat, "detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None, "element_type": "Zone propreté", "thematic": "clean", "is_location": True, } } features = [obj_to_feature(location) for location in qs] return JsonResponse({"type": "FeatureCollection", "features": features}, safe=False) # --------------------- # Assets GeoJSON # --------------------- @check_thematic_access('clean') def clean_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) locations_ids = data.get('locations_ids', []) statuses = data.get('asset_status', []) categories = data.get('category') or [] models = data.get('model') or [] if isinstance(categories, str): categories = [categories] if isinstance(models, str): models = [models] 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: location_id = request.GET.get('location') locations_ids = [location_id] if location_id else [] statuses = request.GET.getlist('asset_status') categories = request.GET.getlist('category', []) models = request.GET.getlist('model', []) filter_geom = None _search_q = (data.get('q') or '').strip() if request.method == 'POST' else '' if not request.GET.get('unified') and not locations_ids and not categories and not models and not filter_geom and not _search_q: return JsonResponse({"type": "FeatureCollection", "features": []}) def build_qs(model_class, asset_type_str): qs = model_class.objects.all() if locations_ids: qs = qs.filter(location__id__in=locations_ids) if categories: qs = qs.filter(category__id__in=categories) if models: qs = qs.filter(model__id__in=models) if filter_geom: filter_geom_3812 = filter_geom.transform(3812, clone=True) qs = qs.filter( Q(geom__intersects=filter_geom_3812) | Q(geom__isnull=True, location__geom__intersects=filter_geom_3812) ) if statuses: qs = qs.filter(status__in=statuses) else: qs = qs.exclude(status='removed') # Recherche plein texte if request.method == 'POST': from assets.views.core import apply_search qs = apply_search(qs, data) qs = ( qs.annotate( name=F(f'name_{lang}'), category_name=F(f'category__name_{lang}'), model_name=F(f'model__name_{lang}'), location_lon=F('location__lon'), location_lat=F('location__lat'), ) .values('id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat', 'geojson', 'category_name', 'model_name', 'category_id', 'model_id', 'color', 'location_lon', 'location_lat') .order_by('code') )[:MAX_ASSET_FEATURES + 1] return qs from assets.views.core import get_element_type_label def obj_to_feature(obj, asset_type): lon = obj['lon'] lat = obj['lat'] if lon is None: lon = obj.get('location_lon') if lat is None: lat = obj.get('location_lat') if obj['geojson']: geometry = json.loads(obj['geojson']) elif lon is not None and lat is not None: geometry = {"type": "Point", "coordinates": [lon, lat]} else: geometry = None detail_url = reverse('assets:clean_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) 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, "color": obj.get('color') or '', "display_color": _color_from_text(obj.get('color')) or _color_from_text(obj.get('model_name')), "asset_type": asset_type, "detail_url": detail_url, "element_type": get_element_type_label(asset_type), "thematic": "clean", "is_location": False, } } litter_bin_qs = build_qs(CleanLitterBin, 'cleanlitterbin') glass_container_qs = build_qs(CleanGlassContainer, 'cleanglasscontainer') total = litter_bin_qs.count() + glass_container_qs.count() if total > MAX_ASSET_FEATURES: return JsonResponse( {"error": f"Too many objects to display (> {MAX_ASSET_FEATURES}). Please narrow your selection."}, status=413 ) features = [] for obj in litter_bin_qs: features.append(obj_to_feature(obj, 'cleanlitterbin')) for obj in glass_container_qs: features.append(obj_to_feature(obj, 'cleanglasscontainer')) return JsonResponse({"type": "FeatureCollection", "features": features}, safe=False) # --------------------- # Asset detail # --------------------- @check_thematic_access('clean') def clean_assets_detail(request, asset_model, asset_id): try: content_type = ContentType.objects.get(model=asset_model) model_class = content_type.model_class() except ContentType.DoesNotExist: raise Http404(_("Unknown model: %(asset_model)s") % {'asset_model': asset_model}) asset = get_object_or_404(model_class, id=asset_id) from django.template.loader import select_template template = select_template([ f"assets/{asset_model}_detail.html", "assets/detail_generic.html", ]) 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)) context['thematic_code'] = 'clean' return render(request, template.template.name, context) # --------------------- # Observations API # --------------------- @require_POST @check_thematic_access('clean') def update_litter_bin_observations(request, asset_id): """ Create / update / delete observations for a litter bin. Expects JSON: { "observations": [ {id?, ...fields}, ... ] } Observations currently in DB but absent from the payload are deleted. """ asset = get_object_or_404(CleanLitterBin, pk=asset_id) if not can_edit_asset(request.user, asset): return JsonResponse({'success': False, 'error': _( "Vous n'avez pas la permission de modifier cet asset" )}, status=403) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'error': _("Format JSON invalide")}, status=400) observations_data = data.get('observations', []) BOOL_FIELDS = [ 'sol_pente_trop_forte', 'sol_instable', 'sol_reflechissant', 'sol_glissant', 'sol_fentes', 'sol_entrave', 'sol_dalles_podo', 'acc_approche_2m', 'acc_voirie_a_traverser', 'acc_poubelle_isolee', 'acc_gene_flux_routier', 'acc_gene_flux_pieton', 'acc_chemin_logique', 'acc_contraste', 'acc_indications_tri', ] FLOAT_FIELDS = ['sol_pente_transversale', 'sol_pente_longitudinale'] TEXT_FIELDS = [ 'observer_note', 'sol_remarque', 'acc_type_quartier', 'acc_densite_flux_pieton', 'acc_agent_vehicule', 'acc_agent_pied', 'acc_point_interet_autre', 'acc_point_interet_remarque', 'acc_remarque', ] JSON_FIELDS = ['acc_point_interet', 'acc_pertinence_placement', 'acc_entrave'] with transaction.atomic(): submitted_ids = [] for obs_data in observations_data: obs_id = obs_data.get('id') if obs_id: try: obs = CleanLitterBinObservation.objects.get(pk=obs_id, litter_bin=asset) except CleanLitterBinObservation.DoesNotExist: obs = CleanLitterBinObservation(litter_bin=asset) else: obs = CleanLitterBinObservation(litter_bin=asset) # datetime dt_str = obs_data.get('observation_datetime') or '' if dt_str: dt = parse_datetime(dt_str) if dt and timezone.is_naive(dt): dt = timezone.make_aware(dt) obs.observation_datetime = dt else: obs.observation_datetime = None # boolean fields for f in BOOL_FIELDS: if f in obs_data: setattr(obs, f, bool(obs_data[f])) # float fields for f in FLOAT_FIELDS: val = obs_data.get(f, '') if val == '' or val is None: setattr(obs, f, None) else: try: setattr(obs, f, float(val)) except (ValueError, TypeError): setattr(obs, f, None) # text / choice fields for f in TEXT_FIELDS: val = obs_data.get(f, '') setattr(obs, f, str(val).strip() if val else None) # JSON list fields for f in JSON_FIELDS: if f in obs_data: raw = obs_data[f] setattr(obs, f, raw if isinstance(raw, list) else []) obs.save() submitted_ids.append(obs.pk) # Delete any observation not in the submitted payload CleanLitterBinObservation.objects.filter( litter_bin=asset ).exclude(pk__in=submitted_ids).delete() return JsonResponse({'success': True}) # --------------------- # Get nearest location # --------------------- def get_nearest_clean_location(request): try: lon = float(request.GET.get('lon')) lat = float(request.GET.get('lat')) except (TypeError, ValueError): return JsonResponse({"error": "Invalid coordinates"}, status=400) user_point = Point(lon, lat, srid=4326) user_point.transform(3812) nearest = ( CleanLocation.objects .filter(geom__isnull=False) .annotate(distance=Distance('geom', user_point)) .filter(distance__lte=50) # within 50 metres .order_by('distance') .first() ) if nearest: location_ct_id = ContentType.objects.get_for_model(nearest).id 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": _("No location found nearby")})