From 55d021b7efea59da77812575c2793f3ec46c65be Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 21 Aug 2026 10:29:26 +0200 Subject: [PATCH] feat: implement exceptional transport structures management with models, admin, and user permissions --- loko/assets/admin.py | 35 +- loko/assets/forms.py | 53 +- ..._structureexceptionaltransport_and_more.py | 75 +++ loko/assets/models/__init__.py | 2 +- loko/assets/models/structures.py | 222 +++++++- loko/assets/permissions.py | 42 ++ .../assets/structures_location_detail.html | 518 ++++++++++++++++++ loko/assets/tests_exceptional_transport.py | 256 +++++++++ loko/assets/urls.py | 2 +- loko/assets/views/structures.py | 81 ++- loko/common/admin.py | 4 + ...exceptional_transport_can_edit_and_more.py | 23 + loko/common/models.py | 11 + loko/documents/permissions.py | 61 ++- 14 files changed, 1367 insertions(+), 18 deletions(-) create mode 100644 loko/assets/migrations/0024_structureexceptionaltransport_and_more.py create mode 100644 loko/assets/tests_exceptional_transport.py create mode 100644 loko/common/migrations/0013_userconfig_exceptional_transport_can_edit_and_more.py diff --git a/loko/assets/admin.py b/loko/assets/admin.py index 931f0ff..6d81ed6 100644 --- a/loko/assets/admin.py +++ b/loko/assets/admin.py @@ -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 diff --git a/loko/assets/forms.py b/loko/assets/forms.py index 2d15324..6ad072a 100644 --- a/loko/assets/forms.py +++ b/loko/assets/forms.py @@ -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) \ No newline at end of file + 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 \ No newline at end of file diff --git a/loko/assets/migrations/0024_structureexceptionaltransport_and_more.py b/loko/assets/migrations/0024_structureexceptionaltransport_and_more.py new file mode 100644 index 0000000..086a868 --- /dev/null +++ b/loko/assets/migrations/0024_structureexceptionaltransport_and_more.py @@ -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'], + }, + ), + ] diff --git a/loko/assets/models/__init__.py b/loko/assets/models/__init__.py index c2882e1..ef9106d 100644 --- a/loko/assets/models/__init__.py +++ b/loko/assets/models/__init__.py @@ -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 diff --git a/loko/assets/models/structures.py b/loko/assets/models/structures.py index 32211c0..edbc81e 100644 --- a/loko/assets/models/structures.py +++ b/loko/assets/models/structures.py @@ -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()})" + + + diff --git a/loko/assets/permissions.py b/loko/assets/permissions.py index 060967a..27213ee 100644 --- a/loko/assets/permissions.py +++ b/loko/assets/permissions.py @@ -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) + ) + diff --git a/loko/assets/templates/assets/structures_location_detail.html b/loko/assets/templates/assets/structures_location_detail.html index 15f9f3f..ec4cc9b 100644 --- a/loko/assets/templates/assets/structures_location_detail.html +++ b/loko/assets/templates/assets/structures_location_detail.html @@ -266,6 +266,301 @@ {% endif %} + + {% if can_view_exceptional_transport and location.structure %} +
+
+

+ {% translate "Transport exceptionnel & Convois lourds" %} +

