feat: implement parking spot models and generic asset inspection framework

This commit is contained in:
kdeterme 2026-08-07 10:09:20 +02:00
parent 70c3ff3dea
commit dad5acc4fb
8 changed files with 833 additions and 2 deletions

View file

@ -681,4 +681,48 @@ class OutdoorAssetAdmin(admin.GISModelAdmin):
search_fields = ("code", "name_fr", "name_nl", "serial_number", "certifying_body")
raw_id_fields = ("site", "location", "parent_asset")
admin.site.register(OutdoorLocationDocument, AssetAdmin)
admin.site.register(OutdoorLocationDocument, AssetAdmin)
from .models import ParkingLocation, ParkingSpot, AssetInspection, ParkingSpotInspection
@admin.register(ParkingLocation)
class ParkingLocationAdmin(AssetGISAdmin):
list_display = ("code", "name_fr", "name_nl", "address")
search_fields = ("code", "name_fr", "name_nl", "address")
@admin.register(ParkingSpot)
class ParkingSpotAdmin(AssetGISAdmin):
list_display = (
"code", "name_fr", "spot_type", "stand_type",
"standard_stand_count", "cargo_stand_count", "total_stand_count",
"has_scooter_drop_zone", "is_compliant", "inspection_status", "status"
)
list_filter = (
"spot_type", "stand_type", "has_scooter_drop_zone",
"is_compliant", "inspection_status", "marking_quality", "is_paid", "status"
)
search_fields = ("code", "name_fr", "name_nl", "note")
filter_horizontal = ("poles", "panels")
raw_id_fields = ("location", "street")
@admin.register(AssetInspection)
class AssetInspectionAdmin(admin.ModelAdmin):
list_display = (
"id", "asset", "inspector", "inspection_date",
"result_status", "apply_changes_to_asset"
)
list_filter = ("result_status", "apply_changes_to_asset", "inspection_date")
search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name")
raw_id_fields = ("inspector",)
@admin.register(ParkingSpotInspection)
class ParkingSpotInspectionAdmin(admin.ModelAdmin):
list_display = (
"id", "asset", "inspector", "inspection_date",
"result_status", "verified_standard_stand_count",
"verified_cargo_stand_count", "verified_has_scooter_drop_zone",
"apply_changes_to_asset"
)
list_filter = ("result_status", "apply_changes_to_asset", "inspection_date")
search_fields = ("notes", "inspector__username", "inspector__first_name", "inspector__last_name")
raw_id_fields = ("inspector",)

View file

