feat: implement exceptional transport structures management with models, admin, and user permissions

This commit is contained in:
kdeterme 2026-08-21 10:29:26 +02:00
parent a410cc855f
commit 55d021b7ef
14 changed files with 1367 additions and 18 deletions

View file

@ -321,7 +321,10 @@ admin.site.register(ArtworkFountainDocument, AssetAdmin)
admin.site.register(ArtworkMonumentDocument, AssetAdmin)
from .models import Structure, StructureLocation, StructureDocument, StructureAssetModel, StructureGeoAsset, StructureContract
from .models import (
Structure, StructureLocation, StructureDocument, StructureAssetModel, StructureGeoAsset, StructureContract,
StructureExceptionalTransport, StructureExceptionalTransportNotice
)
def make_folder_attachment_inline(thematic_code):
@ -359,12 +362,26 @@ StructureFolderAttachmentInline = make_folder_attachment_inline('structures')
ControlCenterFolderAttachmentInline = make_folder_attachment_inline('control_centers')
TrafficLightFolderAttachmentInline = make_folder_attachment_inline('traffic_lights')
class StructureExceptionalTransportInline(admin.StackedInline):
model = StructureExceptionalTransport
extra = 0
can_delete = False
verbose_name = "Transport exceptionnel (Convois lourds)"
verbose_name_plural = "Transport exceptionnel (Convois lourds)"
class StructureExceptionalTransportNoticeInline(admin.TabularInline):
model = StructureExceptionalTransportNotice
extra = 0
@admin.register(Structure)
class StructureAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
list_display = ("code", "name_fr", "structure_type")
list_filter = ("structure_type",)
inlines = [StructureFolderAttachmentInline]
inlines = [StructureFolderAttachmentInline, StructureExceptionalTransportInline, StructureExceptionalTransportNoticeInline]
@admin.register(StructureLocation)
class StructureLocationAdmin(AssetAdmin):
@ -373,9 +390,21 @@ class StructureLocationAdmin(AssetAdmin):
admin.site.register(StructureDocument, AssetAdmin)
admin.site.register(StructureAssetModel, AssetAdmin)
admin.site.register(StructureGeoAsset, AssetAdmin)
admin.site.register(StructureContract, AssetAdmin)
@admin.register(StructureExceptionalTransport)
class StructureExceptionalTransportAdmin(admin.ModelAdmin):
search_fields = ("structure__code", "structure__name_fr", "structure__name_nl", "bridge_class", "national_id", "fod_vnr")
list_display = ("structure", "passage_status", "max_authorized_weight_tonnes", "max_calculated_weight_tonnes", "max_speed_kmh", "updated_at")
list_filter = ("passage_status", "is_braking_forbidden", "is_isolated_crossing_only", "is_railway_metro_crossing")
@admin.register(StructureExceptionalTransportNotice)
class StructureExceptionalTransportNoticeAdmin(admin.ModelAdmin):
search_fields = ("structure__code", "mission_reference")
list_display = ("structure", "mission_reference", "notice_date", "result", "evaluated_total_weight_tonnes")
list_filter = ("result", "notice_date")
from .models import ControlCenter, ControlCenterLocation, ControlCenterDocument, ControlCenterAssetModel, ControlCenterGeoAsset, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument

View file

@ -388,4 +388,55 @@ class BuildingAssetForm(forms.ModelForm):
from assets.models import AssetCategory
t = Thematic.objects.filter(code='publicbuildings').first()
if t and 'category' in self.fields:
self.fields['category'].queryset = AssetCategory.objects.filter(thematic=t)
self.fields['category'].queryset = AssetCategory.objects.filter(thematic=t)
class StructureExceptionalTransportForm(forms.ModelForm):
"""Formulaire d'édition des paramètres de transport exceptionnel sur un ouvrage."""
class Meta:
from assets.models.structures import StructureExceptionalTransport
model = StructureExceptionalTransport
fields = [
'passage_status', 'fod_vnr', 'national_id', 'bridge_class', 'is_railway_metro_crossing', 'number_of_spans',
'max_authorized_weight_tonnes', 'min_guaranteed_capacity_tonnes', 'max_calculated_weight_tonnes',
'max_axle_load_tonnes', 'min_axle_spacing_m',
'clearance_height_left_m', 'clearance_height_center_m', 'clearance_height_right_m', 'usable_width_m',
'max_speed_kmh', 'is_braking_forbidden', 'is_isolated_crossing_only', 'safety_distance_m',
'traffic_interruption_required', 'lane_positioning', 'is_escort_required',
'prescriptions_fr', 'prescriptions_nl',
'last_study_date', 'last_study_ref',
]
widgets = {
'passage_status': forms.Select(attrs={'class': 'form-select'}),
'fod_vnr': forms.NumberInput(attrs={'class': 'form-control'}),
'national_id': forms.TextInput(attrs={'class': 'form-control'}),
'bridge_class': forms.TextInput(attrs={'class': 'form-control'}),
'is_railway_metro_crossing': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'number_of_spans': forms.NumberInput(attrs={'class': 'form-control'}),
'max_authorized_weight_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'min_guaranteed_capacity_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'max_calculated_weight_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'max_axle_load_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'min_axle_spacing_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'clearance_height_left_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'clearance_height_center_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'clearance_height_right_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'usable_width_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'max_speed_kmh': forms.NumberInput(attrs={'class': 'form-control'}),
'is_braking_forbidden': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_isolated_crossing_only': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'safety_distance_m': forms.NumberInput(attrs={'class': 'form-control'}),
'traffic_interruption_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'lane_positioning': forms.Select(attrs={'class': 'form-select'}),
'is_escort_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'prescriptions_fr': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'prescriptions_nl': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'last_study_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'last_study_ref': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput):
field.required = False

View file