+
+ {% if te_info %} + {% if te_info.passage_status == 'authorized' %} + {% translate "Autorisé" %} + {% elif te_info.passage_status == 'conditional' %} + {% translate "Sous conditions" %} + {% elif te_info.passage_status == 'prior_study_required' %} + {% translate "Étude préalable requise" %} + {% elif te_info.passage_status == 'forbidden' %} + {% translate "Interdit" %} + {% else %} + {% translate "Non évalué" %} + {% endif %} + {% else %} + {% translate "Non évalué" %} + {% endif %} + + {% if can_edit_exceptional_transport %} + + {% endif %} +
+
+
+ + +
+ +
+
+
+
+
{% translate "Capacités de charge" %}
+ + + + + + + + + + + + + + + + + + + + + +
{% translate "Poids max. standard (MAXWA)" %} :{% if te_info.max_authorized_weight_tonnes %}{{ te_info.max_authorized_weight_tonnes }} t{% else %}-{% endif %}
{% translate "Tonnage max. calculé (MX)" %} :{% if te_info.max_calculated_weight_tonnes %}{{ te_info.max_calculated_weight_tonnes }} t{% else %}-{% endif %}
{% translate "Capacité min. garantie (MINWR)" %} :{% if te_info.min_guaranteed_capacity_tonnes %}{{ te_info.min_guaranteed_capacity_tonnes }} t{% else %}-{% endif %}
{% translate "Charge max. / essieu" %} :{% if te_info.max_axle_load_tonnes %}{{ te_info.max_axle_load_tonnes }} t/essieu{% else %}-{% endif %}
{% translate "Entraxe minimal essieux" %} :{% if te_info.min_axle_spacing_m %}{{ te_info.min_axle_spacing_m }} m{% else %}-{% endif %}
+
+
+ +
+
+
{% translate "Gabarits & Classification" %}
+ + + + + + + + + + + + + + + + + + + + + +
{% translate "Classe de pont (BKL)" %} :{{ te_info.bridge_class|default:"-" }}
{% translate "N° FOD / ID National" %} :{{ te_info.fod_vnr|default:"-" }} / {{ te_info.national_id|default:"-" }}
{% translate "Hauteurs libres (G / Axe / D)" %} : + {{ te_info.clearance_height_left_m|default:"-" }} m / + {{ te_info.clearance_height_center_m|default:"-" }} m / + {{ te_info.clearance_height_right_m|default:"-" }} m +
{% translate "Largeur utile (BRE)" %} :{% if te_info.usable_width_m %}{{ te_info.usable_width_m }} m{% else %}-{% endif %}
{% translate "Franchissement ferroviaire / métro" %} : + {% if te_info.is_railway_metro_crossing %} + {% translate "Oui" %} + {% else %} + {% translate "Non" %} + {% endif %} +
+
+
+
+
+ + +
+
+
+
+
{% translate "Règles d'exploitation" %}
+
    +
  • + {% translate "Vitesse maximale" %} + {{ te_info.max_speed_kmh|default:5 }} km/h +
  • +
  • + {% translate "Freinage sur ouvrage" %} + {% if te_info.is_braking_forbidden %} + {% translate "Strictement interdit" %} + {% else %} + {% translate "Autorisé" %} + {% endif %} +
  • +
  • + {% translate "Passage isolé (convoi seul)" %} + {% if te_info.is_isolated_crossing_only %} + {% translate "Convoi seul obligatoire" %} + {% else %} + {% translate "Non exigé" %} + {% endif %} +
  • +
  • + {% translate "Distance de sécurité" %} + {{ te_info.safety_distance_m|default:5 }} m +
  • +
  • + {% translate "Interruption circulation" %} + {% if te_info.traffic_interruption_required %} + {% translate "Requise" %} + {% else %} + {% translate "Non" %} + {% endif %} +
  • +
  • + {% translate "Position sur chaussée" %} + {{ te_info.get_lane_positioning_display|default:"Standard" }} +
  • +
+
+
+ +
+
+
{% translate "Prescriptions détaillées" %}
+ {% if te_info.prescriptions_fr %} +
+ FR +

{{ te_info.prescriptions_fr }}

+
+ {% endif %} + {% if te_info.prescriptions_nl %} +
+ NL +

{{ te_info.prescriptions_nl }}

+
+ {% endif %} + {% if not te_info.prescriptions_fr and not te_info.prescriptions_nl %} +

{% translate "Aucune prescription textuelle spécifique enregistrée." %}

