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 %} +