feat: add parking thematic with dedicated management views, models, and configurable funding program field
This commit is contained in:
parent
dad5acc4fb
commit
c74e96bf29
12 changed files with 913 additions and 26 deletions
74
streetup/assets/migrations/0010_add_parking_thematic.py
Normal file
74
streetup/assets/migrations/0010_add_parking_thematic.py
Normal 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),
|
||||||
|
]
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from django.contrib.gis.db import models
|
from django.contrib.gis.db import models
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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 django.utils.translation import get_language, gettext_lazy as _
|
||||||
|
|
||||||
from common.models import Thematic
|
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")
|
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")
|
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")
|
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_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")
|
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):
|
def get_replacement_chain(self):
|
||||||
"""Get the complete replacement chain starting from this asset."""
|
"""Get the complete replacement chain starting from this asset."""
|
||||||
chain = [self]
|
chain = [self]
|
||||||
|
|
|
||||||
|
|
@ -16,36 +16,37 @@ ASSET_EDITABLE_FIELDS_BY_ROLE = {
|
||||||
'admin': [
|
'admin': [
|
||||||
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
||||||
'serial_number', 'installation_date', 'brand', 'warranty_duration',
|
'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',
|
'location_id', 'intersection_id',
|
||||||
],
|
],
|
||||||
'manager': [
|
'manager': [
|
||||||
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
||||||
'serial_number', 'installation_date', 'brand', 'warranty_duration',
|
'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',
|
'location_id', 'intersection_id',
|
||||||
],
|
],
|
||||||
'controller': [
|
'controller': [
|
||||||
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
||||||
'serial_number', 'installation_date', 'brand', 'warranty_duration',
|
'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',
|
'location_id', 'intersection_id',
|
||||||
],
|
],
|
||||||
'external_manager': [
|
'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': [],
|
'operator': [],
|
||||||
'observer': [],
|
'observer': [],
|
||||||
'viewer': [],
|
'viewer': [],
|
||||||
'editor': [
|
'editor': [
|
||||||
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
'code', 'name_fr', 'name_nl', 'status', 'model', 'category',
|
||||||
'serial_number', 'installation_date', 'brand', 'warranty_duration',
|
'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',
|
'location_id', 'intersection_id',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Actions allowed by role for assets
|
# Actions allowed by role for assets
|
||||||
ASSET_ACTIONS_BY_ROLE = {
|
ASSET_ACTIONS_BY_ROLE = {
|
||||||
'admin': ['edit', 'create', 'archive', 'replace', 'bulk_archive'],
|
'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.
|
Check if user has access to view/edit assets for a given thematic.
|
||||||
Returns a tuple (can_view, can_edit).
|
Returns a tuple (can_view, can_edit).
|
||||||
"""
|
"""
|
||||||
|
if getattr(user, 'is_superuser', False):
|
||||||
|
return True, True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_config = UserConfig.objects.get(user=user)
|
user_config = UserConfig.objects.get(user=user)
|
||||||
except UserConfig.DoesNotExist:
|
except UserConfig.DoesNotExist:
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
|
if user_config.roles.filter(name__in=['admin', 'operator']).exists():
|
||||||
|
return True, True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_thematic = UserThematics.objects.get(
|
user_thematic = UserThematics.objects.get(
|
||||||
user_config=user_config,
|
user_config=user_config,
|
||||||
|
|
@ -80,6 +87,7 @@ def get_user_asset_thematic_access(user, thematic):
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _check_instance_permission(user, obj, permission_field, thematic_fallback_fn=None):
|
def _check_instance_permission(user, obj, permission_field, thematic_fallback_fn=None):
|
||||||
"""
|
"""
|
||||||
Shared logic for per-instance permission checks (assets and locations).
|
Shared logic for per-instance permission checks (assets and locations).
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ var locationsFetchSignatures = {};
|
||||||
var assetsFetchSignatures = {};
|
var assetsFetchSignatures = {};
|
||||||
|
|
||||||
// Assets: thematics that always load all assets (small datasets)
|
// 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
|
const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers
|
||||||
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'
|
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'
|
||||||
|
|
||||||
|
|
@ -1795,6 +1796,7 @@ function collectClickedAssets(clickEvent) {
|
||||||
const layers = [
|
const layers = [
|
||||||
'assets',
|
'assets',
|
||||||
'assets-fill',
|
'assets-fill',
|
||||||
|
'assets-polygon-outline',
|
||||||
'assets-line',
|
'assets-line',
|
||||||
'assets-point',
|
'assets-point',
|
||||||
'sign-panels-circles',
|
'sign-panels-circles',
|
||||||
|
|
@ -1803,6 +1805,7 @@ function collectClickedAssets(clickEvent) {
|
||||||
'sign-poles',
|
'sign-poles',
|
||||||
'sign-poles-vertical',
|
'sign-poles-vertical',
|
||||||
].filter(layerId => map.getLayer(layerId));
|
].filter(layerId => map.getLayer(layerId));
|
||||||
|
|
||||||
if (!layers.length) return [];
|
if (!layers.length) return [];
|
||||||
|
|
||||||
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
|
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
|
||||||
|
|
@ -2374,8 +2377,9 @@ function loadTableData(features) {
|
||||||
|
|
||||||
// ─── Map integration ──────────────────────────────────────────────────────────
|
// ─── Map integration ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var locationshighlightState = {};
|
var locationshighlightState = { previousId: null };
|
||||||
var assetshighlightState = {};
|
var assetshighlightState = { previousId: null };
|
||||||
|
|
||||||
|
|
||||||
function isPanelTextSpriteId(missingId) {
|
function isPanelTextSpriteId(missingId) {
|
||||||
if (!missingId) return false;
|
if (!missingId) return false;
|
||||||
|
|
|
||||||
|
|
@ -216,8 +216,22 @@ function loadUnifiedMapIcons(map) {
|
||||||
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
|
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
|
||||||
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
|
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
|
||||||
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
|
{ 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 }) => {
|
GLYPH_ICONS.forEach(({ name, codepoint, color }) => {
|
||||||
// Skip if a file-based icon URL is being loaded for this name (async race condition)
|
// Skip if a file-based icon URL is being loaded for this name (async race condition)
|
||||||
if (FILE_ICON_NAMES.has(name)) return;
|
if (FILE_ICON_NAMES.has(name)) return;
|
||||||
|
|
@ -293,7 +307,9 @@ const THEMATIC_COLOR_EXPR = [
|
||||||
'controlcenters', '#7b1fa2', // purple — control centres
|
'controlcenters', '#7b1fa2', // purple — control centres
|
||||||
'publicbuildings', '#2B579A', // navy blue — public buildings
|
'publicbuildings', '#2B579A', // navy blue — public buildings
|
||||||
'buildings', '#2B579A', // navy blue — public buildings
|
'buildings', '#2B579A', // navy blue — public buildings
|
||||||
|
'parking', '#0d6efd', // blue — parking & mobility
|
||||||
'roads', [
|
'roads', [
|
||||||
|
|
||||||
'match', ['get', 'asset_type'],
|
'match', ['get', 'asset_type'],
|
||||||
'roadway', '#4a4a4a',
|
'roadway', '#4a4a4a',
|
||||||
'sidewalk', '#979898ff',
|
'sidewalk', '#979898ff',
|
||||||
|
|
@ -339,9 +355,22 @@ const _unified_assets_fill = {
|
||||||
],
|
],
|
||||||
['==', ['get', 'thematic'], 'nature'],
|
['==', ['get', 'thematic'], 'nature'],
|
||||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
['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
|
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-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
||||||
'fill-outline-color': [
|
'fill-outline-color': [
|
||||||
'case',
|
'case',
|
||||||
|
|
@ -503,6 +532,16 @@ const _unified_assets_symbol = {
|
||||||
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
|
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
|
||||||
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
|
||||||
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
|
['==', ['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
|
// fallback: match by thematic
|
||||||
[
|
[
|
||||||
'match', ['get', 'thematic'],
|
'match', ['get', 'thematic'],
|
||||||
|
|
@ -518,9 +557,11 @@ const _unified_assets_symbol = {
|
||||||
'controlcenters', 'icon-controlcenters',
|
'controlcenters', 'icon-controlcenters',
|
||||||
'publicbuildings', 'icon-publicbuildings',
|
'publicbuildings', 'icon-publicbuildings',
|
||||||
'publicbuilding', 'icon-publicbuilding',
|
'publicbuilding', 'icon-publicbuilding',
|
||||||
|
'parking', 'icon-parking',
|
||||||
'roads', 'icon-roads',
|
'roads', 'icon-roads',
|
||||||
'icon-roads' // fallback
|
'icon-roads' // fallback
|
||||||
]
|
]
|
||||||
|
|
||||||
],
|
],
|
||||||
'icon-size': [
|
'icon-size': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
|
@ -801,6 +842,39 @@ const _unified_assets_point = {
|
||||||
minzoom: 8,
|
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 ───────────────────────────────────────────────────────
|
// ─── Ordered layer list ───────────────────────────────────────────────────────
|
||||||
// MapLibre renders layers in insertion order, so the array below defines the
|
// 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.
|
// 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_assets_fill, // 2. polygon fills
|
||||||
_unified_locations_outline, // 3. lines
|
_unified_locations_outline, // 3. lines
|
||||||
_unified_assets_line, // 3b. asset lines
|
_unified_assets_line, // 3b. asset lines
|
||||||
|
_unified_assets_polygon_outline, // 3c. polygon asset outlines
|
||||||
_unified_locations_circle, // 4. circles
|
_unified_locations_circle, // 4. circles
|
||||||
_unified_locations_radar_outline,
|
_unified_locations_radar_outline,
|
||||||
_unified_locations_radar,
|
_unified_locations_radar,
|
||||||
|
|
@ -820,3 +895,4 @@ const UNIFIED_LAYERS = [
|
||||||
_unified_sign_poles_vertical,
|
_unified_sign_poles_vertical,
|
||||||
_unified_sign_panels_symbols,
|
_unified_sign_panels_symbols,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,21 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 -->
|
<!-- 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 %}">
|
<tr class="asset-editable-field" data-field="warranty_duration" data-can-edit="{% if 'warranty_duration' in allowed_update_fields %}true{% else %}false{% endif %}">
|
||||||
<th>{% translate "Durée de garantie" %}</th>
|
<th>{% translate "Durée de garantie" %}</th>
|
||||||
|
|
|
||||||
|
|
@ -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.auth.models import User
|
||||||
from django.contrib.gis.geos import Polygon, MultiPolygon
|
from django.contrib.gis.geos import Polygon, MultiPolygon
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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):
|
class ParkingSpotAndInspectionTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="inspector_user", password="password123")
|
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)
|
poly = Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812)
|
||||||
multi_poly = MultiPolygon(poly, srid=3812)
|
multi_poly = MultiPolygon(poly, srid=3812)
|
||||||
|
|
@ -41,7 +62,6 @@ class ParkingSpotAndInspectionTest(TestCase):
|
||||||
self.assertIsNotNone(self.spot.geojson)
|
self.assertIsNotNone(self.spot.geojson)
|
||||||
|
|
||||||
def test_parking_spot_inspection_updates_asset(self):
|
def test_parking_spot_inspection_updates_asset(self):
|
||||||
# Using specialized ParkingSpotInspection model
|
|
||||||
inspection = ParkingSpotInspection.objects.create(
|
inspection = ParkingSpotInspection.objects.create(
|
||||||
asset=self.spot,
|
asset=self.spot,
|
||||||
inspector=self.user,
|
inspector=self.user,
|
||||||
|
|
@ -65,7 +85,6 @@ class ParkingSpotAndInspectionTest(TestCase):
|
||||||
self.assertTrue(self.spot.is_compliant)
|
self.assertTrue(self.spot.is_compliant)
|
||||||
|
|
||||||
def test_generic_asset_inspection_non_compliance(self):
|
def test_generic_asset_inspection_non_compliance(self):
|
||||||
# Generic inspection works for any asset
|
|
||||||
inspection = AssetInspection.objects.create(
|
inspection = AssetInspection.objects.create(
|
||||||
asset=self.spot,
|
asset=self.spot,
|
||||||
inspector=self.user,
|
inspector=self.user,
|
||||||
|
|
@ -79,3 +98,35 @@ class ParkingSpotAndInspectionTest(TestCase):
|
||||||
self.assertFalse(self.spot.is_compliant)
|
self.assertFalse(self.spot.is_compliant)
|
||||||
self.assertEqual(self.spot.inspection_status, "non_compliant")
|
self.assertEqual(self.spot.inspection_status, "non_compliant")
|
||||||
self.assertEqual(self.spot.non_compliance_reason, "Arceau cargo tordu suite à un choc de livraison")
|
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"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
from django.urls import path
|
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"
|
app_name = "assets"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", core.unified_assets_index, name="index"),
|
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/", buildings.buildings_index, name="buildings_index"),
|
||||||
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
|
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
|
||||||
path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"),
|
path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"),
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,9 @@ def index(request):
|
||||||
return redirect('assets:sign_index')
|
return redirect('assets:sign_index')
|
||||||
elif default_thematic == 'controlcenters':
|
elif default_thematic == 'controlcenters':
|
||||||
return redirect('assets:controlcenters_index')
|
return redirect('assets:controlcenters_index')
|
||||||
|
elif default_thematic == 'parking':
|
||||||
|
return redirect('assets:parking_index')
|
||||||
|
|
||||||
# Add more conditions for other thematics
|
# Add more conditions for other thematics
|
||||||
else:
|
else:
|
||||||
return redirect('assets:trafficlights_index') # Fallback URL if no match is found
|
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'),
|
'locations_geojson_url': reverse('assets:outdoor_locations_geojson'),
|
||||||
'assets_geojson_url': reverse('assets:outdoor_assets_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 = {}
|
result = {}
|
||||||
for thematic in thematics:
|
for thematic in thematics:
|
||||||
code = thematic.code
|
code = thematic.code
|
||||||
|
|
@ -804,9 +812,47 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
'options': panel_type_options,
|
'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
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def build_unified_add_modal_config(accessible_thematics, user=None):
|
def build_unified_add_modal_config(accessible_thematics, user=None):
|
||||||
"""
|
"""
|
||||||
Returns the thematic choices allowed for the unified add modal.
|
Returns the thematic choices allowed for the unified add modal.
|
||||||
|
|
|
||||||
367
streetup/assets/views/parking.py
Normal file
367
streetup/assets/views/parking.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -278,24 +278,38 @@ function registerZoomAction(tableSelector, buttonClass, map, zoomLevel = 16) {
|
||||||
* @param {object} highlightState - Object to track the currently highlighted feature.
|
* @param {object} highlightState - Object to track the currently highlighted feature.
|
||||||
*/
|
*/
|
||||||
function highlightMapFeature(map, sourceId, featureId, highlightState) {
|
function highlightMapFeature(map, sourceId, featureId, highlightState) {
|
||||||
|
if (!highlightState || !map) return;
|
||||||
// Remove highlight from the previous feature
|
// Remove highlight from the previous feature
|
||||||
if (highlightState.previousId !== null) {
|
if (highlightState.previousId !== null && highlightState.previousId !== undefined) {
|
||||||
map.setFeatureState(
|
try {
|
||||||
{ source: sourceId, id: highlightState.previousId },
|
map.setFeatureState(
|
||||||
{ highlighted: false }
|
{ 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
|
// Set highlight on the newly selected feature
|
||||||
highlightState.previousId = featureId;
|
if (featureId !== null && featureId !== undefined) {
|
||||||
map.setFeatureState(
|
highlightState.previousId = featureId;
|
||||||
{ source: sourceId, id: featureId },
|
try {
|
||||||
{ highlighted: true }
|
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) {
|
function reverseGeoJson(data) {
|
||||||
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
|
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue