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 "Chronologie" %}

-
-
- - - - - - - - - - - {% for event in intervention.events.all %} - - - - - - - {% endfor %} - -
{% 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 "Chronologie et historique" %} +

+ + {{ timeline_default_count|default:intervention.events.count }} + +
+ + +
+
+ +
+
+ +
+ {% with events_list=timeline_events|default:intervention.events.all %} + {% if events_list %} +
+ {% for event in events_list %} +
+ +
+ +
+ + +
+
+ +
+ {% if event.event_type == 'status_change' %} + {% translate "Statut" %} + {% if event.from_status %} + {{ event.get_from_status_display }} + + {% endif %} + {{ event.get_to_status_display }} + {% if event.event_description %} + ({{ event.event_description }}) + {% endif %} + {% elif event.event_type == 'assignment' %} + {% translate "Assignation" %} + {{ event.event_description|default:_("Assignation mise à jour") }} + {% elif event.event_type == 'creation' %} + {% translate "Création" %} + {% translate "Intervention initialisée" %} + {% if event.to_status %} + {{ event.get_to_status_display }} + {% endif %} + {% elif event.event_type == 'transfer' %} + {% translate "Transfert" %} + {{ event.event_description|default:_("Intervention transférée") }} + {% elif event.event_type == 'modification' %} + {% translate "Modification" %} + {{ event.event_description|default:_("Attributs modifiés") }} + {% else %} + {{ event.get_event_type_display }} + {% if event.to_status %} + {{ event.get_to_status_display }} + {% endif %} + {% endif %} +
+ + +
+ + {{ event.event_user|short_name|default:_("Système") }} + + + {{ event.event_time|timezone:"Europe/Brussels"|date:"d/m/Y H:i" }} + +
+
+ + + {% if event.field_changes %} +
+ {% for field_key, change in event.field_changes.items %} +
+ {{ change.label|default:field_key }} + {% if change.old %} + {{ change.old }} + {% else %} + ({% translate "vide" %}) + {% endif %} + + {% if change.new %} + {{ change.new }} + {% else %} + ({% translate "vide" %}) + {% endif %} +
+ {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+ +

{% translate "Aucun événement enregistré dans l'historique." %}

+
+ {% endif %} + {% endwith %} +
+
+ + diff --git a/loko/interventions/templates/interventions/detail/styles.html b/loko/interventions/templates/interventions/detail/styles.html index b646f41..10de605 100644 --- a/loko/interventions/templates/interventions/detail/styles.html +++ b/loko/interventions/templates/interventions/detail/styles.html @@ -489,4 +489,84 @@ background-color: #6b226e !important; color: #ffffff !important; } + + /* 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/tests/test_timeline_history.py b/loko/interventions/tests/test_timeline_history.py new file mode 100644 index 0000000..3b6e9eb --- /dev/null +++ b/loko/interventions/tests/test_timeline_history.py @@ -0,0 +1,235 @@ +import json +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone + +from common.models import UserConfig, Role, Thematic +from contracts.models import Company, CompanyTeam, CompanyMember +from interventions.models import Intervention, InterventionTimeLine +from interventions.services.timeline_service import ( + record_timeline_event, + record_assignment_event, + InterventionChangeTracker, +) + + +class TimelineHistoryTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username='agent_test', + password='secret_password', + first_name='Jean', + last_name='Dupont' + ) + self.user2 = User.objects.create_user( + username='agent_two', + password='secret_password', + first_name='Marie', + last_name='Curie' + ) + self.role_admin, _ = Role.objects.get_or_create(name='admin') + self.user_config = UserConfig.objects.create(user=self.user, is_intern=True) + self.user_config.roles.add(self.role_admin) + + self.thematic, _ = Thematic.objects.get_or_create(code='trafficlights', defaults={'name_fr': 'Signalisation', 'name_nl': 'Signalisatie'}) + + self.company = Company.objects.create(name='StreetUp Tech') + self.team = CompanyTeam.objects.create(company=self.company, name='Équipe Signalisation') + self.team.thematics.add(self.thematic) + self.team2 = CompanyTeam.objects.create(company=self.company, name='Équipe Éclairage') + self.team2.thematics.add(self.thematic) + + self.member = CompanyMember.objects.create(name='Jean Dupont', user=self.user) + self.member.teams.add(self.team) + self.member2 = CompanyMember.objects.create(name='Marie Curie', user=self.user2) + self.member2.teams.add(self.team2) + + self.intervention = Intervention.objects.create( + title='Test Intervention Timeline', + status='in_preparation', + priority='2', + thematic=self.thematic, + assigned_provider=self.company, + assigned_team=self.team, + assigned_member=self.member, + ) + + def test_timeline_model_properties_and_helpers(self): + """Test InterventionTimeLine model properties, icons, badge classes and filters.""" + # Status change event + event_status = InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=timezone.now(), + event_type='status_change', + from_status='in_preparation', + to_status='planned', + event_description='Passage en planifié' + ) + self.assertTrue(event_status.is_status_change) + self.assertTrue(event_status.is_default_visible) + self.assertFalse(event_status.is_modification) + self.assertFalse(event_status.is_assignment) + self.assertEqual(event_status.get_badge_class(), 'bg-primary') + self.assertEqual(event_status.get_icon(), 'bi-arrow-repeat') + + # Assignment event + event_assign = InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=timezone.now(), + event_type='assignment', + event_description='Assigné à Jean Dupont' + ) + self.assertTrue(event_assign.is_assignment) + self.assertTrue(event_assign.is_default_visible) + self.assertFalse(event_assign.is_modification) + self.assertIn('bg-info', event_assign.get_badge_class()) + self.assertEqual(event_assign.get_icon(), 'bi-person-check-fill') + + # Modification event + event_mod = InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=timezone.now(), + event_type='modification', + event_description='Attributs modifiés', + field_changes={'priority': {'label': 'Priorité', 'old': 'Bas', 'new': 'Elevé'}} + ) + self.assertTrue(event_mod.is_modification) + self.assertFalse(event_mod.is_default_visible) + self.assertEqual(event_mod.get_badge_class(), 'bg-secondary') + self.assertEqual(event_mod.get_icon(), 'bi-pencil-fill') + + def test_change_tracker_records_modifications(self): + """Test that InterventionChangeTracker records field modifications.""" + tracker = InterventionChangeTracker(self.intervention, user=self.user) + self.intervention.priority = '4' + self.intervention.title = 'Nouveau titre' + self.intervention.save() + assign_event, mod_event = tracker.record_changes() + + self.assertIsNone(assign_event) + self.assertIsNotNone(mod_event) + self.assertEqual(mod_event.event_type, 'modification') + self.assertEqual(mod_event.event_user, self.user) + self.assertIn('priority', mod_event.field_changes) + self.assertIn('title', mod_event.field_changes) + self.assertEqual(mod_event.field_changes['title']['old'], 'Test Intervention Timeline') + self.assertEqual(mod_event.field_changes['title']['new'], 'Nouveau titre') + self.assertEqual(self.intervention.last_modified_by, self.user) + + def test_change_tracker_records_assignment_change(self): + """Test that changing assigned_member or assigned_team generates an assignment event.""" + tracker = InterventionChangeTracker(self.intervention, user=self.user) + self.intervention.assigned_member = self.member2 + self.intervention.assigned_team = self.team2 + self.intervention.save() + assign_event, mod_event = tracker.record_changes() + + self.assertIsNotNone(assign_event) + self.assertEqual(assign_event.event_type, 'assignment') + self.assertEqual(assign_event.event_user, self.user) + self.assertIn('assigned_member', assign_event.field_changes) + self.assertIn('assigned_team', assign_event.field_changes) + + def test_change_tracker_no_changes(self): + """Test that if nothing changed, no timeline event is generated.""" + tracker = InterventionChangeTracker(self.intervention, user=self.user) + # No modification to fields + self.intervention.save() + assign_event, mod_event = tracker.record_changes() + self.assertIsNone(assign_event) + self.assertIsNone(mod_event) + + def test_update_intervention_view_tracks_modification(self): + """Test update_intervention view logs attribute changes into InterventionTimeLine.""" + self.client.login(username='agent_test', password='secret_password') + url = reverse('interventions:update_intervention', args=[self.intervention.id]) + payload = { + 'title': 'Titre via AJAX', + 'priority': '1' + } + response = self.client.post(url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(response.status_code, 200) + + self.intervention.refresh_from_db() + self.assertEqual(self.intervention.title, 'Titre via AJAX') + self.assertEqual(self.intervention.priority, '1') + self.assertEqual(self.intervention.last_modified_by, self.user) + + mod_event = self.intervention.events.filter(event_type='modification').last() + self.assertIsNotNone(mod_event) + self.assertEqual(mod_event.event_user, self.user) + self.assertIn('title', mod_event.field_changes) + self.assertIn('priority', mod_event.field_changes) + + def test_update_intervention_view_tracks_assignment(self): + """Test update_intervention view logs assignment changes.""" + self.client.login(username='agent_test', password='secret_password') + url = reverse('interventions:update_intervention', args=[self.intervention.id]) + payload = { + 'member_assignment': f'member-{self.member2.id}', + } + response = self.client.post(url, data=json.dumps(payload), content_type='application/json') + self.assertEqual(response.status_code, 200) + + self.intervention.refresh_from_db() + self.assertEqual(self.intervention.assigned_member, self.member2) + + assign_event = self.intervention.events.filter(event_type='assignment').last() + self.assertIsNotNone(assign_event) + self.assertEqual(assign_event.event_user, self.user) + self.assertIn('assigned_member', assign_event.field_changes) + + def test_intervention_detail_view_context(self): + """Test intervention_detail provides timeline counts and events in context.""" + # Create a creation event, an assignment event and a modification event + InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=timezone.now(), + event_type='creation', + to_status='in_preparation' + ) + InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=timezone.now(), + event_type='assignment', + event_description='Assigné à l’équipe' + ) + InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=timezone.now(), + event_type='modification', + event_description='Titre modifié' + ) + + self.client.login(username='agent_test', password='secret_password') + url = reverse('interventions:intervention_detail', args=[self.intervention.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self.assertIn('timeline_events', response.context) + self.assertIn('timeline_default_count', response.context) + self.assertIn('timeline_modification_count', response.context) + self.assertIn('timeline_total_count', response.context) + self.assertGreaterEqual(response.context['timeline_default_count'], 2) + self.assertGreaterEqual(response.context['timeline_modification_count'], 1) + self.assertGreaterEqual(response.context['timeline_total_count'], 3) + + # Check that response content contains IDs, classes and shortname + self.assertContains(response, 'id="section-chronologie"') + self.assertContains(response, 'id="btnHistoryDefault"') + self.assertContains(response, 'id="btnHistoryFull"') + self.assertContains(response, 'history-timeline') + self.assertContains(response, 'Jean D.') + + from django.utils import translation + with translation.override('fr'): + response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr') + self.assertContains(response_fr, 'Historique complet') diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py index 51f30dd..95f89e3 100644 --- a/loko/interventions/views/calendar.py +++ b/loko/interventions/views/calendar.py @@ -1303,6 +1303,8 @@ def interventions_schedule_update(request): # Déterminer si l'assignation va réellement changer prev_member_id = intervention.assigned_member_id prev_team_id = intervention.assigned_team_id + prev_member = intervention.assigned_member + prev_team = intervention.assigned_team assignment_will_change = False if member_key_present: @@ -1434,8 +1436,31 @@ def interventions_schedule_update(request): fields.append('status') with transaction.atomic(): + if getattr(request.user, 'is_authenticated', False): + intervention.last_modified_by = request.user + if 'last_modified_by' not in fields: + fields.append('last_modified_by') intervention.save(update_fields=fields) _log_planning_change(intervention, request.user, prev_begin, prev_end, prev_status if new_status != prev_status else None) + if 'assigned_team' in fields or 'assigned_member' in fields: + from interventions.services.timeline_service import record_assignment_event + record_assignment_event( + intervention=intervention, + user=request.user, + old_team=prev_team, + new_team=intervention.assigned_team, + old_member=prev_member, + new_member=intervention.assigned_member, + ) + if new_status != prev_status: + from interventions.services.timeline_service import record_timeline_event + record_timeline_event( + intervention=intervention, + user=request.user, + event_type='status_change', + from_status=prev_status, + to_status=new_status, + ) return JsonResponse({ 'success': True, diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 214dc35..e6f81ba 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -63,6 +63,7 @@ from interventions.models import (InterventionSubscription, Intervention, Interv InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices from interventions.services.document_sequencing import get_sequenced_intervention_documents +from interventions.services.timeline_service import InterventionChangeTracker from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, LinkExistingInterventionsForm) @@ -647,6 +648,38 @@ def intervention_detail(request, intervention_id): for st in sub_types_qs ) + # Événements de la chronologie / historique + timeline_events = list( + intervention.events.select_related('event_user').order_by('event_time') + ) + has_assignment_event = any(e.is_assignment for e in timeline_events) + if not has_assignment_event and (intervention.assigned_team or intervention.assigned_member or intervention.assigned_provider): + desc_parts = [] + team_name = intervention.assigned_team.name if intervention.assigned_team else None + member_name = intervention.assigned_member.name if intervention.assigned_member else None + if team_name and member_name: + desc_parts.append(_("Assigné à l'équipe %(team)s (%(member)s)") % {'team': team_name, 'member': member_name}) + elif team_name: + desc_parts.append(_("Assigné à l'équipe %(team)s") % {'team': team_name}) + elif member_name: + desc_parts.append(_("Membre assigné : %(member)s") % {'member': member_name}) + if intervention.assigned_provider: + desc_parts.append(_("Prestataire : %(provider)s") % {'provider': intervention.assigned_provider.name}) + + synth_event = InterventionTimeLine( + intervention=intervention, + event_user=intervention.created_by, + event_time=intervention.creation_time, + event_type='assignment', + event_description=" | ".join(desc_parts), + field_changes={}, + ) + insert_idx = 1 if (timeline_events and timeline_events[0].is_creation) else 0 + timeline_events.insert(insert_idx, synth_event) + + timeline_default_count = sum(1 for e in timeline_events if e.is_default_visible) + timeline_modification_count = sum(1 for e in timeline_events if e.is_modification) + context = { "intervention": intervention, "status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()], @@ -727,6 +760,11 @@ def intervention_detail(request, intervention_id): 'has_period_quantities': intervention.period_quantities.exists(), 'future_planned_events': future_planned_events, 'future_realized_events': future_realized_events, + # Timeline / Chronologie + 'timeline_events': timeline_events, + 'timeline_default_count': timeline_default_count, + 'timeline_modification_count': timeline_modification_count, + 'timeline_total_count': len(timeline_events), # Historique des envois d'emails (ordre chronologique) 'email_logs': intervention.email_logs.order_by('sent_at'), # Operation editing data (same as mobile) @@ -2704,6 +2742,8 @@ def update_intervention(request, intervention_id): return 3 return 4 + tracker = InterventionChangeTracker(intervention) + for field, new_value in sorted(data.items(), key=_field_priority): if field not in allowed_update_fields: errors.append(_('You do not have permission to update field: %(field)s') % {'field': field}) @@ -2717,7 +2757,9 @@ def update_intervention(request, intervention_id): errors.append(error) if updated_fields: + intervention.last_modified_by = request.user intervention.save() + tracker.record_changes(request.user) # Handle notes if provided if notes_data: @@ -2755,12 +2797,16 @@ def update_intervention(request, intervention_id): elif field == "realization": return _handle_realization_update(request, intervention) + tracker = InterventionChangeTracker(intervention) + # Handle standard fields success, error = _update_intervention_field(intervention, field, new_value, user_config=user_config) if not success: return JsonResponse({'success': False, 'error': error}, status=400) + intervention.last_modified_by = request.user intervention.save() + tracker.record_changes(request.user) return JsonResponse({ 'success': True, diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index e5b8a22..7922ff4 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -2280,6 +2280,26 @@ def transfer_intervention(request, intervention_id): # Record the transfer in timeline event_time = now() + # Add a note about the transfer + transfer_note = _("Intervention transférée de %(from_member)s (%(from_team)s) vers %(to_member)s (%(to_team)s)") % { + 'from_member': old_member.name if old_member else _("non assigné"), + 'from_team': old_team.name if old_team else _("aucune équipe"), + 'to_member': target_member.name, + 'to_team': target_team.name if target_team else _("aucune équipe"), + } + transfer_changes = { + 'assigned_team': { + 'label': str(_("Équipe assignée")), + 'old': old_team.name if old_team else None, + 'new': target_team.name if target_team else None, + }, + 'assigned_member': { + 'label': str(_("Membre assigné")), + 'old': old_member.name if old_member else None, + 'new': target_member.name, + } + } + # Always record the transfer event InterventionTimeLine.objects.create( intervention=intervention, @@ -2288,6 +2308,8 @@ def transfer_intervention(request, intervention_id): event_type='transfer', from_status=old_status, to_status=old_status, # Status before any change + event_description=transfer_note, + field_changes=transfer_changes, ) # If status changed, also record a status change event @@ -2299,15 +2321,9 @@ def transfer_intervention(request, intervention_id): event_type='status_change', from_status=old_status, to_status=intervention.status, + event_description=_("Passage en pause automatique suite au transfert."), ) - - # Add a note about the transfer - transfer_note = _("Intervention transférée de %(from_member)s (%(from_team)s) vers %(to_member)s (%(to_team)s)") % { - 'from_member': old_member.name if old_member else _("non assigné"), - 'from_team': old_team.name if old_team else _("aucune équipe"), - 'to_member': target_member.name, - 'to_team': target_team.name if target_team else _("aucune équipe"), - } + if status_changed: transfer_note += " - " + _("Statut changé automatiquement de 'Démarré' à 'En pause'.")