feat: add parking thematic with dedicated management views, models, and configurable funding program field

This commit is contained in:
kdeterme 2026-08-07 11:30:00 +02:00
parent dad5acc4fb
commit c74e96bf29
12 changed files with 913 additions and 26 deletions

View file

@ -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),
]

View file

@ -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'),
),
]

View file

@ -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]

View file

@ -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).

View file

@ -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;

View file

@ -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,
];

View file

@ -359,6 +359,21 @@
</div>
</td>
</tr>
<!-- Programme de financement / Projet -->
{% if asset.funding_program or 'funding_program' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="funding_program" data-can-edit="{% if 'funding_program' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Programme / Financement" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.funding_program|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.funding_program|default:'' }}">
</div>
</td>
</tr>
{% endif %}
<!-- Durée de garantie -->
<tr class="asset-editable-field" data-field="warranty_duration" data-can-edit="{% if 'warranty_duration' in allowed_update_fields %}true{% else %}false{% endif %}">

View file

@ -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"))

View file

@ -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/<str:asset_model>/<int:asset_id>/", parking.parking_assets_detail, name="parking_assets_detail"),
path("buildings/", buildings.buildings_index, name="buildings_index"),
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"),

View file

@ -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.

View file

@ -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)

View file

@ -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 {