5077 lines
No EOL
213 KiB
Python
5077 lines
No EOL
213 KiB
Python
# --------------------
|
||
# Traffic Lights Views
|
||
# --------------------
|
||
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from django.http import HttpResponse, JsonResponse, HttpResponseForbidden, Http404
|
||
from django.shortcuts import render, get_object_or_404, redirect
|
||
from django.urls import reverse
|
||
from django.core.serializers import serialize
|
||
from django.core.serializers.json import DjangoJSONEncoder
|
||
from django.contrib.gis.db.models.functions import Transform
|
||
from django.db.models import Value, OuterRef, Subquery, Q, Case, When
|
||
from django.db.models import IntegerField, CharField, BooleanField
|
||
from django.db.models import Func
|
||
from django.db.models.functions import Lower
|
||
from django.contrib.gis.db.models import GeometryField
|
||
from django.contrib.gis.geos import Point, GEOSGeometry
|
||
from django.contrib.gis.db.models.functions import Distance
|
||
from django.contrib.auth.decorators import permission_required
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.views.decorators.http import require_GET, require_POST
|
||
from django.template.loader import render_to_string
|
||
from django.utils.translation import get_language, gettext as _
|
||
from django.utils.timezone import make_aware, get_current_timezone
|
||
|
||
import json
|
||
from datetime import datetime
|
||
import unicodedata
|
||
from collections import defaultdict
|
||
|
||
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics
|
||
from common.utils import check_thematic_access
|
||
from interventions.models import InterventionLocation
|
||
from interventions.views.drafts import InterventionDraftManager
|
||
from assets.models import TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightController, TrafficLightPole, TrafficLightCable, TrafficLightLantern, TrafficLightDetector, TrafficLightElectricalCabinet, TrafficLightRadar, TrafficLightRadarPole
|
||
from assets.models import AssetCategory, TrafficLightCableModel, TrafficLightControllerModel, TrafficLightDetectorModel, TrafficLightLanternModel, TrafficLightPoleModel, TrafficLightElectricalCabinetModel, TrafficLightContract, TrafficLightRadarModel
|
||
from assets.forms import IntersectionDocumentForm
|
||
from assets.permissions import get_allowed_actions_for_thematic, can_edit_location_for_thematic
|
||
from contracts.models import Contract
|
||
from documents.services import DocumentManagementService
|
||
from documents.permissions import user_can_browse_folder
|
||
import os
|
||
import ezdxf
|
||
|
||
# Global cache for parsed DXF documents to avoid expensive disk reading and parsing (takes ~3.5s for 26MB files)
|
||
DXF_DOC_CACHE = {}
|
||
|
||
def get_cached_dxf_document(file_path):
|
||
import hashlib
|
||
try:
|
||
with open(file_path, 'rb') as f:
|
||
header = f.read(8192)
|
||
cache_key = hashlib.md5(header).hexdigest()
|
||
except Exception:
|
||
cache_key = file_path
|
||
|
||
cached = DXF_DOC_CACHE.get(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
# Read and store in cache
|
||
doc = ezdxf.readfile(file_path)
|
||
DXF_DOC_CACHE[cache_key] = doc
|
||
# Limit cache size to prevent memory leaks (keep at most 5 documents)
|
||
if len(DXF_DOC_CACHE) > 5:
|
||
DXF_DOC_CACHE.pop(next(iter(DXF_DOC_CACHE)))
|
||
|
||
return doc
|
||
|
||
def get_cable_color(code):
|
||
palette = [
|
||
"#10b981", # Emerald
|
||
"#3b82f6", # Blue
|
||
"#f59e0b", # Amber
|
||
"#ef4444", # Red
|
||
"#8b5cf6", # Purple
|
||
"#ec4899", # Pink
|
||
"#06b6d4", # Cyan
|
||
"#eab308", # Yellow
|
||
"#14b8a6", # Teal
|
||
"#f43f5e", # Rose
|
||
"#84cc16", # Lime
|
||
"#a855f7", # Purple-bright
|
||
]
|
||
if not code:
|
||
return palette[0]
|
||
h = sum(ord(char) for char in code)
|
||
return palette[h % len(palette)]
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def trafficlights_index(request):
|
||
thematic = 'trafficlights'
|
||
lang = get_language()
|
||
|
||
# Get filters from URL
|
||
statuses = request.GET.getlist('status')
|
||
voltages = request.GET.getlist('voltage')
|
||
begin_date = request.GET.get('begin_date')
|
||
connected = request.GET.get('connected')
|
||
controller_maintenance_provider = request.GET.get('controller_maintenance_provider')
|
||
selected_categories = request.GET.getlist('category')
|
||
selected_models = request.GET.getlist('model')
|
||
|
||
# Get preventive draft ID (if any) for selecting assets
|
||
preventive_draft_id = request.GET.get('preventive_draft_id')
|
||
if preventive_draft_id:
|
||
drafts = InterventionDraftManager(request.session)
|
||
draft_data = drafts.get(preventive_draft_id)
|
||
form_data = draft_data.get('form_data', {})
|
||
preventive_element_type = form_data.get('type_of_selected_elements')
|
||
if preventive_element_type == 'assets':
|
||
selected_assets = draft_data.get('assets', []) or []
|
||
else:
|
||
selected_locations_ids = draft_data.get('locations', [])
|
||
|
||
thematic_choices_qs = (
|
||
UserThematics.objects
|
||
.filter(user_config=request.user.config, can_view_assets=True)
|
||
.select_related('thematic')
|
||
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
|
||
)
|
||
thematics = [tc.thematic for tc in thematic_choices_qs]
|
||
|
||
|
||
selected_thematic = {
|
||
"code": thematic,
|
||
"name": next((t.name for t in thematics if t.code == thematic), None),
|
||
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
||
}
|
||
|
||
# Catégories limitées à la thématique "trafficlights"
|
||
categories = list(AssetCategory.objects.filter(thematic__code='trafficlights'))
|
||
categories.sort(key=lambda c: c.get_name().lower())
|
||
category_choices = [(c.id, c.get_name()) for c in categories]
|
||
|
||
# Classes de modèles à agréger
|
||
asset_model_classes = [
|
||
TrafficLightCableModel,
|
||
TrafficLightControllerModel,
|
||
TrafficLightDetectorModel,
|
||
TrafficLightLanternModel,
|
||
TrafficLightPoleModel,
|
||
TrafficLightElectricalCabinetModel,
|
||
]
|
||
|
||
# Récupérer tous les modèles de chaque classe
|
||
all_models = []
|
||
for cls in asset_model_classes:
|
||
all_models.extend(cls.objects.all())
|
||
|
||
# Trier et construire les choix
|
||
all_models.sort(key=lambda m: (m.get_name() or "").lower())
|
||
model_choices = [(m.id, m.get_name()) for m in all_models]
|
||
|
||
# Construire le mapping catégorie → modèles
|
||
category_model_map = defaultdict(list)
|
||
for model in all_models:
|
||
if model.category:
|
||
category_model_map[str(model.category_id)].append({
|
||
"key": str(model.id),
|
||
"label": model.get_name(),
|
||
})
|
||
|
||
|
||
# Get controller maintenance contracts (for internal users only)
|
||
user_config = UserConfig.objects.filter(user=request.user).first()
|
||
is_intern = user_config.is_intern if user_config else False
|
||
|
||
controller_maintenance_contracts = []
|
||
if is_intern:
|
||
# Get all contracts linked to traffic light controller maintenance (status='active', maintenance_type='controller')
|
||
controller_maintenance_contracts = Contract.objects.filter(
|
||
id__in=TrafficLightContract.objects.filter(
|
||
status='active',
|
||
maintenance_type='controller'
|
||
).values_list('contract_id', flat=True).distinct()
|
||
).order_by('contract_number')
|
||
controller_maintenance_contracts = [(c.id, f"{c.contract_number} - {c.company.name}") for c in controller_maintenance_contracts]
|
||
|
||
context = {
|
||
'status_choices': TrafficLightIntersection._meta.get_field('status').choices,
|
||
'voltage_choices': TrafficLightIntersection._meta.get_field('voltage_type').choices,
|
||
'connected_choices':[(True, _('Oui')), (False, _('Non'))],
|
||
'selected_statuses': statuses,
|
||
'selected_voltages': voltages,
|
||
'connected': connected,
|
||
'controller_maintenance_provider': controller_maintenance_provider,
|
||
'controller_maintenance_contracts': controller_maintenance_contracts,
|
||
'is_intern': is_intern,
|
||
'begin_date': begin_date,
|
||
'thematics': thematics,
|
||
'selected_thematic': selected_thematic,
|
||
'category_choices': category_choices,
|
||
'model_choices': model_choices,
|
||
'category_model_map': dict(category_model_map),
|
||
'selected_categories': selected_categories,
|
||
'selected_models': selected_models,
|
||
'preventive_draft_id': preventive_draft_id,
|
||
'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '',
|
||
'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [],
|
||
"selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [],
|
||
'locations_tab_title': _("Intersections"),
|
||
'locations_assets_title': "Assets",
|
||
'can_create_location': can_edit_location_for_thematic(request.user, 'trafficlights'),
|
||
'location_type': 'trafficlightintersection',
|
||
'thematic_code': 'trafficlights',
|
||
}
|
||
|
||
return render(request, "assets/trafficlights_index.html", context)
|
||
|
||
|
||
def trafficlights_intersections(request):
|
||
pass
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def trafficlights_intersections_geojson(request):
|
||
|
||
if request.method == "POST":
|
||
try:
|
||
data = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||
|
||
statuses = ensure_list(data.get('status'))
|
||
voltages = ensure_list(data.get('voltage'))
|
||
begin_date = data.get('begin_date')
|
||
connected = data.get('connected')
|
||
controller_maintenance_provider = data.get('controller_maintenance_provider')
|
||
|
||
# Récupération et parsing de la géométrie
|
||
filter_geom_raw = data.get("filter_geom")
|
||
if filter_geom_raw:
|
||
try:
|
||
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
|
||
filter_geom.transform(3812)
|
||
except Exception as e:
|
||
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
|
||
else:
|
||
filter_geom = None
|
||
|
||
else:
|
||
statuses = request.GET.getlist('status')
|
||
voltages = request.GET.getlist('voltage')
|
||
begin_date = request.GET.get('begin_date')
|
||
connected = request.GET.get('connected')
|
||
controller_maintenance_provider = request.GET.get('controller_maintenance_provider')
|
||
filter_geom = None
|
||
|
||
|
||
if connected == 'yes':
|
||
connected_value = True
|
||
elif connected == 'no':
|
||
connected_value = False
|
||
else:
|
||
connected_value = None # correspond à "Tous"
|
||
|
||
|
||
trafficlights = TrafficLightIntersection.objects.annotate(geom_4326=Transform('geom', 4326))
|
||
|
||
if statuses:
|
||
trafficlights = trafficlights.filter(status__in=statuses)
|
||
if voltages:
|
||
trafficlights = trafficlights.filter(voltage_type__in=voltages)
|
||
if begin_date:
|
||
# Convert YYYY-MM-DD string to aware datetime at start of day
|
||
begin_datetime = datetime.strptime(begin_date, '%Y-%m-%d')
|
||
begin_datetime = make_aware(begin_datetime, get_current_timezone())
|
||
trafficlights = trafficlights.filter(begin_time__gte=begin_datetime)
|
||
if connected:
|
||
connected_subquery = TrafficLightController.objects.filter(
|
||
intersection=OuterRef('pk'),
|
||
status='active',
|
||
).order_by('-installation_date').values('connected_to_control_center')[:1]
|
||
trafficlights = trafficlights.annotate(
|
||
is_connected_to_control_center=Subquery(connected_subquery, output_field=BooleanField())
|
||
)
|
||
trafficlights = trafficlights.filter(is_connected_to_control_center=connected_value)
|
||
if controller_maintenance_provider:
|
||
# Filter by controller maintenance contract (TrafficLightContract with type='controller')
|
||
trafficlights = trafficlights.filter(
|
||
trafficlight_contracts__status='active',
|
||
trafficlight_contracts__maintenance_type='controller',
|
||
trafficlight_contracts__contract_id=controller_maintenance_provider
|
||
)
|
||
|
||
if filter_geom:
|
||
trafficlights = trafficlights.filter(geom__intersects=filter_geom)
|
||
|
||
|
||
# Check user access to contracts
|
||
user_config = UserConfig.objects.filter(user=request.user).first()
|
||
if user_config.limit_assets_to_contracts:
|
||
accessible_contract_ids = UserContractAccess.objects.filter(
|
||
user_config=user_config,
|
||
can_view_assets=True
|
||
).values_list('contract_id', flat=True)
|
||
|
||
trafficlights = trafficlights.filter(
|
||
trafficlight_contracts__status='active',
|
||
trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
|
||
# Recherche plein texte
|
||
if request.method == 'POST':
|
||
from assets.views.core import apply_search
|
||
trafficlights = apply_search(trafficlights, data)
|
||
|
||
def obj_to_feature(obj):
|
||
geometry = json.loads(obj.geom_4326.geojson) if obj.geom_4326 else None
|
||
return {
|
||
"type": "Feature",
|
||
"id": obj.id,
|
||
"geometry": geometry,
|
||
"properties": {
|
||
"code": obj.code,
|
||
"name": obj.get_name(),
|
||
"status": obj.status,
|
||
"voltage_type": obj.voltage_type,
|
||
"lon": obj.lon,
|
||
"lat": obj.lat,
|
||
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||
"element_type": "Carrefour",
|
||
"thematic": "trafficlights",
|
||
"is_location": True,
|
||
"is_radar": obj.is_radar,
|
||
}
|
||
}
|
||
|
||
features = []
|
||
|
||
for tl in trafficlights:
|
||
features.append(obj_to_feature(tl))
|
||
|
||
geojson_data = {
|
||
"type": "FeatureCollection",
|
||
"features": features,
|
||
}
|
||
|
||
return JsonResponse(geojson_data, safe=False)
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def trafficlights_intersections_detail(request, intersection_id):
|
||
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
||
|
||
ct_tli = ContentType.objects.get_for_model(TrafficLightIntersection)
|
||
interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=ct_tli, object_id=intersection.id).select_related('intervention').order_by('-intervention__creation_time')]
|
||
|
||
assets = intersection.get_active_assets()
|
||
if 'cables' in assets:
|
||
for cable in assets['cables']:
|
||
cable.color = get_cable_color(cable.code)
|
||
|
||
# --- NOUVELLE GESTION DOCUMENTAIRE ---
|
||
# Récupérer les documents depuis le système de gestion documentaire
|
||
doc_data = DocumentManagementService.get_documents_for_asset(intersection)
|
||
managed_main_documents = doc_data.get("main_documents", [])
|
||
document_folder = doc_data.get("folder")
|
||
|
||
# Fallback vers l'ancien système si pas de répertoire configuré
|
||
if document_folder is None:
|
||
# Ancien système: récupérer depuis TrafficLightIntersectionDocument
|
||
as_built_doc = intersection.documents.filter(document_type='as_built').filter(status='active').order_by('-id').first()
|
||
cross_plan_doc = intersection.documents.filter(document_type='cross_plan').filter(status='active').order_by('-id').first()
|
||
programming_file_doc = intersection.documents.filter(document_type='programming_file').filter(status='active').order_by('-id').first()
|
||
connexion_plan_doc = intersection.documents.filter(document_type='connexion_plan').filter(status='active').order_by('-id').first()
|
||
|
||
main_documents = []
|
||
if as_built_doc:
|
||
main_documents.append(as_built_doc)
|
||
if cross_plan_doc:
|
||
main_documents.append(cross_plan_doc)
|
||
if programming_file_doc:
|
||
main_documents.append(programming_file_doc)
|
||
if connexion_plan_doc:
|
||
main_documents.append(connexion_plan_doc)
|
||
|
||
use_managed_documents = False
|
||
else:
|
||
main_documents = managed_main_documents
|
||
use_managed_documents = True
|
||
# --- FIN NOUVELLE GESTION DOCUMENTAIRE ---
|
||
|
||
user = request.user
|
||
user_config = getattr(user, "config", None)
|
||
|
||
can_add_unique_tag_documents = False
|
||
if user_config and user_config.is_intern:
|
||
can_add_unique_tag_documents = True
|
||
elif user_config:
|
||
can_add_unique_tag_documents = user_config.can_add_unique_tag_documents
|
||
|
||
# Générer le GeoJSON des assets (poles, câbles, armoires, contrôleurs, détecteurs, radars avec géométrie)
|
||
assets_features = []
|
||
for type_key, asset_list in assets.items():
|
||
for a in asset_list:
|
||
if hasattr(a, 'geom') and a.geom:
|
||
try:
|
||
# Transformer la géométrie en WGS84 (EPSG:4326) pour MapLibre
|
||
geom_wgs84 = a.geom.clone()
|
||
geom_wgs84.transform(4326)
|
||
|
||
category_name = None
|
||
if getattr(a, 'category', None):
|
||
category_name = a.category.get_name()
|
||
|
||
model_code = None
|
||
if getattr(a, 'model', None):
|
||
model_code = getattr(a.model, 'code', None)
|
||
|
||
properties = {
|
||
"code": a.code,
|
||
"name": a.get_name(),
|
||
"category": category_name,
|
||
"model": model_code,
|
||
"asset_type": a._meta.model_name,
|
||
"detail_url": a.get_absolute_url() if hasattr(a, 'get_absolute_url') else None,
|
||
}
|
||
if a._meta.model_name == 'trafficlightcable':
|
||
properties["color"] = get_cable_color(a.code)
|
||
|
||
assets_features.append({
|
||
"type": "Feature",
|
||
"id": a.id,
|
||
"geometry": json.loads(geom_wgs84.json),
|
||
"properties": properties,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
assets_geojson = {
|
||
"type": "FeatureCollection",
|
||
"features": assets_features,
|
||
}
|
||
|
||
thematic = Thematic.objects.filter(code='trafficlights').first()
|
||
allowed_location_actions = get_allowed_actions_for_thematic(request.user, thematic) if thematic else []
|
||
can_edit_location = can_edit_location_for_thematic(request.user, 'trafficlights')
|
||
|
||
# Vérifier si l'utilisateur peut naviguer vers le folder dans le module documents
|
||
can_browse_folder = False
|
||
if document_folder:
|
||
can_browse_folder = user_can_browse_folder(request.user, document_folder)
|
||
|
||
# Contrats liés à l'intersection
|
||
trafficlight_contracts = intersection.trafficlight_contracts.select_related('contract__company').all() if can_edit_location else None
|
||
available_contracts = (
|
||
Contract.objects.filter(thematics__code='trafficlights').order_by('contract_number')
|
||
if can_edit_location else None
|
||
)
|
||
|
||
# Check for DXF staged assets count
|
||
staged_assets_count = TrafficLightPole.objects.filter(
|
||
intersection=intersection,
|
||
status='to_be_approved',
|
||
validation_status='to_be_validated'
|
||
).count()
|
||
|
||
# Fetch DXF files associated with this intersection
|
||
import os
|
||
dxf_files = []
|
||
|
||
# 1. Add document management DXF files first (most recent updated_at first)
|
||
from documents.models import DocumentAttachment, ManagedDocument
|
||
|
||
ct = ContentType.objects.get_for_model(intersection)
|
||
attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id)
|
||
managed_dxf_docs = ManagedDocument.objects.filter(
|
||
id__in=attachments.values_list('document_id', flat=True),
|
||
is_archived=False
|
||
).select_related('current_version')
|
||
|
||
managed_dxf_docs = sorted(
|
||
list(managed_dxf_docs),
|
||
key=lambda x: x.updated_at,
|
||
reverse=True
|
||
)
|
||
for doc in managed_dxf_docs:
|
||
if doc.current_version and doc.current_version.file:
|
||
fn = os.path.basename(doc.current_version.file.name)
|
||
if fn.lower().endswith('.dxf') and fn not in dxf_files:
|
||
dxf_files.append(fn)
|
||
|
||
# 2. Add DB documents next, ordered by upload_date descending (legacy fallback)
|
||
dxf_docs = sorted(
|
||
[doc for doc in intersection.documents.all() if doc.file.name.lower().endswith('.dxf')],
|
||
key=lambda x: x.upload_date,
|
||
reverse=True
|
||
)
|
||
for doc in dxf_docs:
|
||
fn = os.path.basename(doc.file.name)
|
||
if fn not in dxf_files:
|
||
dxf_files.append(fn)
|
||
|
||
# 3. Add read-only external files next
|
||
external_files = []
|
||
read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
|
||
if os.path.exists(read_only_dxf_dir):
|
||
code_lower = intersection.code.lower() if intersection.code else ""
|
||
for f in os.listdir(read_only_dxf_dir):
|
||
if f.lower().endswith('.dxf'):
|
||
if code_lower and code_lower in f.lower():
|
||
external_files.append(f)
|
||
for f in sorted(external_files):
|
||
if f not in dxf_files:
|
||
dxf_files.append(f)
|
||
|
||
from django.utils import timezone
|
||
today_date = timezone.localdate().strftime('%Y-%m-%d')
|
||
|
||
from django.urls import reverse
|
||
georeferenced_documents = []
|
||
if use_managed_documents and document_folder:
|
||
from documents.models import ManagedDocument
|
||
folder_ids = DocumentManagementService.get_folder_and_descendants(document_folder)
|
||
docs = ManagedDocument.objects.filter(
|
||
folders__pk__in=folder_ids,
|
||
is_archived=False,
|
||
georeference_coords__isnull=False
|
||
).distinct()
|
||
for doc in docs:
|
||
is_dxf_overlay = bool(doc.unique_params and 'dxf_source_id' in doc.unique_params)
|
||
georeferenced_documents.append({
|
||
'id': doc.id,
|
||
'title': doc.title,
|
||
'coords': doc.georeference_coords,
|
||
'url': reverse('documents:page_image', args=[doc.pk, doc.georeference_page or 1]),
|
||
'is_dxf': is_dxf_overlay
|
||
})
|
||
|
||
# Add DXF overlay images to georeferenced documents list (legacy fallback)
|
||
if not use_managed_documents:
|
||
for dxf_file in dxf_files:
|
||
file_path = None
|
||
for d in intersection.documents.all():
|
||
if os.path.basename(d.file.name) == dxf_file:
|
||
file_path = d.file.path
|
||
break
|
||
if not file_path:
|
||
uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files')
|
||
read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
|
||
file_path = os.path.join(uploaded_dxf_dir, dxf_file)
|
||
if not os.path.exists(file_path):
|
||
file_path = os.path.join(read_only_dxf_dir, dxf_file)
|
||
|
||
if os.path.exists(file_path):
|
||
try:
|
||
overlay_filename = dxf_file.lower().replace('.dxf', '.overlay.png')
|
||
overlay_path = os.path.join(settings.MEDIA_ROOT, 'dxf_layouts', overlay_filename)
|
||
|
||
# Propose ONLY if the overlay has been previously generated/saved
|
||
if os.path.exists(overlay_path):
|
||
from django.core.cache import cache
|
||
from pyproj import Transformer
|
||
from assets.views.trafficlights import calculate_dxf_limits_fast
|
||
|
||
cache_key = f"dxf_limits_{dxf_file.lower()}"
|
||
limits = cache.get(cache_key)
|
||
if not limits:
|
||
limits = calculate_dxf_limits_fast(file_path)
|
||
if limits:
|
||
cache.set(cache_key, limits, timeout=86400 * 30)
|
||
|
||
if limits:
|
||
transformer = Transformer.from_crs(31370, 4326, always_xy=True)
|
||
min_x, min_y, max_x, max_y = limits
|
||
top_left = transformer.transform(min_x, max_y)
|
||
top_right = transformer.transform(max_x, max_y)
|
||
bottom_right = transformer.transform(max_x, min_y)
|
||
bottom_left = transformer.transform(min_x, min_y)
|
||
|
||
georeferenced_documents.append({
|
||
'id': f"dxf-{dxf_file}",
|
||
'title': f"[CAO] {dxf_file}",
|
||
'coords': [
|
||
[top_left[0], top_left[1]],
|
||
[top_right[0], top_right[1]],
|
||
[bottom_right[0], bottom_right[1]],
|
||
[bottom_left[0], bottom_left[1]]
|
||
],
|
||
'url': f"{settings.MEDIA_URL}dxf_layouts/{overlay_filename}",
|
||
'is_dxf': True
|
||
})
|
||
except Exception as e:
|
||
print(f"Error preparing DXF overlay for {dxf_file}: {e}")
|
||
|
||
georeferenced_documents_json = json.dumps(georeferenced_documents)
|
||
|
||
return render(request, "assets/trafficlights_intersection_detail.html",
|
||
{
|
||
"intersection":intersection,
|
||
"interventions":interventions,
|
||
"assets":assets,
|
||
"assets_geojson": assets_geojson,
|
||
'main_documents': main_documents,
|
||
'add_document_form': IntersectionDocumentForm(),
|
||
'allowed_location_actions': allowed_location_actions,
|
||
'can_edit_location': can_edit_location,
|
||
'thematic_code': 'trafficlights',
|
||
'location_type': 'trafficlightintersection',
|
||
'document_folder': document_folder,
|
||
'use_managed_documents': use_managed_documents if 'use_managed_documents' in locals() else False,
|
||
'can_add_unique_tag_documents': can_add_unique_tag_documents,
|
||
'can_browse_folder': can_browse_folder,
|
||
'trafficlight_contracts': trafficlight_contracts,
|
||
'available_contracts': available_contracts,
|
||
'staged_assets_count': staged_assets_count,
|
||
'today_date': today_date,
|
||
'dxf_files': dxf_files,
|
||
'georeferenced_documents_json': georeferenced_documents_json,
|
||
})
|
||
|
||
|
||
|
||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||
@require_POST
|
||
def trafficlights_intersections_contracts_add(request, intersection_id):
|
||
"""Ajoute un contrat à une intersection."""
|
||
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
||
contract_id = request.POST.get('contract_id')
|
||
maintenance_type = request.POST.get('maintenance_type', 'other')
|
||
status = request.POST.get('status', 'active')
|
||
|
||
if not contract_id:
|
||
return JsonResponse({'success': False, 'error': _("Contrat requis.")}, status=400)
|
||
|
||
contract = get_object_or_404(Contract, pk=contract_id)
|
||
tl_contract, created = TrafficLightContract.objects.get_or_create(
|
||
intersection=intersection,
|
||
contract=contract,
|
||
defaults={'maintenance_type': maintenance_type, 'status': status},
|
||
)
|
||
if not created:
|
||
tl_contract.maintenance_type = maintenance_type
|
||
tl_contract.status = status
|
||
tl_contract.save(update_fields=['maintenance_type', 'status'])
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'id': tl_contract.pk,
|
||
'contract_number': contract.contract_number,
|
||
'company': contract.company.name,
|
||
'maintenance_type': tl_contract.get_maintenance_type_display(),
|
||
'status': tl_contract.get_status_display(),
|
||
})
|
||
|
||
|
||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||
@require_POST
|
||
def trafficlights_intersections_contracts_remove(request, intersection_id, tl_contract_id):
|
||
"""Supprime un contrat d'une intersection."""
|
||
tl_contract = get_object_or_404(
|
||
TrafficLightContract, pk=tl_contract_id, intersection_id=intersection_id
|
||
)
|
||
tl_contract.delete()
|
||
return JsonResponse({'success': True})
|
||
|
||
|
||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||
@require_POST
|
||
def trafficlights_intersections_add_document(request, intersection_id):
|
||
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
||
count = int(request.POST.get('file_count', 0))
|
||
files = request.FILES.getlist('files')
|
||
|
||
user = request.user
|
||
|
||
# Vérifier si on utilise le nouveau système de gestion documentaire
|
||
document_folder = DocumentManagementService.get_default_folder_for_object(intersection)
|
||
|
||
if document_folder is not None:
|
||
# Nouveau système de gestion documentaire
|
||
for i in range(count):
|
||
file = files[i]
|
||
tag_slug = request.POST.get(f'tag_{i}', None)
|
||
folder_id = request.POST.get(f'folder_{i}', None)
|
||
unique_param_value = request.POST.get(f'unique_param_{i}', None)
|
||
|
||
if not tag_slug:
|
||
continue
|
||
|
||
# Récupérer le tag
|
||
from documents.models import DocumentTag
|
||
try:
|
||
tag = DocumentTag.objects.get(slug=tag_slug)
|
||
except DocumentTag.DoesNotExist:
|
||
continue
|
||
|
||
# Vérifier les permissions pour les tags uniques
|
||
if tag.is_unique:
|
||
can_add = DocumentManagementService.can_user_add_unique_tag_document(user, tag)
|
||
if not can_add:
|
||
continue
|
||
|
||
# Si le tag est unique, utiliser le répertoire par défaut
|
||
if tag.is_unique:
|
||
target_folder = document_folder
|
||
else:
|
||
# Sinon, utiliser le répertoire choisi par l'utilisateur
|
||
from documents.models import DocumentFolder
|
||
if folder_id:
|
||
target_folder = DocumentFolder.objects.filter(id=folder_id).first() or document_folder
|
||
else:
|
||
target_folder = document_folder
|
||
|
||
# Préparer les unique_params
|
||
# Le format est automatiquement ajouté par le service d'après l'extension du fichier.
|
||
# On ne passe ici que les paramètres extra spécifiques au tag.
|
||
unique_params = None
|
||
if tag.slug == 'crossplan' and unique_param_value:
|
||
# Pour crossplan: ajouter la valeur des câbles fournie par l'utilisateur
|
||
cables_normalized = unique_param_value.strip()
|
||
if cables_normalized:
|
||
unique_params = {'cables': cables_normalized}
|
||
|
||
# Ajouter le document via le service
|
||
DocumentManagementService.add_document(
|
||
file=file,
|
||
tag=tag,
|
||
folder=target_folder,
|
||
user=user,
|
||
content_object=intersection,
|
||
unique_params=unique_params
|
||
)
|
||
else:
|
||
# Ancien système
|
||
for i in range(count):
|
||
file = files[i]
|
||
document_type = request.POST.get(f'document_type_{i}', 'other')
|
||
|
||
TrafficLightIntersectionDocument.objects.create(
|
||
intersection=intersection,
|
||
file=file,
|
||
document_type=document_type,
|
||
uploaded_by=request.user
|
||
)
|
||
|
||
return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection_id)
|
||
|
||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||
@require_POST
|
||
def trafficlights_intersections_delete_document(request, document_id):
|
||
document = get_object_or_404(TrafficLightIntersectionDocument, id=document_id)
|
||
intersection_id = document.intersection.id # pour rediriger vers la bonne page
|
||
|
||
# Suppression du fichier si nécessaire (en plus de l'objet DB)
|
||
if document.file:
|
||
document.file.delete(save=False)
|
||
if document.thumbnail:
|
||
document.thumbnail.delete(save=False)
|
||
|
||
document.delete()
|
||
#messages.success(request, "Le document a été supprimé avec succès.")
|
||
return redirect('assets:trafficlights_intersections_detail', intersection_id=intersection_id)
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def fetch_document_history(request):
|
||
intersection_id = request.GET.get('intersection_id')
|
||
document_type = request.GET.get('document_type')
|
||
tag_slug = request.GET.get('tag_slug')
|
||
unique_params_str = request.GET.get('unique_params')
|
||
|
||
user = request.user
|
||
user_config = getattr(user, "config", None)
|
||
|
||
is_delete_document_allowed = False
|
||
if user_config:
|
||
if user_config.roles.filter(name='admin').exists():
|
||
is_delete_document_allowed = True
|
||
elif UserThematics.objects.filter(
|
||
user_config=user_config,
|
||
thematic__code='trafficlights',
|
||
can_edit_assets=True
|
||
).exists():
|
||
is_delete_document_allowed = True
|
||
|
||
# Vérifier si on utilise le nouveau système de gestion documentaire
|
||
intersection = TrafficLightIntersection.objects.filter(pk=intersection_id).first()
|
||
if intersection:
|
||
document_folder = DocumentManagementService.get_default_folder_for_object(intersection)
|
||
else:
|
||
document_folder = None
|
||
|
||
if document_folder is not None and tag_slug:
|
||
# Nouveau système: récupérer l'historique des versions
|
||
# Décoder les unique_params si fournis
|
||
unique_params = None
|
||
if unique_params_str:
|
||
import json
|
||
try:
|
||
unique_params = json.loads(unique_params_str)
|
||
except (json.JSONDecodeError, ValueError):
|
||
unique_params = None
|
||
|
||
versions = DocumentManagementService.get_document_history_by_tag(intersection, tag_slug, unique_params)
|
||
html = render_to_string('assets/document_version_history_list.html', {
|
||
'versions': versions,
|
||
'is_delete_document_allowed': False,
|
||
'use_managed_documents': True
|
||
})
|
||
else:
|
||
# Ancien système: récupérer depuis TrafficLightIntersectionDocument
|
||
documents = TrafficLightIntersectionDocument.objects.filter(
|
||
intersection_id=intersection_id,
|
||
document_type=document_type
|
||
).order_by('-id')
|
||
|
||
html = render_to_string('assets/document_history_list.html', {
|
||
'documents': documents,
|
||
'is_delete_document_allowed': is_delete_document_allowed
|
||
})
|
||
|
||
return JsonResponse({'html': html})
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
@require_GET
|
||
def get_document_config(request, intersection_id):
|
||
"""
|
||
API pour récupérer la configuration des documents (tags et répertoires disponibles).
|
||
Utilisé par le JavaScript pour le formulaire d'ajout de document.
|
||
"""
|
||
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
||
|
||
user = request.user
|
||
user_config = getattr(user, "config", None)
|
||
|
||
# Vérifier les permissions
|
||
can_add_unique_tag_documents = False
|
||
if user_config and user_config.is_intern:
|
||
can_add_unique_tag_documents = True
|
||
elif user_config:
|
||
can_add_unique_tag_documents = user_config.can_add_unique_tag_documents
|
||
|
||
# Vérifier si le nouveau système est activé
|
||
document_folder = DocumentManagementService.get_default_folder_for_object(intersection)
|
||
|
||
if document_folder is None:
|
||
return JsonResponse({
|
||
'use_managed_documents': False,
|
||
'can_add_unique_tag_documents': can_add_unique_tag_documents,
|
||
'tags': [],
|
||
'folders': [],
|
||
})
|
||
|
||
# Récupérer les tags disponibles pour la thématique "trafficlights"
|
||
tags = DocumentManagementService.get_available_tags_for_thematic('trafficlights')
|
||
tags_data = [
|
||
{
|
||
'slug': tag.slug,
|
||
'name': tag.name,
|
||
'is_unique': tag.is_unique,
|
||
'show_in_asset_detail': tag.show_in_asset_detail,
|
||
}
|
||
for tag in tags
|
||
]
|
||
|
||
# Récupérer les sous-répertoires disponibles
|
||
folders = DocumentManagementService.get_subfolders_for_object(intersection)
|
||
|
||
return JsonResponse({
|
||
'use_managed_documents': True,
|
||
'can_add_unique_tag_documents': can_add_unique_tag_documents,
|
||
'tags': tags_data,
|
||
'folders': folders,
|
||
})
|
||
|
||
|
||
def trafficlights_assets(request):
|
||
pass
|
||
|
||
|
||
def ensure_list(value):
|
||
if isinstance(value, list):
|
||
return value
|
||
if value is None:
|
||
return []
|
||
return [value]
|
||
|
||
|
||
MAX_ASSET_FEATURES = 5000 # limite imposée
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def trafficlights_assets_geojson(request):
|
||
if request.method == "POST":
|
||
try:
|
||
data = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||
locations_ids = data.get('locations_ids', [])
|
||
statuses = ensure_list(data.get('asset_status'))
|
||
location_statuses = ensure_list(data.get('status'))
|
||
voltages = ensure_list(data.get('voltage'))
|
||
connected = data.get('connected')
|
||
controller_maintenance_provider = data.get('controller_maintenance_provider')
|
||
begin_date = data.get('asset_begin_date') or data.get('begin_date')
|
||
categories = ensure_list(data.get('category'))
|
||
models = ensure_list(data.get('model'))
|
||
|
||
# Récupération et parsing de la géométrie
|
||
filter_geom_raw = data.get("filter_geom")
|
||
if filter_geom_raw:
|
||
try:
|
||
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
|
||
filter_geom.transform(3812)
|
||
except Exception as e:
|
||
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
|
||
else:
|
||
filter_geom = None
|
||
|
||
else:
|
||
location_id = request.GET.get('location')
|
||
locations_ids = [location_id] if location_id else []
|
||
statuses = request.GET.getlist('asset_status')
|
||
location_statuses = request.GET.getlist('status')
|
||
voltages = request.GET.getlist('voltage')
|
||
connected = request.GET.get('connected')
|
||
controller_maintenance_provider = request.GET.get('controller_maintenance_provider')
|
||
begin_date = request.GET.get('asset_begin_date') or request.GET.get('begin_date')
|
||
categories = request.GET.getlist('category', [])
|
||
models = request.GET.getlist('model', [])
|
||
filter_geom = None
|
||
|
||
|
||
_search_q = (data.get('q') or '').strip() if request.method == 'POST' else ''
|
||
|
||
if not request.GET.get('unified') and not locations_ids and not categories and not models and not filter_geom and not _search_q:
|
||
geojson_data = {
|
||
"type": "FeatureCollection",
|
||
"features": [],
|
||
}
|
||
return JsonResponse(geojson_data)
|
||
|
||
|
||
from assets.views.core import get_element_type_label
|
||
def build_geometry_and_coords(obj):
|
||
geometry = None
|
||
if hasattr(obj, 'geojson') and obj.geojson:
|
||
try:
|
||
geometry = json.loads(obj.geojson)
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
geometry = None
|
||
|
||
lon = getattr(obj, 'lon', None)
|
||
lat = getattr(obj, 'lat', None)
|
||
|
||
if lon is None or lat is None:
|
||
intersection = getattr(obj, 'intersection', None)
|
||
if intersection is not None:
|
||
if lon is None:
|
||
lon = getattr(intersection, 'lon', None)
|
||
if lat is None:
|
||
lat = getattr(intersection, 'lat', None)
|
||
|
||
if geometry is None and lon is not None and lat is not None:
|
||
geometry = {
|
||
"type": "Point",
|
||
"coordinates": [lon, lat],
|
||
}
|
||
|
||
return geometry, lon, lat
|
||
|
||
def obj_to_feature(obj, asset_type=None):
|
||
geometry, lon, lat = build_geometry_and_coords(obj)
|
||
properties = {
|
||
"code": obj.code,
|
||
"name": obj.get_name(),
|
||
"category": obj.category.get_name() if obj.category else None,
|
||
"model": obj.model.get_name() if obj.model else None,
|
||
"status": obj.status,
|
||
"validation_status": obj.validation_status,
|
||
"lon": lon,
|
||
"lat": lat,
|
||
"asset_type": asset_type,
|
||
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||
"element_type": get_element_type_label(asset_type or ''),
|
||
"thematic": "trafficlights",
|
||
"is_location": False,
|
||
}
|
||
if asset_type == 'trafficlightcable':
|
||
properties["color"] = get_cable_color(obj.code)
|
||
elif asset_type == 'trafficlightradar':
|
||
properties["has_support"] = bool(
|
||
getattr(obj, 'pole_id', None) or
|
||
getattr(obj, 'publiclighting_pole_id', None) or
|
||
getattr(obj, 'trafficlights_pole_id', None)
|
||
)
|
||
return {
|
||
"type": "Feature",
|
||
"id": obj.id,
|
||
"geometry": geometry,
|
||
"properties": properties,
|
||
}
|
||
|
||
def add_assets_to_features(queryset, asset_type, features, feature_count):
|
||
for obj in queryset:
|
||
if feature_count >= MAX_ASSET_FEATURES:
|
||
return feature_count, True # early stop
|
||
features.append(obj_to_feature(obj, asset_type=asset_type))
|
||
feature_count += 1
|
||
return feature_count, False
|
||
|
||
features = []
|
||
feature_count = 0
|
||
|
||
|
||
|
||
controller_qs = TrafficLightController.objects.all()
|
||
if locations_ids:
|
||
controller_qs = controller_qs.filter(intersection__id__in=locations_ids)
|
||
if statuses:
|
||
controller_qs = controller_qs.filter(status__in=statuses)
|
||
if models:
|
||
controller_qs = controller_qs.filter(model__id__in=models)
|
||
if categories:
|
||
controller_qs = controller_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
controller_qs = controller_qs.filter(
|
||
Q(geom__intersects=filter_geom) |
|
||
Q(geom__isnull=True, intersection__geom__intersects=filter_geom)
|
||
)
|
||
|
||
pole_qs = TrafficLightPole.objects.all()
|
||
if locations_ids:
|
||
pole_qs = pole_qs.filter(intersection__id__in=locations_ids)
|
||
if statuses:
|
||
pole_qs = pole_qs.filter(status__in=statuses)
|
||
if models:
|
||
pole_qs = pole_qs.filter(model__id__in=models)
|
||
if categories:
|
||
pole_qs = pole_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
pole_qs = pole_qs.filter(
|
||
Q(geom__intersects=filter_geom) |
|
||
Q(geom__isnull=True, intersection__geom__intersects=filter_geom)
|
||
)
|
||
|
||
electrical_cabinet_qs = TrafficLightElectricalCabinet.objects.all()
|
||
if locations_ids:
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(intersection__id__in=locations_ids)
|
||
if statuses:
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(status__in=statuses)
|
||
if models:
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(model__id__in=models)
|
||
if categories:
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(
|
||
Q(geom__intersects=filter_geom) |
|
||
Q(geom__isnull=True, intersection__geom__intersects=filter_geom)
|
||
)
|
||
|
||
detector_qs = TrafficLightDetector.objects.all()
|
||
if locations_ids:
|
||
detector_qs = detector_qs.filter(intersection__id__in=locations_ids)
|
||
if statuses:
|
||
detector_qs = detector_qs.filter(status__in=statuses)
|
||
if models:
|
||
detector_qs = detector_qs.filter(model__id__in=models)
|
||
if categories:
|
||
detector_qs = detector_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
detector_qs = detector_qs.filter(
|
||
Q(geom__intersects=filter_geom) |
|
||
Q(geom__isnull=True, intersection__geom__intersects=filter_geom)
|
||
)
|
||
|
||
radar_qs = TrafficLightRadar.objects.all()
|
||
if locations_ids:
|
||
radar_qs = radar_qs.filter(intersection__id__in=locations_ids)
|
||
if statuses:
|
||
radar_qs = radar_qs.filter(status__in=statuses)
|
||
if models:
|
||
radar_qs = radar_qs.filter(model__id__in=models)
|
||
if categories:
|
||
radar_qs = radar_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
radar_qs = radar_qs.filter(
|
||
Q(geom__intersects=filter_geom) |
|
||
Q(geom__isnull=True, intersection__geom__intersects=filter_geom)
|
||
)
|
||
|
||
radar_pole_qs = TrafficLightRadarPole.objects.all()
|
||
if locations_ids:
|
||
radar_pole_qs = radar_pole_qs.filter(intersection__id__in=locations_ids)
|
||
if statuses:
|
||
radar_pole_qs = radar_pole_qs.filter(status__in=statuses)
|
||
if models:
|
||
radar_pole_qs = radar_pole_qs.filter(model__id__in=models)
|
||
if categories:
|
||
radar_pole_qs = radar_pole_qs.filter(category__id__in=categories)
|
||
if filter_geom:
|
||
radar_pole_qs = radar_pole_qs.filter(
|
||
Q(geom__intersects=filter_geom) |
|
||
Q(geom__isnull=True, intersection__geom__intersects=filter_geom)
|
||
)
|
||
|
||
# Applique les filtres de carrefours à toutes les requêtes assets.
|
||
if location_statuses or voltages or connected or controller_maintenance_provider:
|
||
intersections_qs = TrafficLightIntersection.objects.all()
|
||
|
||
if location_statuses:
|
||
intersections_qs = intersections_qs.filter(status__in=location_statuses)
|
||
if voltages:
|
||
intersections_qs = intersections_qs.filter(voltage_type__in=voltages)
|
||
|
||
if connected == 'yes':
|
||
connected_value = True
|
||
elif connected == 'no':
|
||
connected_value = False
|
||
else:
|
||
connected_value = None
|
||
|
||
if connected:
|
||
connected_subquery = TrafficLightController.objects.filter(
|
||
intersection=OuterRef('pk'),
|
||
status='active',
|
||
).order_by('-installation_date').values('connected_to_control_center')[:1]
|
||
intersections_qs = intersections_qs.annotate(
|
||
is_connected_to_control_center=Subquery(connected_subquery, output_field=BooleanField())
|
||
).filter(is_connected_to_control_center=connected_value)
|
||
|
||
if controller_maintenance_provider:
|
||
intersections_qs = intersections_qs.filter(
|
||
trafficlight_contracts__status='active',
|
||
trafficlight_contracts__maintenance_type='controller',
|
||
trafficlight_contracts__contract_id=controller_maintenance_provider
|
||
)
|
||
|
||
filtered_intersections = intersections_qs.values('id').distinct()
|
||
|
||
controller_qs = controller_qs.filter(intersection_id__in=Subquery(filtered_intersections))
|
||
pole_qs = pole_qs.filter(intersection_id__in=Subquery(filtered_intersections))
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(intersection_id__in=Subquery(filtered_intersections))
|
||
detector_qs = detector_qs.filter(intersection_id__in=Subquery(filtered_intersections))
|
||
radar_qs = radar_qs.filter(intersection_id__in=Subquery(filtered_intersections))
|
||
radar_pole_qs = radar_pole_qs.filter(intersection_id__in=Subquery(filtered_intersections))
|
||
|
||
if begin_date:
|
||
try:
|
||
begin_datetime = datetime.strptime(begin_date, '%Y-%m-%d')
|
||
begin_datetime = make_aware(begin_datetime, get_current_timezone())
|
||
except (TypeError, ValueError):
|
||
return JsonResponse({"error": _("Date de mise en service invalide.")}, status=400)
|
||
|
||
controller_qs = controller_qs.filter(installation_date__gte=begin_datetime)
|
||
pole_qs = pole_qs.filter(installation_date__gte=begin_datetime)
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(installation_date__gte=begin_datetime)
|
||
detector_qs = detector_qs.filter(installation_date__gte=begin_datetime)
|
||
radar_qs = radar_qs.filter(installation_date__gte=begin_datetime)
|
||
radar_pole_qs = radar_pole_qs.filter(installation_date__gte=begin_datetime)
|
||
|
||
|
||
# --------- Limitation pour utilisateurs externes
|
||
try:
|
||
user_config = request.user.config
|
||
except UserConfig.DoesNotExist:
|
||
user_config = None
|
||
|
||
is_external = (user_config is None) or (not user_config.is_intern)
|
||
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
|
||
|
||
if must_limit:
|
||
accessible_contract_ids = UserContractAccess.objects.filter(
|
||
user_config=user_config,
|
||
can_view_assets=True
|
||
).values_list('contract_id', flat=True)
|
||
|
||
# Ne garder que les assets dont l'intersection (via la location) a un contrat actif
|
||
# auquel l'utilisateur a accès
|
||
pole_qs = pole_qs.filter(
|
||
intersection__trafficlight_contracts__status='active',
|
||
intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
controller_qs = controller_qs.filter(
|
||
intersection__trafficlight_contracts__status='active',
|
||
intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
electrical_cabinet_qs = electrical_cabinet_qs.filter(
|
||
intersection__trafficlight_contracts__status='active',
|
||
intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
detector_qs = detector_qs.filter(
|
||
pole__intersection__trafficlight_contracts__status='active',
|
||
pole__intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
radar_qs = radar_qs.filter(
|
||
intersection__trafficlight_contracts__status='active',
|
||
intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
radar_pole_qs = radar_pole_qs.filter(
|
||
intersection__trafficlight_contracts__status='active',
|
||
intersection__trafficlight_contracts__contract_id__in=accessible_contract_ids
|
||
).distinct()
|
||
|
||
|
||
if _search_q and request.method == 'POST':
|
||
from assets.views.core import apply_search
|
||
controller_qs = apply_search(controller_qs, data, fields=['code'])
|
||
pole_qs = apply_search(pole_qs, data, fields=['code'])
|
||
electrical_cabinet_qs = apply_search(electrical_cabinet_qs, data, fields=['code'])
|
||
detector_qs = apply_search(detector_qs, data, fields=['code'])
|
||
radar_qs = apply_search(radar_qs, data, fields=['code'])
|
||
radar_pole_qs = apply_search(radar_pole_qs, data, fields=['code'])
|
||
|
||
controller_qs = controller_qs.select_related('intersection', 'category', 'model')
|
||
pole_qs = pole_qs.select_related('intersection', 'category', 'model')
|
||
electrical_cabinet_qs = electrical_cabinet_qs.select_related('intersection', 'category', 'model')
|
||
detector_qs = detector_qs.select_related('intersection', 'category', 'model')
|
||
radar_qs = radar_qs.select_related('intersection', 'category', 'model')
|
||
radar_pole_qs = radar_pole_qs.select_related('intersection', 'category', 'model')
|
||
|
||
pole_qs = pole_qs[0:MAX_ASSET_FEATURES+1]
|
||
controller_qs = controller_qs[0:MAX_ASSET_FEATURES+1]
|
||
electrical_cabinet_qs = electrical_cabinet_qs[0:MAX_ASSET_FEATURES+1]
|
||
detector_qs = detector_qs[0:MAX_ASSET_FEATURES+1]
|
||
radar_qs = radar_qs[0:MAX_ASSET_FEATURES+1]
|
||
radar_pole_qs = radar_pole_qs[0:MAX_ASSET_FEATURES+1]
|
||
|
||
if len(controller_qs) >= MAX_ASSET_FEATURES or len(pole_qs) >= MAX_ASSET_FEATURES or len(electrical_cabinet_qs) >= MAX_ASSET_FEATURES or len(radar_qs) >= MAX_ASSET_FEATURES:
|
||
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
|
||
|
||
|
||
for qs, asset_type in [
|
||
(controller_qs, "trafficlightcontroller"),
|
||
(pole_qs, "trafficlightpole"),
|
||
(electrical_cabinet_qs, "trafficlightelectricalcabinet"),
|
||
(detector_qs, "trafficlightdetector"),
|
||
(radar_qs, "trafficlightradar"),
|
||
(radar_pole_qs, "trafficlightradarpole"),
|
||
]:
|
||
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
|
||
if limit_reached:
|
||
return JsonResponse({
|
||
"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES}). {_("Limitez votre sélection.")}"
|
||
}, status=413)
|
||
|
||
features = sorted(features, key=lambda f: f["properties"]["code"])
|
||
|
||
geojson_data = {
|
||
"type": "FeatureCollection",
|
||
"features": features,
|
||
}
|
||
|
||
return JsonResponse(geojson_data, safe=False)
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def trafficlights_assets_detail(request, asset_model, asset_id):
|
||
try:
|
||
# Récupérer le ContentType pour le modèle fourni (ex: 'trafficlightpole')
|
||
content_type = ContentType.objects.get(model=asset_model)
|
||
model_class = content_type.model_class()
|
||
except ContentType.DoesNotExist:
|
||
raise Http404(_("Modèle %(asset_model)s inconnu.") % (asset_model))
|
||
|
||
# Récupérer l’objet asset
|
||
asset = get_object_or_404(model_class, id=asset_id)
|
||
|
||
# Choisir dynamiquement le template à utiliser
|
||
template_name = f"trafficlights/assets/detail_{asset_model}.html"
|
||
|
||
# Si le template spécifique n’existe pas, tu peux fallback vers un template générique :
|
||
from django.template.loader import select_template
|
||
try:
|
||
template = select_template([template_name, "assets/detail_generic.html"])
|
||
except:
|
||
raise Http404(_("Aucun template trouvé pour ce type d’asset."))
|
||
|
||
# Get edit context
|
||
from assets.views.core import get_asset_edit_context
|
||
context = {
|
||
"asset": asset,
|
||
"content_type": content_type,
|
||
}
|
||
context.update(get_asset_edit_context(request, asset, content_type))
|
||
|
||
# Override thematic_code for trafficlights
|
||
context['thematic_code'] = 'trafficlights'
|
||
|
||
# Build GeoJSON of sibling assets at the same intersection (excluding current asset)
|
||
intersection_assets_geojson = None
|
||
asset_intersection = getattr(asset, 'intersection', None)
|
||
if asset_intersection:
|
||
assets_features = []
|
||
active_assets = asset_intersection.get_active_assets()
|
||
for type_key, asset_list in active_assets.items():
|
||
for a in asset_list:
|
||
if a.id == asset.id and a.__class__ == asset.__class__:
|
||
continue # the current asset is already shown highlighted
|
||
if hasattr(a, 'geom') and a.geom:
|
||
try:
|
||
geom_wgs84 = a.geom.clone()
|
||
geom_wgs84.transform(4326)
|
||
properties = {
|
||
"code": a.code,
|
||
"asset_type": a._meta.model_name,
|
||
"detail_url": a.get_absolute_url() if hasattr(a, 'get_absolute_url') else None,
|
||
}
|
||
if a._meta.model_name == 'trafficlightcable':
|
||
properties["color"] = get_cable_color(a.code)
|
||
|
||
assets_features.append({
|
||
"type": "Feature",
|
||
"id": a.id,
|
||
"geometry": json.loads(geom_wgs84.json),
|
||
"properties": properties,
|
||
})
|
||
except Exception:
|
||
pass
|
||
intersection_assets_geojson = {
|
||
"type": "FeatureCollection",
|
||
"features": assets_features,
|
||
}
|
||
context['asset_intersection_url'] = asset_intersection.get_absolute_url()
|
||
context['intersection_assets_geojson'] = intersection_assets_geojson
|
||
|
||
return render(request, template.template.name, context)
|
||
|
||
|
||
def trafficlights_controllers(request):
|
||
pass
|
||
|
||
def trafficlights_controllers_detail(request, asset_id):
|
||
pass
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def get_intersection_assets(request, intersection_id):
|
||
|
||
try:
|
||
# Récupérer l'intersection
|
||
intersection = TrafficLightIntersection.objects.get(pk=intersection_id)
|
||
|
||
# Récupérer les assets associés via la méthode du modèle
|
||
assets = intersection.get_active_assets()
|
||
|
||
# Construire la réponse JSON
|
||
data = {
|
||
"intersection": {
|
||
"id": intersection.id,
|
||
"name": getattr(intersection, 'name', 'Intersection ...'),
|
||
"geom": intersection.geom.json if intersection.geom else None
|
||
},
|
||
"assets": assets
|
||
}
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
except TrafficLightIntersection.DoesNotExist:
|
||
return JsonResponse({"error": _("Intersection introuvable")}, status=404)
|
||
|
||
|
||
@check_thematic_access('trafficlights')
|
||
def trafficlights_intersections_get_by_code(request, intersection_code):
|
||
try:
|
||
# Récupérer l'intersection par son code
|
||
intersection = TrafficLightIntersection.objects.get(code__iexact=intersection_code)
|
||
|
||
# Récupérer les assets associés via la méthode du modèle
|
||
# assets = intersection.get_active_assets()
|
||
|
||
# Construire la réponse JSON
|
||
data = {
|
||
"intersection": {
|
||
"id": intersection.id,
|
||
"code": intersection.code,
|
||
"name": getattr(intersection, 'name', 'Intersection ...'),
|
||
"geom": intersection.geom.json if intersection.geom else None,
|
||
"lon": intersection.lon,
|
||
"lat": intersection.lat
|
||
}
|
||
# "assets": assets
|
||
}
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
except TrafficLightIntersection.DoesNotExist:
|
||
return JsonResponse({"error": _("Intersection introuvable")}, status=404)
|
||
|
||
|
||
@require_GET
|
||
def get_nearest_trafficlight_intersection(request):
|
||
try:
|
||
lon = float(request.GET.get('lon'))
|
||
lat = float(request.GET.get('lat'))
|
||
except (TypeError, ValueError):
|
||
return JsonResponse({"error": _("Coordonnées invalides")}, status=400)
|
||
|
||
user_point = Point(lon, lat, srid=4326)
|
||
user_point.transform(3812)
|
||
|
||
nearest = (
|
||
TrafficLightIntersection.objects
|
||
.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', user_point))
|
||
.filter(distance__lte=50) # filtre à 50 mètres
|
||
.order_by('distance')
|
||
.first()
|
||
)
|
||
|
||
location_ct_id = ContentType.objects.get_for_model(nearest).id
|
||
|
||
if nearest:
|
||
return JsonResponse({
|
||
"location_id": nearest.id,
|
||
"location_ct": location_ct_id,
|
||
"location_label": f"{nearest.code} - {nearest.get_name()}",
|
||
"lon": nearest.lon, "lat": nearest.lat
|
||
})
|
||
else:
|
||
return JsonResponse({"error": _("Aucune intersection trouvée")})
|
||
|
||
|
||
|
||
# Appelle la fonction PostgreSQL unaccent(...)
|
||
class Unaccent(Func):
|
||
function = 'public.unaccent_immutable'
|
||
output_field = CharField()
|
||
|
||
def strip_accents_lower(s: str) -> str:
|
||
if not s:
|
||
return ''
|
||
s = ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
|
||
return s.lower()
|
||
|
||
|
||
@require_GET
|
||
def trafficlights_locations_autocomplete(request):
|
||
raw_query = (request.GET.get('q') or '').strip()
|
||
if len(raw_query) < 2:
|
||
return JsonResponse([], safe=False)
|
||
|
||
# Normalise la requête (équivalent de unaccent+lower côté Python)
|
||
qn = strip_accents_lower(raw_query)
|
||
|
||
# Prépare des colonnes "recherchables" unaccentées + minuscules
|
||
qs = (
|
||
TrafficLightIntersection.objects
|
||
.annotate(
|
||
code_u=Lower(Unaccent('code')),
|
||
name_fr_u=Lower(Unaccent('name_fr')),
|
||
name_nl_u=Lower(Unaccent('name_nl')),
|
||
)
|
||
.filter(
|
||
Q(code_u__contains=qn) | # __contains car on a déjà des champs en lower
|
||
Q(name_fr_u__contains=qn) |
|
||
Q(name_nl_u__contains=qn)
|
||
)
|
||
)
|
||
|
||
# Priorité : exact > startswith > nom startswith > contient
|
||
priority = Case(
|
||
When(code_u=qn, then=0),
|
||
When(code_u__startswith=qn, then=1),
|
||
When(name_fr_u__startswith=qn, then=2),
|
||
When(name_nl_u__startswith=qn, then=3),
|
||
When(code_u__contains=qn, then=4),
|
||
When(name_fr_u__contains=qn, then=5),
|
||
When(name_nl_u__contains=qn, then=6),
|
||
default=7,
|
||
output_field=IntegerField(),
|
||
)
|
||
|
||
qs = qs.annotate(_prio=priority).order_by('_prio', 'code')[:50]
|
||
|
||
data = [{
|
||
"label": f"{loc.code} - {loc.get_name()}",
|
||
"location_id": loc.id,
|
||
"location_label": f"{loc.code} - {loc.get_name()}",
|
||
"lon": loc.lon,
|
||
"lat": loc.lat
|
||
} for loc in qs]
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
|
||
# --- helpers spécifiques trafficlights ---
|
||
def _equipment_type_from_model(asset):
|
||
m = getattr(asset, 'model', None)
|
||
return (getattr(m, 'name', None) or getattr(m, 'code', None)) if m else None
|
||
|
||
def _intersection_of(asset):
|
||
"""
|
||
Renvoie l'intersection associée à un asset, directement ou via le pole (lantern/detector).
|
||
- Pole/Controller/Cable: asset.intersection
|
||
- Lantern/Detector: asset.pole.intersection si pole présent
|
||
"""
|
||
# Lantern or Detector via pole
|
||
if hasattr(asset, 'pole') and getattr(asset, 'pole', None):
|
||
return getattr(asset.pole, 'intersection', None)
|
||
# Others have direct FK 'intersection'
|
||
return getattr(asset, 'intersection', None)
|
||
|
||
def _coords_of(asset):
|
||
"""
|
||
Détermine lon/lat:
|
||
1) asset.lon/lat (si présents)
|
||
2) si PointField: coords du point (transformé en 4326 si nécessaire)
|
||
si LineString: centroid en 4326
|
||
3) via pole.geom (lantern/detector)
|
||
4) via intersection.geom (point)
|
||
"""
|
||
# 1) lon/lat directs
|
||
lon = getattr(asset, 'lon', None)
|
||
lat = getattr(asset, 'lat', None)
|
||
if lon is not None and lat is not None:
|
||
return lon, lat
|
||
|
||
# 2) géométrie sur l'asset (poles point, cables line)
|
||
geom = getattr(asset, 'geom', None)
|
||
if geom:
|
||
try:
|
||
g = geom
|
||
if getattr(g, 'srid', None) and g.srid != 4326:
|
||
g = geom.transform(4326, clone=True)
|
||
# point
|
||
if hasattr(g, 'coords'):
|
||
c = g.coords
|
||
if isinstance(c, (list, tuple)) and len(c) >= 2:
|
||
return c[0], c[1]
|
||
# line/poly: centroid
|
||
centroid = getattr(g, 'centroid', None)
|
||
if centroid:
|
||
if getattr(centroid, 'srid', None) and centroid.srid != 4326:
|
||
centroid = centroid.transform(4326, clone=True)
|
||
cc = getattr(centroid, 'coords', None)
|
||
if cc and len(cc) >= 2:
|
||
return cc[0], cc[1]
|
||
except Exception:
|
||
pass
|
||
|
||
# 3) pole.geom (lantern/detector)
|
||
pole = getattr(asset, 'pole', None)
|
||
if pole and getattr(pole, 'geom', None):
|
||
try:
|
||
g = pole.geom
|
||
if getattr(g, 'srid', None) and g.srid != 4326:
|
||
g = g.transform(4326, clone=True)
|
||
if hasattr(g, 'coords'):
|
||
c = g.coords
|
||
if c and len(c) >= 2:
|
||
return c[0], c[1]
|
||
except Exception:
|
||
pass
|
||
|
||
# 4) intersection.geom
|
||
inter = _intersection_of(asset)
|
||
if inter and getattr(inter, 'geom', None):
|
||
try:
|
||
g = inter.geom
|
||
if getattr(g, 'srid', None) and g.srid != 4326:
|
||
g = g.transform(4326, clone=True)
|
||
if hasattr(g, 'coords'):
|
||
c = g.coords
|
||
if c and len(c) >= 2:
|
||
return c[0], c[1]
|
||
except Exception:
|
||
pass
|
||
|
||
return None, None
|
||
|
||
@require_GET
|
||
def trafficlights_assets_autocomplete(request):
|
||
raw_query_original = (request.GET.get('q') or '')
|
||
raw_query = raw_query_original.strip()
|
||
|
||
qn = strip_accents_lower(raw_query)
|
||
has_search_term = len(qn) > 0
|
||
|
||
location_id = request.GET.get('location_id') # = id de l'intersection
|
||
category_id = request.GET.get('asset_category')
|
||
try:
|
||
category_id = int(category_id)
|
||
except (TypeError, ValueError):
|
||
category_id = None
|
||
|
||
# Prépare filtre "dans cette intersection uniquement" si location_id fourni
|
||
inter_filter = {}
|
||
pole_inter_filter = {}
|
||
if location_id:
|
||
inter_filter = {'intersection_id': location_id}
|
||
pole_inter_filter = {'pole__intersection_id': location_id}
|
||
|
||
|
||
def base_qs(model, extra_filter=None, via_pole=False):
|
||
ann = {
|
||
'code_u': Lower(Unaccent('code')),
|
||
'name_fr_u': Lower(Unaccent('name_fr')),
|
||
'name_nl_u': Lower(Unaccent('name_nl')),
|
||
}
|
||
q = model.objects.annotate(**ann)
|
||
|
||
# Si un terme de recherche est fourni, filtrer dessus
|
||
if has_search_term:
|
||
q = q.filter(
|
||
Q(code_u__contains=qn) |
|
||
Q(name_fr_u__contains=qn) |
|
||
Q(name_nl_u__contains=qn)
|
||
)
|
||
|
||
# Filtrer uniquement les non supprimés
|
||
q = q.exclude(status='removed')
|
||
|
||
if extra_filter:
|
||
q = q.filter(**extra_filter)
|
||
|
||
if category_id is not None:
|
||
q = q.filter(
|
||
Q(category_id=category_id) |
|
||
Q(category__isnull=True, model__category_id=category_id)
|
||
)
|
||
|
||
# select_related utile selon le modèle
|
||
if via_pole:
|
||
q = q.select_related('pole', 'pole__intersection', 'model')
|
||
else:
|
||
q = q.select_related('intersection', 'model')
|
||
|
||
return q
|
||
|
||
# Récupère QS pour chaque type (limités grossièrement pour éviter de charger trop, puis triés/merge côté Python)
|
||
poles_qs = base_qs(TrafficLightPole, extra_filter=inter_filter)[:100]
|
||
cables_qs = base_qs(TrafficLightCable, extra_filter=inter_filter)[:100]
|
||
controllers_qs = base_qs(TrafficLightController, extra_filter=inter_filter)[:100]
|
||
detectors_qs = base_qs(TrafficLightDetector, extra_filter=inter_filter)[:100]
|
||
lanterns_qs = base_qs(TrafficLightLantern, extra_filter=pole_inter_filter, via_pole=True)[:100]
|
||
radars_qs = base_qs(TrafficLightRadar, extra_filter=inter_filter)[:100]
|
||
|
||
# Concatène en mémoire (on priorise ensuite)
|
||
items = []
|
||
for a in list(poles_qs) + list(cables_qs) + list(controllers_qs) + list(detectors_qs) + list(lanterns_qs) + list(radars_qs):
|
||
# priorisation locale (recalcule ici car on merge des modèles différents)
|
||
code_u = getattr(a, 'code_u', None)
|
||
name_fr_u = getattr(a, 'name_fr_u', None)
|
||
name_nl_u = getattr(a, 'name_nl_u', None)
|
||
|
||
prio = 7
|
||
# Priorisation uniquement si un terme de recherche est fourni
|
||
if has_search_term:
|
||
if code_u == qn:
|
||
prio = 0
|
||
elif code_u and code_u.startswith(qn):
|
||
prio = 1
|
||
elif (name_fr_u and name_fr_u.startswith(qn)) or (name_nl_u and name_nl_u.startswith(qn)):
|
||
prio = 2
|
||
elif (code_u and qn in code_u) or (name_fr_u and qn in name_fr_u) or (name_nl_u and qn in name_nl_u):
|
||
prio = 4
|
||
else:
|
||
# Sans terme de recherche, on trie par code
|
||
prio = 5
|
||
|
||
inter = _intersection_of(a)
|
||
equipment_type = _equipment_type_from_model(a)
|
||
parts = [a.code, a.get_name()]
|
||
equipment_label = " - ".join(p for p in parts if p)
|
||
code = getattr(a, 'code', '') or ''
|
||
label = equipment_label
|
||
|
||
lon, lat = _coords_of(a)
|
||
|
||
location_id_val = inter.id if inter else None
|
||
location_label = (f"{inter.code} - {inter.get_name()}" if inter else None)
|
||
|
||
equipment_ct_id = ContentType.objects.get_for_model(a).id
|
||
location_ct_id = ContentType.objects.get_for_model(inter).id if inter else None
|
||
|
||
items.append({
|
||
"prio": prio,
|
||
"code": code or "",
|
||
"id": getattr(a, 'id', None),
|
||
"label": label,
|
||
|
||
# attendus par ton autocomplete équipements
|
||
"equipment_id": getattr(a, 'id', None),
|
||
"equipment_label": equipment_label,
|
||
"equipment_type": equipment_type,
|
||
"equipment_ct": equipment_ct_id,
|
||
|
||
"location_id": location_id_val,
|
||
"location_label": location_label,
|
||
"location_ct": location_ct_id,
|
||
|
||
"lon": lon,
|
||
"lat": lat,
|
||
})
|
||
|
||
# Tri global par priorité puis code, coupe à 100
|
||
items.sort(key=lambda x: (x["prio"], x["code"]))
|
||
data = items[:100]
|
||
|
||
return JsonResponse(data, safe=False)
|
||
|
||
|
||
# -------------------------------------------------------------
|
||
# DXF Import and Validation Views
|
||
# -------------------------------------------------------------
|
||
|
||
import os
|
||
import re
|
||
import geopandas as gpd
|
||
from shapely.geometry import Point as ShapelyPoint
|
||
from pyproj import Transformer
|
||
from django.db import transaction
|
||
from django.conf import settings
|
||
from django.contrib.gis.geos import Point as DjangoPoint
|
||
from django.utils import timezone
|
||
|
||
|
||
GPD_DF_CACHE = {}
|
||
|
||
def get_cached_gpd_df(file_path):
|
||
import hashlib
|
||
try:
|
||
with open(file_path, 'rb') as f:
|
||
header = f.read(8192)
|
||
cache_key = hashlib.md5(header).hexdigest()
|
||
except Exception:
|
||
cache_key = file_path
|
||
|
||
cached = GPD_DF_CACHE.get(cache_key)
|
||
if cached is not None:
|
||
return cached.copy()
|
||
|
||
df = gpd.read_file(file_path)
|
||
GPD_DF_CACHE[cache_key] = df
|
||
if len(GPD_DF_CACHE) > 5:
|
||
GPD_DF_CACHE.pop(next(iter(GPD_DF_CACHE)))
|
||
return df.copy()
|
||
|
||
|
||
def parse_dxf_poles(file_path):
|
||
df = get_cached_gpd_df(file_path)
|
||
|
||
# Find relevant layers containing trico, feux, or poteau
|
||
relevant_layers = [
|
||
l for l in df['Layer'].unique()
|
||
if any(x in l.lower() for x in ['trico', 'feux', 'poteau'])
|
||
]
|
||
|
||
trico_df = df[
|
||
df['Layer'].isin(relevant_layers) &
|
||
df.geom_type.isin(['GeometryCollection', 'MultiLineString', 'LineString', 'Point'])
|
||
].copy()
|
||
|
||
# Exclude text subclasses
|
||
if 'SubClasses' in trico_df.columns:
|
||
trico_df = trico_df[~trico_df['SubClasses'].astype(str).str.contains('Text|MText', case=False, na=False)]
|
||
|
||
# If a feature is a Point and has non-empty text, it is a text label, not a physical pole
|
||
trico_df = trico_df[~( (trico_df.geom_type == 'Point') & trico_df['Text'].notna() & (trico_df['Text'].str.strip() != "") )]
|
||
|
||
if trico_df.empty:
|
||
raise ValueError("Aucune géométrie de poteau trouvée dans les calques TRICO/Feux.")
|
||
|
||
# Extract centroids
|
||
centroids = [geom.centroid for geom in trico_df.geometry]
|
||
|
||
# Cluster centroids within 1.0m to avoid duplicate points representing the same pole
|
||
unique_centroids = []
|
||
for c in centroids:
|
||
found = False
|
||
for uc in unique_centroids:
|
||
if uc.distance(c) < 1.0:
|
||
found = True
|
||
break
|
||
if not found:
|
||
unique_centroids.append(c)
|
||
|
||
# All text features in DXF with non-empty text
|
||
text_df = df[
|
||
df['Text'].notna() &
|
||
(df['Text'].str.strip() != "") &
|
||
df['Layer'].str.contains('trico|feux', case=False)
|
||
].copy()
|
||
|
||
# CRS transformation: 31370 (Lambert 72) -> 3812 (Lambert 2008) -> 4326 (WGS84)
|
||
transformer_31370_to_3812 = Transformer.from_crs(31370, 3812, always_xy=True)
|
||
transformer_3812_to_4326 = Transformer.from_crs(3812, 4326, always_xy=True)
|
||
|
||
extracted_poles = []
|
||
for idx, c in enumerate(unique_centroids):
|
||
x_3812, y_3812 = transformer_31370_to_3812.transform(c.x, c.y)
|
||
lon, lat = transformer_3812_to_4326.transform(x_3812, y_3812)
|
||
|
||
# Find nearest text annotation within 5.0m
|
||
nearest_text = ""
|
||
if not text_df.empty:
|
||
dists = text_df.geometry.distance(c)
|
||
nearest_idx = dists.idxmin()
|
||
if dists[nearest_idx] < 5.0:
|
||
nearest_text = text_df.loc[nearest_idx, 'Text'].strip()
|
||
|
||
extracted_poles.append({
|
||
'id': idx + 1,
|
||
'x_31370': c.x,
|
||
'y_31370': c.y,
|
||
'x_3812': x_3812,
|
||
'y_3812': y_3812,
|
||
'lon': lon,
|
||
'lat': lat,
|
||
'label': nearest_text
|
||
})
|
||
return extracted_poles
|
||
|
||
def get_base_dxf_name(filename):
|
||
import re
|
||
basename = os.path.basename(filename)
|
||
base, ext = os.path.splitext(basename)
|
||
base_cleaned = re.sub(r'_(?:[a-zA-Z0-9]{7}|[0-9]+)$', '', base)
|
||
return f"{base_cleaned}{ext}".lower()
|
||
|
||
|
||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||
def import_dxf(request, intersection_id):
|
||
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
||
uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files')
|
||
read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
|
||
|
||
selected_file = request.GET.get('dxf_file') or request.POST.get('dxf_file')
|
||
logger.info("import_dxf: method=%s, selected_file=%s, ajax=%s, files=%s", request.method, selected_file, request.GET.get('ajax'), list(request.FILES.keys()))
|
||
|
||
# 1. GET: Render the file selection template
|
||
if request.method == 'GET':
|
||
dxf_files = []
|
||
code_lower = intersection.code.lower() if intersection.code else ""
|
||
|
||
# Read-only external files
|
||
if os.path.exists(read_only_dxf_dir):
|
||
for f in os.listdir(read_only_dxf_dir):
|
||
if f.lower().endswith('.dxf'):
|
||
if code_lower and code_lower in f.lower():
|
||
dxf_files.append(f)
|
||
|
||
# Managed documents
|
||
from documents.models import DocumentAttachment, ManagedDocument
|
||
ct = ContentType.objects.get_for_model(intersection)
|
||
attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id)
|
||
managed_dxf_docs = ManagedDocument.objects.filter(
|
||
id__in=attachments.values_list('document_id', flat=True),
|
||
is_archived=False
|
||
).select_related('current_version')
|
||
for doc in managed_dxf_docs:
|
||
if doc.current_version and doc.current_version.file:
|
||
filename = os.path.basename(doc.current_version.file.name)
|
||
if filename.lower().endswith('.dxf'):
|
||
dxf_files.append(filename)
|
||
|
||
# Database documents (legacy fallback)
|
||
dxf_docs = [doc for doc in intersection.documents.all() if doc.file.name.lower().endswith('.dxf')]
|
||
for doc in dxf_docs:
|
||
dxf_files.append(os.path.basename(doc.file.name))
|
||
|
||
dxf_files = sorted(list(set(dxf_files)))
|
||
|
||
return render(request, "assets/trafficlights_import_dxf.html", {
|
||
'intersection': intersection,
|
||
'dxf_files': dxf_files,
|
||
'selected_file': selected_file,
|
||
'step': 'select',
|
||
'thematic_code': 'trafficlights',
|
||
})
|
||
|
||
else: # POST request
|
||
if not selected_file:
|
||
uploaded_file = request.FILES.get('dxf_file_upload')
|
||
if uploaded_file:
|
||
import uuid
|
||
dxf_temp_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp')
|
||
os.makedirs(dxf_temp_dir, exist_ok=True)
|
||
|
||
# Make a unique temp filename: temp_<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) |