feat(interventions): trace notes, documents and element links in full timeline

- Add 'note', 'document', and 'link' event types to InterventionTimeLine
- Record timeline events for note additions/edits/deletions (standalone and batch)
- Record document uploads and deletions (desktop and mobile APIs)
- Record asset linking/unlinking with friendly names and linked interventions
- Display detailed items exclusively in the toggleable "Historique complet" view
- Maintain shortname display for all event authors
- Update and compile FR, EN and NL translation catalogs
- Add comprehensive test coverage for all new timeline event records
This commit is contained in:
kdeterme 2026-09-25 22:05:22 +02:00
parent 79af86fb7f
commit f510a086bd
17 changed files with 1171 additions and 20 deletions

View file

@ -8240,3 +8240,137 @@ msgstr "Assignment update"
msgid "Champs modifiés : %(fields)s" msgid "Champs modifiés : %(fields)s"
msgstr "Modified fields: %(fields)s" msgstr "Modified fields: %(fields)s"
#: interventions/models.py:1259
msgid "Lien"
msgstr "Link"
#: interventions/services/timeline_service.py:417
#, python-format
msgid "Note ajoutée (%(type)s)"
msgstr "Note added (%(type)s)"
#: interventions/services/timeline_service.py:418
msgid "Ajout"
msgstr "Addition"
#: interventions/services/timeline_service.py:423
#, python-format
msgid "Note modifiée (%(type)s)"
msgstr "Note updated (%(type)s)"
#: interventions/services/timeline_service.py:433
msgid "Mise à jour"
msgstr "Update"
#: interventions/services/timeline_service.py:435
#, python-format
msgid "Note supprimée (%(type)s)"
msgstr "Note deleted (%(type)s)"
#: interventions/services/timeline_service.py:436
msgid "Suppression"
msgstr "Deletion"
#: interventions/services/timeline_service.py:441
#, python-format
msgid "Note : %(action)s"
msgstr "Note: %(action)s"
#: interventions/services/timeline_service.py:465
#, python-format
msgid "Document ajouté : %(name)s"
msgstr "Document added: %(name)s"
#: interventions/services/timeline_service.py:467
#, python-format
msgid "%(count)d documents ajoutés : %(names)s"
msgstr "%(count)d documents added: %(names)s"
#: interventions/services/timeline_service.py:472
msgid "Document(s) ajouté(s)"
msgstr "Added document(s)"
#: interventions/services/timeline_service.py:478
#, python-format
msgid "Document supprimé : %(name)s"
msgstr "Document deleted: %(name)s"
#: interventions/services/timeline_service.py:480
#, python-format
msgid "%(count)d documents supprimés : %(names)s"
msgstr "%(count)d documents deleted: %(names)s"
#: interventions/services/timeline_service.py:485
msgid "Document(s) supprimé(s)"
msgstr "Deleted document(s)"
#: interventions/services/timeline_service.py:490
#, python-format
msgid "Document : %(action)s"
msgstr "Document: %(action)s"
#: interventions/services/timeline_service.py:549
#, python-format
msgid "Équipement lié : %(name)s"
msgstr "Equipment linked: %(name)s"
#: interventions/services/timeline_service.py:551
#, python-format
msgid "%(count)d équipements liés : %(names)s"
msgstr "%(count)d equipment linked: %(names)s"
#: interventions/services/timeline_service.py:557
#, python-format
msgid "Équipement délié : %(name)s"
msgstr "Equipment unlinked: %(name)s"
#: interventions/services/timeline_service.py:559
#, python-format
msgid "%(count)d équipements déliés : %(names)s"
msgstr "%(count)d equipment unlinked: %(names)s"
#: interventions/services/timeline_service.py:564
#, python-format
msgid "Équipements modifiés (+%(added)d, -%(removed)d)"
msgstr "Equipment updated (+%(added)d, -%(removed)d)"
#: interventions/services/timeline_service.py:570
msgid "Équipement(s) lié(s)"
msgstr "Linked equipment"
#: interventions/services/timeline_service.py:576
msgid "Équipement(s) délié(s)"
msgstr "Unlinked equipment"
#: interventions/services/timeline_service.py:603
#, python-format
msgid "%(relation)s liée : %(target)s"
msgstr "Linked %(relation)s: %(target)s"
#: interventions/services/timeline_service.py:608
#, python-format
msgid "%(count)d %(relation)ss liées : %(targets)s"
msgstr "%(count)d linked %(relation)ss: %(targets)s"
#: interventions/services/timeline_service.py:614
#, python-format
msgid "%(relation)s liée(s)"
msgstr "Linked %(relation)s(s)"
#: interventions/services/timeline_service.py:620
#, python-format
msgid "%(relation)s déliée : %(target)s"
msgstr "Unlinked %(relation)s: %(target)s"
#: interventions/services/timeline_service.py:625
#, python-format
msgid "%(count)d %(relation)ss déliées : %(targets)s"
msgstr "%(count)d unlinked %(relation)ss: %(targets)s"
#: interventions/services/timeline_service.py:631
#, python-format
msgid "%(relation)s déliée(s)"
msgstr "Unlinked %(relation)s(s)"

View file

