diff --git a/streetup/assets/migrations/0010_add_parking_thematic.py b/streetup/assets/migrations/0010_add_parking_thematic.py new file mode 100644 index 0000000..6562ad1 --- /dev/null +++ b/streetup/assets/migrations/0010_add_parking_thematic.py @@ -0,0 +1,74 @@ +from django.db import migrations + + +def create_parking_thematic(apps, schema_editor): + Thematic = apps.get_model('common', 'Thematic') + AssetCategory = apps.get_model('assets', 'AssetCategory') + ContentType = apps.get_model('contenttypes', 'ContentType') + + thematic, _created = Thematic.objects.get_or_create( + code='parking', + defaults={ + 'name_fr': 'Stationnement & Mobilité', + 'name_nl': 'Parkeren & Mobiliteit', + 'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)', + 'icon': 'bi-p-square', + 'color': '#0d6efd', + } + ) + + category, _cat_created = AssetCategory.objects.get_or_create( + code='parking_spot', + defaults={ + 'thematic': thematic, + 'name_fr': 'Emplacements de stationnement', + 'name_nl': 'Parkeerplaatsen', + } + ) + if category.thematic_id != thematic.id: + category.thematic = thematic + category.save() + + # Link ContentTypes + try: + spot_ct = ContentType.objects.get(app_label='assets', model='parkingspot') + category.allowed_models.add(spot_ct) + except Exception: + pass + + try: + loc_ct = ContentType.objects.get(app_label='assets', model='parkinglocation') + category.allowed_models.add(loc_ct) + except Exception: + pass + + # Assign category to existing ParkingSpot records + ParkingSpot = apps.get_model('assets', 'ParkingSpot') + ParkingSpot.objects.filter(category__isnull=True).update(category=category) + + # Grant thematic permissions only to admin user configurations + UserConfig = apps.get_model('common', 'UserConfig') + UserThematics = apps.get_model('common', 'UserThematics') + for uc in UserConfig.objects.filter(roles__name='admin').distinct(): + UserThematics.objects.get_or_create( + user_config=uc, + thematic=thematic, + defaults={'can_view_assets': True, 'can_edit_assets': True} + ) + + + + +def remove_parking_thematic(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('assets', '0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more'), + ('common', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_parking_thematic, remove_parking_thematic), + ] diff --git a/streetup/assets/migrations/0011_artworkfountain_funding_program_and_more.py b/streetup/assets/migrations/0011_artworkfountain_funding_program_and_more.py new file mode 100644 index 0000000..bd1b7eb --- /dev/null +++ b/streetup/assets/migrations/0011_artworkfountain_funding_program_and_more.py @@ -0,0 +1,213 @@ +# Generated by Django 6.0.7 on 2026-08-07 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0010_add_parking_thematic'), + ] + + operations = [ + migrations.AddField( + model_name='artworkfountain', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='artworkmonument', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='buildingasset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='cleanglasscontainer', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='cleanlitterbin', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='controlcentergeoasset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='itsgeoasset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='naturebench', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='naturegreensurface', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='naturerwiasset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='natureshrub', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='naturetree', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='outdoorasset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='parkingspot', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='publiclightingelectricalcabinet', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='publiclightinglightfixture', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='publiclightingpole', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='roadconcreteblockset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='roaddrain', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='roadfurniture', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='roadsidewalk', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='roadway', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='signpanel', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='signparkingspot', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='signpole', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='structuregeoasset', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightaccessory', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightcable', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightcontroller', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightcontrollerhardware', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightdetector', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightelectricalcabinet', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightelectricalcabinetcontent', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightlantern', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightnetworkhardware', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightpole', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightprogramming', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightradar', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightradarpole', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + migrations.AddField( + model_name='trafficlightvoltagehardware', + name='funding_program', + field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'), + ), + ] diff --git a/streetup/assets/models/core.py b/streetup/assets/models/core.py index 0831519..67b33b5 100644 --- a/streetup/assets/models/core.py +++ b/streetup/assets/models/core.py @@ -1,6 +1,8 @@ from django.contrib.gis.db import models from django.contrib.gis.geos import Point from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericRelation + from django.utils.translation import get_language, gettext_lazy as _ from common.models import Thematic @@ -280,10 +282,20 @@ class AbstractAsset(models.Model): marked_as_removed_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_marked_as_removed', verbose_name="Marqué comme retiré par") note = models.TextField(null=True, blank=True, verbose_name="Note") validation_status = models.CharField(max_length=50, choices=ASSET_VALIDATION_STATUS_CHOICES, null=True, blank=True, verbose_name="Statut de validation") + funding_program = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Programme de financement / Projet")) updated_at = models.DateTimeField(auto_now=True) updated_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_updated_by', verbose_name="Mis à jour par") + asset_inspections = GenericRelation( + 'assets.AssetInspection', + content_type_field='asset_content_type', + object_id_field='asset_object_id', + related_query_name='%(app_label)s_%(class)s' + ) + + + def get_replacement_chain(self): """Get the complete replacement chain starting from this asset.""" chain = [self] diff --git a/streetup/assets/permissions.py b/streetup/assets/permissions.py index 1724a46..1619b2b 100644 --- a/streetup/assets/permissions.py +++ b/streetup/assets/permissions.py @@ -16,36 +16,37 @@ ASSET_EDITABLE_FIELDS_BY_ROLE = { 'admin': [ 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', - 'last_inspection_date', 'geom', 'lon', 'lat', + 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], 'manager': [ 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', - 'last_inspection_date', 'geom', 'lon', 'lat', + 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], 'controller': [ 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', - 'last_inspection_date', 'geom', 'lon', 'lat', + 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], 'external_manager': [ - 'name_fr', 'name_nl', 'category', 'brand', 'serial_number' + 'name_fr', 'name_nl', 'category', 'brand', 'serial_number', 'funding_program' ], - 'technician': ['name_fr', 'name_nl', 'category', 'brand', 'serial_number'], + 'technician': ['name_fr', 'name_nl', 'category', 'brand', 'serial_number', 'funding_program'], 'operator': [], 'observer': [], 'viewer': [], 'editor': [ 'code', 'name_fr', 'name_nl', 'status', 'model', 'category', 'serial_number', 'installation_date', 'brand', 'warranty_duration', - 'last_inspection_date', 'geom', 'lon', 'lat', + 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat', 'location_id', 'intersection_id', ], } + # Actions allowed by role for assets ASSET_ACTIONS_BY_ROLE = { 'admin': ['edit', 'create', 'archive', 'replace', 'bulk_archive'], @@ -65,11 +66,17 @@ def get_user_asset_thematic_access(user, thematic): Check if user has access to view/edit assets for a given thematic. Returns a tuple (can_view, can_edit). """ + if getattr(user, 'is_superuser', False): + return True, True + try: user_config = UserConfig.objects.get(user=user) except UserConfig.DoesNotExist: return False, False - + + if user_config.roles.filter(name__in=['admin', 'operator']).exists(): + return True, True + try: user_thematic = UserThematics.objects.get( user_config=user_config, @@ -80,6 +87,7 @@ def get_user_asset_thematic_access(user, thematic): return False, False + def _check_instance_permission(user, obj, permission_field, thematic_fallback_fn=None): """ Shared logic for per-instance permission checks (assets and locations). diff --git a/streetup/assets/static/assets/unified_index.js b/streetup/assets/static/assets/unified_index.js index af1471e..1fd0971 100644 --- a/streetup/assets/static/assets/unified_index.js +++ b/streetup/assets/static/assets/unified_index.js @@ -38,7 +38,8 @@ var locationsFetchSignatures = {}; var assetsFetchSignatures = {}; // Assets: thematics that always load all assets (small datasets) -const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters']); +const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', 'parking']); + const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox' @@ -1795,6 +1796,7 @@ function collectClickedAssets(clickEvent) { const layers = [ 'assets', 'assets-fill', + 'assets-polygon-outline', 'assets-line', 'assets-point', 'sign-panels-circles', @@ -1803,6 +1805,7 @@ function collectClickedAssets(clickEvent) { 'sign-poles', 'sign-poles-vertical', ].filter(layerId => map.getLayer(layerId)); + if (!layers.length) return []; const rendered = map.queryRenderedFeatures(clickEvent.point, { layers }); @@ -2374,8 +2377,9 @@ function loadTableData(features) { // ─── Map integration ────────────────────────────────────────────────────────── -var locationshighlightState = {}; -var assetshighlightState = {}; +var locationshighlightState = { previousId: null }; +var assetshighlightState = { previousId: null }; + function isPanelTextSpriteId(missingId) { if (!missingId) return false; diff --git a/streetup/assets/static/assets/unified_map_layers.js b/streetup/assets/static/assets/unified_map_layers.js index b99c7fd..4eae967 100644 --- a/streetup/assets/static/assets/unified_map_layers.js +++ b/streetup/assets/static/assets/unified_map_layers.js @@ -216,8 +216,22 @@ function loadUnifiedMapIcons(map) { { name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' }, { name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' }, { name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris + { name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement + { name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement + { name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' }, // bicycle — vert mobilité active + { name: 'icon-parkingspot-scooter_drop_zone', codepoint: 'f8ab', color: '#10b981' }, // scooter — vert mobilité active + { name: 'icon-parkingspot-pmr', codepoint: 'f8fb', color: '#0d6efd' }, // person-wheelchair — bleu voiture PMR + { name: 'icon-parkingspot-ev_charging', codepoint: 'f839', color: '#0d6efd' }, // ev-station-fill — bleu voiture IRVE + { name: 'icon-parkingspot-delivery', codepoint: 'f820', color: '#f59e0b' }, // truck-front-fill — orange camion livraison + { name: 'icon-parkingspot-kiss_and_ride', codepoint: 'f291', color: '#0d6efd' }, // clock-fill — bleu voiture CD + { name: 'icon-parkingspot-carsharing', codepoint: 'f7e0', color: '#0d6efd' }, // car-front-fill — bleu voiture autopartage + { name: 'icon-parkinglocation', codepoint: 'f806', color: '#0d6efd' }, // p-square-fill — bleu stationnement ]; + + + + GLYPH_ICONS.forEach(({ name, codepoint, color }) => { // Skip if a file-based icon URL is being loaded for this name (async race condition) if (FILE_ICON_NAMES.has(name)) return; @@ -293,7 +307,9 @@ const THEMATIC_COLOR_EXPR = [ 'controlcenters', '#7b1fa2', // purple — control centres 'publicbuildings', '#2B579A', // navy blue — public buildings 'buildings', '#2B579A', // navy blue — public buildings + 'parking', '#0d6efd', // blue — parking & mobility 'roads', [ + 'match', ['get', 'asset_type'], 'roadway', '#4a4a4a', 'sidewalk', '#979898ff', @@ -339,9 +355,22 @@ const _unified_assets_fill = { ], ['==', ['get', 'thematic'], 'nature'], ['coalesce', ['get', 'color'], '#2e7d32'], + ['==', ['get', 'thematic'], 'parking'], + [ + 'match', ['get', 'spot_type'], + ['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active + 'delivery', '#f59e0b', // Orange/Ambre camion livraison + '#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking) + ], THEMATIC_COLOR_EXPR ], - 'fill-opacity': 0.7, + + 'fill-opacity': [ + 'case', + ['==', ['get', 'thematic'], 'parking'], 0.35, + 0.7 + ], + 'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices 'fill-outline-color': [ 'case', @@ -503,6 +532,16 @@ const _unified_assets_symbol = { ['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset', ['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset', + ['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone', + ['==', ['get', 'spot_type'], 'pmr'], 'icon-parkingspot-pmr', + ['==', ['get', 'spot_type'], 'ev_charging'], 'icon-parkingspot-ev_charging', + ['==', ['get', 'spot_type'], 'delivery'], 'icon-parkingspot-delivery', + ['==', ['get', 'spot_type'], 'kiss_and_ride'], 'icon-parkingspot-kiss_and_ride', + ['==', ['get', 'spot_type'], 'carsharing'], 'icon-parkingspot-carsharing', + ['match', ['get', 'spot_type'], ['bicycle', 'cargo_bike'], true, false], 'icon-parkingspot-bicycle', + ['==', ['get', 'asset_type'], 'parkingspot'], 'icon-parkingspot', + + // fallback: match by thematic [ 'match', ['get', 'thematic'], @@ -518,9 +557,11 @@ const _unified_assets_symbol = { 'controlcenters', 'icon-controlcenters', 'publicbuildings', 'icon-publicbuildings', 'publicbuilding', 'icon-publicbuilding', + 'parking', 'icon-parking', 'roads', 'icon-roads', 'icon-roads' // fallback ] + ], 'icon-size': [ 'interpolate', ['linear'], ['zoom'], @@ -801,6 +842,39 @@ const _unified_assets_point = { minzoom: 8, }; +// ─── Layer 3c — asset polygon outline (line layer for polygon asset borders) ─── +const _unified_assets_polygon_outline = { + id: 'assets-polygon-outline', + type: 'line', + source: 'assets', + filter: ['any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'] + ], + paint: { + 'line-color': [ + 'case', + ['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b', + ['==', ['get', 'thematic'], 'parking'], + [ + 'match', ['get', 'spot_type'], + ['bicycle', 'cargo_bike', 'scooter_drop_zone'], '#10b981', // Vert mobilité active + 'delivery', '#f59e0b', // Orange/Ambre camion livraison + '#0d6efd' // Bleu voitures (PMR, CD, EV, carsharing, car_parking) + ], + THEMATIC_COLOR_EXPR + ], + + 'line-width': [ + 'case', + ['boolean', ['feature-state', 'highlighted'], false], 4, + 2 + ], + 'line-opacity': 0.9, + }, + minzoom: 8, +}; + // ─── Ordered layer list ─────────────────────────────────────────────────────── // MapLibre renders layers in insertion order, so the array below defines the // visual stacking: fills at the bottom, then lines, then circles/symbols on top. @@ -809,6 +883,7 @@ const UNIFIED_LAYERS = [ _unified_assets_fill, // 2. polygon fills _unified_locations_outline, // 3. lines _unified_assets_line, // 3b. asset lines + _unified_assets_polygon_outline, // 3c. polygon asset outlines _unified_locations_circle, // 4. circles _unified_locations_radar_outline, _unified_locations_radar, @@ -820,3 +895,4 @@ const UNIFIED_LAYERS = [ _unified_sign_poles_vertical, _unified_sign_panels_symbols, ]; + diff --git a/streetup/assets/templates/assets/detail_generic.html b/streetup/assets/templates/assets/detail_generic.html index 547c2b3..bc608dc 100644 --- a/streetup/assets/templates/assets/detail_generic.html +++ b/streetup/assets/templates/assets/detail_generic.html @@ -359,6 +359,21 @@ + + + {% if asset.funding_program or 'funding_program' in allowed_update_fields %} + + {% translate "Programme / Financement" %} + +
+ {{ asset.funding_program|default:"-" }} +
+
+ +
+ + + {% endif %} diff --git a/streetup/assets/tests_parking_inspections.py b/streetup/assets/tests_parking_inspections.py index 24e8366..49ed5f9 100644 --- a/streetup/assets/tests_parking_inspections.py +++ b/streetup/assets/tests_parking_inspections.py @@ -1,13 +1,34 @@ -from django.test import TestCase +from django.test import TestCase, Client from django.contrib.auth.models import User from django.contrib.gis.geos import Polygon, MultiPolygon +from django.urls import reverse from django.utils import timezone -from assets.models import ParkingLocation, ParkingSpot, AssetInspection, ParkingSpotInspection, SignPole, SignPanel +from assets.models import ParkingLocation, ParkingSpot, AssetInspection, ParkingSpotInspection +from common.models import Thematic, UserConfig, UserThematics class ParkingSpotAndInspectionTest(TestCase): def setUp(self): self.user = User.objects.create_user(username="inspector_user", password="password123") + self.client = Client() + + # Create thematic and user thematic permissions + self.thematic, _ = Thematic.objects.get_or_create( + code="parking", + defaults={ + "name_fr": "Stationnement & Mobilité", + "name_nl": "Parkeren & Mobiliteit" + } + ) + + self.user_config = UserConfig.objects.create(user=self.user) + UserThematics.objects.create( + user_config=self.user_config, + thematic=self.thematic, + can_view_assets=True + ) + self.client.force_login(self.user) + poly = Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812) multi_poly = MultiPolygon(poly, srid=3812) @@ -41,7 +62,6 @@ class ParkingSpotAndInspectionTest(TestCase): self.assertIsNotNone(self.spot.geojson) def test_parking_spot_inspection_updates_asset(self): - # Using specialized ParkingSpotInspection model inspection = ParkingSpotInspection.objects.create( asset=self.spot, inspector=self.user, @@ -65,7 +85,6 @@ class ParkingSpotAndInspectionTest(TestCase): self.assertTrue(self.spot.is_compliant) def test_generic_asset_inspection_non_compliance(self): - # Generic inspection works for any asset inspection = AssetInspection.objects.create( asset=self.spot, inspector=self.user, @@ -79,3 +98,35 @@ class ParkingSpotAndInspectionTest(TestCase): self.assertFalse(self.spot.is_compliant) self.assertEqual(self.spot.inspection_status, "non_compliant") self.assertEqual(self.spot.non_compliance_reason, "Arceau cargo tordu suite à un choc de livraison") + + def test_parking_assets_geojson_view(self): + url = reverse('assets:parking_assets_geojson') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data.get("type"), "FeatureCollection") + features = data.get("features", []) + self.assertEqual(len(features), 1) + feat = features[0] + self.assertEqual(feat["properties"]["code"], "SPOT-VELO-01") + self.assertEqual(feat["properties"]["standard_stand_count"], 3) + self.assertEqual(feat["properties"]["cargo_stand_count"], 2) + self.assertEqual(feat["properties"]["total_stand_count"], 5) + self.assertTrue(feat["properties"]["has_scooter_drop_zone"]) + + def test_parking_assets_autocomplete_view(self): + url = reverse('assets:parking_autocomplete_assets') + response = self.client.get(url, {'q': 'Grand Place'}) + self.assertEqual(response.status_code, 200) + data = response.json() + results = data.get("results", []) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["code"], "SPOT-VELO-01") + + def test_parking_thematic_config(self): + from assets.views.core import build_thematic_config + config = build_thematic_config([self.thematic]) + self.assertIn("parking", config) + self.assertEqual(config["parking"]["assets_geojson_url"], reverse("assets:parking_assets_geojson")) + self.assertEqual(config["parking"]["locations_geojson_url"], reverse("assets:parking_locations_geojson")) + diff --git a/streetup/assets/urls.py b/streetup/assets/urls.py index 91a841b..631d153 100644 --- a/streetup/assets/urls.py +++ b/streetup/assets/urls.py @@ -1,10 +1,17 @@ from django.urls import path -from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor +from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking app_name = "assets" urlpatterns = [ path("", core.unified_assets_index, name="index"), + path("parking/", parking.parking_index, name="parking_index"), + path("parking/assets/", parking.parking_assets, name="parking_assets"), + path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"), + path("parking/assets/autocomplete/", parking.parking_assets_autocomplete, name="parking_autocomplete_assets"), + path("parking/locations/geojson/", parking.parking_locations_geojson, name="parking_locations_geojson"), + path("parking///", parking.parking_assets_detail, name="parking_assets_detail"), + path("buildings/", buildings.buildings_index, name="buildings_index"), path("buildings//", buildings.building_detail, name="publicbuilding_detail"), path("buildings//edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"), diff --git a/streetup/assets/views/core.py b/streetup/assets/views/core.py index db76c45..624d420 100644 --- a/streetup/assets/views/core.py +++ b/streetup/assets/views/core.py @@ -229,6 +229,9 @@ def index(request): return redirect('assets:sign_index') elif default_thematic == 'controlcenters': return redirect('assets:controlcenters_index') + elif default_thematic == 'parking': + return redirect('assets:parking_index') + # Add more conditions for other thematics else: return redirect('assets:trafficlights_index') # Fallback URL if no match is found @@ -487,7 +490,12 @@ def build_thematic_config(thematics): 'locations_geojson_url': reverse('assets:outdoor_locations_geojson'), 'assets_geojson_url': reverse('assets:outdoor_assets_geojson'), }, + 'parking': { + 'locations_geojson_url': reverse('assets:parking_locations_geojson'), + 'assets_geojson_url': reverse('assets:parking_assets_geojson'), + }, } + result = {} for thematic in thematics: code = thematic.code @@ -804,9 +812,47 @@ def build_conditional_filter_config(accessible_thematics, user=None): 'options': panel_type_options, }) + # Parking specific filter + if 'parking' in accessible_codes: + config['parking'] = [ + { + 'key': 'spot_type', + 'label': _("Type d'emplacement"), + 'type': 'multi', + 'options': [ + {'value': 'bicycle', 'label': str(_('Stationnement vélo'))}, + {'value': 'cargo_bike', 'label': str(_('Stationnement vélo cargo'))}, + {'value': 'scooter_drop_zone', 'label': str(_('Drop zone trottinettes / EDPM'))}, + {'value': 'delivery', 'label': str(_('Zone de livraison'))}, + {'value': 'pmr', 'label': str(_('Emplacement PMR'))}, + {'value': 'kiss_and_ride', 'label': str(_('Arrêt minute (CD)'))}, + {'value': 'car_parking', 'label': str(_('Stationnement automobile'))}, + ], + }, + { + 'key': 'has_scooter_drop_zone', + 'label': _('Drop zone trottinettes'), + 'type': 'single', + 'options': [ + {'value': 'true', 'label': str(_('Oui'))}, + {'value': 'false', 'label': str(_('Non'))}, + ], + }, + { + 'key': 'is_compliant', + 'label': _('Conformité'), + 'type': 'single', + 'options': [ + {'value': 'true', 'label': str(_('Conforme'))}, + {'value': 'false', 'label': str(_('Non conforme'))}, + ], + }, + ] + return config + def build_unified_add_modal_config(accessible_thematics, user=None): """ Returns the thematic choices allowed for the unified add modal. diff --git a/streetup/assets/views/parking.py b/streetup/assets/views/parking.py new file mode 100644 index 0000000..eab10b3 --- /dev/null +++ b/streetup/assets/views/parking.py @@ -0,0 +1,367 @@ +import json +import unicodedata +from django.shortcuts import render, get_object_or_404, redirect +from django.http import JsonResponse, Http404, HttpResponseForbidden +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.contrib.gis.geos import GEOSGeometry +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils.translation import gettext as _, get_language + +from common.models import Thematic +from common.utils import check_thematic_access +from assets.models.parking import ( + ParkingLocation, ParkingSpot, + SPOT_TYPE_CHOICES, STAND_TYPE_CHOICES, MARKING_QUALITY_CHOICES +) +from assets.views.core import get_asset_edit_context + +MAX_ASSET_FEATURES = 5000 + + +@login_required +def parking_index(request): + """Page d'accueil de la thématique Stationnement & Mobilité.""" + check_thematic_access(request.user, 'parking') + + parking_thematic = Thematic.objects.filter(code='parking').first() + + context = { + 'thematic': parking_thematic, + 'spot_types': SPOT_TYPE_CHOICES, + 'stand_types': STAND_TYPE_CHOICES, + 'marking_qualities': MARKING_QUALITY_CHOICES, + } + return render(request, 'assets/parking_index.html', context) + + +def parking_assets_geojson(request): + """ + Vue GeoJSON renvoyant les emplacements de stationnement (ParkingSpot). + Prend en charge le filtrage par type d'emplacement, type d'arceau, drop zone, statut, conformité, géométrie et recherche texte. + """ + if request.method == "POST": + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + spot_types = data.get('spot_type') or [] + if isinstance(spot_types, str): + spot_types = [spot_types] + stand_types = data.get('stand_type') or [] + if isinstance(stand_types, str): + stand_types = [stand_types] + categories = data.get('categories') or data.get('category') or [] + if isinstance(categories, (str, int)): + categories = [categories] + has_drop_zone = data.get('has_scooter_drop_zone') + is_compliant = data.get('is_compliant') + inspection_status = data.get('inspection_status') + status = data.get('status') + + filter_geom_raw = data.get("filter_geom") + if filter_geom_raw: + try: + filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) + filter_geom.transform(3812) + except Exception as e: + return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) + else: + filter_geom = None + search_q = (data.get('q') or '').strip() + else: + spot_types = request.GET.getlist('spot_type', []) + stand_types = request.GET.getlist('stand_type', []) + categories = request.GET.getlist('category', []) + has_drop_zone = request.GET.get('has_scooter_drop_zone') + is_compliant = request.GET.get('is_compliant') + inspection_status = request.GET.get('inspection_status') + status = request.GET.get('status') + filter_geom = None + search_q = request.GET.get('q', '').strip() + + qs = ParkingSpot.objects.select_related('category', 'location').all() + + if categories: + qs = qs.filter(category_id__in=categories) + if spot_types: + qs = qs.filter(spot_type__in=spot_types) + if stand_types: + qs = qs.filter(stand_type__in=stand_types) + if has_drop_zone in ['true', '1', True]: + qs = qs.filter(has_scooter_drop_zone=True) + elif has_drop_zone in ['false', '0', False]: + qs = qs.filter(has_scooter_drop_zone=False) + if is_compliant in ['true', '1', True]: + qs = qs.filter(is_compliant=True) + elif is_compliant in ['false', '0', False]: + qs = qs.filter(is_compliant=False) + if inspection_status: + qs = qs.filter(inspection_status=inspection_status) + if status: + qs = qs.filter(status=status) + + if filter_geom: + qs = qs.filter(geom__intersects=filter_geom) + + if search_q: + qs = qs.filter( + Q(code__icontains=search_q) | + Q(name_fr__icontains=search_q) | + Q(name_nl__icontains=search_q) | + Q(note__icontains=search_q) + ) + + qs = qs[:MAX_ASSET_FEATURES] + + features = [] + for spot in qs: + geometry = None + if spot.geojson: + try: + geometry = json.loads(spot.geojson) + except Exception: + pass + if not geometry and spot.geom: + try: + geom_wgs84 = spot.geom.clone() + geom_wgs84.transform(4326) + geometry = json.loads(geom_wgs84.json) + except Exception: + pass + + if not geometry: + continue + + detail_url = reverse('assets:parking_assets_detail', kwargs={ + 'asset_model': 'parkingspot', + 'asset_id': spot.id + }) + + category_name = spot.category.get_name() if spot.category else _("Emplacements de stationnement") + category_id = spot.category_id if spot.category else None + lon = getattr(spot, 'lon', None) + + lat = getattr(spot, 'lat', None) + if (lon is None or lat is None) and spot.geom: + try: + centroid = spot.geom.centroid + centroid.transform(4326) + lon = centroid.x + lat = centroid.y + except Exception: + pass + + properties = { + "id": spot.id, + "code": spot.code, + "name_fr": spot.get_name(), + "name_nl": spot.name_nl or spot.name_fr or "", + "lon": lon, + "lat": lat, + "thematic": "parking", + "asset_type": "parkingspot", + "category": category_name, + "category_name": category_name, + "category_id": category_id, + "spot_type": spot.spot_type, + "spot_type_display": spot.get_spot_type_display(), + "standard_stand_count": spot.standard_stand_count, + "cargo_stand_count": spot.cargo_stand_count, + "total_stand_count": spot.total_stand_count, + "stand_type": spot.stand_type, + "stand_type_display": spot.get_stand_type_display(), + "has_scooter_drop_zone": spot.has_scooter_drop_zone, + "scooter_capacity": spot.scooter_capacity, + "capacity": spot.capacity, + "marking_quality": spot.marking_quality, + "is_compliant": spot.is_compliant, + "inspection_status": spot.inspection_status, + "funding_program": spot.funding_program, + "status": spot.status, + + "status_display": spot.get_status_display(), + "last_inspection_date": spot.last_inspection_date.isoformat() if spot.last_inspection_date else None, + "detail_url": detail_url, + "asset_model": "parkingspot", + "element_type": "asset" + } + + features.append({ + "type": "Feature", + "id": spot.id, + "geometry": geometry, + "properties": properties + }) + + return JsonResponse({ + "type": "FeatureCollection", + "features": features + }) + + +def parking_locations_geojson(request): + """ + Vue GeoJSON renvoyant les zones/secteurs de stationnement (ParkingLocation). + """ + qs = ParkingLocation.objects.all() + search_q = request.GET.get('q', '').strip() + if search_q: + qs = qs.filter( + Q(code__icontains=search_q) | + Q(name_fr__icontains=search_q) | + Q(name_nl__icontains=search_q) | + Q(address__icontains=search_q) + ) + + features = [] + for loc in qs: + geometry = None + if loc.geojson: + try: + geometry = json.loads(loc.geojson) + except Exception: + pass + if not geometry and loc.geom: + try: + geom_wgs84 = loc.geom.clone() + geom_wgs84.transform(4326) + geometry = json.loads(geom_wgs84.json) + except Exception: + pass + + if not geometry: + continue + + loc_lon = getattr(loc, 'lon', None) + loc_lat = getattr(loc, 'lat', None) + if (loc_lon is None or loc_lat is None) and loc.geom: + try: + centroid = loc.geom.centroid + centroid.transform(4326) + loc_lon = centroid.x + loc_lat = centroid.y + except Exception: + pass + + features.append({ + "type": "Feature", + "id": loc.id, + "geometry": geometry, + "properties": { + "id": loc.id, + "code": loc.code, + "name_fr": loc.get_name(), + "address": loc.address or "", + "lon": loc_lon, + "lat": loc_lat, + "thematic": "parking", + "location_type": "parkinglocation", + "element_type": "location" + } + }) + + + return JsonResponse({ + + "type": "FeatureCollection", + "features": features + }) + + +def parking_assets_autocomplete(request): + """ + API d'autocomplétion et de recherche pour les emplacements de stationnement. + """ + raw_query = (request.GET.get('q') or '').strip() + qn = unicodedata.normalize('NFD', raw_query).encode('ascii', 'ignore').decode('ascii').lower() + + qs = ParkingSpot.objects.filter(status='active') + + if qn: + qs = qs.filter( + Q(code__icontains=qn) | + Q(name_fr__icontains=qn) | + Q(name_nl__icontains=qn) | + Q(note__icontains=qn) + ) + + qs = qs.order_by('code')[:20] + + results = [] + for spot in qs: + results.append({ + 'id': spot.id, + 'code': spot.code, + 'name': spot.get_name(), + 'spot_type': spot.spot_type, + 'spot_type_display': spot.get_spot_type_display(), + 'total_stand_count': spot.total_stand_count, + 'has_scooter_drop_zone': spot.has_scooter_drop_zone, + 'inspection_status': spot.inspection_status, + 'detail_url': reverse('assets:parking_assets_detail', kwargs={'asset_model': 'parkingspot', 'asset_id': spot.id}) + }) + + return JsonResponse({'results': results}) + + +@login_required +def parking_assets(request): + """Vue de la liste et recherche des emplacements de stationnement.""" + check_thematic_access(request.user, 'parking') + + spots = ParkingSpot.objects.all().select_related('location', 'street') + search_q = request.GET.get('q', '').strip() + if search_q: + spots = spots.filter( + Q(code__icontains=search_q) | + Q(name_fr__icontains=search_q) | + Q(name_nl__icontains=search_q) | + Q(note__icontains=search_q) + ) + + context = { + 'spots': spots, + 'spot_types': SPOT_TYPE_CHOICES, + 'search_q': search_q, + } + return render(request, 'assets/parking_assets.html', context) + + +@login_required +def parking_assets_detail(request, asset_model, asset_id): + """Vue de détail pour un emplacement de stationnement.""" + check_thematic_access(request.user, 'parking') + + spot = get_object_or_404(ParkingSpot, id=asset_id) + content_type = ContentType.objects.get_for_model(ParkingSpot) + + from assets.models.inspection import AssetInspection + inspections = AssetInspection.objects.filter( + asset_content_type=content_type, + asset_object_id=spot.id + ).order_by('-inspection_date') + + from interventions.models import Intervention + from interventions.permissions import filter_viewable_interventions_for_user + base_interventions = filter_viewable_interventions_for_user(request.user) + interventions = base_interventions.filter( + interventionasset__content_type=content_type, + interventionasset__object_id=spot.id + ).distinct().order_by('-creation_time') + + + context = { + 'asset': spot, + 'spot': spot, + 'content_type': content_type, + 'inspections': inspections, + 'interventions': interventions, + } + context.update(get_asset_edit_context(request, spot, content_type)) + + return render(request, "assets/detail_generic.html", context) + + diff --git a/streetup/common/static/common/base_map.js b/streetup/common/static/common/base_map.js index d4577fa..0faf993 100644 --- a/streetup/common/static/common/base_map.js +++ b/streetup/common/static/common/base_map.js @@ -278,24 +278,38 @@ function registerZoomAction(tableSelector, buttonClass, map, zoomLevel = 16) { * @param {object} highlightState - Object to track the currently highlighted feature. */ function highlightMapFeature(map, sourceId, featureId, highlightState) { + if (!highlightState || !map) return; // Remove highlight from the previous feature - if (highlightState.previousId !== null) { - map.setFeatureState( - { source: sourceId, id: highlightState.previousId }, - { highlighted: false } - ); + if (highlightState.previousId !== null && highlightState.previousId !== undefined) { + try { + map.setFeatureState( + { source: sourceId, id: highlightState.previousId }, + { highlighted: false } + ); + } catch (e) { + // Ignore feature state errors if layer/source is not present + } } // Set highlight on the newly selected feature - highlightState.previousId = featureId; - map.setFeatureState( - { source: sourceId, id: featureId }, - { highlighted: true } - ); + if (featureId !== null && featureId !== undefined) { + highlightState.previousId = featureId; + try { + map.setFeatureState( + { source: sourceId, id: featureId }, + { highlighted: true } + ); + } catch (e) { + // Ignore feature state errors if layer/source is not present + } + } else { + highlightState.previousId = null; + } } + function reverseGeoJson(data) { if (data.type === 'FeatureCollection' && Array.isArray(data.features)) { return {