320 lines
11 KiB
Python
320 lines
11 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_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 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")
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
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 ""
|
|
|
|
def __str__(self):
|
|
return f"Document {self.filename} (Inspection #{self.inspection_id})"
|