loko/streetup/inspections/models.py

495 lines
18 KiB
Python

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_CONFIG = {
'compliant': {
'label': _('Conforme'),
'badge_class': 'bg-success',
'btn_class': 'btn-outline-success',
'icon': 'bi-check-circle-fill',
'color_hex': '#198754',
},
'non_compliant': {
'label': _('Non conforme'),
'badge_class': 'bg-warning text-dark',
'btn_class': 'btn-outline-warning text-dark',
'icon': 'bi-exclamation-triangle-fill',
'color_hex': '#ffc107',
},
'damaged': {
'label': _('Endommagé'),
'badge_class': 'bg-danger',
'btn_class': 'btn-outline-danger',
'icon': 'bi-x-circle-fill',
'color_hex': '#dc3545',
},
'missing': {
'label': _('Absent / Retiré'),
'badge_class': 'bg-dark',
'btn_class': 'btn-outline-dark',
'icon': 'bi-dash-circle-fill',
'color_hex': '#212529',
},
}
INSPECTION_RESULT_CHOICES = tuple(
(code, cfg['label']) for code, cfg in INSPECTION_RESULT_CONFIG.items()
)
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 Inspection(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')
# Liaisons optionnelles avec les Interventions
mission_intervention = models.ForeignKey(
'interventions.Intervention',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='generated_inspections',
verbose_name=_("Intervention / Mission d'inspection")
)
checked_intervention = models.ForeignKey(
'interventions.Intervention',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='quality_inspections',
verbose_name=_("Intervention de travaux contrôlée")
)
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='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 à la cible")
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Inspection (Générique)")
verbose_name_plural = _("Inspections (Génériques)")
ordering = ['-inspection_date']
@property
def is_mission_inspection(self):
return self.mission_intervention_id is not None
@property
def is_cross_check(self):
return self.checked_intervention_id is not None
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):
return INSPECTION_RESULT_CONFIG.get(self.result_status, {}).get('badge_class', 'bg-secondary')
@property
def status_btn_class(self):
return INSPECTION_RESULT_CONFIG.get(self.result_status, {}).get('btn_class', 'btn-outline-secondary')
@property
def status_icon(self):
return INSPECTION_RESULT_CONFIG.get(self.result_status, {}).get('icon', 'bi-question-circle')
@property
def status_color_hex(self):
return INSPECTION_RESULT_CONFIG.get(self.result_status, {}).get('color_hex', '#6c757d')
@classmethod
def get_result_choices_config(cls):
"""Retourne la liste des configurations pour chaque statut de résultat (code, label, badge_class, btn_class, icon, color_hex)."""
return [
{'code': code, **cfg}
for code, cfg in INSPECTION_RESULT_CONFIG.items()
]
@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, GiepInspection)."""
if hasattr(self, 'parkingspotinspection'):
return self.parkingspotinspection
if hasattr(self, 'giepinspection'):
return self.giepinspection
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 hasattr(leaf, 'data') and isinstance(leaf.data, dict) and leaf.data:
ouvrages = leaf.data.get('ouvrages', [])
if ouvrages:
parts.append(str(_("%(count)d ouvrage(s) GIEP") % {'count': len(ouvrages)}))
if getattr(leaf, 'date_derniere_pluie', None) and leaf.date_derniere_pluie != 'null':
parts.append(str(_("Pluie : %s") % leaf.get_date_derniere_pluie_display()))
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"Cible #{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()}"
# Alias de compatibilité
AssetInspection = Inspection
class ParkingSpotInspection(Inspection):
"""
Spécialisation de l'inspection pour les emplacements de stationnement.
"""
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_drop_off_stand_count = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Nombre d'arceaux drop-off 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_drop_off_stand_count is not None and hasattr(target_asset, 'drop_off_stand_count'):
target_asset.drop_off_stand_count = self.verified_drop_off_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()
class GiepInspection(Inspection):
"""
Spécialisation de l'inspection pour les zones / ouvrages de gestion infiltrative des eaux pluviales (GIEP).
"""
DATE_DERNIERE_PLUIE_CHOICES = (
('aujourdhui', _("Aujourd'hui")),
('moins_24h', _("Moins de 24h")),
('plus_24h', _("Plus de 24h")),
('null', _("Non vérifié")),
)
date_derniere_pluie = models.CharField(
max_length=50,
choices=DATE_DERNIERE_PLUIE_CHOICES,
default='null',
null=True,
blank=True,
verbose_name=_("Date de la dernière pluie")
)
orage_moins_une_heure = models.BooleanField(
null=True,
blank=True,
verbose_name=_("Orage il y a moins d'une heure")
)
data = models.JSONField(
default=dict,
blank=True,
verbose_name=_("Données structurées d'inspection GIEP")
)
class Meta:
verbose_name = _("Inspection GIEP / Eaux pluviales")
verbose_name_plural = _("Inspections GIEP / Eaux pluviales")
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.
"""
inspection = models.ForeignKey(
Inspection,
related_name='documents',
on_delete=models.CASCADE,
verbose_name=_("Inspection")
)
field_key = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name=_("Clé de question / Média"),
help_text=_("Identifie la question ou l'ouvrage auquel ce média est rattaché (vide = général).")
)
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('inspections', 'InspectionDocument', self.pk, 'file')
def __str__(self):
return f"Document {self.filename} (Inspection #{self.inspection_id})"