diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.mo b/loko/interventions/locale/en/LC_MESSAGES/django.mo index e14ba9a..0e5c610 100644 Binary files a/loko/interventions/locale/en/LC_MESSAGES/django.mo and b/loko/interventions/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.po b/loko/interventions/locale/en/LC_MESSAGES/django.po index 75297c6..9e5956f 100644 --- a/loko/interventions/locale/en/LC_MESSAGES/django.po +++ b/loko/interventions/locale/en/LC_MESSAGES/django.po @@ -8137,3 +8137,106 @@ msgstr "Control:" msgid "Équipe de contrôle" msgstr "Control team" +#: interventions/models.py:1260 +msgid "Assignation" +msgstr "Assignment" + +#: interventions/models.py:1284 +msgid "Description de l'événement" +msgstr "Event description" + +#: interventions/models.py:1285 +msgid "Attributs modifiés" +msgstr "Modified attributes" + +#: interventions/templates/interventions/detail/card_chronologie.html:9 +msgid "Chronologie et historique" +msgstr "Timeline and history" + +#: interventions/templates/interventions/detail/card_chronologie.html:18 +msgid "Filtrer l'historique" +msgstr "Filter history" + +#: interventions/templates/interventions/detail/card_chronologie.html:23 +msgid "Afficher les statuts et les assignations" +msgstr "Show statuses and assignments" + +#: interventions/templates/interventions/detail/card_chronologie.html:24 +msgid "Statuts & Assignations" +msgstr "Statuses & Assignments" + +#: interventions/templates/interventions/detail/card_chronologie.html:30 +msgid "Afficher toutes les modifications détaillées" +msgstr "Show all detailed modifications" + +#: interventions/templates/interventions/detail/card_chronologie.html:31 +msgid "Historique complet" +msgstr "Full history" + +#: interventions/templates/interventions/detail/card_chronologie.html:69 +msgid "Assignation mise à jour" +msgstr "Assignment updated" + +#: interventions/templates/interventions/detail/card_chronologie.html:72 +msgid "Intervention initialisée" +msgstr "Intervention initialized" + +#: interventions/templates/interventions/detail/card_chronologie.html:78 +msgid "Intervention transférée" +msgstr "Intervention transferred" + +#: interventions/templates/interventions/detail/card_chronologie.html:92 +msgid "Auteur de la modification" +msgstr "Author of modification" + +#: interventions/templates/interventions/detail/card_chronologie.html:93 +msgid "Système" +msgstr "System" + +#: interventions/templates/interventions/detail/card_chronologie.html:110 +msgid "vide" +msgstr "empty" + +#: interventions/templates/interventions/detail/card_chronologie.html:129 +msgid "Aucun événement enregistré dans l'historique." +msgstr "No events recorded in the history." + +#: interventions/services/timeline_service.py:217 +#, python-format +msgid "Assigné à l'équipe %(team)s (%(member)s)" +msgstr "Assigned to team %(team)s (%(member)s)" + +#: interventions/services/timeline_service.py:222 +#, python-format +msgid "Assigné à l'équipe %(team)s" +msgstr "Assigned to team %(team)s" + +#: interventions/services/timeline_service.py:224 +#, python-format +msgid "Désassigné de l'équipe %(team)s" +msgstr "Unassigned from team %(team)s" + +#: interventions/services/timeline_service.py:226 +#, python-format +msgid "Membre assigné : %(member)s" +msgstr "Assigned member: %(member)s" + +#: interventions/services/timeline_service.py:228 +#, python-format +msgid "Membre retiré : %(member)s" +msgstr "Removed member: %(member)s" + +#: interventions/services/timeline_service.py:231 +#, python-format +msgid "Prestataire : %(provider)s" +msgstr "Provider: %(provider)s" + +#: interventions/services/timeline_service.py:236 +msgid "Mise à jour de l'assignation" +msgstr "Assignment update" + +#: interventions/services/timeline_service.py:379 +#, python-format +msgid "Champs modifiés : %(fields)s" +msgstr "Modified fields: %(fields)s" + diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.mo b/loko/interventions/locale/fr/LC_MESSAGES/django.mo index 8ece14c..dc55020 100644 Binary files a/loko/interventions/locale/fr/LC_MESSAGES/django.mo and b/loko/interventions/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.po b/loko/interventions/locale/fr/LC_MESSAGES/django.po index 365e51d..8a2aa86 100644 --- a/loko/interventions/locale/fr/LC_MESSAGES/django.po +++ b/loko/interventions/locale/fr/LC_MESSAGES/django.po @@ -8244,3 +8244,106 @@ msgstr "Contrôle :" msgid "Équipe de contrôle" msgstr "Équipe de contrôle" +#: interventions/models.py:1260 +msgid "Assignation" +msgstr "Assignation" + +#: interventions/models.py:1284 +msgid "Description de l'événement" +msgstr "Description de l'événement" + +#: interventions/models.py:1285 +msgid "Attributs modifiés" +msgstr "Attributs modifiés" + +#: interventions/templates/interventions/detail/card_chronologie.html:9 +msgid "Chronologie et historique" +msgstr "Chronologie et historique" + +#: interventions/templates/interventions/detail/card_chronologie.html:18 +msgid "Filtrer l'historique" +msgstr "Filtrer l'historique" + +#: interventions/templates/interventions/detail/card_chronologie.html:23 +msgid "Afficher les statuts et les assignations" +msgstr "Afficher les statuts et les assignations" + +#: interventions/templates/interventions/detail/card_chronologie.html:24 +msgid "Statuts & Assignations" +msgstr "Statuts & Assignations" + +#: interventions/templates/interventions/detail/card_chronologie.html:30 +msgid "Afficher toutes les modifications détaillées" +msgstr "Afficher toutes les modifications détaillées" + +#: interventions/templates/interventions/detail/card_chronologie.html:31 +msgid "Historique complet" +msgstr "Historique complet" + +#: interventions/templates/interventions/detail/card_chronologie.html:69 +msgid "Assignation mise à jour" +msgstr "Assignation mise à jour" + +#: interventions/templates/interventions/detail/card_chronologie.html:72 +msgid "Intervention initialisée" +msgstr "Intervention initialisée" + +#: interventions/templates/interventions/detail/card_chronologie.html:78 +msgid "Intervention transférée" +msgstr "Intervention transférée" + +#: interventions/templates/interventions/detail/card_chronologie.html:92 +msgid "Auteur de la modification" +msgstr "Auteur de la modification" + +#: interventions/templates/interventions/detail/card_chronologie.html:93 +msgid "Système" +msgstr "Système" + +#: interventions/templates/interventions/detail/card_chronologie.html:110 +msgid "vide" +msgstr "vide" + +#: interventions/templates/interventions/detail/card_chronologie.html:129 +msgid "Aucun événement enregistré dans l'historique." +msgstr "Aucun événement enregistré dans l'historique." + +#: interventions/services/timeline_service.py:217 +#, python-format +msgid "Assigné à l'équipe %(team)s (%(member)s)" +msgstr "Assigné à l'équipe %(team)s (%(member)s)" + +#: interventions/services/timeline_service.py:222 +#, python-format +msgid "Assigné à l'équipe %(team)s" +msgstr "Assigné à l'équipe %(team)s" + +#: interventions/services/timeline_service.py:224 +#, python-format +msgid "Désassigné de l'équipe %(team)s" +msgstr "Désassigné de l'équipe %(team)s" + +#: interventions/services/timeline_service.py:226 +#, python-format +msgid "Membre assigné : %(member)s" +msgstr "Membre assigné : %(member)s" + +#: interventions/services/timeline_service.py:228 +#, python-format +msgid "Membre retiré : %(member)s" +msgstr "Membre retiré : %(member)s" + +#: interventions/services/timeline_service.py:231 +#, python-format +msgid "Prestataire : %(provider)s" +msgstr "Prestataire : %(provider)s" + +#: interventions/services/timeline_service.py:236 +msgid "Mise à jour de l'assignation" +msgstr "Mise à jour de l'assignation" + +#: interventions/services/timeline_service.py:379 +#, python-format +msgid "Champs modifiés : %(fields)s" +msgstr "Champs modifiés : %(fields)s" + diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.mo b/loko/interventions/locale/nl/LC_MESSAGES/django.mo index 7a7f1a9..475ac13 100644 Binary files a/loko/interventions/locale/nl/LC_MESSAGES/django.mo and b/loko/interventions/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.po b/loko/interventions/locale/nl/LC_MESSAGES/django.po index 9e26ef7..2263962 100644 --- a/loko/interventions/locale/nl/LC_MESSAGES/django.po +++ b/loko/interventions/locale/nl/LC_MESSAGES/django.po @@ -8178,3 +8178,106 @@ msgstr "Controle:" msgid "Équipe de contrôle" msgstr "Controleteam" +#: interventions/models.py:1260 +msgid "Assignation" +msgstr "Toewijzing" + +#: interventions/models.py:1284 +msgid "Description de l'événement" +msgstr "Beschrijving van het evenement" + +#: interventions/models.py:1285 +msgid "Attributs modifiés" +msgstr "Gewijzigde attributen" + +#: interventions/templates/interventions/detail/card_chronologie.html:9 +msgid "Chronologie et historique" +msgstr "Tijdlijn en geschiedenis" + +#: interventions/templates/interventions/detail/card_chronologie.html:18 +msgid "Filtrer l'historique" +msgstr "Geschiedenis filteren" + +#: interventions/templates/interventions/detail/card_chronologie.html:23 +msgid "Afficher les statuts et les assignations" +msgstr "Statussen en toewijzingen weergeven" + +#: interventions/templates/interventions/detail/card_chronologie.html:24 +msgid "Statuts & Assignations" +msgstr "Statussen & Toewijzingen" + +#: interventions/templates/interventions/detail/card_chronologie.html:30 +msgid "Afficher toutes les modifications détaillées" +msgstr "Alle gedetailleerde wijzigingen weergeven" + +#: interventions/templates/interventions/detail/card_chronologie.html:31 +msgid "Historique complet" +msgstr "Volledige geschiedenis" + +#: interventions/templates/interventions/detail/card_chronologie.html:69 +msgid "Assignation mise à jour" +msgstr "Toewijzing bijgewerkt" + +#: interventions/templates/interventions/detail/card_chronologie.html:72 +msgid "Intervention initialisée" +msgstr "Interventie geïnitieerd" + +#: interventions/templates/interventions/detail/card_chronologie.html:78 +msgid "Intervention transférée" +msgstr "Interventie overgedragen" + +#: interventions/templates/interventions/detail/card_chronologie.html:92 +msgid "Auteur de la modification" +msgstr "Auteur van de wijziging" + +#: interventions/templates/interventions/detail/card_chronologie.html:93 +msgid "Système" +msgstr "Systeem" + +#: interventions/templates/interventions/detail/card_chronologie.html:110 +msgid "vide" +msgstr "leeg" + +#: interventions/templates/interventions/detail/card_chronologie.html:129 +msgid "Aucun événement enregistré dans l'historique." +msgstr "Geen gebeurtenis geregistreerd in de geschiedenis." + +#: interventions/services/timeline_service.py:217 +#, python-format +msgid "Assigné à l'équipe %(team)s (%(member)s)" +msgstr "Toegewezen aan team %(team)s (%(member)s)" + +#: interventions/services/timeline_service.py:222 +#, python-format +msgid "Assigné à l'équipe %(team)s" +msgstr "Toegewezen aan team %(team)s" + +#: interventions/services/timeline_service.py:224 +#, python-format +msgid "Désassigné de l'équipe %(team)s" +msgstr "Toewijzing team %(team)s opgeheven" + +#: interventions/services/timeline_service.py:226 +#, python-format +msgid "Membre assigné : %(member)s" +msgstr "Toegewezen lid: %(member)s" + +#: interventions/services/timeline_service.py:228 +#, python-format +msgid "Membre retiré : %(member)s" +msgstr "Verwijderd lid: %(member)s" + +#: interventions/services/timeline_service.py:231 +#, python-format +msgid "Prestataire : %(provider)s" +msgstr "Dienstverlener: %(provider)s" + +#: interventions/services/timeline_service.py:236 +msgid "Mise à jour de l'assignation" +msgstr "Toewijzing bijgewerkt" + +#: interventions/services/timeline_service.py:379 +#, python-format +msgid "Champs modifiés : %(fields)s" +msgstr "Gewijzigde velden: %(fields)s" + diff --git a/loko/interventions/migrations/0014_interventiontimeline_event_description_and_more.py b/loko/interventions/migrations/0014_interventiontimeline_event_description_and_more.py new file mode 100644 index 0000000..f30e7c4 --- /dev/null +++ b/loko/interventions/migrations/0014_interventiontimeline_event_description_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.7 on 2026-09-25 19:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('interventions', '0013_intervention_assigned_control_team_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='interventiontimeline', + name='event_description', + field=models.TextField(blank=True, null=True, verbose_name="Description de l'événement"), + ), + migrations.AddField( + model_name='interventiontimeline', + name='field_changes', + field=models.JSONField(blank=True, default=dict, null=True, verbose_name='Attributs modifiés'), + ), + migrations.AlterField( + model_name='interventiontimeline', + name='event_type', + field=models.CharField(choices=[('creation', 'Création'), ('status_change', 'Changement de statut'), ('transfer', 'Transfert'), ('assignment', 'Assignation'), ('modification', 'Modification')]), + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index e4bd436..6bb4156 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -1252,6 +1252,26 @@ ACTION_TYPE_CHOICES = { 'creation': _('Création'), 'status_change': _('Changement de statut'), 'transfer': _('Transfert'), + 'assignment': _('Assignation'), + 'modification': _('Modification'), +} + +STATUS_COLOR_MAP = { + "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", } class InterventionTimeLine(models.Model): @@ -1259,12 +1279,58 @@ class InterventionTimeLine(models.Model): 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) + from_status = models.CharField(choices=STATUS_CHOICES, null=True, blank=True) + to_status = models.CharField(choices=STATUS_CHOICES, null=True, blank=True) + event_description = models.TextField(null=True, blank=True, verbose_name=_("Description de l'événement")) + field_changes = models.JSONField(null=True, blank=True, default=dict, verbose_name=_("Attributs modifiés")) class Meta: ordering = ["event_time"] + @property + def is_assignment(self): + return self.event_type in ('assignment', 'transfer') + + @property + def is_modification(self): + return self.event_type == 'modification' + + @property + def is_status_change(self): + return self.event_type == 'status_change' + + @property + def is_creation(self): + return self.event_type == 'creation' + + @property + def is_default_visible(self): + return self.event_type in ('creation', 'status_change', 'transfer', 'assignment') + + def get_from_status_color(self): + return STATUS_COLOR_MAP.get(self.from_status, "secondary") + + def get_to_status_color(self): + return STATUS_COLOR_MAP.get(self.to_status, "secondary") + + def get_badge_class(self): + return { + 'creation': 'bg-success', + 'status_change': 'bg-primary', + 'assignment': 'bg-info text-dark', + 'transfer': 'bg-warning text-dark', + 'modification': 'bg-secondary', + }.get(self.event_type, 'bg-secondary') + + def get_icon(self): + return { + 'creation': 'bi-plus-lg', + 'status_change': 'bi-arrow-repeat', + 'assignment': 'bi-person-check-fill', + 'transfer': 'bi-arrow-left-right', + 'modification': 'bi-pencil-fill', + }.get(self.event_type, 'bi-dot') + PLAN_ACTION_TYPE_CHOICES = { 'expected_begin': _('Début prévu'), diff --git a/loko/interventions/services/__init__.py b/loko/interventions/services/__init__.py index e5a5fd9..704776d 100644 --- a/loko/interventions/services/__init__.py +++ b/loko/interventions/services/__init__.py @@ -4,9 +4,17 @@ Services pour le module interventions. from .export_service import generate_interventions_excel from .pending_analysis_service import generate_pending_analysis_excel, get_pending_analysis_data +from .timeline_service import ( + record_timeline_event, + record_assignment_event, + InterventionChangeTracker, +) __all__ = [ 'generate_interventions_excel', 'generate_pending_analysis_excel', 'get_pending_analysis_data', + 'record_timeline_event', + 'record_assignment_event', + 'InterventionChangeTracker', ] diff --git a/loko/interventions/services/timeline_service.py b/loko/interventions/services/timeline_service.py new file mode 100644 index 0000000..9dafed7 --- /dev/null +++ b/loko/interventions/services/timeline_service.py @@ -0,0 +1,393 @@ +import logging +from typing import Dict, Any, Optional, Tuple +from django.utils import timezone +from django.utils.timezone import localtime +from django.utils.translation import gettext as _ +from django.contrib.auth import get_user_model + +from interventions.models import ( + Intervention, + InterventionTimeLine, + MAINTAIN_CHOICES, + ORIGIN_TYPE_CHOICES, + STATUS_CHOICES, + get_type_choices, +) + +logger = logging.getLogger(__name__) + +ASSIGNMENT_FIELDS = { + 'assigned_provider', + 'assigned_team', + 'assigned_member', + 'assigned_control_team', + 'assigned_controller', +} + +FIELD_METADATA = { + 'title': {'label': _("Titre"), 'is_assignment': False}, + 'description': {'label': _("Description"), 'is_assignment': False}, + 'priority': {'label': _("Priorité"), 'is_assignment': False}, + 'maintain_type': {'label': _("Type de maintenance"), 'is_assignment': False}, + 'type': {'label': _("Type d'intervention"), 'is_assignment': False}, + 'sub_type': {'label': _("Sous-type"), 'is_assignment': False}, + 'origin_type': {'label': _("Origine"), 'is_assignment': False}, + 'asset_category': {'label': _("Catégorie d'équipement"), 'is_assignment': False}, + 'symptom': {'label': _("Symptôme"), 'is_assignment': False}, + 'source_category': {'label': _("Catégorie de la source"), 'is_assignment': False}, + 'source_ref': {'label': _("Réf. source"), 'is_assignment': False}, + 'source_detail': {'label': _("Détail de la source"), 'is_assignment': False}, + 'contract': {'label': _("Contrat"), 'is_assignment': False}, + 'order': {'label': _("Bon de commande"), 'is_assignment': False}, + 'osiris': {'label': _("N° Osiris"), 'is_assignment': False}, + 'pmo_number': {'label': _("N° PMO"), 'is_assignment': False}, + 'linked_folder': {'label': _("Dossier lié"), 'is_assignment': False}, + 'address': {'label': _("Adresse"), 'is_assignment': False}, + 'lat': {'label': _("Latitude"), 'is_assignment': False}, + 'lon': {'label': _("Longitude"), 'is_assignment': False}, + 'planned_begin_time': {'label': _("Début planifié"), 'is_assignment': False}, + 'planned_end_time': {'label': _("Fin planifiée"), 'is_assignment': False}, + 'expected_begin_time': {'label': _("Début prévu"), 'is_assignment': False}, + 'expected_end_time': {'label': _("Fin prévue"), 'is_assignment': False}, + 'begin_time': {'label': _("Début effectif"), 'is_assignment': False}, + 'end_time': {'label': _("Fin effective"), 'is_assignment': False}, + 'assigned_provider': {'label': _("Prestataire assigné"), 'is_assignment': True}, + 'assigned_team': {'label': _("Équipe assignée"), 'is_assignment': True}, + 'assigned_member': {'label': _("Membre assigné"), 'is_assignment': True}, + 'assigned_control_team': {'label': _("Équipe de contrôle"), 'is_assignment': True}, + 'assigned_controller': {'label': _("Contrôleur assigné"), 'is_assignment': True}, +} + + +def _format_datetime(dt): + if not dt: + return None + try: + return localtime(dt).strftime('%d/%m/%Y %H:%M') + except Exception: + return str(dt) + + +def _format_field_value(intervention, field_name, value): + """Retourne une représentation textuelle conviviale de la valeur d'un champ.""" + if value is None or value == "": + return None + + if field_name == 'priority': + choices = dict(Intervention._meta.get_field('priority').choices) + return str(choices.get(value, value)) + + if field_name == 'maintain_type': + choices = dict(MAINTAIN_CHOICES) + return str(choices.get(value, value)) + + if field_name == 'type': + thematic = getattr(intervention, 'thematic', None) + choices = dict(get_type_choices(thematic)) + return str(choices.get(value, value)) + + if field_name == 'origin_type': + choices = dict(ORIGIN_TYPE_CHOICES) + return str(choices.get(value, value)) + + if field_name in ('planned_begin_time', 'planned_end_time', 'expected_begin_time', + 'expected_end_time', 'begin_time', 'end_time'): + return _format_datetime(value) + + if field_name in ('lat', 'lon'): + try: + return f"{float(value):.5f}" + except (ValueError, TypeError): + return str(value) + + # Objets reliés (ForeignKeys) + if hasattr(value, 'get_name') and callable(value.get_name): + return value.get_name() + if hasattr(value, 'name'): + return str(value.name) + if hasattr(value, 'contract_number'): + return str(value.contract_number) + if hasattr(value, 'order_code'): + return str(value.order_code) + + return str(value) + + +def record_timeline_event( + intervention: Intervention, + user, + event_type: str, + from_status: Optional[str] = None, + to_status: Optional[str] = None, + description: Optional[str] = None, + field_changes: Optional[Dict[str, Any]] = None, + event_time=None, +) -> InterventionTimeLine: + """Crée et enregistre un événement dans la chronologie de l'intervention.""" + event_user = user if getattr(user, 'is_authenticated', False) else None + if event_time is None: + event_time = timezone.now() + + return InterventionTimeLine.objects.create( + intervention=intervention, + event_user=event_user, + event_time=event_time, + event_type=event_type, + from_status=from_status, + to_status=to_status, + event_description=description or "", + field_changes=field_changes or {}, + ) + + +def record_assignment_event( + intervention: Intervention, + user, + old_team=None, + new_team=None, + old_member=None, + new_member=None, + old_provider=None, + new_provider=None, + old_control_team=None, + new_control_team=None, + old_controller=None, + new_controller=None, + description: Optional[str] = None, + event_time=None, +) -> Optional[InterventionTimeLine]: + """Enregistre un événement d'assignation explicite avec détails des modifications.""" + changes = {} + + def get_obj_name(obj): + if not obj: + return None + if hasattr(obj, 'get_name') and callable(obj.get_name): + return obj.get_name() + if hasattr(obj, 'name'): + return str(obj.name) + return str(obj) + + if old_provider != new_provider: + changes['assigned_provider'] = { + 'label': str(_("Prestataire assigné")), + 'old': get_obj_name(old_provider), + 'new': get_obj_name(new_provider), + } + + if old_team != new_team: + changes['assigned_team'] = { + 'label': str(_("Équipe assignée")), + 'old': get_obj_name(old_team), + 'new': get_obj_name(new_team), + } + + if old_member != new_member: + changes['assigned_member'] = { + 'label': str(_("Membre assigné")), + 'old': get_obj_name(old_member), + 'new': get_obj_name(new_member), + } + + if old_control_team != new_control_team: + changes['assigned_control_team'] = { + 'label': str(_("Équipe de contrôle")), + 'old': get_obj_name(old_control_team), + 'new': get_obj_name(new_control_team), + } + + if old_controller != new_controller: + changes['assigned_controller'] = { + 'label': str(_("Contrôleur assigné")), + 'old': get_obj_name(old_controller), + 'new': get_obj_name(new_controller), + } + + if not changes and not description: + return None + + if not description: + # Construire une description intuitive + parts = [] + new_team_name = get_obj_name(new_team) + new_member_name = get_obj_name(new_member) + old_team_name = get_obj_name(old_team) + + if new_team and new_member: + parts.append(_("Assigné à l'équipe %(team)s (%(member)s)") % { + 'team': new_team_name, + 'member': new_member_name, + }) + elif new_team: + parts.append(_("Assigné à l'équipe %(team)s") % {'team': new_team_name}) + elif old_team and not new_team: + parts.append(_("Désassigné de l'équipe %(team)s") % {'team': old_team_name}) + elif new_member and not old_member: + parts.append(_("Membre assigné : %(member)s") % {'member': new_member_name}) + elif old_member and not new_member: + parts.append(_("Membre retiré : %(member)s") % {'member': get_obj_name(old_member)}) + + if old_provider != new_provider and new_provider: + parts.append(_("Prestataire : %(provider)s") % {'provider': get_obj_name(new_provider)}) + + if old_control_team != new_control_team and new_control_team: + parts.append(_("Contrôle : %(team)s") % {'team': get_obj_name(new_control_team)}) + + description = " | ".join(parts) if parts else _("Mise à jour de l'assignation") + + return record_timeline_event( + intervention=intervention, + user=user, + event_type='assignment', + description=str(description), + field_changes=changes, + event_time=event_time, + ) + + +class InterventionChangeTracker: + """Capture l'état initial d'une intervention et enregistre les modifications apportées.""" + + TRACKED_FIELDS = [ + 'title', 'description', 'priority', 'maintain_type', 'type', + 'sub_type', 'origin_type', 'asset_category', 'symptom', + 'source_category', 'source_ref', 'source_detail', + 'contract', 'order', 'osiris', 'pmo_number', 'linked_folder', + 'address', 'lat', 'lon', + 'planned_begin_time', 'planned_end_time', + 'expected_begin_time', 'expected_end_time', + 'begin_time', 'end_time', + 'assigned_provider', 'assigned_team', 'assigned_member', + 'assigned_control_team', 'assigned_controller', + ] + + def __init__(self, intervention: Intervention, user=None): + self.intervention = intervention + self.user = user + self.snapshot = self._take_snapshot() + + def _take_snapshot(self) -> Dict[str, Any]: + snapshot = {} + for field in self.TRACKED_FIELDS: + val = getattr(self.intervention, field, None) + snapshot[field] = val + return snapshot + + def detect_changes(self) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Compare l'état actuel avec le snapshot et retourne (assignment_changes, other_changes).""" + assignment_changes = {} + other_changes = {} + + for field in self.TRACKED_FIELDS: + old_raw = self.snapshot.get(field) + new_raw = getattr(self.intervention, field, None) + + # Comparaison normalisée + if self._has_changed(field, old_raw, new_raw): + meta = FIELD_METADATA.get(field, {'label': field, 'is_assignment': False}) + old_disp = _format_field_value(self.intervention, field, old_raw) + new_disp = _format_field_value(self.intervention, field, new_raw) + + # Éviter un faux changement si les affichages sont identiques + if old_disp == new_disp and (old_raw is not None or new_raw is not None): + continue + + change_data = { + 'label': str(meta['label']), + 'old': old_disp, + 'new': new_disp, + } + + if meta['is_assignment']: + assignment_changes[field] = change_data + else: + other_changes[field] = change_data + + return assignment_changes, other_changes + + def _has_changed(self, field: str, old_val, new_val) -> bool: + """Détecte si la valeur d'un champ a réellement changé.""" + if old_val is None and new_val is None: + return False + + if field in ('lat', 'lon'): + if old_val is None or new_val is None: + return old_val != new_val + try: + return round(float(old_val), 5) != round(float(new_val), 5) + except (ValueError, TypeError): + return old_val != new_val + + # Objets de modèle ForeignKey + if hasattr(old_val, 'pk') or hasattr(new_val, 'pk'): + old_pk = getattr(old_val, 'pk', None) + new_pk = getattr(new_val, 'pk', None) + return old_pk != new_pk + + # Chaines de caractères + if isinstance(old_val, str) or isinstance(new_val, str): + s_old = (old_val or "").strip() + s_new = (new_val or "").strip() + return s_old != s_new + + return old_val != new_val + + def record_changes(self, user=None, event_time=None) -> Tuple[Optional[InterventionTimeLine], Optional[InterventionTimeLine]]: + """Détecte les modifications et crée les événements correspondants dans la chronologie.""" + if user is None: + user = self.user + assignment_changes, other_changes = self.detect_changes() + assignment_event = None + modification_event = None + + if not assignment_changes and not other_changes: + return None, None + + if event_time is None: + event_time = timezone.now() + + # Enregistrer l'auteur de la dernière modification sur l'intervention + if getattr(user, 'is_authenticated', False): + self.intervention.last_modified_by = user + self.intervention.save(update_fields=['last_modified_by']) + + # 1. Enregistrement d'un événement d'assignation si des champs d'assignation ont changé + if assignment_changes: + old_team = self.snapshot.get('assigned_team') + new_team = getattr(self.intervention, 'assigned_team', None) + old_member = self.snapshot.get('assigned_member') + new_member = getattr(self.intervention, 'assigned_member', None) + old_provider = self.snapshot.get('assigned_provider') + new_provider = getattr(self.intervention, 'assigned_provider', None) + + assignment_event = record_assignment_event( + intervention=self.intervention, + user=user, + old_team=old_team, + new_team=new_team, + old_member=old_member, + new_member=new_member, + old_provider=old_provider, + new_provider=new_provider, + old_control_team=self.snapshot.get('assigned_control_team'), + new_control_team=getattr(self.intervention, 'assigned_control_team', None), + old_controller=self.snapshot.get('assigned_controller'), + new_controller=getattr(self.intervention, 'assigned_controller', None), + event_time=event_time, + ) + + # 2. Enregistrement d'un événement de modification si d'autres attributs ont changé + if other_changes: + changed_labels = [c['label'] for c in other_changes.values()] + desc = _("Champs modifiés : %(fields)s") % {'fields': ", ".join(changed_labels)} + + modification_event = record_timeline_event( + intervention=self.intervention, + user=user, + event_type='modification', + description=str(desc), + field_changes=other_changes, + event_time=event_time, + ) + + return assignment_event, modification_event diff --git a/loko/interventions/static/interventions/timeline.css b/loko/interventions/static/interventions/timeline.css index 172c407..e27fa1d 100644 --- a/loko/interventions/static/interventions/timeline.css +++ b/loko/interventions/static/interventions/timeline.css @@ -142,3 +142,85 @@ background-color: rgba(13, 110, 253, 0.05); } +/* ========================================================================== + Historique et Chronologie des Interventions (Detail view) + ========================================================================== */ +.history-timeline { + position: relative; + padding-left: 2.25rem; + margin: 0.5rem 0 0.5rem 0; +} + +.history-timeline::before { + content: ''; + position: absolute; + top: 14px; + bottom: 14px; + left: 15px; + width: 2px; + background: #e2e8f0; +} + +.history-timeline-item { + position: relative; + margin-bottom: 0.85rem; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.history-timeline-item:last-child { + margin-bottom: 0; +} + +.history-timeline-node { + position: absolute; + left: -2.25rem; + top: 4px; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + z-index: 2; + box-shadow: 0 0 0 3px #ffffff, 0 1px 3px rgba(0, 0, 0, 0.12); +} + +.history-timeline-card { + background-color: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 0.65rem 0.85rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.history-timeline-card:hover { + border-color: #cbd5e1; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07); +} + +.history-item-modification .history-timeline-card { + background-color: #fafbfc; + border-left: 3px solid #94a3b8; +} + +.history-changes { + background: #f8fafc; + border-radius: 6px; + padding: 0.35rem 0.6rem; + margin-top: 0.5rem; +} + +.history-field-row { + font-family: inherit; + line-height: 1.4; + word-break: break-word; +} + +.history-field-row:not(:last-child) { + border-bottom: 1px dashed #e2e8f0; + padding-bottom: 0.25rem; + margin-bottom: 0.25rem; +} + diff --git a/loko/interventions/templates/interventions/detail/card_chronologie.html b/loko/interventions/templates/interventions/detail/card_chronologie.html index c85bfd1..8e97430 100644 --- a/loko/interventions/templates/interventions/detail/card_chronologie.html +++ b/loko/interventions/templates/interventions/detail/card_chronologie.html @@ -1,28 +1,158 @@ {% load i18n short_name tz %} -
| {% translate "Date" %} | -{% translate "Evénement" %} | -{% translate "Statut" %} | -{% translate "Utilisateur" %} | -
|---|---|---|---|
| {{ event.event_time|timezone:"Europe/Brussels"|date:"d-m-Y H:i" }} | -{{ event.get_event_type_display }} | -{{ event.get_to_status_display }} | -{{ event.event_user|short_name }} | -
{% translate "Aucun événement enregistré dans l'historique." %}
+