@ -8347,3 +8347,137 @@ msgstr "Mise à jour de l'assignation"
msgid "Champs modifiés : %(fields)s" msgid "Champs modifiés : %(fields)s"
msgstr "Champs modifiés : %(fields)s" msgstr "Champs modifiés : %(fields)s"
#: interventions/models.py:1259
msgid "Lien"
msgstr "Lien"
#: interventions/services/timeline_service.py:417
#, python-format
msgid "Note ajoutée (%(type)s)"
msgstr "Note ajoutée (%(type)s)"
#: interventions/services/timeline_service.py:418
msgid "Ajout"
msgstr "Ajout"
#: interventions/services/timeline_service.py:423
#, python-format
msgid "Note modifiée (%(type)s)"
msgstr "Note modifiée (%(type)s)"
#: interventions/services/timeline_service.py:433
msgid "Mise à jour"
msgstr "Mise à jour"
#: interventions/services/timeline_service.py:435
#, python-format
msgid "Note supprimée (%(type)s)"
msgstr "Note supprimée (%(type)s)"
#: interventions/services/timeline_service.py:436
msgid "Suppression"
msgstr "Suppression"
#: interventions/services/timeline_service.py:441
#, python-format
msgid "Note : %(action)s"
msgstr "Note : %(action)s"
#: interventions/services/timeline_service.py:465
#, python-format
msgid "Document ajouté : %(name)s"
msgstr "Document ajouté : %(name)s"
#: interventions/services/timeline_service.py:467
#, python-format
msgid "%(count)d documents ajoutés : %(names)s"
msgstr "%(count)d documents ajoutés : %(names)s"
#: interventions/services/timeline_service.py:472
msgid "Document(s) ajouté(s)"
msgstr "Document(s) ajouté(s)"
#: interventions/services/timeline_service.py:478
#, python-format
msgid "Document supprimé : %(name)s"
msgstr "Document supprimé : %(name)s"
#: interventions/services/timeline_service.py:480
#, python-format
msgid "%(count)d documents supprimés : %(names)s"
msgstr "%(count)d documents supprimés : %(names)s"
#: interventions/services/timeline_service.py:485
msgid "Document(s) supprimé(s)"
msgstr "Document(s) supprimé(s)"
#: interventions/services/timeline_service.py:490
#, python-format
msgid "Document : %(action)s"
msgstr "Document : %(action)s"
#: interventions/services/timeline_service.py:549
#, python-format
msgid "Équipement lié : %(name)s"
msgstr "Équipement lié : %(name)s"
#: interventions/services/timeline_service.py:551
#, python-format
msgid "%(count)d équipements liés : %(names)s"
msgstr "%(count)d équipements liés : %(names)s"
#: interventions/services/timeline_service.py:557
#, python-format
msgid "Équipement délié : %(name)s"
msgstr "Équipement délié : %(name)s"
#: interventions/services/timeline_service.py:559
#, python-format
msgid "%(count)d équipements déliés : %(names)s"
msgstr "%(count)d équipements déliés : %(names)s"
#: interventions/services/timeline_service.py:564
#, python-format
msgid "Équipements modifiés (+%(added)d, -%(removed)d)"
msgstr "Équipements modifiés (+%(added)d, -%(removed)d)"
#: interventions/services/timeline_service.py:570
msgid "Équipement(s) lié(s)"
msgstr "Équipement(s) lié(s)"
#: interventions/services/timeline_service.py:576
msgid "Équipement(s) délié(s)"
msgstr "Équipement(s) délié(s)"
#: interventions/services/timeline_service.py:603
#, python-format
msgid "%(relation)s liée : %(target)s"
msgstr "%(relation)s liée : %(target)s"
#: interventions/services/timeline_service.py:608
#, python-format
msgid "%(count)d %(relation)ss liées : %(targets)s"
msgstr "%(count)d %(relation)ss liées : %(targets)s"
#: interventions/services/timeline_service.py:614
#, python-format
msgid "%(relation)s liée(s)"
msgstr "%(relation)s liée(s)"
#: interventions/services/timeline_service.py:620
#, python-format
msgid "%(relation)s déliée : %(target)s"
msgstr "%(relation)s déliée : %(target)s"
#: interventions/services/timeline_service.py:625
#, python-format
msgid "%(count)d %(relation)ss déliées : %(targets)s"
msgstr "%(count)d %(relation)ss déliées : %(targets)s"
#: interventions/services/timeline_service.py:631
#, python-format
msgid "%(relation)s déliée(s)"
msgstr "%(relation)s déliée(s)"

View file

@ -8281,3 +8281,136 @@ msgstr "Toewijzing bijgewerkt"
msgid "Champs modifiés : %(fields)s" msgid "Champs modifiés : %(fields)s"
msgstr "Gewijzigde velden: %(fields)s" msgstr "Gewijzigde velden: %(fields)s"
#: interventions/models.py:1259
msgid "Lien"
msgstr "Koppeling"
#: interventions/services/timeline_service.py:417
#, python-format
msgid "Note ajoutée (%(type)s)"
msgstr "Notitie toegevoegd (%(type)s)"
#: interventions/services/timeline_service.py:418
msgid "Ajout"
msgstr "Toevoeging"
#: interventions/services/timeline_service.py:423
#, python-format
msgid "Note modifiée (%(type)s)"
msgstr "Notitie gewijzigd (%(type)s)"
#: interventions/services/timeline_service.py:433
msgid "Mise à jour"
msgstr "Bijwerking"
#: interventions/services/timeline_service.py:435
#, python-format
msgid "Note supprimée (%(type)s)"
msgstr "Notitie verwijderd (%(type)s)"
#: interventions/services/timeline_service.py:436
msgid "Suppression"
msgstr "Verwijdering"
#: interventions/services/timeline_service.py:441
#, python-format
msgid "Note : %(action)s"
msgstr "Notitie: %(action)s"
#: interventions/services/timeline_service.py:465
#, python-format
msgid "Document ajouté : %(name)s"
msgstr "Document toegevoegd: %(name)s"
#: interventions/services/timeline_service.py:467
#, python-format
msgid "%(count)d documents ajoutés : %(names)s"
msgstr "%(count)d documenten toegevoegd: %(names)s"
#: interventions/services/timeline_service.py:472
msgid "Document(s) ajouté(s)"
msgstr "Toegevoegd(e) document(en)"
#: interventions/services/timeline_service.py:478
#, python-format
msgid "Document supprimé : %(name)s"
msgstr "Document verwijderd: %(name)s"
#: interventions/services/timeline_service.py:480
#, python-format
msgid "%(count)d documents supprimés : %(names)s"
msgstr "%(count)d documenten verwijderd: %(names)s"
#: interventions/services/timeline_service.py:485
msgid "Document(s) supprimé(s)"
msgstr "Verwijderd(e) document(en)"
#: interventions/services/timeline_service.py:490
#, python-format
msgid "Document : %(action)s"
msgstr "Document: %(action)s"
#: interventions/services/timeline_service.py:549
#, python-format
msgid "Équipement lié : %(name)s"
msgstr "Apparaat gekoppeld: %(name)s"
#: interventions/services/timeline_service.py:551
#, python-format
msgid "%(count)d équipements liés : %(names)s"
msgstr "%(count)d apparaten gekoppeld: %(names)s"
#: interventions/services/timeline_service.py:557
#, python-format
msgid "Équipement délié : %(name)s"
msgstr "Apparaat ontkoppeld: %(name)s"
#: interventions/services/timeline_service.py:559
#, python-format
msgid "%(count)d équipements déliés : %(names)s"
msgstr "%(count)d apparaten ontkoppeld: %(names)s"
#: interventions/services/timeline_service.py:564
#, python-format
msgid "Équipements modifiés (+%(added)d, -%(removed)d)"
msgstr "Apparaten gewijzigd (+%(added)d, -%(removed)d)"
#: interventions/services/timeline_service.py:570
msgid "Équipement(s) lié(s)"
msgstr "Gekoppeld(e) appara(a)t(en)"
#: interventions/services/timeline_service.py:576
msgid "Équipement(s) délié(s)"
msgstr "Ontkoppeld(e) appara(a)t(en)"
#: interventions/services/timeline_service.py:603
#, python-format
msgid "%(relation)s liée : %(target)s"
msgstr "%(relation)s gekoppeld: %(target)s"
#: interventions/services/timeline_service.py:608
#, python-format
msgid "%(count)d %(relation)ss liées : %(targets)s"
msgstr "%(count)d %(relation)ss gekoppeld: %(targets)s"
#: interventions/services/timeline_service.py:614
#, python-format
msgid "%(relation)s liée(s)"
msgstr "Gekoppelde %(relation)s(s)"
#: interventions/services/timeline_service.py:620
#, python-format
msgid "%(relation)s déliée : %(target)s"
msgstr "%(relation)s ontkoppeld: %(target)s"
#: interventions/services/timeline_service.py:625
#, python-format
msgid "%(count)d %(relation)ss déliées : %(targets)s"
msgstr "%(count)d %(relation)ss ontkoppeld: %(targets)s"
#: interventions/services/timeline_service.py:631
#, python-format
msgid "%(relation)s déliée(s)"
msgstr "Ontkoppelde %(relation)s(s)"

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-09-25 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('interventions', '0014_interventiontimeline_event_description_and_more'),
]
operations = [
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'), ('note', 'Note'), ('document', 'Document'), ('link', 'Lien')]),
),
]