+ {% endif %} + + {% if te_info.last_study_ref or te_info.last_study_date %} +
+
+ + {% translate "Dernière note de calcul" %} : + {{ te_info.last_study_ref|default:"-" }} + {% if te_info.last_study_date %}({{ te_info.last_study_date|date:"d/m/Y" }}){% endif %} +
+ {% endif %} +
+
+
+
+ + +
+
+
{% translate "Documents du répertoire Transport exceptionnel" %}
+ {% if te_folder %} + + {% translate "Ouvrir dans la GED" %} + + {% endif %} +
+ + {% if te_documents %} +
+ {% for doc in te_documents %} +
+
+
+
+ +
+ + {{ doc.title }} + +
{{ doc.updated_at|date:"d/m/Y" }}
+
+
+ {% if doc.current_version and doc.current_version.file %} +
+ v{{ doc.current_version.version_number }} + + + +
+ {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+ +

{% translate "Aucun document spécifique dans le répertoire Transport exceptionnel." %}

+
+ {% endif %} + + {% if te_notices %} +
+
{% translate "Historique des missions d'études et avis" %}
+
+ + + + + + + + + + + + + {% for notice in te_notices %} + + + + + + + + + {% endfor %} + +
{% translate "Mission / Réf" %}{% translate "Date" %}{% translate "Résultat" %}{% translate "Tonnage évalué" %}{% translate "Charge / essieu" %}{% translate "Commentaires" %}
{{ notice.mission_reference }}{{ notice.notice_date|date:"d/m/Y"|default:"-" }} + {% if notice.result == 'positive' %} + {% translate "Favorable" %} + {% elif notice.result == 'conditional' %} + {% translate "Sous conditions" %} + {% else %} + {% translate "Défavorable" %} + {% endif %} + {% if notice.evaluated_total_weight_tonnes %}{{ notice.evaluated_total_weight_tonnes }} t{% else %}-{% endif %}{% if notice.evaluated_axle_load_tonnes %}{{ notice.evaluated_axle_load_tonnes }} t{% else %}-{% endif %}{{ notice.comments|default:"-" }}
+
+ {% endif %} +
+
+
+
+ {% endif %} +
@@ -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 %} + +{% if can_edit_exceptional_transport and location.structure %} + +{% 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 %} @@ -406,3 +923,4 @@ {% endif %} {% endblock script %} + diff --git a/loko/assets/tests_exceptional_transport.py b/loko/assets/tests_exceptional_transport.py new file mode 100644 index 0000000..7028f2f --- /dev/null +++ b/loko/assets/tests_exceptional_transport.py @@ -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) diff --git a/loko/assets/urls.py b/loko/assets/urls.py index f57d12a..2ca4e2a 100644 --- a/loko/assets/urls.py +++ b/loko/assets/urls.py @@ -161,7 +161,7 @@ urlpatterns = [ path('structures/locations//', 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//edit-exceptional-transport/', structures.structures_edit_exceptional_transport, name='structures_edit_exceptional_transport'), path('structures///', structures.structures_assets_detail, name='structures_assets_detail'), path('controlcenters/', controlcenters.controlcenters_index, name='controlcenters_index'), diff --git a/loko/assets/views/structures.py b/loko/assets/views/structures.py index de6be35..ee02cef 100644 --- a/loko/assets/views/structures.py +++ b/loko/assets/views/structures.py @@ -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): diff --git a/loko/common/admin.py b/loko/common/admin.py index 5b6553c..1844747 100644 --- a/loko/common/admin.py +++ b/loko/common/admin.py @@ -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') }), diff --git a/loko/common/migrations/0013_userconfig_exceptional_transport_can_edit_and_more.py b/loko/common/migrations/0013_userconfig_exceptional_transport_can_edit_and_more.py new file mode 100644 index 0000000..85b44b7 --- /dev/null +++ b/loko/common/migrations/0013_userconfig_exceptional_transport_can_edit_and_more.py @@ -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'), + ), + ] diff --git a/loko/common/models.py b/loko/common/models.py index 98ec490..e7c9656 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -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', diff --git a/loko/documents/permissions.py b/loko/documents/permissions.py index 3918975..1664ba0 100644 --- a/loko/documents/permissions.py +++ b/loko/documents/permissions.py @@ -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: