# --------------------- # Control Centers Views # --------------------- from django.shortcuts import render, get_object_or_404, redirect from django.http import JsonResponse, Http404 from django.views.decorators.http import require_GET, require_POST from django.contrib.gis.db.models.functions import Transform from django.contrib.gis.geos import GEOSGeometry from django.contrib.contenttypes.models import ContentType from django.db.models import Q, Case, When, IntegerField, CharField from django.db.models.functions import Lower from django.db.models import Func, Prefetch from django.db import connection from django.template.loader import render_to_string from django.utils.translation import get_language, gettext as _ import unicodedata import json from collections import defaultdict from common.utils import check_thematic_access from common.models import Thematic, UserConfig, UserContractAccess, UserThematics from assets.models import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, AssetCategory from assets.forms import ControlCenterDocumentForm from interventions.models import InterventionLocation from interventions.views.drafts import InterventionDraftManager @check_thematic_access('controlcenters') def controlcenters_index(request): thematic = 'controlcenters' lang = get_language() # Get filters from URL statuses = request.GET.getlist('status') selected_is_main_location = request.GET.get('is_main_location') selected_categories = request.GET.getlist('category') selected_models = request.GET.getlist('model') # Get preventive draft ID (if any) for selecting assets preventive_draft_id = request.GET.get('preventive_draft_id') if preventive_draft_id: drafts = InterventionDraftManager(request.session) draft_data = drafts.get(preventive_draft_id) form_data = draft_data.get('form_data', {}) preventive_element_type = form_data.get('type_of_selected_elements') if preventive_element_type == 'assets': selected_assets = draft_data.get('assets', []) or [] else: selected_locations_ids = draft_data.get('locations', []) thematic_choices_qs = ( UserThematics.objects .filter(user_config=request.user.config, can_view_assets=True) .select_related('thematic') .order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}') ) thematics = [tc.thematic for tc in thematic_choices_qs] selected_thematic = { "code": thematic, "name": next((t.name for t in thematics if t.code == thematic), None), "icon": next((t.icon for t in thematics if t.code == thematic), None), } categories = list(AssetCategory.objects.filter(thematic__code = 'controlcenters')) categories.sort(key=lambda m: m.get_name().lower()) category_choices = [(m.id, m.get_name()) for m in categories] models = list(ControlCenterAssetModel.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 = ControlCenterAssetModel.objects.all() for model in models: if model.category: display_name = model.get_name() category_model_map[str(model.category_id)].append({ "key": str(model.id), "label": display_name, }) context = { 'thematics': thematics, 'category_choices': category_choices, 'model_choices': model_choices, 'category_model_map': dict(category_model_map), 'status_choices': ControlCenterGeoAsset._meta.get_field('status').choices, 'selected_thematic': selected_thematic, 'selected_is_main_location': selected_is_main_location, 'selected_categories': selected_categories, 'selected_models': selected_models, 'selected_statuses': statuses, 'preventive_draft_id': preventive_draft_id, 'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '', 'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [], "selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [], "locations_tab_title": _("Localisations"), "locations_assets_title": _("Assets"), } return render(request, "assets/controlcenters_index.html", context) def controlcenters_locations(request): pass def controlcenters_assets(request): pass def ensure_list(value): if isinstance(value, list): return value if value is None: return [] return [value] @check_thematic_access('controlcenters') def controlcenters_locations_geojson(request): if request.method == "POST": try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON"}, status=400) is_main_location = data.get('is_main_location') filter_geom_raw = data.get("filter_geom") if filter_geom_raw: try: filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) filter_geom.transform(3812) except Exception as e: return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) else: filter_geom = None else: is_main_location = request.GET.get('is_main_location') filter_geom = None # --------- Point d'entrée du queryset controlcenters_locations = ControlCenterLocation.objects.select_related('control_center').all() # --------- Filtre par localisation principale if is_main_location: val = is_main_location.lower() if val == 'true': controlcenters_locations = controlcenters_locations.filter(is_main_location=True) elif val == 'false': controlcenters_locations = controlcenters_locations.filter(is_main_location=False) # --------- Filtre géométrique if filter_geom: controlcenters_locations = controlcenters_locations.filter(geom__intersects=filter_geom) # --------- Limitation pour utilisateurs externes user = request.user if user.is_authenticated: try: user_config = user.config # OneToOne UserConfig except UserConfig.DoesNotExist: user_config = None # externe = pas interne ; on tient aussi compte du flag limit_assets_to_contracts is_external = (user_config is None) or (not user_config.is_intern) must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts) if must_limit: # contrats accessibles à l'utilisateur (idéalement avec droit "assets") accessible_contract_ids = UserContractAccess.objects.filter( user_config=user_config, can_view_assets=True # champ prévu dans la table d'accès contrat ).values_list('contract_id', flat=True) # Ne garder que les locations dont le centre de contrôle a un contrat actif lié à ces contrats controlcenters_locations = controlcenters_locations.filter( control_center__controlcenters_contracts__status='active', # ControlCenterContract.status control_center__controlcenters_contracts__contract_id__in=accessible_contract_ids # FK contract ).distinct() else: # Non authentifié : ne rien exposer (option sûre) controlcenters_locations = controlcenters_locations.none() # Recherche plein texte if request.method == 'POST': from assets.views.core import apply_search controlcenters_locations = apply_search(controlcenters_locations, data) # --------- Conversion en GeoJSON def obj_to_feature(obj): geometry = json.loads(obj.geojson) if obj.geojson else None # AbstractLocation.geojson # :contentReference[oaicite:16]{index=16} return { "type": "Feature", "id": obj.id, "geometry": geometry, "properties": { "code": obj.code, "control_center": obj.control_center.get_name() if obj.control_center else "-", "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": "Centre de contrôle", "thematic": "controlcenters", "is_location": True, } } features = [] for location in controlcenters_locations.order_by('control_center__code', 'code'): features.append(obj_to_feature(location)) geojson_data = { "type": "FeatureCollection", "features": features, } return JsonResponse(geojson_data, safe=False) MAX_ASSET_FEATURES = 5000 # limite imposée @check_thematic_access('controlcenters') def controlcenters_assets_geojson(request): if request.method == "POST": try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON"}, status=400) controlcenter_location_ids = data.get('locations_ids', []) statuses = data.get('asset_status', []) begin_date = data.get('asset_begin_date') categories = ensure_list(data.get('category')) models = ensure_list(data.get('model')) # Récupération et parsing de la géométrie filter_geom_raw = data.get("filter_geom") if filter_geom_raw: try: filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) filter_geom.transform(3812) except Exception as e: return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) else: filter_geom = None else: controlcenter_location_id = request.GET.get('location') controlcenter_location_ids = [controlcenter_location_id] if controlcenter_location_id else [] statuses = request.GET.getlist('asset_status') begin_date = request.GET.get('asset_begin_date') categories = request.GET.getlist('category', []) models = request.GET.getlist('model', []) filter_geom = None # Pas de paramètres = pas de carte (comportement existant conservé) _search_q = (data.get('q') or '').strip() if request.method == 'POST' else '' if not request.GET.get('unified') and not controlcenter_location_ids and not categories and not models and not filter_geom and not _search_q: geojson_data = {"type": "FeatureCollection", "features": []} return JsonResponse(geojson_data) from assets.views.core import get_element_type_label def build_geometry_and_coords(obj): geometry = None if obj.geojson: try: geometry = json.loads(obj.geojson) except (TypeError, ValueError, json.JSONDecodeError): geometry = None lon = obj.lon lat = obj.lat if lon is None or lat is None: location = getattr(obj, 'location', None) if location is not None: if lon is None: lon = getattr(location, 'lon', None) if lat is None: lat = getattr(location, 'lat', None) if geometry is None and lon is not None and lat is not None: geometry = { "type": "Point", "coordinates": [lon, lat], } return geometry, lon, lat def obj_to_feature(obj, asset_type=None): geometry, lon, lat = build_geometry_and_coords(obj) return { "type": "Feature", "id": obj.id, "geometry": geometry, "properties": { "code": obj.code, "name": obj.get_name(), "category": obj.category.get_name() if obj.category else None, "model": obj.model.get_name() if obj.model else None, "status": obj.status, "validation_status": obj.validation_status, "lon": lon, "lat": lat, "asset_type": asset_type, "detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None, "element_type": get_element_type_label(asset_type or ''), "thematic": "controlcenters", "is_location": False, }, } def add_assets_to_features(queryset, asset_type, features, feature_count): for obj in queryset: if feature_count >= MAX_ASSET_FEATURES: return feature_count, True # early stop features.append(obj_to_feature(obj, asset_type=asset_type)) feature_count += 1 return feature_count, False # --------- Query de base + optimisations controlcenter_asset_qs = ( ControlCenterGeoAsset.objects .select_related('location', 'location__control_center', 'category', 'model') .all() ) # --------- Filtres métier existants if controlcenter_location_ids: controlcenter_asset_qs = controlcenter_asset_qs.filter(location__id__in=controlcenter_location_ids) if models: controlcenter_asset_qs = controlcenter_asset_qs.filter(model__id__in=models) if categories: controlcenter_asset_qs = controlcenter_asset_qs.filter(category__id__in=categories) if filter_geom: controlcenter_asset_qs = controlcenter_asset_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, location__geom__intersects=filter_geom) ) # Recherche plein texte if request.method == 'POST': from assets.views.core import apply_search controlcenter_asset_qs = apply_search(controlcenter_asset_qs, data) # --------- Limitation pour utilisateurs externes user = request.user if user.is_authenticated: try: user_config = user.config # OneToOne except UserConfig.DoesNotExist: user_config = None is_external = (user_config is None) or (not user_config.is_intern) must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts) if must_limit: accessible_contract_ids = UserContractAccess.objects.filter( user_config=user_config, can_view_assets=True ).values_list('contract_id', flat=True) # Ne garder que les assets qui ont un contrat actif # auquel l'utilisateur a accès controlcenter_asset_qs = controlcenter_asset_qs.filter( controlcenters_assets_contracts__status='active', controlcenters_assets_contracts__contract_id__in=accessible_contract_ids ).distinct() else: # Non authentifié : ne rien exposer (plus sûr) controlcenter_asset_qs = controlcenter_asset_qs.none() # --------- Cap de sécurité controlcenter_asset_qs = controlcenter_asset_qs[0:MAX_ASSET_FEATURES+1] if len(controlcenter_asset_qs) >= MAX_ASSET_FEATURES: return JsonResponse( { "error": _("Trop d'objets à afficher (> %(max)d)") % {"max": MAX_ASSET_FEATURES} }, status=413, ) # --------- Construction GeoJSON features = [] feature_count = 0 for qs, asset_type in [ (controlcenter_asset_qs, "controlcentergeoasset"), ]: feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count) if limit_reached: return JsonResponse({ "error": _("Trop d'objets à afficher (> %(max)d). Limitez votre sélection.") % {"max": MAX_ASSET_FEATURES} }, status=413) features = sorted(features, key=lambda f: f["properties"]["code"]) geojson_data = { "type": "FeatureCollection", "features": features, } return JsonResponse(geojson_data, safe=False) @check_thematic_access('controlcenters') def controlcenters_locations_detail(request, location_id): # Préfetch (réduction N+1) : ASSETS + enfants (2 niveaux) location = ( ControlCenterLocation.objects .prefetch_related( Prefetch( 'assets', queryset=ControlCenterGeoAsset.objects.filter(status='active').order_by('code') ), 'child_locations__assets', 'child_locations__child_locations', ) .select_related('control_center') .get(pk=location_id) ) # 1) Construire la liste des ids (location + enfants) def collect_ids(loc): ids = [loc.id] for child in loc.child_locations.all().order_by('code'): ids.extend(collect_ids(child)) return ids all_loc_ids = collect_ids(location) # 2) Récupérer en bloc tous les liens GFK pour ces ids ct_sl = ContentType.objects.get_for_model(ControlCenterLocation) links = ( InterventionLocation.objects .filter(content_type=ct_sl, object_id__in=all_loc_ids) .select_related('intervention') ) # 3) Indexer par location_id → [interventions...] by_loc = defaultdict(list) for link in links: by_loc[link.object_id].append(link.intervention) # 4) Arbre def build_tree(loc): return { 'loc': loc, 'assets': list(loc.assets.all().order_by('code')), 'interventions': by_loc.get(loc.id, []), 'children': [build_tree(child) for child in loc.child_locations.all().order_by('code')], } tree = build_tree(location) # 5) Aplatir les interventions de l’arbre (sans doublons) + tri global desc. par code def flatten_interventions(node): seen = set() out = [] def walk(n): for itv in n['interventions']: if itv.pk not in seen: seen.add(itv.pk) out.append(itv) for c in n['children']: walk(c) walk(node) return out interventions = sorted( flatten_interventions(tree), key=lambda itv: itv.creation_time, reverse=True ) # 6) Assets + documents assets = location.get_active_assets() other_docs = location.control_center.documents.filter(document_type='other', status='active').order_by('-id') # 7) Géométrie 4326 (main_location fallback) if location.geom: location_geom_4326 = location.geom.transform(4326, clone=True) elif location.control_center.main_location and location.control_center.main_location.geom: location_geom_4326 = location.control_center.main_location.geom.transform(4326, clone=True) else: location_geom_4326 = None geom = json.loads(location_geom_4326.geojson) if location_geom_4326 else None return render(request, "assets/controlcenters_location_detail.html", { "location": location, "interventions": interventions, # toutes les interventions de l’arbre "assets": assets, "other_documents": other_docs, "add_document_form": ControlCenterDocumentForm(), "geom": geom, "location_tree": tree, # contient interventions par nœud }) @check_thematic_access('controlcenters') def controlcenters_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 '%(model)s' inconnu.") % {"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"controlcenters/detail_{asset_model}.html" # Si le template spécifique n’existe pas, tu peux fallback vers un template générique : from django.template.loader import select_template try: template = select_template([template_name, "assets/detail_generic.html"]) except: raise Http404(_("Aucun template trouvé pour ce type d’asset.")) # Get edit context from assets.views.core import get_asset_edit_context context = { "asset": asset, "content_type": content_type, } context.update(get_asset_edit_context(request, asset, content_type)) # Override thematic_code for controlcenters context['thematic_code'] = 'controlcenters' return render(request, template.template.name, context) # Appelle la fonction PostgreSQL unaccent(...) class Unaccent(Func): function = 'public.unaccent_immutable' output_field = CharField() def strip_accents_lower(s: str) -> str: if not s: return '' s = ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') return s.lower() def get_descendant_location_ids(root_location_id: int) -> list[int]: """ Récupère l'id de la localisation racine + tous ses descendants (FK parent_location) via un CTE récursif. Nécessite PostgreSQL. """ # Récupérer dynamiquement le nom de la table depuis le modèle Django table_name = ControlCenterLocation._meta.db_table with connection.cursor() as cur: cur.execute(f""" WITH RECURSIVE loc_tree AS ( SELECT id FROM {table_name} WHERE id = %s UNION ALL SELECT sl.id FROM {table_name} sl JOIN loc_tree lt ON sl.parent_location_id = lt.id ) SELECT id FROM loc_tree; """, [root_location_id]) rows = cur.fetchall() return [r[0] for r in rows] @require_GET def controlcenters_assets_autocomplete(request): raw_query_original = (request.GET.get('q') or '') raw_query = raw_query_original.strip() # CT ids (calculés une fois) equipment_ct_id = ContentType.objects.get_for_model(ControlCenterGeoAsset).id location_ct_id = ContentType.objects.get_for_model(ControlCenterLocation).id qn = strip_accents_lower(raw_query) has_search_term = len(qn) > 0 # filtres optionnels location_id = request.GET.get('location_id') controlcenter_id = request.GET.get('controlcenter_id') category_id = request.GET.get('asset_category') try: category_id = int(category_id) except (TypeError, ValueError): category_id = None # si location fournie, calcule l'ensemble {location + descendants} location_ids = None if location_id: try: location_ids = get_descendant_location_ids(int(location_id)) except Exception: # en cas d'ID invalide ou autre, on ignore le filtre location_ids = None # Base queryset accent-insensible + actifs uniquement qs = ( ControlCenterGeoAsset.objects .annotate( code_u=Lower(Unaccent('code')), name_fr_u=Lower(Unaccent('name_fr')), name_nl_u=Lower(Unaccent('name_nl')), ) ) # Si un terme de recherche est fourni, filtrer dessus if has_search_term: qs = qs.filter( Q(code_u__contains=qn) | Q(name_fr_u__contains=qn) | Q(name_nl_u__contains=qn) ) qs = qs.exclude(status='removed') # <-- exclure les supprimés qs = qs.select_related( 'location', 'location__control_center', 'control_center', 'control_center__main_location', 'model', 'category', ) if location_ids: qs = qs.filter(location_id__in=location_ids) if controlcenter_id: qs = qs.filter(control_center_id=controlcenter_id) if category_id is not None: qs = qs.filter( Q(category_id=category_id) | Q(category__isnull=True, model__category_id=category_id) ) # Priorité : exact > code startswith > nom startswith > contient if has_search_term: priority = Case( When(code_u=qn, then=0), When(code_u__startswith=qn, then=1), When(name_fr_u__startswith=qn, then=2), When(name_nl_u__startswith=qn, then=3), When(code_u__contains=qn, then=4), When(name_fr_u__contains=qn, then=5), When(name_nl_u__contains=qn, then=6), default=7, output_field=IntegerField(), ) qs = qs.annotate(_prio=priority).order_by('_prio', 'code')[:100] else: # Sans terme de recherche, tri par code uniquement qs = qs.order_by('code')[:100] data = [] for a in qs: # equipment_type depuis model/category equipment_type = None if getattr(a, 'model', None): equipment_type = getattr(a.model, 'name', None) or getattr(a.model, 'code', None) if not equipment_type and getattr(a, 'category', None): equipment_type = getattr(a.category, 'name', None) # coordonnées: asset > geom(4326) > location > controlcenter.main_location lon = a.lon lat = a.lat if (lon is None or lat is None) and getattr(a, 'geom', None): try: g = a.geom if g.srid and g.srid != 4326: g = a.geom.transform(4326, clone=True) coords = getattr(g, 'coords', None) if coords: if lon is None: lon = coords[0] if lat is None: lat = coords[1] except Exception: pass if (lon is None or lat is None) and getattr(a, 'location', None): if lon is None and getattr(a.location, 'lon', None) is not None: lon = a.location.lon if lat is None and getattr(a.location, 'lat', None) is not None: lat = a.location.lat main_loc = (a.control_center.main_location if getattr(a, 'control_center', None) and getattr(a.control_center, 'main_location', None) else (a.location.control_center.main_location if getattr(a, 'location', None) and getattr(a.location, 'control_center', None) and getattr(a.location.control_center, 'main_location', None) else None)) if (lon is None or lat is None) and main_loc: if lon is None: lon = getattr(main_loc, 'lon', None) if lat is None: lat = getattr(main_loc, 'lat', None) equipment_label = f"{a.get_name()} ({a.code})" if a.code else a.get_name() label = f"{a.code} - {equipment_label}" if a.code else equipment_label location_label = ( f"{a.location.get_name()} ({a.location.code})" if getattr(a, "location", None) else None ) struct = a.control_center or (a.location.control_center if getattr(a, "location", None) else None) controlcenter_id_val = struct.id if struct else None controlcenter_label = (f"{struct.get_name()} ({struct.code})" if struct else None) data.append({ "id": a.id, "label": label, "equipment_id": a.id, "equipment_label": equipment_label, "equipment_type": equipment_type, "equipment_ct": equipment_ct_id, "location_id": a.location.id if getattr(a, "location", None) else None, "location_label": location_label, "location_ct": location_ct_id, "parent_ref_id": controlcenter_id_val, "parent_ref_label": controlcenter_label, "lon": lon, "lat": lat, }) return JsonResponse(data, safe=False) @check_thematic_access('controlcenters', edit_permission_required=True) @require_POST def controlcenters_add_document(request, controlcenter_id): controlcenter = get_object_or_404(ControlCenter, pk=controlcenter_id) count = int(request.POST.get('file_count', 0)) files = request.FILES.getlist('files') for i in range(count): file = files[i] document_type = request.POST.get(f'document_type_{i}', 'other') ControlCenterDocument.objects.create( control_center=controlcenter, file=file, document_type=document_type, uploaded_by = request.user ) return redirect('assets:controlcenters_locations_detail', location_id=controlcenter.main_location.id) @check_thematic_access('controlcenters', edit_permission_required=True) @require_POST def controlcenters_delete_document(request, document_id): document = get_object_or_404(ControlCenterDocument, id=document_id) controlcenter_location_id = document.control_center.main_location.id # pour rediriger vers la bonne page # Suppression du fichier si nécessaire (en plus de l'objet DB) if document.file: document.file.delete(save=False) if document.thumbnail: document.thumbnail.delete(save=False) document.delete() #messages.success(request, "Le document a été supprimé avec succès.") return redirect('assets:controlcenters_locations_detail', location_id=controlcenter_location_id)