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:
kdeterme 2026-09-25 21:29:42 +02:00
parent 71cb8369a0
commit 79af86fb7f
17 changed files with 1453 additions and 35 deletions

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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')]),
),
]

View file

@ -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'),

View file

@ -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',
]

View 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

View file

@ -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;
}

View file

@ -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>

View file

@ -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>

View 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')

View file

@ -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,

View file

@ -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,

View file

@ -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'.")