from django.contrib.gis.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.conf import settings from django.utils.translation import get_language, gettext_lazy as _ from django.utils import timezone from django.db.models import Sum, F, Q from django.db.models.functions import Coalesce import os from datetime import datetime, timedelta from decimal import Decimal from common.models import Thematic, Document from common.storages import PrivateMediaStorage private_storage = PrivateMediaStorage() from assets.models import AssetCategory from contracts.models import Contract, ContractPost, ContractOrder, Company, CompanyTeam, CompanyMember STATUS_CHOICES = { 'in_preparation': _('En préparation'), # status_order 1 'to_be_approved': _('A approuver'), # status_order 2 'to_be_planned': _('A planifier'), # status_order 5 'to_be_processed': _('A traiter'), # status_order 11 'assigned': _('Pris en charge'), # status_order 15 'in_progress': _('Démarré'), # status_order 21 'on_pause': _('En pause'), # status_order 22 'finished': _('Terminé'), # status_order 25 'processed': _('Traité'), # status_order 31 'to_be_corrected': _('A corriger'), # status_order 41 'corrected': _('Corrigé'), # status_order 45 'validated': _('Validé'), # status_order 51 'invoiced': _('Comptabilisé'), # status_order 61 'closed': _('Finalisé'), # status_order 71 # 'archived': _('Archivé'), # status_order 100 'canceled': _('Annulé'), # status_order 200 } STATUS_ORDERS = { 'in_preparation': 1, 'to_be_approved': 2, 'to_be_planned': 5, 'to_be_processed': 11, 'assigned': 15, 'in_progress': 21, 'on_pause': 22, 'finished': 25, 'processed': 31, 'to_be_corrected': 41, 'corrected': 45, 'validated': 51, 'invoiced': 61, 'closed': 71, #'archived': 100, 'canceled': 200, } # Couleurs canoniques des statuts (hex). Source de vérité partagée avec status_colors.js. STATUS_COLORS = { 'in_preparation': '#757575', 'to_be_approved': '#ffb74d', 'to_be_planned': '#ff8a80', 'to_be_processed': '#f4511e', 'assigned': '#63b3ed', 'in_progress': '#3182ce', 'on_pause': '#90a4ae', 'finished': '#26a69a', 'processed': '#9ae9e2', 'to_be_corrected': '#9e87f6', 'corrected': '#81c784', 'validated': '#388e3c', 'invoiced': '#738f7a', 'closed': '#09425f', 'canceled': '#cfd8dc', } # Statuts dont le texte doit être sombre (fond clair). STATUS_COLORS_DARK_TEXT = {'to_be_approved', 'to_be_planned', 'assigned', 'on_pause', 'processed', 'to_be_corrected', 'corrected', 'canceled'} PRIORITY_CHOICES = { '1': _('Urgent'), '2': _('Elevé'), '3': _('Normal'), '4': _('Bas'), } MAINTAIN_CHOICES = [ ('corrective', _('Maintenance corrective')), ('preventive', _('Maintenance préventive')), ('ameliorative', _('Maintenance améliorative')), ] TYPE_CHOICES = [ ('fixing', _('Dépannage')), ('repair', _('Réparation')), ('repair_post_preventive', _('Réparation post préventif')), ('replace', _('Remplacement')), ('care', _('Entretien')), ('control', _('Contrôle')), ('inspection', _('Inspection')), ('build', _('Construction')), ('collection', _('Ramassage')), ] ORIGIN_TYPE_CHOICES = [ ('damage', _('Avarie')), ('defect', _('Défaut/Panne')), ('demand', _('Demande')) ] PAUSE_REASON_CHOICES = ( ("reschedule", _("Intervention à replanifier")), ("order_material", _("Matériel à commander")), ("treated_in_next_maintenance", _("Traité dans le prochain entretien")), ("waiting_for_material_delivery", _("En attente livraison de matériel")), ("waiting_for_supplier", _("En attente sous-traitant")), ("offer_by_provider", _("Offre à faire par le prestataire")), ("waiting_for_validation", _("En attente de validation")), ("under_framework_contract", _("Sous garantie marché transversal")), ("other", _("Autre")), ) CANCELLATION_TYPE_CHOICES = ( ("duplicate", _("Doublon")), ("to_redirect", _("A rediriger")), ("not_our_assets", _("Pas sous notre gestion")), ("non_relevant", _("Non-relevant")), ("other", _("Autre")), ) class SourceCategory(models.Model): name_fr = models.CharField(max_length=200) name_nl = models.CharField(max_length=200) description = models.TextField(null=True,blank=True) has_reference = models.BooleanField(default=False) is_active = models.BooleanField(default=True) def getName(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr def __str__(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr class Meta: ordering = ["name_fr"] SYMPTOM_MAINTENANCE_TYPE_CHOICES = { 'infra': _('TL - Infrastructure'), 'controller': _('TL - Contrôleur de traffic'), 'electromeca': _('TU - Electromécanique'), 'civil_engineering': _('TU - Génie civil'), 'equipments': _('ITS - Équipement'), 'clean': _('Clean - Nettoyage'), 'collection': _('Clean - Ramassage'), 'other': _('Autre'), } class Symptom(models.Model): code = models.CharField(max_length=50, unique=True, null=True, blank=True, help_text=_("Code unique optionnel pour identifier le symptôme (ex: pour les raccourcis).")) name_fr = models.CharField(max_length=255) name_nl = models.CharField(max_length=255) thematic = models.ForeignKey(Thematic, on_delete=models.PROTECT) asset_categories = models.ManyToManyField( AssetCategory, blank=True, related_name='symptoms', ) priority = models.CharField(choices=PRIORITY_CHOICES, default='3') check_priority = models.BooleanField(default=False, help_text=_("Indique si la priorité doit être vérifiée.")) origin_type = models.CharField(choices=ORIGIN_TYPE_CHOICES, default='damage', null=True, blank=True) provider = models.ForeignKey(Company, on_delete=models.SET_NULL, null=True, blank=True) contract = models.ForeignKey(Contract, on_delete=models.SET_NULL, null=True, blank=True) maintenance_type = models.CharField(max_length=50, choices=SYMPTOM_MAINTENANCE_TYPE_CHOICES, null=True, blank=True, help_text=_("Type de maintenance concerné (ex: 'controller' pour les contrôleurs de traffic).")) require_equipment_selection = models.BooleanField( default=False, verbose_name=_("Équipement obligatoire"), help_text=_("Si coché, une intervention avec ce symptôme doit obligatoirement être liée à un équipement."), ) teams = models.ManyToManyField( CompanyTeam, blank=True, related_name='symptoms', verbose_name=_("Équipes désignées"), help_text=_("Équipes désignées pour prendre en charge les observations liées à ce symptôme."), ) order_number = models.IntegerField(null=True, blank=True) is_active = models.BooleanField(default=True) recommended_next_status = models.CharField(choices=STATUS_CHOICES, null=True, blank=True, max_length=50, default='to_be_processed') auto_create_intervention = models.BooleanField( default=False, verbose_name=_("Création automatique d'interventions"), help_text=_("Si coché, une intervention est automatiquement créée lorsque l'observation passe au statut 'À traiter'.") ) auto_create_intervention_status = models.CharField( max_length=50, choices=STATUS_CHOICES, default='to_be_processed', null=True, blank=True, verbose_name=_("Statut de l'intervention auto-créée"), help_text=_("Statut dans lequel l'intervention automatique doit être créée.") ) internal_notification_emails = models.CharField(blank=True, null=True, max_length=1000, help_text=_("Liste d'emails pour les notifications internes.")) def __str__(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr def get_name(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr class Meta: ordering = ["thematic", "order_number"] class SymptomRecommendation(models.Model): """ Modèle pour les recommandations associées aux symptômes. Les textes peuvent contenir des variables entre doubles crochets qui seront remplacées par les valeurs du contrat associé à l'intervention : - [[phone_number]] : Numéro de téléphone de contact du contrat - [[contract_number]] : Numéro du contrat - [[company_name]] : Nom de la société/prestataire """ symptom = models.ForeignKey(Symptom, on_delete=models.CASCADE, related_name='recommendations') icon = models.CharField(max_length=255, null=True, blank=True) text_fr = models.TextField() text_nl = models.TextField() order_number = models.PositiveIntegerField(null=True, blank=True) document = models.FileField(upload_to='symptom_recommendations/', storage=private_storage, null=True, blank=True) confirm_call = models.BooleanField(default=False) # Variables disponibles pour le remplacement dans les textes TEMPLATE_VARIABLES = { 'phone_number': lambda contract: contract.contact_phone if contract else None, 'contract_number': lambda contract: contract.contract_number if contract else None, 'company_name': lambda contract: contract.company.name if contract and contract.company else None, 'company_phone': lambda contract: contract.company.phone_number if contract and contract.company else None, } def get_rendered_text(self, contract=None, lang=None): """ Retourne le texte de la recommandation avec les variables remplacées. Les variables entre doubles crochets [[variable_name]] sont remplacées par les valeurs correspondantes du contrat. Args: contract: Instance du contrat pour récupérer les valeurs lang: Code langue ('fr' ou 'nl'). Si None, utilise la langue courante. Returns: str: Texte avec les variables remplacées """ import re if lang is None: lang = get_language() text = self.text_nl if lang == 'nl' else self.text_fr def replace_variable(match): var_name = match.group(1) if var_name in self.TEMPLATE_VARIABLES: value = self.TEMPLATE_VARIABLES[var_name](contract) return value if value else f'[[{var_name}]]' return match.group(0) # Remplace les variables [[variable_name]] par leurs valeurs rendered_text = re.sub(r'\[\[([\w_]+)\]\]', replace_variable, text) return rendered_text def __str__(self): lang = get_language() return self.text_nl if lang == 'nl' else self.text_fr class Meta: ordering = ['order_number'] class ReflexSheet(models.Model): title_fr = models.CharField(max_length=255) title_nl = models.CharField(max_length=255) symptoms = models.ManyToManyField(Symptom, related_name='reflex_sheets') contact_persons = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name='reflex_sheets') comment_fr = models.TextField(blank=True, null=True) comment_nl = models.TextField(blank=True, null=True) asset_category = models.ForeignKey(AssetCategory, on_delete=models.SET_NULL, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) is_active = models.BooleanField(default=True) thematic = models.ForeignKey(Thematic, on_delete=models.PROTECT) def __str__(self): lang = get_language() return self.title_nl if lang == 'nl' else self.title_fr class Meta: ordering = ['thematic', 'title_fr'] def intervention_sheet_upload_path(instance, filename): # On récupère l'année et le mois actuels now = timezone.now() year = now.strftime("%Y") month = now.strftime("%m") # On construit le chemin avec l'ID de l'intervention (attention : doit être déjà enregistrée) intervention_id = instance.id return f"intervention_documents/{year}/{month}/{intervention_id}/{filename}" class Intervention(models.Model): code = models.CharField(max_length=50, unique=True, blank=True, db_index=True) title = models.CharField(max_length=200) lon = models.FloatField(null=True,blank=True) lat = models.FloatField(null=True,blank=True) address = models.CharField(max_length=500, null=True, blank=True) location_code = models.CharField(max_length=500, null=True, blank=True) # Location code status = models.CharField(choices=STATUS_CHOICES, default='in_preparation') status_order = models.IntegerField(default=1) # Order of the status in the workflow pause_reason = models.CharField(choices=PAUSE_REASON_CHOICES, max_length=50, null=True, blank=True) pause_reason_other = models.CharField(max_length=255, null=True, blank=True) cancellation_type = models.CharField(choices=CANCELLATION_TYPE_CHOICES, max_length=50, null=True, blank=True) priority = models.CharField(choices=PRIORITY_CHOICES, default='3') description = models.TextField(null=True,blank=True) maintain_type = models.CharField(choices=MAINTAIN_CHOICES, default='corrective') type = models.CharField(choices=TYPE_CHOICES,null=True,blank=True) origin_type = models.CharField(choices=ORIGIN_TYPE_CHOICES, default='damage') begin_time = models.DateTimeField(null=True,blank=True) end_time = models.DateTimeField(null=True,blank=True) planned_begin_time = models.DateTimeField(null=True, blank=True) planned_end_time = models.DateTimeField(null=True, blank=True) expected_begin_time = models.DateTimeField(null=True, blank=True) expected_end_time = models.DateTimeField(null=True, blank=True) source_category = models.ForeignKey(SourceCategory, on_delete=models.SET_NULL, null=True, blank=True) source_detail = models.CharField(null=True,blank=True) source_ref = models.CharField(null=True,blank=True) pmo_number = models.IntegerField(null=True, blank=True, verbose_name=_("Numéro PMO")) asset_category = models.ForeignKey(AssetCategory, on_delete=models.SET_NULL, null=True, blank=True) symptom = models.ForeignKey(Symptom, on_delete=models.SET_NULL, null=True, blank=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="created_interventions", null=True, blank=True) creation_time = models.DateTimeField(auto_now_add=True) last_modified_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="modified_interventions", null=True, blank=True) last_modified_time = models.DateTimeField(auto_now=True, null=True, blank=True) transmission_time = models.DateTimeField(null=True,blank=True) init_call_confirmed_time = models.DateTimeField(null=True,blank=True) init_call_received = models.BooleanField(default=False) init_description = models.TextField(null=True,blank=True) contract = models.ForeignKey(Contract, on_delete=models.SET_NULL, null=True, blank=True, related_name="interventions") intervention_manager = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_interventions", verbose_name=_("Gestionnaire d'intervention"), help_text=_("Utilisateur qui approuve cette intervention (spécifique aux contrats concernés)."), ) order = models.ForeignKey(ContractOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="interventions") assigned_provider = models.ForeignKey(Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_interventions") assigned_team = models.ForeignKey(CompanyTeam, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_interventions") assigned_member = models.ForeignKey(CompanyMember, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_interventions") osiris = models.IntegerField(null=True, blank=True) linked_folder = models.CharField(max_length=1024, null=True, blank=True) intervention_sheet = models.FileField(upload_to=intervention_sheet_upload_path, storage=private_storage, null=True, blank=True) thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True) geom = models.MultiPolygonField(srid=3812, null=True,blank=True) # Validation par périodes period_validation_enabled = models.BooleanField(default=False) period_number = models.PositiveIntegerField(default=1) period_is_final = models.BooleanField(default=False) equipment_operation_templates = models.ManyToManyField( 'OperationTemplate', blank=True, related_name='interventions_equipment', ) guided_operation_template = models.ForeignKey( 'GuidedOperationTemplate', on_delete=models.SET_NULL, null=True, blank=True, related_name='interventions', verbose_name=_("Template d'opérations guidées"), help_text=_("Assigné automatiquement lors du choix d'un template d'opérations avec un template guidé associé.") ) # Indique si l'intervention a été créée par un prestataire (external_manager) created_by_provider = models.BooleanField( default=False, verbose_name="Créée par le prestataire", help_text="Indique que cette intervention a été créée par un prestataire externe." ) # Indique si le traitement est réalisé via une autre intervention processed_via_other_intervention = models.BooleanField( default=False, verbose_name="Traitement via une autre intervention", help_text="Indique que le traitement de cette intervention est réalisé via une autre intervention." ) # Raison de la correction demandée (quand le statut passe à 'à corriger') reason_for_correction = models.TextField( null=True, blank=True, verbose_name="Raison de la correction", help_text="Raison pour laquelle une correction est demandée." ) # Détails de ce qui a été corrigé (quand le statut passe à 'corrigé') correction_details = models.TextField( null=True, blank=True, verbose_name="Détails de la correction", help_text="Détails de ce qui a été corrigé." ) # Vérification : l'intervention a été vérifiée par un utilisateur ayant le droit can_check_interventions is_checked = models.BooleanField( default=False, verbose_name=_("Vérifiée"), help_text=_("Indique si l'intervention a été vérifiée par un utilisateur habilité.") ) # Résultat du contrôle de suivi FOLLOW_UP_RESULT_CHOICES = [ ('ok', _('OK')), ('attention', _('Points d’attention')), ('not_ok', _('Not OK')), ] follow_up_performed = models.CharField( max_length=20, choices=FOLLOW_UP_RESULT_CHOICES, null=True, blank=True, default=None, verbose_name=_("Résultat du contrôle"), help_text=_("Résultat du contrôle / rapport de suivi.") ) def save(self, *args, **kwargs): is_new = self._state.adding # True uniquement lors de la création status_changed_to_closed = False status_changed_to_validated = False old_status = None if not is_new: old_status = Intervention.objects.filter(pk=self.pk).values_list('status', flat=True).first() if old_status and old_status != 'closed' and self.status == 'closed': status_changed_to_closed = True if old_status and old_status != 'validated' and self.status == 'validated': status_changed_to_validated = True else: if self.status == 'validated': status_changed_to_validated = True if not self.code: # Generate code only if not defined next_number = InterventionSequence.get_next_number() self.code = f"I{str(next_number).zfill(6)}" # Prefix "I" followed by the number if self.status in STATUS_ORDERS: self.status_order = STATUS_ORDERS[self.status] if is_new and self.created_by_id: from common.models import UserConfig # import local pour éviter les imports circulaires user_config = UserConfig.objects.filter(user_id=self.created_by_id).first() if user_config and not user_config.is_intern: self.created_by_provider = True super().save(*args, **kwargs) # Sync daily assignments history self.sync_daily_assignments() if status_changed_to_validated: from interventions.utils import create_preventive_intervention_report import logging try: create_preventive_intervention_report(self) except Exception: logging.getLogger(__name__).exception( f"Error automatically generating preventive report for intervention {self.code}" ) if status_changed_to_closed: from observations.models import Observation, ObservationComment linked_obs = self.observations.exclude(status='closed') for obs in linked_obs: obs.status = 'closed' obs.save(update_fields=['status']) try: ObservationComment.objects.create( observation=obs, comment=f"Observation clôturée automatiquement suite à la clôture de l'intervention {self.code}", ) except Exception: pass if old_status and old_status != self.status: self.notify_subscribers(old_status, self.status) def notify_subscribers(self, old_status, new_status): from django.contrib.auth.models import User from django.urls import reverse from django.utils.translation import gettext as _ from notifications.services import notify_users subscribers = User.objects.filter(intervention_subscriptions__intervention=self) if self.last_modified_by: subscribers = subscribers.exclude(pk=self.last_modified_by.pk) if subscribers.exists(): old_label = dict(STATUS_CHOICES).get(old_status, old_status) new_label = dict(STATUS_CHOICES).get(self.status, self.status) title = _("Mise à jour de l'intervention %(code)s") % {'code': self.code} content = _("L'intervention « %(title)s » est passée du statut « %(old)s » à « %(new)s ».") % { 'title': self.title, 'old': old_label, 'new': new_label } action_url = reverse('interventions:intervention_detail', args=[self.id]) try: notify_users( recipients=subscribers, title=title, content=content, notification_type='info', action_url=action_url, action_label=_("Voir l'intervention"), source_app='interventions', sender=self.last_modified_by, is_system=True, ) except Exception: import logging logging.getLogger(__name__).exception( f"Error sending subscription notification for intervention {self.code}" ) def sync_daily_assignments(self): """Synchronise les assignations quotidiennes de l'intervention.""" start_dt = self.planned_begin_time or self.begin_time or self.expected_begin_time or self.creation_time end_dt = self.planned_end_time or self.end_time or self.expected_end_time or start_dt if not start_dt: return start_date = start_dt.date() end_date = end_dt.date() from datetime import timedelta active_dates = set() curr = start_date while curr <= end_date: active_dates.add(curr) curr += timedelta(days=1) today = timezone.now().date() if self.assigned_team: for d in active_dates: if d < today: # Pour les dates passées, on conserve l'historique : get_or_create pour ne pas écraser InterventionAssignment.objects.get_or_create( intervention=self, date=d, defaults={ 'team': self.assigned_team, 'member': self.assigned_member, 'status': self.status, } ) else: # Pour aujourd'hui ou le futur, on met à jour en cas d'erreur ou réassignation InterventionAssignment.objects.update_or_create( intervention=self, date=d, defaults={ 'team': self.assigned_team, 'member': self.assigned_member, 'status': self.status, } ) # Nettoyage des assignations futures obsolètes uniquement qs_to_delete = InterventionAssignment.objects.filter(intervention=self).exclude(date__in=active_dates) qs_to_delete.filter(date__gte=today).delete() else: # Si l'intervention n'a plus d'équipe assignée, supprimer toutes les assignations pour aujourd'hui et le futur InterventionAssignment.objects.filter(intervention=self, date__gte=today).delete() def __str__(self): return f"{self.code} - {self.title}" class Meta: ordering = ["-creation_time"] indexes = [ models.Index(fields=['status', 'thematic']), models.Index(fields=['status', 'creation_time']), models.Index(fields=['thematic', 'creation_time']), # Accélère l'agrégat par (thématique, statut, type de maintenance) models.Index(fields=['thematic', 'status', 'maintain_type'], name='intv_thematic_status_mtype_idx'), # Accélère la sous-requête de permissions par contrat models.Index(fields=['contract', 'status'], name='intv_contract_status_idx'), # Accélère la requête du graphique : GROUP BY date_trunc(COALESCE(begin_time, ...)) models.Index( fields=['begin_time', 'planned_begin_time', 'expected_begin_time', 'maintain_type'], name='intv_chart_begin_maintain_idx', ), ] @property def intervention_sheet_filename(self): if self.intervention_sheet: return os.path.basename(self.intervention_sheet.name) return None @property def status_color_hex(self): return STATUS_COLORS.get(self.status, '#757575') @property def status_text_color_hex(self): return '#000000' if self.status in STATUS_COLORS_DARK_TEXT else '#ffffff' def get_status_color(self): return { "in_preparation": "secondary", "to_be_processed": "danger", "to_be_approved": "warning", "to_be_planned": "warning", "assigned": "warning", "in_progress": "primary", "on_pause": "warning", "finished": "info", "processed": "info", "to_be_corrected": "danger", "corrected": "info", "validated": "success", "invoiced": "dark", "closed": "dark", "canceled": "secondary" }.get(self.status, "secondary") def get_total_occupation_minutes(self): return (self.occupations .aggregate(total=Coalesce(Sum('minutes'), 0)) .get('total', 0)) def get_preparation_orders_status(self): """ Agrège les statuts des bons de préparation liés. Retourne: 'processed' si tous les bons sont traités/annulés, 'not_processed' si au moins un bon est à traiter/en attente, 'none' si aucun bon n'existe. """ prep_orders = self.preparation_orders.all().values_list('status', flat=True) if not prep_orders: return 'none' # Statuts considérés comme 'traité' treated_statuses = {'processed', 'cancelled'} # Statuts considérés comme 'non traité' untreated_statuses = {'to_process', 'pending'} statuses = set(prep_orders) has_untreated = bool(statuses & untreated_statuses) if has_untreated: return 'not_processed' return 'processed' @property def has_pending_preparation(self): """Retourne True si au moins un bon de préparation est à traiter ou en attente.""" return self.preparation_orders.filter( status__in=['to_process', 'pending'] ).exists() class InterventionLink(models.Model): class LinkType(models.TextChoices): RELATED = "related", _("Lien manuel") REPAIR = "repair", _("Réparation") first = models.ForeignKey( "Intervention", on_delete=models.CASCADE, related_name="links_as_first", ) second = models.ForeignKey( "Intervention", on_delete=models.CASCADE, related_name="links_as_second", ) link_type = models.CharField(max_length=32, choices=LinkType.choices, default=LinkType.RELATED) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint( fields=["first", "second", "link_type"], name="uniq_intervention_link", ), models.CheckConstraint( condition=~Q(first=F("second")), name="prevent_self_link", ), ] ordering = ["-created_at"] def save(self, *args, **kwargs): if self.first_id and self.second_id: if self.first_id == self.second_id: raise ValueError("An intervention link cannot reference the same intervention twice.") if self.first_id > self.second_id: self.first, self.second = self.second, self.first super().save(*args, **kwargs) def __str__(self): return f"{self.first} ↔ {self.second} ({self.get_link_type_display()})" @classmethod def normalize_pair(cls, a, b): if a.pk == b.pk: raise ValueError("Cannot create a link between the same intervention") return (a, b) if a.pk < b.pk else (b, a) @classmethod def link(cls, a, b, link_type: str): first, second = cls.normalize_pair(a, b) return cls.objects.get_or_create(first=first, second=second, link_type=link_type) def other(self, intervention): if intervention.pk == self.first_id: return self.second if intervention.pk == self.second_id: return self.first raise ValueError("Intervention is not part of this link") class SymptomContractAutoClose(models.Model): """ Règle de clôture automatique spécifique à une combinaison (symptôme × contrat). L'intervention sera clôturée automatiquement si elle porte ce symptôme ET est assignée à ce contrat, après le délai indiqué. """ symptom = models.ForeignKey( 'Symptom', on_delete=models.CASCADE, related_name='auto_close_rules', verbose_name=_("Symptôme"), ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name='auto_close_rules', verbose_name=_("Contrat"), ) delay_days = models.PositiveIntegerField( default=30, verbose_name=_("Délai (jours)"), help_text=_("Délai en jours après lequel l'intervention est clôturée automatiquement."), ) class Meta: unique_together = [('symptom', 'contract')] verbose_name = _("Règle de clôture automatique (symptôme × contrat)") verbose_name_plural = _("Règles de clôture automatique (symptôme × contrat)") ordering = ['symptom', 'contract'] def __str__(self): return f"{self.symptom} × {self.contract.contract_number} ({self.delay_days}j)" class ContractPauseReasonAssignment(models.Model): """ Permet de configurer, pour un contrat donné, le membre à assigner automatiquement lorsqu'une intervention est mise en pause avec un motif particulier. """ contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name='pause_reason_assignments', verbose_name=_("Contrat"), ) pause_reason = models.CharField( max_length=50, choices=PAUSE_REASON_CHOICES, verbose_name=_("Motif de pause"), ) assigned_member = models.ForeignKey( CompanyMember, on_delete=models.SET_NULL, null=True, blank=True, related_name='pause_reason_assignments', verbose_name=_("Membre assigné"), help_text=_("Membre à qui réassigner automatiquement l'intervention lors d'une mise en pause avec ce motif. Laisser vide pour désassigner."), ) class Meta: verbose_name = _("Réassignation automatique sur pause") verbose_name_plural = _("Réassignations automatiques sur pause") unique_together = [('contract', 'pause_reason')] def __str__(self): member_label = str(self.assigned_member) if self.assigned_member else _("(désassigner)") return f"{self.contract} – {self.get_pause_reason_display()} → {member_label}" class InterventionSequence(models.Model): current_number = models.PositiveIntegerField(default=0) @staticmethod def get_next_number(): # Atomically retrieve and increment the number from django.db import transaction with transaction.atomic(): (sequence, created) = InterventionSequence.objects.get_or_create(id=1) # Fixed ID for a single sequence sequence.current_number += 1 sequence.save() return sequence.current_number ACTION_TYPE_CHOICES = { 'creation': _('Création'), 'status_change': _('Changement de statut'), 'transfer': _('Transfert'), } class InterventionTimeLine(models.Model): intervention = models.ForeignKey(Intervention, related_name='events', on_delete=models.CASCADE) event_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="actions", null=True, blank=True) event_time = models.DateTimeField() event_type = models.CharField(choices=ACTION_TYPE_CHOICES) from_status = models.CharField(choices=STATUS_CHOICES,null=True,blank=True) to_status = models.CharField(choices=STATUS_CHOICES,null=True,blank=True) class Meta: ordering = ["event_time"] PLAN_ACTION_TYPE_CHOICES = { 'expected_begin': _('Début prévu'), 'expected_end': _('Fin prévue'), 'planned_begin': _('Début planifié'), 'planned_end': _('Fin planifiée'), 'plan_other': _('Autre'), } class InterventionPlanificationTimeLine(models.Model): intervention = models.ForeignKey(Intervention, related_name='planned_events', on_delete=models.CASCADE) event_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="planned_actions", null=True, blank=True) event_time = models.DateTimeField() event_type = models.CharField(choices=PLAN_ACTION_TYPE_CHOICES, default='plan_other') event_description = models.CharField(max_length=1024) class Meta: ordering = ["event_time"] REAL_ACTION_TYPE_CHOICES = { 'real_begin': _("Démarrage de l'intervention"), 'real_end': _("Fin de l'intervention"), 'real_pause': _("Pause de l'intervention"), 'real_resume': _("Reprise de l'intervention"), 'real_other': _('Autre'), } class InterventionRealizationTimeLine(models.Model): intervention = models.ForeignKey(Intervention, related_name='realized_events', on_delete=models.CASCADE) event_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="realized_actions", null=True, blank=True) event_time = models.DateTimeField() event_type = models.CharField(choices=REAL_ACTION_TYPE_CHOICES, default='real_other') event_description = models.CharField(max_length=1024) class Meta: ordering = ["event_time"] class InterventionOccupation(models.Model): intervention = models.ForeignKey( Intervention, related_name='occupations', on_delete=models.CASCADE, ) member = models.ForeignKey( CompanyMember, on_delete=models.CASCADE, related_name='occupations', ) minutes = models.PositiveIntegerField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_intervention_occupations', ) updated_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='updated_intervention_occupations', ) class Meta: ordering = ['created_at'] def __str__(self): member_name = getattr(self.member, 'name', '') return f"{member_name} - {self.minutes} min" class InterventionAssignment(models.Model): """Historique des assignations quotidiennes par intervention et par équipe.""" intervention = models.ForeignKey( Intervention, on_delete=models.CASCADE, related_name='daily_assignments', verbose_name=_("Intervention"), ) team = models.ForeignKey( CompanyTeam, on_delete=models.CASCADE, related_name='daily_assignments', verbose_name=_("Équipe"), ) member = models.ForeignKey( CompanyMember, on_delete=models.SET_NULL, null=True, blank=True, related_name='daily_assignments', verbose_name=_("Membre"), ) status = models.CharField( choices=STATUS_CHOICES, max_length=50, null=True, blank=True, verbose_name=_("Statut"), ) date = models.DateField(verbose_name=_("Date d'assignation")) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('intervention', 'date') verbose_name = _("Assignation quotidienne d'intervention") verbose_name_plural = _("Assignations quotidiennes d'interventions") ordering = ['date', 'team'] def __str__(self): return f"{self.intervention.code} - {self.team.name} ({self.date})" POST_CONTROL_STATUS_CHOICES = { 'validated': _('Approuvé'), 'refused': _('Refusé'), 'pending': _('A justifier'), } PERIOD_STATUS_CHOICES = { 'pending': _('En attente'), 'validated': _('Validé'), 'invoiced': _('Facturé'), } class InterventionContractPost(models.Model): """Poste de contrat associé à une intervention.""" intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="interv_contract_posts") contract_post = models.ForeignKey(ContractPost, on_delete=models.CASCADE) quantity = models.DecimalField(verbose_name=_("Quantité"), null=True, blank=True, max_digits=10, decimal_places=2) comment = models.CharField(max_length=1024, verbose_name=_("Commentaire"), null=True, blank=True) control_status = models.CharField(choices=POST_CONTROL_STATUS_CHOICES, null=True, blank=True) control_comment = models.CharField(max_length=1024, verbose_name=_("Commentaire de contrôle"), null=True, blank=True) def __str__(self): return f"{self.intervention} - {self.contract_post.get_description_for_language()} ({self.quantity})" @property def total_price(self): """Calcule le total pour ce poste dans l'intervention. Si is_coefficient_multipliable est True, force la quantité à 1 (sauf si 0 ou vide) et multiplie le prix unitaire par la somme des coûts totaux des postes du même post_type.""" if not self.contract_post.unit_price: return Decimal("0.00") qty = self.quantity or 0 # Si le poste est un coefficient multiplicateur if self.contract_post.is_coefficient_multipliable and self.contract_post.post_type: # Pour les postes avec coefficient, la quantité doit être 1 (sauf si 0 ou vide) # Si l'utilisateur a saisi une quantité > 0, on la ramène à 1 pour le calcul effective_qty = Decimal('1') if qty > 0 else Decimal('0') # Calculer la somme des coûts totaux (quantité × prix unitaire) de tous les postes du même type same_type_posts = InterventionContractPost.objects.filter( intervention=self.intervention, contract_post__post_type=self.contract_post.post_type ).exclude( contract_post__is_coefficient_multipliable=True # Exclure les postes multiplicateurs ).select_related('contract_post') total_same_type_cost = sum( (post.quantity or Decimal('0')) * (post.contract_post.unit_price or Decimal('0')) for post in same_type_posts ) # Si c'est une validation par période, ajouter les quantités des périodes if self.intervention.period_validation_enabled: period_quantities = InterventionPostPeriodQuantity.objects.filter( intervention=self.intervention, contract_post=self.contract_post ) total_same_type_cost += sum( (pq.quantity or Decimal('0')) * (self.contract_post.unit_price or Decimal('0')) for pq in period_quantities ) # Multiplier 1 × prix unitaire × somme totale des coûts return effective_qty * self.contract_post.unit_price * total_same_type_cost # Calcul standard base = Decimal(qty) * self.contract_post.unit_price if self.contract_post.is_amount_to_be_justified and self.contract_post.coeff_amount_to_be_justified: return base * self.contract_post.coeff_amount_to_be_justified return base class Meta: ordering = ["contract_post__order_number"] class InterventionPostPeriodQuantity(models.Model): """ Historique des quantités validées par période pour une intervention. Une ligne par poste de contrat et par période. """ intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="period_quantities") contract_post = models.ForeignKey(ContractPost, on_delete=models.CASCADE, related_name="period_quantities") period_number = models.PositiveIntegerField() quantity = models.DecimalField(max_digits=10, decimal_places=2) validated_at = models.DateTimeField() validated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) status = models.CharField(max_length=20, choices=PERIOD_STATUS_CHOICES, default='pending', verbose_name=_("Statut")) class Meta: unique_together = ("intervention", "contract_post", "period_number") ordering = ["intervention", "period_number", "contract_post__order_number"] def __str__(self): return f"{self.intervention.code} - P{self.period_number} - {self.contract_post.code}: {self.quantity}" class InterventionAsset(models.Model): intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() asset = GenericForeignKey('content_type', 'object_id') class InterventionNote(models.Model): class NoteType(models.TextChoices): WORK_DONE = "work_done", _("Travaux réalisés") MATERIAL = "material", _("Matériel à prévoir") EQUIPMENT = "equipment", _("Matériel utilisé") IMPORTANT_INFO = "important_info", _("Information importante") ORIGIN = "origin", _("Origine") COMMENT = "comment", _("Commentaire") OTHER = "other", _("Autre") NOTE_TYPE_STYLES = { NoteType.WORK_DONE: {"badge_class": "bg-success", "icon_class": "bi-clipboard-check"}, NoteType.EQUIPMENT: {"badge_class": "bg-warning text-dark", "icon_class": "bi-tools"}, NoteType.IMPORTANT_INFO: {"badge_class": "bg-danger", "icon_class": "bi-exclamation-octagon-fill"}, NoteType.ORIGIN: {"badge_class": "bg-secondary", "icon_class": "bi-flag-fill"}, NoteType.COMMENT: {"badge_class": "bg-secondary", "icon_class": "bi-chat-dots"}, NoteType.OTHER: {"badge_class": "bg-secondary", "icon_class": "bi-sticky"}, } NOTE_TYPE_PRIORITIES = { NoteType.EQUIPMENT: 0, NoteType.IMPORTANT_INFO: 1, NoteType.ORIGIN: 2, NoteType.WORK_DONE: 3, NoteType.COMMENT: 3, NoteType.OTHER: 3, } intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="notes") content = models.TextField() note_time = models.DateTimeField() note_type = models.CharField( max_length=32, choices=NoteType.choices, default=NoteType.WORK_DONE, verbose_name=_("Type de note"), ) note_author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="interventions_notes", null=True, blank=True) updated_at = models.DateTimeField(null=True, blank=True) updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="updated_interventions_notes", null=True, blank=True) def save(self, *args, **kwargs): # Ne définir updated_at que lors des mises à jour (pas à la création) if self.pk: self.updated_at = timezone.now() super().save(*args, **kwargs) @property def type_priority_value(self): return self.NOTE_TYPE_PRIORITIES.get(self.note_type, 99) @property def type_badge_class(self): return self.NOTE_TYPE_STYLES.get(self.note_type, {"badge_class": "bg-secondary"}).get("badge_class", "bg-secondary") @property def type_icon_class(self): return self.NOTE_TYPE_STYLES.get(self.note_type, {"icon_class": "bi-card-text"}).get("icon_class", "bi-card-text") def intervention_document_upload_path(instance, filename): # On récupère l'année et le mois actuels now = timezone.now() year = now.strftime("%Y") month = now.strftime("%m") # On construit le chemin avec l'ID de l'intervention (attention : doit être déjà enregistrée) intervention_id = instance.intervention.id if instance.intervention else "unknown" return f"intervention_documents/{year}/{month}/{intervention_id}/{filename}" class InterventionDocument(Document): intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="documents", verbose_name="Intervention associée") file = models.FileField(upload_to=intervention_document_upload_path, storage=private_storage, verbose_name="Fichier", max_length=255) thumbnail = models.ImageField(upload_to=intervention_document_upload_path, storage=private_storage, null=True, blank=True, max_length=255) upload_date = models.DateTimeField(auto_now_add=True, verbose_name="Date d'upload", null=True, blank=True) uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="uploaded_intervention_documents", null=True, blank=True) class InterventionLocation(models.Model): intervention = models.ForeignKey( "interventions.Intervention", on_delete=models.CASCADE, related_name="locations", db_index=True, ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, db_index=True) object_id = models.PositiveIntegerField(db_index=True) location = GenericForeignKey("content_type", "object_id") class Meta: constraints = [ models.UniqueConstraint( fields=["intervention", "content_type", "object_id"], name="uniq_intervention_location" ), ] indexes = [ models.Index(fields=["content_type", "object_id"], name="ix_ct_obj"), models.Index(fields=["intervention", "content_type"], name="ix_intv_ct"), ] ### Traffic Lights ### # class InterventionTrafficLightIntersection(models.Model): # intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="intervention_intersections") # intersection = models.ForeignKey(TrafficLightIntersection, on_delete=models.CASCADE, related_name="intervention_intersections") ### Public Lighting ### # class InterventionPublicLightingStreet(models.Model): # intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="intervention_streets") # street = models.ForeignKey(PublicLightingStreet, on_delete=models.CASCADE, related_name="intervention_streets") ### Green ### # class InterventionGreenSection(models.Model): # intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="intervention_green_sections") # section = models.ForeignKey(GreenSection, on_delete=models.CASCADE, related_name="intervention_sections") ### Structures ### # class InterventionStructureLocation(models.Model): # intervention = models.ForeignKey(Intervention, on_delete=models.CASCADE, related_name="intervention_structures_locations") # location = models.ForeignKey(StructureLocation, on_delete=models.CASCADE, related_name="intervention_locations") ### Operations ### class OperationStatusSet(models.Model): name = models.CharField(max_length=255, unique=True) def __str__(self): return self.name BOOTSTRAP_COLOR_CHOICES = [ ('primary', 'Primary'), ('secondary', 'Secondary'), ('success', 'Success'), ('danger', 'Danger'), ('warning', 'Warning'), ('info', 'Info'), ('light', 'Light'), ('dark', 'Dark'), ] class OperationStatus(models.Model): status_set = models.ForeignKey(OperationStatusSet, related_name='statuses', on_delete=models.CASCADE) label_fr = models.CharField(max_length=50) label_nl = models.CharField(max_length=50) value = models.CharField(max_length=50) order = models.PositiveIntegerField(default=0) bootstrap_icon = models.CharField(max_length=50, blank=True, null=True, help_text=_("Icône Bootstrap (ex: bi-check-circle)")) bootstrap_color = models.CharField(max_length=20, choices=BOOTSTRAP_COLOR_CHOICES, blank=True, null=True, help_text=_("Couleur Bootstrap")) requires_comment = models.BooleanField(default=False, verbose_name=_("Commentaire obligatoire"), help_text=_("Si coché, un commentaire sera requis lorsque ce statut est sélectionné")) class Meta: unique_together = ('status_set', 'value') ordering = ['order'] def __str__(self): lang = get_language() if lang == 'nl': return self.label_nl or self.label_fr return self.label_fr VALUE_TYPE_CHOICES = [ ('numeric', _('Numérique')), ('duration', _('Durée (hh:mm:ss)')), ('datetime', _('Date et heure')), ] class MeasurementDefinition(models.Model): name = models.CharField(max_length=255) value_type = models.CharField( max_length=20, choices=VALUE_TYPE_CHOICES, default='numeric', verbose_name=_('Type de valeur'), ) unit = models.CharField(max_length=50, blank=True) # Bornes pour type numérique min_value = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True) max_value = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True) # Bornes pour type durée min_duration = models.DurationField(null=True, blank=True, verbose_name=_('Durée min')) max_duration = models.DurationField(null=True, blank=True, verbose_name=_('Durée max')) # Bornes pour type date/heure min_datetime = models.DateTimeField(null=True, blank=True, verbose_name=_('Date/heure min')) max_datetime = models.DateTimeField(null=True, blank=True, verbose_name=_('Date/heure max')) def __str__(self): return f"{self.name} ({self.unit})" if self.unit else self.name class GuidedOperationTemplate(models.Model): """ Représente un template d'opérations guidées avec une interface HTML personnalisée. Le slug identifie le fichier template: interventions/guided_operations/{slug}.html """ slug = models.SlugField( max_length=100, unique=True, verbose_name=_("Slug"), help_text=_("Identifiant unique. Doit correspondre au nom du fichier HTML: guided_operations/{slug}.html") ) name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)")) name_nl = models.CharField(max_length=255, blank=True, verbose_name=_("Nom (NL)")) description = models.TextField(blank=True, verbose_name=_("Description")) class Meta: verbose_name = _("Template d'opérations guidées") verbose_name_plural = _("Templates d'opérations guidées") ordering = ["name_fr"] def getName(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr def __str__(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr class OperationTemplate(models.Model): code = models.CharField(max_length=100, null=True, blank=True) name = models.CharField(max_length=255, unique=True) description = models.TextField(blank=True) thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True) guided_template = models.ForeignKey( GuidedOperationTemplate, on_delete=models.SET_NULL, null=True, blank=True, related_name='operation_templates', verbose_name=_("Template guidé associé"), help_text=_("Si défini, ce template d'opérations déclenchera automatiquement l'assignation du template guidé à l'intervention préventive.") ) # Optional: filter equipment operations to specific categories categories = models.ManyToManyField( 'assets.AssetCategory', blank=True, related_name='operation_templates', verbose_name=_("Catégories"), help_text=_("Si défini, les opérations équipement ne seront créées que pour les assets de ces catégories.") ) def __str__(self): return self.name def get_category_ids(self): """Return set of category IDs this template applies to, or None if no filter.""" ids = set(self.categories.values_list('id', flat=True)) return ids if ids else None def get_asset_model_filters(self): """ Return dict of {content_type_model: set(object_ids)} for asset model filtering. Returns None if no asset model filters are defined. """ filters = {} for entry in self.asset_model_filters.all(): ct = entry.asset_model_content_type if ct: model_name = ct.model.lower() if model_name not in filters: filters[model_name] = set() filters[model_name].add(entry.asset_model_object_id) return filters if filters else None class InspectionConfiguration(models.Model): name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)")) name_nl = models.CharField(max_length=255, blank=True, verbose_name=_("Nom (NL)")) description = models.TextField(blank=True, verbose_name=_("Description")) thematic = models.ForeignKey(Thematic, on_delete=models.PROTECT, verbose_name=_("Thématique")) maintain_type = models.CharField( max_length=50, choices=MAINTAIN_CHOICES, default='preventive', verbose_name=_("Type de maintenance") ) operation_templates = models.ManyToManyField( OperationTemplate, blank=True, verbose_name=_("Templates d'opérations") ) default_category = models.ForeignKey( 'assets.AssetCategory', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Catégorie par défaut") ) displayed_location_types = models.ManyToManyField( ContentType, blank=True, related_name='inspection_configs_as_location', limit_choices_to=Q(app_label='assets'), verbose_name=_("Types de localisations à afficher sur la carte") ) displayed_asset_types = models.ManyToManyField( ContentType, blank=True, related_name='inspection_configs_as_asset', limit_choices_to=Q(app_label='assets'), verbose_name=_("Types d'équipements à afficher sur la carte") ) concerned_categories = models.ManyToManyField( 'assets.AssetCategory', blank=True, related_name='inspection_configs', verbose_name=_("Catégories concernées"), help_text=_("Filtre les localisations et équipements affichés sur la carte par catégorie. Si vide, tous sont affichés.") ) show_equipment = models.BooleanField( default=True, verbose_name=_("Afficher le champ équipement"), help_text=_("Si décoché, le champ équipement sera masqué lors de l'encodage de cette inspection.") ) class Meta: verbose_name = _("Configuration d'inspection") verbose_name_plural = _("Configurations d'inspections") ordering = ['name_fr'] @property def name(self): lang = get_language() if lang == 'nl': return self.name_nl or self.name_fr return self.name_fr def __str__(self): return self.name class InspectionConfigurationContract(models.Model): inspection_configuration = models.ForeignKey( InspectionConfiguration, on_delete=models.CASCADE, related_name='contracts_providers', verbose_name=_("Configuration d'inspection") ) provider = models.ForeignKey( Company, on_delete=models.CASCADE, related_name='inspection_configuration_contracts', verbose_name=_("Prestataire") ) contract = models.ForeignKey( Contract, on_delete=models.CASCADE, related_name='inspection_configuration_contracts', verbose_name=_("Contrat") ) class Meta: verbose_name = _("Prestataire et Contrat d'inspection") verbose_name_plural = _("Prestataires et Contrats d'inspections") unique_together = ('inspection_configuration', 'provider', 'contract') def __str__(self): return f"{self.provider.name} - {self.contract.contract_number}" class OperationTemplateAssetModelFilter(models.Model): """ Stores a reference to a specific asset model (e.g., StructureAssetModel, TrafficLightPoleModel). Uses GenericForeignKey to allow referencing any asset model type. """ template = models.ForeignKey( OperationTemplate, on_delete=models.CASCADE, related_name='asset_model_filters', verbose_name=_("Template") ) asset_model_content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, verbose_name=_("Type de modèle d'asset"), limit_choices_to={'app_label': 'assets'} ) asset_model_object_id = models.PositiveIntegerField(verbose_name=_("ID du modèle")) asset_model = GenericForeignKey('asset_model_content_type', 'asset_model_object_id') class Meta: verbose_name = _("Filtre de modèle d'asset") verbose_name_plural = _("Filtres de modèles d'assets") unique_together = ('template', 'asset_model_content_type', 'asset_model_object_id') def __str__(self): if self.asset_model: return str(self.asset_model) return f"Model {self.asset_model_content_type_id}:{self.asset_model_object_id}" class OperationType(models.Model): code = models.CharField(max_length=100, unique=True) name_fr = models.CharField(max_length=255) name_nl = models.CharField(max_length=255) def __str__(self): lang = get_language() if lang == 'nl': return self.name_nl return self.name_fr class OperationTemplateItem(models.Model): code = models.CharField(max_length=100, null=True, blank=True) template = models.ForeignKey(OperationTemplate, related_name='items', on_delete=models.CASCADE) name_fr = models.CharField(max_length=255) name_nl = models.CharField(max_length=255) operation_type = models.ForeignKey(OperationType, on_delete=models.SET_NULL, null=True, blank=True) status_set = models.ForeignKey(OperationStatusSet, on_delete=models.SET_NULL, null=True, blank=True) measurement_definition = models.ForeignKey(MeasurementDefinition, on_delete=models.SET_NULL, null=True, blank=True) order = models.PositiveIntegerField(default=0) # Indique si cette opération est générique (une fois par intervention) ou concerne un équipement (répétée par équipement) is_generic = models.BooleanField(default=False, verbose_name=_("Opération générique"), help_text=_("Si coché, cette opération sera ajoutée une seule fois par intervention. Sinon, elle sera répétée pour chaque équipement.")) # Indique si cette opération permet/requiert l'ajout de photos requires_photo = models.BooleanField(default=False, verbose_name=_("Requiert une photo")) class Meta: ordering = ['order'] def __str__(self): lang = get_language() if lang == 'nl': return f"{self.template.name} - {self.name_nl}" return f"{self.template.name} - {self.name_fr}" EMAIL_LOG_TYPE_CHOICES = { 'transmission': _('Envoi de la fiche'), 'reminder': _('Rappel'), 'modification': _('Modification'), 'cancellation': _('Annulation'), 'daily_summary': _('Résumé journalier'), } class InterventionEmailLog(models.Model): """Historique des envois d'emails pour une intervention.""" intervention = models.ForeignKey(Intervention, related_name='email_logs', on_delete=models.CASCADE) email_type = models.CharField(choices=EMAIL_LOG_TYPE_CHOICES, max_length=50) sent_at = models.DateTimeField(auto_now_add=True) sent_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='intervention_emails_sent') recipients = models.TextField(help_text=_("Liste des destinataires (séparés par des virgules)")) subject = models.CharField(max_length=500, null=True, blank=True) detail = models.TextField(null=True, blank=True, help_text=_("Détails supplémentaires (ex: motif de modification)")) success = models.BooleanField(default=True) error_message = models.TextField(null=True, blank=True) class Meta: ordering = ['-sent_at'] verbose_name = _("Historique d'envoi d'email") verbose_name_plural = _("Historiques d'envois d'emails") def __str__(self): return f"{self.intervention.code} - {self.get_email_type_display()} - {self.sent_at.strftime('%d/%m/%Y %H:%M')}" @property def recipients_list(self): """Retourne la liste des destinataires.""" if not self.recipients: return [] return [r.strip() for r in self.recipients.split(',') if r.strip()] class Operation(models.Model): intervention = models.ForeignKey(Intervention, related_name='operations', on_delete=models.CASCADE) name_fr = models.CharField(max_length=255) name_nl = models.CharField(max_length=255) comment = models.TextField(blank=True) status_set = models.ForeignKey(OperationStatusSet, on_delete=models.SET_NULL, null=True, blank=True) status = models.ForeignKey(OperationStatus, on_delete=models.SET_NULL, null=True, blank=True) # Pour les opérations de mesure measurement_definition = models.ForeignKey(MeasurementDefinition, on_delete=models.SET_NULL, null=True, blank=True) measured_value = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True) measured_duration = models.DurationField(null=True, blank=True, verbose_name=_('Durée mesurée')) measured_datetime = models.DateTimeField(null=True, blank=True, verbose_name=_('Date/heure mesurée')) # Option "Non applicable" pour marquer une opération comme non pertinente is_not_applicable = models.BooleanField(default=False, verbose_name=_("Non applicable")) order = models.PositiveIntegerField(default=0) # Pour grouper les opérations par template template_group = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Groupe de template")) # Pour lier à l'asset si nécessaire (pour templates équipement) asset_content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True, blank=True, related_name='operations') asset_object_id = models.PositiveIntegerField(null=True, blank=True) asset = GenericForeignKey('asset_content_type', 'asset_object_id') # Marquer les opérations ajoutées manuellement par l'utilisateur is_custom = models.BooleanField(default=False, verbose_name=_("Opération personnalisée")) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_operations') created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True) # Indique si cette opération est générique (une fois par intervention) ou concerne un équipement (répétée par équipement) is_generic = models.BooleanField(default=False, verbose_name=_("Opération générique")) # Indique si cette opération permet/requiert l'ajout de photos requires_photo = models.BooleanField(default=False, verbose_name=_("Requiert une photo")) class Meta: ordering = ['template_group', 'order'] def __str__(self): lang = get_language() if lang == 'nl': return f"{self.name_nl} - {self.status.label_nl if self.status else '/'}" return f"{self.name_fr} - {self.status.label_fr if self.status else '/'}" def is_measurement(self): return self.measurement_definition is not None def is_measurement_out_of_range(self): """Check if measured value is outside the allowed range""" if not self.is_measurement(): return False md = self.measurement_definition if md.value_type == 'duration': if self.measured_duration is None: return False if md.min_duration and self.measured_duration < md.min_duration: return True if md.max_duration and self.measured_duration > md.max_duration: return True return False elif md.value_type == 'datetime': if self.measured_datetime is None: return False if md.min_datetime and self.measured_datetime < md.min_datetime: return True if md.max_datetime and self.measured_datetime > md.max_datetime: return True return False else: # numeric if self.measured_value is None: return False if md.min_value is None and md.max_value is None: return False if md.min_value is not None and self.measured_value < md.min_value: return True if md.max_value is not None and self.measured_value > md.max_value: return True return False @property def measured_duration_display(self): """Format measured_duration as HH:MM:SS string""" if self.measured_duration is None: return '' total_seconds = int(self.measured_duration.total_seconds()) hours, remainder = divmod(abs(total_seconds), 3600) minutes, seconds = divmod(remainder, 60) return f"{hours:02d}:{minutes:02d}:{seconds:02d}" def operation_photo_upload_path(instance, filename): """Generate upload path for operation photos""" from django.utils.text import get_valid_filename now = timezone.now() year = now.strftime("%Y") month = now.strftime("%m") intervention_id = instance.operation.intervention.id if instance.operation and instance.operation.intervention else "unknown" operation_id = instance.operation.id if instance.operation else "unknown" # Sanitize filename to prevent path traversal attacks safe_filename = get_valid_filename(filename) return f"operation_photos/{year}/{month}/{intervention_id}/{operation_id}/{safe_filename}" class OperationPhoto(models.Model): """Photos attached to operations""" operation = models.ForeignKey(Operation, related_name='photos', on_delete=models.CASCADE) photo = models.ImageField(upload_to=operation_photo_upload_path, storage=private_storage, verbose_name=_("Photo")) thumbnail = models.ImageField(upload_to=operation_photo_upload_path, storage=private_storage, null=True, blank=True, max_length=255) uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'upload")) uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='uploaded_operation_photos') description = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Description")) class Meta: ordering = ['-uploaded_at'] verbose_name = _("Photo d'opération") verbose_name_plural = _("Photos d'opérations") def __str__(self): return f"Photo for {self.operation} - {self.uploaded_at}" # ───────────────────────────────────────────────────────────────────────────── # Guided Operation Templates — données saisies & médias # ───────────────────────────────────────────────────────────────────────────── class GuidedOperationData(models.Model): """Stocke les réponses saisies via un template d'opérations guidées.""" intervention = models.OneToOneField( Intervention, on_delete=models.CASCADE, related_name='guided_operation_data', verbose_name=_("Intervention") ) guided_template = models.ForeignKey( GuidedOperationTemplate, on_delete=models.CASCADE, related_name='data_entries', verbose_name=_("Template guidé") ) data = models.JSONField(default=dict, verbose_name=_("Données")) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = _("Données d'opérations guidées") verbose_name_plural = _("Données d'opérations guidées") def __str__(self): return f"{self.intervention.code} – {self.guided_template}" def guided_operation_media_upload_path(instance, filename): """Generate upload path for guided operation media files.""" from django.utils.text import get_valid_filename now = timezone.now() year = now.strftime("%Y") month = now.strftime("%m") intervention_id = instance.guided_data.intervention_id if instance.guided_data_id else "unknown" field_key = instance.field_key or "general" safe_filename = get_valid_filename(filename) return f"guided_operation_media/{year}/{month}/{intervention_id}/{field_key}/{safe_filename}" GUIDED_MEDIA_TYPE_CHOICES = [ ('photo', _('Photo')), ('document', _('Document')), ] class GuidedOperationMedia(models.Model): """Photos ou documents attachés à une réponse d'opérations guidées.""" guided_data = models.ForeignKey( GuidedOperationData, on_delete=models.CASCADE, related_name='media', verbose_name=_("Données guidées") ) field_key = models.CharField( max_length=100, blank=True, default='', verbose_name=_("Clé de question"), help_text=_("Identifie la question à laquelle ce média est rattaché (vide = général).") ) file = models.FileField( upload_to=guided_operation_media_upload_path, storage=private_storage, verbose_name=_("Fichier") ) media_type = models.CharField( max_length=20, choices=GUIDED_MEDIA_TYPE_CHOICES, default='photo', verbose_name=_("Type de média") ) description = models.CharField(max_length=255, blank=True, verbose_name=_("Description")) uploaded_at = models.DateTimeField(auto_now_add=True) uploaded_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='uploaded_guided_media' ) class Meta: ordering = ['field_key', '-uploaded_at'] verbose_name = _("Média d'opérations guidées") verbose_name_plural = _("Médias d'opérations guidées") def __str__(self): return f"{self.guided_data} – {self.field_key or 'general'} – {self.uploaded_at.strftime('%d/%m/%Y')}" class InterventionSubscription(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='intervention_subscriptions', verbose_name=_("Utilisateur") ) intervention = models.ForeignKey( 'Intervention', on_delete=models.CASCADE, related_name='subscriptions', verbose_name=_("Intervention") ) created_at = models.DateTimeField( auto_now_add=True, verbose_name=_("Abonné le") ) class Meta: unique_together = ('user', 'intervention') verbose_name = _("Abonnement à une intervention") verbose_name_plural = _("Abonnements aux interventions") def __str__(self): return f"{self.user.username} → {self.intervention.code}" class DirectContractRouting(models.Model): THEMATIC_CHOICES = [ ('structures', _("Structures")), ('trafficlights', _("Traffic Lights")), ('clean', _("Clean")), ('its', _("ITS")), ('nature', _("Nature")), ('roads', _("Roads")), ('publiclighting', _("Public Lighting")), ] thematic = models.CharField( max_length=50, choices=THEMATIC_CHOICES, verbose_name=_("Thématique"), ) asset_code = models.CharField( max_length=100, verbose_name=_("Code de l'asset (ex: OA054)"), help_text=_("Le code de l'ouvrage d'art ou de l'équipement concerné."), ) symptom = models.ForeignKey( "interventions.Symptom", on_delete=models.CASCADE, null=True, blank=True, verbose_name=_("Symptôme spécifique"), help_text=_("Si renseigné, cette règle s'applique uniquement à ce symptôme. Sinon, elle s'applique par défaut."), ) contract = models.ForeignKey( "contracts.Contract", on_delete=models.CASCADE, verbose_name=_("Contrat à forcer"), ) class Meta: verbose_name = _("Routage direct de contrat") verbose_name_plural = _("Routages directs de contrats") unique_together = ('thematic', 'asset_code', 'symptom') def __str__(self): symptom_str = f" [{self.symptom.code}]" if self.symptom else " [default]" return f"{self.thematic} × {self.asset_code}{symptom_str} → {self.contract.contract_number}"