View file

@ -1254,6 +1254,9 @@ ACTION_TYPE_CHOICES = {
'transfer': _('Transfert'), 'transfer': _('Transfert'),
'assignment': _('Assignation'), 'assignment': _('Assignation'),
'modification': _('Modification'), 'modification': _('Modification'),
'note': _('Note'),
'document': _('Document'),
'link': _('Lien'),
} }
STATUS_COLOR_MAP = { STATUS_COLOR_MAP = {
@ -1295,6 +1298,18 @@ class InterventionTimeLine(models.Model):
def is_modification(self): def is_modification(self):
return self.event_type == 'modification' return self.event_type == 'modification'
@property
def is_note(self):
return self.event_type == 'note'
@property
def is_document(self):
return self.event_type == 'document'
@property
def is_link(self):
return self.event_type == 'link'
@property @property
def is_status_change(self): def is_status_change(self):
return self.event_type == 'status_change' return self.event_type == 'status_change'
@ -1320,6 +1335,9 @@ class InterventionTimeLine(models.Model):
'assignment': 'bg-info text-dark', 'assignment': 'bg-info text-dark',
'transfer': 'bg-warning text-dark', 'transfer': 'bg-warning text-dark',
'modification': 'bg-secondary', 'modification': 'bg-secondary',
'note': 'bg-info text-dark',
'document': 'bg-secondary',
'link': 'bg-dark',
}.get(self.event_type, 'bg-secondary') }.get(self.event_type, 'bg-secondary')
def get_icon(self): def get_icon(self):
@ -1329,6 +1347,9 @@ class InterventionTimeLine(models.Model):
'assignment': 'bi-person-check-fill', 'assignment': 'bi-person-check-fill',
'transfer': 'bi-arrow-left-right', 'transfer': 'bi-arrow-left-right',
'modification': 'bi-pencil-fill', 'modification': 'bi-pencil-fill',
'note': 'bi-chat-left-text-fill',
'document': 'bi-paperclip',
'link': 'bi-link-45deg',
}.get(self.event_type, 'bi-dot') }.get(self.event_type, 'bi-dot')

View file