@ -0,0 +1,125 @@
# Generated by Django 6.0.7 on 2026-08-07 07:54
import assets.models.core
import django.contrib.gis.db.models.fields
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0007_outdoorlocation_outdoorasset_outdoorlocationdocument'),
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AssetInspection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('asset_object_id', models.PositiveIntegerField(verbose_name="ID de l'asset")),
('inspection_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name="Date d'inspection")),
('result_status', models.CharField(choices=[('compliant', 'Conforme'), ('non_compliant', 'Non conforme / Anomalie'), ('updated', 'Données corrigées'), ('damaged', 'Endommagé'), ('missing', 'Absent / Retiré')], default='compliant', max_length=50, verbose_name='Résultat du contrôle')),
('verified_standard_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux classiques constaté")),
('verified_cargo_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux cargo constaté")),
('verified_stand_type', models.CharField(blank=True, max_length=50, null=True, verbose_name="Type d'arceau constaté")),
('verified_has_scooter_drop_zone', models.BooleanField(blank=True, null=True, verbose_name='Drop zone trottinette constatée')),
('verified_marking_quality', models.CharField(blank=True, max_length=50, null=True, verbose_name='Qualité du marquage constatée')),
('verified_obsolescence', models.CharField(blank=True, max_length=50, null=True, verbose_name="État d'obsolescence constaté")),
('notes', models.TextField(blank=True, null=True, verbose_name='Remarques / Constats')),
('apply_changes_to_asset', models.BooleanField(default=True, verbose_name="Appliquer directement les modifications à l'asset")),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('asset_content_type', models.ForeignKey(limit_choices_to={'app_label': 'assets'}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset")),
('inspector', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_inspections', to=settings.AUTH_USER_MODEL, verbose_name='Inspecteur / Vérificateur')),
],
options={
'verbose_name': "Inspection / Vérification d'asset",
'verbose_name_plural': "Inspections / Vérifications d'assets",
'ordering': ['-inspection_date'],
},
),
migrations.CreateModel(
name='ParkingLocation',
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)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('address', models.CharField(blank=True, max_length=500, null=True, verbose_name='Adresse')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_location_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Zone de stationnement',
'verbose_name_plural': 'Zones de stationnement',
'ordering': ['code', 'name_fr'],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.CreateModel(
name='ParkingSpot',
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)),
('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)),
('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')),
('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.PolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise polygone')),
('spot_type', models.CharField(choices=[('bicycle', 'Stationnement vélo'), ('scooter_drop_zone', 'Drop zone trottinettes / EDPM'), ('cargo_bike', 'Stationnement vélo cargo'), ('delivery', 'Zone de livraison / Chargement-déchargement'), ('pmr', 'Emplacement PMR'), ('kiss_and_ride', 'Arrêt minute / Court-durée (CD)'), ('car_parking', 'Stationnement automobile'), ('carsharing', 'Autopartage'), ('ev_charging', 'Recharge véhicule électrique'), ('other', 'Autre')], default='bicycle', max_length=50, verbose_name="Type d'emplacement")),
('standard_stand_count', models.IntegerField(default=0, verbose_name="Nombre d'arceaux vélos classiques")),
('cargo_stand_count', models.IntegerField(default=0, verbose_name="Nombre d'arceaux vélos cargo")),
('other_stand_count', models.IntegerField(default=0, verbose_name='Autres arceaux / agencements')),
('stand_type', models.CharField(choices=[('normal', 'Arceau standard (U inversé)'), ('cargo', 'Arceau vélo cargo'), ('mixed', 'Mixte (normal et cargo)'), ('rack', 'Râteau / Pince-roue'), ('box', 'Box / Abri sécurisé'), ('other', 'Autre')], default='normal', max_length=50, verbose_name="Type d'arceau principal")),
('has_scooter_drop_zone', models.BooleanField(default=False, verbose_name='Drop zone trottinettes / EDPM')),
('scooter_capacity', models.IntegerField(blank=True, null=True, verbose_name='Capacité estimée trottinettes')),
('capacity', models.IntegerField(blank=True, null=True, verbose_name='Capacité générale (nbr véhicules)')),
('is_paid', models.BooleanField(default=False, verbose_name='Payant')),
('time_limit_minutes', models.IntegerField(blank=True, null=True, verbose_name='Durée maximale (minutes)')),
('marking_quality', models.CharField(choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('damaged', 'Dégradé'), ('missing', 'Absent / Effacé')], default='good', max_length=50, verbose_name='Qualité du marquage au sol')),
('is_temporary', models.BooleanField(default=False, verbose_name='Temporaire (chantier/événement)')),
('is_compliant', models.BooleanField(default=True, verbose_name='Conforme aux spécifications')),
('non_compliance_reason', models.TextField(blank=True, null=True, verbose_name='Motif de non-conformité')),
('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='parking_spots', to='assets.parkinglocation', verbose_name='Zone / Localisation parente')),
('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')),
('panels', models.ManyToManyField(blank=True, related_name='parking_spots_attached_new', to='assets.signpanel', verbose_name='Panneaux de signalisation associés')),
('poles', models.ManyToManyField(blank=True, related_name='parking_spots_attached_new', to='assets.signpole', verbose_name='Poteaux de signalisation associés')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.parkingspot', verbose_name='Remplacé par')),
('street', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parking_spots_new', to='assets.signstreet', verbose_name='Rue')),
('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': 'Emplacement de stationnement',
'verbose_name_plural': 'Emplacements de stationnement',
'ordering': ['code', 'name_fr'],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
]

View file

@ -0,0 +1,254 @@
# Generated by Django 6.0.7 on 2026-08-07 08:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0008_assetinspection_parkinglocation_parkingspot'),
]
operations = [
migrations.RemoveField(
model_name='assetinspection',
name='verified_has_scooter_drop_zone',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_stand_type',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_marking_quality',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_cargo_stand_count',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_standard_stand_count',
),
migrations.AlterModelOptions(
name='assetinspection',
options={'ordering': ['-inspection_date'], 'verbose_name': "Inspection d'asset (Générique)", 'verbose_name_plural': "Inspections d'assets (Génériques)"},
),
migrations.AddField(
model_name='artworkfountain',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='artworkmonument',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='buildingasset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='cleanlitterbin',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='itsgeoasset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='naturebench',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='naturegreensurface',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='naturerwiasset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='natureshrub',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='naturetree',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='outdoorasset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='parkingspot',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='publiclightingpole',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='roaddrain',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='roadfurniture',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='roadsidewalk',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='roadway',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='signpanel',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='signparkingspot',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='signpole',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='structuregeoasset',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightaccessory',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightcable',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightcontroller',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightcontrollerhardware',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightdetector',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightelectricalcabinet',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightelectricalcabinetcontent',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightlantern',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightnetworkhardware',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightpole',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightprogramming',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightradar',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightradarpole',
name='inspection_status',
field=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"),
),
migrations.AddField(
model_name='trafficlightvoltagehardware',
name='inspection_status',
field=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"),
),
migrations.CreateModel(
name='ParkingSpotInspection',
fields=[
('assetinspection_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.assetinspection')),
('verified_standard_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux classiques constaté")),
('verified_cargo_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux cargo constaté")),
('verified_stand_type', models.CharField(blank=True, max_length=50, null=True, verbose_name="Type d'arceau constaté")),
('verified_has_scooter_drop_zone', models.BooleanField(blank=True, null=True, verbose_name='Drop zone trottinette constatée')),
('verified_marking_quality', models.CharField(blank=True, max_length=50, null=True, verbose_name='Qualité du marquage constatée')),
],
options={
'verbose_name': "Inspection d'emplacement de stationnement",
'verbose_name_plural': "Inspections d'emplacements de stationnement",
},
bases=('assets.assetinspection',),
),
]

View file

@ -11,4 +11,6 @@ from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSi
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
from .parking import ParkingLocation, ParkingSpot
from .inspection import AssetInspection, ParkingSpotInspection

View file

@ -249,6 +249,14 @@ ASSET_VALIDATION_STATUS_CHOICES = {
'rejected': _('Rejeté'),
}
ASSET_INSPECTION_STATUS_CHOICES = {
'to_inspect': _('À inspecter'),
'compliant': _('Conforme'),
'non_compliant': _('Non conforme'),
'updated': _('Mis à jour'),
'unknown': _('Non inspecté'),
}
# Abstract base model for assets
class AbstractAsset(models.Model):
code = models.CharField(max_length=50)
@ -261,6 +269,7 @@ class AbstractAsset(models.Model):
serial_number = models.CharField(max_length=200,null=True,blank=True)
warranty_duration = models.IntegerField(null=True,blank=True, verbose_name="Duration of the warranty (months)")
last_inspection_date = models.DateTimeField(null=True,blank=True)
inspection_status = models.CharField(max_length=50, choices=ASSET_INSPECTION_STATUS_CHOICES, default='unknown', null=True, blank=True, verbose_name="Statut d'inspection")
last_preventive_date = models.DateTimeField(null=True,blank=True)
last_corrective_date = models.DateTimeField(null=True,blank=True)
last_survey_date = models.DateTimeField(null=True,blank=True)
@ -274,6 +283,7 @@ class AbstractAsset(models.Model):
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_updated_by', verbose_name="Mis à jour par")
def get_replacement_chain(self):
"""Get the complete replacement chain starting from this asset."""
chain = [self]

View file

@ -0,0 +1,172 @@
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
INSPECTION_RESULT_CHOICES = (
('compliant', _('Conforme')),
('non_compliant', _('Non conforme / Anomalie')),
('updated', _('Données corrigées')),
('damaged', _('Endommagé')),
('missing', _('Absent / Retiré')),
)
class AssetInspection(models.Model):
"""
Modèle d'enregistrement générique d'une vérification/inspection sur le terrain d'un asset.
Contient uniquement les données communes à tous les assets.
"""
asset_content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name=_("Type d'asset"),
limit_choices_to={'app_label': 'assets'}
)
asset_object_id = models.PositiveIntegerField(verbose_name=_("ID de l'asset"))
asset = GenericForeignKey('asset_content_type', 'asset_object_id')
inspector = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='asset_inspections',
verbose_name=_("Inspecteur / Vérificateur")
)
inspection_date = models.DateTimeField(default=timezone.now, verbose_name=_("Date d'inspection"))
result_status = models.CharField(
max_length=50,
choices=INSPECTION_RESULT_CHOICES,
default='compliant',
verbose_name=_("Résultat du contrôle")
)
verified_obsolescence = models.CharField(
max_length=50,
null=True,
blank=True,
verbose_name=_("État d'obsolescence constaté")
)
notes = models.TextField(null=True, blank=True, verbose_name=_("Remarques / Constats"))
apply_changes_to_asset = models.BooleanField(
default=True,
verbose_name=_("Appliquer directement les modifications à l'asset")
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Inspection d'asset (Générique)")
verbose_name_plural = _("Inspections d'assets (Génériques)")
ordering = ['-inspection_date']
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.apply_changes_to_asset and self.asset:
target_asset = self.asset
modified = False
if hasattr(target_asset, 'last_inspection_date'):
target_asset.last_inspection_date = self.inspection_date
modified = True
if hasattr(target_asset, 'inspection_status'):
target_asset.inspection_status = self.result_status
modified = True
if hasattr(target_asset, 'validation_status'):
target_asset.validation_status = 'validated'
modified = True
if hasattr(target_asset, 'is_compliant'):
if self.result_status in ('non_compliant', 'damaged', 'missing'):
target_asset.is_compliant = False
if hasattr(target_asset, 'non_compliance_reason') and self.notes:
target_asset.non_compliance_reason = self.notes
else:
target_asset.is_compliant = True
if hasattr(target_asset, 'non_compliance_reason'):
target_asset.non_compliance_reason = None
modified = True
if self.verified_obsolescence and hasattr(target_asset, 'obsolescence'):
target_asset.obsolescence = self.verified_obsolescence
modified = True
if modified:
target_asset.save()
def __str__(self):
asset_str = str(self.asset) if self.asset else f"Asset #{self.asset_object_id}"
date_str = self.inspection_date.strftime('%d/%m/%Y') if self.inspection_date else ''
return f"Inspection {asset_str} ({date_str}) - {self.get_result_status_display()}"
class ParkingSpotInspection(AssetInspection):
"""
Spécialisation de l'inspection pour les emplacements de stationnement (vélos, trottinettes, livraison, PMR).
"""
verified_standard_stand_count = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Nombre d'arceaux classiques constaté")
)
verified_cargo_stand_count = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Nombre d'arceaux cargo constaté")
)
verified_stand_type = models.CharField(
max_length=50,
null=True,
blank=True,
verbose_name=_("Type d'arceau constaté")
)
verified_has_scooter_drop_zone = models.BooleanField(
null=True,
blank=True,
verbose_name=_("Drop zone trottinette constatée")
)
verified_marking_quality = models.CharField(
max_length=50,
null=True,
blank=True,
verbose_name=_("Qualité du marquage constatée")
)
class Meta:
verbose_name = _("Inspection d'emplacement de stationnement")
verbose_name_plural = _("Inspections d'emplacements de stationnement")
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.apply_changes_to_asset and self.asset and self.asset.__class__.__name__ == 'ParkingSpot':
target_asset = self.asset
modified = False
if self.verified_standard_stand_count is not None:
target_asset.standard_stand_count = self.verified_standard_stand_count
modified = True
if self.verified_cargo_stand_count is not None:
target_asset.cargo_stand_count = self.verified_cargo_stand_count
modified = True
if self.verified_stand_type is not None:
target_asset.stand_type = self.verified_stand_type
modified = True
if self.verified_has_scooter_drop_zone is not None:
target_asset.has_scooter_drop_zone = self.verified_has_scooter_drop_zone
modified = True
if self.verified_marking_quality is not None:
target_asset.marking_quality = self.verified_marking_quality
modified = True
if modified:
target_asset.save()

View file

@ -0,0 +1,143 @@
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from assets.models import AbstractLocation, AbstractGeoAsset
from assets.models.sign import SignStreet, SignPole, SignPanel
SPOT_TYPE_CHOICES = (
('bicycle', _('Stationnement vélo')),
('scooter_drop_zone', _('Drop zone trottinettes / EDPM')),
('cargo_bike', _('Stationnement vélo cargo')),
('delivery', _('Zone de livraison / Chargement-déchargement')),
('pmr', _('Emplacement PMR')),
('kiss_and_ride', _('Arrêt minute / Court-durée (CD)')),
('car_parking', _('Stationnement automobile')),
('carsharing', _('Autopartage')),
('ev_charging', _('Recharge véhicule électrique')),
('other', _('Autre')),
)
STAND_TYPE_CHOICES = (
('normal', _('Arceau standard (U inversé)')),
('cargo', _('Arceau vélo cargo')),
('mixed', _('Mixte (normal et cargo)')),
('rack', _('Râteau / Pince-roue')),
('box', _('Box / Abri sécurisé')),
('other', _('Autre')),
)
MARKING_QUALITY_CHOICES = (
('good', _('Bon état')),
('medium', _('Moyen')),
('damaged', _('Dégradé')),
('missing', _('Absent / Effacé')),
)
class ParkingLocation(AbstractLocation):
"""
Modèle représentant un secteur ou zone d'ensemble de stationnement.
"""
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse"))
class Meta:
verbose_name = _("Zone de stationnement")
verbose_name_plural = _("Zones de stationnement")
ordering = ['code', 'name_fr']
class ParkingSpot(AbstractGeoAsset):
"""
Modèle représentant un emplacement ou zone de stationnement vélo/trottinette/PMR/livraison.
Utilise une géométrie en Polygone.
"""
geom = models.PolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise polygone"))
location = models.ForeignKey(
ParkingLocation,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='parking_spots',
verbose_name=_("Zone / Localisation parente")
)
street = models.ForeignKey(
SignStreet,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='parking_spots_new',
verbose_name=_("Rue")
)
spot_type = models.CharField(
max_length=50,
choices=SPOT_TYPE_CHOICES,
default='bicycle',
verbose_name=_("Type d'emplacement")
)
# Métriques et types d'arceaux vélos
standard_stand_count = models.IntegerField(default=0, verbose_name=_("Nombre d'arceaux vélos classiques"))
cargo_stand_count = models.IntegerField(default=0, verbose_name=_("Nombre d'arceaux vélos cargo"))
other_stand_count = models.IntegerField(default=0, verbose_name=_("Autres arceaux / agencements"))
stand_type = models.CharField(
max_length=50,
choices=STAND_TYPE_CHOICES,
default='normal',
verbose_name=_("Type d'arceau principal")
)
has_scooter_drop_zone = models.BooleanField(default=False, verbose_name=_("Drop zone trottinettes / EDPM"))
scooter_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Capacité estimée trottinettes"))
# Métriques générales & stationnement auto/livraison/PMR
capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Capacité générale (nbr véhicules)"))
is_paid = models.BooleanField(default=False, verbose_name=_("Payant"))
time_limit_minutes = models.IntegerField(null=True, blank=True, verbose_name=_("Durée maximale (minutes)"))
marking_quality = models.CharField(
max_length=50,
choices=MARKING_QUALITY_CHOICES,
default='good',
verbose_name=_("Qualité du marquage au sol")
)
is_temporary = models.BooleanField(default=False, verbose_name=_("Temporaire (chantier/événement)"))
# Conformité & résultat d'inspection
is_compliant = models.BooleanField(default=True, verbose_name=_("Conforme aux spécifications"))
non_compliance_reason = models.TextField(null=True, blank=True, verbose_name=_("Motif de non-conformité"))
# Signalisation associée
poles = models.ManyToManyField(
SignPole,
related_name='parking_spots_attached_new',
blank=True,
verbose_name=_("Poteaux de signalisation associés")
)
panels = models.ManyToManyField(
SignPanel,
related_name='parking_spots_attached_new',
blank=True,
verbose_name=_("Panneaux de signalisation associés")
)
@property
def total_stand_count(self):
"""Retourne le nombre total d'arceaux (classiques + cargo + autres)."""
return (self.standard_stand_count or 0) + (self.cargo_stand_count or 0) + (self.other_stand_count or 0)
class Meta:
verbose_name = _("Emplacement de stationnement")
verbose_name_plural = _("Emplacements de stationnement")
ordering = ['code', 'name_fr']
def get_absolute_url(self):
return reverse('assets:sign_assets_detail', kwargs={'asset_model': 'parking_spot', 'asset_id': self.id})
def __str__(self):
type_label = self.get_spot_type_display()
name = self.get_name()
if name:
return f"{self.code} - {type_label} ({name})"
return f"{self.code} - {type_label}"

View file

@ -0,0 +1,81 @@
from django.test import TestCase
from django.contrib.auth.models import User
from django.contrib.gis.geos import Polygon, MultiPolygon
from django.utils import timezone
from assets.models import ParkingLocation, ParkingSpot, AssetInspection, ParkingSpotInspection, SignPole, SignPanel
class ParkingSpotAndInspectionTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="inspector_user", password="password123")
poly = Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812)
multi_poly = MultiPolygon(poly, srid=3812)
self.parking_loc = ParkingLocation.objects.create(
code="PARK-LOC-01",
name_fr="Zone vélo Grand Place",
geom=multi_poly
)
self.spot = ParkingSpot.objects.create(
code="SPOT-VELO-01",
name_fr="Stationnement vélo Grand Place",
location=self.parking_loc,
spot_type="bicycle",
standard_stand_count=3,
cargo_stand_count=2,
stand_type="mixed",
has_scooter_drop_zone=True,
scooter_capacity=10,
geom=poly
)
def test_parking_spot_creation_and_stand_counts(self):
self.assertEqual(self.spot.standard_stand_count, 3)
self.assertEqual(self.spot.cargo_stand_count, 2)
self.assertEqual(self.spot.total_stand_count, 5)
self.assertTrue(self.spot.has_scooter_drop_zone)
self.assertTrue(self.spot.is_compliant)
self.assertEqual(self.spot.inspection_status, "unknown")
self.assertIsNotNone(self.spot.geojson)
def test_parking_spot_inspection_updates_asset(self):
# Using specialized ParkingSpotInspection model
inspection = ParkingSpotInspection.objects.create(
asset=self.spot,
inspector=self.user,
inspection_date=timezone.now(),
result_status="updated",
verified_standard_stand_count=4,
verified_cargo_stand_count=3,
verified_has_scooter_drop_zone=True,
verified_marking_quality="good",
apply_changes_to_asset=True,
notes="Ajout récent de 1 arceau classique et 1 arceau cargo"
)
self.spot.refresh_from_db()
self.assertEqual(self.spot.standard_stand_count, 4)
self.assertEqual(self.spot.cargo_stand_count, 3)
self.assertEqual(self.spot.total_stand_count, 7)
self.assertEqual(self.spot.validation_status, "validated")
self.assertEqual(self.spot.inspection_status, "updated")
self.assertIsNotNone(self.spot.last_inspection_date)
self.assertTrue(self.spot.is_compliant)
def test_generic_asset_inspection_non_compliance(self):
# Generic inspection works for any asset
inspection = AssetInspection.objects.create(
asset=self.spot,
inspector=self.user,
inspection_date=timezone.now(),
result_status="non_compliant",
notes="Arceau cargo tordu suite à un choc de livraison",
apply_changes_to_asset=True
)
self.spot.refresh_from_db()
self.assertFalse(self.spot.is_compliant)
self.assertEqual(self.spot.inspection_status, "non_compliant")
self.assertEqual(self.spot.non_compliance_reason, "Arceau cargo tordu suite à un choc de livraison")