feat: implement exceptional transport structures management with models, admin, and user permissions
This commit is contained in:
parent
a410cc855f
commit
55d021b7ef
14 changed files with 1367 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()})"
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
256
loko/assets/tests_exceptional_transport.py
Normal file
256
loko/assets/tests_exceptional_transport.py
Normal 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 & 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)
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue