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 _ from common.storages import PrivateMediaStorage private_storage = PrivateMediaStorage() INSPECTION_RESULT_CHOICES = ( ('compliant', _('Conforme')), ('non_compliant', _('Non conforme / Anomalie')), ('updated', _('Données corrigées')), ('damaged', _('Endommagé')), ('missing', _('Absent / Retiré')), ) INSPECTION_NATURE_CHOICES = ( ('visual', _('Inspection visuelle')), ('technical', _('Inspection technique')), ('post_intervention', _('Suivi / Qualité post-intervention')), ) INSPECTOR_ROLE_CHOICES = ( ('observer', _('Observer / Agent de terrain')), ('provider', _('Prestataire technique externe')), ('manager', _('Gestionnaire / Contrôleur interne')), ) class AssetInspection(models.Model): """ Modèle d'enregistrement générique d'une vérification/inspection sur le terrain d'une cible (Asset, Localisation, Intervention). """ asset_content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, verbose_name=_("Type d'asset / Cible"), limit_choices_to={'app_label__in': ['assets', 'interventions']} ) asset_object_id = models.PositiveIntegerField(verbose_name=_("ID de la cible")) asset = GenericForeignKey('asset_content_type', 'asset_object_id') nature = models.CharField( max_length=50, choices=INSPECTION_NATURE_CHOICES, default='visual', verbose_name=_("Nature de l'inspection") ) inspector_role = models.CharField( max_length=50, choices=INSPECTOR_ROLE_CHOICES, default='observer', verbose_name=_("Rôle de l'inspecteur") ) 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() @property def status_badge_class(self): mapping = { 'compliant': 'bg-success', 'non_compliant': 'bg-warning text-dark', 'damaged': 'bg-danger', 'missing': 'bg-dark', 'updated': 'bg-info text-dark', } return mapping.get(self.result_status, 'bg-secondary') @property def target_display(self): if self.asset: return str(self.asset) return f"Cible #{self.asset_object_id}" @property def thematic(self): from assets.permissions import _get_asset_thematic if self.asset: return _get_asset_thematic(self.asset) return None @property def lon_lat(self): """Retourne les coordonnées (lon, lat) WGS84 du centroïde de la cible s'il est géolocalisé.""" if not self.asset: return None if hasattr(self.asset, 'lon') and hasattr(self.asset, 'lat') and self.asset.lon and self.asset.lat: return (float(self.asset.lon), float(self.asset.lat)) if hasattr(self.asset, 'geom') and self.asset.geom: g = self.asset.geom if g.srid and g.srid != 4326: try: g = g.transform(4326, clone=True) except Exception: pass centroid = g.centroid return (float(centroid.x), float(centroid.y)) return None @property def thematic_icon(self): th = self.thematic if th and hasattr(th, 'icon') and th.icon: return th.icon return 'bi-geo-alt' @property def detailed_inspection(self): """Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection).""" if hasattr(self, 'parkingspotinspection'): return self.parkingspotinspection return self @property def details_summary(self): """Résumé textuel concis des constatations spécifiques de l'inspection.""" leaf = self.detailed_inspection parts = [] if hasattr(leaf, 'verified_standard_stand_count') and leaf.verified_standard_stand_count is not None: parts.append(str(_("%(count)d arceaux classiques") % {'count': leaf.verified_standard_stand_count})) if hasattr(leaf, 'verified_cargo_stand_count') and leaf.verified_cargo_stand_count is not None: parts.append(str(_("%(count)d arceaux cargo") % {'count': leaf.verified_cargo_stand_count})) if hasattr(leaf, 'verified_has_scooter_drop_zone') and leaf.verified_has_scooter_drop_zone is not None: parts.append(str(_("Drop-zone trottinette") if leaf.verified_has_scooter_drop_zone else _("Sans drop-zone"))) if hasattr(leaf, 'verified_marking_quality') and leaf.verified_marking_quality: marking_labels = { 'good': _("Marquage bon état"), 'medium': _("Marquage moyen"), 'damaged': _("Marquage dégradé"), 'missing': _("Marquage absent / effacé"), 'bad': _("Marquage mauvais"), } val = marking_labels.get(leaf.verified_marking_quality, leaf.verified_marking_quality) parts.append(str(val)) if leaf.verified_obsolescence: parts.append(str(_("Obsolescence : %s") % leaf.verified_obsolescence)) return ", ".join(parts) if parts else "-" def get_asset_url(self, is_mobile=False): """Retourne l'URL de détail de l'asset cible (route mobile /mobile/... si is_mobile est True).""" if not self.asset: return None if is_mobile: from django.urls import reverse from assets.permissions import _get_asset_thematic th = _get_asset_thematic(self.asset) thematic_code = th.code if th else 'parking' asset_model = self.asset.__class__.__name__.lower() try: return reverse('mobile:mobile_asset_detail', kwargs={ 'thematic_code': thematic_code, 'asset_model': asset_model, 'asset_id': self.asset.id }) except Exception: pass if hasattr(self.asset, 'get_absolute_url'): return self.asset.get_absolute_url() return None @property def mobile_asset_url(self): """Propriété raccourcie pour l'URL mobile de l'asset.""" return self.get_asset_url(is_mobile=True) 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") ) MARKING_QUALITY_CHOICES = ( ('good', _("Bon état")), ('medium', _("Moyen")), ('damaged', _("Dégradé")), ('missing', _("Absent / Effacé")), ) verified_marking_quality = models.CharField( max_length=50, choices=MARKING_QUALITY_CHOICES, null=True, blank=True, verbose_name=_("Qualité du marquage constatée") ) apply_physical_counts_to_asset = models.BooleanField( default=False, verbose_name=_("Mettre à jour la capacité / comptages de l'asset") ) 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_physical_counts_to_asset and self.asset: target_asset = self.asset modified = False if self.verified_standard_stand_count is not None and hasattr(target_asset, 'standard_stand_count'): target_asset.standard_stand_count = self.verified_standard_stand_count modified = True if self.verified_cargo_stand_count is not None and hasattr(target_asset, 'cargo_stand_count'): target_asset.cargo_stand_count = self.verified_cargo_stand_count modified = True if self.verified_has_scooter_drop_zone is not None and hasattr(target_asset, 'has_scooter_drop_zone'): target_asset.has_scooter_drop_zone = self.verified_has_scooter_drop_zone modified = True if self.verified_marking_quality and hasattr(target_asset, 'marking_quality'): target_asset.marking_quality = self.verified_marking_quality modified = True if modified: target_asset.save() def inspection_document_upload_path(instance, filename): now = timezone.now() year = now.strftime("%Y") month = now.strftime("%m") return f"inspections/documents/{year}/{month}/{filename}" class InspectionDocument(models.Model): """ Document / Photo / Pièce jointe associée à une inspection d'asset. """ inspection = models.ForeignKey( AssetInspection, related_name='documents', on_delete=models.CASCADE, verbose_name=_("Inspection") ) file = models.FileField( upload_to=inspection_document_upload_path, storage=private_storage, verbose_name=_("Fichier / Photo / Document") ) created_at = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = _("Document / Photo d'inspection") verbose_name_plural = _("Documents / Photos d'inspection") @property def is_image(self): if not self.file or not self.file.name: return False ext = self.file.name.split('.')[-1].lower() return ext in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'bmp'] @property def filename(self): import os return os.path.basename(self.file.name) if self.file else "" @property def file_url(self): if not self.file: return "" from common.private_files.helpers import build_private_url return build_private_url('assets', 'InspectionDocument', self.pk, 'file') def __str__(self): return f"Document {self.filename} (Inspection #{self.inspection_id})"