From 7d5718dec23c9ec979d7c3e7453f9c1c8504f1a5 Mon Sep 17 00:00:00 2001 From: Karl Determe Date: Thu, 8 Oct 2026 14:29:19 +0200 Subject: [PATCH] feat: add admin, models, views, and templates for ITS assets --- loko/assets/admin.py | 43 +++- ...et_extra_data_itsbollardsystem_and_more.py | 160 +++++++++++++ loko/assets/models/__init__.py | 2 +- loko/assets/models/its.py | 144 +++++++++++- loko/assets/templates/assets/its_index.html | 32 ++- .../templates/its/detail_itsbollardplot.html | 92 ++++++++ .../its/detail_itsbollardsystem.html | 218 ++++++++++++++++++ loko/assets/templatetags/asset_tags.py | 4 +- loko/assets/views/asset_editing.py | 17 ++ loko/assets/views/core.py | 2 + loko/assets/views/its.py | 206 +++++++++++------ 11 files changed, 842 insertions(+), 78 deletions(-) create mode 100644 loko/assets/migrations/0031_itsgeoasset_extra_data_itsbollardsystem_and_more.py create mode 100644 loko/assets/templates/its/detail_itsbollardplot.html create mode 100644 loko/assets/templates/its/detail_itsbollardsystem.html diff --git a/loko/assets/admin.py b/loko/assets/admin.py index a5b2233..7ff1215 100644 --- a/loko/assets/admin.py +++ b/loko/assets/admin.py @@ -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) diff --git a/loko/assets/migrations/0031_itsgeoasset_extra_data_itsbollardsystem_and_more.py b/loko/assets/migrations/0031_itsgeoasset_extra_data_itsbollardsystem_and_more.py new file mode 100644 index 0000000..acc8e93 --- /dev/null +++ b/loko/assets/migrations/0031_itsgeoasset_extra_data_itsbollardsystem_and_more.py @@ -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')}, + ), + ] diff --git a/loko/assets/models/__init__.py b/loko/assets/models/__init__.py index 74b1cb7..7628757 100644 --- a/loko/assets/models/__init__.py +++ b/loko/assets/models/__init__.py @@ -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 diff --git a/loko/assets/models/its.py b/loko/assets/models/its.py index 23b298e..83a8438 100644 --- a/loko/assets/models/its.py +++ b/loko/assets/models/its.py @@ -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') + diff --git a/loko/assets/templates/assets/its_index.html b/loko/assets/templates/assets/its_index.html index 64920fa..4013801 100644 --- a/loko/assets/templates/assets/its_index.html +++ b/loko/assets/templates/assets/its_index.html @@ -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 diff --git a/loko/assets/templates/its/detail_itsbollardplot.html b/loko/assets/templates/its/detail_itsbollardplot.html new file mode 100644 index 0000000..bf13498 --- /dev/null +++ b/loko/assets/templates/its/detail_itsbollardplot.html @@ -0,0 +1,92 @@ +{% extends "assets/detail_generic.html" %} +{% load i18n %} +{% load static %} + +{% block extra_blocks %} + +
+
+
+ {% translate "Système de bornes de rattachement" %} +
+ {% if asset.system %} + + {% translate "Voir le système complet" %} + + {% endif %} +
+
+ {% if asset.system %} +
+
+ +
+ {{ asset.system.location|default:"Localisation non définie" }} +
+
+ {% if asset.system.width %} + + {% translate "Largeur" %} : {{ asset.system.width }} m + + {% endif %} + {% if asset.system.direction %} + + {{ asset.system.get_direction_display }} + + {% endif %} + {% if asset.system.model %} + + {{ asset.system.model.get_name }} + + {% endif %} +
+
+
+
+
{% translate "Traverse complète" %} ({{ asset.system.plots.count }} {% translate "plots" %}) :
+
+ {% for sibling in asset.system.plots.all %} + + #{{ sibling.position }} ({{ sibling.code }}) + + {% endfor %} +
+
+
+
+ {% else %} +
+ {% translate "Ce plot n'est actuellement rattaché à aucun système de bornes." %} +
+ {% endif %} +
+
+ + +{% if asset.extra_data %} +
+
+
+ {% translate "Métadonnées techniques" %} +
+
+
+
+ {% for key, val in asset.extra_data.items %} +
{{ key }}
+
{{ val }}
+ {% endfor %} +
+
+
+{% endif %} +{% endblock extra_blocks %} diff --git a/loko/assets/templates/its/detail_itsbollardsystem.html b/loko/assets/templates/its/detail_itsbollardsystem.html new file mode 100644 index 0000000..15da303 --- /dev/null +++ b/loko/assets/templates/its/detail_itsbollardsystem.html @@ -0,0 +1,218 @@ +{% extends "assets/detail_generic.html" %} +{% load i18n %} +{% load static %} + +{% block extra_blocks %} + +
+
+
+ {% translate "Plots de la traverse" %} + {{ asset.plots.count }} +
+
+
+ {% if asset.plots.all %} +
+ + + + + + + + + + + + + + {% for plot in asset.plots.all %} + + + + + + + + + + {% endfor %} + +
{% translate "Pos." %}{% translate "Code" %}{% translate "Type de borne" %}{% translate "Modèle" %}{% translate "N° de série" %}{% translate "Statut" %}{% translate "Action" %}
+ #{{ plot.position }} + + + {{ plot.code }} + + {% if plot.get_name %} +
{{ plot.get_name }}
+ {% endif %} +
+ {% if plot.bollard_type == 'automatic' %} + {{ plot.get_bollard_type_display }} + {% elif plot.bollard_type == 'semi_automatic' %} + {{ plot.get_bollard_type_display }} + {% elif plot.bollard_type == 'removable' %} + {{ plot.get_bollard_type_display }} + {% else %} + {{ plot.get_bollard_type_display }} + {% endif %} + {{ plot.model.get_name|default:"-" }}{{ plot.serial_number|default:"-" }} + {% if plot.status == 'active' %} + {{ plot.get_status_display }} + {% elif plot.status == 'damaged' %} + {{ plot.get_status_display }} + {% elif plot.status == 'to_replace' %} + {{ plot.get_status_display }} + {% else %} + {{ plot.get_status_display }} + {% endif %} + + + {% translate "Voir" %} + +
+
+ {% else %} +
+ + {% translate "Aucun plot physique n'est encore configuré sur ce système de bornes." %} +
+ {% endif %} +
+
+ + +
+
+
+ {% translate "Équipements associés" %} + {{ asset.system_assets.count }} +
+
+
+ {% if asset.system_assets.all %} +
+ + + + + + + + + + + + + {% for item in asset.system_assets.all %} + + + + + + + + + {% endfor %} + +
{% translate "Rôle" %}{% translate "Équipement" %}{% translate "Modèle" %}{% translate "Statut" %}{% translate "Commentaire" %}{% translate "Action" %}
+ {% if 'camera' in item.role %} + {{ item.get_role_display }} + {% elif item.role == 'cabinet' %} + {{ item.get_role_display }} + {% elif item.role == 'totem' %} + {{ item.get_role_display }} + {% elif item.role == 'traffic_light' %} + {{ item.get_role_display }} + {% else %} + {{ item.get_role_display }} + {% endif %} + + + {{ item.asset.code }} + + {% if item.asset.get_name %} +
{{ item.asset.get_name }}
+ {% endif %} +
{{ item.asset.model.get_name|default:"-" }} + {% if item.asset.status == 'active' %} + {{ item.asset.get_status_display }} + {% else %} + {{ item.asset.get_status_display }} + {% endif %} + {{ item.comment|default:"-" }} + + + +
+
+ {% else %} +
+ + {% translate "Aucun équipement périphérique (caméra, armoire, totem...) n'est actuellement associé." %} +
+ {% endif %} +
+
+ + +{% if asset.bollard_systems_contracts.exists %} +
+
+
+ {% translate "Contrats d'entretien" %} + {{ asset.bollard_systems_contracts.count }} +
+
+
+
+ + + + + + + + + + {% for sc in asset.bollard_systems_contracts.all %} + + + + + + {% endfor %} + +
{% translate "Numéro de contrat" %}{% translate "Type de maintenance" %}{% translate "Statut" %}
{{ sc.contract.contract_number }} - {{ sc.contract.name }}{{ sc.get_maintenance_type_display }} + {% if sc.status == 'active' %} + {% translate "Actif" %} + {% else %} + {{ sc.get_status_display }} + {% endif %} +
+
+
+
+{% endif %} + + +{% if asset.extra_data %} +
+
+
+ {% translate "Métadonnées techniques" %} +
+
+
+
+ {% for key, val in asset.extra_data.items %} +
{{ key }}
+
{{ val }}
+ {% endfor %} +
+
+
+{% endif %} +{% endblock extra_blocks %} diff --git a/loko/assets/templatetags/asset_tags.py b/loko/assets/templatetags/asset_tags.py index 316582b..3f2bbea 100644 --- a/loko/assets/templatetags/asset_tags.py +++ b/loko/assets/templatetags/asset_tags.py @@ -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} diff --git a/loko/assets/views/asset_editing.py b/loko/assets/views/asset_editing.py index e0c280e..8d28dd8 100644 --- a/loko/assets/views/asset_editing.py +++ b/loko/assets/views/asset_editing.py @@ -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): diff --git a/loko/assets/views/core.py b/loko/assets/views/core.py index 4d517c9..2afb4e5 100644 --- a/loko/assets/views/core.py +++ b/loko/assets/views/core.py @@ -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'), diff --git a/loko/assets/views/its.py b/loko/assets/views/its.py index c0385bf..1006272 100644 --- a/loko/assets/views/its.py +++ b/loko/assets/views/its.py @@ -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 = { @@ -348,16 +359,34 @@ def its_assets_geojson(request): .select_related('location', 'category', 'model') .all() ) + bollard_system_qs = ( + ITSBollardSystem.objects + .select_related('location', 'category', 'model') + .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 +394,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 +430,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 +651,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 +676,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 +751,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 +787,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,