@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
from interventions.models import ( from interventions.models import (
Intervention, Intervention,
InterventionTimeLine, InterventionTimeLine,
InterventionNote,
MAINTAIN_CHOICES, MAINTAIN_CHOICES,
ORIGIN_TYPE_CHOICES, ORIGIN_TYPE_CHOICES,
STATUS_CHOICES, STATUS_CHOICES,
@ -391,3 +392,291 @@ class InterventionChangeTracker:
) )
return assignment_event, modification_event return assignment_event, modification_event
def truncate_text(text: Optional[str], max_len: int = 120) -> str:
"""Tronque un texte pour un affichage concis dans l'historique."""
if not text:
return ""
text = text.strip().replace('\r\n', ' ').replace('\n', ' ')
if len(text) <= max_len:
return text
return text[:max_len - 3] + "..."
def record_note_event(
intervention: Intervention,
user,
action: str, # 'create', 'update', 'delete'
note_type: str,
content: str,
old_note_type: Optional[str] = None,
old_content: Optional[str] = None,
event_time=None,
) -> InterventionTimeLine:
"""Enregistre un événement lié aux notes dans la chronologie."""
type_choices = dict(InterventionNote.NoteType.choices)
type_label = type_choices.get(note_type, note_type)
old_type_label = type_choices.get(old_note_type, old_note_type) if old_note_type else None
changes = {}
if action == 'create':
desc = _("Note ajoutée (%(type)s)") % {'type': type_label}
changes['note_action'] = {'label': str(_("Action")), 'old': None, 'new': str(_("Ajout"))}
changes['note_type'] = {'label': str(_("Type de note")), 'old': None, 'new': str(type_label)}
if content:
changes['content'] = {'label': str(_("Contenu")), 'old': None, 'new': truncate_text(content)}
elif action == 'update':
desc = _("Note modifiée (%(type)s)") % {'type': type_label}
if old_note_type and old_note_type != note_type:
changes['note_type'] = {'label': str(_("Type de note")), 'old': str(old_type_label), 'new': str(type_label)}
if old_content is not None and old_content != content:
changes['content'] = {
'label': str(_("Contenu")),
'old': truncate_text(old_content),
'new': truncate_text(content),
}
if not changes:
changes['note_action'] = {'label': str(_("Action")), 'old': None, 'new': str(_("Mise à jour"))}
elif action == 'delete':
desc = _("Note supprimée (%(type)s)") % {'type': type_label}
changes['note_action'] = {'label': str(_("Action")), 'old': str(_("Suppression")), 'new': None}
changes['note_type'] = {'label': str(_("Type de note")), 'old': str(type_label), 'new': None}
if content:
changes['content'] = {'label': str(_("Contenu")), 'old': truncate_text(content), 'new': None}
else:
desc = _("Note : %(action)s") % {'action': action}
return record_timeline_event(
intervention=intervention,
user=user,
event_type='note',
description=str(desc),
field_changes=changes,
event_time=event_time,
)
def record_document_event(
intervention: Intervention,
user,
action: str, # 'upload', 'delete'
filenames: list,
event_time=None,
) -> Optional[InterventionTimeLine]:
"""Enregistre un événement de document (téléversement ou suppression)."""
if not filenames:
return None
import os
clean_filenames = [os.path.basename(str(f)) for f in filenames if f]
if not clean_filenames:
return None
changes = {}
names_str = ", ".join(clean_filenames)
if action == 'upload':
if len(clean_filenames) == 1:
desc = _("Document ajouté : %(name)s") % {'name': clean_filenames[0]}
else:
desc = _("%(count)d documents ajoutés : %(names)s") % {
'count': len(clean_filenames),
'names': names_str,
}
changes['documents'] = {
'label': str(_("Document(s) ajouté(s)")),
'old': None,
'new': names_str,
}
elif action == 'delete':
if len(clean_filenames) == 1:
desc = _("Document supprimé : %(name)s") % {'name': clean_filenames[0]}
else:
desc = _("%(count)d documents supprimés : %(names)s") % {
'count': len(clean_filenames),
'names': names_str,
}
changes['documents'] = {
'label': str(_("Document(s) supprimé(s)")),
'old': names_str,
'new': None,
}
else:
desc = _("Document : %(action)s") % {'action': action}
return record_timeline_event(
intervention=intervention,
user=user,
event_type='document',
description=str(desc),
field_changes=changes,
event_time=event_time,
)
def resolve_asset_label(asset_obj, ct=None) -> str:
"""Retourne une désignation lisible d'un équipement."""
if not asset_obj:
return ""
if hasattr(asset_obj, 'get_name') and callable(asset_obj.get_name):
name = asset_obj.get_name()
elif hasattr(asset_obj, 'name') and asset_obj.name:
name = str(asset_obj.name)
else:
name = str(asset_obj)
if ct and hasattr(ct, 'name') and ct.name:
return f"{name} ({ct.name})"
return str(name)
def get_asset_labels_from_pairs(pairs) -> list:
"""Résout une collection de (content_type_id, object_id) en liste de désignations lisibles."""
if not pairs:
return []
from collections import defaultdict
from django.contrib.contenttypes.models import ContentType
by_ct = defaultdict(set)
for ct_id, obj_id in pairs:
by_ct[ct_id].add(obj_id)
labels = []
ct_map = {ct.id: ct for ct in ContentType.objects.filter(id__in=by_ct.keys())}
for ct_id, obj_ids in by_ct.items():
ct = ct_map.get(ct_id)
if not ct:
continue
model_class = ct.model_class()
if not model_class:
continue
obj_map = {obj.pk: obj for obj in model_class.objects.filter(pk__in=obj_ids)}
for obj_id in obj_ids:
asset_obj = obj_map.get(obj_id)
if asset_obj:
labels.append(resolve_asset_label(asset_obj, ct))
else:
labels.append(f"{ct.model} #{obj_id}")
return labels
def record_asset_link_event(
intervention: Intervention,
user,
added_asset_names: Optional[list] = None,
removed_asset_names: Optional[list] = None,
event_time=None,
) -> Optional[InterventionTimeLine]:
"""Enregistre un événement de liaison / déliaison d'équipements (assets)."""
added_names = [str(n) for n in (added_asset_names or []) if n]
removed_names = [str(n) for n in (removed_asset_names or []) if n]
if not added_names and not removed_names:
return None
changes = {}
if added_names and not removed_names:
if len(added_names) == 1:
desc = _("Équipement lié : %(name)s") % {'name': added_names[0]}
else:
desc = _("%(count)d équipements liés : %(names)s") % {
'count': len(added_names),
'names': ", ".join(added_names),
}
elif removed_names and not added_names:
if len(removed_names) == 1:
desc = _("Équipement délié : %(name)s") % {'name': removed_names[0]}
else:
desc = _("%(count)d équipements déliés : %(names)s") % {
'count': len(removed_names),
'names': ", ".join(removed_names),
}
else:
desc = _("Équipements modifiés (+%(added)d, -%(removed)d)") % {
'added': len(added_names),
'removed': len(removed_names),
}
if added_names:
changes['linked_assets'] = {
'label': str(_("Équipement(s) lié(s)")),
'old': None,
'new': ", ".join(added_names),
}
if removed_names:
changes['unlinked_assets'] = {
'label': str(_("Équipement(s) délié(s)")),
'old': ", ".join(removed_names),
'new': None,
}
return record_timeline_event(
intervention=intervention,
user=user,
event_type='link',
description=str(desc),
field_changes=changes,
event_time=event_time,
)
def record_relation_link_event(
intervention: Intervention,
user,
relation_label: str,
target_labels: list,
action: str = 'link',
event_time=None,
) -> Optional[InterventionTimeLine]:
"""Enregistre un événement de liaison avec un autre élément (ex: intervention liée)."""
targets = [str(t) for t in target_labels if t]
if not targets:
return None
targets_str = ", ".join(targets)
changes = {}
if action == 'link':
if len(targets) == 1:
desc = _("%(relation)s liée : %(target)s") % {
'relation': relation_label,
'target': targets[0],
}
else:
desc = _("%(count)d %(relation)ss liées : %(targets)s") % {
'count': len(targets),
'relation': relation_label,
'targets': targets_str,
}
changes['linked_items'] = {
'label': str(_("%(relation)s liée(s)") % {'relation': relation_label}),
'old': None,
'new': targets_str,
}
else:
if len(targets) == 1:
desc = _("%(relation)s déliée : %(target)s") % {
'relation': relation_label,
'target': targets[0],
}
else:
desc = _("%(count)d %(relation)ss déliées : %(targets)s") % {
'count': len(targets),
'relation': relation_label,
'targets': targets_str,
}
changes['unlinked_items'] = {
'label': str(_("%(relation)s déliée(s)") % {'relation': relation_label}),
'old': targets_str,
'new': None,
}
return record_timeline_event(
intervention=intervention,
user=user,
event_type='link',
description=str(desc),
field_changes=changes,
event_time=event_time,
)

View file

