Compare commits

..

2 commits

15 changed files with 1006 additions and 103 deletions

View file

@ -451,7 +451,10 @@ admin.site.register(ComplementaryPoliceRegulation, AssetAdmin)
admin.site.register(SignParkingSpot, AssetAdmin)
from .models import ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract
from .models import (
ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract,
ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
)
admin.site.register(ITSAssetModel, AssetAdmin)
@ -468,6 +471,44 @@ class ITSGeoAssetContractAdmin(AssetAdmin):
list_filter = ("contract",)
class ITSBollardPlotInline(admin.TabularInline):
model = ITSBollardPlot
extra = 0
fields = ("code", "position", "bollard_type", "model", "serial_number", "status")
show_change_link = True
class ITSBollardSystemAssetInline(admin.TabularInline):
model = ITSBollardSystemAsset
extra = 0
fields = ("asset", "role", "comment")
autocomplete_fields = ("asset",)
show_change_link = True
@admin.register(ITSBollardSystem)
class ITSBollardSystemAdmin(AssetGISAdmin, AssetAdmin):
search_fields = ("code", "name_fr", "name_nl", "location__code", "location__name_fr")
list_display = ("code", "name_fr", "location", "width", "direction", "status", "plots_count")
list_filter = ("status", "direction", "location")
inlines = [ITSBollardPlotInline, ITSBollardSystemAssetInline]
@admin.register(ITSBollardPlot)
class ITSBollardPlotAdmin(AssetGISAdmin, AssetAdmin):
search_fields = ("code", "name_fr", "system__code", "serial_number")
list_display = ("code", "system", "position", "bollard_type", "model", "status")
list_filter = ("bollard_type", "status", "system")
@admin.register(ITSBollardSystemContract)
class ITSBollardSystemContractAdmin(AssetAdmin):
search_fields = ("system__code", "contract__contract_number")
list_display = ("system", "contract", "status", "maintenance_type")
list_filter = ("status", "maintenance_type", "contract")
from .models import ITSLocation, ITSLocationDocument, ITSLocationContract
admin.site.register(ITSLocation, AssetAdmin)

View file

@ -0,0 +1,160 @@
# Generated by Django 6.0.7 on 2026-10-08 10:19
import assets.models.core
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
('contracts', '0014_contract_color_contract_geom'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='itsgeoasset',
name='extra_data',
field=models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques'),
),
migrations.CreateModel(
name='ITSBollardSystem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.LineStringField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Ligne)')),
('width', models.FloatField(blank=True, null=True, verbose_name='Largeur de passage (m)')),
('direction', models.CharField(blank=True, choices=[('unidirectional', 'Sens unique'), ('bidirectional', 'Double sens')], default='bidirectional', max_length=50, null=True, verbose_name='Sens de circulation')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_systems', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de système')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardsystem', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Système de bornes',
'verbose_name_plural': 'Systèmes de bornes',
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.CreateModel(
name='ITSBollardSystemAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('camera_entry', 'Caméra entrée (LAPI)'), ('camera_exit', 'Caméra sortie (LAPI)'), ('camera_cctv', 'Caméra de contexte / surveillance'), ('cabinet', 'Armoire électrique / automate'), ('totem', 'Totem de commande'), ('traffic_light', 'Feu de franchissement'), ('loop', 'Boucle de détection'), ('other', 'Autre')], default='other', max_length=50, verbose_name="Rôle de l'équipement")),
('comment', models.CharField(blank=True, max_length=255, null=True, verbose_name='Commentaire')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name="Date d'association")),
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_system_links', to='assets.itsgeoasset', verbose_name='Équipement ITS')),
('bollard_system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='system_assets', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Équipement associé au système de bornes',
'verbose_name_plural': 'Équipements associés au système de bornes',
'unique_together': {('bollard_system', 'asset')},
},
),
migrations.AddField(
model_name='itsbollardsystem',
name='associated_assets',
field=models.ManyToManyField(blank=True, related_name='bollard_systems', through='assets.ITSBollardSystemAsset', to='assets.itsgeoasset', verbose_name='Équipements associés'),
),
migrations.CreateModel(
name='ITSBollardSystemContract',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active', max_length=50)),
('maintenance_type', models.CharField(choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other', max_length=50)),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='contracts.contract')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='assets.itsbollardsystem')),
],
options={
'verbose_name': 'Contrat de système de bornes',
'verbose_name_plural': 'Contrats de systèmes de bornes',
},
),
migrations.CreateModel(
name='ITSBollardPlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Point)')),
('position', models.PositiveSmallIntegerField(default=1, help_text='Ordre de gauche à droite (1, 2, 3...)', verbose_name='Position sur la traverse')),
('bollard_type', models.CharField(choices=[('automatic', 'Automatique / Escamotable'), ('semi_automatic', 'Semi-automatique'), ('fixed', 'Fixe'), ('removable', 'Amovible')], default='automatic', max_length=50, verbose_name='Type de plot')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_plots', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de plot')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardplot', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plots', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Plot de borne',
'verbose_name_plural': 'Plots de bornes',
'ordering': ['system', 'position'],
'indexes': [models.Index(fields=['code'], name='assets_itsb_code_de6000_idx')],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.AddIndex(
model_name='itsbollardsystem',
index=models.Index(fields=['code'], name='assets_itsb_code_a4c148_idx'),
),
migrations.AlterUniqueTogether(
name='itsbollardsystemcontract',
unique_together={('system', 'contract')},
),
]