@ -0,0 +1,75 @@
# Generated by Django 6.0.7 on 2026-08-21 08:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0023_add_trafficlight_programming_category'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StructureExceptionalTransport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('fod_vnr', models.IntegerField(blank=True, help_text='Volgnummer pont dans la base fédérale', null=True, verbose_name="Numéro d'ordre FOD/SPF (VNR)")),
('national_id', models.CharField(blank=True, max_length=50, null=True, verbose_name='Identifiant national (IDN)')),
('bridge_class', models.CharField(blank=True, help_text='Ex: T180152402024030, D18015..., T09015', max_length=100, null=True, verbose_name='Classe de charge du pont (BKL)')),
('is_railway_metro_crossing', models.BooleanField(default=False, help_text="Indique si l'ouvrage surplombe ou supporte des voies ferrées/métro/tram", verbose_name='Ouvrage ferroviaire / métro / tram (Spoorlijn)')),
('number_of_spans', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Nombre de travées (OVS)')),
('max_authorized_weight_tonnes', models.DecimalField(blank=True, decimal_places=2, help_text='Poids maximal standard sans étude particulière (ex: 44 t)', max_digits=6, null=True, verbose_name='Poids max. autorisé standard (MAXWA) [t]')),
('min_guaranteed_capacity_tonnes', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='Capacité minimale garantie (MINWR) [t]')),
('max_calculated_weight_tonnes', models.DecimalField(blank=True, decimal_places=2, help_text="Charge maximale absolue évaluée pour l'ouvrage (ex: 140t, 265t, 450t)", max_digits=6, null=True, verbose_name='Tonnage maximal admissible calculé (MX) [t]')),
('max_axle_load_tonnes', models.DecimalField(blank=True, decimal_places=2, help_text='Ex: 12.0, 12.8, 13.0 t/essieu', max_digits=5, null=True, verbose_name='Charge max. par essieu [t/essieu]')),
('min_axle_spacing_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='Entraxe minimal entre essieux [m]')),
('clearance_height_left_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='Hauteur libre gauche / entrée (H1) [m]')),
('clearance_height_center_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name="Hauteur libre dans l'axe (H2) [m]")),
('clearance_height_right_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='Hauteur libre droite / sortie (H3) [m]')),
('usable_width_m', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Largeur utile roulable (BRE) [m]')),
('passage_status', models.CharField(choices=[('authorized', 'Autorisé sans condition particulière'), ('conditional', 'Autorisé sous conditions / prescriptions'), ('prior_study_required', 'Étude / calcul préalable obligatoire'), ('forbidden', 'Strictement interdit'), ('unknown', 'Non évalué')], default='unknown', max_length=30, verbose_name="Statut d'admissibilité TE")),
('max_speed_kmh', models.PositiveIntegerField(blank=True, default=5, help_text='Généralement 5 km/h pour supprimer les effets dynamiques', null=True, verbose_name='Vitesse maximale de franchissement (km/h)')),
('is_braking_forbidden', models.BooleanField(default=False, help_text='Notamment sur les dalles de toit de métro', verbose_name="Interdiction formelle de freiner sur l'ouvrage")),
('is_isolated_crossing_only', models.BooleanField(default=True, help_text="Aucune autre charge mobile simultanée autorisée sur l'ouvrage", verbose_name="Convoi seul sur l'ouvrage")),
('safety_distance_m', models.PositiveIntegerField(blank=True, default=5, help_text='Distance minimale avec tout autre véhicule (souvent 5m)', null=True, verbose_name='Distance de sécurité / zone tampon (m)')),
('traffic_interruption_required', models.BooleanField(default=False, verbose_name='Interruption de circulation requise pendant le passage')),
('lane_positioning', models.CharField(choices=[('standard', 'Voie de circulation normale'), ('center_axis', "Dans l'axe de l'ouvrage / ligne médiane"), ('first_lane', 'Première bande de circulation'), ('tram_tracks', 'Sur les voies de tram'), ('specific_path', 'Trajectoire spécifique (voir prescriptions)')], default='standard', max_length=30, verbose_name='Positionnement sur la chaussée')),
('is_escort_required', models.BooleanField(default=False, verbose_name='Escorte policière ou accompagnement requis')),
('prescriptions_fr', models.TextField(blank=True, null=True, verbose_name='Prescriptions techniques (FR)')),
('prescriptions_nl', models.TextField(blank=True, null=True, verbose_name='Prescriptions techniques (NL)')),
('last_study_date', models.DateField(blank=True, null=True, verbose_name='Date de la dernière étude / note de calcul')),
('last_study_ref', models.CharField(blank=True, max_length=150, null=True, verbose_name='Référence de la dernière note de calcul / Mission')),
('updated_at', models.DateTimeField(auto_now=True)),
('structure', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='exceptional_transport_info', to='assets.structure', verbose_name='Ouvrage associé')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_structure_te_infos', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Transport exceptionnel (Ouvrage)',
'verbose_name_plural': 'Transports exceptionnels (Ouvrages)',
},
),
migrations.CreateModel(
name='StructureExceptionalTransportNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mission_reference', models.CharField(help_text='Ex: Mission 64, Avis DITP 468830', max_length=100, verbose_name='Référence mission / Avis DITP')),
('notice_date', models.DateField(blank=True, null=True, verbose_name="Date de l'avis")),
('result', models.CharField(choices=[('positive', 'Favorable / Positif'), ('conditional', 'Favorable sous conditions'), ('negative', 'Défavorable / Négatif')], default='positive', max_length=20, verbose_name='Résultat')),
('evaluated_total_weight_tonnes', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='Tonnage évalué [t]')),
('evaluated_axle_load_tonnes', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Charge par essieu évaluée [t/essieu]')),
('comments', models.TextField(blank=True, verbose_name="Conditions / Remarques de l'avis")),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('structure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exceptional_transport_notices', to='assets.structure', verbose_name='Ouvrage')),
],
options={
'verbose_name': "Avis / Mission d'étude TE",
'verbose_name_plural': "Avis / Missions d'études TE",
'ordering': ['-notice_date', '-created_at'],
},
),
]

View file

@ -4,7 +4,7 @@ from .trafficlights import TrafficLightIntersection, TrafficLightIntersectionDoc
from .publiclighting import PublicLightingStreet, PublicLightingStreetDocument, PublicLightingPoleModel, PublicLightingPole, PublicLightingLightFixtureModel, PublicLightingLightFixture, PublicLightingElectricalCabinetModel, PublicLightingElectricalCabinet
from .artworks import ArtworkMonument, ArtworkFountain, ArtworkFountainDocument, ArtworkMonumentDocument, ArtworkStreet
from .nature import NatureLocation, NatureAssetModel, NatureGreenSurface, NatureGreenSurfaceDocument, NatureTree, NatureShrub, NatureBench, GreenSurfacesComment, GreenSurfacesCommentPhoto, NatureRWIZ, NatureRWIAsset, NatureRWIAssetDocument
from .structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel, StructureDocument, StructureContract
from .structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel, StructureDocument, StructureContract, StructureExceptionalTransport, StructureExceptionalTransportNotice
from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument
from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain

View file

@ -1,11 +1,8 @@
# -----------------------------
# Engineering Structures Models
# -----------------------------
from django.conf import settings
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import get_language
from django.utils.translation import get_language, gettext_lazy as _
from django.core.exceptions import ValidationError
import os
@ -202,3 +199,218 @@ class StructureGeoAsset(AbstractGeoAsset):
return reverse('assets:structures_assets_detail', kwargs={'asset_model': 'structuregeoasset', 'asset_id':self.id})
TE_PASSAGE_STATUS_CHOICES = (
('authorized', _('Autorisé sans condition particulière')),
('conditional', _('Autorisé sous conditions / prescriptions')),
('prior_study_required', _('Étude / calcul préalable obligatoire')),
('forbidden', _('Strictement interdit')),
('unknown', _('Non évalué')),
)
TE_LANE_POSITIONING_CHOICES = (
('standard', _('Voie de circulation normale')),
('center_axis', _('Dans l\'axe de l\'ouvrage / ligne médiane')),
('first_lane', _('Première bande de circulation')),
('tram_tracks', _('Sur les voies de tram')),
('specific_path', _('Trajectoire spécifique (voir prescriptions)')),
)
class StructureExceptionalTransport(models.Model):
structure = models.OneToOneField(
Structure,
on_delete=models.CASCADE,
related_name='exceptional_transport_info',
verbose_name=_("Ouvrage associé")
)
# --- Références administratives & Classification SPF / Région ---
fod_vnr = models.IntegerField(
null=True, blank=True,
verbose_name=_("Numéro d'ordre FOD/SPF (VNR)"),
help_text=_("Volgnummer pont dans la base fédérale")
)
national_id = models.CharField(
max_length=50, null=True, blank=True,
verbose_name=_("Identifiant national (IDN)")
)
bridge_class = models.CharField(
max_length=100, null=True, blank=True,
verbose_name=_("Classe de charge du pont (BKL)"),
help_text=_("Ex: T180152402024030, D18015..., T09015")
)
is_railway_metro_crossing = models.BooleanField(
default=False,
verbose_name=_("Ouvrage ferroviaire / métro / tram (Spoorlijn)"),
help_text=_("Indique si l'ouvrage surplombe ou supporte des voies ferrées/métro/tram")
)
number_of_spans = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name=_("Nombre de travées (OVS)")
)
# --- Capacités portantes & Tonnages ---
max_authorized_weight_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Poids max. autorisé standard (MAXWA) [t]"),
help_text=_("Poids maximal standard sans étude particulière (ex: 44 t)")
)
min_guaranteed_capacity_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Capacité minimale garantie (MINWR) [t]")
)
max_calculated_weight_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Tonnage maximal admissible calculé (MX) [t]"),
help_text=_("Charge maximale absolue évaluée pour l'ouvrage (ex: 140t, 265t, 450t)")
)
max_axle_load_tonnes = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name=_("Charge max. par essieu [t/essieu]"),
help_text=_("Ex: 12.0, 12.8, 13.0 t/essieu")
)
min_axle_spacing_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Entraxe minimal entre essieux [m]")
)
# --- Gabarits géométriques & Hauteurs libres ---
clearance_height_left_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Hauteur libre gauche / entrée (H1) [m]")
)
clearance_height_center_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Hauteur libre dans l'axe (H2) [m]")
)
clearance_height_right_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Hauteur libre droite / sortie (H3) [m]")
)
usable_width_m = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name=_("Largeur utile roulable (BRE) [m]")
)
# --- Prescriptions de franchissement & Exploitation ---
passage_status = models.CharField(
max_length=30,
choices=TE_PASSAGE_STATUS_CHOICES,
default='unknown',
verbose_name=_("Statut d'admissibilité TE")
)
max_speed_kmh = models.PositiveIntegerField(
default=5, null=True, blank=True,
verbose_name=_("Vitesse maximale de franchissement (km/h)"),
help_text=_("Généralement 5 km/h pour supprimer les effets dynamiques")
)
is_braking_forbidden = models.BooleanField(
default=False,
verbose_name=_("Interdiction formelle de freiner sur l'ouvrage"),
help_text=_("Notamment sur les dalles de toit de métro")
)
is_isolated_crossing_only = models.BooleanField(
default=True,
verbose_name=_("Convoi seul sur l'ouvrage"),
help_text=_("Aucune autre charge mobile simultanée autorisée sur l'ouvrage")
)
safety_distance_m = models.PositiveIntegerField(
default=5, null=True, blank=True,
verbose_name=_("Distance de sécurité / zone tampon (m)"),
help_text=_("Distance minimale avec tout autre véhicule (souvent 5m)")
)
traffic_interruption_required = models.BooleanField(
default=False,
verbose_name=_("Interruption de circulation requise pendant le passage")
)
lane_positioning = models.CharField(
max_length=30,
choices=TE_LANE_POSITIONING_CHOICES,
default='standard',
verbose_name=_("Positionnement sur la chaussée")
)
is_escort_required = models.BooleanField(
default=False,
verbose_name=_("Escorte policière ou accompagnement requis")
)
# --- Prescriptions textuelles bilingues ---
prescriptions_fr = models.TextField(
null=True, blank=True,
verbose_name=_("Prescriptions techniques (FR)")
)
prescriptions_nl = models.TextField(
null=True, blank=True,
verbose_name=_("Prescriptions techniques (NL)")
)
# --- Suivi & Métadonnées ---
last_study_date = models.DateField(
null=True, blank=True,
verbose_name=_("Date de la dernière étude / note de calcul")
)
last_study_ref = models.CharField(
max_length=150, null=True, blank=True,
verbose_name=_("Référence de la dernière note de calcul / Mission")
)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='updated_structure_te_infos'
)
class Meta:
verbose_name = _("Transport exceptionnel (Ouvrage)")
verbose_name_plural = _("Transports exceptionnels (Ouvrages)")
def __str__(self):
return f"TE - {self.structure.code} ({self.get_passage_status_display()})"
class StructureExceptionalTransportNotice(models.Model):
RESULT_CHOICES = (
('positive', _('Favorable / Positif')),
('conditional', _('Favorable sous conditions')),
('negative', _('Défavorable / Négatif')),
)
structure = models.ForeignKey(
Structure,
on_delete=models.CASCADE,
related_name='exceptional_transport_notices',
verbose_name=_("Ouvrage")
)
mission_reference = models.CharField(
max_length=100,
verbose_name=_("Référence mission / Avis DITP"),
help_text=_("Ex: Mission 64, Avis DITP 468830")
)
notice_date = models.DateField(null=True, blank=True, verbose_name=_("Date de l'avis"))
result = models.CharField(max_length=20, choices=RESULT_CHOICES, default='positive', verbose_name=_("Résultat"))
evaluated_total_weight_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Tonnage évalué [t]")
)
evaluated_axle_load_tonnes = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name=_("Charge par essieu évaluée [t/essieu]")
)
comments = models.TextField(blank=True, verbose_name=_("Conditions / Remarques de l'avis"))
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
verbose_name = _("Avis / Mission d'étude TE")
verbose_name_plural = _("Avis / Missions d'études TE")
ordering = ['-notice_date', '-created_at']
def __str__(self):
return f"{self.structure.code} - {self.mission_reference} ({self.get_result_display()})"

View file

@ -742,3 +742,45 @@ def location_asset_edit_permission_required(thematic_code):
return wrapper
return decorator
def can_view_exceptional_transport(user):
"""
Vérifie si l'utilisateur a le droit de visualiser les données et documents de transport exceptionnel sur les ouvrages.
"""
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False):
return True
user_config = getattr(user, 'config', None)
if not user_config:
try:
user_config = UserConfig.objects.get(user=user)
except Exception:
return False
if user_config.has_role('admin') or user_config.has_role('top_manager'):
return True
return bool(getattr(user_config, 'has_exceptional_transport_profile', False))
def can_edit_exceptional_transport(user):
"""
Vérifie si l'utilisateur a le droit de modifier les données de transport exceptionnel sur les ouvrages.
"""
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False):
return True
user_config = getattr(user, 'config', None)
if not user_config:
try:
user_config = UserConfig.objects.get(user=user)
except Exception:
return False
if user_config.has_role('admin'):
return True
return bool(
getattr(user_config, 'has_exceptional_transport_profile', False)
and getattr(user_config, 'exceptional_transport_can_edit', False)
)

View file

@ -266,6 +266,301 @@
</div>
{% endif %}
<!-- Transport Exceptionnel & Convois Lourds -->
{% if can_view_exceptional_transport and location.structure %}
<div class="card mb-4 border-warning shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h4 class="mb-0 text-dark">
<i class="bi bi-truck text-warning me-2"></i>{% translate "Transport exceptionnel & Convois lourds" %}
</h4>
<div class="d-flex align-items-center">
{% if te_info %}
{% if te_info.passage_status == 'authorized' %}
<span class="badge bg-success fs-6"><i class="bi bi-check-circle me-1"></i>{% translate "Autorisé" %}</span>
{% elif te_info.passage_status == 'conditional' %}
<span class="badge bg-warning text-dark fs-6"><i class="bi bi-exclamation-triangle me-1"></i>{% translate "Sous conditions" %}</span>
{% elif te_info.passage_status == 'prior_study_required' %}
<span class="badge bg-danger fs-6"><i class="bi bi-file-earmark-medical me-1"></i>{% translate "Étude préalable requise" %}</span>
{% elif te_info.passage_status == 'forbidden' %}
<span class="badge bg-dark fs-6"><i class="bi bi-slash-circle me-1"></i>{% translate "Interdit" %}</span>
{% else %}
<span class="badge bg-secondary fs-6">{% translate "Non évalué" %}</span>
{% endif %}
{% else %}
<span class="badge bg-secondary fs-6">{% translate "Non évalué" %}</span>
{% endif %}
{% if can_edit_exceptional_transport %}
<button type="button" class="btn btn-sm btn-outline-primary ms-3" data-bs-toggle="modal" data-bs-target="#editExceptionalTransportModal" title="{% translate 'Modifier les paramètres TE' %}">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</button>
{% endif %}
</div>
</div>
<div class="card-body">
<ul class="nav nav-tabs mb-3" id="teTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active text-dark" id="te-specs-tab" data-bs-toggle="tab" data-bs-target="#te-specs" type="button" role="tab">
<i class="bi bi-speedometer2 me-1"></i>{% translate "Charges & Gabarits" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-dark" id="te-rules-tab" data-bs-toggle="tab" data-bs-target="#te-rules" type="button" role="tab">
<i class="bi bi-signpost-split me-1"></i>{% translate "Prescriptions de passage" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link text-dark" id="te-docs-tab" data-bs-toggle="tab" data-bs-target="#te-docs" type="button" role="tab">
<i class="bi bi-folder me-1"></i>{% translate "Documents & Études" %}
{% if te_documents %}
<span class="badge bg-primary rounded-pill ms-1">{{ te_documents|length }}</span>
{% endif %}
</button>
</li>
</ul>
<div class="tab-content" id="teTabContent">
<!-- Onglet 1: Charges & Gabarits -->
<div class="tab-pane fade show active" id="te-specs" role="tabpanel">
<div class="row g-3">
<div class="col-md-6">
<div class="p-3 bg-light rounded border h-100">
<h6 class="fw-bold text-primary mb-3"><i class="bi bi-box-seam me-1"></i>{% translate "Capacités de charge" %}</h6>
<table class="table table-sm table-borderless mb-0">
<tr>
<th class="text-muted" style="width: 55%;">{% translate "Poids max. standard (MAXWA)" %} :</th>
<td><strong>{% if te_info.max_authorized_weight_tonnes %}{{ te_info.max_authorized_weight_tonnes }} t{% else %}<span class="text-muted">-</span>{% endif %}</strong></td>
</tr>
<tr>
<th class="text-muted">{% translate "Tonnage max. calculé (MX)" %} :</th>
<td><span class="badge bg-info text-dark">{% if te_info.max_calculated_weight_tonnes %}{{ te_info.max_calculated_weight_tonnes }} t{% else %}-{% endif %}</span></td>
</tr>
<tr>
<th class="text-muted">{% translate "Capacité min. garantie (MINWR)" %} :</th>
<td>{% if te_info.min_guaranteed_capacity_tonnes %}{{ te_info.min_guaranteed_capacity_tonnes }} t{% else %}<span class="text-muted">-</span>{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Charge max. / essieu" %} :</th>
<td><strong>{% if te_info.max_axle_load_tonnes %}{{ te_info.max_axle_load_tonnes }} t/essieu{% else %}<span class="text-muted">-</span>{% endif %}</strong></td>
</tr>
<tr>
<th class="text-muted">{% translate "Entraxe minimal essieux" %} :</th>
<td>{% if te_info.min_axle_spacing_m %}{{ te_info.min_axle_spacing_m }} m{% else %}<span class="text-muted">-</span>{% endif %}</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-light rounded border h-100">
<h6 class="fw-bold text-primary mb-3"><i class="bi bi-rulers me-1"></i>{% translate "Gabarits & Classification" %}</h6>
<table class="table table-sm table-borderless mb-0">
<tr>
<th class="text-muted" style="width: 55%;">{% translate "Classe de pont (BKL)" %} :</th>
<td><code>{{ te_info.bridge_class|default:"-" }}</code></td>
</tr>
<tr>
<th class="text-muted">{% translate "N° FOD / ID National" %} :</th>
<td>{{ te_info.fod_vnr|default:"-" }} / {{ te_info.national_id|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Hauteurs libres (G / Axe / D)" %} :</th>
<td>
<strong>{{ te_info.clearance_height_left_m|default:"-" }} m</strong> /
<strong>{{ te_info.clearance_height_center_m|default:"-" }} m</strong> /
<strong>{{ te_info.clearance_height_right_m|default:"-" }} m</strong>
</td>
</tr>
<tr>
<th class="text-muted">{% translate "Largeur utile (BRE)" %} :</th>
<td>{% if te_info.usable_width_m %}{{ te_info.usable_width_m }} m{% else %}<span class="text-muted">-</span>{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Franchissement ferroviaire / métro" %} :</th>
<td>
{% if te_info.is_railway_metro_crossing %}
<span class="badge bg-warning text-dark"><i class="bi bi-train-front me-1"></i>{% translate "Oui" %}</span>
{% else %}
<span class="text-muted">{% translate "Non" %}</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Onglet 2: Prescriptions de passage -->
<div class="tab-pane fade" id="te-rules" role="tabpanel">
<div class="row g-3">
<div class="col-md-5">
<div class="p-3 bg-light rounded border h-100">
<h6 class="fw-bold text-primary mb-3"><i class="bi bi-shield-check me-1"></i>{% translate "Règles d'exploitation" %}</h6>
<ul class="list-group list-group-flush bg-transparent">
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2">
<span>{% translate "Vitesse maximale" %}</span>
<span class="badge bg-primary">{{ te_info.max_speed_kmh|default:5 }} km/h</span>
</li>
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2">
<span>{% translate "Freinage sur ouvrage" %}</span>
{% if te_info.is_braking_forbidden %}
<span class="badge bg-danger">{% translate "Strictement interdit" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Autorisé" %}</span>
{% endif %}
</li>
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2">
<span>{% translate "Passage isolé (convoi seul)" %}</span>
{% if te_info.is_isolated_crossing_only %}
<span class="badge bg-warning text-dark">{% translate "Convoi seul obligatoire" %}</span>
{% else %}
<span class="badge bg-light text-dark">{% translate "Non exigé" %}</span>
{% endif %}
</li>
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2">
<span>{% translate "Distance de sécurité" %}</span>
<span class="badge bg-info text-dark">{{ te_info.safety_distance_m|default:5 }} m</span>
</li>
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2">
<span>{% translate "Interruption circulation" %}</span>
{% if te_info.traffic_interruption_required %}
<span class="badge bg-danger">{% translate "Requise" %}</span>
{% else %}
<span class="badge bg-light text-dark">{% translate "Non" %}</span>
{% endif %}
</li>
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2">
<span>{% translate "Position sur chaussée" %}</span>
<span class="badge bg-secondary">{{ te_info.get_lane_positioning_display|default:"Standard" }}</span>
</li>
</ul>
</div>
</div>
<div class="col-md-7">
<div class="p-3 bg-light rounded border h-100">
<h6 class="fw-bold text-primary mb-3"><i class="bi bi-card-text me-1"></i>{% translate "Prescriptions détaillées" %}</h6>
{% if te_info.prescriptions_fr %}
<div class="mb-3">
<span class="badge bg-secondary mb-1">FR</span>
<p class="small text-dark mb-0" style="white-space: pre-line;">{{ te_info.prescriptions_fr }}</p>
</div>
{% endif %}
{% if te_info.prescriptions_nl %}
<div class="mb-3">
<span class="badge bg-secondary mb-1">NL</span>
<p class="small text-dark mb-0" style="white-space: pre-line;">{{ te_info.prescriptions_nl }}</p>
</div>
{% endif %}
{% if not te_info.prescriptions_fr and not te_info.prescriptions_nl %}
<p class="text-muted small">{% translate "Aucune prescription textuelle spécifique enregistrée." %}</p>
{% endif %}
{% if te_info.last_study_ref or te_info.last_study_date %}
<hr>
<div class="small text-muted">
<i class="bi bi-journal-check me-1"></i>
{% translate "Dernière note de calcul" %} :
<strong>{{ te_info.last_study_ref|default:"-" }}</strong>
{% if te_info.last_study_date %}({{ te_info.last_study_date|date:"d/m/Y" }}){% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Onglet 3: Documents & Études -->
<div class="tab-pane fade" id="te-docs" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold text-primary mb-0"><i class="bi bi-file-earmark-pdf me-1"></i>{% translate "Documents du répertoire Transport exceptionnel" %}</h6>
{% if te_folder %}
<a href="/documents/?folder={{ te_folder.slug }}" class="btn btn-sm btn-outline-secondary" target="_blank">
<i class="bi bi-folder2-open me-1"></i>{% translate "Ouvrir dans la GED" %}
</a>
{% endif %}
</div>
{% if te_documents %}
<div class="row row-cols-1 row-cols-md-3 g-3">
{% for doc in te_documents %}
<div class="col">
<div class="card h-100 shadow-sm border">
<div class="card-body p-2 d-flex flex-column">
<div class="d-flex align-items-start mb-2">
<i class="bi bi-file-earmark-text text-danger fs-3 me-2"></i>
<div class="flex-grow-1 text-truncate">
<a href="{% url 'documents:detail' doc.id %}" class="fw-bold text-dark text-decoration-none" title="{{ doc.title }}">
{{ doc.title }}
</a>
<div class="text-muted small">{{ doc.updated_at|date:"d/m/Y" }}</div>
</div>
</div>
{% if doc.current_version and doc.current_version.file %}
<div class="mt-auto pt-2 border-top d-flex justify-content-between align-items-center">
<span class="badge bg-light text-dark">v{{ doc.current_version.version_number }}</span>
<a href="{% private_file_url doc.current_version 'file' %}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-download"></i>
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-4 bg-light rounded border text-muted">
<i class="bi bi-folder2 text-secondary fs-2 d-block mb-2"></i>
<p class="mb-0">{% translate "Aucun document spécifique dans le répertoire Transport exceptionnel." %}</p>
</div>
{% endif %}
{% if te_notices %}
<hr class="my-4">
<h6 class="fw-bold text-primary mb-3"><i class="bi bi-list-check me-1"></i>{% translate "Historique des missions d'études et avis" %}</h6>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>{% translate "Mission / Réf" %}</th>
<th>{% translate "Date" %}</th>
<th>{% translate "Résultat" %}</th>
<th>{% translate "Tonnage évalué" %}</th>
<th>{% translate "Charge / essieu" %}</th>
<th>{% translate "Commentaires" %}</th>
</tr>
</thead>
<tbody>
{% for notice in te_notices %}
<tr>
<td><strong>{{ notice.mission_reference }}</strong></td>
<td>{{ notice.notice_date|date:"d/m/Y"|default:"-" }}</td>
<td>
{% if notice.result == 'positive' %}
<span class="badge bg-success">{% translate "Favorable" %}</span>
{% elif notice.result == 'conditional' %}
<span class="badge bg-warning text-dark">{% translate "Sous conditions" %}</span>
{% else %}
<span class="badge bg-danger">{% translate "Défavorable" %}</span>
{% endif %}
</td>
<td>{% if notice.evaluated_total_weight_tonnes %}{{ notice.evaluated_total_weight_tonnes }} t{% else %}-{% endif %}</td>
<td>{% if notice.evaluated_axle_load_tonnes %}{{ notice.evaluated_axle_load_tonnes }} t{% else %}-{% endif %}</td>
<td class="small">{{ notice.comments|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Assets -->
<div class="card mb-4">
@ -385,6 +680,191 @@
{% include "assets/components/add_asset_modal.html" with modal_id="addAssetLocationModal" thematic_code="structures" location=location categories=categories suggested_code=suggested_code %}
{% endif %}
<!-- Modal d'édition des paramètres Transport Exceptionnel -->
{% if can_edit_exceptional_transport and location.structure %}
<div class="modal fade" id="editExceptionalTransportModal" tabindex="-1" aria-labelledby="editExceptionalTransportModalLabel" aria-hidden="true" style="z-index: 10060;">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<form id="exceptionalTransportForm" method="post" action="{% url 'assets:structures_edit_exceptional_transport' location.structure.id %}">
{% csrf_token %}
<div class="modal-header bg-light">
<h5 class="modal-title" id="editExceptionalTransportModalLabel">
<i class="bi bi-truck text-warning me-2"></i>{% translate "Modifier les paramètres Transport Exceptionnel" %} - {{ location.structure.code }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<!-- Statut & Classification -->
<div class="col-md-12">
<div class="card p-3 bg-light border-0">
<h6 class="fw-bold text-primary mb-3">{% translate "Statut d'admissibilité & Références" %}</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-bold">{% translate "Statut d'admissibilité" %}</label>
{{ te_form.passage_status }}
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Classe de pont (BKL)" %}</label>
{{ te_form.bridge_class }}
</div>
<div class="col-md-4">
<label class="form-label">{% translate "N° d'ordre FOD/SPF (VNR)" %}</label>
{{ te_form.fod_vnr }}
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Identifiant national (IDN)" %}</label>
{{ te_form.national_id }}
</div>
<div class="col-md-4">
<label class="form-label">{% translate "Nombre de travées (OVS)" %}</label>
{{ te_form.number_of_spans }}
</div>
<div class="col-md-4 d-flex align-items-center mt-4">
<div class="form-check">
{{ te_form.is_railway_metro_crossing }}
<label class="form-check-label" for="{{ te_form.is_railway_metro_crossing.id_for_label }}">
{% translate "Ouvrage ferroviaire / métro / tram" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Capacités & Tonnages -->
<div class="col-md-6">
<div class="card p-3 h-100 border">
<h6 class="fw-bold text-primary mb-3">{% translate "Capacités de charge" %}</h6>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label small">{% translate "Poids max standard (MAXWA) [t]" %}</label>
{{ te_form.max_authorized_weight_tonnes }}
</div>
<div class="col-md-6">
<label class="form-label small">{% translate "Tonnage max calculé (MX) [t]" %}</label>
{{ te_form.max_calculated_weight_tonnes }}
</div>
<div class="col-md-6">
<label class="form-label small">{% translate "Capacité min garantie (MINWR) [t]" %}</label>
{{ te_form.min_guaranteed_capacity_tonnes }}
</div>
<div class="col-md-6">
<label class="form-label small">{% translate "Charge max / essieu [t]" %}</label>
{{ te_form.max_axle_load_tonnes }}
</div>
<div class="col-md-12">
<label class="form-label small">{% translate "Entraxe minimal essieux [m]" %}</label>
{{ te_form.min_axle_spacing_m }}
</div>
</div>
</div>
</div>
<!-- Gabarits géométriques -->
<div class="col-md-6">
<div class="card p-3 h-100 border">
<h6 class="fw-bold text-primary mb-3">{% translate "Gabarits géométriques" %}</h6>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small">{% translate "Hauteur gauche H1 [m]" %}</label>
{{ te_form.clearance_height_left_m }}
</div>
<div class="col-md-4">
<label class="form-label small">{% translate "Hauteur axe H2 [m]" %}</label>
{{ te_form.clearance_height_center_m }}
</div>
<div class="col-md-4">
<label class="form-label small">{% translate "Hauteur droite H3 [m]" %}</label>
{{ te_form.clearance_height_right_m }}
</div>
<div class="col-md-12">
<label class="form-label small">{% translate "Largeur utile roulable (BRE) [m]" %}</label>
{{ te_form.usable_width_m }}
</div>
</div>
</div>
</div>
<!-- Règles d'exploitation -->
<div class="col-md-12">
<div class="card p-3 border">
<h6 class="fw-bold text-primary mb-3">{% translate "Prescriptions d'exploitation" %}</h6>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small">{% translate "Vitesse max [km/h]" %}</label>
{{ te_form.max_speed_kmh }}
</div>
<div class="col-md-3">
<label class="form-label small">{% translate "Distance sécurité [m]" %}</label>
{{ te_form.safety_distance_m }}
</div>
<div class="col-md-6">
<label class="form-label small">{% translate "Positionnement sur chaussée" %}</label>
{{ te_form.lane_positioning }}
</div>
<div class="col-md-4">
<div class="form-check">
{{ te_form.is_braking_forbidden }}
<label class="form-check-label" for="{{ te_form.is_braking_forbidden.id_for_label }}">
{% translate "Interdiction de freiner" %}
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
{{ te_form.is_isolated_crossing_only }}
<label class="form-check-label" for="{{ te_form.is_isolated_crossing_only.id_for_label }}">
{% translate "Convoi seul (passage isolé)" %}
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
{{ te_form.traffic_interruption_required }}
<label class="form-check-label" for="{{ te_form.traffic_interruption_required.id_for_label }}">
{% translate "Interruption circulation" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Prescriptions textuelles bilingues -->
<div class="col-md-6">
<label class="form-label fw-bold">{% translate "Prescriptions techniques (FR)" %}</label>
{{ te_form.prescriptions_fr }}
</div>
<div class="col-md-6">
<label class="form-label fw-bold">{% translate "Prescriptions techniques (NL)" %}</label>
{{ te_form.prescriptions_nl }}
</div>
<!-- Dernière étude -->
<div class="col-md-6">
<label class="form-label small">{% translate "Référence dernière note de calcul / Mission" %}</label>
{{ te_form.last_study_ref }}
</div>
<div class="col-md-6">
<label class="form-label small">{% translate "Date dernière note de calcul" %}</label>
{{ te_form.last_study_date }}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="saveTeBtn">
{% translate "Enregistrer" %}
<span class="spinner-border spinner-border-sm ms-2 d-none" role="status" aria-hidden="true" id="saveTeSpinner"></span>
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock content%}
@ -398,6 +878,43 @@
const feature_geom = JSON.parse(document.getElementById("geom_value").textContent);
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
{% if can_edit_exceptional_transport %}
const teForm = document.getElementById('exceptionalTransportForm');
if (teForm) {
teForm.addEventListener('submit', function(e) {
e.preventDefault();
const submitBtn = document.getElementById('saveTeBtn');
const spinner = document.getElementById('saveTeSpinner');
if (submitBtn) submitBtn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
const formData = new FormData(teForm);
fetch(teForm.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.error || JSON.stringify(data.errors));
if (submitBtn) submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
}
})
.catch(err => {
alert("{% translate 'Une erreur est survenue lors de l\'enregistrement.' %}");
if (submitBtn) submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
});
});
}
{% endif %}
</script>
<!-- Turf.js (local) -->
<script src="{% static 'common/turf.min.js' %}"></script>
@ -406,3 +923,4 @@
<script src="{% static 'assets/asset_add_modal.js' %}"></script>
{% endif %}
{% endblock script %}

View file

@ -0,0 +1,256 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point
from django.urls import reverse
from common.models import Thematic, Role, UserConfig, UserThematics
from assets.models.structures import (
Structure, StructureLocation, StructureExceptionalTransport, StructureExceptionalTransportNotice
)
from assets.permissions import can_view_exceptional_transport, can_edit_exceptional_transport
from documents.models import DocumentFolder, ManagedDocument, DocumentVersion
from documents.permissions import filter_documents_for_user, filter_folders_for_user
User = get_user_model()
class ExceptionalTransportTests(TestCase):
def setUp(self):
self.thematic_structures, _ = Thematic.objects.get_or_create(
code="structures",
defaults={"name_fr": "Ouvrages d'art", "name_nl": "Kunstwerken"}
)
self.thematic_documents, _ = Thematic.objects.get_or_create(
code="documents",
defaults={"name_fr": "Documents", "name_nl": "Documenten"}
)
self.role_admin, _ = Role.objects.get_or_create(name="admin")
self.role_top_manager, _ = Role.objects.get_or_create(name="top_manager")
self.role_agent, _ = Role.objects.get_or_create(name="agent")
# Users
self.superuser = User.objects.create_superuser(
username="admin_te",
email="admin_te@example.com",
password="password123"
)
UserConfig.objects.get_or_create(user=self.superuser, defaults={"is_intern": True})
self.user_no_te = User.objects.create_user(
username="user_no_te",
email="user_no_te@example.com",
password="password123"
)
cfg_no_te, _ = UserConfig.objects.get_or_create(user=self.user_no_te, defaults={"is_intern": True, "limit_assets_to_contracts": False})
cfg_no_te.roles.add(self.role_agent)
UserThematics.objects.update_or_create(user_config=cfg_no_te, thematic=self.thematic_structures, defaults={"can_view_assets": True})
UserThematics.objects.update_or_create(user_config=cfg_no_te, thematic=self.thematic_documents, defaults={"can_view_assets": True})
self.user_te_viewer = User.objects.create_user(
username="user_te_viewer",
email="user_te_viewer@example.com",
password="password123"
)
cfg_viewer, _ = UserConfig.objects.get_or_create(
user=self.user_te_viewer,
defaults={
"is_intern": True,
"limit_assets_to_contracts": False,
"has_exceptional_transport_profile": True,
"exceptional_transport_can_edit": False
}
)
cfg_viewer.roles.add(self.role_agent)
UserThematics.objects.update_or_create(user_config=cfg_viewer, thematic=self.thematic_structures, defaults={"can_view_assets": True})
UserThematics.objects.update_or_create(user_config=cfg_viewer, thematic=self.thematic_documents, defaults={"can_view_assets": True})
self.user_te_editor = User.objects.create_user(
username="user_te_editor",
email="user_te_editor@example.com",
password="password123"
)
cfg_editor, _ = UserConfig.objects.get_or_create(
user=self.user_te_editor,
defaults={
"is_intern": True,
"limit_assets_to_contracts": False,
"has_exceptional_transport_profile": True,
"exceptional_transport_can_edit": True
}
)
cfg_editor.roles.add(self.role_agent)
UserThematics.objects.update_or_create(user_config=cfg_editor, thematic=self.thematic_structures, defaults={"can_view_assets": True})
UserThematics.objects.update_or_create(user_config=cfg_editor, thematic=self.thematic_documents, defaults={"can_view_assets": True})
# Structure & Location
self.structure = Structure.objects.create(
code="OA-TEST-001",
name_fr="Pont Test TE",
name_nl="Brug Test TE"
)
self.location = StructureLocation.objects.create(
structure=self.structure,
code="LOC-OA-001",
name_fr="Emplacement Principal",
is_main_location=True
)
self.structure.main_location = self.location
self.structure.save()
# TE Info
self.te_info = StructureExceptionalTransport.objects.create(
structure=self.structure,
fod_vnr=1234,
national_id="IDN-5678",
bridge_class="T180152402024030",
passage_status="conditional",
max_authorized_weight_tonnes=180.0,
max_calculated_weight_tonnes=240.0,
min_guaranteed_capacity_tonnes=150.0,
max_axle_load_tonnes=20.0,
min_axle_spacing_m=1.5,
clearance_height_left_m=4.20,
clearance_height_center_m=4.50,
clearance_height_right_m=4.20,
usable_width_m=7.50,
max_speed_kmh=5,
is_braking_forbidden=True,
is_isolated_crossing_only=True,
safety_distance_m=5.0,
traffic_interruption_required=False,
lane_positioning="center",
prescriptions_fr="Vitesse ≤ 5 km/h, sans freinage.",
prescriptions_nl="Snelheid ≤ 5 km/u, niet remmen."
)
# Notice
self.notice = StructureExceptionalTransportNotice.objects.create(
structure=self.structure,
mission_reference="Mission 64",
result="conditional",
evaluated_total_weight_tonnes=200.0,
evaluated_axle_load_tonnes=20.0,
comments="Passage autorisé sous réserve d'escorte."
)
def test_permission_helpers(self):
# Superuser
self.assertTrue(can_view_exceptional_transport(self.superuser))
self.assertTrue(can_edit_exceptional_transport(self.superuser))
# Standard User without TE profile
self.assertFalse(can_view_exceptional_transport(self.user_no_te))
self.assertFalse(can_edit_exceptional_transport(self.user_no_te))
# User with TE viewer profile
self.assertTrue(can_view_exceptional_transport(self.user_te_viewer))
self.assertFalse(can_edit_exceptional_transport(self.user_te_viewer))
# User with TE editor profile
self.assertTrue(can_view_exceptional_transport(self.user_te_editor))
self.assertTrue(can_edit_exceptional_transport(self.user_te_editor))
def test_models_str_and_properties(self):
self.assertIn("OA-TEST-001", str(self.te_info))
self.assertIn("Mission 64", str(self.notice))
def test_detail_view_visibility_for_unauthorized_user(self):
self.client.force_login(self.user_no_te)
url = reverse("assets:structures_locations_detail", kwargs={"location_id": self.location.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
# Ne doit pas afficher la section TE ni les prescriptions secrètes
self.assertNotIn("Transport exceptionnel &amp; Convois lourds", content)
self.assertNotIn("Transport exceptionnel & Convois lourds", content)
self.assertNotIn("T180152402024030", content)
def test_detail_view_visibility_for_te_viewer(self):
self.client.force_login(self.user_te_viewer)
url = reverse("assets:structures_locations_detail", kwargs={"location_id": self.location.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
# Doit afficher les infos TE
self.assertIn("Transport exceptionnel", content)
self.assertIn("T180152402024030", content)
self.assertIn("1234", content)
# Mais ne doit PAS afficher le bouton de modification
self.assertNotIn('data-bs-target="#editExceptionalTransportModal"', content)
def test_detail_view_visibility_for_te_editor(self):
self.client.force_login(self.user_te_editor)
url = reverse("assets:structures_locations_detail", kwargs={"location_id": self.location.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf-8")
# Doit afficher les infos TE et le modal de modification
self.assertIn("Transport exceptionnel", content)
self.assertIn('data-bs-target="#editExceptionalTransportModal"', content)
self.assertIn('id="exceptionalTransportForm"', content)
def test_edit_exceptional_transport_endpoint_permissions(self):
url = reverse("assets:structures_edit_exceptional_transport", kwargs={"structure_id": self.structure.id})
post_data = {
"passage_status": "authorized",
"max_authorized_weight_tonnes": 220.0,
"max_speed_kmh": 10,
"bridge_class": "T200",
}
# Non authentifié -> 302/401/403
self.client.logout()
res_anon = self.client.post(url, data=post_data)
self.assertIn(res_anon.status_code, (302, 401, 403))
# Viewer sans droit d'édition -> 403
self.client.force_login(self.user_te_viewer)
res_viewer = self.client.post(url, data=post_data)
self.assertEqual(res_viewer.status_code, 403)
# Editor avec droit d'édition -> 200
self.client.force_login(self.user_te_editor)
res_editor = self.client.post(url, data=post_data)
self.assertEqual(res_editor.status_code, 200)
json_data = res_editor.json()
self.assertTrue(json_data["success"])
# Vérification en BDD
self.te_info.refresh_from_db()
self.assertEqual(self.te_info.passage_status, "authorized")
self.assertEqual(float(self.te_info.max_authorized_weight_tonnes), 220.0)
self.assertEqual(self.te_info.max_speed_kmh, 10)
self.assertEqual(self.te_info.bridge_class, "T200")
self.assertEqual(self.te_info.updated_by, self.user_te_editor)
def test_document_filtering_security(self):
# Création d'un dossier TE et d'un document
te_folder = DocumentFolder.objects.create(
name="Civil engineering / Exceptional transport",
slug="oa-test-001-exceptional-transport"
)
te_folder.thematics.add(self.thematic_documents)
doc = ManagedDocument.objects.create(
title="Note de calcul convoi 240t",
created_by=self.superuser
)
doc.folders.add(te_folder)
user_no_te = User.objects.get(pk=self.user_no_te.pk)
user_te_viewer = User.objects.get(pk=self.user_te_viewer.pk)
# Filtrage pour l'utilisateur sans TE
docs_no_te = filter_documents_for_user(ManagedDocument.objects.all(), user_no_te)
self.assertNotIn(doc, docs_no_te)
folders_no_te = filter_folders_for_user(DocumentFolder.objects.all(), user_no_te)
self.assertNotIn(te_folder, folders_no_te)
# Filtrage pour l'utilisateur avec profil TE
docs_te = filter_documents_for_user(ManagedDocument.objects.all(), user_te_viewer)
self.assertIn(doc, docs_te)
folders_te = filter_folders_for_user(DocumentFolder.objects.all(), user_te_viewer)
self.assertIn(te_folder, folders_te)

View file

@ -161,7 +161,7 @@ urlpatterns = [
path('structures/locations/<int:location_id>/', structures.structures_locations_detail, name='structures_locations_detail'),
path('structures/assets/', structures.structures_assets, name='structures_assets'),
path('structures/assets/geojson/', structures.structures_assets_geojson, name='structures_assets_geojson'),
path('structures/assets/autocomplete/', structures.structures_assets_autocomplete, name='structures_autocomplete_assets'),
path('structures/structures/<int:structure_id>/edit-exceptional-transport/', structures.structures_edit_exceptional_transport, name='structures_edit_exceptional_transport'),
path('structures/<str:asset_model>/<int:asset_id>/', structures.structures_assets_detail, name='structures_assets_detail'),
path('controlcenters/', controlcenters.controlcenters_index, name='controlcenters_index'),

View file

@ -23,14 +23,17 @@ from collections import defaultdict
from common.utils import check_thematic_access
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics
from assets.models import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel, StructureDocument, StructureContract, AssetCategory
from assets.models import (
Structure, StructureLocation, StructureGeoAsset, StructureAssetModel, StructureDocument, StructureContract, AssetCategory,
StructureExceptionalTransport, StructureExceptionalTransportNotice
)
from assets.permissions import can_view_exceptional_transport, can_edit_exceptional_transport
from assets.forms import StructureDocumentForm, StructureExceptionalTransportForm
from interventions.models import InterventionLocation
from interventions.views.drafts import InterventionDraftManager
from assets.forms import StructureDocumentForm
from documents.models import DocumentTag, DocumentFolder
from documents.models import DocumentTag, DocumentFolder, ManagedDocument
from documents.services import DocumentManagementService
@check_thematic_access('structures')
@ -614,7 +617,7 @@ def structures_locations_detail(request, location_id):
# 7) Géométrie 4326 (main_location fallback)
if location.geom:
location_geom_4326 = location.geom.transform(4326, clone=True)
elif location.structure and location.structure.main_location:
elif location.structure and location.structure.main_location and location.structure.main_location.geom:
location_geom_4326 = location.structure.main_location.geom.transform(4326, clone=True)
else:
location_geom_4326 = None
@ -628,6 +631,43 @@ def structures_locations_detail(request, location_id):
from assets.utils.asset_code_generator import get_suggested_asset_code
suggested_code = get_suggested_asset_code('structures', location=location)
# --- GESTION DU TRANSPORT EXCEPTIONNEL ---
can_view_te = can_view_exceptional_transport(user)
can_edit_te = can_edit_exceptional_transport(user)
te_info = None
te_documents = []
te_notices = []
te_form = None
te_folder = None
if can_view_te and location.structure:
te_info = getattr(location.structure, 'exceptional_transport_info', None)
te_notices = list(location.structure.exceptional_transport_notices.all())
if can_edit_te:
te_form = StructureExceptionalTransportForm(instance=te_info)
# Récupérer le dossier "Exceptional transport" et ses documents
if document_folder:
all_descendants = DocumentManagementService.get_folder_and_descendants(document_folder)
te_folder = DocumentFolder.objects.filter(
models.Q(pk__in=all_descendants)
& (
models.Q(slug__icontains="exceptional-transport")
| models.Q(slug__icontains="transport-exceptionnel")
| models.Q(slug__icontains="transports-exceptionnels")
| models.Q(name__icontains="Exceptional transport")
| models.Q(name__icontains="Transport exceptionnel")
)
).first()
if te_folder:
te_folder_ids = DocumentManagementService.get_folder_and_descendants(te_folder)
te_documents = list(
ManagedDocument.objects.filter(
folders__pk__in=te_folder_ids,
is_archived=False
).select_related('current_version', 'created_by').prefetch_related('tags').order_by('-updated_at')
)
return render(request, "assets/structures_location_detail.html", {
"location": location,
"interventions": interventions, # toutes les interventions de l'arbre
@ -648,9 +688,40 @@ def structures_locations_detail(request, location_id):
"available_folders": available_folders,
"document_folder": document_folder,
"use_managed_documents": use_managed_documents if 'use_managed_documents' in dir() else False,
"can_view_exceptional_transport": can_view_te,
"can_edit_exceptional_transport": can_edit_te,
"te_info": te_info,
"te_documents": te_documents,
"te_notices": te_notices,
"te_form": te_form,
"te_folder": te_folder,
})
@require_POST
def structures_edit_exceptional_transport(request, structure_id):
if not request.user.is_authenticated:
return JsonResponse({'success': False, 'error': _("Authentification requise.")}, status=401)
if not can_edit_exceptional_transport(request.user):
return JsonResponse({'success': False, 'error': _("Permission refusée.")}, status=403)
structure = get_object_or_404(Structure, pk=structure_id)
te_info, _created = StructureExceptionalTransport.objects.get_or_create(structure=structure)
form = StructureExceptionalTransportForm(request.POST, instance=te_info)
if form.is_valid():
instance = form.save(commit=False)
instance.updated_by = request.user
instance.save()
return JsonResponse({
'success': True,
'message': _("Paramètres de transport exceptionnel enregistrés avec succès.")
})
else:
return JsonResponse({'success': False, 'errors': form.errors}, status=400)
@check_thematic_access('structures')
def structures_assets_detail(request, asset_model, asset_id):

View file

@ -631,6 +631,10 @@ class UserConfigAdmin(admin.ModelAdmin):
'fields': ('panoramax_can_upload',),
'description': "Permissions spécifiques pour l'application Photos 360 / Panoramax."
}),
('Ouvrages : Transports exceptionnels', {
'fields': ('has_exceptional_transport_profile', 'exceptional_transport_can_edit'),
'description': "Permissions pour l'accès aux données et documents de transport exceptionnel sur les ouvrages d'art."
}),
('Limitations', {
'fields': ('limit_assets_to_contracts', 'limit_interventions_to_contracts', 'limit_shortcuts_to_contracts', 'hide_intervention_map_by_default')
}),

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-08-21 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0012_userconfig_control_list_filters_and_more'),
]
operations = [
migrations.AddField(
model_name='userconfig',
name='exceptional_transport_can_edit',
field=models.BooleanField(default=False, help_text='Autorise la modification des caractéristiques, gabarits et prescriptions de transport exceptionnel.', verbose_name='Peut modifier les données de transport exceptionnel'),
),
migrations.AddField(
model_name='userconfig',
name='has_exceptional_transport_profile',
field=models.BooleanField(default=False, help_text="Donne accès aux données et documents de transport exceptionnel sur les ouvrages d'art.", verbose_name='Activer le profil Transport exceptionnel'),
),
]

View file

@ -304,6 +304,17 @@ class UserConfig(models.Model):
verbose_name="eSign : Gestionnaires",
help_text="Les gestionnaires (communes, Bruxelles Mobilité) pour lesquels l'utilisateur a des permissions."
)
# Permissions pour le transport exceptionnel (ouvrages d'art)
has_exceptional_transport_profile = models.BooleanField(
default=False,
verbose_name="Activer le profil Transport exceptionnel",
help_text="Donne accès aux données et documents de transport exceptionnel sur les ouvrages d'art."
)
exceptional_transport_can_edit = models.BooleanField(
default=False,
verbose_name="Peut modifier les données de transport exceptionnel",
help_text="Autorise la modification des caractéristiques, gabarits et prescriptions de transport exceptionnel."
)
# Catégories d'assets concernées pour l'approbation des interventions
approval_categories = models.ManyToManyField(
'assets.AssetCategory',

View file

@ -76,7 +76,34 @@ def filter_documents_for_user(
if accessible_ct_ids:
filter_q |= models.Q(attachments__content_type_id__in=accessible_ct_ids)
return queryset.filter(filter_q).distinct()
result_qs = queryset.filter(filter_q).distinct()
from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user):
result_qs = result_qs.exclude(
models.Q(tags__slug__in=['exceptional_transport', 'exceptional-transport', 'transports-exceptionnels'])
| models.Q(folders__slug__icontains='exceptional-transport')
| models.Q(folders__slug__icontains='transport-exceptionnel')
| models.Q(folders__slug__icontains='transports-exceptionnels')
| models.Q(folders__name__icontains='Exceptional transport')
| models.Q(folders__name__icontains='Transport exceptionnel')
)
return result_qs
def _is_exceptional_transport_folder(folder: DocumentFolder) -> bool:
if not folder:
return False
name = (folder.name or "").lower()
slug = (folder.slug or "").lower()
return (
"exceptional transport" in name
or "transport exceptionnel" in name
or "exceptional-transport" in slug
or "transport-exceptionnel" in slug
or "transports-exceptionnels" in slug
)
def user_has_document_permission(
@ -93,6 +120,20 @@ def user_has_document_permission(
if getattr(user, "is_superuser", False):
return True
from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user):
if (
document.tags.filter(slug__in=['exceptional_transport', 'exceptional-transport', 'transports-exceptionnels']).exists()
or document.folders.filter(
models.Q(slug__icontains='exceptional-transport')
| models.Q(slug__icontains='transport-exceptionnel')
| models.Q(slug__icontains='transports-exceptionnels')
| models.Q(name__icontains='Exceptional transport')
| models.Q(name__icontains='Transport exceptionnel')
).exists()
):
return False
if document.created_by_id and document.created_by_id == getattr(user, "pk", None):
return True
@ -703,7 +744,19 @@ def filter_folders_for_user(
if not visible_ids:
return queryset.none()
return queryset.filter(pk__in=visible_ids).distinct()
result_qs = queryset.filter(pk__in=visible_ids).distinct()
from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user):
result_qs = result_qs.exclude(
models.Q(slug__icontains='exceptional-transport')
| models.Q(slug__icontains='transport-exceptionnel')
| models.Q(slug__icontains='transports-exceptionnels')
| models.Q(name__icontains='Exceptional transport')
| models.Q(name__icontains='Transport exceptionnel')
)
return result_qs
def user_can_browse_folder(user, folder: DocumentFolder) -> bool:
@ -725,6 +778,10 @@ def user_can_browse_folder(user, folder: DocumentFolder) -> bool:
"""
if user is None or not getattr(user, "is_authenticated", False):
return False
from assets.permissions import can_view_exceptional_transport
if not can_view_exceptional_transport(user) and _is_exceptional_transport_folder(folder):
return False
config = getattr(user, "config", None)
if not config: