loko/loko/assets/views/trafficlights.py

5084 lines
No EOL
213 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# --------------------
# 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)
ct_id = ContentType.objects.get_for_model(obj).id
properties = {
"id": obj.id,
"code": obj.code,
"content_type_id": ct_id,
"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 not asset_intersection and hasattr(asset, 'get_location'):
loc = asset.get_location()
if loc and getattr(loc._meta, 'model_name', '') == 'trafficlightintersection':
asset_intersection = loc
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_<uuid>_<safe_name>
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)