diff --git a/loko/assets/tests_exceptional_transport.py b/loko/assets/tests_exceptional_transport.py index 7028f2f..56a964e 100644 --- a/loko/assets/tests_exceptional_transport.py +++ b/loko/assets/tests_exceptional_transport.py @@ -254,3 +254,54 @@ class ExceptionalTransportTests(TestCase): folders_te = filter_folders_for_user(DocumentFolder.objects.all(), user_te_viewer) self.assertIn(te_folder, folders_te) + + def test_exceptional_transport_geojson_endpoint(self): + # Set geometry on location + from django.contrib.gis.geos import MultiPolygon, Polygon + p = Polygon(((4.35, 50.84), (4.36, 50.84), (4.36, 50.85), (4.35, 50.85), (4.35, 50.84))) + p.srid = 4326 + p.transform(3812) + self.location.geom = MultiPolygon(p, srid=3812) + self.location.save() + + client = Client() + url = reverse("assets:structures_exceptional_transport_geojson") + + # Anonymous / unauthenticated (redirects to login) + res = client.get(url) + self.assertIn(res.status_code, (302, 401, 403)) + + # User without TE profile + client.force_login(self.user_no_te) + res = client.get(url) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["features"], []) + + # User with TE profile + client.force_login(self.user_te_viewer) + res = client.get(url) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["type"], "FeatureCollection") + self.assertEqual(len(data["features"]), 1) + feat = data["features"][0] + self.assertEqual(feat["properties"]["structure_code"], "OA-TEST-001") + self.assertEqual(feat["properties"]["fod_vnr"], 1234) + self.assertEqual(feat["properties"]["passage_status"], "conditional") + + def test_home_widget_exceptional_transport_resolution(self): + from home.models import HomeWidget + widget = HomeWidget.objects.filter(widget_code="exceptional_transport").first() + self.assertIsNotNone(widget) + + cfg_no_te = self.user_no_te.config + cfg_viewer = self.user_te_viewer.config + + # User without profile should not resolve widget + res_no = widget.resolve_for_user(cfg_no_te, None, None) + self.assertIsNone(res_no) + + # User with TE profile should resolve widget + res_yes = widget.resolve_for_user(cfg_viewer, None, None) + self.assertIsNotNone(res_yes) + self.assertEqual(res_yes["template_name"], "home/widgets/exceptional_transport.html") diff --git a/loko/assets/urls.py b/loko/assets/urls.py index 2ca4e2a..9278d70 100644 --- a/loko/assets/urls.py +++ b/loko/assets/urls.py @@ -161,6 +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/exceptional-transport/geojson/', structures.structures_exceptional_transport_geojson, name='structures_exceptional_transport_geojson'), 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'), diff --git a/loko/assets/views/structures.py b/loko/assets/views/structures.py index 0d914e3..4ec617e 100644 --- a/loko/assets/views/structures.py +++ b/loko/assets/views/structures.py @@ -3,6 +3,7 @@ # ---------------- from django.shortcuts import render, get_object_or_404, redirect +from django.urls import reverse from django.http import JsonResponse, Http404 from django.views.decorators.http import require_GET, require_POST from django.contrib.gis.db.models.functions import Transform, Distance @@ -720,6 +721,96 @@ def structures_edit_exceptional_transport(request, structure_id): return JsonResponse({'success': False, 'errors': form.errors}, status=400) +@require_GET +def structures_exceptional_transport_geojson(request): + """Retourne l'ensemble des ouvrages avec données de transport exceptionnel au format GeoJSON.""" + if not request.user.is_authenticated or not can_view_exceptional_transport(request.user): + return JsonResponse({'type': 'FeatureCollection', 'features': []}) + + te_list = ( + StructureExceptionalTransport.objects + .select_related('structure', 'structure__main_location') + .all() + ) + + features = [] + for te in te_list: + structure = te.structure + loc = structure.main_location + if not loc: + continue + + lon, lat = None, None + if loc.geom: + try: + pt = loc.geom.centroid.transform(4326, clone=True) + lon = pt.x + lat = pt.y + except Exception: + pass + + if (lon is None or lat is None) and loc.lon and loc.lat: + try: + lon = float(loc.lon) + lat = float(loc.lat) + except (ValueError, TypeError): + pass + + if lon is None or lat is None: + continue + + detail_url = reverse('assets:structures_locations_detail', args=[loc.id]) + + h_str = "" + if te.clearance_height_left_m or te.clearance_height_center_m or te.clearance_height_right_m: + h_left = f"{te.clearance_height_left_m}m" if te.clearance_height_left_m else "-" + h_center = f"{te.clearance_height_center_m}m" if te.clearance_height_center_m else "-" + h_right = f"{te.clearance_height_right_m}m" if te.clearance_height_right_m else "-" + h_str = f"{h_left} / {h_center} / {h_right}" + + features.append({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [lon, lat] + }, + 'properties': { + 'id': te.id, + 'structure_id': structure.id, + 'structure_code': structure.code or '', + 'name': structure.get_name(), + 'name_fr': structure.name_fr or '', + 'name_nl': structure.name_nl or '', + 'structure_type': structure.get_structure_type_display() if hasattr(structure, 'get_structure_type_display') else (structure.structure_type or ''), + 'location_id': loc.id, + 'location_code': loc.code or '', + 'detail_url': detail_url, + 'fod_vnr': te.fod_vnr, + 'national_id': te.national_id or '', + 'bridge_class': te.bridge_class or '', + 'passage_status': te.passage_status, + 'passage_status_display': str(te.get_passage_status_display()), + 'max_authorized_weight_tonnes': float(te.max_authorized_weight_tonnes) if te.max_authorized_weight_tonnes is not None else None, + 'min_guaranteed_capacity_tonnes': float(te.min_guaranteed_capacity_tonnes) if te.min_guaranteed_capacity_tonnes is not None else None, + 'max_calculated_weight_tonnes': float(te.max_calculated_weight_tonnes) if te.max_calculated_weight_tonnes is not None else None, + 'usable_width_m': float(te.usable_width_m) if te.usable_width_m is not None else None, + 'clearance_heights_str': h_str, + 'max_speed_kmh': te.max_speed_kmh, + 'is_braking_forbidden': te.is_braking_forbidden, + 'is_isolated_crossing_only': te.is_isolated_crossing_only, + 'safety_distance_m': float(te.safety_distance_m) if te.safety_distance_m is not None else None, + 'prescriptions_fr': te.prescriptions_fr or '', + 'prescriptions_nl': te.prescriptions_nl or '', + 'last_study_ref': te.last_study_ref or '', + } + }) + + return JsonResponse({ + 'type': 'FeatureCollection', + 'features': features + }) + + @check_thematic_access('structures') diff --git a/loko/home/migrations/0005_alter_homewidget_widget_code_and_more.py b/loko/home/migrations/0005_alter_homewidget_widget_code_and_more.py new file mode 100644 index 0000000..6fb9583 --- /dev/null +++ b/loko/home/migrations/0005_alter_homewidget_widget_code_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-08-21 09:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0004_alter_homewidget_widget_code'), + ] + + operations = [ + migrations.AlterField( + model_name='homewidget', + name='widget_code', + field=models.CharField(choices=[('intervention_stats', 'Statistiques interventions'), ('contracts_summary', 'Résumé contrats'), ('map_today', 'Carte du jour'), ('operator_shortcuts', 'Raccourcis opérateur'), ('assets_links', 'Liens assets'), ('stock_summary', 'Résumé stock'), ('observer_shortcuts', 'Raccourcis observateur'), ('incident_counter', 'Compteur incidents à traiter'), ('intervention_to_verify', 'Compteur interventions à vérifier'), ('intervention_to_plan', 'Compteur interventions à planifier'), ('intervention_to_approve', 'Compteur interventions à approuver'), ('intervention_checked_to_approve', 'Compteur interventions vérifiées à approuver'), ('intervention_finished', 'Compteur interventions terminées'), ('intervention_on_pause', 'Compteur interventions en pause'), ('external_interventions', 'Interventions externes'), ('stock_actions', 'Actions stock'), ('exceptional_transport', 'Transport exceptionnel')], max_length=50, unique=True, verbose_name='Code du widget'), + ), + migrations.AlterField( + model_name='homewidgetcondition', + name='condition_type', + field=models.CharField(choices=[('role', 'Rôle'), ('team', 'Équipe'), ('contract', 'Contrat'), ('thematic', 'Thématique'), ('is_intern', 'Statut interne'), ('exceptional_transport', 'Profil Transport Exceptionnel')], max_length=50, verbose_name='Type de condition'), + ), + ] diff --git a/loko/home/migrations/0006_create_exceptional_transport_widget.py b/loko/home/migrations/0006_create_exceptional_transport_widget.py new file mode 100644 index 0000000..f25d0ba --- /dev/null +++ b/loko/home/migrations/0006_create_exceptional_transport_widget.py @@ -0,0 +1,44 @@ +from django.db import migrations + + +def create_exceptional_transport_widget(apps, schema_editor): + HomeWidget = apps.get_model('home', 'HomeWidget') + HomeWidgetConditionGroup = apps.get_model('home', 'HomeWidgetConditionGroup') + HomeWidgetCondition = apps.get_model('home', 'HomeWidgetCondition') + + widget, created = HomeWidget.objects.get_or_create( + widget_code='exceptional_transport', + defaults={'is_active': True} + ) + + if not widget.condition_groups.exists(): + group = HomeWidgetConditionGroup.objects.create( + widget=widget, + label="Profil Transport Exceptionnel", + order=25, + col_width=12, + priority=0, + is_active=True + ) + HomeWidgetCondition.objects.create( + group=group, + condition_type='exceptional_transport', + is_active=True, + is_negated=False + ) + + +def remove_exceptional_transport_widget(apps, schema_editor): + HomeWidget = apps.get_model('home', 'HomeWidget') + HomeWidget.objects.filter(widget_code='exceptional_transport').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0005_alter_homewidget_widget_code_and_more'), + ] + + operations = [ + migrations.RunPython(create_exceptional_transport_widget, remove_exceptional_transport_widget), + ] diff --git a/loko/home/models.py b/loko/home/models.py index 1f122a0..6099ace 100644 --- a/loko/home/models.py +++ b/loko/home/models.py @@ -20,6 +20,7 @@ WIDGET_CODE_CHOICES = [ ('intervention_on_pause', _('Compteur interventions en pause')), ('external_interventions', _('Interventions externes')), ('stock_actions', _('Actions stock')), + ('exceptional_transport', _('Transport exceptionnel')), ] # Condition type choices @@ -29,6 +30,7 @@ CONDITION_TYPE_CHOICES = [ ('contract', _('Contrat')), ('thematic', _('Thématique')), ('is_intern', _('Statut interne')), + ('exceptional_transport', _('Profil Transport Exceptionnel')), ] @@ -221,7 +223,7 @@ class HomeWidgetCondition(models.Model): verbose_name=_("Groupe") ) condition_type = models.CharField( - max_length=20, + max_length=50, choices=CONDITION_TYPE_CHOICES, verbose_name=_("Type de condition") ) @@ -330,6 +332,9 @@ class HomeWidgetCondition(models.Model): elif self.condition_type == 'is_intern': res = user_config.is_intern == self.is_intern + + elif self.condition_type == 'exceptional_transport': + res = bool(user_config.user.is_superuser or user_config.has_exceptional_transport_profile) else: res = False diff --git a/loko/home/templates/home/widgets/exceptional_transport.html b/loko/home/templates/home/widgets/exceptional_transport.html new file mode 100644 index 0000000..25ccd53 --- /dev/null +++ b/loko/home/templates/home/widgets/exceptional_transport.html @@ -0,0 +1,647 @@ +{% load i18n %} +{% load static %} + +
+ +
+
+
+ +
+
+
+ {% translate "Transport Exceptionnel & Convois Lourds" %} +
+ + {% translate "Cartographie du réseau d'ouvrages d'art et conditions d'admissibilité" %} + +
+
+ +
+ + 0 {% translate "ouvrages répertoriés" %} + + + {% translate "Tous les ouvrages" %} + +
+
+ + +
+ +
+ +
+
+ + + + + +
+ +
+
+
+ + +
+
+ + + + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ {% translate "Statut de passage" %} +
+
+
+ + {% translate "Autorisé (standard / calculé)" %} +
+
+ + {% translate "Sous conditions / prescriptions" %} +
+
+ + {% translate "Étude préalable requise" %} +
+
+ + {% translate "Interdit" %} +
+
+ + {% translate "Non évalué" %} +
+
+
+ + +
+
+
+ {% translate "Chargement..." %} +
+
{% translate "Chargement du réseau d'ouvrages..." %}
+
+
+
+
+
+ + + + + +