feat(interventions): amélioration de la chronologie et traçabilité complète de l'historique
- Ajout des types d'événements 'assignment' et 'modification' sur InterventionTimeLine - Enregistrement des attributs modifiés (field_changes JSON) et des descriptions d'événements - Création du service InterventionChangeTracker pour historiser les changements de champs et d'assignations - Refonte visuelle de card_chronologie avec vue par défaut (statuts + assignations) et bascule vers l'historique complet - Préservation des shortnames pour tous les auteurs des modifications - Ajout des traductions i18n et de tests unitaires dédiés
This commit is contained in:
parent
71cb8369a0
commit
79af86fb7f
17 changed files with 1453 additions and 35 deletions
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
@ -1261,10 +1281,56 @@ class InterventionTimeLine(models.Model):
|
|||
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)
|
||||
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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
393
loko/interventions/services/timeline_service.py
Normal file
393
loko/interventions/services/timeline_service.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,158 @@
|
|||
{% load i18n short_name tz %}
|
||||
<div id="section-chronologie" class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Chronologie" %}</h4>
|
||||
<div id="section-chronologie" class="card mb-4 shadow-sm border-0"
|
||||
data-default-count="{{ timeline_default_count|default:intervention.events.count }}"
|
||||
data-total-count="{{ timeline_total_count|default:intervention.events.count }}">
|
||||
<div class="card-header bg-light d-flex flex-wrap align-items-center justify-content-between py-2 px-3 gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h4 class="mb-0 fs-6 fw-bold text-dark d-flex align-items-center">
|
||||
<i class="bi bi-clock-history text-primary me-2"></i>
|
||||
{% translate "Chronologie et historique" %}
|
||||
</h4>
|
||||
<span class="badge rounded-pill bg-primary" id="historyCounterBadge">
|
||||
{{ timeline_default_count|default:intervention.events.count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtre mode d'affichage -->
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="{% translate 'Filtrer l\'historique' %}">
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary active btn-sm"
|
||||
id="btnHistoryDefault"
|
||||
onclick="switchHistoryTimeline('default')"
|
||||
title="{% translate 'Afficher les statuts et les assignations' %}">
|
||||
<i class="bi bi-list-check me-1"></i>{% translate "Statuts & Assignations" %}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
id="btnHistoryFull"
|
||||
onclick="switchHistoryTimeline('full')"
|
||||
title="{% translate 'Afficher toutes les modifications détaillées' %}">
|
||||
<i class="bi bi-clock-history me-1"></i>{% translate "Historique complet" %}
|
||||
{% if timeline_modification_count %}
|
||||
<span class="badge bg-secondary ms-1">+{{ timeline_modification_count }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3">
|
||||
{% with events_list=timeline_events|default:intervention.events.all %}
|
||||
{% if events_list %}
|
||||
<div class="history-timeline">
|
||||
{% for event in events_list %}
|
||||
<div class="history-timeline-item {% if event.is_modification %}history-item-modification d-none{% endif %}"
|
||||
data-event-type="{{ event.event_type }}">
|
||||
<!-- Node Icon -->
|
||||
<div class="history-timeline-node {{ event.get_badge_class }}">
|
||||
<i class="bi {{ event.get_icon }}"></i>
|
||||
</div>
|
||||
|
||||
<!-- Card Bubble -->
|
||||
<div class="history-timeline-card">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<!-- Event Info -->
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
{% if event.event_type == 'status_change' %}
|
||||
<span class="badge bg-primary">{% translate "Statut" %}</span>
|
||||
{% if event.from_status %}
|
||||
<span class="badge bg-{{ event.get_from_status_color }}">{{ event.get_from_status_display }}</span>
|
||||
<i class="bi bi-arrow-right text-muted small"></i>
|
||||
{% endif %}
|
||||
<span class="badge bg-{{ event.get_to_status_color }}">{{ event.get_to_status_display }}</span>
|
||||
{% if event.event_description %}
|
||||
<span class="small text-muted ms-1">({{ event.event_description }})</span>
|
||||
{% endif %}
|
||||
{% elif event.event_type == 'assignment' %}
|
||||
<span class="badge bg-info text-dark">{% translate "Assignation" %}</span>
|
||||
<span class="fw-semibold text-dark">{{ event.event_description|default:_("Assignation mise à jour") }}</span>
|
||||
{% elif event.event_type == 'creation' %}
|
||||
<span class="badge bg-success">{% translate "Création" %}</span>
|
||||
<span class="fw-semibold text-dark">{% translate "Intervention initialisée" %}</span>
|
||||
{% if event.to_status %}
|
||||
<span class="badge bg-{{ event.get_to_status_color }} ms-1">{{ event.get_to_status_display }}</span>
|
||||
{% endif %}
|
||||
{% elif event.event_type == 'transfer' %}
|
||||
<span class="badge bg-warning text-dark">{% translate "Transfert" %}</span>
|
||||
<span class="fw-semibold text-dark">{{ event.event_description|default:_("Intervention transférée") }}</span>
|
||||
{% elif event.event_type == 'modification' %}
|
||||
<span class="badge bg-secondary">{% translate "Modification" %}</span>
|
||||
<span class="fw-semibold text-secondary">{{ event.event_description|default:_("Attributs modifiés") }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ event.get_event_type_display }}</span>
|
||||
{% if event.to_status %}
|
||||
<span class="badge bg-{{ event.get_to_status_color }} ms-1">{{ event.get_to_status_display }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Author & Time -->
|
||||
<div class="d-flex align-items-center gap-3 ms-auto text-nowrap">
|
||||
<span class="small text-secondary" title="{% translate 'Auteur de la modification' %}">
|
||||
<i class="bi bi-person me-1"></i><strong class="text-dark">{{ event.event_user|short_name|default:_("Système") }}</strong>
|
||||
</span>
|
||||
<span class="small text-muted" title="{% translate 'Date et heure' %}">
|
||||
<i class="bi bi-clock me-1"></i>{{ event.event_time|timezone:"Europe/Brussels"|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Field Changes (if any) -->
|
||||
{% if event.field_changes %}
|
||||
<div class="history-changes mt-2 pt-2 border-top">
|
||||
{% for field_key, change in event.field_changes.items %}
|
||||
<div class="history-field-row d-flex flex-wrap align-items-center py-1 small">
|
||||
<span class="badge bg-light text-dark border me-2 py-1 px-2 fw-semibold">{{ change.label|default:field_key }}</span>
|
||||
{% if change.old %}
|
||||
<span class="text-muted text-decoration-line-through me-1">{{ change.old }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic me-1">({% translate "vide" %})</span>
|
||||
{% endif %}
|
||||
<i class="bi bi-arrow-right text-muted mx-1"></i>
|
||||
{% if change.new %}
|
||||
<span class="fw-bold text-dark ms-1">{{ change.new }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted fst-italic ms-1">({% translate "vide" %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Date" %}</th>
|
||||
<th>{% translate "Evénement" %}</th>
|
||||
<th>{% translate "Statut" %}</th>
|
||||
<th>{% translate "Utilisateur" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in intervention.events.all %}
|
||||
<tr>
|
||||
<td>{{ event.event_time|timezone:"Europe/Brussels"|date:"d-m-Y H:i" }}</td>
|
||||
<td>{{ event.get_event_type_display }}</td>
|
||||
<td>{{ event.get_to_status_display }}</td>
|
||||
<td>{{ event.event_user|short_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-clock-history fs-3 d-block mb-2 text-secondary opacity-50"></i>
|
||||
<p class="mb-0 small">{% translate "Aucun événement enregistré dans l'historique." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchHistoryTimeline(mode) {
|
||||
const defaultBtn = document.getElementById('btnHistoryDefault');
|
||||
const fullBtn = document.getElementById('btnHistoryFull');
|
||||
const badge = document.getElementById('historyCounterBadge');
|
||||
const modItems = document.querySelectorAll('.history-item-modification');
|
||||
const section = document.getElementById('section-chronologie');
|
||||
const defaultCount = section ? section.dataset.defaultCount : '';
|
||||
const totalCount = section ? section.dataset.totalCount : '';
|
||||
|
||||
if (mode === 'full') {
|
||||
defaultBtn?.classList.remove('active');
|
||||
fullBtn?.classList.add('active');
|
||||
modItems.forEach(el => el.classList.remove('d-none'));
|
||||
if (badge && totalCount) badge.textContent = totalCount;
|
||||
} else {
|
||||
fullBtn?.classList.remove('active');
|
||||
defaultBtn?.classList.add('active');
|
||||
modItems.forEach(el => el.classList.add('d-none'));
|
||||
if (badge && defaultCount) badge.textContent = defaultCount;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
235
loko/interventions/tests/test_timeline_history.py
Normal file
235
loko/interventions/tests/test_timeline_history.py
Normal file
|
|
@ -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')
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'.")
|
||||
|
|
|
|||
Loading…
Reference in a new issue