From dad5acc4fb12fd8004517ad25f54d4deded6c3b9 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 7 Aug 2026 10:09:20 +0200 Subject: [PATCH] feat: implement parking spot models and generic asset inspection framework --- streetup/assets/admin.py | 46 +++- ...tinspection_parkinglocation_parkingspot.py | 125 +++++++++ ...verified_has_scooter_drop_zone_and_more.py | 254 ++++++++++++++++++ streetup/assets/models/__init__.py | 4 +- streetup/assets/models/core.py | 10 + streetup/assets/models/inspection.py | 172 ++++++++++++ streetup/assets/models/parking.py | 143 ++++++++++ streetup/assets/tests_parking_inspections.py | 81 ++++++ 8 files changed, 833 insertions(+), 2 deletions(-) create mode 100644 streetup/assets/migrations/0008_assetinspection_parkinglocation_parkingspot.py create mode 100644 streetup/assets/migrations/0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more.py create mode 100644 streetup/assets/models/inspection.py create mode 100644 streetup/assets/models/parking.py create mode 100644 streetup/assets/tests_parking_inspections.py diff --git a/streetup/assets/admin.py b/streetup/assets/admin.py index 3028c35..13cf680 100644 --- a/streetup/assets/admin.py +++ b/streetup/assets/admin.py @@ -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) \ No newline at end of file +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",) \ No newline at end of file diff --git a/streetup/assets/migrations/0008_assetinspection_parkinglocation_parkingspot.py b/streetup/assets/migrations/0008_assetinspection_parkinglocation_parkingspot.py new file mode 100644 index 0000000..38ece68 --- /dev/null +++ b/streetup/assets/migrations/0008_assetinspection_parkinglocation_parkingspot.py @@ -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), + ), + ] diff --git a/streetup/assets/migrations/0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more.py b/streetup/assets/migrations/0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more.py new file mode 100644 index 0000000..d89825d --- /dev/null +++ b/streetup/assets/migrations/0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more.py @@ -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',), + ), + ] diff --git a/streetup/assets/models/__init__.py b/streetup/assets/models/__init__.py index 7853568..faaca3d 100644 --- a/streetup/assets/models/__init__.py +++ b/streetup/assets/models/__init__.py @@ -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 \ No newline at end of file +from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument +from .parking import ParkingLocation, ParkingSpot +from .inspection import AssetInspection, ParkingSpotInspection \ No newline at end of file diff --git a/streetup/assets/models/core.py b/streetup/assets/models/core.py index a24715d..0831519 100644 --- a/streetup/assets/models/core.py +++ b/streetup/assets/models/core.py @@ -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] diff --git a/streetup/assets/models/inspection.py b/streetup/assets/models/inspection.py new file mode 100644 index 0000000..5fbe2d3 --- /dev/null +++ b/streetup/assets/models/inspection.py @@ -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() diff --git a/streetup/assets/models/parking.py b/streetup/assets/models/parking.py new file mode 100644 index 0000000..a0004ee --- /dev/null +++ b/streetup/assets/models/parking.py @@ -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}" diff --git a/streetup/assets/tests_parking_inspections.py b/streetup/assets/tests_parking_inspections.py new file mode 100644 index 0000000..24e8366 --- /dev/null +++ b/streetup/assets/tests_parking_inspections.py @@ -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")