@ -200,11 +200,24 @@
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
} }
.history-item-modification .history-timeline-card { .history-item-modification .history-timeline-card,
.history-item-detailed .history-timeline-card {
background-color: #fafbfc; background-color: #fafbfc;
border-left: 3px solid #94a3b8; border-left: 3px solid #94a3b8;
} }
.history-timeline-item[data-event-type="note"] .history-timeline-card {
border-left: 3px solid #0dcaf0;
}
.history-timeline-item[data-event-type="document"] .history-timeline-card {
border-left: 3px solid #6c757d;
}
.history-timeline-item[data-event-type="link"] .history-timeline-card {
border-left: 3px solid #212529;
}
.history-changes { .history-changes {
background: #f8fafc; background: #f8fafc;
border-radius: 6px; border-radius: 6px;

View file

@ -29,8 +29,8 @@
onclick="switchHistoryTimeline('full')" onclick="switchHistoryTimeline('full')"
title="{% translate 'Afficher toutes les modifications détaillées' %}"> title="{% translate 'Afficher toutes les modifications détaillées' %}">
<i class="bi bi-clock-history me-1"></i>{% translate "Historique complet" %} <i class="bi bi-clock-history me-1"></i>{% translate "Historique complet" %}
{% if timeline_modification_count %} {% if timeline_detailed_count or timeline_modification_count %}
<span class="badge bg-secondary ms-1">+{{ timeline_modification_count }}</span> <span class="badge bg-secondary ms-1">+{{ timeline_detailed_count|default:timeline_modification_count }}</span>
{% endif %} {% endif %}
</button> </button>
</div> </div>
@ -42,7 +42,7 @@
{% if events_list %} {% if events_list %}
<div class="history-timeline"> <div class="history-timeline">
{% for event in events_list %} {% for event in events_list %}
<div class="history-timeline-item {% if event.is_modification %}history-item-modification d-none{% endif %}" <div class="history-timeline-item {% if not event.is_default_visible %}history-item-detailed history-item-modification d-none{% endif %}"
data-event-type="{{ event.event_type }}"> data-event-type="{{ event.event_type }}">
<!-- Node Icon --> <!-- Node Icon -->
<div class="history-timeline-node {{ event.get_badge_class }}"> <div class="history-timeline-node {{ event.get_badge_class }}">
@ -79,6 +79,15 @@
{% elif event.event_type == 'modification' %} {% elif event.event_type == 'modification' %}
<span class="badge bg-secondary">{% translate "Modification" %}</span> <span class="badge bg-secondary">{% translate "Modification" %}</span>
<span class="fw-semibold text-secondary">{{ event.event_description|default:_("Attributs modifiés") }}</span> <span class="fw-semibold text-secondary">{{ event.event_description|default:_("Attributs modifiés") }}</span>
{% elif event.event_type == 'note' %}
<span class="badge bg-info text-dark">{% translate "Note" %}</span>
<span class="fw-semibold text-dark">{{ event.event_description }}</span>
{% elif event.event_type == 'document' %}
<span class="badge bg-secondary">{% translate "Document" %}</span>
<span class="fw-semibold text-dark">{{ event.event_description }}</span>
{% elif event.event_type == 'link' %}
<span class="badge bg-dark">{% translate "Lien" %}</span>
<span class="fw-semibold text-dark">{{ event.event_description }}</span>
{% else %} {% else %}
<span class="badge bg-secondary">{{ event.get_event_type_display }}</span> <span class="badge bg-secondary">{{ event.get_event_type_display }}</span>
{% if event.to_status %} {% if event.to_status %}
@ -104,16 +113,16 @@
{% for field_key, change in event.field_changes.items %} {% for field_key, change in event.field_changes.items %}
<div class="history-field-row d-flex flex-wrap align-items-center py-1 small"> <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> <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 %} {% if change.old and change.new %}
<span class="text-muted text-decoration-line-through me-1">{{ change.old }}</span> <span class="text-muted text-decoration-line-through me-1">{{ change.old }}</span>
{% else %} <i class="bi bi-arrow-right text-muted mx-1"></i>
<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> <span class="fw-bold text-dark ms-1">{{ change.new }}</span>
{% elif change.new %}
<span class="fw-bold text-dark">{{ change.new }}</span>
{% elif change.old %}
<span class="text-muted text-decoration-line-through">{{ change.old }}</span>
{% else %} {% else %}
<span class="text-muted fst-italic ms-1">({% translate "vide" %})</span> <span class="text-muted fst-italic">({% translate "vide" %})</span>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@ -138,7 +147,7 @@ function switchHistoryTimeline(mode) {
const defaultBtn = document.getElementById('btnHistoryDefault'); const defaultBtn = document.getElementById('btnHistoryDefault');
const fullBtn = document.getElementById('btnHistoryFull'); const fullBtn = document.getElementById('btnHistoryFull');
const badge = document.getElementById('historyCounterBadge'); const badge = document.getElementById('historyCounterBadge');
const modItems = document.querySelectorAll('.history-item-modification'); const detailedItems = document.querySelectorAll('.history-item-detailed, .history-item-modification');
const section = document.getElementById('section-chronologie'); const section = document.getElementById('section-chronologie');
const defaultCount = section ? section.dataset.defaultCount : ''; const defaultCount = section ? section.dataset.defaultCount : '';
const totalCount = section ? section.dataset.totalCount : ''; const totalCount = section ? section.dataset.totalCount : '';
@ -146,12 +155,12 @@ function switchHistoryTimeline(mode) {
if (mode === 'full') { if (mode === 'full') {
defaultBtn?.classList.remove('active'); defaultBtn?.classList.remove('active');
fullBtn?.classList.add('active'); fullBtn?.classList.add('active');
modItems.forEach(el => el.classList.remove('d-none')); detailedItems.forEach(el => el.classList.remove('d-none'));
if (badge && totalCount) badge.textContent = totalCount; if (badge && totalCount) badge.textContent = totalCount;
} else { } else {
fullBtn?.classList.remove('active'); fullBtn?.classList.remove('active');
defaultBtn?.classList.add('active'); defaultBtn?.classList.add('active');
modItems.forEach(el => el.classList.add('d-none')); detailedItems.forEach(el => el.classList.add('d-none'));
if (badge && defaultCount) badge.textContent = defaultCount; if (badge && defaultCount) badge.textContent = defaultCount;
} }
} }

View file

@ -546,11 +546,24 @@
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
} }
.history-item-modification .history-timeline-card { .history-item-modification .history-timeline-card,
.history-item-detailed .history-timeline-card {
background-color: #fafbfc; background-color: #fafbfc;
border-left: 3px solid #94a3b8; border-left: 3px solid #94a3b8;
} }
.history-timeline-item[data-event-type="note"] .history-timeline-card {
border-left: 3px solid #0dcaf0;
}
.history-timeline-item[data-event-type="document"] .history-timeline-card {
border-left: 3px solid #6c757d;
}
.history-timeline-item[data-event-type="link"] .history-timeline-card {
border-left: 3px solid #212529;
}
.history-changes { .history-changes {
background: #f8fafc; background: #f8fafc;
border-radius: 6px; border-radius: 6px;

View file

@ -4,14 +4,30 @@ from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.core.files.uploadedfile import SimpleUploadedFile
from django.contrib.contenttypes.models import ContentType
from common.models import UserConfig, Role, Thematic from common.models import UserConfig, Role, Thematic
from contracts.models import Company, CompanyTeam, CompanyMember from contracts.models import Company, CompanyTeam, CompanyMember
from interventions.models import Intervention, InterventionTimeLine from interventions.models import (
Intervention,
InterventionTimeLine,
InterventionNote,
InterventionDocument,
InterventionAsset,
InterventionLink,
)
from assets.models import TrafficLightIntersection
from interventions.services.timeline_service import ( from interventions.services.timeline_service import (
record_timeline_event, record_timeline_event,
record_assignment_event, record_assignment_event,
record_note_event,
record_document_event,
record_asset_link_event,
record_relation_link_event,
InterventionChangeTracker, InterventionChangeTracker,
) )
from interventions.views.helpers import _sync_intervention_assets_data, _handle_notes_update
class TimelineHistoryTests(TestCase): class TimelineHistoryTests(TestCase):
@ -233,3 +249,221 @@ class TimelineHistoryTests(TestCase):
with translation.override('fr'): with translation.override('fr'):
response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr') response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr')
self.assertContains(response_fr, 'Historique complet') self.assertContains(response_fr, 'Historique complet')
def test_timeline_model_new_event_types(self):
"""Test properties, badge classes, and icons for note, document, and link event types."""
# Note event
event_note = record_note_event(
intervention=self.intervention,
user=self.user,
action='create',
note_type='work_done',
content='Remplacement du contrôleur effectué.',
)
self.assertTrue(event_note.is_note)
self.assertFalse(event_note.is_default_visible)
self.assertEqual(event_note.get_badge_class(), 'bg-info text-dark')
self.assertEqual(event_note.get_icon(), 'bi-chat-left-text-fill')
self.assertIn('note_type', event_note.field_changes)
self.assertIn('content', event_note.field_changes)
# Document event
event_doc = record_document_event(
intervention=self.intervention,
user=self.user,
action='upload',
filenames=['rapport_technique.pdf', 'photo_avant.jpg'],
)
self.assertTrue(event_doc.is_document)
self.assertFalse(event_doc.is_default_visible)
self.assertEqual(event_doc.get_badge_class(), 'bg-secondary')
self.assertEqual(event_doc.get_icon(), 'bi-paperclip')
self.assertIn('rapport_technique.pdf', event_doc.event_description)
# Link event
event_link = record_asset_link_event(
intervention=self.intervention,
user=self.user,
added_asset_names=['Carrefour Louise (Carrefour)'],
removed_asset_names=['Carrefour Trône (Carrefour)'],
)
self.assertTrue(event_link.is_link)
self.assertFalse(event_link.is_default_visible)
self.assertEqual(event_link.get_badge_class(), 'bg-dark')
self.assertEqual(event_link.get_icon(), 'bi-link-45deg')
self.assertIn('linked_assets', event_link.field_changes)
self.assertIn('unlinked_assets', event_link.field_changes)
def test_note_views_timeline_recording(self):
"""Test that adding, updating, and deleting notes via views records timeline events."""
self.client.login(username='agent_test', password='secret_password')
# 1. Add Note
add_url = reverse('interventions:intervention_add_note', args=[self.intervention.id])
resp = self.client.post(add_url, {
'note_type': 'important_info',
'content': 'Attention au câble sous tension',
})
self.assertEqual(resp.status_code, 200)
note_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(note_event)
self.assertEqual(note_event.event_user, self.user)
self.assertIn('Attention au câble', note_event.field_changes['content']['new'])
created_note = InterventionNote.objects.filter(intervention=self.intervention).last()
# 2. Update Note
update_url = reverse('interventions:intervention_update_note', args=[self.intervention.id, created_note.id])
resp_update = self.client.post(update_url, {
'note_type': 'important_info',
'content': 'Attention au câble sous tension (mis à jour)',
})
self.assertEqual(resp_update.status_code, 200)
update_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(update_event)
self.assertIn('old', update_event.field_changes['content'])
self.assertIn('mis à jour', update_event.field_changes['content']['new'])
# 3. Delete Note
delete_url = reverse('interventions:intervention_delete_note', args=[self.intervention.id, created_note.id])
resp_del = self.client.post(delete_url)
self.assertEqual(resp_del.status_code, 200)
del_event = self.intervention.events.filter(event_type='note').last()
self.assertIsNotNone(del_event)
self.assertIn(del_event.field_changes['note_action']['old'], ['Suppression', 'Deletion'])
def test_batch_notes_update_timeline_recording(self):
"""Test _handle_notes_update records timeline events for notes in batch mode."""
from django.test.client import RequestFactory
factory = RequestFactory()
request = factory.post('/fake-url')
request.user = self.user
# Create note directly first
existing_note = InterventionNote.objects.create(
intervention=self.intervention,
note_type='work_done',
content='Note initiale',
note_author=self.user,
note_time=timezone.now(),
)
notes_data = {
'new_notes': [{'note_type': 'comment', 'content': 'Nouvelle note en batch'}],
'updated_notes': [{'id': existing_note.id, 'note_type': 'comment', 'content': 'Note modifiée en batch'}],
'deleted_notes': [],
}
success, errors = _handle_notes_update(request, self.intervention, notes_data)
self.assertTrue(success)
self.assertEqual(len(errors), 0)
note_events = list(self.intervention.events.filter(event_type='note'))
self.assertGreaterEqual(len(note_events), 2)
descriptions = [e.event_description for e in note_events]
self.assertTrue(any('Note ajoutée' in d or 'Note added' in d for d in descriptions))
self.assertTrue(any('Note modifiée' in d or 'Note updated' in d for d in descriptions))
def test_document_views_timeline_recording(self):
"""Test adding and deleting a document logs document events in timeline."""
self.client.login(username='agent_test', password='secret_password')
# 1. Add document
add_url = reverse('interventions:intervention_add_document', args=[self.intervention.id])
test_file = SimpleUploadedFile("schema_test.pdf", b"file_content_binary", content_type="application/pdf")
resp = self.client.post(add_url, {'documents': [test_file]})
self.assertEqual(resp.status_code, 302)
doc_event = self.intervention.events.filter(event_type='document').last()
self.assertIsNotNone(doc_event)
self.assertEqual(doc_event.event_user, self.user)
self.assertIn('schema_test.pdf', doc_event.event_description)
created_doc = InterventionDocument.objects.filter(intervention=self.intervention).last()
# 2. Delete document
del_url = reverse('interventions:intervention_delete_document', args=[self.intervention.id, created_doc.id])
resp_del = self.client.post(del_url)
self.assertEqual(resp_del.status_code, 200)
del_event = self.intervention.events.filter(event_type='document').last()
self.assertIsNotNone(del_event)
self.assertTrue('Document supprimé' in del_event.event_description or 'Document deleted' in del_event.event_description)
def test_asset_link_timeline_recording(self):
"""Test linking and unlinking an asset records link events."""
asset = TrafficLightIntersection.objects.create(code='TL-001', name_fr='Carrefour Arts-Loi')
ct = ContentType.objects.get_for_model(TrafficLightIntersection)
# Sync asset to intervention
_sync_intervention_assets_data(
intervention=self.intervention,
normalized_assets=[{'model': 'trafficlightintersection', 'object_id': asset.id}],
user=self.user,
)
link_event = self.intervention.events.filter(event_type='link').last()
self.assertIsNotNone(link_event)
self.assertEqual(link_event.event_user, self.user)
self.assertIn('Carrefour Arts-Loi', link_event.event_description)
self.assertIn('linked_assets', link_event.field_changes)
# Unlink asset
_sync_intervention_assets_data(
intervention=self.intervention,
normalized_assets=[],
user=self.user,
)
unlink_event = self.intervention.events.filter(event_type='link').last()
self.assertIsNotNone(unlink_event)
self.assertTrue('délié' in unlink_event.event_description or 'unlinked' in unlink_event.event_description.lower())
self.assertIn('unlinked_assets', unlink_event.field_changes)
def test_intervention_link_timeline_recording(self):
"""Test linking two interventions records link events on both sides."""
target_intervention = Intervention.objects.create(
title='Target Linked Intervention',
status='in_preparation',
thematic=self.thematic,
)
self.client.login(username='agent_test', password='secret_password')
url = reverse('interventions:link_existing_interventions', args=[self.intervention.id])
resp = self.client.post(url, {'codes': target_intervention.code})
self.assertEqual(resp.status_code, 302)
# Event on origin intervention
origin_link_event = self.intervention.events.filter(event_type='link').last()
self.assertIsNotNone(origin_link_event)
self.assertEqual(origin_link_event.event_user, self.user)
self.assertIn(target_intervention.code, origin_link_event.event_description)
# Event on target intervention
target_link_event = target_intervention.events.filter(event_type='link').last()
self.assertIsNotNone(target_link_event)
self.assertEqual(target_link_event.event_user, self.user)
self.assertIn(self.intervention.code, target_link_event.event_description)
def test_detail_view_shows_all_in_full_history(self):
"""Test that notes, documents, and links appear as detailed events in timeline."""
record_note_event(self.intervention, self.user, action='create', note_type='comment', content='Note test')
record_document_event(self.intervention, self.user, action='upload', filenames=['doc_test.png'])
record_relation_link_event(self.intervention, self.user, relation_label='Intervention', target_labels=['I999999'])
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_detailed_count', response.context)
# Note, document, link are all non-default-visible (detailed)
self.assertGreaterEqual(response.context['timeline_detailed_count'], 3)
self.assertContains(response, 'history-item-detailed')
self.assertContains(response, 'data-event-type="note"')
self.assertContains(response, 'data-event-type="document"')
self.assertContains(response, 'data-event-type="link"')

View file

@ -63,7 +63,12 @@ from interventions.models import (InterventionSubscription, Intervention, Interv
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) 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.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.document_sequencing import get_sequenced_intervention_documents
from interventions.services.timeline_service import InterventionChangeTracker from interventions.services.timeline_service import (
InterventionChangeTracker,
record_asset_link_event,
record_relation_link_event,
get_asset_labels_from_pairs,
)
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
LinkExistingInterventionsForm) LinkExistingInterventionsForm)
@ -678,7 +683,8 @@ def intervention_detail(request, intervention_id):
timeline_events.insert(insert_idx, synth_event) timeline_events.insert(insert_idx, synth_event)
timeline_default_count = sum(1 for e in timeline_events if e.is_default_visible) 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) timeline_detailed_count = sum(1 for e in timeline_events if not e.is_default_visible)
timeline_modification_count = timeline_detailed_count
context = { context = {
"intervention": intervention, "intervention": intervention,
@ -763,6 +769,7 @@ def intervention_detail(request, intervention_id):
# Timeline / Chronologie # Timeline / Chronologie
'timeline_events': timeline_events, 'timeline_events': timeline_events,
'timeline_default_count': timeline_default_count, 'timeline_default_count': timeline_default_count,
'timeline_detailed_count': timeline_detailed_count,
'timeline_modification_count': timeline_modification_count, 'timeline_modification_count': timeline_modification_count,
'timeline_total_count': len(timeline_events), 'timeline_total_count': len(timeline_events),
# Historique des envois d'emails (ordre chronologique) # Historique des envois d'emails (ordre chronologique)
@ -885,6 +892,24 @@ def link_existing_interventions(request, intervention_id):
already_codes.append(target.code) already_codes.append(target.code)
if created_codes: if created_codes:
record_relation_link_event(
intervention=intervention,
user=request.user,
relation_label=str(_("Intervention")),
target_labels=created_codes,
action='link',
)
for target_code in created_codes:
target = found_map.get(target_code)
if target:
record_relation_link_event(
intervention=target,
user=request.user,
relation_label=str(_("Intervention")),
target_labels=[intervention.code],
action='link',
)
messages.success( messages.success(
request, request,
ngettext( ngettext(
@ -2204,6 +2229,9 @@ def intervention_link_assets(request, intervention_id):
to_add = target_pairs - existing_pairs to_add = target_pairs - existing_pairs
to_del = existing_pairs - target_pairs to_del = existing_pairs - target_pairs
removed_labels = get_asset_labels_from_pairs(to_del) if to_del else []
added_labels = get_asset_labels_from_pairs(to_add) if to_add else []
created_count = 0 created_count = 0
deleted_count = 0 deleted_count = 0
operations_deleted = 0 operations_deleted = 0
@ -2232,6 +2260,14 @@ def intervention_link_assets(request, intervention_id):
InterventionAsset.objects.bulk_create(objs) InterventionAsset.objects.bulk_create(objs)
created_count = len(objs) created_count = len(objs)
if to_add or to_del:
record_asset_link_event(
intervention=intervention,
user=request.user,
added_asset_names=added_labels,
removed_asset_names=removed_labels,
)
except Exception as e: except Exception as e:
messages.error(request, f"{_("Erreur de synchro")}: {e}") messages.error(request, f"{_("Erreur de synchro")}: {e}")
return redirect("interventions:intervention_detail", intervention_id) return redirect("interventions:intervention_detail", intervention_id)

View file

@ -53,6 +53,11 @@ from dateutil.relativedelta import relativedelta
from common.private_files.helpers import build_private_url from common.private_files.helpers import build_private_url
from common.utils import get_short_name from common.utils import get_short_name
from interventions.services.timeline_service import (
record_note_event,
record_asset_link_event,
get_asset_labels_from_pairs,
)
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
InterventionLink, InterventionSubType, InterventionLink, InterventionSubType,
ContractPauseReasonAssignment, ContractPauseReasonAssignment,
@ -1595,6 +1600,9 @@ def _sync_intervention_assets_data(
to_add = target_pairs - existing_pairs to_add = target_pairs - existing_pairs
to_remove = existing_pairs - target_pairs to_remove = existing_pairs - target_pairs
removed_labels = get_asset_labels_from_pairs(to_remove) if to_remove else []
added_labels = get_asset_labels_from_pairs(to_add) if to_add else []
created_count = 0 created_count = 0
deleted_count = 0 deleted_count = 0
operations_deleted = 0 operations_deleted = 0
@ -1641,6 +1649,15 @@ def _sync_intervention_assets_data(
InterventionAsset.objects.bulk_create(new_relations) InterventionAsset.objects.bulk_create(new_relations)
created_count = len(new_relations) created_count = len(new_relations)
if to_add or to_remove:
event_user = user or (request.user if request and getattr(request, 'user', None) else None)
record_asset_link_event(
intervention=intervention,
user=event_user,
added_asset_names=added_labels,
removed_asset_names=removed_labels,
)
operations_created = 0 operations_created = 0
if to_add: if to_add:
assets_with_ct = _resolve_assets_from_pairs(to_add) assets_with_ct = _resolve_assets_from_pairs(to_add)
@ -2457,13 +2474,20 @@ def _handle_notes_update(request, intervention, notes_data):
continue continue
try: try:
InterventionNote.objects.create( note_inst = InterventionNote.objects.create(
intervention=intervention, intervention=intervention,
content=content, content=content,
note_type=note_type, note_type=note_type,
note_author=request.user, note_author=request.user,
note_time=timezone.now() note_time=timezone.now()
) )
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=note_type,
content=content,
)
success = True success = True
except Exception as e: except Exception as e:
errors.append(_('Error creating note: %(error)s') % {'error': str(e)}) errors.append(_('Error creating note: %(error)s') % {'error': str(e)})
@ -2487,11 +2511,24 @@ def _handle_notes_update(request, intervention, notes_data):
errors.append(_('You do not have permission to edit this note')) errors.append(_('You do not have permission to edit this note'))
continue continue
old_note_type = note.note_type
old_content = note.content
note.content = content note.content = content
note.note_type = note_type note.note_type = note_type
note.updated_by = request.user note.updated_by = request.user
note.updated_at = timezone.now() note.updated_at = timezone.now()
note.save() note.save()
record_note_event(
intervention=intervention,
user=request.user,
action='update',
note_type=note_type,
content=content,
old_note_type=old_note_type,
old_content=old_content,
)
success = True success = True
except InterventionNote.DoesNotExist: except InterventionNote.DoesNotExist:
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id}) errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})
@ -2510,7 +2547,17 @@ def _handle_notes_update(request, intervention, notes_data):
errors.append(_('You do not have permission to delete this note')) errors.append(_('You do not have permission to delete this note'))
continue continue
del_note_type = note.note_type
del_content = note.content
note.delete() note.delete()
record_note_event(
intervention=intervention,
user=request.user,
action='delete',
note_type=del_note_type,
content=del_content,
)
success = True success = True
except InterventionNote.DoesNotExist: except InterventionNote.DoesNotExist:
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id}) errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})

