import json import re from datetime import datetime from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.http import Http404 from django.shortcuts import redirect, render, get_object_or_404 from django.template.loader import select_template from django.urls import reverse from django.utils.translation import gettext as _ from django.contrib.auth.decorators import login_required from common.models import UserConfig, Thematic from common.utils import check_thematic_access from assets.models import AssetCategory, ASSET_STATUS_CHOICES from assets.models.core import ASSET_VALIDATION_STATUS_CHOICES from assets.permissions import ( get_allowed_update_fields_for_asset, get_allowed_actions_for_asset, ) def get_connection_lines_geojson(asset): """ Returns a GeoJSON FeatureCollection containing LineStrings representing only electrical connections. For a TrafficLightRadar, this means connections to power_publiclighting_pole and/or power_trafficlights_pole when they are set and have geoms. """ import json from django.contrib.gis.geos import LineString from assets.models.trafficlights import TrafficLightRadar if not isinstance(asset, TrafficLightRadar): return None if not asset.geom: return None features = [] # We check both power sources power_sources = [ getattr(asset, 'power_publiclighting_pole', None), getattr(asset, 'power_trafficlights_pole', None) ] for ps in power_sources: if ps and getattr(ps, 'geom', None): p1 = asset.geom.clone() p1.transform(4326) p2 = ps.geom.clone() p2.transform(4326) # Avoid zero-length lines if by any chance they are at the exact same point if p1.distance(p2) >= 0.00001: line = LineString(p1, p2) features.append({ "type": "Feature", "geometry": json.loads(line.json), "properties": { "parent_code": ps.code, "relation_type": "electrical_connection" } }) if not features: return None return { "type": "FeatureCollection", "features": features } def get_asset_edit_context(request, asset, content_type): """ Get context variables for asset editing in templates. Includes the complete replacement chain based on the replaced_by field. """ allowed_update_fields = get_allowed_update_fields_for_asset(request.user, asset) allowed_actions = get_allowed_actions_for_asset(request.user, asset) # Format coordinates: convert to float then to string with proper decimal separator (dot) lon_display = None lat_display = None if hasattr(asset, 'lon') and asset.lon is not None: try: # Convert to float to normalize, then back to string lon_value = float(str(asset.lon).replace(',', '.')) lon_display = str(lon_value) except (ValueError, TypeError): lon_display = None if hasattr(asset, 'lat') and asset.lat is not None: try: # Convert to float to normalize, then back to string lat_value = float(str(asset.lat).replace(',', '.')) lat_display = str(lat_value) except (ValueError, TypeError): lat_display = None # Get status choices status_choices = list(ASSET_STATUS_CHOICES.items()) validation_status_choices = list(ASSET_VALIDATION_STATUS_CHOICES.items()) # Get categories for the same thematic # Priority: asset.category.thematic > asset.model.category.thematic > URL path categories = [] _cat_thematic = None if hasattr(asset, 'category') and asset.category and asset.category.thematic: _cat_thematic = asset.category.thematic elif hasattr(asset, 'model') and asset.model and getattr(asset.model, 'category', None) and asset.model.category.thematic: _cat_thematic = asset.model.category.thematic else: # Fallback: extract thematic code from URL path (/assets//...) url_match = re.match(r'^/assets/([^/]+)/', request.path) if url_match: try: _cat_thematic = Thematic.objects.get(code=url_match.group(1)) except Thematic.DoesNotExist: pass if _cat_thematic: from django.contrib.contenttypes.models import ContentType from django.db.models import Q asset_content_type = ContentType.objects.get_for_model(asset) categories = list(AssetCategory.objects.filter( thematic=_cat_thematic ).filter( Q(allowed_models=asset_content_type) | Q(id=getattr(asset, 'category_id', None)) ).distinct().order_by('name_fr')) # Get asset models for replacement asset_models = [] if hasattr(asset, 'model') and asset.model: model_field = asset._meta.get_field('model') related_model = model_field.related_model asset_models = list(related_model.objects.all().order_by('code')) # Get the original asset (first in the chain) and the complete replacement chain asset_replacements = [] asset_base_code = None if hasattr(asset, 'get_original_asset'): try: original_asset = asset.get_original_asset() asset_replacements = original_asset.get_replacement_chain() asset_base_code = getattr(original_asset, 'code', None) except Exception: asset_replacements = [asset] asset_base_code = getattr(asset, 'code', None) else: asset_replacements = [asset] asset_base_code = getattr(asset, 'code', None) # Get geometry JSON for JavaScript (transform to WGS84 for MapLibre) asset_geometry_json = None if hasattr(asset, 'geom') and asset.geom: try: geom_wgs84 = asset.geom.clone() geom_wgs84.transform(4326) asset_geometry_json = geom_wgs84.json except Exception: pass # Get thematic code for URL construction # Priority: asset.category > asset.model.category > None thematic_code = None thematic = None _thematic_source = None if hasattr(asset, 'category') and asset.category and asset.category.thematic: _thematic_source = asset.category.thematic elif hasattr(asset, 'model') and asset.model and getattr(asset.model, 'category', None) and asset.model.category.thematic: _thematic_source = asset.model.category.thematic if _thematic_source: thematic_code = _thematic_source.code thematic = _thematic_source # Verbose name of the concrete model (e.g. "Traffic light pole") asset_verbose_name = str(asset._meta.verbose_name).capitalize() # Is the current user an admin? (used to display the permissions panel) is_admin = False try: from common.models import UserConfig as _UC is_admin = _UC.objects.get(user=request.user).roles.filter(name='admin').exists() except Exception: pass return { 'allowed_update_fields': allowed_update_fields, 'allowed_actions': allowed_actions, 'status_choices': status_choices, 'validation_status_choices': validation_status_choices, 'categories': categories, 'asset_models': asset_models, 'asset_model': content_type.model, 'asset_geometry_json': asset_geometry_json, 'thematic_code': thematic_code, 'thematic': thematic, 'asset_replacements': asset_replacements, 'asset_base_code': asset_base_code, 'lon_display': lon_display, 'lat_display': lat_display, 'asset_verbose_name': asset_verbose_name, 'is_admin': is_admin, 'connection_lines_geojson': get_connection_lines_geojson(asset), } def index(request): user_config = UserConfig.objects.filter(user=request.user).first() default_thematic = user_config.default_thematic.code if user_config and user_config.default_thematic else 'trafficlights' # Redirection based on default_thematic if default_thematic == 'trafficlights': return redirect('assets:trafficlights_index') elif default_thematic == 'publiclighting': return redirect('assets:publiclighting_index') elif default_thematic == 'artworks': return redirect('assets:artworks_index') elif default_thematic == 'structures': return redirect('assets:structures_index') elif default_thematic == 'nature': return redirect('assets:nature_index') elif default_thematic == 'water': return redirect('assets:water_index') elif default_thematic == 'roads': return redirect('assets:roads_index') elif default_thematic == 'sign': return redirect('assets:sign_index') elif default_thematic == 'controlcenters': return redirect('assets:controlcenters_index') elif default_thematic == 'parking': return redirect('assets:parking_index') # Add more conditions for other thematics else: return redirect('assets:trafficlights_index') # Fallback URL if no match is found def locations_geojson(request): pass def mobile_asset_detail(request, thematic_code, asset_model, asset_id): """Mobile-friendly asset detail using the generic template with the mobile base.""" try: model_name = asset_model.lower().replace('_', '') content_type = ContentType.objects.get(app_label='assets', model=model_name) model_class = content_type.model_class() except ContentType.DoesNotExist: raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model}) asset = get_object_or_404(model_class, id=asset_id) # Permission gate based on the provided thematic code permission_check = check_thematic_access(thematic_code) result = permission_check(lambda req, *a, **kw: None)(request) if result is not None: return result template = select_template([ f"assets/{asset_model}_detail_mobile.html", "assets/detail_generic.html", ]) # Build back URL from referer or intervention context back_url = request.GET.get('back') if not back_url: referer = request.META.get('HTTP_REFERER', '') if referer: # If coming from an intervention, build the back URL if '/mobile/interventions/' in referer: import re match = re.search(r'/interventions/(\d+)', referer) if match: back_url = reverse('mobile:intervention_detail_mobile', args=[match.group(1)]) context = { "asset": asset, "content_type": content_type, "base_template": "mobile/mobile_base.html", "mobile_view": True, "back_url": back_url, } context.update(get_asset_edit_context(request, asset, content_type)) return render(request, template.template.name, context) def filter_queryset_by_geom(qs, filter_geom, include_no_geom, geom_field='geom', location_field='location'): """ Filtre un queryset d'assets selon une géométrie (intersects) et prend en compte l'option include_no_geom. """ if not filter_geom: return qs from django.db.models import Q # Standard intersects query q_intersects = Q(**{f"{geom_field}__intersects": filter_geom}) if location_field: q_intersects |= Q(**{ f"{geom_field}__isnull": True, f"{location_field}__geom__intersects": filter_geom }) if include_no_geom: q_no_geom = Q(**{f"{geom_field}__isnull": True}) if location_field: q_no_geom &= ( Q(**{f"{location_field}__isnull": True}) | Q(**{f"{location_field}__geom__isnull": True}) ) return qs.filter(q_intersects | q_no_geom) return qs.filter(q_intersects) # --------------------- # Recherche plein texte # --------------------- def apply_search(qs, data, fields=None): """ Filtre un queryset par recherche plein texte (AND par défaut, ou OR). Paramètres POST attendus : `q` (texte) et `search_mode` ('and'/'or'). Par défaut, cherche dans `code`, `name_fr` et `name_nl`. """ q = (data.get('q') or '').strip() mode = (data.get('search_mode') or 'and').lower() if not q: return qs if fields is None: fields = ['code', 'name_fr', 'name_nl'] words = q.split() def _word_q(w): combined = Q() for field in fields: combined |= Q(**{f'{field}__icontains': w}) return combined combined = Q() if mode == 'and': for w in words: combined &= _word_q(w) else: for w in words: combined |= _word_q(w) return qs.filter(combined) # ----------------------------------------------- # Libellés lisibles par type d'élément # ----------------------------------------------- ELEMENT_TYPE_LABELS = { # --- Localisations --- 'roadstreet': 'Voirie', 'signstreet': 'Voirie signalisation', 'publiclightingstreet': 'Rue EP', 'trafficlightintersection': 'Carrefour', 'naturelocation': 'Section verte', # --- Voirie --- 'street': _('Rue / Voie'), 'roadway': _('Chaussée'), 'sidewalk': _('Trottoir'), 'roadfurniture': _('Mobilier voirie'), 'roaddrain': _('Drain'), 'roadconcreteblockset': _('Blocs béton'), # --- Éclairage public --- 'publiclightingpole': _('Candélabre'), 'publiclightinglightfixture': _('Luminaire'), 'publiclightingelectricalcabinet': _('Armoire électrique EP'), # --- Signalisation --- 'signpole': _('Poteau signalisation'), 'signpanel': _('Panneau'), 'signparkingspot': _('Place de parking'), # --- Tricolores --- 'trafficlightpole': _('Poteau tricolore'), 'trafficlightcable': _('Câble TL'), 'trafficlightlantern': _('Lanterne TL'), 'trafficlightcontroller': _('Contrôleur TL'), 'trafficlightcontrollerhardware': _('Hardware contrôleur'), 'trafficlightdetector': _('Détecteur TL'), 'trafficlightnetworkhardware': _('Hardware réseau TL'), 'trafficlightvoltagehardware': _('Hardware tension TL'), 'trafficlightelectricalcabinet': _('Armoire électrique TL'), 'trafficlightelectricalcabinetcontent': _('Contenu armoire TL'), 'trafficlightaccessory': _('Accessoire TL'), # --- Nature --- 'naturegreensurface': _('Surface verte'), 'naturetree': _('Arbre'), 'natureshrub': _('Arbuste'), 'naturebench': _('Banc'), 'naturerwiasset': _('Ouvrage RWI'), # --- Ouvrages d'art --- 'structuregeoasset': _("Composant d'ouvrage"), # --- Œuvres d'art --- 'artworkmonument': _('Monument'), 'artworkfountain': _('Fontaine'), # --- Centres de contrôle --- 'controlcentergeoasset': _('Équipement CdC'), # --- ITS --- 'itsgeoasset': _('Équipement ITS'), # --- Propreté --- 'cleanlitterbin': _('Corbeille'), 'cleanglasscontainer': _('Conteneur verre'), # --- Bâtiments publics --- 'publicbuilding': _('Bâtiment public'), 'buildingequipment': _('Équipement bâtiment'), # --- Espaces plein air --- 'outdoorlocation': _('Espace public plein air'), 'outdoorasset': _('Équipement plein air'), } def get_element_type_label(model_name): """Returns the translated display label for an asset/location model name (lowercase).""" if not model_name: return '' label = ELEMENT_TYPE_LABELS.get((model_name or '').lower(), model_name or '') return str(_(label)) if label else '' def build_thematic_config(thematics): """ Returns a dict mapping thematic code → {name, icon, locations_geojson_url, assets_geojson_url}. Used to populate the unified assets page JS config. """ _THEMATIC_URLS = { 'roads': { 'locations_geojson_url': reverse('assets:roads_streets_geojson'), 'assets_geojson_url': reverse('assets:roads_assets_geojson'), }, 'publiclighting': { 'locations_geojson_url': reverse('assets:publiclighting_streets_geojson'), 'assets_geojson_url': reverse('assets:publiclighting_assets_geojson'), }, 'sign': { 'locations_geojson_url': reverse('assets:sign_streets_geojson'), 'assets_geojson_url': reverse('assets:sign_assets_geojson'), }, 'trafficlights': { 'locations_geojson_url': reverse('assets:trafficlights_intersections_geojson'), 'assets_geojson_url': reverse('assets:trafficlights_assets_geojson'), }, 'nature': { 'locations_geojson_url': reverse('assets:nature_locations_geojson'), 'assets_geojson_url': reverse('assets:nature_assets_geojson'), }, 'water': { 'locations_geojson_url': reverse('assets:water_locations_geojson'), 'assets_geojson_url': reverse('assets:water_assets_geojson'), }, 'structures': { 'locations_geojson_url': reverse('assets:structures_locations_geojson'), 'assets_geojson_url': reverse('assets:structures_assets_geojson'), }, 'artworks': { 'locations_geojson_url': reverse('assets:artworks_streets_geojson'), 'assets_geojson_url': reverse('assets:artworks_assets_geojson'), }, 'controlcenters': { 'locations_geojson_url': reverse('assets:controlcenters_locations_geojson'), 'assets_geojson_url': reverse('assets:controlcenters_assets_geojson'), }, 'its': { 'locations_geojson_url': reverse('assets:its_locations_geojson'), 'assets_geojson_url': reverse('assets:its_assets_geojson'), }, 'clean': { 'locations_geojson_url': reverse('assets:clean_locations_geojson'), 'assets_geojson_url': reverse('assets:clean_assets_geojson'), }, 'publicbuildings': { 'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'), 'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'), }, 'buildings': { 'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'), 'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'), }, 'outdoorfacilities': { 'locations_geojson_url': reverse('assets:outdoor_locations_geojson'), 'assets_geojson_url': reverse('assets:outdoor_assets_geojson'), }, 'outdoor': { 'locations_geojson_url': reverse('assets:outdoor_locations_geojson'), 'assets_geojson_url': reverse('assets:outdoor_assets_geojson'), }, 'parking': { 'locations_geojson_url': reverse('assets:parking_locations_geojson'), 'assets_geojson_url': reverse('assets:parking_assets_geojson'), }, } result = {} for thematic in thematics: code = thematic.code if code in _THEMATIC_URLS: entry = dict(_THEMATIC_URLS[code]) entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else '' result[code] = entry return result def build_conditional_filter_config(accessible_thematics, user=None): """ Returns a dict mapping thematic code -> conditional filters definition used by the unified assets page. """ accessible_codes = {t.code for t in accessible_thematics} config = {} # Shared lists used by multiple thematics from common.models import Municipality municipalities = Municipality.objects.all().order_by('name_fr') municipality_options = [ {'value': str(m.id), 'label': m.name} for m in municipalities ] # Traffic lights specific choices from assets.models import TrafficLightIntersection, SignPanelType, TrafficLightContract, Structure from assets.models.roads import QUALITY_LEVEL_CHOICES from contracts.models import Contract voltage_options = [ {'value': str(key), 'label': str(value)} for key, value in TrafficLightIntersection._meta.get_field('voltage_type').choices ] trafficlight_status_options = [ {'value': str(key), 'label': str(value)} for key, value in TrafficLightIntersection._meta.get_field('status').choices ] if not any(option['value'] == 'archived' for option in trafficlight_status_options): trafficlight_status_options.append({'value': 'archived', 'label': str(_('Archivé'))}) quality_level_options = [ {'value': str(key), 'label': str(value)} for key, value in QUALITY_LEVEL_CHOICES ] structure_type_options = [ {'value': str(key), 'label': str(value)} for key, value in Structure._meta.get_field('structure_type').choices ] PANEL_CLASSES = { 'danger': _("Signaux de danger"), 'priority': _("Signaux de priorité"), 'prohibition': _("Signaux d'interdiction"), 'obligation': _("Signaux d'obligation"), 'parking': _("Arrêt / Stationnement"), 'indication': _("Signaux d'indication"), 'zone': _("Signaux de zone"), 'additional': _("Signaux additionnels"), 'other': _("Autres") } def get_panel_class_key(code): code_upper = (code or '').upper() if code_upper.startswith('A'): return 'danger' elif code_upper.startswith('B'): return 'priority' elif code_upper.startswith('C'): return 'prohibition' elif code_upper.startswith('D'): return 'obligation' elif code_upper.startswith('E'): return 'parking' elif code_upper.startswith('F'): return 'indication' elif code_upper.startswith('Z'): return 'zone' else: return 'additional' panel_type_options = [] for pt in SignPanelType.objects.all().order_by('code'): code = (pt.code or '').strip() if not code: continue group_key = get_panel_class_key(code) group_label = str(PANEL_CLASSES.get(group_key, PANEL_CLASSES['additional'])) panel_type_options.append({ 'value': str(pt.id), 'label': code, 'code': code, 'name': pt.get_name(), 'group': group_key, 'group_label': group_label, 'image_url': None, }) controller_maintenance_provider_options = [] if user is not None: user_config = getattr(user, 'config', None) is_intern = bool(user_config and user_config.is_intern) if is_intern: contracts = Contract.objects.filter( id__in=TrafficLightContract.objects.filter( status='active', maintenance_type='controller' ).values_list('contract_id', flat=True).distinct() ).select_related('company').order_by('contract_number') controller_maintenance_provider_options = [ {'value': str(contract.id), 'label': f"{contract.contract_number} - {contract.company.name}"} for contract in contracts ] # Roads / public lighting / sign / artworks: municipality on locations for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']: if thematic_code in accessible_codes: config.setdefault(thematic_code, []).append({ 'key': 'municipality', 'label': _('Commune'), 'type': 'multi', 'options': municipality_options, }) # Traffic lights if 'trafficlights' in accessible_codes: config['trafficlights'] = [ { 'key': 'status', 'label': _('Statut des carrefours'), 'type': 'multi', 'options': trafficlight_status_options, }, { 'key': 'voltage', 'label': _('Voltage'), 'type': 'multi', 'options': voltage_options, }, { 'key': 'connected', 'label': _('Connexion à la centrale'), 'type': 'single', 'options': [ {'value': 'yes', 'label': _('Oui')}, {'value': 'no', 'label': _('Non')}, ], }, { 'key': 'asset_begin_date', 'label': _('Date de mise en service (à partir de)'), 'type': 'date', }, ] if controller_maintenance_provider_options: config['trafficlights'].append({ 'key': 'controller_maintenance_provider', 'label': _('Prestataire maintenance contrôleurs'), 'type': 'select', 'options': controller_maintenance_provider_options, }) # Structures / ITS / control centers: main location flag for thematic_code in ['structures', 'its', 'controlcenters']: if thematic_code in accessible_codes: config[thematic_code] = [{ 'key': 'is_main_location', 'label': _('Localisation principale'), 'type': 'single', 'options': [ {'value': 'true', 'label': _('Oui')}, {'value': 'false', 'label': _('Non')}, ], }] # Structures: structure type (same option set as dedicated page) if 'structures' in accessible_codes: config.setdefault('structures', []).insert(0, { 'key': 'structure_type', 'label': _("Type d'ouvrage"), 'type': 'multi', 'options': structure_type_options, }) # Nature if 'nature' in accessible_codes: config['nature'] = [ { 'key': 'location_type', 'label': _('Type de localisation'), 'type': 'single', 'options': [ {'value': '', 'label': _('Tous')}, {'value': 'naturelocation', 'label': _('Voiries')}, ], }, { 'key': 'highway', 'label': _('Ring et autoroutes'), 'type': 'checkbox', 'value': '1', }, ] # Water if 'water' in accessible_codes: config['water'] = [ { 'key': 'location_type', 'label': _('Type de localisation'), 'type': 'single', 'options': [ {'value': '', 'label': _('Tous')}, {'value': 'naturerwiz', 'label': _('GIEP')}, ], }, ] # Roads assets specific filter if 'roads' in accessible_codes: config.setdefault('roads', []).append({ 'key': 'quality_level', 'label': _('Niveau de qualité'), 'type': 'multi', 'options': quality_level_options, }) # Public buildings specific choices from assets.models.buildings import USAGE_TYPE_CHOICES building_usage_options = [ {'value': str(key), 'label': str(value)} for key, value in USAGE_TYPE_CHOICES ] # Public buildings specific filters for thematic_code in ['publicbuildings', 'buildings']: if thematic_code in accessible_codes: config[thematic_code] = [ { 'key': 'usage_type', 'label': _("Type d'usage (Localisation)"), 'type': 'multi', 'options': building_usage_options, }, { 'key': 'is_main_location', 'label': _('Localisation principale'), 'type': 'single', 'options': [ {'value': 'true', 'label': _('Oui')}, {'value': 'false', 'label': _('Non')}, ], }, { 'key': 'is_erp', 'label': _('Accueil ERP / PMR'), 'type': 'single', 'options': [ {'value': 'true', 'label': _('Oui')}, {'value': 'false', 'label': _('Non')}, ], }, ] # Outdoor facilities specific choices from assets.models.outdoor import SITE_TYPE_CHOICES, NORM_CHOICES outdoor_site_type_options = [ {'value': str(key), 'label': str(value)} for key, value in SITE_TYPE_CHOICES ] outdoor_norm_options = [ {'value': str(key), 'label': str(value)} for key, value in NORM_CHOICES ] for thematic_code in ['outdoorfacilities', 'outdoor']: if thematic_code in accessible_codes: config[thematic_code] = [ { 'key': 'site_type', 'label': _("Type de site (Localisation)"), 'type': 'multi', 'options': outdoor_site_type_options, }, { 'key': 'norm', 'label': _("Norme de sécurité (Asset)"), 'type': 'multi', 'options': outdoor_norm_options, }, { 'key': 'pmr_accessible', 'label': _('Accessible PMR'), 'type': 'single', 'options': [ {'value': 'true', 'label': _('Oui')}, {'value': 'false', 'label': _('Non')}, ], }, ] # Sign assets specific filter if 'sign' in accessible_codes: config.setdefault('sign', []).append({ 'key': 'panel_type', 'label': _('Type de panneau'), 'type': 'multi', 'options': panel_type_options, }) # Parking specific filter if 'parking' in accessible_codes: config['parking'] = [ { 'key': 'spot_type', 'label': _("Type d'emplacement"), 'type': 'multi', 'options': [ {'value': 'bicycle', 'label': str(_('Stationnement vélo'))}, {'value': 'cargo_bike', 'label': str(_('Stationnement vélo cargo'))}, {'value': 'scooter_drop_zone', 'label': str(_('Drop zone trottinettes / EDPM'))}, {'value': 'delivery', 'label': str(_('Zone de livraison'))}, {'value': 'pmr', 'label': str(_('Emplacement PMR'))}, {'value': 'kiss_and_ride', 'label': str(_('Arrêt minute (CD)'))}, {'value': 'car_parking', 'label': str(_('Stationnement automobile'))}, ], }, { 'key': 'has_scooter_drop_zone', 'label': _('Drop zone trottinettes'), 'type': 'single', 'options': [ {'value': 'true', 'label': str(_('Oui'))}, {'value': 'false', 'label': str(_('Non'))}, ], }, { 'key': 'is_compliant', 'label': _('Conformité'), 'type': 'single', 'options': [ {'value': 'true', 'label': str(_('Conforme'))}, {'value': 'false', 'label': str(_('Non conforme'))}, ], }, ] return config def build_unified_add_modal_config(accessible_thematics, user=None): """ Returns the thematic choices allowed for the unified add modal. Asset creation uses thematic edit rights. Location creation is limited to thematics that also expose a generic location form. """ from assets.forms import LOCATION_FORM_REGISTRY from assets.permissions import ( can_edit_location_for_thematic, get_allowed_actions_for_thematic, ) location_type_by_thematic = { entry['thematic_code']: { 'location_type': location_type, 'label': entry.get('label', location_type), } for location_type, entry in LOCATION_FORM_REGISTRY.items() } asset_thematics = [] location_thematics = [] for thematic in accessible_thematics: thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) thematic_icon = thematic.icon if hasattr(thematic, 'icon') else '' if 'create' in get_allowed_actions_for_thematic(user, thematic): asset_entry = { 'code': thematic.code, 'name': thematic_name, 'icon': thematic_icon, } location_meta = location_type_by_thematic.get(thematic.code) if location_meta: asset_entry['location_type'] = location_meta['location_type'] asset_thematics.append(asset_entry) location_meta = location_type_by_thematic.get(thematic.code) if location_meta and can_edit_location_for_thematic(user, thematic.code): location_thematics.append({ 'code': thematic.code, 'name': thematic_name, 'icon': thematic_icon, 'location_type': location_meta['location_type'], 'location_label': location_meta['label'], }) return { 'asset_thematics': asset_thematics, 'location_thematics': location_thematics, } SPECIFIC_TABLES_REGISTRY = [ { 'thematic_code': 'publicbuildings', 'title': _("Bâtiments publics"), 'description': _("Gestion du patrimoine bâti & équipements"), 'icon': 'bi-building', 'url_name': 'assets:buildings_index', }, ] def get_user_specific_tables(user, accessible_thematics): """ Retourne la liste générique des répertoires/tableaux spécifiques accessibles par l'utilisateur. """ from django.urls import reverse accessible_codes = {t.code for t in accessible_thematics} specific_tables = [] for item in SPECIFIC_TABLES_REGISTRY: if user.is_superuser or item['thematic_code'] in accessible_codes: try: url = reverse(item['url_name']) specific_tables.append({ 'title': item['title'], 'description': item['description'], 'icon': item['icon'], 'url': url, }) except Exception: pass return specific_tables @login_required def unified_assets_index(request): """Unified asset/location index page combining all accessible thematics.""" from django.utils.translation import get_language from common.models import UserThematics from interventions.views.drafts import InterventionDraftManager lang = get_language() or 'fr' user_config, _ = UserConfig.objects.get_or_create(user=request.user) # Thématiques accessibles à l'utilisateur if getattr(request.user, 'is_superuser', False): accessible_thematics = list(Thematic.objects.order_by(f'{"name_nl" if lang == "nl" else "name_fr"}')) else: thematic_qs = ( UserThematics.objects .filter(user_config=user_config, can_view_assets=True) .select_related('thematic') .order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}') ) accessible_thematics = [tc.thematic for tc in thematic_qs] # Mode intervention préventive preventive_draft_id = request.GET.get('preventive_draft_id') locked_thematic_code = request.GET.get('thematic_code', '') 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', {}) if draft_data else {} preventive_element_type = form_data.get('type_of_selected_elements', 'locations') if preventive_element_type == 'assets': selected_assets = draft_data.get('assets', []) or [] else: selected_locations_ids = draft_data.get('locations', []) or [] # Thématiques présélectionnées (GET, intervention, thématique par défaut ou toutes les thématiques accessibles) has_url_thematics = 'thematic' in request.GET if has_url_thematics: selected_thematics = request.GET.getlist('thematic') elif locked_thematic_code: selected_thematics = [locked_thematic_code] elif user_config and user_config.assets_index_preferences and user_config.assets_index_preferences.get('thematics'): saved_thematics = user_config.assets_index_preferences.get('thematics') accessible_codes = {t.code for t in accessible_thematics} selected_thematics = [code for code in saved_thematics if code in accessible_codes] if not selected_thematics and accessible_thematics: selected_thematics = [t.code for t in accessible_thematics] elif user_config and user_config.default_thematic and user_config.default_thematic in accessible_thematics: selected_thematics = [user_config.default_thematic.code] elif accessible_thematics: selected_thematics = [t.code for t in accessible_thematics] else: selected_thematics = [] selected_categories = request.GET.getlist('category') # Config JS par thématique (URLs GeoJSON, nom, icône) thematic_config = build_thematic_config(accessible_thematics) conditional_filter_config = build_conditional_filter_config(accessible_thematics, request.user) add_modal_config = build_unified_add_modal_config(accessible_thematics, request.user) # Catégories groupées par thématique thematic_categories = {} for thematic in accessible_thematics: cats = list(AssetCategory.objects.filter(thematic=thematic).order_by('name_fr')) thematic_categories[thematic.code] = [ {'id': c.id, 'name': c.get_name()} for c in cats ] link_assets_url = ( reverse( 'interventions:intervention_preventive_link_elements', kwargs={'thematic_code': locked_thematic_code}, ) if locked_thematic_code else '' ) user_roles = user_config.roles.values_list('name', flat=True) if user_config else [] is_admin = 'admin' in user_roles or request.user.is_superuser has_bulk_edit_status_permission = is_admin or UserThematics.objects.filter(user_config=user_config, can_edit_assets=True).exists() has_bulk_edit_validation_status_permission = is_admin or UserThematics.objects.filter(user_config=user_config, can_validate_assets=True).exists() specific_tables = get_user_specific_tables(request.user, accessible_thematics) context = { 'accessible_thematics': accessible_thematics, 'accessible_thematic_codes': [t.code for t in accessible_thematics], 'specific_tables': specific_tables, 'has_bulk_edit_status_permission': has_bulk_edit_status_permission, 'has_bulk_edit_validation_status_permission': has_bulk_edit_validation_status_permission, 'thematic_config_json': json.dumps(thematic_config), 'conditional_filter_config_json': json.dumps(conditional_filter_config), 'thematic_categories_json': json.dumps(thematic_categories), 'asset_status_choices': list(ASSET_STATUS_CHOICES.items()), 'asset_validation_status_choices': list(ASSET_VALIDATION_STATUS_CHOICES.items()), 'add_modal_config_json': json.dumps(add_modal_config), 'selected_thematics': selected_thematics, 'has_url_thematics': has_url_thematics, 'selected_categories_json': json.dumps(selected_categories), 'preventive_draft_id': preventive_draft_id, 'locked_thematic_code': locked_thematic_code, 'preventive_element_type': preventive_element_type, 'selected_assets_json': json.dumps(selected_assets), 'selected_locations_ids': selected_locations_ids, 'link_assets_url': link_assets_url, 'assets_index_prefs_json': json.dumps(user_config.assets_index_preferences or {}), 'default_thematic_code': (user_config.default_thematic.code if user_config.default_thematic_id else ''), } return render(request, 'assets/unified_index.html', context)