View file

@ -8,7 +8,7 @@ from .structures import Structure, StructureLocation, StructureGeoAsset, Structu
from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument
from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract, ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument

View file

@ -5,7 +5,7 @@
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import get_language
from django.utils.translation import get_language, gettext_lazy as _
import os
@ -91,6 +91,7 @@ class ITSGeoAsset(AbstractGeoAsset):
location = models.ForeignKey(ITSLocation, related_name="assets", on_delete=models.SET_NULL, null=True, blank=True)
trafficlights_pole = models.ForeignKey("assets.TrafficLightPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Traffic Light Pole")
lighting_pole = models.ForeignKey("assets.PublicLightingPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Lighting Pole")
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
def clean(self):
super().clean()
@ -117,6 +118,147 @@ class ITSGeoAsset(AbstractGeoAsset):
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsgeoasset', 'asset_id':self.id})
BOLLARD_DIRECTION_CHOICES = (
('unidirectional', _('Sens unique')),
('bidirectional', _('Double sens')),
)
class ITSBollardSystem(AbstractGeoAsset):
"""
Système / Traverse de bornes escamotables (représentation linéaire).
Contrôle l'accès à une voie ou zone piétonne.
"""
geom = models.LineStringField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Ligne)"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de système"))
location = models.ForeignKey(ITSLocation, related_name="bollard_systems", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
width = models.FloatField(null=True, blank=True, verbose_name=_("Largeur de passage (m)"))
direction = models.CharField(max_length=50, choices=BOLLARD_DIRECTION_CHOICES, default='bidirectional', null=True, blank=True, verbose_name=_("Sens de circulation"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
associated_assets = models.ManyToManyField(
'assets.ITSGeoAsset',
through='assets.ITSBollardSystemAsset',
related_name="bollard_systems",
blank=True,
verbose_name=_("Équipements associés")
)
class Meta:
verbose_name = _("Système de bornes")
verbose_name_plural = _("Systèmes de bornes")
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardsystem', 'asset_id': self.id})
@property
def plots_count(self):
return self.plots.count()
BOLLARD_TYPE_CHOICES = (
('automatic', _('Automatique / Escamotable')),
('semi_automatic', _('Semi-automatique')),
('fixed', _('Fixe')),
('removable', _('Amovible')),
)
class ITSBollardPlot(AbstractGeoAsset):
"""
Plot / borne physique individuel sur un système de bornes.
"""
geom = models.PointField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Point)"))
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="plots", verbose_name=_("Système de bornes"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de plot"))
position = models.PositiveSmallIntegerField(default=1, verbose_name=_("Position sur la traverse"), help_text=_("Ordre de gauche à droite (1, 2, 3...)"))
bollard_type = models.CharField(max_length=50, choices=BOLLARD_TYPE_CHOICES, default='automatic', verbose_name=_("Type de plot"))
location = models.ForeignKey(ITSLocation, related_name="bollard_plots", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
def clean(self):
super().clean()
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
def save(self, *args, **kwargs):
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Plot de borne")
verbose_name_plural = _("Plots de bornes")
ordering = ["system", "position"]
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardplot', 'asset_id': self.id})
class ITSBollardSystemAsset(models.Model):
"""
Table de liaison ManyToMany entre un ITSBollardSystem et des ITSGeoAsset
(caméras LAPI, totems, armoires électriques, feux bicolores, boucles...).
"""
class AssetRole(models.TextChoices):
CAMERA_ENTRY = "camera_entry", _("Caméra entrée (LAPI)")
CAMERA_EXIT = "camera_exit", _("Caméra sortie (LAPI)")
CAMERA_CCTV = "camera_cctv", _("Caméra de contexte / surveillance")
CABINET = "cabinet", _("Armoire électrique / automate")
TOTEM = "totem", _("Totem de commande")
TRAFFIC_LIGHT = "traffic_light", _("Feu de franchissement")
LOOP = "loop", _("Boucle de détection")
OTHER = "other", _("Autre")
bollard_system = models.ForeignKey(
ITSBollardSystem,
on_delete=models.CASCADE,
related_name="system_assets",
verbose_name=_("Système de bornes")
)
asset = models.ForeignKey(
ITSGeoAsset,
on_delete=models.CASCADE,
related_name="bollard_system_links",
verbose_name=_("Équipement ITS")
)
role = models.CharField(
max_length=50,
choices=AssetRole.choices,
default=AssetRole.OTHER,
verbose_name=_("Rôle de l'équipement")
)
comment = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Commentaire"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'association"))
class Meta:
verbose_name = _("Équipement associé au système de bornes")
verbose_name_plural = _("Équipements associés au système de bornes")
unique_together = ('bollard_system', 'asset')
def __str__(self):
return f"{self.bollard_system.code} - {self.asset.code} ({self.get_role_display()})"
class ITSBollardSystemContract(models.Model):
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="bollard_systems_contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="bollard_systems_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other')
def __str__(self):
return f"{self.system.code} - {self.contract.contract_number}"
class Meta:
verbose_name = _("Contrat de système de bornes")
verbose_name_plural = _("Contrats de systèmes de bornes")
unique_together = ('system', 'contract')

View file

@ -427,6 +427,33 @@
glyphCodepoint: 'f82b',
mapImage: 'icon-sign',
},
// ITS
itsgeoasset: {
thematic: 'its',
color: '#0288d1',
iconUrl: null,
iconClass: 'bi-broadcast-pin',
glyphCodepoint: 'f1d5',
mapImage: 'icon-its',
},
itsbollardsystem: {
thematic: 'its',
color: '#0288d1',
iconUrl: null,
iconClass: 'bi-border-width',
glyphCodepoint: 'f1ca',
mapImage: 'icon-itsbollardsystem',
},
itsbollardplot: {
thematic: 'its',
color: '#0288d1',
iconUrl: '/static/assets/its_bollard_plot_icon.svg?v=20261008',
iconSize: 42,
iconClass: 'bi-cone-striped',
glyphCodepoint: 'f2aa',
mapImage: 'icon-itsbollardplot',
},
};
// ─── 3. Specific Parking Spot Overrides by spot_type ─────────────────────────
@ -633,6 +660,7 @@
natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl),
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
itsbollardplot: injected.itsbollardplot || (ASSET_TYPE_OVERRIDES.itsbollardplot && ASSET_TYPE_OVERRIDES.itsbollardplot.iconUrl),
};
return urls;
}
@ -656,6 +684,9 @@
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-itsgeoasset', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-itsbollardsystem', codepoint: 'f1ca', color: '#0288d1' },
{ name: 'icon-itsbollardplot', codepoint: 'f2aa', color: '#0288d1' },
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
@ -749,6 +780,9 @@
['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
['==', ['get', 'asset_type'], 'itsbollardplot'], 'icon-itsbollardplot',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 'icon-itsbollardsystem',
['==', ['get', 'asset_type'], 'itsgeoasset'], 'icon-its',
['==', ['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',
@ -924,14 +958,20 @@
'#0288d1'
],
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['==', ['get', 'asset_type'], 'itsbollardsystem'], '#0288d1',
['match', ['get', 'asset_type'], 'roadway', '#334155', 'sidewalk', '#64748b', getThematicColorExpr()]
],
'line-width': [
'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 6,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 5,
3.5
],
'line-opacity': 0.88,
'line-opacity': [
'case',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0.95,
0.88
],
},
minzoom: 8,
};

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<ellipse cx="32" cy="54" rx="22" ry="5" fill="#1e293b" opacity="0.3"/>
<ellipse cx="32" cy="52" rx="18" ry="4" fill="#334155"/>
<ellipse cx="32" cy="51" rx="16" ry="3.5" fill="#64748b"/>
<ellipse cx="32" cy="50.5" rx="13" ry="2.8" fill="#1e293b"/>
<defs>
<linearGradient id="bollardGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0284c7"/>
<stop offset="25%" stop-color="#38bdf8"/>
<stop offset="60%" stop-color="#0284c7"/>
<stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
<linearGradient id="capGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#7dd3fc"/>
<stop offset="100%" stop-color="#0284c7"/>
</linearGradient>
<linearGradient id="bandGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f59e0b"/>
<stop offset="35%" stop-color="#fef08a"/>
<stop offset="70%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
</defs>
<path d="M 21,24 L 21,50 C 21,53 43,53 43,50 L 43,24 Z" fill="url(#bollardGrad)"/>
<path d="M 21,21 L 21,27 C 21,29.5 43,29.5 43,27 L 43,21 C 43,23.5 21,23.5 21,21 Z" fill="url(#bandGrad)"/>
<path d="M 21,15 L 21,21 C 21,23.5 43,23.5 43,21 L 43,15 Z" fill="url(#bollardGrad)"/>
<ellipse cx="32" cy="15" rx="11" ry="3.5" fill="url(#capGrad)" stroke="#bae6fd" stroke-width="0.8"/>
<ellipse cx="32" cy="14.2" rx="7" ry="2" fill="#ffffff" opacity="0.6"/>
<circle cx="26" cy="24.5" r="1" fill="#ffffff"/>
<circle cx="32" cy="25.2" r="1.1" fill="#ffffff"/>
<circle cx="38" cy="24.5" r="1" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -535,7 +535,7 @@ function buildColumns() {
}
},
{
field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle',
field: 'element_type', title: gettext('Type'), sortable: true, width: 145, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) {
const table = $('#unified-table');
let isCatVisible = false;
@ -543,10 +543,21 @@ function buildColumns() {
isCatVisible = table.length && typeof table.bootstrapTable === 'function' &&
table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category');
} catch (e) {}
if (!isCatVisible && row && row.is_location && row.category) {
return `${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
let iconPrefix = '';
if (typeof AssetSymbology !== 'undefined' && row && !row.is_location && !row.is_management_zone) {
const symb = AssetSymbology.getAssetSymbology(row);
const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} font-size: 0.95rem; vertical-align: -1px; margin-right: 5px;`,
imgClass: 'me-1'
});
}
return escapeHtml(v || '-');
if (!isCatVisible && row && row.is_location && row.category) {
return `${iconPrefix}${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
}
return `${iconPrefix}${escapeHtml(v || '-')}`;
}
},
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' },
@ -2016,6 +2027,7 @@ function collectClickedAssets(clickEvent) {
element_type: p.element_type || '',
detail_url: p.detail_url || null,
feature_id: id,
extra_info: p.plots_count != null ? `${p.plots_count} plot(s)` : (p.bollard_type || ''),
});
});
@ -2149,15 +2161,26 @@ function showAssetClickPopup(clickEvent) {
const type = escapeHtml(a.element_type || a.asset_type || '');
const thematic = escapeHtml(a.thematic_name || a.thematic || '');
const name = escapeHtml(a.name || '');
const extra = a.extra_info ? ` &bull; <span class="badge bg-light text-primary border" style="font-size:0.7rem;">${escapeHtml(a.extra_info)}</span>` : '';
const detailAnchor = a.detail_url
? `<a href="${escapeHtml(a.detail_url)}">${code}</a>`
: `<span>${code}</span>`;
let iconPrefix = '';
if (typeof AssetSymbology !== 'undefined') {
const symb = AssetSymbology.getAssetSymbology(a);
const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} font-size: 1rem; vertical-align: -1px; margin-right: 5px;`,
imgClass: 'me-1'
});
}
return `
<div class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
<div style="font-weight:600; line-height:1.1;">${iconPrefix}${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}${extra}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"

View file

@ -201,6 +201,12 @@ function loadUnifiedMapIcons(map) {
.then(data => { if (!map.hasImage('icon-artworkmonument')) map.addImage('icon-artworkmonument', data); })
.catch(e => console.warn('unified_map_layers: could not load artworkmonument icon', e));
}
// itsbollardplot: dedicated automatic bollard / plot SVG (42 px)
if (iconUrls.itsbollardplot && !map.hasImage('icon-itsbollardplot')) {
loadNormalisedIcon(iconUrls.itsbollardplot, 42)
.then(data => { if (!map.hasImage('icon-itsbollardplot')) map.addImage('icon-itsbollardplot', data); })
.catch(e => console.warn('unified_map_layers: could not load itsbollardplot icon', e));
}
// If the SVG above is unavailable, publiclighting falls back to Bootstrap glyph.
// ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ──────────
@ -634,6 +640,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -669,6 +676,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -704,6 +712,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -739,6 +748,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -774,6 +784,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -809,6 +820,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -844,6 +856,7 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
@ -876,6 +889,7 @@ const _unified_assets_symbol = {
['==', ['get', 'thematic'], 'publiclighting'], 'bottom',
['==', ['get', 'thematic'], 'trafficlights'], 'bottom',
['==', ['get', 'thematic'], 'nature'], 'bottom',
['==', ['get', 'asset_type'], 'itsbollardplot'], 'bottom',
'center'
],
'icon-offset': [
@ -912,6 +926,8 @@ const _unified_assets_symbol = {
['all', ['==', ['get', 'thematic'], 'roads'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
['all', ['==', ['get', 'asset_type'], 'naturerwiasset'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
['==', ['get', 'asset_type'], 'naturegreensurface'], 0,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0,
['match', ['geometry-type'], ['LineString', 'MultiLineString'], true, false], 0,
['boolean', ['feature-state', 'highlighted'], false], 1,
0.85
],
@ -1084,14 +1100,20 @@ const _unified_assets_line = (typeof AssetSymbology !== 'undefined' && AssetSymb
'line-color': [
'case',
['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['==', ['get', 'asset_type'], 'itsbollardsystem'], '#0288d1',
THEMATIC_COLOR_EXPR
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 5,
['boolean', ['feature-state', 'highlighted'], false], 6,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 5,
3
],
'line-opacity': 0.8,
'line-opacity': [
'case',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0.95,
0.8
],
},
minzoom: 8,
};

View file

@ -111,6 +111,11 @@
id: 'assets',
type: 'circle',
source: 'assets',
filter: [
'any',
['==', ['geometry-type'], 'Point'],
['==', ['geometry-type'], 'MultiPoint']
],
paint: {
'circle-radius': [
'interpolate',
@ -135,7 +140,32 @@
minzoom: 8
};
const assets_layers_defs = [assets_layer_def];
const assets_lines_layer_def = {
id: 'assets-lines',
type: 'line',
source: 'assets',
filter: [
'any',
['==', ['geometry-type'], 'LineString'],
['==', ['geometry-type'], 'MultiLineString']
],
paint: {
'line-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
'#0022ff'
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 6,
4
],
'line-opacity': 0.85
},
minzoom: 8
};
const assets_layers_defs = [assets_lines_layer_def, assets_layer_def];
// Filtrer les modèles par catégorie dans le formulaire des assets

View file

@ -0,0 +1,92 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% block extra_blocks %}
<!-- Système de bornes parent -->
<div class="card mb-3" id="bollard-plot-system-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-diagram-3-fill text-primary me-2"></i>{% translate "Système de bornes de rattachement" %}
</h5>
{% if asset.system %}
<a href="{{ asset.system.get_absolute_url }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-right-circle me-1"></i>{% translate "Voir le système complet" %}
</a>
{% endif %}
</div>
<div class="card-body">
{% if asset.system %}
<div class="row align-items-center mb-3">
<div class="col-md-7">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-dark rounded-pill fs-6 me-2">#{{ asset.position }}</span>
<h5 class="mb-0">
<a href="{{ asset.system.get_absolute_url }}" class="text-decoration-none">
{{ asset.system.code }} - {{ asset.system.get_name|default:asset.system.code }}
</a>
</h5>
</div>
<div class="text-muted small mb-2">
<i class="bi bi-geo-alt me-1"></i>{{ asset.system.location|default:"Localisation non définie" }}
</div>
<div class="d-flex flex-wrap gap-2">
{% if asset.system.width %}
<span class="badge bg-light text-dark border">
<i class="bi bi-arrows-expand me-1"></i>{% translate "Largeur" %} : {{ asset.system.width }} m
</span>
{% endif %}
{% if asset.system.direction %}
<span class="badge bg-light text-dark border">
<i class="bi bi-signpost-split me-1"></i>{{ asset.system.get_direction_display }}
</span>
{% endif %}
{% if asset.system.model %}
<span class="badge bg-light text-dark border">
<i class="bi bi-tag me-1"></i>{{ asset.system.model.get_name }}
</span>
{% endif %}
</div>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
<div class="p-3 bg-light rounded border text-start">
<div class="small text-muted mb-1">{% translate "Traverse complète" %} ({{ asset.system.plots.count }} {% translate "plots" %}) :</div>
<div class="d-flex flex-wrap gap-1">
{% for sibling in asset.system.plots.all %}
<a href="{{ sibling.get_absolute_url }}"
class="btn btn-sm {% if sibling.id == asset.id %}btn-primary fw-bold{% else %}btn-outline-secondary{% endif %}"
title="{{ sibling.code }} - {{ sibling.get_bollard_type_display }}">
#{{ sibling.position }} ({{ sibling.code }})
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="text-muted text-center p-3">
<i class="bi bi-exclamation-triangle text-warning me-2"></i>{% translate "Ce plot n'est actuellement rattaché à aucun système de bornes." %}
</div>
{% endif %}
</div>
</div>
<!-- Métadonnées techniques (extra_data) -->
{% if asset.extra_data %}
<div class="card mb-3" id="bollard-plot-extra-data-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-slash text-secondary me-2"></i>{% translate "Métadonnées techniques" %}
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, val in asset.extra_data.items %}
<dt class="col-sm-4 text-truncate" title="{{ key }}">{{ key }}</dt>
<dd class="col-sm-8 mb-2"><code>{{ val }}</code></dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endblock extra_blocks %}

View file

@ -0,0 +1,218 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% block extra_blocks %}
<!-- Plots de la traverse -->
<div class="card mb-3" id="bollard-system-plots-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plots de la traverse" %}
<span class="badge bg-primary ms-2">{{ asset.plots.count }}</span>
</h5>
</div>
<div class="card-body p-0">
{% if asset.plots.all %}
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th style="width: 80px;" class="text-center">{% translate "Pos." %}</th>
<th>{% translate "Code" %}</th>
<th>{% translate "Type de borne" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "N° de série" %}</th>
<th>{% translate "Statut" %}</th>
<th style="width: 110px;" class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for plot in asset.plots.all %}
<tr>
<td class="text-center">
<span class="badge bg-dark rounded-pill">#{{ plot.position }}</span>
</td>
<td>
<a href="{{ plot.get_absolute_url }}" class="fw-bold text-decoration-none">
{{ plot.code }}
</a>
{% if plot.get_name %}
<div class="small text-muted">{{ plot.get_name }}</div>
{% endif %}
</td>
<td>
{% if plot.bollard_type == 'automatic' %}
<span class="badge bg-primary"><i class="bi bi-arrow-up-down me-1"></i>{{ plot.get_bollard_type_display }}</span>
{% elif plot.bollard_type == 'semi_automatic' %}
<span class="badge bg-info text-dark">{{ plot.get_bollard_type_display }}</span>
{% elif plot.bollard_type == 'removable' %}
<span class="badge bg-warning text-dark">{{ plot.get_bollard_type_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ plot.get_bollard_type_display }}</span>
{% endif %}
</td>
<td>{{ plot.model.get_name|default:"-" }}</td>
<td><code>{{ plot.serial_number|default:"-" }}</code></td>
<td>
{% if plot.status == 'active' %}
<span class="badge bg-success">{{ plot.get_status_display }}</span>
{% elif plot.status == 'damaged' %}
<span class="badge bg-warning text-dark">{{ plot.get_status_display }}</span>
{% elif plot.status == 'to_replace' %}
<span class="badge bg-danger">{{ plot.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ plot.get_status_display }}</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ plot.get_absolute_url }}" class="btn btn-sm btn-outline-primary" title="{% translate 'Voir le détail du plot' %}">
<i class="bi bi-eye me-1"></i>{% translate "Voir" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-cone-striped display-6 d-block mb-2 text-secondary opacity-50"></i>
{% translate "Aucun plot physique n'est encore configuré sur ce système de bornes." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements associés au système (Caméras LAPI, totems, armoires...) -->
<div class="card mb-3" id="bollard-system-assets-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-hdd-network text-info me-2"></i>{% translate "Équipements associés" %}
<span class="badge bg-secondary ms-2">{{ asset.system_assets.count }}</span>
</h5>
</div>
<div class="card-body p-0">
{% if asset.system_assets.all %}
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th>{% translate "Rôle" %}</th>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Commentaire" %}</th>
<th style="width: 110px;" class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for item in asset.system_assets.all %}
<tr>
<td>
{% if 'camera' in item.role %}
<span class="badge bg-info text-dark"><i class="bi bi-camera-video me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'cabinet' %}
<span class="badge bg-dark"><i class="bi bi-cpu me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'totem' %}
<span class="badge bg-primary"><i class="bi bi-tablet me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'traffic_light' %}
<span class="badge bg-warning text-dark"><i class="bi bi-traffic-light me-1"></i>{{ item.get_role_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ item.get_role_display }}</span>
{% endif %}
</td>
<td>
<a href="{{ item.asset.get_absolute_url }}" class="fw-bold text-decoration-none">
{{ item.asset.code }}
</a>
{% if item.asset.get_name %}
<div class="small text-muted">{{ item.asset.get_name }}</div>
{% endif %}
</td>
<td>{{ item.asset.model.get_name|default:"-" }}</td>
<td>
{% if item.asset.status == 'active' %}
<span class="badge bg-success">{{ item.asset.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ item.asset.get_status_display }}</span>
{% endif %}
</td>
<td>{{ item.comment|default:"-" }}</td>
<td class="text-end">
<a href="{{ item.asset.get_absolute_url }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Voir équipement' %}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-camera-video display-6 d-block mb-2 text-secondary opacity-50"></i>
{% translate "Aucun équipement périphérique (caméra, armoire, totem...) n'est actuellement associé." %}
</div>
{% endif %}
</div>
</div>
<!-- Contrats de maintenance liés -->
{% if asset.bollard_systems_contracts.exists %}
<div class="card mb-3" id="bollard-system-contracts-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-file-earmark-text text-secondary me-2"></i>{% translate "Contrats d'entretien" %}
<span class="badge bg-secondary ms-2">{{ asset.bollard_systems_contracts.count }}</span>
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th>{% translate "Numéro de contrat" %}</th>
<th>{% translate "Type de maintenance" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for sc in asset.bollard_systems_contracts.all %}
<tr>
<td class="fw-bold">{{ sc.contract.contract_number }} - {{ sc.contract.name }}</td>
<td>{{ sc.get_maintenance_type_display }}</td>
<td>
{% if sc.status == 'active' %}
<span class="badge bg-success">{% translate "Actif" %}</span>
{% else %}
<span class="badge bg-secondary">{{ sc.get_status_display }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Métadonnées techniques (extra_data) -->
{% if asset.extra_data %}
<div class="card mb-3" id="bollard-system-extra-data-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-slash text-secondary me-2"></i>{% translate "Métadonnées techniques" %}
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, val in asset.extra_data.items %}
<dt class="col-sm-4 text-truncate" title="{{ key }}">{{ key }}</dt>
<dd class="col-sm-8 mb-2"><code>{{ val }}</code></dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endblock extra_blocks %}

View file

@ -103,11 +103,11 @@ def get_asset_specific_fields(asset):
from django.contrib.gis.db.models import GeometryField
from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField,
BooleanField, FloatField, DecimalField, IntegerField,
DateField, DateTimeField, TextField)
DateField, DateTimeField, TextField, JSONField)
from django.utils.translation import gettext_lazy as _
from assets.models import AbstractAsset, AbstractGeoAsset
excluded_types = (ForeignKey, OneToOneField, ManyToManyField, AutoField, GeometryField)
excluded_types = (ForeignKey, OneToOneField, ManyToManyField, AutoField, GeometryField, JSONField)
base_field_names = {f.name for f in AbstractAsset._meta.fields}
base_field_names |= {f.name for f in AbstractGeoAsset._meta.fields}

View file

@ -1411,6 +1411,11 @@ CATEGORY_ASSET_MAPPING = {
'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'),
'publiclighting_luminaire': ('PublicLightingLuminaire', 'PublicLightingLuminaireModel'),
'LI_lightfixtures': ('PublicLightingLightFixture', 'PublicLightingLightFixtureModel'),
# ITS categories
'its_bollard_system': ('ITSBollardSystem', 'ITSAssetModel'),
'bollard_system': ('ITSBollardSystem', 'ITSAssetModel'),
'its_bollard_plot': ('ITSBollardPlot', 'ITSAssetModel'),
'bollard_plot': ('ITSBollardPlot', 'ITSAssetModel'),
}
@ -1856,6 +1861,18 @@ def create_asset_api(request):
except Exception:
continue
# Handle bollard system (for bollard plots)
system_id = data.get('system_id') or data.get('bollard_system_id')
if system_id and _has_model_field(new_asset, 'system'):
from assets.models import ITSBollardSystem
try:
system_obj = ITSBollardSystem.objects.get(pk=system_id)
new_asset.system = system_obj
if not getattr(new_asset, 'location', None) and system_obj.location:
new_asset.location = system_obj.location
except ITSBollardSystem.DoesNotExist:
pass
# Set additional data
extra_data = data.get('data', {})
if isinstance(extra_data, dict):

View file

@ -461,6 +461,8 @@ ELEMENT_TYPE_LABELS = {
'controlcentergeoasset': _('Équipement CdC'),
# --- ITS ---
'itsgeoasset': _('Équipement ITS'),
'itsbollardsystem': _('Système de bornes'),
'itsbollardplot': _('Plot de borne'),
# --- Propreté ---
'cleanlitterbin': _('Corbeille'),
'cleanglasscontainer': _('Conteneur verre'),

View file

@ -10,7 +10,7 @@ from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import GEOSGeometry, Point
from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, Case, When, IntegerField, CharField
from django.db.models import Q, Case, When, IntegerField, CharField, Count
from django.db.models.functions import Lower
from django.db.models import Func, Prefetch
from django.db import connection
@ -25,7 +25,10 @@ from collections import defaultdict
from common.utils import check_thematic_access
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics
from assets.models import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory
from assets.models import (
ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory,
ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
)
from assets.permissions import can_edit_location_for_thematic, get_allowed_actions_for_thematic
from assets.forms import ITSLocationDocumentForm
@ -297,11 +300,19 @@ def its_assets_geojson(request):
if lon is None or lat is None:
location = getattr(obj, 'location', None)
system = getattr(obj, 'system', None)
if location is None and system is not None:
location = getattr(system, 'location', None)
if location is not None:
if lon is None:
lon = getattr(location, 'lon', None)
if lat is None:
lat = getattr(location, 'lat', None)
if (lon is None or lat is None) and system is not None:
if lon is None:
lon = getattr(system, 'lon', None)
if lat is None:
lat = getattr(system, 'lat', None)
if geometry is None and lon is not None and lat is not None:
geometry = {
@ -313,25 +324,42 @@ def its_assets_geojson(request):
def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj)
props = {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"category_id": obj.category_id,
"model": obj.model.get_name() if obj.model else None,
"model_id": obj.model_id,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
}
if asset_type == "itsbollardsystem":
props["width"] = obj.width
props["direction"] = obj.get_direction_display() if hasattr(obj, 'get_direction_display') else obj.direction
props["plots_count"] = getattr(obj, 'plots_total', None)
if props["plots_count"] is None and hasattr(obj, 'plots'):
props["plots_count"] = obj.plots.count()
if obj.geom:
props["length"] = round(obj.geom.length, 2)
elif asset_type == "itsbollardplot":
props["position"] = obj.position
props["bollard_type"] = obj.get_bollard_type_display() if hasattr(obj, 'get_bollard_type_display') else obj.bollard_type
if getattr(obj, 'system', None):
props["system_code"] = obj.system.code
props["system_name"] = obj.system.name
return {
"type": "Feature",
"id": obj.id,
"geometry": geometry,
"properties": {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
},
"properties": props,
}
def add_assets_to_features(queryset, asset_type, features, feature_count):
@ -348,16 +376,35 @@ def its_assets_geojson(request):
.select_related('location', 'category', 'model')
.all()
)
bollard_system_qs = (
ITSBollardSystem.objects
.select_related('location', 'category', 'model')
.annotate(plots_total=Count('plots'))
.all()
)
bollard_plot_qs = (
ITSBollardPlot.objects
.select_related('system', 'location', 'category', 'model')
.all()
)
# --------- Filtres métier existants
if its_location_ids:
its_asset_qs = its_asset_qs.filter(location__id__in=its_location_ids)
bollard_system_qs = bollard_system_qs.filter(location__id__in=its_location_ids)
bollard_plot_qs = bollard_plot_qs.filter(
Q(location__id__in=its_location_ids) | Q(system__location__id__in=its_location_ids)
)
if models:
its_asset_qs = its_asset_qs.filter(model__id__in=models)
bollard_system_qs = bollard_system_qs.filter(model__id__in=models)
bollard_plot_qs = bollard_plot_qs.filter(model__id__in=models)
if categories:
its_asset_qs = its_asset_qs.filter(category__id__in=categories)
bollard_system_qs = bollard_system_qs.filter(category__id__in=categories)
bollard_plot_qs = bollard_plot_qs.filter(category__id__in=categories)
if filter_geom:
its_asset_qs = its_asset_qs.filter(
@ -365,11 +412,24 @@ def its_assets_geojson(request):
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True)
)
bollard_system_qs = bollard_system_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True)
)
bollard_plot_qs = bollard_plot_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, system__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, system__isnull=True)
)
# Recherche plein texte
if request.method == 'POST':
from assets.views.core import apply_search
its_asset_qs = apply_search(its_asset_qs, data)
bollard_system_qs = apply_search(bollard_system_qs, data)
bollard_plot_qs = apply_search(bollard_plot_qs, data)
# --------- Limitation pour utilisateurs externes
user = request.user
@ -388,26 +448,32 @@ def its_assets_geojson(request):
can_view_assets=True
).values_list('contract_id', flat=True)
# Ne garder que les assets qui ont un contrat actif
# auquel l'utilisateur a accès
# Ne garder que les assets qui ont un contrat actif auquel l'utilisateur a accès
its_asset_qs = its_asset_qs.filter(
its_assets_contracts__status='active',
its_assets_contracts__contract_id__in=accessible_contract_ids
).distinct()
bollard_system_qs = bollard_system_qs.filter(
bollard_systems_contracts__status='active',
bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
bollard_plot_qs = bollard_plot_qs.filter(
system__bollard_systems_contracts__status='active',
system__bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
else:
# Non authentifié : ne rien exposer (plus sûr)
its_asset_qs = its_asset_qs.none()
# --------- Cap de sécurité
its_asset_qs = its_asset_qs[0:MAX_ASSET_FEATURES+1]
if len(its_asset_qs) >= MAX_ASSET_FEATURES:
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
bollard_system_qs = bollard_system_qs.none()
bollard_plot_qs = bollard_plot_qs.none()
# --------- Construction GeoJSON
features = []
feature_count = 0
for qs, asset_type in [
(its_asset_qs, "itsgeoasset"),
(bollard_system_qs, "itsbollardsystem"),
(bollard_plot_qs, "itsbollardplot"),
]:
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
if limit_reached:
@ -603,7 +669,9 @@ def its_assets_autocomplete(request):
raw_query = raw_query_original.strip()
# CT ids (calculés une fois)
equipment_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id
geoasset_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id
bollard_system_ct_id = ContentType.objects.get_for_model(ITSBollardSystem).id
bollard_plot_ct_id = ContentType.objects.get_for_model(ITSBollardPlot).id
location_ct_id = ContentType.objects.get_for_model(ITSLocation).id
qn = strip_accents_lower(raw_query)
@ -626,68 +694,71 @@ def its_assets_autocomplete(request):
# en cas d'ID invalide ou autre, on ignore le filtre
location_ids = None
# Base queryset accent-insensible + actifs uniquement
qs = (
ITSGeoAsset.objects
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
name_nl_u=Lower(Unaccent('name_nl')),
def query_model(model_class, ct_id, extra_select=()):
q = (
model_class.objects
.annotate(
code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')),
name_nl_u=Lower(Unaccent('name_nl')),
)
)
if has_search_term:
q = q.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
q = q.exclude(status='removed')
q = q.select_related('location', 'model', 'category', *extra_select)
if location_ids:
if hasattr(model_class, 'system'):
q = q.filter(Q(location_id__in=location_ids) | Q(system__location_id__in=location_ids))
else:
q = q.filter(location_id__in=location_ids)
if category_id is not None:
q = q.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
When(name_fr_u__startswith=qn, then=2),
When(name_nl_u__startswith=qn, then=3),
When(code_u__contains=qn, then=4),
When(name_fr_u__contains=qn, then=5),
When(name_nl_u__contains=qn, then=6),
default=7,
output_field=IntegerField(),
)
q = q.annotate(_prio=priority).order_by('_prio', 'code')[:50]
else:
q = q.order_by('code')[:50]
results = []
for obj in q:
results.append((obj, ct_id))
return results
all_results = (
query_model(ITSGeoAsset, geoasset_ct_id) +
query_model(ITSBollardSystem, bollard_system_ct_id) +
query_model(ITSBollardPlot, bollard_plot_ct_id, ('system', 'system__location'))
)
# Si un terme de recherche est fourni, filtrer dessus
if has_search_term:
qs = qs.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
qs = qs.exclude(status='removed') # <-- exclure les supprimés
qs = qs.select_related(
'location',
'model',
'category',
)
if location_ids:
qs = qs.filter(location_id__in=location_ids)
if category_id is not None:
qs = qs.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
# Priorité : exact > code startswith > nom startswith > contient
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
When(name_fr_u__startswith=qn, then=2),
When(name_nl_u__startswith=qn, then=3),
When(code_u__contains=qn, then=4),
When(name_fr_u__contains=qn, then=5),
When(name_nl_u__contains=qn, then=6),
default=7,
output_field=IntegerField(),
)
qs = qs.annotate(_prio=priority).order_by('_prio', 'code')[:100]
else:
# Sans terme de recherche, tri par code uniquement
qs = qs.order_by('code')[:100]
data = []
for a in qs:
# equipment_type depuis model/category
for a, ct_id in all_results[:100]:
equipment_type = None
if getattr(a, 'model', None):
equipment_type = getattr(a.model, 'name', None) or getattr(a.model, 'code', None)
if not equipment_type and getattr(a, 'category', None):
equipment_type = getattr(a.category, 'name', None)
# coordonnées: asset > geom(4326) > location
lon = a.lon
lat = a.lat
@ -698,24 +769,33 @@ def its_assets_autocomplete(request):
g = a.geom.transform(4326, clone=True)
coords = getattr(g, 'coords', None)
if coords:
if lon is None: lon = coords[0]
if lat is None: lat = coords[1]
if isinstance(coords[0], (int, float)):
if lon is None: lon = coords[0]
if lat is None: lat = coords[1]
elif isinstance(coords[0], (list, tuple)) and coords[0]:
# Pour LineString (ITSBollardSystem)
mid = len(coords) // 2
if lon is None: lon = coords[mid][0]
if lat is None: lat = coords[mid][1]
except Exception:
pass
if (lon is None or lat is None) and getattr(a, 'location', None):
if lon is None and getattr(a.location, 'lon', None) is not None:
lon = a.location.lon
if lat is None and getattr(a.location, 'lat', None) is not None:
lat = a.location.lat
loc = getattr(a, 'location', None)
if loc is None and hasattr(a, 'system') and a.system:
loc = getattr(a.system, 'location', None)
if (lon is None or lat is None) and loc:
if lon is None and getattr(loc, 'lon', None) is not None:
lon = loc.lon
if lat is None and getattr(loc, 'lat', None) is not None:
lat = loc.lat
equipment_label = f"{a.get_name()} ({a.code})" if a.code else a.get_name()
label = f"{a.code} - {equipment_label}" if a.code else equipment_label
location_label = (
f"{a.location.get_name()} ({a.location.code})"
if getattr(a, "location", None) else None
f"{loc.get_name()} ({loc.code})"
if loc else None
)
data.append({
@ -725,9 +805,9 @@ def its_assets_autocomplete(request):
"equipment_id": a.id,
"equipment_label": equipment_label,
"equipment_type": equipment_type,
"equipment_ct": equipment_ct_id,
"equipment_ct": ct_id,
"location_id": a.location.id if getattr(a, "location", None) else None,
"location_id": loc.id if loc else None,
"location_label": location_label,
"location_ct": location_ct_id,