View file

@ -53,6 +53,7 @@ from dateutil.relativedelta import relativedelta
from common.private_files.helpers import build_private_url from common.private_files.helpers import build_private_url
from common.utils import get_short_name from common.utils import get_short_name
from interventions.services.timeline_service import record_document_event
from interventions import views from interventions import views
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
InterventionLink, InterventionLink,
@ -1214,6 +1215,13 @@ def mobile_upload_photo_api(request, intervention_id):
file=upload, file=upload,
uploaded_by=request.user, uploaded_by=request.user,
) )
filename = getattr(upload, 'name', '') or getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
record_document_event(
intervention=intervention,
user=request.user,
action='upload',
filenames=[filename],
)
except Exception as e: except Exception as e:
logger.exception("Erreur lors de l\'upload de la photo mobile") logger.exception("Erreur lors de l\'upload de la photo mobile")
return JsonResponse({'success': False, 'error': _('Erreur lors de l\'enregistrement de la photo.')}, status=500) return JsonResponse({'success': False, 'error': _('Erreur lors de l\'enregistrement de la photo.')}, status=500)
@ -1259,8 +1267,15 @@ def mobile_delete_photo_api(request, intervention_id, document_id):
status=403 status=403
) )
filename = getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
try: try:
doc.delete() doc.delete()
record_document_event(
intervention=intervention,
user=request.user,
action='delete',
filenames=[filename],
)
except Exception: except Exception:
logger.exception("Erreur lors de la suppression du document mobile") logger.exception("Erreur lors de la suppression du document mobile")
return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500) return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500)

View file

@ -53,6 +53,10 @@ from dateutil.relativedelta import relativedelta
from common.private_files.helpers import build_private_url from common.private_files.helpers import build_private_url
from common.utils import get_short_name from common.utils import get_short_name
from interventions.services.timeline_service import (
record_note_event,
record_document_event,
)
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation, from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
InterventionLink, InterventionLink,
ContractPauseReasonAssignment, ContractPauseReasonAssignment,
@ -171,6 +175,14 @@ def intervention_add_note(request, intervention_id):
note_instance.note_time = now() note_instance.note_time = now()
note_instance.save() note_instance.save()
record_note_event(
intervention=intervention,
user=request.user,
action='create',
note_type=note_instance.note_type,
content=note_instance.content,
)
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'note': _serialize_note(note_instance, request.user) 'note': _serialize_note(note_instance, request.user)
@ -186,6 +198,9 @@ def intervention_update_note(request, intervention_id, note_id):
if not can_edit_note(request.user, request.user.config, note): if not can_edit_note(request.user, request.user.config, note):
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas modifier cette note.')}, status=403) return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas modifier cette note.')}, status=403)
old_note_type = note.note_type
old_content = note.content
form = InterventionNoteForm(request.POST, instance=note) form = InterventionNoteForm(request.POST, instance=note)
if form.is_valid(): if form.is_valid():
note_instance = form.save(commit=False) note_instance = form.save(commit=False)
@ -193,6 +208,16 @@ def intervention_update_note(request, intervention_id, note_id):
note_instance.save(update_fields=['content', 'note_type', 'updated_at', 'updated_by']) note_instance.save(update_fields=['content', 'note_type', 'updated_at', 'updated_by'])
note.refresh_from_db(fields=['content', 'note_type', 'updated_at', 'updated_by']) note.refresh_from_db(fields=['content', 'note_type', 'updated_at', 'updated_by'])
record_note_event(
intervention=intervention,
user=request.user,
action='update',
note_type=note.note_type,
content=note.content,
old_note_type=old_note_type,
old_content=old_content,
)
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'note': _serialize_note(note, request.user) 'note': _serialize_note(note, request.user)
@ -208,7 +233,18 @@ def intervention_delete_note(request, intervention_id, note_id):
if not can_edit_note(request.user, request.user.config, note): if not can_edit_note(request.user, request.user.config, note):
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer cette note.')}, status=403) return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer cette note.')}, status=403)
note_type = note.note_type
content = note.content
note.delete() note.delete()
record_note_event(
intervention=intervention,
user=request.user,
action='delete',
note_type=note_type,
content=content,
)
return JsonResponse({'success': True}) return JsonResponse({'success': True})
@ -220,8 +256,17 @@ def intervention_add_document(request, intervention_id):
form = InterventionDocumentForm(request.POST, request.FILES) form = InterventionDocumentForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
files = form.cleaned_data["documents"] files = form.cleaned_data["documents"]
filenames = []
for file in files: for file in files:
InterventionDocument.objects.create(intervention=intervention, file=file, uploaded_by=request.user) doc = InterventionDocument.objects.create(intervention=intervention, file=file, uploaded_by=request.user)
filenames.append(getattr(file, 'name', '') or str(doc.id))
record_document_event(
intervention=intervention,
user=request.user,
action='upload',
filenames=filenames,
)
messages.success(request, _("Document(s) sauvegardé(s) avec succès !")) messages.success(request, _("Document(s) sauvegardé(s) avec succès !"))
return redirect_with_variant('interventions:intervention_detail', variant=variant, url_kwargs={'intervention_id': intervention_id}) return redirect_with_variant('interventions:intervention_detail', variant=variant, url_kwargs={'intervention_id': intervention_id})
@ -249,12 +294,22 @@ def intervention_delete_document(request, intervention_id, document_id):
if not can_delete_document(request.user, user_config, document): if not can_delete_document(request.user, user_config, document):
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer ce document.')}, status=403) return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer ce document.')}, status=403)
filename = getattr(document.file, 'name', '') or str(document.id)
if document.file: if document.file:
document.file.delete(save=False) document.file.delete(save=False)
if document.thumbnail: if document.thumbnail:
document.thumbnail.delete(save=False) document.thumbnail.delete(save=False)
document.delete() document.delete()
record_document_event(
intervention=intervention,
user=request.user,
action='delete',
filenames=[filename],
)
return JsonResponse({'success': True}) return JsonResponse({'success': True})