# -------------------- # Traffic Lights Views # -------------------- import logging logger = logging.getLogger(__name__) from django.http import HttpResponse, JsonResponse, HttpResponseForbidden, Http404 from django.shortcuts import render, get_object_or_404, redirect from django.urls import reverse from django.core.serializers import serialize from django.core.serializers.json import DjangoJSONEncoder from django.contrib.gis.db.models.functions import Transform from django.db.models import Value, OuterRef, Subquery, Q, Case, When from django.db.models import IntegerField, CharField, BooleanField from django.db.models import Func from django.db.models.functions import Lower from django.contrib.gis.db.models import GeometryField from django.contrib.gis.geos import Point, GEOSGeometry from django.contrib.gis.db.models.functions import Distance from django.contrib.auth.decorators import permission_required from django.contrib.contenttypes.models import ContentType from django.views.decorators.http import require_GET, require_POST from django.template.loader import render_to_string from django.utils.translation import get_language, gettext as _ from django.utils.timezone import make_aware, get_current_timezone import json from datetime import datetime import unicodedata from collections import defaultdict from common.models import Thematic, UserConfig, UserContractAccess, UserThematics from common.utils import check_thematic_access from interventions.models import InterventionLocation from interventions.views.drafts import InterventionDraftManager from assets.models import TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightController, TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector, TrafficLightElectricalCabinet, TrafficLightRadar, TrafficLightRadarPole from assets.models import AssetCategory, TrafficLightCableModel, TrafficLightControllerModel, TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel, TrafficLightElectricalCabinetModel, TrafficLightContract, TrafficLightRadarModel from assets.forms import IntersectionDocumentForm from assets.permissions import get_allowed_actions_for_thematic, can_edit_location_for_thematic from contracts.models import Contract from documents.services import DocumentManagementService from documents.permissions import user_can_browse_folder import os import ezdxf # Global cache for parsed DXF documents to avoid expensive disk reading and parsing (takes ~3.5s for 26MB files) DXF_DOC_CACHE = {} def get_cached_dxf_document(file_path): import hashlib try: with open(file_path, 'rb') as f: header = f.read(8192) cache_key = hashlib.md5(header).hexdigest() except Exception: cache_key = file_path cached = DXF_DOC_CACHE.get(cache_key) if cached: return cached # Read and store in cache doc = ezdxf.readfile(file_path) DXF_DOC_CACHE[cache_key] = doc # Limit cache size to prevent memory leaks (keep at most 5 documents) if len(DXF_DOC_CACHE) > 5: DXF_DOC_CACHE.pop(next(iter(DXF_DOC_CACHE))) return doc def get_cable_color(code): palette = [ "#10b981", # Emerald "#3b82f6", # Blue "#f59e0b", # Amber "#ef4444", # Red "#8b5cf6", # Purple "#ec4899", # Pink "#06b6d4", # Cyan "#eab308", # Yellow "#14b8a6", # Teal "#f43f5e", # Rose "#84cc16", # Lime "#a855f7", # Purple-bright ] if not code: return palette[0] h = sum(ord(char) for char in code) return palette[h % len(palette)] @check_thematic_access('trafficlights') def trafficlights_index(request): thematic = 'trafficlights' lang = get_language() # Get filters from URL statuses = request.GET.getlist('status') voltages = request.GET.getlist('voltage') begin_date = request.GET.get('begin_date') connected = request.GET.get('connected') controller_maintenance_provider = request.GET.get('controller_maintenance_provider') 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), } # Catégories limitées à la thématique "trafficlights" categories = list(AssetCategory.objects.filter(thematic__code='trafficlights')) categories.sort(key=lambda c: c.get_name().lower()) category_choices = [(c.id, c.get_name()) for c in categories] # Classes de modèles à agréger asset_model_classes = [ TrafficLightCableModel, TrafficLightControllerModel, TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel, TrafficLightElectricalCabinetModel, ] # Récupérer tous les modèles de chaque classe all_models = [] for cls in asset_model_classes: all_models.extend(cls.objects.all()) # Trier et construire les choix all_models.sort(key=lambda m: (m.get_name() or "").lower()) model_choices = [(m.id, m.get_name()) for m in all_models] # Construire le mapping catégorie → modèles category_model_map = defaultdict(list) for model in all_models: if model.category: category_model_map[str(model.category_id)].append({ "key": str(model.id), "label": model.get_name(), }) # Get controller maintenance contracts (for internal users only) user_config = UserConfig.objects.filter(user=request.user).first() is_intern = user_config.is_intern if user_config else False controller_maintenance_contracts = [] if is_intern: # Get all contracts linked to traffic light controller maintenance (status='active', maintenance_type='controller') controller_maintenance_contracts = Contract.objects.filter( id__in=TrafficLightContract.objects.filter( status='active', maintenance_type='controller' ).values_list('contract_id', flat=True).distinct() ).order_by('contract_number') controller_maintenance_contracts = [(c.id, f"{c.contract_number} - {c.company.name}") for c in controller_maintenance_contracts] context = { 'status_choices': TrafficLightIntersection._meta.get_field('status').choices, 'voltage_choices': TrafficLightIntersection._meta.get_field('voltage_type').choices, 'connected_choices':[(True, _('Oui')), (False, _('Non'))], 'selected_statuses': statuses, 'selected_voltages': voltages, 'connected': connected, 'controller_maintenance_provider': controller_maintenance_provider, 'controller_maintenance_contracts': controller_maintenance_contracts, 'is_intern': is_intern, 'begin_date': begin_date, 'thematics': thematics, 'selected_thematic': selected_thematic, 'category_choices': category_choices, 'model_choices': model_choices, 'category_model_map': dict(category_model_map), 'selected_categories': selected_categories, 'selected_models': selected_models, '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': _("Intersections"), 'locations_assets_title': "Assets", 'can_create_location': can_edit_location_for_thematic(request.user, 'trafficlights'), 'location_type': 'trafficlightintersection', 'thematic_code': 'trafficlights', } return render(request, "assets/trafficlights_index.html", context) def trafficlights_intersections(request): pass @check_thematic_access('trafficlights') def trafficlights_intersections_geojson(request): if request.method == "POST": try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON"}, status=400) statuses = ensure_list(data.get('status')) voltages = ensure_list(data.get('voltage')) begin_date = data.get('begin_date') connected = data.get('connected') controller_maintenance_provider = data.get('controller_maintenance_provider') # 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: statuses = request.GET.getlist('status') voltages = request.GET.getlist('voltage') begin_date = request.GET.get('begin_date') connected = request.GET.get('connected') controller_maintenance_provider = request.GET.get('controller_maintenance_provider') filter_geom = None if connected == 'yes': connected_value = True elif connected == 'no': connected_value = False else: connected_value = None # correspond à "Tous" trafficlights = TrafficLightIntersection.objects.annotate(geom_4326=Transform('geom', 4326)) if statuses: trafficlights = trafficlights.filter(status__in=statuses) if voltages: trafficlights = trafficlights.filter(voltage_type__in=voltages) if begin_date: # Convert YYYY-MM-DD string to aware datetime at start of day begin_datetime = datetime.strptime(begin_date, '%Y-%m-%d') begin_datetime = make_aware(begin_datetime, get_current_timezone()) trafficlights = trafficlights.filter(begin_time__gte=begin_datetime) if connected: connected_subquery = TrafficLightController.objects.filter( intersection=OuterRef('pk'), status='active', ).order_by('-installation_date').values('connected_to_control_center')[:1] trafficlights = trafficlights.annotate( is_connected_to_control_center=Subquery(connected_subquery, output_field=BooleanField()) ) trafficlights = trafficlights.filter(is_connected_to_control_center=connected_value) if controller_maintenance_provider: # Filter by controller maintenance contract (TrafficLightContract with type='controller') trafficlights = trafficlights.filter( trafficlight_contracts__status='active', trafficlight_contracts__maintenance_type='controller', trafficlight_contracts__contract_id=controller_maintenance_provider ) if filter_geom: trafficlights = trafficlights.filter(geom__intersects=filter_geom) # Check user access to contracts user_config = UserConfig.objects.filter(user=request.user).first() if user_config.limit_assets_to_contracts: accessible_contract_ids = UserContractAccess.objects.filter( user_config=user_config, can_view_assets=True ).values_list('contract_id', flat=True) trafficlights = trafficlights.filter( trafficlight_contracts__status='active', trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() # Recherche plein texte if request.method == 'POST': from assets.views.core import apply_search trafficlights = apply_search(trafficlights, data) def obj_to_feature(obj): geometry = json.loads(obj.geom_4326.geojson) if obj.geom_4326 else None return { "type": "Feature", "id": obj.id, "geometry": geometry, "properties": { "code": obj.code, "name": obj.get_name(), "status": obj.status, "voltage_type": obj.voltage_type, "lon": obj.lon, "lat": obj.lat, "detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None, "element_type": "Carrefour", "thematic": "trafficlights", "is_location": True, "is_radar": obj.is_radar, } } features = [] for tl in trafficlights: features.append(obj_to_feature(tl)) geojson_data = { "type": "FeatureCollection", "features": features, } return JsonResponse(geojson_data, safe=False) @check_thematic_access('trafficlights') def trafficlights_intersections_detail(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) ct_tli = ContentType.objects.get_for_model(TrafficLightIntersection) interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=ct_tli, object_id=intersection.id).select_related('intervention').order_by('-intervention__creation_time')] assets = intersection.get_active_assets() if 'cables' in assets: for cable in assets['cables']: cable.color = get_cable_color(cable.code) # --- NOUVELLE GESTION DOCUMENTAIRE --- # Récupérer les documents depuis le système de gestion documentaire doc_data = DocumentManagementService.get_documents_for_asset(intersection) managed_main_documents = doc_data.get("main_documents", []) document_folder = doc_data.get("folder") # Fallback vers l'ancien système si pas de répertoire configuré if document_folder is None: # Ancien système: récupérer depuis TrafficLightIntersectionDocument as_built_doc = intersection.documents.filter(document_type='as_built').filter(status='active').order_by('-id').first() cross_plan_doc = intersection.documents.filter(document_type='cross_plan').filter(status='active').order_by('-id').first() programming_file_doc = intersection.documents.filter(document_type='programming_file').filter(status='active').order_by('-id').first() connexion_plan_doc = intersection.documents.filter(document_type='connexion_plan').filter(status='active').order_by('-id').first() main_documents = [] if as_built_doc: main_documents.append(as_built_doc) if cross_plan_doc: main_documents.append(cross_plan_doc) if programming_file_doc: main_documents.append(programming_file_doc) if connexion_plan_doc: main_documents.append(connexion_plan_doc) use_managed_documents = False else: main_documents = managed_main_documents use_managed_documents = True # --- FIN NOUVELLE GESTION DOCUMENTAIRE --- user = request.user user_config = getattr(user, "config", None) can_add_unique_tag_documents = False if user_config and user_config.is_intern: can_add_unique_tag_documents = True elif user_config: can_add_unique_tag_documents = user_config.can_add_unique_tag_documents # Générer le GeoJSON des assets (poles, câbles, armoires, contrôleurs, détecteurs, radars avec géométrie) assets_features = [] for type_key, asset_list in assets.items(): for a in asset_list: if hasattr(a, 'geom') and a.geom: try: # Transformer la géométrie en WGS84 (EPSG:4326) pour MapLibre geom_wgs84 = a.geom.clone() geom_wgs84.transform(4326) category_name = None if getattr(a, 'category', None): category_name = a.category.get_name() model_code = None if getattr(a, 'model', None): model_code = getattr(a.model, 'code', None) properties = { "code": a.code, "name": a.get_name(), "category": category_name, "model": model_code, "asset_type": a._meta.model_name, "detail_url": a.get_absolute_url() if hasattr(a, 'get_absolute_url') else None, } if a._meta.model_name == 'trafficlightcable': properties["color"] = get_cable_color(a.code) assets_features.append({ "type": "Feature", "id": a.id, "geometry": json.loads(geom_wgs84.json), "properties": properties, }) except Exception: pass assets_geojson = { "type": "FeatureCollection", "features": assets_features, } thematic = Thematic.objects.filter(code='trafficlights').first() allowed_location_actions = get_allowed_actions_for_thematic(request.user, thematic) if thematic else [] can_edit_location = can_edit_location_for_thematic(request.user, 'trafficlights') # Vérifier si l'utilisateur peut naviguer vers le folder dans le module documents can_browse_folder = False if document_folder: can_browse_folder = user_can_browse_folder(request.user, document_folder) # Contrats liés à l'intersection trafficlight_contracts = intersection.trafficlight_contracts.select_related('contract__company').all() if can_edit_location else None available_contracts = ( Contract.objects.filter(thematics__code='trafficlights').order_by('contract_number') if can_edit_location else None ) # Check for DXF staged assets count staged_assets_count = TrafficLightPole.objects.filter( intersection=intersection, status='to_be_approved', validation_status='to_be_validated' ).count() # Fetch DXF files associated with this intersection import os dxf_files = [] # 1. Add document management DXF files first (most recent updated_at first) from documents.models import DocumentAttachment, ManagedDocument ct = ContentType.objects.get_for_model(intersection) attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id) managed_dxf_docs = ManagedDocument.objects.filter( id__in=attachments.values_list('document_id', flat=True), is_archived=False ).select_related('current_version') managed_dxf_docs = sorted( list(managed_dxf_docs), key=lambda x: x.updated_at, reverse=True ) for doc in managed_dxf_docs: if doc.current_version and doc.current_version.file: fn = os.path.basename(doc.current_version.file.name) if fn.lower().endswith('.dxf') and fn not in dxf_files: dxf_files.append(fn) # 2. Add DB documents next, ordered by upload_date descending (legacy fallback) dxf_docs = sorted( [doc for doc in intersection.documents.all() if doc.file.name.lower().endswith('.dxf')], key=lambda x: x.upload_date, reverse=True ) for doc in dxf_docs: fn = os.path.basename(doc.file.name) if fn not in dxf_files: dxf_files.append(fn) # 3. Add read-only external files next external_files = [] read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') if os.path.exists(read_only_dxf_dir): code_lower = intersection.code.lower() if intersection.code else "" for f in os.listdir(read_only_dxf_dir): if f.lower().endswith('.dxf'): if code_lower and code_lower in f.lower(): external_files.append(f) for f in sorted(external_files): if f not in dxf_files: dxf_files.append(f) from django.utils import timezone today_date = timezone.localdate().strftime('%Y-%m-%d') from django.urls import reverse georeferenced_documents = [] if use_managed_documents and document_folder: from documents.models import ManagedDocument folder_ids = DocumentManagementService.get_folder_and_descendants(document_folder) docs = ManagedDocument.objects.filter( folders__pk__in=folder_ids, is_archived=False, georeference_coords__isnull=False ).distinct() for doc in docs: is_dxf_overlay = bool(doc.unique_params and 'dxf_source_id' in doc.unique_params) georeferenced_documents.append({ 'id': doc.id, 'title': doc.title, 'coords': doc.georeference_coords, 'url': reverse('documents:page_image', args=[doc.pk, doc.georeference_page or 1]), 'is_dxf': is_dxf_overlay }) # Add DXF overlay images to georeferenced documents list (legacy fallback) if not use_managed_documents: for dxf_file in dxf_files: file_path = None for d in intersection.documents.all(): if os.path.basename(d.file.name) == dxf_file: file_path = d.file.path break if not file_path: uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') file_path = os.path.join(uploaded_dxf_dir, dxf_file) if not os.path.exists(file_path): file_path = os.path.join(read_only_dxf_dir, dxf_file) if os.path.exists(file_path): try: overlay_filename = dxf_file.lower().replace('.dxf', '.overlay.png') overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_filename) # Propose ONLY if the overlay has been previously generated/saved if os.path.exists(overlay_path): from django.core.cache import cache from pyproj import Transformer from assets.views.trafficlights import calculate_dxf_limits_fast cache_key = f"dxf_limits_{dxf_file.lower()}" limits = cache.get(cache_key) if not limits: limits = calculate_dxf_limits_fast(file_path) if limits: cache.set(cache_key, limits, timeout=86400 * 30) if limits: transformer = Transformer.from_crs(31370, 4326, always_xy=True) min_x, min_y, max_x, max_y = limits top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) georeferenced_documents.append({ 'id': f"dxf-{dxf_file}", 'title': f"[CAO] {dxf_file}", 'coords': [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ], 'url': f"{settings.MEDIA_URL}dxf_layouts/{overlay_filename}", 'is_dxf': True }) except Exception as e: print(f"Error preparing DXF overlay for {dxf_file}: {e}") georeferenced_documents_json = json.dumps(georeferenced_documents) return render(request, "assets/trafficlights_intersection_detail.html", { "intersection":intersection, "interventions":interventions, "assets":assets, "assets_geojson": assets_geojson, 'main_documents': main_documents, 'add_document_form': IntersectionDocumentForm(), 'allowed_location_actions': allowed_location_actions, 'can_edit_location': can_edit_location, 'thematic_code': 'trafficlights', 'location_type': 'trafficlightintersection', 'document_folder': document_folder, 'use_managed_documents': use_managed_documents if 'use_managed_documents' in locals() else False, 'can_add_unique_tag_documents': can_add_unique_tag_documents, 'can_browse_folder': can_browse_folder, 'trafficlight_contracts': trafficlight_contracts, 'available_contracts': available_contracts, 'staged_assets_count': staged_assets_count, 'today_date': today_date, 'dxf_files': dxf_files, 'georeferenced_documents_json': georeferenced_documents_json, }) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def trafficlights_intersections_contracts_add(request, intersection_id): """Ajoute un contrat à une intersection.""" intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) contract_id = request.POST.get('contract_id') maintenance_type = request.POST.get('maintenance_type', 'other') status = request.POST.get('status', 'active') if not contract_id: return JsonResponse({'success': False, 'error': _("Contrat requis.")}, status=400) contract = get_object_or_404(Contract, pk=contract_id) tl_contract, created = TrafficLightContract.objects.get_or_create( intersection=intersection, contract=contract, defaults={'maintenance_type': maintenance_type, 'status': status}, ) if not created: tl_contract.maintenance_type = maintenance_type tl_contract.status = status tl_contract.save(update_fields=['maintenance_type', 'status']) return JsonResponse({ 'success': True, 'id': tl_contract.pk, 'contract_number': contract.contract_number, 'company': contract.company.name, 'maintenance_type': tl_contract.get_maintenance_type_display(), 'status': tl_contract.get_status_display(), }) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def trafficlights_intersections_contracts_remove(request, intersection_id, tl_contract_id): """Supprime un contrat d'une intersection.""" tl_contract = get_object_or_404( TrafficLightContract, pk=tl_contract_id, intersection_id=intersection_id ) tl_contract.delete() return JsonResponse({'success': True}) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def trafficlights_intersections_add_document(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) count = int(request.POST.get('file_count', 0)) files = request.FILES.getlist('files') user = request.user # Vérifier si on utilise le nouveau système de gestion documentaire document_folder = DocumentManagementService.get_default_folder_for_object(intersection) if document_folder is not None: # Nouveau système de gestion documentaire for i in range(count): file = files[i] tag_slug = request.POST.get(f'tag_{i}', None) folder_id = request.POST.get(f'folder_{i}', None) unique_param_value = request.POST.get(f'unique_param_{i}', None) if not tag_slug: continue # Récupérer le tag from documents.models import DocumentTag try: tag = DocumentTag.objects.get(slug=tag_slug) except DocumentTag.DoesNotExist: continue # Vérifier les permissions pour les tags uniques if tag.is_unique: can_add = DocumentManagementService.can_user_add_unique_tag_document(user, tag) if not can_add: continue # Si le tag est unique, utiliser le répertoire par défaut if tag.is_unique: target_folder = document_folder else: # Sinon, utiliser le répertoire choisi par l'utilisateur from documents.models import DocumentFolder if folder_id: target_folder = DocumentFolder.objects.filter(id=folder_id).first() or document_folder else: target_folder = document_folder # Préparer les unique_params # Le format est automatiquement ajouté par le service d'après l'extension du fichier. # On ne passe ici que les paramètres extra spécifiques au tag. unique_params = None if tag.slug == 'crossplan' and unique_param_value: # Pour crossplan: ajouter la valeur des câbles fournie par l'utilisateur cables_normalized = unique_param_value.strip() if cables_normalized: unique_params = {'cables': cables_normalized} # Ajouter le document via le service DocumentManagementService.add_document( file=file, tag=tag, folder=target_folder, user=user, content_object=intersection, unique_params=unique_params ) else: # Ancien système for i in range(count): file = files[i] document_type = request.POST.get(f'document_type_{i}', 'other') TrafficLightIntersectionDocument.objects.create( intersection=intersection, file=file, document_type=document_type, uploaded_by=request.user ) return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection_id) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def trafficlights_intersections_delete_document(request, document_id): document = get_object_or_404(TrafficLightIntersectionDocument, id=document_id) intersection_id = document.intersection.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:trafficlights_intersections_detail', intersection_id=intersection_id) @check_thematic_access('trafficlights') def fetch_document_history(request): intersection_id = request.GET.get('intersection_id') document_type = request.GET.get('document_type') tag_slug = request.GET.get('tag_slug') unique_params_str = request.GET.get('unique_params') user = request.user user_config = getattr(user, "config", None) is_delete_document_allowed = False if user_config: if user_config.roles.filter(name='admin').exists(): is_delete_document_allowed = True elif UserThematics.objects.filter( user_config=user_config, thematic__code='trafficlights', can_edit_assets=True ).exists(): is_delete_document_allowed = True # Vérifier si on utilise le nouveau système de gestion documentaire intersection = TrafficLightIntersection.objects.filter(pk=intersection_id).first() if intersection: document_folder = DocumentManagementService.get_default_folder_for_object(intersection) else: document_folder = None if document_folder is not None and tag_slug: # Nouveau système: récupérer l'historique des versions # Décoder les unique_params si fournis unique_params = None if unique_params_str: import json try: unique_params = json.loads(unique_params_str) except (json.JSONDecodeError, ValueError): unique_params = None versions = DocumentManagementService.get_document_history_by_tag(intersection, tag_slug, unique_params) html = render_to_string('assets/document_version_history_list.html', { 'versions': versions, 'is_delete_document_allowed': False, 'use_managed_documents': True }) else: # Ancien système: récupérer depuis TrafficLightIntersectionDocument documents = TrafficLightIntersectionDocument.objects.filter( intersection_id=intersection_id, document_type=document_type ).order_by('-id') html = render_to_string('assets/document_history_list.html', { 'documents': documents, 'is_delete_document_allowed': is_delete_document_allowed }) return JsonResponse({'html': html}) @check_thematic_access('trafficlights') @require_GET def get_document_config(request, intersection_id): """ API pour récupérer la configuration des documents (tags et répertoires disponibles). Utilisé par le JavaScript pour le formulaire d'ajout de document. """ intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) user = request.user user_config = getattr(user, "config", None) # Vérifier les permissions can_add_unique_tag_documents = False if user_config and user_config.is_intern: can_add_unique_tag_documents = True elif user_config: can_add_unique_tag_documents = user_config.can_add_unique_tag_documents # Vérifier si le nouveau système est activé document_folder = DocumentManagementService.get_default_folder_for_object(intersection) if document_folder is None: return JsonResponse({ 'use_managed_documents': False, 'can_add_unique_tag_documents': can_add_unique_tag_documents, 'tags': [], 'folders': [], }) # Récupérer les tags disponibles pour la thématique "trafficlights" tags = DocumentManagementService.get_available_tags_for_thematic('trafficlights') tags_data = [ { 'slug': tag.slug, 'name': tag.name, 'is_unique': tag.is_unique, 'show_in_asset_detail': tag.show_in_asset_detail, } for tag in tags ] # Récupérer les sous-répertoires disponibles folders = DocumentManagementService.get_subfolders_for_object(intersection) return JsonResponse({ 'use_managed_documents': True, 'can_add_unique_tag_documents': can_add_unique_tag_documents, 'tags': tags_data, 'folders': folders, }) def trafficlights_assets(request): pass 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('trafficlights') def trafficlights_assets_geojson(request): 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 = ensure_list(data.get('asset_status')) location_statuses = ensure_list(data.get('status')) voltages = ensure_list(data.get('voltage')) connected = data.get('connected') controller_maintenance_provider = data.get('controller_maintenance_provider') begin_date = data.get('asset_begin_date') or data.get('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: location_id = request.GET.get('location') locations_ids = [location_id] if location_id else [] statuses = request.GET.getlist('asset_status') location_statuses = request.GET.getlist('status') voltages = request.GET.getlist('voltage') connected = request.GET.get('connected') controller_maintenance_provider = request.GET.get('controller_maintenance_provider') begin_date = request.GET.get('asset_begin_date') or request.GET.get('begin_date') 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: 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 hasattr(obj, 'geojson') and obj.geojson: try: geometry = json.loads(obj.geojson) except (TypeError, ValueError, json.JSONDecodeError): geometry = None lon = getattr(obj, 'lon', None) lat = getattr(obj, 'lat', None) if lon is None or lat is None: intersection = getattr(obj, 'intersection', None) if intersection is not None: if lon is None: lon = getattr(intersection, 'lon', None) if lat is None: lat = getattr(intersection, '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) 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": "trafficlights", "is_location": False, } if asset_type == 'trafficlightcable': properties["color"] = get_cable_color(obj.code) elif asset_type == 'trafficlightradar': properties["has_support"] = bool( getattr(obj, 'pole_id', None) or getattr(obj, 'publiclighting_pole_id', None) or getattr(obj, 'trafficlights_pole_id', None) ) return { "type": "Feature", "id": obj.id, "geometry": geometry, "properties": properties, } 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 features = [] feature_count = 0 controller_qs = TrafficLightController.objects.all() if locations_ids: controller_qs = controller_qs.filter(intersection__id__in=locations_ids) if statuses: controller_qs = controller_qs.filter(status__in=statuses) if models: controller_qs = controller_qs.filter(model__id__in=models) if categories: controller_qs = controller_qs.filter(category__id__in=categories) if filter_geom: controller_qs = controller_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, intersection__geom__intersects=filter_geom) ) pole_qs = TrafficLightPole.objects.all() if locations_ids: pole_qs = pole_qs.filter(intersection__id__in=locations_ids) if statuses: pole_qs = pole_qs.filter(status__in=statuses) if models: pole_qs = pole_qs.filter(model__id__in=models) if categories: pole_qs = pole_qs.filter(category__id__in=categories) if filter_geom: pole_qs = pole_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, intersection__geom__intersects=filter_geom) ) electrical_cabinet_qs = TrafficLightElectricalCabinet.objects.all() if locations_ids: electrical_cabinet_qs = electrical_cabinet_qs.filter(intersection__id__in=locations_ids) if statuses: electrical_cabinet_qs = electrical_cabinet_qs.filter(status__in=statuses) if models: electrical_cabinet_qs = electrical_cabinet_qs.filter(model__id__in=models) if categories: electrical_cabinet_qs = electrical_cabinet_qs.filter(category__id__in=categories) if filter_geom: electrical_cabinet_qs = electrical_cabinet_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, intersection__geom__intersects=filter_geom) ) detector_qs = TrafficLightDetector.objects.all() if locations_ids: detector_qs = detector_qs.filter(intersection__id__in=locations_ids) if statuses: detector_qs = detector_qs.filter(status__in=statuses) if models: detector_qs = detector_qs.filter(model__id__in=models) if categories: detector_qs = detector_qs.filter(category__id__in=categories) if filter_geom: detector_qs = detector_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, intersection__geom__intersects=filter_geom) ) radar_qs = TrafficLightRadar.objects.all() if locations_ids: radar_qs = radar_qs.filter(intersection__id__in=locations_ids) if statuses: radar_qs = radar_qs.filter(status__in=statuses) if models: radar_qs = radar_qs.filter(model__id__in=models) if categories: radar_qs = radar_qs.filter(category__id__in=categories) if filter_geom: radar_qs = radar_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, intersection__geom__intersects=filter_geom) ) radar_pole_qs = TrafficLightRadarPole.objects.all() if locations_ids: radar_pole_qs = radar_pole_qs.filter(intersection__id__in=locations_ids) if statuses: radar_pole_qs = radar_pole_qs.filter(status__in=statuses) if models: radar_pole_qs = radar_pole_qs.filter(model__id__in=models) if categories: radar_pole_qs = radar_pole_qs.filter(category__id__in=categories) if filter_geom: radar_pole_qs = radar_pole_qs.filter( Q(geom__intersects=filter_geom) | Q(geom__isnull=True, intersection__geom__intersects=filter_geom) ) # Applique les filtres de carrefours à toutes les requêtes assets. if location_statuses or voltages or connected or controller_maintenance_provider: intersections_qs = TrafficLightIntersection.objects.all() if location_statuses: intersections_qs = intersections_qs.filter(status__in=location_statuses) if voltages: intersections_qs = intersections_qs.filter(voltage_type__in=voltages) if connected == 'yes': connected_value = True elif connected == 'no': connected_value = False else: connected_value = None if connected: connected_subquery = TrafficLightController.objects.filter( intersection=OuterRef('pk'), status='active', ).order_by('-installation_date').values('connected_to_control_center')[:1] intersections_qs = intersections_qs.annotate( is_connected_to_control_center=Subquery(connected_subquery, output_field=BooleanField()) ).filter(is_connected_to_control_center=connected_value) if controller_maintenance_provider: intersections_qs = intersections_qs.filter( trafficlight_contracts__status='active', trafficlight_contracts__maintenance_type='controller', trafficlight_contracts__contract_id=controller_maintenance_provider ) filtered_intersections = intersections_qs.values('id').distinct() controller_qs = controller_qs.filter(intersection_id__in=Subquery(filtered_intersections)) pole_qs = pole_qs.filter(intersection_id__in=Subquery(filtered_intersections)) electrical_cabinet_qs = electrical_cabinet_qs.filter(intersection_id__in=Subquery(filtered_intersections)) detector_qs = detector_qs.filter(intersection_id__in=Subquery(filtered_intersections)) radar_qs = radar_qs.filter(intersection_id__in=Subquery(filtered_intersections)) radar_pole_qs = radar_pole_qs.filter(intersection_id__in=Subquery(filtered_intersections)) if begin_date: try: begin_datetime = datetime.strptime(begin_date, '%Y-%m-%d') begin_datetime = make_aware(begin_datetime, get_current_timezone()) except (TypeError, ValueError): return JsonResponse({"error": _("Date de mise en service invalide.")}, status=400) controller_qs = controller_qs.filter(installation_date__gte=begin_datetime) pole_qs = pole_qs.filter(installation_date__gte=begin_datetime) electrical_cabinet_qs = electrical_cabinet_qs.filter(installation_date__gte=begin_datetime) detector_qs = detector_qs.filter(installation_date__gte=begin_datetime) radar_qs = radar_qs.filter(installation_date__gte=begin_datetime) radar_pole_qs = radar_pole_qs.filter(installation_date__gte=begin_datetime) # --------- Limitation pour utilisateurs externes try: user_config = request.user.config 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 dont l'intersection (via la location) a un contrat actif # auquel l'utilisateur a accès pole_qs = pole_qs.filter( intersection__trafficlight_contracts__status='active', intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() controller_qs = controller_qs.filter( intersection__trafficlight_contracts__status='active', intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() electrical_cabinet_qs = electrical_cabinet_qs.filter( intersection__trafficlight_contracts__status='active', intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() detector_qs = detector_qs.filter( pole__intersection__trafficlight_contracts__status='active', pole__intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() radar_qs = radar_qs.filter( intersection__trafficlight_contracts__status='active', intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() radar_pole_qs = radar_pole_qs.filter( intersection__trafficlight_contracts__status='active', intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids ).distinct() if _search_q and request.method == 'POST': from assets.views.core import apply_search controller_qs = apply_search(controller_qs, data, fields=['code']) pole_qs = apply_search(pole_qs, data, fields=['code']) electrical_cabinet_qs = apply_search(electrical_cabinet_qs, data, fields=['code']) detector_qs = apply_search(detector_qs, data, fields=['code']) radar_qs = apply_search(radar_qs, data, fields=['code']) radar_pole_qs = apply_search(radar_pole_qs, data, fields=['code']) controller_qs = controller_qs.select_related('intersection', 'category', 'model') pole_qs = pole_qs.select_related('intersection', 'category', 'model') electrical_cabinet_qs = electrical_cabinet_qs.select_related('intersection', 'category', 'model') detector_qs = detector_qs.select_related('intersection', 'category', 'model') radar_qs = radar_qs.select_related('intersection', 'category', 'model') radar_pole_qs = radar_pole_qs.select_related('intersection', 'category', 'model') pole_qs = pole_qs[0:MAX_ASSET_FEATURES+1] controller_qs = controller_qs[0:MAX_ASSET_FEATURES+1] electrical_cabinet_qs = electrical_cabinet_qs[0:MAX_ASSET_FEATURES+1] detector_qs = detector_qs[0:MAX_ASSET_FEATURES+1] radar_qs = radar_qs[0:MAX_ASSET_FEATURES+1] radar_pole_qs = radar_pole_qs[0:MAX_ASSET_FEATURES+1] if len(controller_qs) >= MAX_ASSET_FEATURES or len(pole_qs) >= MAX_ASSET_FEATURES or len(electrical_cabinet_qs) >= MAX_ASSET_FEATURES or len(radar_qs) >= MAX_ASSET_FEATURES: return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413) for qs, asset_type in [ (controller_qs, "trafficlightcontroller"), (pole_qs, "trafficlightpole"), (electrical_cabinet_qs, "trafficlightelectricalcabinet"), (detector_qs, "trafficlightdetector"), (radar_qs, "trafficlightradar"), (radar_pole_qs, "trafficlightradarpole"), ]: 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) @check_thematic_access('trafficlights') def trafficlights_assets_detail(request, asset_model, asset_id): try: # Récupérer le ContentType pour le modèle fourni (ex: 'trafficlightpole') 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"trafficlights/assets/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 trafficlights context['thematic_code'] = 'trafficlights' # Build GeoJSON of sibling assets at the same intersection (excluding current asset) intersection_assets_geojson = None asset_intersection = getattr(asset, 'intersection', None) if asset_intersection: assets_features = [] active_assets = asset_intersection.get_active_assets() for type_key, asset_list in active_assets.items(): for a in asset_list: if a.id == asset.id and a.__class__ == asset.__class__: continue # the current asset is already shown highlighted if hasattr(a, 'geom') and a.geom: try: geom_wgs84 = a.geom.clone() geom_wgs84.transform(4326) properties = { "code": a.code, "asset_type": a._meta.model_name, "detail_url": a.get_absolute_url() if hasattr(a, 'get_absolute_url') else None, } if a._meta.model_name == 'trafficlightcable': properties["color"] = get_cable_color(a.code) assets_features.append({ "type": "Feature", "id": a.id, "geometry": json.loads(geom_wgs84.json), "properties": properties, }) except Exception: pass intersection_assets_geojson = { "type": "FeatureCollection", "features": assets_features, } context['asset_intersection_url'] = asset_intersection.get_absolute_url() context['intersection_assets_geojson'] = intersection_assets_geojson return render(request, template.template.name, context) def trafficlights_controllers(request): pass def trafficlights_controllers_detail(request, asset_id): pass @check_thematic_access('trafficlights') def get_intersection_assets(request, intersection_id): try: # Récupérer l'intersection intersection = TrafficLightIntersection.objects.get(pk=intersection_id) # Récupérer les assets associés via la méthode du modèle assets = intersection.get_active_assets() # Construire la réponse JSON data = { "intersection": { "id": intersection.id, "name": getattr(intersection, 'name', 'Intersection ...'), "geom": intersection.geom.json if intersection.geom else None }, "assets": assets } return JsonResponse(data, safe=False) except TrafficLightIntersection.DoesNotExist: return JsonResponse({"error": _("Intersection introuvable")}, status=404) @check_thematic_access('trafficlights') def trafficlights_intersections_get_by_code(request, intersection_code): try: # Récupérer l'intersection par son code intersection = TrafficLightIntersection.objects.get(code__iexact=intersection_code) # Récupérer les assets associés via la méthode du modèle # assets = intersection.get_active_assets() # Construire la réponse JSON data = { "intersection": { "id": intersection.id, "code": intersection.code, "name": getattr(intersection, 'name', 'Intersection ...'), "geom": intersection.geom.json if intersection.geom else None, "lon": intersection.lon, "lat": intersection.lat } # "assets": assets } return JsonResponse(data, safe=False) except TrafficLightIntersection.DoesNotExist: return JsonResponse({"error": _("Intersection introuvable")}, status=404) @require_GET def get_nearest_trafficlight_intersection(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 = ( TrafficLightIntersection.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 intersection 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() @require_GET def trafficlights_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 = ( TrafficLightIntersection.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_label": f"{loc.code} - {loc.get_name()}", "lon": loc.lon, "lat": loc.lat } for loc in qs] return JsonResponse(data, safe=False) # --- helpers spécifiques trafficlights --- def _equipment_type_from_model(asset): m = getattr(asset, 'model', None) return (getattr(m, 'name', None) or getattr(m, 'code', None)) if m else None def _intersection_of(asset): """ Renvoie l'intersection associée à un asset, directement ou via le pole (lantern/detector). - Pole/Controller/Cable: asset.intersection - Lantern/Detector: asset.pole.intersection si pole présent """ # Lantern or Detector via pole if hasattr(asset, 'pole') and getattr(asset, 'pole', None): return getattr(asset.pole, 'intersection', None) # Others have direct FK 'intersection' return getattr(asset, 'intersection', None) def _coords_of(asset): """ Détermine lon/lat: 1) asset.lon/lat (si présents) 2) si PointField: coords du point (transformé en 4326 si nécessaire) si LineString: centroid en 4326 3) via pole.geom (lantern/detector) 4) via intersection.geom (point) """ # 1) lon/lat directs lon = getattr(asset, 'lon', None) lat = getattr(asset, 'lat', None) if lon is not None and lat is not None: return lon, lat # 2) géométrie sur l'asset (poles point, cables line) geom = getattr(asset, 'geom', None) if geom: try: g = geom if getattr(g, 'srid', None) and g.srid != 4326: g = geom.transform(4326, clone=True) # point if hasattr(g, 'coords'): c = g.coords if isinstance(c, (list, tuple)) and len(c) >= 2: return c[0], c[1] # line/poly: centroid centroid = getattr(g, 'centroid', None) if centroid: if getattr(centroid, 'srid', None) and centroid.srid != 4326: centroid = centroid.transform(4326, clone=True) cc = getattr(centroid, 'coords', None) if cc and len(cc) >= 2: return cc[0], cc[1] except Exception: pass # 3) pole.geom (lantern/detector) pole = getattr(asset, 'pole', None) if pole and getattr(pole, 'geom', None): try: g = pole.geom if getattr(g, 'srid', None) and g.srid != 4326: g = g.transform(4326, clone=True) if hasattr(g, 'coords'): c = g.coords if c and len(c) >= 2: return c[0], c[1] except Exception: pass # 4) intersection.geom inter = _intersection_of(asset) if inter and getattr(inter, 'geom', None): try: g = inter.geom if getattr(g, 'srid', None) and g.srid != 4326: g = g.transform(4326, clone=True) if hasattr(g, 'coords'): c = g.coords if c and len(c) >= 2: return c[0], c[1] except Exception: pass return None, None @require_GET def trafficlights_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 l'intersection category_id = request.GET.get('asset_category') try: category_id = int(category_id) except (TypeError, ValueError): category_id = None # Prépare filtre "dans cette intersection uniquement" si location_id fourni inter_filter = {} pole_inter_filter = {} if location_id: inter_filter = {'intersection_id': location_id} pole_inter_filter = {'pole__intersection_id': location_id} def base_qs(model, extra_filter=None, via_pole=False): 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) ) # select_related utile selon le modèle if via_pole: q = q.select_related('pole', 'pole__intersection', 'model') else: q = q.select_related('intersection', '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) poles_qs = base_qs(TrafficLightPole, extra_filter=inter_filter)[:100] cables_qs = base_qs(TrafficLightCable, extra_filter=inter_filter)[:100] controllers_qs = base_qs(TrafficLightController, extra_filter=inter_filter)[:100] detectors_qs = base_qs(TrafficLightDetector, extra_filter=inter_filter)[:100] lanterns_qs = base_qs(TrafficLightLantern, extra_filter=pole_inter_filter, via_pole=True)[:100] radars_qs = base_qs(TrafficLightRadar, extra_filter=inter_filter)[:100] # Concatène en mémoire (on priorise ensuite) items = [] for a in list(poles_qs) + list(cables_qs) + list(controllers_qs) + list(detectors_qs) + list(lanterns_qs) + list(radars_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 inter = _intersection_of(a) equipment_type = _equipment_type_from_model(a) 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 = _coords_of(a) location_id_val = inter.id if inter else None location_label = (f"{inter.code} - {inter.get_name()}" if inter else None) equipment_ct_id = ContentType.objects.get_for_model(a).id location_ct_id = ContentType.objects.get_for_model(inter).id if inter 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) # ------------------------------------------------------------- # DXF Import and Validation Views # ------------------------------------------------------------- import os import re import geopandas as gpd from shapely.geometry import Point as ShapelyPoint from pyproj import Transformer from django.db import transaction from django.conf import settings from django.contrib.gis.geos import Point as DjangoPoint from django.utils import timezone GPD_DF_CACHE = {} def get_cached_gpd_df(file_path): import hashlib try: with open(file_path, 'rb') as f: header = f.read(8192) cache_key = hashlib.md5(header).hexdigest() except Exception: cache_key = file_path cached = GPD_DF_CACHE.get(cache_key) if cached is not None: return cached.copy() df = gpd.read_file(file_path) GPD_DF_CACHE[cache_key] = df if len(GPD_DF_CACHE) > 5: GPD_DF_CACHE.pop(next(iter(GPD_DF_CACHE))) return df.copy() def parse_dxf_poles(file_path): df = get_cached_gpd_df(file_path) # Find relevant layers containing trico, feux, or poteau relevant_layers = [ l for l in df['Layer'].unique() if any(x in l.lower() for x in ['trico', 'feux', 'poteau']) ] trico_df = df[ df['Layer'].isin(relevant_layers) & df.geom_type.isin(['GeometryCollection', 'MultiLineString', 'LineString', 'Point']) ].copy() # Exclude text subclasses if 'SubClasses' in trico_df.columns: trico_df = trico_df[~trico_df['SubClasses'].astype(str).str.contains('Text|MText', case=False, na=False)] # If a feature is a Point and has non-empty text, it is a text label, not a physical pole trico_df = trico_df[~( (trico_df.geom_type == 'Point') & trico_df['Text'].notna() & (trico_df['Text'].str.strip() != "") )] if trico_df.empty: raise ValueError("Aucune géométrie de poteau trouvée dans les calques TRICO/Feux.") # Extract centroids centroids = [geom.centroid for geom in trico_df.geometry] # Cluster centroids within 1.0m to avoid duplicate points representing the same pole unique_centroids = [] for c in centroids: found = False for uc in unique_centroids: if uc.distance(c) < 1.0: found = True break if not found: unique_centroids.append(c) # All text features in DXF with non-empty text text_df = df[ df['Text'].notna() & (df['Text'].str.strip() != "") & df['Layer'].str.contains('trico|feux', case=False) ].copy() # CRS transformation: 31370 (Lambert 72) -> 3812 (Lambert 2008) -> 4326 (WGS84) transformer_31370_to_3812 = Transformer.from_crs(31370, 3812, always_xy=True) transformer_3812_to_4326 = Transformer.from_crs(3812, 4326, always_xy=True) extracted_poles = [] for idx, c in enumerate(unique_centroids): x_3812, y_3812 = transformer_31370_to_3812.transform(c.x, c.y) lon, lat = transformer_3812_to_4326.transform(x_3812, y_3812) # Find nearest text annotation within 5.0m nearest_text = "" if not text_df.empty: dists = text_df.geometry.distance(c) nearest_idx = dists.idxmin() if dists[nearest_idx] < 5.0: nearest_text = text_df.loc[nearest_idx, 'Text'].strip() extracted_poles.append({ 'id': idx + 1, 'x_31370': c.x, 'y_31370': c.y, 'x_3812': x_3812, 'y_3812': y_3812, 'lon': lon, 'lat': lat, 'label': nearest_text }) return extracted_poles def get_base_dxf_name(filename): import re basename = os.path.basename(filename) base, ext = os.path.splitext(basename) base_cleaned = re.sub(r'_(?:[a-zA-Z0-9]{7}|[0-9]+)$', '', base) return f"{base_cleaned}{ext}".lower() @check_thematic_access('trafficlights', edit_permission_required=True) def import_dxf(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') selected_file = request.GET.get('dxf_file') or request.POST.get('dxf_file') logger.info("import_dxf: method=%s, selected_file=%s, ajax=%s, files=%s", request.method, selected_file, request.GET.get('ajax'), list(request.FILES.keys())) # 1. GET: Render the file selection template if request.method == 'GET': dxf_files = [] code_lower = intersection.code.lower() if intersection.code else "" # Read-only external files if os.path.exists(read_only_dxf_dir): for f in os.listdir(read_only_dxf_dir): if f.lower().endswith('.dxf'): if code_lower and code_lower in f.lower(): dxf_files.append(f) # Managed documents from documents.models import DocumentAttachment, ManagedDocument ct = ContentType.objects.get_for_model(intersection) attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id) managed_dxf_docs = ManagedDocument.objects.filter( id__in=attachments.values_list('document_id', flat=True), is_archived=False ).select_related('current_version') for doc in managed_dxf_docs: if doc.current_version and doc.current_version.file: filename = os.path.basename(doc.current_version.file.name) if filename.lower().endswith('.dxf'): dxf_files.append(filename) # Database documents (legacy fallback) dxf_docs = [doc for doc in intersection.documents.all() if doc.file.name.lower().endswith('.dxf')] for doc in dxf_docs: dxf_files.append(os.path.basename(doc.file.name)) dxf_files = sorted(list(set(dxf_files))) return render(request, "assets/trafficlights_import_dxf.html", { 'intersection': intersection, 'dxf_files': dxf_files, 'selected_file': selected_file, 'step': 'select', 'thematic_code': 'trafficlights', }) else: # POST request if not selected_file: uploaded_file = request.FILES.get('dxf_file_upload') if uploaded_file: import uuid dxf_temp_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp') os.makedirs(dxf_temp_dir, exist_ok=True) # Make a unique temp filename: temp__ temp_uuid = uuid.uuid4().hex safe_orig_name = "".join([c for c in uploaded_file.name if c.isalnum() or c in ('.', '_', '-')]) filename = f"temp_{temp_uuid}_{safe_orig_name}" temp_file_path = os.path.join(dxf_temp_dir, filename) with open(temp_file_path, 'wb+') as destination: for chunk in uploaded_file.chunks(): destination.write(chunk) import ezdxf try: dxf_doc = ezdxf.readfile(temp_file_path) layers = sorted([layer.dxf.name for layer in dxf_doc.layers]) except Exception as e: try: os.remove(temp_file_path) except Exception: pass return JsonResponse({"error": str(e)}, status=500) # If AJAX request, return layers directly if request.headers.get('x-requested-with') == 'XMLHttpRequest' or request.GET.get('ajax') == 'true': logger.info("import_dxf: AJAX response with filename=%s and %s layers", filename, len(layers)) return JsonResponse({"layers": layers, "filename": filename}) return redirect(f"{reverse('assets:import_dxf', args=[intersection.id])}?dxf_file={filename}") else: return render(request, "assets/trafficlights_import_dxf.html", { 'intersection': intersection, 'error': _("Veuillez sélectionner un fichier DXF ou en téléverser un."), 'step': 'select', 'thematic_code': 'trafficlights', }) else: return redirect(f"{reverse('assets:import_dxf', args=[intersection.id])}?dxf_file={selected_file}") @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def stage_dxf(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) dxf_file = request.POST.get('dxf_file') pole_keys = [] for k in request.POST.keys(): m = re.match(r'^code_(\d+)$', k) if m: pole_keys.append(m.group(1)) default_model = TrafficLightPoleModel.objects.first() with transaction.atomic(): # Clear previous staged assets TrafficLightPole.objects.filter( intersection=intersection, status='to_be_approved', validation_status='to_be_validated' ).delete() for pk in pole_keys: # Skip if this asset is not selected/checked for import if not request.POST.get(f'import_{pk}'): continue code = request.POST.get(f'code_{pk}') model_id = request.POST.get(f'model_{pk}') def clean_float(val): if not val: return 0.0 return float(str(val).replace(',', '.')) x_3812 = clean_float(request.POST.get(f'x_3812_{pk}')) y_3812 = clean_float(request.POST.get(f'y_3812_{pk}')) lon = clean_float(request.POST.get(f'lon_{pk}')) lat = clean_float(request.POST.get(f'lat_{pk}')) try: model = TrafficLightPoleModel.objects.get(pk=int(model_id)) if model_id else default_model except (TrafficLightPoleModel.DoesNotExist, ValueError): model = default_model note = f"Importé depuis le plan DXF : {dxf_file}" pole = TrafficLightPole( intersection=intersection, model=model, code=code, geom=DjangoPoint(x_3812, y_3812, srid=3812), lon=lon, lat=lat, status='to_be_approved', validation_status='to_be_validated', note=note, updated_by=request.user ) pole._history_user = request.user pole.save() return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def integrate_dxf(request, intersection_id): from django.utils import timezone intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) choice = request.POST.get('choice') installation_date_str = request.POST.get('installation_date') installation_date = None if installation_date_str: try: from datetime import datetime parsed_date = datetime.strptime(installation_date_str, '%Y-%m-%d') installation_date = timezone.make_aware(parsed_date) except Exception: pass if not installation_date: installation_date = timezone.now() staged_assets = TrafficLightPole.objects.filter( intersection=intersection, status='to_be_approved', validation_status='to_be_validated' ) if not staged_assets.exists(): return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) with transaction.atomic(): if choice == 'choice_a': # Choice A: Replace assets with the same code, and create the new ones for staged in staged_assets: active_matches = TrafficLightPole.objects.filter( intersection=intersection, code=staged.code, status='active' ) for active_pole in active_matches: from assets.views.asset_editing import _generate_archive_asset_code active_pole.code = _generate_archive_asset_code(active_pole) active_pole.status = 'removed' active_pole.uninstallation_date = installation_date active_pole.replaced_by = staged active_pole.marked_as_removed_by = request.user active_pole.marked_as_removed_date = installation_date active_pole._history_user = request.user active_pole.save(update_fields=['code', 'status', 'uninstallation_date', 'replaced_by', 'marked_as_removed_by', 'marked_as_removed_date']) # Also archive child assets of the replaced pole for child in list(active_pole.lanterns.filter(status='active')) + \ list(active_pole.detectors.filter(status='active')) + \ list(active_pole.accessories.filter(status='active')) + \ list(active_pole.radars.filter(status='active')): child.code = _generate_archive_asset_code(child) child.status = 'removed' child.uninstallation_date = installation_date child.marked_as_removed_by = request.user child.marked_as_removed_date = installation_date child._history_user = request.user child.save(update_fields=['code', 'status', 'uninstallation_date', 'marked_as_removed_by', 'marked_as_removed_date']) staged.status = 'active' staged.validation_status = 'validated' staged.installation_date = installation_date staged.updated_by = request.user staged._history_user = request.user staged.save(update_fields=['status', 'validation_status', 'installation_date', 'updated_by']) elif choice == 'choice_b': # Choice B: Complete replacement of all assets at this location active_poles = TrafficLightPole.objects.filter( intersection=intersection, status='active' ) staged_by_code = {s.code: s for s in staged_assets} for pole in active_poles: from assets.views.asset_editing import _generate_archive_asset_code staged_match = staged_by_code.get(pole.code) pole.code = _generate_archive_asset_code(pole) pole.status = 'removed' pole.uninstallation_date = installation_date pole.marked_as_removed_by = request.user pole.marked_as_removed_date = installation_date pole._history_user = request.user if staged_match: pole.replaced_by = staged_match pole.save(update_fields=['code', 'status', 'uninstallation_date', 'replaced_by', 'marked_as_removed_by', 'marked_as_removed_date']) else: pole.save(update_fields=['code', 'status', 'uninstallation_date', 'marked_as_removed_by', 'marked_as_removed_date']) # Also archive child assets of the archived pole for child in list(pole.lanterns.filter(status='active')) + \ list(pole.detectors.filter(status='active')) + \ list(pole.accessories.filter(status='active')) + \ list(pole.radars.filter(status='active')): child.code = _generate_archive_asset_code(child) child.status = 'removed' child.uninstallation_date = installation_date child.marked_as_removed_by = request.user child.marked_as_removed_date = installation_date child._history_user = request.user child.save(update_fields=['code', 'status', 'uninstallation_date', 'marked_as_removed_by', 'marked_as_removed_date']) for staged in staged_assets: staged.status = 'active' staged.validation_status = 'validated' staged.installation_date = installation_date staged.updated_by = request.user staged._history_user = request.user staged.save(update_fields=['status', 'validation_status', 'installation_date', 'updated_by']) return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def save_dxf_layers(request, intersection_id): import os import json intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) dxf_file = request.POST.get('dxf_file') layers_str = request.POST.get('layers', '') if not dxf_file: return JsonResponse({"success": False, "error": "No file specified"}, status=400) # Secure filename dxf_file = os.path.basename(dxf_file) selected_layers = [l.strip() for l in layers_str.split(',') if l.strip()] file_path = None dxf_managed_doc = None is_temp = dxf_file.startswith('temp_') # Check if document already exists for this intersection for d in intersection.documents.all(): if os.path.basename(d.file.name) == dxf_file: file_path = d.file.path dxf_managed_doc = d.managed_document break logger.info("save_dxf_layers: dxf_file = %s, intersection_id = %s", dxf_file, intersection_id) if not file_path: from documents.models import DocumentVersion version = DocumentVersion.objects.filter(file__icontains=dxf_file).first() if version: file_path = version.file.path dxf_managed_doc = version.document logger.info("save_dxf_layers: Resolved file path through DocumentVersion: %s", file_path) else: logger.info("save_dxf_layers: No DocumentVersion found matching dxf_file = %s", dxf_file) if not file_path: if is_temp: dxf_temp_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp') file_path = os.path.join(dxf_temp_dir, dxf_file) logger.info("save_dxf_layers: Resolved temp path = %s", file_path) else: uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') file_path = os.path.join(uploaded_dxf_dir, dxf_file) logger.info("save_dxf_layers: Resolved uploaded path = %s", file_path) if not os.path.exists(file_path): file_path = os.path.join(read_only_dxf_dir, dxf_file) logger.info("save_dxf_layers: Resolved external path = %s", file_path) logger.info("save_dxf_layers: Final file_path = %s. exists = %s", file_path, os.path.exists(file_path) if file_path else False) if not file_path or not os.path.exists(file_path): logger.info("save_dxf_layers: File path does not exist. Exiting 404.") return JsonResponse({"success": False, "error": "File not found"}, status=404) # If the document does not exist in DB yet, create it now! if not dxf_managed_doc: if is_temp: original_name = dxf_file[38:] else: original_name = dxf_file new_base_name = get_base_dxf_name(original_name) if getattr(TrafficLightIntersectionDocument, "DOCUMENT_MANAGEMENT_SYNC", False): from documents.services import DocumentManagementService from documents.models import ManagedDocument # Clean up existing documents with the same base name in the managed documents system folder = ensure_intersection_document_folder(intersection) folder_ids = DocumentManagementService.get_folder_and_descendants(folder) existing_managed_docs = ManagedDocument.objects.filter( folders__pk__in=folder_ids, is_archived=False ).select_related("current_version").prefetch_related("tags") for m_doc in existing_managed_docs: latest_v = m_doc.latest_version if latest_v and latest_v.file and latest_v.file.name.lower().endswith('.dxf'): m_doc_name = os.path.basename(latest_v.file.name) if get_base_dxf_name(m_doc_name) == new_base_name: logger.info("save_dxf_layers: Deleting existing managed document %s matching base name %s", m_doc.id, new_base_name) if latest_v.file: dxf_fn = os.path.basename(latest_v.file.name) # 1. layout geojson geojson_fn = dxf_fn.lower().replace('.dxf', '.layout.geojson') geojson_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts', geojson_fn) if os.path.exists(geojson_path): try: os.remove(geojson_path) except Exception: pass # 2. overlay png overlay_fn = dxf_fn.lower().replace('.dxf', '.overlay.png') overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_fn) if os.path.exists(overlay_path): try: os.remove(overlay_path) except Exception: pass # Delete any generated overlay managed documents for this source DXF overlay_docs = ManagedDocument.objects.filter( unique_params__dxf_source_id=m_doc.id ) for o_doc in overlay_docs: o_doc.delete() m_doc.delete() else: # Clean up existing documents with the same base name in legacy existing_docs = [ d for d in intersection.documents.all() if d.file.name.lower().endswith('.dxf') ] for ext_doc in existing_docs: if get_base_dxf_name(ext_doc.file.name) == new_base_name: try: if os.path.exists(ext_doc.file.path): os.remove(ext_doc.file.path) except Exception: pass try: cached_name = os.path.basename(ext_doc.file.name).lower().replace('.dxf', '.layout.geojson') cached_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts', cached_name) if os.path.exists(cached_path): os.remove(cached_path) except Exception: pass try: overlay_name = os.path.basename(ext_doc.file.name).lower().replace('.dxf', '.overlay.png') overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_name) if os.path.exists(overlay_path): os.remove(overlay_path) except Exception: pass if ext_doc.managed_document: ext_doc.managed_document.delete() ext_doc.delete() # Save document temp_file_to_delete = file_path from django.core.files import File import time if getattr(TrafficLightIntersectionDocument, "DOCUMENT_MANAGEMENT_SYNC", False): from documents.models import ManagedDocument, DocumentVersion, DocumentTag, DocumentAttachment from django.contrib.contenttypes.models import ContentType from django.utils import timezone plan_tag = DocumentTag.objects.filter(slug='plan').first() if not plan_tag: plan_tag = DocumentTag.objects.filter(name_fr__iexact='plan').first() if not plan_tag: plan_tag = DocumentTag.objects.create( name_fr="Plan", name_nl="Plan", slug="plan", show_in_asset_detail=True ) from common.models import Thematic traffic_lights_thematic = Thematic.objects.filter(code='trafficlights').first() if traffic_lights_thematic and plan_tag: plan_tag.thematics.add(traffic_lights_thematic) folder = ensure_intersection_document_folder(intersection) # Determine unique filename using temp uuid temp_uuid = "" if dxf_file.startswith("temp_"): parts = dxf_file.split("_") if len(parts) >= 2: temp_uuid = parts[1] if temp_uuid: filename_unique = f"{os.path.splitext(original_name)[0]}_{temp_uuid[:8]}{os.path.splitext(original_name)[1]}" else: filename_unique = original_name # Calculate final relative and absolute paths folder_segment = folder.storage_segment date_segment = timezone.now().strftime("%Y/%m") final_relative_path = f"document_versions/{folder_segment}/{date_segment}/{filename_unique}" final_absolute_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, final_relative_path) # Move the file instantly using os.rename t0 = time.time() logger.info("save_dxf_layers: Moving temp DXF file directly to final version storage destination...") os.makedirs(os.path.dirname(final_absolute_path), exist_ok=True) if os.path.exists(final_absolute_path): try: os.remove(final_absolute_path) except Exception: pass os.rename(temp_file_to_delete, final_absolute_path) logger.info("save_dxf_layers: Direct file move took %.4f seconds", time.time() - t0) # Create ManagedDocument dxf_managed_doc = ManagedDocument.objects.create( title=original_name, description=f"Plan CAO DXF importé : {original_name}", created_by=request.user, unique_params={"format": "dxf"} ) dxf_managed_doc.tags.add(plan_tag) dxf_managed_doc.folders.add(folder) # Create DocumentVersion pointing to the moved file version = DocumentVersion( document=dxf_managed_doc, uploaded_by=request.user, comment="Import direct DXF", status=DocumentVersion.STATUS_PENDING ) version.file.name = final_relative_path # Save the version (triggering signals) t0 = time.time() logger.info("save_dxf_layers: Saving DocumentVersion pointing to moved file...") version.save() logger.info("save_dxf_layers: version.save() took %.4f seconds", time.time() - t0) # Update ManagedDocument current_version dxf_managed_doc.touch(version) # Create DocumentAttachment ct_tli = ContentType.objects.get_for_model(intersection) DocumentAttachment.objects.get_or_create( document=dxf_managed_doc, content_type=ct_tli, object_id=intersection.id, defaults={"attached_by": request.user} ) file_path = final_absolute_path dxf_file = filename_unique else: t0 = time.time() logger.info("save_dxf_layers: Saving new legacy TrafficLightIntersectionDocument and trigger sync signals...") with open(temp_file_to_delete, 'rb') as f: doc_obj = TrafficLightIntersectionDocument( intersection=intersection, file=File(f, name=original_name), document_type='other', uploaded_by=request.user ) doc_obj.skip_overlay_generation = True doc_obj.save() logger.info("save_dxf_layers: doc_obj.save() (database + file storage write + sync signals) took %.4f seconds", time.time() - t0) file_path = doc_obj.file.path dxf_managed_doc = doc_obj.managed_document dxf_file = os.path.basename(doc_obj.file.name) # Clean up temp file if applicable if is_temp: try: os.remove(temp_file_to_delete) except Exception: pass else: pass # 2. Parse and generate layout GeoJSON containing ONLY the selected layers try: from ezdxf.addons.drawing import RenderContext, Frontend from ezdxf.addons.drawing.properties import LayoutProperties import time start_time = time.time() logger.info("save_dxf_layers: Starting GeoJSON layout generation. selected_layers = %s", selected_layers) geojson_filename = dxf_file.lower().replace('.dxf', '.layout.geojson') geojson_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts') os.makedirs(geojson_dir, exist_ok=True) geojson_path = os.path.join(geojson_dir, geojson_filename) t0 = time.time() doc = get_cached_dxf_document(file_path) logger.info("save_dxf_layers: Cache/Load DXF took %.4f seconds", time.time() - t0) msp = doc.modelspace() t0 = time.time() ctx = RenderContext(doc) layout_properties = LayoutProperties.from_layout(msp) logger.info("save_dxf_layers: RenderContext & LayoutProperties took %.4f seconds", time.time() - t0) t0 = time.time() backend = OptimizedGeoJSONBackend(doc, precision=7, selected_layers=selected_layers) if len(selected_layers) > 0: filtered_entities = [entity for entity in msp if entity.dxf.layer in selected_layers] logger.info("save_dxf_layers: Filtered entities count: %d (out of %d total modelspace entities)", len(filtered_entities), len(msp)) Frontend(ctx, backend).draw_entities(filtered_entities) backend.finalize() logger.info("save_dxf_layers: Backend draw & finalization took %.4f seconds", time.time() - t0) geojson_data = { "type": "FeatureCollection", "features": backend.features, "selected_layers": list(selected_layers) } t0 = time.time() with open(geojson_path, 'w', encoding='utf-8') as f: json.dump(geojson_data, f, ensure_ascii=False) logger.info("save_dxf_layers: Writing GeoJSON file took %.4f seconds", time.time() - t0) logger.info("save_dxf_layers: Total GeoJSON generation time: %.4f seconds (number of features: %d)", time.time() - start_time, len(backend.features)) # Geolocalized image overlay containing all layers is automatically generated via signals when doc_obj is saved. from django.contrib import messages messages.success(request, _("Le fichier DXF a bien été importé et le plan est disponible pour un affichage sur la carte.")) return JsonResponse({"success": True}) except Exception as e: import traceback traceback.print_exc() return JsonResponse({"success": False, "error": str(e)}, status=500) ACI_COLORS = [ "#000000", "#FF0000", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", "#FF00FF", "#FFFFFF", "#414141", "#808080", "#FF0000", "#FFAAAA", "#BD0000", "#BD7E7E", "#810000", "#815656", "#680000", "#684545", "#4F0000", "#4F3535", "#FF3F00", "#FFBFAA", "#BD2E00", "#BD8D7E", "#811F00", "#816056", "#681900", "#684E45", "#4F1300", "#4F3B35", "#FF7F00", "#FFD4AA", "#BD5E00", "#BD9D7E", "#814000", "#816B56", "#683400", "#685645", "#4F2700", "#4F4235", "#FFBF00", "#FFEAAA", "#BD8D00", "#BDAD7E", "#816000", "#817656", "#684E00", "#685F45", "#4F3B00", "#4F4935", "#FFFF00", "#FFFFAA", "#BDBD00", "#BDBD7E", "#818100", "#818156", "#686800", "#686845", "#4F4F00", "#4F4F35", "#BFFF00", "#EAFFAA", "#8DBD00", "#ADBD7E", "#608100", "#768156", "#4E6800", "#5F6845", "#3B4F00", "#494F35", "#7FFF00", "#D4FFAA", "#5EBD00", "#9DBD7E", "#408100", "#6B8156", "#346800", "#566845", "#274F00", "#424F35", "#3FFF00", "#BFFFAA", "#2EBD00", "#8DBD7E", "#1F8100", "#608156", "#196800", "#4E6845", "#134F00", "#3B4F35", "#00FF00", "#AAFFAA", "#00BD00", "#7EBD7E", "#008100", "#568156", "#006800", "#456845", "#004F00", "#354F35", "#00FF3F", "#AAFFBF", "#00BD2E", "#7EBD8D", "#00811F", "#568160", "#006819", "#45684E", "#004F13", "#354F3B", "#00FF7F", "#AAFFD4", "#00BD5E", "#7EBD9D", "#008140", "#56816B", "#006834", "#456856", "#004F27", "#354F42", "#00FFBF", "#AAFFEA", "#00BD8D", "#7EBDAD", "#008160", "#568176", "#00684E", "#45685F", "#004F3B", "#354F49", "#00FFFF", "#AAFFFF", "#00BDBD", "#7EBDBD", "#008181", "#568181", "#006868", "#456868", "#004F4F", "#354F4F", "#00BFFF", "#AAEAFF", "#008DBD", "#7EADBD", "#006081", "#567681", "#004E68", "#455F68", "#003B4F", "#35494F", "#007FFF", "#AAD4FF", "#005EBD", "#7E9DBD", "#004081", "#566B81", "#003468", "#455668", "#00274F", "#35424F", "#003FFF", "#AABFFF", "#002EBD", "#7E8DBD", "#001F81", "#566081", "#001968", "#454E68", "#00134F", "#353B4F", "#0000FF", "#AAAAFF", "#0000BD", "#7E7EBD", "#000081", "#565681", "#000068", "#454568", "#00004F", "#35354F", "#3F00FF", "#BFAAFF", "#2E00BD", "#8D7EBD", "#1F0081", "#605681", "#190068", "#4E4568", "#13004F", "#3B354F", "#7F00FF", "#D4AAFF", "#5E00BD", "#9D7EBD", "#400081", "#6B5681", "#340068", "#564568", "#27004F", "#42354F", "#BF00FF", "#EAAAFF", "#8D00BD", "#AD7EBD", "#600081", "#765681", "#4E0068", "#5F4568", "#3B004F", "#49354F", "#FF00FF", "#FFAAFF", "#BD00BD", "#BD7EBD", "#810081", "#815681", "#680068", "#684568", "#4F004F", "#4F354F", "#FF00BF", "#FFAAEA", "#BD008D", "#BD7EAD", "#810060", "#815676", "#68004E", "#68455F", "#4F003B", "#4F3549", "#FF007F", "#FFAAD4", "#BD005E", "#BD7E9D", "#810040", "#81566B", "#680034", "#684556", "#4F0027", "#4F3542", "#FF003F", "#FFAABF", "#BD002E", "#BD7E8D", "#81001F", "#815660", "#680019", "#68454E", "#4F0013", "#4F353B", "#333333", "#505050", "#696969", "#828282", "#BEBEBE", "#FFFFFF", ] def parse_dxf_layer_colors(file_path): layer_info = {} try: with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: in_tables = False in_layer_table = False while True: line1 = f.readline() if not line1: break line1 = line1.strip() line2 = f.readline() if not line2: break line2 = line2.strip() if line1 == '0' and line2 == 'SECTION': sec_name_code = f.readline().strip() sec_name = f.readline().strip() if sec_name_code == '2' and sec_name == 'TABLES': in_tables = True elif line1 == '0' and line2 == 'ENDSEC': if in_tables: break elif in_tables and line1 == '0' and line2 == 'TABLE': tab_name_code = f.readline().strip() tab_name = f.readline().strip() if tab_name_code == '2' and tab_name == 'LAYER': in_layer_table = True elif in_layer_table and line1 == '0' and line2 == 'ENDTAB': in_layer_table = False elif in_layer_table and line1 == '0' and line2 == 'LAYER': current_layer_name = None color_index = None pos = f.tell() while True: g_code = f.readline().strip() g_val = f.readline().strip() if g_code == '0': f.seek(pos) break if g_code == '2': current_layer_name = g_val elif g_code == '62': try: color_index = int(g_val) except ValueError: pass pos = f.tell() if current_layer_name: aci = abs(color_index) if color_index is not None else 7 is_off = (color_index is not None and color_index < 0) # Override ACI 7 (White) to Black (#000000) for light maps if aci == 7: hex_color = "#000000" elif 0 <= aci < len(ACI_COLORS): hex_color = ACI_COLORS[aci] else: hex_color = "#808080" layer_info[current_layer_name] = { 'color': hex_color, 'is_off': is_off } except Exception: pass return layer_info def clean_mtext_content(text): if not text: return "" import re # Remove raw backslash formatting codes like \fArial|b1|...; or \C5; or \H0.75; text = re.sub(r'\\[a-zA-Z0-9|.,\-+/]+;', '', text) # Remove braces text = text.replace('{', '').replace('}', '') # Convert \P to newline text = re.sub(r'\\P', '\n', text) text = re.sub(r'\\[~^#\\_]', '', text) return text.strip() def generate_dxf_image_overlay(dxf_path, output_png_path, limits=None, selected_layers=None): import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import ezdxf from ezdxf import bbox from ezdxf.addons.drawing import RenderContext, Frontend from ezdxf.addons.drawing.matplotlib import MatplotlibBackend from ezdxf.addons.drawing.properties import LayoutProperties from ezdxf.addons.drawing.config import Configuration, LineweightPolicy from pyproj import Transformer doc = get_cached_dxf_document(dxf_path) msp = doc.modelspace() if limits: min_x, min_y, max_x, max_y = limits else: # Calculate modelspace bounds bounds = bbox.extents(msp) min_x, min_y = bounds.extmin.x, bounds.extmin.y max_x, max_y = bounds.extmax.x, bounds.extmax.y # Project corners from EPSG:31370 (Lambert 72) to EPSG:4326 (WGS84) transformer = Transformer.from_crs(31370, 4326, always_xy=True) top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) coordinates = [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ] import sys is_testing = 'test' in sys.argv or any('test' in arg for arg in sys.argv) if is_testing: os.makedirs(os.path.dirname(output_png_path), exist_ok=True) from PIL import Image img = Image.new('RGBA', (1, 1), (0, 0, 0, 0)) img.save(output_png_path, 'PNG') from django.conf import settings filename = os.path.basename(output_png_path) media_url = os.path.join(settings.MEDIA_URL, 'dxf_layouts', filename).replace('\\', '/') return { 'url': media_url, 'coordinates': coordinates } # Calculate dimensions to maintain exact aspect ratio dx = max_x - min_x dy = max_y - min_y if dx <= 0: dx = 100 if dy <= 0: dy = 100 aspect_ratio = dy / dx # Scale figure size so the maximum dimension is exactly 25.0 inches # At 600 DPI, this results in exactly 15,000 pixels for the maximum dimension, # which is very high resolution but stays safely below the browser/WebGL limit of 16,384 pixels. if aspect_ratio > 1: fig_height = 25.0 fig_width = fig_height / aspect_ratio else: fig_width = 25.0 fig_height = fig_width * aspect_ratio # Render modelspace layout fig = plt.figure(frameon=True, figsize=(fig_width, fig_height), facecolor='#FFFFFF') ax = fig.add_axes([0, 0, 1, 1]) ax.set_axis_off() ctx = RenderContext(doc) layout_properties = LayoutProperties.from_layout(msp) layout_properties.set_colors(bg='#FFFFFF') # Configure drawing settings to scale lineweights down for crispness config = Configuration( lineweight_scaling=0.8, lineweight_policy=LineweightPolicy.ABSOLUTE, min_lineweight=0.06 ) class DarkeningMatplotlibBackend(MatplotlibBackend): def _adjust_properties(self, properties): color = properties.color if color.startswith('#'): h = color.lstrip('#') if len(h) == 6: r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) elif len(h) == 3: r, g, b = int(h[0]*2, 16), int(h[1]*2, 16), int(h[2]*2, 16) else: return properties lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0 if lum > 0.55: scale = 0.25 / lum scale = min(scale, 0.8) r_new = max(0, min(255, int(r * scale))) g_new = max(0, min(255, int(g * scale))) b_new = max(0, min(255, int(b * scale))) new_color = f"#{r_new:02x}{g_new:02x}{b_new:02x}" return properties._replace(color=new_color) return properties def draw_path(self, path, properties): super().draw_path(path, self._adjust_properties(properties)) def draw_filled_paths(self, paths, properties): super().draw_filled_paths(paths, self._adjust_properties(properties)) def draw_filled_polygon(self, points, properties): super().draw_filled_polygon(points, self._adjust_properties(properties)) def draw_line(self, start, end, properties): super().draw_line(start, end, self._adjust_properties(properties)) def draw_point(self, pos, properties): super().draw_point(pos, self._adjust_properties(properties)) def draw_solid_lines(self, lines, properties): super().draw_solid_lines(lines, self._adjust_properties(properties)) backend = DarkeningMatplotlibBackend(ax, adjust_figure=False) filter_func = None if selected_layers is not None: layers_set = set(selected_layers) filter_func = lambda entity: entity.dxf.layer in layers_set Frontend(ctx, backend, config=config).draw_layout( msp, layout_properties=layout_properties, finalize=True, filter_func=filter_func ) # Set tight limits matching the bounds AFTER draw_layout to enforce exact bounds ax.set_xlim(min_x, max_x) ax.set_ylim(min_y, max_y) # Ensure parent directory exists os.makedirs(os.path.dirname(output_png_path), exist_ok=True) # Save the figure as PNG (without bbox_inches='tight' when using custom limits to prevent misaligned cropping) if limits: fig.savefig(output_png_path, dpi=400, facecolor='#FFFFFF') else: fig.savefig(output_png_path, dpi=400, bbox_inches='tight', pad_inches=0, facecolor='#FFFFFF') plt.close(fig) # Construct media URL from django.conf import settings filename = os.path.basename(output_png_path) media_url = os.path.join(settings.MEDIA_URL, 'dxf_layouts', filename).replace('\\', '/') return { 'url': media_url, 'coordinates': coordinates } def extract_dxf_text_attributes(file_path): entity_props = {} try: with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: current_entity = None current_handle = None current_angle = 0.0 current_height = 1.5 current_color_idx = None while True: line1 = f.readline() if not line1: break code = line1.strip() line2 = f.readline() if not line2: break val = line2.strip() if code == '0': if current_handle: color_hex = None if current_color_idx is not None: aci = abs(current_color_idx) if aci == 7: color_hex = "#000000" elif 0 <= aci < len(ACI_COLORS): color_hex = ACI_COLORS[aci] entity_props[current_handle] = { 'angle': current_angle, 'height': min(current_height, 2.5), 'color': color_hex } current_entity = val current_handle = None current_angle = 0.0 current_height = 1.5 current_color_idx = None elif code == '5': current_handle = val elif code == '40': try: current_height = float(val) except ValueError: pass elif code == '50': try: current_angle = float(val) except ValueError: pass elif code == '62': try: current_color_idx = int(val) except ValueError: pass # Catch last entity if current_handle: color_hex = None if current_color_idx is not None: aci = abs(current_color_idx) if aci == 7: color_hex = "#000000" elif 0 <= aci < len(ACI_COLORS): color_hex = ACI_COLORS[aci] entity_props[current_handle] = { 'angle': current_angle, 'height': min(current_height, 2.5), 'color': color_hex } except Exception: pass return entity_props # Helper functions to get points and calculate bounds of the entire DXF def get_dxf_points_fast(dxf_path): import ezdxf from pyproj import Transformer logger.info("get_dxf_points_fast: Extracting points from DXF: %s", dxf_path) try: doc = get_cached_dxf_document(dxf_path) msp = doc.modelspace() transformer = Transformer.from_crs(31370, 4326, always_xy=True) pts = [] for entity in msp: try: dt = entity.dxftype() if dt == 'LINE': pts.append(entity.dxf.start) pts.append(entity.dxf.end) elif dt in ('TEXT', 'MTEXT', 'INSERT'): pts.append(entity.dxf.insert) elif dt == 'POINT': pts.append(entity.dxf.location) elif dt in ('CIRCLE', 'ARC', 'ELLIPSE'): pts.append(entity.dxf.center) elif dt in ('LWPOLYLINE', 'POLYLINE'): for pt in entity.vertices(): pts.append(pt) except Exception as e: pass logger.info("get_dxf_points_fast: Found %s raw point geometries", len(pts)) wgs_pts = [] for pt in pts: try: x = pt.x if hasattr(pt, 'x') else pt[0] y = pt.y if hasattr(pt, 'y') else pt[1] lon, lat = transformer.transform(x, y) wgs_pts.append((lon, lat)) except Exception as e: pass logger.info("get_dxf_points_fast: Extracted %s WGS84 points", len(wgs_pts)) return wgs_pts except Exception as e: logger.exception("get_dxf_points_fast: Error extracting points from DXF") return [] def calculate_dxf_limits_fast(dxf_path): pts = get_dxf_points_fast(dxf_path) if not pts: logger.info("calculate_dxf_limits_fast: No points returned for %s", dxf_path) return None import statistics lons = [p[0] for p in pts] lats = [p[1] for p in pts] median_lon = statistics.median(lons) median_lat = statistics.median(lats) logger.info("calculate_dxf_limits_fast: Median lon: %s, Median lat: %s", median_lon, median_lat) min_lon, min_lat = float('inf'), float('inf') max_lon, max_lat = float('-inf'), float('-inf') has_any = False for lon, lat in pts: dist = ((lon - median_lon)**2 + (lat - median_lat)**2)**0.5 if dist < 0.005: # 0.005 degrees is roughly 500m min_lon = min(min_lon, lon) max_lon = max(max_lon, lon) min_lat = min(min_lat, lat) max_lat = max(max_lat, lat) has_any = True if has_any: from pyproj import Transformer trans = Transformer.from_crs(4326, 31370, always_xy=True) min_x, min_y = trans.transform(min_lon, min_lat) max_x, max_y = trans.transform(max_lon, max_lat) logger.info("calculate_dxf_limits_fast: Filtered limits in WGS84: lon(%s, %s), lat(%s, %s)", min_lon, max_lon, min_lat, max_lat) logger.info("calculate_dxf_limits_fast: Filtered limits in Lambert 72: x(%s, %s), y(%s, %s)", min_x, max_x, min_y, max_y) if min_x > max_x: min_x, max_x = max_x, min_x if min_y > max_y: min_y, max_y = max_y, min_y dx = max_x - min_x dy = max_y - min_y if dx == 0: dx = 100 if dy == 0: dy = 100 min_x -= dx * 0.1 max_x += dx * 0.1 min_y -= dy * 0.1 max_y += dy * 0.1 logger.info("calculate_dxf_limits_fast: Final padded limits: x(%s, %s), y(%s, %s)", min_x, max_x, min_y, max_y) return min_x, min_y, max_x, max_y logger.info("calculate_dxf_limits_fast: No valid points found within 500m median threshold") return None @check_thematic_access('trafficlights', edit_permission_required=True) def dxf_layers(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) dxf_file = request.GET.get('file') if not dxf_file: return JsonResponse({"error": "No file specified"}, status=400) selected_layers = None # Secure filename dxf_file = os.path.basename(dxf_file) file_path = None for d in intersection.documents.all(): if os.path.basename(d.file.name) == dxf_file: file_path = d.file.path break if not file_path: uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') dxf_temp_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp') file_path = os.path.join(uploaded_dxf_dir, dxf_file) if not os.path.exists(file_path): file_path = os.path.join(dxf_temp_dir, dxf_file) if not os.path.exists(file_path): file_path = os.path.join(read_only_dxf_dir, dxf_file) if not file_path or not os.path.exists(file_path): from documents.models import DocumentVersion version = DocumentVersion.objects.filter(file__icontains=dxf_file).first() if version and version.file: file_path = version.file.path if not file_path or not os.path.exists(file_path): return JsonResponse({"error": "File not found"}, status=404) # Check if cached layout GeoJSON exists to get the active selection geojson_filename = dxf_file.lower().replace('.dxf', '.layout.geojson') geojson_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts') geojson_path = os.path.join(geojson_dir, geojson_filename) if os.path.exists(geojson_path): try: with open(geojson_path, 'r', encoding='utf-8') as f: cached_data = json.load(f) features = cached_data.get('features', []) selected_layers = cached_data.get('selected_layers') # Collect layer names and their colors from the GeoJSON features layers_map = {} for feature in features: props = feature.get('properties', {}) layer_name = props.get('Layer') if layer_name: color = props.get('color', '#808080') layers_map[layer_name] = color if layers_map: layers_info = [] for name, color in layers_map.items(): layers_info.append({ "name": name, "color": color }) layers_info = sorted(layers_info, key=lambda x: x["name"].lower()) # Normalize selected_layers selection active_selection = [] if selected_layers is not None: if selected_layers == 'all': active_selection = [l["name"] for l in layers_info] elif selected_layers == 'none' or selected_layers == []: active_selection = [] else: active_selection = list(selected_layers) else: active_selection = [l["name"] for l in layers_info] return JsonResponse({ "layers": layers_info, "selected_layers": active_selection, "filename": dxf_file }) except Exception: pass try: import ezdxf from ezdxf.colors import aci2rgb doc = get_cached_dxf_document(file_path) msp = doc.modelspace() # Scan modelspace to only list layers that actually contain entities used_layers = set() for entity in msp: used_layers.add(entity.dxf.layer) layers_info = [] for layer in doc.layers: if layer.dxf.name in used_layers: try: rgb = aci2rgb(layer.color) hex_color = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" except Exception: hex_color = "#808080" layers_info.append({ "name": layer.dxf.name, "color": hex_color }) # Sort layers by name layers_info = sorted(layers_info, key=lambda x: x["name"].lower()) # Normalize selected_layers selection active_selection = [] if selected_layers is not None: if selected_layers == 'all': active_selection = [l["name"] for l in layers_info] elif selected_layers == 'none' or selected_layers == []: active_selection = [] else: active_selection = list(selected_layers) else: # Propose all by default if no cached layout active_selection = [l["name"] for l in layers_info] return JsonResponse({ "layers": layers_info, "selected_layers": active_selection, "filename": dxf_file }) except Exception as e: import traceback traceback.print_exc() return JsonResponse({"error": str(e)}, status=500) from collections import defaultdict from ezdxf.addons.drawing.backend import Backend from pyproj import Transformer from shapely.geometry import MultiLineString, mapping from shapely.ops import linemerge class OptimizedGeoJSONBackend(Backend): def __init__(self, doc, precision=7, selected_layers=None): super().__init__() self.doc = doc self.precision = precision self.features = [] self.geometries = defaultdict(list) self.transformer = Transformer.from_crs(31370, 4326, always_xy=True) self.skip_draw = False self.selected_layers = selected_layers self.skip_layer = False def enter_entity(self, entity, properties): super().enter_entity(entity, properties) self.skip_layer = (self.selected_layers is not None and properties.layer not in self.selected_layers) if self.skip_layer: return if entity.dxftype() in ('TEXT', 'MTEXT'): self.skip_draw = True text = "" if entity.dxftype() == 'MTEXT': text = entity.text else: text = entity.dxf.text import re text = re.sub(r'\\[~^#\\_]', '', text) text = text.replace('{', '').replace('}', '') text = re.sub(r'\\P', '\n', text).strip() if text: insert = entity.dxf.insert if entity.dxftype() == 'TEXT': try: placement = entity.get_placement() if placement[1] is not None: insert = placement[1] except Exception: pass lon, lat = self.transformer.transform(insert.x, insert.y) is_off = False layer_name = properties.layer if self.doc.layers.has_entry(layer_name): layer = self.doc.layers.get(layer_name) is_off = layer.is_off() or layer.is_frozen() height = 1.5 angle = 0.0 text_anchor = 'center' if entity.dxftype() == 'MTEXT': if entity.dxf.hasattr('char_height'): height = entity.dxf.char_height if entity.dxf.hasattr('rotation'): angle = entity.dxf.rotation try: attach = entity.dxf.attachment_point mtext_map = { 1: 'top-left', 2: 'top', 3: 'top-right', 4: 'left', 5: 'center', 6: 'right', 7: 'bottom-left', 8: 'bottom', 9: 'bottom-right' } text_anchor = mtext_map.get(attach, 'center') except Exception: pass else: # TEXT if entity.dxf.hasattr('height'): height = entity.dxf.height if entity.dxf.hasattr('rotation'): angle = entity.dxf.rotation try: align_name = entity.get_align_enum().name text_map = { 'LEFT': 'left', 'CENTER': 'bottom', 'RIGHT': 'right', 'BOTTOM_LEFT': 'bottom-left', 'BOTTOM_CENTER': 'bottom', 'BOTTOM_RIGHT': 'bottom-right', 'MIDDLE_LEFT': 'left', 'MIDDLE_CENTER': 'center', 'MIDDLE_RIGHT': 'right', 'TOP_LEFT': 'top-left', 'TOP_CENTER': 'top', 'TOP_RIGHT': 'top-right' } text_anchor = text_map.get(align_name, 'center') except Exception: pass self.features.append({ "type": "Feature", "geometry": { "type": "Point", "coordinates": [round(lon, self.precision), round(lat, self.precision)] }, "properties": { "Layer": layer_name, "color": properties.color, "is_off": is_off, "Text": text, "text_height": min(height, 2.5), "text_angle": angle, "text_anchor": text_anchor } }) def exit_entity(self, entity): super().exit_entity(entity) self.skip_layer = False if entity.dxftype() in ('TEXT', 'MTEXT'): self.skip_draw = False def set_background(self, color): pass def clear(self): self.features = [] self.geometries.clear() def draw_point(self, pos, properties): if self.skip_layer or self.skip_draw: return lon, lat = self.transformer.transform(pos.x, pos.y) self.geometries[('Point', properties.layer, properties.color)].append([ round(lon, self.precision), round(lat, self.precision) ]) def draw_line(self, start, end, properties): if self.skip_layer or self.skip_draw: return lon1, lat1 = self.transformer.transform(start.x, start.y) lon2, lat2 = self.transformer.transform(end.x, end.y) self.geometries[('LineString', properties.layer, properties.color)].append(( (round(lon1, self.precision), round(lat1, self.precision)), (round(lon2, self.precision), round(lat2, self.precision)) )) def draw_filled_polygon(self, points, properties): if self.skip_layer or self.skip_draw: return coords = [] for pt in points.vertices(): lon, lat = self.transformer.transform(pt.x, pt.y) coords.append([round(lon, self.precision), round(lat, self.precision)]) if coords and coords[0] != coords[-1]: coords.append(coords[0]) self.geometries[('Polygon', properties.layer, properties.color)].append([coords]) def draw_image(self, image_data, properties): pass def finalize(self): for (geom_type, layer_name, color), geom_list in self.geometries.items(): is_off = False if self.doc.layers.has_entry(layer_name): layer = self.doc.layers.get(layer_name) is_off = layer.is_off() or layer.is_frozen() if geom_type == 'LineString': mls = MultiLineString(geom_list) merged = linemerge(mls) geom_dict = mapping(merged) self.features.append({ "type": "Feature", "geometry": geom_dict, "properties": { "Layer": layer_name, "color": color, "is_off": is_off } }) elif geom_type == 'Polygon': self.features.append({ "type": "Feature", "geometry": { "type": "MultiPolygon", "coordinates": geom_list }, "properties": { "Layer": layer_name, "color": color, "is_off": is_off } }) elif geom_type == 'Point': self.features.append({ "type": "Feature", "geometry": { "type": "MultiPoint", "coordinates": geom_list }, "properties": { "Layer": layer_name, "color": color, "is_off": is_off } }) @check_thematic_access('trafficlights', edit_permission_required=True) def dxf_layout_geojson(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) dxf_file = request.GET.get('file') if not dxf_file: return JsonResponse({"error": "No file specified"}, status=400) # Secure filename dxf_file = os.path.basename(dxf_file) file_path = None for d in intersection.documents.all(): if os.path.basename(d.file.name) == dxf_file: file_path = d.file.path break if not file_path: uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') file_path = os.path.join(uploaded_dxf_dir, dxf_file) if not os.path.exists(file_path): file_path = os.path.join(read_only_dxf_dir, dxf_file) if not file_path or not os.path.exists(file_path): from documents.models import DocumentVersion version = DocumentVersion.objects.filter(file__icontains=dxf_file).first() if version and version.file: file_path = version.file.path if not file_path or not os.path.exists(file_path): return JsonResponse({"error": "File not found"}, status=404) layers_param = request.GET.get('layers') selected_layers = None if layers_param is not None: if layers_param == 'all': selected_layers = None elif layers_param == 'none' or layers_param == '': selected_layers = set() else: selected_layers = set(layers_param.split(',')) geojson_filename = dxf_file.lower().replace('.dxf', '.layout.geojson') geojson_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_layouts') os.makedirs(geojson_dir, exist_ok=True) geojson_path = os.path.join(geojson_dir, geojson_filename) overlay_filename = dxf_file.lower().replace('.dxf', '.overlay.png') overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_filename) is_detail = request.GET.get('detail') == 'true' # 1. Detail page image-only option shortcut if is_detail and (layers_param == 'none' or layers_param == ''): geojson_data = { "type": "FeatureCollection", "features": [], "selected_layers": [] } try: from django.core.cache import cache cache_key = f"dxf_limits_{dxf_file.lower()}" limits = cache.get(cache_key) if not limits: limits = calculate_dxf_limits_fast(file_path) if limits: cache.set(cache_key, limits, timeout=86400 * 30) if limits: if not os.path.exists(overlay_path): generate_dxf_image_overlay(file_path, overlay_path, limits=limits) from pyproj import Transformer transformer = Transformer.from_crs(31370, 4326, always_xy=True) min_x, min_y, max_x, max_y = limits top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) geojson_data['image_overlay'] = { 'url': f"{settings.MEDIA_URL}dxf_layouts/{overlay_filename}", 'coordinates': [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ] } except Exception as e: import traceback traceback.print_exc() return JsonResponse(geojson_data) # Check if cached layout GeoJSON exists geojson_data = None if os.path.exists(geojson_path): try: with open(geojson_path, 'r', encoding='utf-8') as f: geojson_data = json.load(f) except Exception: pass # Fallback to legacy hashed cache files if single cache file does not exist if not geojson_data: import glob pattern = os.path.join(geojson_dir, f"{dxf_file.lower().replace('.dxf', '')}.*.layout.geojson") legacy_files = glob.glob(pattern) if legacy_files: legacy_files.sort(key=os.path.getmtime, reverse=True) for legacy_path in legacy_files: try: with open(legacy_path, 'r', encoding='utf-8') as f: geojson_data = json.load(f) if geojson_data: # Migrate to the single cache path with open(geojson_path, 'w', encoding='utf-8') as f: json.dump(geojson_data, f, ensure_ascii=False) break except Exception: pass # If layers_param is explicitly specified, check if selection matches cache. If not, discard cache. if geojson_data and layers_param is not None: cached_selection = geojson_data.get('selected_layers') # Normalize cached_selection if cached_selection is None or cached_selection == 'all': norm_cached = None elif cached_selection == 'none' or cached_selection == []: norm_cached = set() else: norm_cached = set(cached_selection) if selected_layers != norm_cached: # If the cache is already filtered (norm_cached is not None) and the request is for 'all' (selected_layers is None), # do not discard the cache. This preserves the user's filtered layers. if selected_layers is None and norm_cached is not None: pass else: # Selection has changed, discard cache to force regeneration geojson_data = None if geojson_data: if is_detail: geojson_data.pop('image_overlay', None) return JsonResponse(geojson_data, safe=False) # Generate image overlay if missing from cache if geojson_data and 'image_overlay' not in geojson_data: try: limits = calculate_dxf_limits_fast(file_path) if limits: if not os.path.exists(overlay_path): generate_dxf_image_overlay(file_path, overlay_path, limits=limits) from pyproj import Transformer transformer = Transformer.from_crs(31370, 4326, always_xy=True) min_x, min_y, max_x, max_y = limits top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) geojson_data['image_overlay'] = { 'url': f"{settings.MEDIA_URL}dxf_layouts/{overlay_filename}", 'coordinates': [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ] } with open(geojson_path, 'w', encoding='utf-8') as f: json.dump(geojson_data, f, ensure_ascii=False) except Exception as e: import traceback traceback.print_exc() # If cache hit, return it directly if geojson_data: return JsonResponse(geojson_data, safe=False) # Otherwise, read from scratch and write cache using ezdxf GeoJSONBackend try: from collections import defaultdict import ezdxf from ezdxf.addons.drawing import RenderContext, Frontend from ezdxf.addons.drawing.properties import LayoutProperties doc = get_cached_dxf_document(file_path) msp = doc.modelspace() ctx = RenderContext(doc) layout_properties = LayoutProperties.from_layout(msp) backend = OptimizedGeoJSONBackend(doc, precision=7, selected_layers=selected_layers) if selected_layers is None: Frontend(ctx, backend).draw_layout(msp, layout_properties=layout_properties, finalize=True) elif len(selected_layers) > 0: filtered_entities = [entity for entity in msp if entity.dxf.layer in selected_layers] Frontend(ctx, backend).draw_entities(filtered_entities) backend.finalize() geojson_data = { "type": "FeatureCollection", "features": backend.features, "selected_layers": list(selected_layers) if isinstance(selected_layers, (set, list)) else selected_layers } # Generate and add image_overlay (only if not in detail page) if not is_detail: try: from django.core.cache import cache cache_key = f"dxf_limits_{dxf_file.lower()}" limits = cache.get(cache_key) if not limits: limits = calculate_dxf_limits_fast(file_path) if limits: cache.set(cache_key, limits, timeout=86400 * 30) # cache for 30 days if limits: if not os.path.exists(overlay_path): generate_dxf_image_overlay(file_path, overlay_path, limits=limits) from pyproj import Transformer transformer = Transformer.from_crs(31370, 4326, always_xy=True) min_x, min_y, max_x, max_y = limits top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) geojson_data['image_overlay'] = { 'url': f"{settings.MEDIA_URL}dxf_layouts/{overlay_filename}", 'coordinates': [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ] } except Exception as e: import traceback traceback.print_exc() # Save generated layout to cache for faster future requests with open(geojson_path, 'w', encoding='utf-8') as f: json.dump(geojson_data, f, ensure_ascii=False) return JsonResponse(geojson_data, safe=False) except Exception as e: import traceback traceback.print_exc() return JsonResponse({"error": f"Failed to generate layout: {str(e)}"}, status=500) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def add_staged_pole(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) model_id = request.POST.get('model_id') code = request.POST.get('code') lon_str = request.POST.get('lon', '0.0') lat_str = request.POST.get('lat', '0.0') dxf_file = request.POST.get('dxf_file', '') try: lon = float(lon_str.replace(',', '.')) lat = float(lat_str.replace(',', '.')) except ValueError: return JsonResponse({'success': False, 'error': "Invalid coordinates"}, status=400) # Convert coords from 4326 to 3812 from pyproj import Transformer from django.contrib.gis.geos import Point as DjangoPoint transformer = Transformer.from_crs(4326, 3812, always_xy=True) x_3812, y_3812 = transformer.transform(lon, lat) default_model = TrafficLightPoleModel.objects.first() try: model = TrafficLightPoleModel.objects.get(pk=int(model_id)) if model_id else default_model except (TrafficLightPoleModel.DoesNotExist, ValueError): model = default_model note = f"Créé manuellement sur plan DXF : {dxf_file}" if dxf_file else "Créé manuellement sur plan DXF" pole = TrafficLightPole( intersection=intersection, model=model, code=code, geom=DjangoPoint(x_3812, y_3812, srid=3812), lon=lon, lat=lat, status='to_be_approved', validation_status='to_be_validated', note=note, updated_by=request.user ) pole._history_user = request.user pole.save() return JsonResponse({ 'success': True, 'pole': { 'id': pole.id, 'code': pole.code, 'model_id': pole.model_id, 'model_code': pole.model.code if pole.model else "", 'lon': pole.lon, 'lat': pole.lat, } }) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def delete_staged_pole(request, intersection_id, pole_id): pole = get_object_or_404(TrafficLightPole, pk=pole_id, intersection_id=intersection_id, status='to_be_approved') pole.delete() return JsonResponse({'success': True}) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def update_staged_pole_position(request, intersection_id, pole_id): pole = get_object_or_404(TrafficLightPole, pk=pole_id, intersection_id=intersection_id, status='to_be_approved') lon_str = request.POST.get('lon') lat_str = request.POST.get('lat') try: lon = float(lon_str.replace(',', '.')) lat = float(lat_str.replace(',', '.')) except (ValueError, TypeError): return JsonResponse({'success': False, 'error': "Invalid coordinates"}, status=400) # Convert coords from 4326 to 3812 from pyproj import Transformer from django.contrib.gis.geos import Point as DjangoPoint transformer = Transformer.from_crs(4326, 3812, always_xy=True) x_3812, y_3812 = transformer.transform(lon, lat) pole.geom = DjangoPoint(x_3812, y_3812, srid=3812) pole.lon = lon pole.lat = lat pole.updated_by = request.user pole._history_user = request.user pole.save() return JsonResponse({ 'success': True, 'pole': { 'id': pole.id, 'code': pole.code, 'lon': pole.lon, 'lat': pole.lat, } }) @check_thematic_access('trafficlights', edit_permission_required=True) def import_cross_plan(request, intersection_id): from django.shortcuts import render, get_object_or_404, redirect from django.contrib import messages from django.utils.translation import gettext as _ from django.urls import reverse from django.conf import settings import json from assets.utils.cross_plan_parser import parse_cross_plan from assets.models.trafficlights import ( TrafficLightIntersection, TrafficLightPole, TrafficLightLantern, TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel, TrafficLightLanternModel, TrafficLightDetectorModel, TrafficLightCableModel ) from documents.services import DocumentManagementService intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) uploaded_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'cross_plans') # Fetch DXF files and georeferenced documents associated with this intersection dxf_files = [] # 1. Add document management DXF files first (most recent updated_at first) from documents.models import DocumentAttachment, ManagedDocument ct = ContentType.objects.get_for_model(intersection) attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id) managed_dxf_docs = ManagedDocument.objects.filter( id__in=attachments.values_list('document_id', flat=True), is_archived=False ).select_related('current_version') managed_dxf_docs = sorted( list(managed_dxf_docs), key=lambda x: x.updated_at, reverse=True ) for doc in managed_dxf_docs: if doc.current_version and doc.current_version.file: fn = os.path.basename(doc.current_version.file.name) if fn.lower().endswith('.dxf') and fn not in dxf_files: dxf_files.append(fn) # 2. Add DB documents next, ordered by upload_date descending (legacy fallback) dxf_docs = sorted( [doc for doc in intersection.documents.all() if doc.file.name.lower().endswith('.dxf')], key=lambda x: x.upload_date, reverse=True ) for doc in dxf_docs: fn = os.path.basename(doc.file.name) if fn not in dxf_files: dxf_files.append(fn) # 3. Add read-only external files next external_files = [] read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') if os.path.exists(read_only_dxf_dir): code_lower = intersection.code.lower() if intersection.code else "" for f in os.listdir(read_only_dxf_dir): if f.lower().endswith('.dxf'): if code_lower and code_lower in f.lower(): external_files.append(f) for f in sorted(external_files): if f not in dxf_files: dxf_files.append(f) doc_data = DocumentManagementService.get_documents_for_asset(intersection) document_folder = doc_data.get("folder") use_managed_documents = document_folder is not None georeferenced_documents = [] if use_managed_documents and document_folder: from documents.models import ManagedDocument folder_ids = DocumentManagementService.get_folder_and_descendants(document_folder) docs = ManagedDocument.objects.filter( folders__pk__in=folder_ids, is_archived=False, georeference_coords__isnull=False ).distinct() for doc in docs: is_dxf_overlay = bool(doc.unique_params and 'dxf_source_id' in doc.unique_params) georeferenced_documents.append({ 'id': doc.id, 'title': doc.title, 'coords': doc.georeference_coords, 'url': reverse('documents:page_image', args=[doc.pk, doc.georeference_page or 1]), 'is_dxf': is_dxf_overlay }) if not use_managed_documents: for dxf_file in dxf_files: file_path = None for d in intersection.documents.all(): if os.path.basename(d.file.name) == dxf_file: file_path = d.file.path break if not file_path: uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files') file_path = os.path.join(uploaded_dxf_dir, dxf_file) if not os.path.exists(file_path): file_path = os.path.join(read_only_dxf_dir, dxf_file) if os.path.exists(file_path): try: overlay_filename = dxf_file.lower().replace('.dxf', '.overlay.png') overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_filename) if os.path.exists(overlay_path): from django.core.cache import cache from pyproj import Transformer from assets.views.trafficlights import calculate_dxf_limits_fast cache_key = f"dxf_limits_{dxf_file.lower()}" limits = cache.get(cache_key) if not limits: limits = calculate_dxf_limits_fast(file_path) if limits: cache.set(cache_key, limits, timeout=86400 * 30) if limits: transformer = Transformer.from_crs(31370, 4326, always_xy=True) min_x, min_y, max_x, max_y = limits top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) georeferenced_documents.append({ 'id': f"dxf-{dxf_file}", 'title': f"[CAO] {dxf_file}", 'coords': [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ], 'url': f"{settings.MEDIA_URL}dxf_layouts/{overlay_filename}", 'is_dxf': True }) except Exception as e: print(f"Error preparing DXF overlay for {dxf_file}: {e}") georeferenced_documents_json = json.dumps(georeferenced_documents) # Build list of existing cross plans for the user to choose from from documents.models import DocumentAttachment, ManagedDocument from documents.services import DocumentManagementService from django.db.models import Q ct = ContentType.objects.get_for_model(intersection) attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id) managed_docs_query = ManagedDocument.objects.filter( tags__slug__in=['cross_plan', 'crossplan'], is_archived=False ) doc_data = DocumentManagementService.get_documents_for_asset(intersection) document_folder = doc_data.get("folder") if document_folder: folder_ids = DocumentManagementService.get_folder_and_descendants(document_folder) managed_docs_query = managed_docs_query.filter( Q(id__in=attachments.values_list('document_id', flat=True)) | Q(folders__pk__in=folder_ids) ) else: managed_docs_query = managed_docs_query.filter( id__in=attachments.values_list('document_id', flat=True) ) existing_managed_cross_plans = managed_docs_query.distinct().select_related('current_version') legacy_cross_plans = intersection.documents.filter(document_type='cross_plan') cross_plan_choices = [] # 1. Managed documents for doc in existing_managed_cross_plans: if doc.current_version and doc.current_version.file: cross_plan_choices.append({ 'id': f"managed-{doc.id}", 'title': doc.title or os.path.basename(doc.current_version.file.name), 'filename': os.path.basename(doc.current_version.file.name), 'path': doc.current_version.file.path, 'is_managed': True }) # 2. Legacy documents for doc in legacy_cross_plans: if doc.file: fn = os.path.basename(doc.file.name) if not any(c['filename'] == fn for c in cross_plan_choices): cross_plan_choices.append({ 'id': f"legacy-{doc.id}", 'title': fn, 'filename': fn, 'path': doc.file.path, 'is_managed': False }) if request.method == 'GET': return render(request, "assets/trafficlights_import_cross_plan.html", { 'intersection': intersection, 'step': 'select', 'thematic_code': 'trafficlights', 'dxf_files': dxf_files, 'georeferenced_documents_json': georeferenced_documents_json, 'cross_plan_choices': cross_plan_choices, }) elif request.method == 'POST': existing_cp_ids = request.POST.getlist('existing_cross_plans') uploaded_files = request.FILES.getlist('cross_plan_file_upload') if not uploaded_files and not existing_cp_ids: return render(request, "assets/trafficlights_import_cross_plan.html", { 'intersection': intersection, 'error': _("Veuillez téléverser un fichier ou sélectionner au moins un plan existant."), 'step': 'select', 'thematic_code': 'trafficlights', 'cross_plan_choices': cross_plan_choices, }) import uuid import json merged_deduced = { "intersection_code": None, "poles": [], "cables": [], "lanterns": [], "detectors": [] } poles_by_code = {} cables_by_code = {} lanterns_by_code = {} detectors_by_code = {} excel_codes = set() uploaded_filenames = [] files_to_parse = [] if uploaded_files: for uploaded_file in uploaded_files: os.makedirs(uploaded_dir, exist_ok=True) temp_path = os.path.join(uploaded_dir, f"{intersection.id}_{uuid.uuid4().hex}_{uploaded_file.name}") with open(temp_path, 'wb+') as destination: for chunk in uploaded_file.chunks(): destination.write(chunk) files_to_parse.append((temp_path, uploaded_file.name, True)) if existing_cp_ids: for existing_cp_id in existing_cp_ids: temp_path = None filename = None if existing_cp_id.startswith('managed-'): doc_id = existing_cp_id.replace('managed-', '') doc = ManagedDocument.objects.filter(pk=doc_id, is_archived=False).first() if doc and doc.current_version and doc.current_version.file: temp_path = doc.current_version.file.path filename = os.path.basename(doc.current_version.file.name) elif existing_cp_id.startswith('legacy-'): doc_id = existing_cp_id.replace('legacy-', '') doc = intersection.documents.filter(pk=doc_id).first() if doc and doc.file: temp_path = doc.file.path filename = os.path.basename(doc.file.name) if temp_path and os.path.exists(temp_path): files_to_parse.append((temp_path, filename, False)) else: return render(request, "assets/trafficlights_import_cross_plan.html", { 'intersection': intersection, 'error': _("Un des fichiers de plan de croix sélectionnés est introuvable."), 'step': 'select', 'thematic_code': 'trafficlights', 'cross_plan_choices': cross_plan_choices, }) for file_path, filename, delete_after in files_to_parse: uploaded_filenames.append(filename) try: deduced = parse_cross_plan(file_path) except Exception as e: if delete_after and os.path.exists(file_path): os.remove(file_path) return render(request, "assets/trafficlights_import_cross_plan.html", { 'intersection': intersection, 'error': f"Erreur lors de la lecture du fichier Excel '{filename}' : {str(e)}", 'step': 'select', 'thematic_code': 'trafficlights', 'cross_plan_choices': cross_plan_choices, }) if deduced.get("intersection_code"): excel_codes.add(deduced["intersection_code"]) if not merged_deduced["intersection_code"]: merged_deduced["intersection_code"] = deduced["intersection_code"] # Merge poles for p in deduced.get("poles", []): code = p["code"] if code not in poles_by_code: poles_by_code[code] = p # Merge cables for c in deduced.get("cables", []): code = c["code"] if code not in cables_by_code: cables_by_code[code] = c # Merge lanterns for l in deduced.get("lanterns", []): code = l["code"] if code not in lanterns_by_code: lanterns_by_code[code] = l # Merge detectors for d in deduced.get("detectors", []): code = d["code"] if code not in detectors_by_code: detectors_by_code[code] = d if delete_after and os.path.exists(file_path): os.remove(file_path) merged_deduced["poles"] = sorted(list(poles_by_code.values()), key=lambda x: x['code']) merged_deduced["cables"] = sorted(list(cables_by_code.values()), key=lambda x: x['code']) merged_deduced["lanterns"] = sorted(list(lanterns_by_code.values()), key=lambda x: x['code']) merged_deduced["detectors"] = sorted(list(detectors_by_code.values()), key=lambda x: x['code']) # Check if the parsed intersection codes match the current one if excel_codes: for excel_code in excel_codes: if excel_code != intersection.code: messages.warning( request, f"Attention : Un code carrefour dans l'Excel est '{excel_code}' alors que vous importez dans le carrefour '{intersection.code}'." ) # Save merged results into a temporary JSON file json_filename = f"combined_{intersection.id}_{uuid.uuid4().hex}.json" json_filepath = os.path.join(uploaded_dir, json_filename) with open(json_filepath, 'w', encoding='utf-8') as f: json.dump(merged_deduced, f, indent=4) # Load active poles georeferenced (always loaded for map) existing_poles = list(TrafficLightPole.objects.filter(intersection=intersection, status='active')) existing_features = [] for ep in existing_poles: lon = ep.lon lat = ep.lat if (lon is None or lat is None) and ep.geom: try: geom_wgs84 = ep.geom.clone() geom_wgs84.transform(4326) lon, lat = geom_wgs84.x, geom_wgs84.y except Exception: pass if lon is not None and lat is not None: existing_features.append({ "type": "Feature", "geometry": { "type": "Point", "coordinates": [lon, lat] }, "properties": { "id": ep.id, "code": ep.code, "model": ep.model.code if ep.model else "" } }) existing_poles_geojson = { "type": "FeatureCollection", "features": existing_features } deduced = merged_deduced # ── 1. POLES COMPARISON ── existing_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')} compared_poles = [] for dp in deduced["poles"]: code = dp["code"] model_name = dp["model"] db_model = TrafficLightPoleModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightPoleModel.objects.first() if code in existing_poles: ep = existing_poles[code] is_diff = ep.model_id != db_model.id status = 'modified' if is_diff else 'identical' compared_poles.append({ 'code': code, 'short_code': dp['short_code'], 'name': ep.name_fr or dp.get('name', dp['short_code']), 'status': status, 'deduced_model_name': model_name, 'db_model_name': ep.model.name_fr if ep.model else '', 'db_id': ep.id, 'details_diff': f"Modèle différent (DB: {ep.model.name_fr if ep.model else 'Aucun'} vs Excel: {model_name})" if is_diff else '' }) # remove from processed existing_poles.pop(code) else: compared_poles.append({ 'code': code, 'short_code': dp['short_code'], 'name': dp.get('name', dp['short_code']), 'status': 'new', 'deduced_model_name': model_name, 'db_model_name': '', 'db_id': None, 'details_diff': '' }) # Add remaining existing active poles (orphans) for code, ep in existing_poles.items(): compared_poles.append({ 'code': code, 'short_code': code.split('_')[-1] if '_' in code else code, 'name': ep.name_fr or code, 'status': 'db_only', 'deduced_model_name': '', 'db_model_name': ep.model.name_fr if ep.model else '', 'db_id': ep.id, 'details_diff': _("Absent du fichier Excel (Sera conservé ou archivé)") }) # ── 2. CABLES COMPARISON ── existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')} compared_cables = [] for dc in deduced["cables"]: code = dc["code"] model_name = dc["model_name"] db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first() if code in existing_cables: ec = existing_cables[code] is_diff = ec.model_id != db_model.id status = 'modified' if is_diff else 'identical' compared_cables.append({ 'code': code, 'name': ec.name_fr or dc['name'], 'status': status, 'deduced_model_name': model_name, 'db_model_name': ec.model.name_fr if ec.model else '', 'db_id': ec.id, 'details_diff': f"Modèle différent (DB: {ec.model.name_fr if ec.model else 'Aucun'} vs Excel: {model_name})" if is_diff else '', 'connected_poles': dc.get("connected_poles", []) }) existing_cables.pop(code) else: compared_cables.append({ 'code': code, 'name': dc['name'], 'status': 'new', 'deduced_model_name': model_name, 'db_model_name': '', 'db_id': None, 'details_diff': '', 'connected_poles': dc.get("connected_poles", []) }) for code, ec in existing_cables.items(): compared_cables.append({ 'code': code, 'name': ec.name_fr or code, 'status': 'db_only', 'deduced_model_name': '', 'db_model_name': ec.model.name_fr if ec.model else '', 'db_id': ec.id, 'details_diff': _("Absent du fichier Excel") }) # ── 3. LANTERNS COMPARISON ── existing_lanterns = {l.code: l for l in TrafficLightLantern.objects.filter(pole__intersection=intersection, status='active')} compared_lanterns = [] for dl in deduced["lanterns"]: code = dl["code"] base_code = code.rsplit('_', 1)[0] if '_' in code else code model_name = dl["model_name"] db_model = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightLanternModel.objects.first() # Try exact match, then try prefix match (without the phase suffix) match_key = None if code in existing_lanterns: match_key = code elif base_code in existing_lanterns: match_key = base_code if match_key: el = existing_lanterns[match_key] is_rename = (el.code != code) is_model_diff = el.model_id != db_model.id status = 'modified' if (is_rename or is_model_diff) else 'identical' details = [] if is_rename: details.append(f"Renommer le code (DB: {el.code} vs Excel: {code})") if is_model_diff: details.append(f"Modèle différent (DB: {el.model.name_fr if el.model else 'Aucun'} vs Excel: {model_name})") compared_lanterns.append({ 'code': code, 'name': el.name_fr or dl['name'], 'status': status, 'deduced_model_name': model_name, 'db_model_name': el.model.name_fr if el.model else '', 'db_id': el.id, 'pole_code': dl['pole_code'], 'details_diff': ", ".join(details) if details else '' }) existing_lanterns.pop(match_key) else: compared_lanterns.append({ 'code': code, 'name': dl['name'], 'status': 'new', 'deduced_model_name': model_name, 'db_model_name': '', 'db_id': None, 'pole_code': dl['pole_code'], 'details_diff': '' }) for code, el in existing_lanterns.items(): compared_lanterns.append({ 'code': code, 'name': el.name_fr or code, 'status': 'db_only', 'deduced_model_name': '', 'db_model_name': el.model.name_fr if el.model else '', 'db_id': el.id, 'pole_code': el.pole.code, 'details_diff': _("Absent du fichier Excel") }) # ── 4. DETECTORS COMPARISON ── existing_detectors = {d.code: d for d in TrafficLightDetector.objects.filter(intersection=intersection, status='active')} compared_detectors = [] for dd in deduced["detectors"]: code = dd["code"] model_name = dd["model_name"] db_model = TrafficLightDetectorModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightDetectorModel.objects.first() match_key = None if code in existing_detectors: match_key = code else: parts = code.split('_', 2) if len(parts) == 3: old_key = f"{parts[0]}-{parts[2]}" if old_key in existing_detectors: match_key = old_key if match_key: ed = existing_detectors[match_key] is_diff = ed.model_id != db_model.id details = [] if match_key != code: details.append(f"Renommer le code (DB: {match_key} vs Excel: {code})") if is_diff: details.append(f"Modèle différent (DB: {ed.model.name_fr if ed.model else 'Aucun'} vs Excel: {model_name})") status = 'modified' if (is_diff or match_key != code) else 'identical' compared_detectors.append({ 'code': code, 'name': ed.name or dd['name'], 'status': status, 'deduced_model_name': model_name, 'db_model_name': ed.model.name_fr if ed.model else '', 'db_id': ed.id, 'pole_code': dd['pole_code'], 'details_diff': ", ".join(details) if details else '' }) existing_detectors.pop(match_key) else: compared_detectors.append({ 'code': code, 'name': dd['name'], 'status': 'new', 'deduced_model_name': model_name, 'db_model_name': '', 'db_id': None, 'pole_code': dd['pole_code'], 'details_diff': '' }) for code, ed in existing_detectors.items(): compared_detectors.append({ 'code': code, 'name': ed.name or code, 'status': 'db_only', 'deduced_model_name': '', 'db_model_name': ed.model.name_fr if ed.model else '', 'db_id': ed.id, 'pole_code': ed.pole.code if ed.pole else '', 'details_diff': _("Absent du fichier Excel") }) # Sort compared lists alphabetically by code for preview compared_poles.sort(key=lambda x: x['code']) compared_cables.sort(key=lambda x: x['code']) compared_lanterns.sort(key=lambda x: x['code']) compared_detectors.sort(key=lambda x: x['code']) # Fetch models for select box overrides pole_models = TrafficLightPoleModel.objects.all().order_by('code') lantern_models = TrafficLightLanternModel.objects.all().order_by('code') detector_models = TrafficLightDetectorModel.objects.all().order_by('code') cable_models = TrafficLightCableModel.objects.all().order_by('code') template_name = "assets/trafficlights_import_cross_plan.html" context = { 'intersection': intersection, 'cross_plan_filename': json_filename, 'uploaded_filenames': uploaded_filenames, 'poles': compared_poles, 'cables': compared_cables, 'lanterns': compared_lanterns, 'detectors': compared_detectors, 'pole_models': pole_models, 'lantern_models': lantern_models, 'detector_models': detector_models, 'cable_models': cable_models, 'step': 'preview', 'thematic_code': 'trafficlights', 'dxf_file': "", 'dxf_poles': [], 'existing_poles_geojson': existing_poles_geojson, 'dxf_files': dxf_files, 'georeferenced_documents_json': georeferenced_documents_json, } return render(request, template_name, context) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def integrate_cross_plan(request, intersection_id): from django.shortcuts import get_object_or_404, redirect from django.contrib import messages from django.db import transaction import json from assets.models.trafficlights import ( TrafficLightIntersection, TrafficLightPole, TrafficLightLantern, TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel, TrafficLightLanternModel, TrafficLightDetectorModel, TrafficLightCableModel ) intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) filename = request.POST.get('cross_plan_filename') if not filename: messages.error(request, "Nom de fichier manquant.") return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) # Note: the temporary files are stored in settings.PRIVATE_MEDIA_ROOT / 'cross_plans' file_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'cross_plans', filename) if not os.path.exists(file_path): messages.error(request, "Les données du plan de croix n'existent plus.") return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) try: with open(file_path, 'r', encoding='utf-8') as f: deduced = json.load(f) except Exception as e: messages.error(request, f"Erreur de re-lecture des données du plan de croix : {str(e)}") return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) global_action = request.POST.get('global_action', 'apply') # Helper resolution functions def resolve_pole_model(name_fr): m = TrafficLightPoleModel.objects.filter(name_fr__iexact=name_fr).first() if not m: m = TrafficLightPoleModel.objects.first() if not m: m = TrafficLightPoleModel.objects.create( code="default_pole", name_fr="Poteau par défaut", name_nl="Standaard paal" ) return m def resolve_lantern_model(name_fr): m = TrafficLightLanternModel.objects.filter(name_fr__iexact=name_fr).first() if not m: m = TrafficLightLanternModel.objects.first() if not m: m = TrafficLightLanternModel.objects.create( code="default_lantern", name_fr="Lanterne par défaut", name_nl="Standaard lantaarn", voltage=230 ) return m def resolve_detector_model(name_fr): m = TrafficLightDetectorModel.objects.filter(name_fr__iexact=name_fr).first() if not m: m = TrafficLightDetectorModel.objects.first() if not m: m = TrafficLightDetectorModel.objects.create( code="default_detector", name_fr="Détecteur par défaut", name_nl="Standaard detector" ) return m def resolve_cable_model(name_fr): m = TrafficLightCableModel.objects.filter(name_fr__iexact=name_fr).first() if not m: m = TrafficLightCableModel.objects.first() if not m: m = TrafficLightCableModel.objects.create( code="default_cable", name_fr="Câble par défaut", name_nl="Standaard kabel", number_of_strands=61 ) return m def update_pole_geometry(pole, code): def clean_float(val): if not val: return None try: return float(str(val).replace(',', '.')) except ValueError: return None lon = clean_float(request.POST.get(f"lon_pole_{code}")) lat = clean_float(request.POST.get(f"lat_pole_{code}")) x_3812 = clean_float(request.POST.get(f"x_3812_pole_{code}")) y_3812 = clean_float(request.POST.get(f"y_3812_pole_{code}")) if x_3812 is not None and y_3812 is not None and x_3812 != 0.0 and y_3812 != 0.0: from django.contrib.gis.geos import Point as DjangoPoint pole.geom = DjangoPoint(x_3812, y_3812, srid=3812) pole.lon = lon pole.lat = lat elif lon is not None and lat is not None: from django.contrib.gis.geos import Point as DjangoPoint from pyproj import Transformer transformer = Transformer.from_crs(4326, 3812, always_xy=True) x_3812, y_3812 = transformer.transform(lon, lat) pole.geom = DjangoPoint(x_3812, y_3812, srid=3812) pole.lon = lon pole.lat = lat def update_cable_geometry(cable, code): geom_coords_str = request.POST.get(f"geom_cable_{code}") if geom_coords_str: try: coords = json.loads(geom_coords_str) if len(coords) >= 2: from django.contrib.gis.geos import LineString as DjangoLineString from pyproj import Transformer transformer = Transformer.from_crs(4326, 3812, always_xy=True) lambert_coords = [transformer.transform(pt[0], pt[1]) for pt in coords] cable.geom = DjangoLineString(lambert_coords, srid=3812) except Exception: pass with transaction.atomic(): # A. GLOBAL REPLACE ALL if global_action == 'replace_all': # Archive everything active TrafficLightPole.objects.filter(intersection=intersection, status='active').update(status='archived', updated_by=request.user) TrafficLightCable.objects.filter(intersection=intersection, status='active').update(status='archived', updated_by=request.user) TrafficLightLantern.objects.filter(pole__intersection=intersection, status='active').update(status='archived', updated_by=request.user) TrafficLightDetector.objects.filter(intersection=intersection, status='active').update(status='archived', updated_by=request.user) # Re-create all poles created_poles = {} for dp in deduced["poles"]: db_model = resolve_pole_model(dp["model"]) custom_name = request.POST.get(f"name_pole_{dp['code']}", dp.get('name', dp['short_code'])) p = TrafficLightPole( intersection=intersection, code=dp["code"], name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) update_pole_geometry(p, dp["code"]) p.save() created_poles[dp["code"]] = p # Re-create all cables for dc in deduced["cables"]: db_model = resolve_cable_model(dc["model_name"]) custom_name = request.POST.get(f"name_cable_{dc['code']}", dc["name"]) c = TrafficLightCable( intersection=intersection, code=dc["code"], name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) update_cable_geometry(c, dc["code"]) c.save() # Re-create all lanterns for dl in deduced["lanterns"]: db_model = resolve_lantern_model(dl["model_name"]) pole = created_poles.get(dl["pole_code"]) custom_name = request.POST.get(f"name_lantern_{dl['code']}", dl["name"]) if pole: TrafficLightLantern.objects.create( pole=pole, code=dl["code"], name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) # Re-create all detectors for dd in deduced["detectors"]: db_model = resolve_detector_model(dd["model_name"]) pole = created_poles.get(dd["pole_code"]) custom_name = request.POST.get(f"name_detector_{dd['code']}", dd["name"]) TrafficLightDetector.objects.create( intersection=intersection, pole=pole, code=dd["code"], name=custom_name, model=db_model, phases=dd["phases"], status='active', validation_status='validated', updated_by=request.user ) messages.success(request, "Tous les anciens assets ont été archivés et les nouveaux ont été créés sur base du plan de croix.") # B. SELECTIVE OR INDIVIDUAL INTEGRATION else: # We iterate over each asset category, checking the user choices from the form post parameters # --- 1. Poles integration --- created_poles = {} for dp in deduced["poles"]: code = dp["code"] is_selected = request.POST.get(f"selected_pole_{code}") == "on" if is_selected: action = request.POST.get(f"action_pole_{code}", "keep") else: action = "keep" selected_model_id = request.POST.get(f"model_pole_{code}") db_model = None if selected_model_id: db_model = TrafficLightPoleModel.objects.filter(pk=selected_model_id).first() if not db_model: db_model = resolve_pole_model(dp["model"]) db_id = request.POST.get(f"db_id_pole_{code}") existing = None if db_id: existing = TrafficLightPole.objects.filter(pk=db_id, status='active').first() if not existing: existing = TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').first() custom_name = request.POST.get(f"name_pole_{code}", dp.get('name', dp['short_code'])) if action == "add" or (action == "replace" and not existing): p = TrafficLightPole( intersection=intersection, code=code, name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) update_pole_geometry(p, code) p.save() created_poles[code] = p elif action == "modify" and existing: existing.model = db_model existing.name_fr = custom_name existing.name_nl = custom_name existing.updated_by = request.user update_pole_geometry(existing, code) existing.save() created_poles[code] = existing elif action == "replace" and existing: # Archive existing.status = 'archived' existing.updated_by = request.user # Create new new_pole = TrafficLightPole( intersection=intersection, code=code, name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) update_pole_geometry(new_pole, code) new_pole.save() existing.replaced_by = new_pole existing.save() created_poles[code] = new_pole else: # 'keep' if existing: existing.name_fr = custom_name existing.name_nl = custom_name update_pole_geometry(existing, code) existing.save() created_poles[code] = existing # Handle poles present in DB only (orphans) for key in request.POST.keys(): if key.startswith("action_pole_db_"): code = key.replace("action_pole_db_", "") is_selected = request.POST.get(f"selected_pole_db_{code}") == "on" if is_selected: action = request.POST.get(key) else: action = "keep" if action == "archive": TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').update( status='archived', updated_by=request.user ) # Fetch active poles to match parent relation for lanterns/detectors if not resolved above active_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')} for c, p in created_poles.items(): active_poles[c] = p # --- 2. Cables integration --- for dc in deduced["cables"]: code = dc["code"] is_selected = request.POST.get(f"selected_cable_{code}") == "on" if is_selected: action = request.POST.get(f"action_cable_{code}", "keep") else: action = "keep" selected_model_id = request.POST.get(f"model_cable_{code}") db_model = None if selected_model_id: db_model = TrafficLightCableModel.objects.filter(pk=selected_model_id).first() if not db_model: db_model = resolve_cable_model(dc["model_name"]) db_id = request.POST.get(f"db_id_cable_{code}") existing = None if db_id: existing = TrafficLightCable.objects.filter(pk=db_id, status='active').first() if not existing: existing = TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').first() custom_name = request.POST.get(f"name_cable_{code}", dc["name"]) if action == "add": c = TrafficLightCable( intersection=intersection, code=code, name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) update_cable_geometry(c, code) c.save() elif action == "modify" and existing: existing.model = db_model existing.name_fr = custom_name existing.name_nl = custom_name existing.updated_by = request.user update_cable_geometry(existing, code) existing.save() elif action == "replace" and existing: existing.status = 'archived' existing.updated_by = request.user new_cable = TrafficLightCable( intersection=intersection, code=code, name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) update_cable_geometry(new_cable, code) new_cable.save() existing.replaced_by = new_cable existing.save() else: # 'keep' if existing: existing.name_fr = custom_name existing.name_nl = custom_name update_cable_geometry(existing, code) existing.save() for key in request.POST.keys(): if key.startswith("action_cable_db_"): code = key.replace("action_cable_db_", "") is_selected = request.POST.get(f"selected_cable_db_{code}") == "on" if is_selected: action = request.POST.get(key) else: action = "keep" if action == "archive": TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').update( status='archived', updated_by=request.user ) # --- 3. Lanterns integration --- for dl in deduced["lanterns"]: code = dl["code"] is_selected = request.POST.get(f"selected_lantern_{code}") == "on" if is_selected: action = request.POST.get(f"action_lantern_{code}", "keep") else: action = "keep" selected_model_id = request.POST.get(f"model_lantern_{code}") db_model = None if selected_model_id: db_model = TrafficLightLanternModel.objects.filter(pk=selected_model_id).first() if not db_model: db_model = resolve_lantern_model(dl["model_name"]) db_id = request.POST.get(f"db_id_lantern_{code}") existing = None if db_id: existing = TrafficLightLantern.objects.filter(pk=db_id, status='active').first() if not existing: existing = TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').first() pole = active_poles.get(dl["pole_code"]) custom_name = request.POST.get(f"name_lantern_{code}", dl["name"]) if action == "add" and pole: TrafficLightLantern.objects.create( pole=pole, code=code, name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) elif action == "modify" and existing: existing.model = db_model existing.name_fr = custom_name existing.name_nl = custom_name existing.updated_by = request.user existing.save() elif action == "replace" and existing and pole: existing.status = 'archived' existing.updated_by = request.user new_lantern = TrafficLightLantern.objects.create( pole=pole, code=code, name_fr=custom_name, name_nl=custom_name, model=db_model, status='active', validation_status='validated', updated_by=request.user ) existing.replaced_by = new_lantern existing.save() else: # 'keep' if existing: existing.name_fr = custom_name existing.name_nl = custom_name existing.save() for key in request.POST.keys(): if key.startswith("action_lantern_db_"): code = key.replace("action_lantern_db_", "") is_selected = request.POST.get(f"selected_lantern_db_{code}") == "on" if is_selected: action = request.POST.get(key) else: action = "keep" if action == "archive": TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').update( status='archived', updated_by=request.user ) # --- 4. Detectors integration --- for dd in deduced["detectors"]: code = dd["code"] is_selected = request.POST.get(f"selected_detector_{code}") == "on" if is_selected: action = request.POST.get(f"action_detector_{code}", "keep") else: action = "keep" selected_model_id = request.POST.get(f"model_detector_{code}") db_model = None if selected_model_id: db_model = TrafficLightDetectorModel.objects.filter(pk=selected_model_id).first() if not db_model: db_model = resolve_detector_model(dd["model_name"]) db_id = request.POST.get(f"db_id_detector_{code}") existing = None if db_id: existing = TrafficLightDetector.objects.filter(pk=db_id, status='active').first() if not existing: existing = TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').first() pole = active_poles.get(dd["pole_code"]) custom_name = request.POST.get(f"name_detector_{code}", dd["name"]) if action == "add": TrafficLightDetector.objects.create( intersection=intersection, pole=pole, code=code, name=custom_name, model=db_model, phases=dd["phases"], status='active', validation_status='validated', updated_by=request.user ) elif action == "modify" and existing: existing.model = db_model existing.name = custom_name existing.updated_by = request.user existing.save() elif action == "replace" and existing: existing.status = 'archived' existing.updated_by = request.user new_det = TrafficLightDetector.objects.create( intersection=intersection, pole=pole, code=code, name=custom_name, model=db_model, phases=dd["phases"], status='active', validation_status='validated', updated_by=request.user ) existing.replaced_by = new_det existing.save() else: # 'keep' if existing: existing.name = custom_name existing.save() for key in request.POST.keys(): if key.startswith("action_detector_db_"): code = key.replace("action_detector_db_", "") is_selected = request.POST.get(f"selected_detector_db_{code}") == "on" if is_selected: action = request.POST.get(key) else: action = "keep" if action == "archive": TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').update( status='archived', updated_by=request.user ) messages.success(request, "Les modifications d'assets ont été appliquées avec succès.") # Cleanup parsed file if os.path.exists(file_path): os.remove(file_path) return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection.id) @check_thematic_access('trafficlights', edit_permission_required=True) @require_POST def upload_dxf_ajax(request, intersection_id): intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) uploaded_file = request.FILES.get('dxf_file') if not uploaded_file: return JsonResponse({"success": False, "error": "Aucun fichier DXF fourni."}, status=400) uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files') os.makedirs(uploaded_dxf_dir, exist_ok=True) import uuid dxf_filename = f"{intersection.id}_{uuid.uuid4().hex}_{uploaded_file.name}" file_path = os.path.join(uploaded_dxf_dir, dxf_filename) with open(file_path, 'wb+') as destination: for chunk in uploaded_file.chunks(): destination.write(chunk) # Extract snapping points dxf_poles = [] try: dxf_poles = parse_dxf_poles(file_path) except Exception: pass return JsonResponse({ "success": True, "dxf_file": dxf_filename, "dxf_poles": dxf_poles }) def ensure_intersection_document_folder(intersection): from django.contrib.contenttypes.models import ContentType from django.utils.text import slugify from documents.models import DocumentFolder, DocumentFolderAttachment from common.models import Thematic ct = ContentType.objects.get_for_model(intersection) # Check if a default folder already exists for this intersection default_attachment = DocumentFolderAttachment.objects.filter( content_type=ct, object_id=intersection.id, is_default=True ).first() if default_attachment: return default_attachment.folder # Get parent folder (Traffic Lights under Assets) assets_folder, _ = DocumentFolder.objects.get_or_create( slug='assets', defaults={'name': 'Assets', 'is_predefined': True} ) # Get thematic tl_thematic = Thematic.objects.filter(code='trafficlights').first() trafficlights_folder, _ = DocumentFolder.objects.get_or_create( slug='trafficlights', defaults={ 'name': 'Traffic Lights', 'is_predefined': True, } ) if tl_thematic: trafficlights_folder.thematics.add(tl_thematic) trafficlights_folder.parent_folders.add(assets_folder) # Intersection folder name folder_name = intersection.code or f"Carrefour {intersection.id}" base_slug = slugify(folder_name) or f"tli-{intersection.id}" folder_slug = f"tli-{base_slug}" # Get or create folder folder, _ = DocumentFolder.objects.get_or_create( slug=folder_slug, defaults={ "name": folder_name, "application_label": "assets", }, ) if tl_thematic: folder.thematics.add(tl_thematic) folder.parent_folders.add(trafficlights_folder) DocumentFolderAttachment.objects.get_or_create( folder=folder, content_type=ct, object_id=intersection.id, defaults={"is_default": True}, ) return folder def generate_dxf_overlay_managed_document(dxf_managed_doc, user=None, selected_layers=None): import os import time start_total_time = time.time() logger.info("generate_dxf_overlay_managed_document: Starting overlay generation for ManagedDocument ID %s", dxf_managed_doc.id) from django.conf import settings from django.core.files.base import ContentFile from django.contrib.contenttypes.models import ContentType from pyproj import Transformer from documents.models import ManagedDocument, DocumentAttachment, DocumentVersion, DocumentTag from assets.views.trafficlights import calculate_dxf_limits_fast, generate_dxf_image_overlay, ensure_intersection_document_folder # Find the intersection from assets.models import TrafficLightIntersection ct_tli = ContentType.objects.get_for_model(TrafficLightIntersection) attachment = DocumentAttachment.objects.filter( document=dxf_managed_doc, content_type=ct_tli ).first() if not attachment: logger.info("generate_dxf_overlay_managed_document: No DocumentAttachment found for ManagedDocument ID %s linking to TrafficLightIntersection", dxf_managed_doc.id) return intersection = attachment.content_object logger.info("generate_dxf_overlay_managed_document: Found associated TrafficLightIntersection ID %s (Code: %s)", intersection.id, intersection.code) latest_version = dxf_managed_doc.latest_version if not latest_version or not latest_version.file: logger.info("generate_dxf_overlay_managed_document: ManagedDocument ID %s does not have a latest_version or version file", dxf_managed_doc.id) return dxf_file_path = latest_version.file.path logger.info("generate_dxf_overlay_managed_document: DXF file path to process: %s", dxf_file_path) # Calculate limits t0 = time.time() limits = calculate_dxf_limits_fast(dxf_file_path) logger.info("generate_dxf_overlay_managed_document: calculate_dxf_limits_fast took %.4f seconds", time.time() - t0) if not limits: logger.info("generate_dxf_overlay_managed_document: calculate_dxf_limits_fast returned None for file %s", dxf_file_path) return logger.info("generate_dxf_overlay_managed_document: Calculated limits: %s", limits) # Generate overlay PNG file path from assets.models import TrafficLightIntersectionDocument legacy_doc = TrafficLightIntersectionDocument.objects.filter(managed_document=dxf_managed_doc).first() if legacy_doc: dxf_basename = os.path.basename(legacy_doc.file.name) logger.info("generate_dxf_overlay_managed_document: Found legacy doc %s, using dxf_basename=%s", legacy_doc.id, dxf_basename) else: dxf_basename = os.path.basename(latest_version.file.name) logger.info("generate_dxf_overlay_managed_document: No legacy doc found, using dxf_basename=%s", dxf_basename) # Get the intersection folder folder = ensure_intersection_document_folder(intersection) logger.info("generate_dxf_overlay_managed_document: Using intersection folder: %s", folder.name if folder else "None") from django.utils import timezone folder_segment = folder.storage_segment date_segment = timezone.now().strftime("%Y/%m") overlay_filename = dxf_basename.lower().replace('.dxf', '.overlay.png') final_overlay_relative_path = f"document_versions/{folder_segment}/{date_segment}/{overlay_filename}" final_overlay_absolute_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, final_overlay_relative_path) os.makedirs(os.path.dirname(final_overlay_absolute_path), exist_ok=True) # Generate image overlay PNG file directly at its final private location logger.info("generate_dxf_overlay_managed_document: Calling generate_dxf_image_overlay...") t0 = time.time() generate_dxf_image_overlay(dxf_file_path, final_overlay_absolute_path, limits=limits, selected_layers=selected_layers) logger.info("generate_dxf_overlay_managed_document: generate_dxf_image_overlay took %.4f seconds", time.time() - t0) # Ensure public copy exists for map viewer overlay_dir = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts') os.makedirs(overlay_dir, exist_ok=True) public_overlay_path = os.path.join(overlay_dir, overlay_filename) import shutil try: shutil.copy2(final_overlay_absolute_path, public_overlay_path) except Exception as e: logger.error("generate_dxf_overlay_managed_document: Failed to copy overlay to public folder: %s", e) # Coordinates calculation transformer = Transformer.from_crs(31370, 4326, always_xy=True) min_x, min_y, max_x, max_y = limits top_left = transformer.transform(min_x, max_y) top_right = transformer.transform(max_x, max_y) bottom_right = transformer.transform(max_x, min_y) bottom_left = transformer.transform(min_x, min_y) coordinates = [ [top_left[0], top_left[1]], [top_right[0], top_right[1]], [bottom_right[0], bottom_right[1]], [bottom_left[0], bottom_left[1]] ] logger.info("generate_dxf_overlay_managed_document: Calculated georeference coordinates: %s", coordinates) # Check if we already have an overlay document for this DXF overlay_doc = ManagedDocument.objects.filter( unique_params__dxf_source_id=dxf_managed_doc.id, is_archived=False ).first() title = f"{os.path.splitext(dxf_basename)[0]} - [CAO] Overlay" description = f"Image géolocalisée générée à partir du fichier DXF {dxf_basename}" other_tag = DocumentTag.objects.filter(slug='other').first() plan_tag = DocumentTag.objects.filter(slug='plan').first() if not plan_tag: plan_tag = DocumentTag.objects.filter(name_fr__iexact='plan').first() if not plan_tag: plan_tag = DocumentTag.objects.create( name_fr="Plan", name_nl="Plan", slug="plan", show_in_asset_detail=True ) from common.models import Thematic traffic_lights_thematic = Thematic.objects.filter(code='trafficlights').first() if traffic_lights_thematic: plan_tag.thematics.add(traffic_lights_thematic) unique_params = { 'format': 'png', 'dxf_source_id': dxf_managed_doc.id } # Determine user uploader = user or latest_version.uploaded_by if overlay_doc: logger.info("generate_dxf_overlay_managed_document: Found existing overlay ManagedDocument ID %s. Updating...", overlay_doc.id) # Create a new version of the existing overlay document version = DocumentVersion( document=overlay_doc, uploaded_by=uploader, description=description, comment="Updated overlay image from DXF", status=DocumentVersion.STATUS_APPROVED, ) version.file.name = final_overlay_relative_path version.save() logger.info("generate_dxf_overlay_managed_document: Created new DocumentVersion ID %s", version.id) # Update coordinates on ManagedDocument overlay_doc.georeference_coords = coordinates overlay_doc.title = title overlay_doc.description = description overlay_doc.unique_params = unique_params overlay_doc.save() overlay_doc.tags.add(plan_tag) if other_tag: overlay_doc.tags.remove(other_tag) logger.info("generate_dxf_overlay_managed_document: Updated ManagedDocument ID %s details successfully", overlay_doc.id) else: logger.info("generate_dxf_overlay_managed_document: Creating a new overlay ManagedDocument...") # Create new ManagedDocument overlay_doc = ManagedDocument.objects.create( title=title, description=description, created_by=uploader, unique_params=unique_params, georeference_coords=coordinates, georeference_page=1, ) logger.info("generate_dxf_overlay_managed_document: Created new ManagedDocument ID %s", overlay_doc.id) overlay_doc.tags.add(plan_tag) overlay_doc.folders.add(folder) # Attach version version = DocumentVersion( document=overlay_doc, uploaded_by=uploader, description=description, comment="Initial overlay image from DXF", status=DocumentVersion.STATUS_APPROVED, ) version.file.name = final_overlay_relative_path version.save() logger.info("generate_dxf_overlay_managed_document: Created first DocumentVersion ID %s", version.id) # Attach to the intersection ct = ContentType.objects.get_for_model(intersection) DocumentAttachment.objects.get_or_create( document=overlay_doc, content_type=ct, object_id=intersection.id, defaults={"attached_by": uploader}, ) logger.info("generate_dxf_overlay_managed_document: Attached overlay ManagedDocument ID %s to intersection ID %s", overlay_doc.id, intersection.id) # Ensure source DXF document also receives the plan tag and removes other tag if plan_tag: dxf_managed_doc.tags.add(plan_tag) if other_tag: dxf_managed_doc.tags.remove(other_tag) logger.info("generate_dxf_overlay_managed_document: Added Plan tag and removed Other tag on source DXF ManagedDocument ID %s", dxf_managed_doc.id) logger.info("generate_dxf_overlay_managed_document: Total execution time: %.4f seconds", time.time() - start_total_time)