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:
parent
79af86fb7f
commit
f510a086bd
17 changed files with 1171 additions and 20 deletions
Binary file not shown.
|
|
@ -8240,3 +8240,137 @@ msgstr "Assignment update"
|
|||
msgid "Champs modifiés : %(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)"
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -8347,3 +8347,137 @@ msgstr "Mise à jour de l'assignation"
|
|||
msgid "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)"
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -8281,3 +8281,136 @@ msgstr "Toewijzing bijgewerkt"
|
|||
msgid "Champs modifiés : %(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)"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -1254,6 +1254,9 @@ ACTION_TYPE_CHOICES = {
|
|||
'transfer': _('Transfert'),
|
||||
'assignment': _('Assignation'),
|
||||
'modification': _('Modification'),
|
||||
'note': _('Note'),
|
||||
'document': _('Document'),
|
||||
'link': _('Lien'),
|
||||
}
|
||||
|
||||
STATUS_COLOR_MAP = {
|
||||
|
|
@ -1295,6 +1298,18 @@ class InterventionTimeLine(models.Model):
|
|||
def is_modification(self):
|
||||
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
|
||||
def is_status_change(self):
|
||||
return self.event_type == 'status_change'
|
||||
|
|
@ -1320,6 +1335,9 @@ class InterventionTimeLine(models.Model):
|
|||
'assignment': 'bg-info text-dark',
|
||||
'transfer': 'bg-warning text-dark',
|
||||
'modification': 'bg-secondary',
|
||||
'note': 'bg-info text-dark',
|
||||
'document': 'bg-secondary',
|
||||
'link': 'bg-dark',
|
||||
}.get(self.event_type, 'bg-secondary')
|
||||
|
||||
def get_icon(self):
|
||||
|
|
@ -1329,6 +1347,9 @@ class InterventionTimeLine(models.Model):
|
|||
'assignment': 'bi-person-check-fill',
|
||||
'transfer': 'bi-arrow-left-right',
|
||||
'modification': 'bi-pencil-fill',
|
||||
'note': 'bi-chat-left-text-fill',
|
||||
'document': 'bi-paperclip',
|
||||
'link': 'bi-link-45deg',
|
||||
}.get(self.event_type, 'bi-dot')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
|
|||
from interventions.models import (
|
||||
Intervention,
|
||||
InterventionTimeLine,
|
||||
InterventionNote,
|
||||
MAINTAIN_CHOICES,
|
||||
ORIGIN_TYPE_CHOICES,
|
||||
STATUS_CHOICES,
|
||||
|
|
@ -391,3 +392,291 @@ class InterventionChangeTracker:
|
|||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -200,11 +200,24 @@
|
|||
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;
|
||||
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 {
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@
|
|||
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>
|
||||
{% if timeline_detailed_count or timeline_modification_count %}
|
||||
<span class="badge bg-secondary ms-1">+{{ timeline_detailed_count|default:timeline_modification_count }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
{% 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 %}"
|
||||
<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 }}">
|
||||
<!-- Node Icon -->
|
||||
<div class="history-timeline-node {{ event.get_badge_class }}">
|
||||
|
|
@ -79,6 +79,15 @@
|
|||
{% 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>
|
||||
{% 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 %}
|
||||
<span class="badge bg-secondary">{{ event.get_event_type_display }}</span>
|
||||
{% if event.to_status %}
|
||||
|
|
@ -104,16 +113,16 @@
|
|||
{% 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 %}
|
||||
{% if change.old and change.new %}
|
||||
<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 %}
|
||||
<i class="bi bi-arrow-right text-muted mx-1"></i>
|
||||
<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 %}
|
||||
<span class="text-muted fst-italic ms-1">({% translate "vide" %})</span>
|
||||
<span class="text-muted fst-italic">({% translate "vide" %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
@ -138,7 +147,7 @@ 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 detailedItems = document.querySelectorAll('.history-item-detailed, .history-item-modification');
|
||||
const section = document.getElementById('section-chronologie');
|
||||
const defaultCount = section ? section.dataset.defaultCount : '';
|
||||
const totalCount = section ? section.dataset.totalCount : '';
|
||||
|
|
@ -146,12 +155,12 @@ function switchHistoryTimeline(mode) {
|
|||
if (mode === 'full') {
|
||||
defaultBtn?.classList.remove('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;
|
||||
} else {
|
||||
fullBtn?.classList.remove('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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -546,11 +546,24 @@
|
|||
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;
|
||||
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 {
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,30 @@ from django.contrib.auth import get_user_model
|
|||
from django.urls import reverse
|
||||
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 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 (
|
||||
record_timeline_event,
|
||||
record_assignment_event,
|
||||
record_note_event,
|
||||
record_document_event,
|
||||
record_asset_link_event,
|
||||
record_relation_link_event,
|
||||
InterventionChangeTracker,
|
||||
)
|
||||
from interventions.views.helpers import _sync_intervention_assets_data, _handle_notes_update
|
||||
|
||||
|
||||
class TimelineHistoryTests(TestCase):
|
||||
|
|
@ -233,3 +249,221 @@ class TimelineHistoryTests(TestCase):
|
|||
with translation.override('fr'):
|
||||
response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr')
|
||||
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"')
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,12 @@ 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.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,
|
||||
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
|
||||
LinkExistingInterventionsForm)
|
||||
|
|
@ -678,7 +683,8 @@ def intervention_detail(request, intervention_id):
|
|||
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)
|
||||
timeline_detailed_count = sum(1 for e in timeline_events if not e.is_default_visible)
|
||||
timeline_modification_count = timeline_detailed_count
|
||||
|
||||
context = {
|
||||
"intervention": intervention,
|
||||
|
|
@ -763,6 +769,7 @@ def intervention_detail(request, intervention_id):
|
|||
# Timeline / Chronologie
|
||||
'timeline_events': timeline_events,
|
||||
'timeline_default_count': timeline_default_count,
|
||||
'timeline_detailed_count': timeline_detailed_count,
|
||||
'timeline_modification_count': timeline_modification_count,
|
||||
'timeline_total_count': len(timeline_events),
|
||||
# Historique des envois d'emails (ordre chronologique)
|
||||
|
|
@ -885,6 +892,24 @@ def link_existing_interventions(request, intervention_id):
|
|||
already_codes.append(target.code)
|
||||
|
||||
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(
|
||||
request,
|
||||
ngettext(
|
||||
|
|
@ -2204,6 +2229,9 @@ def intervention_link_assets(request, intervention_id):
|
|||
to_add = target_pairs - existing_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
|
||||
deleted_count = 0
|
||||
operations_deleted = 0
|
||||
|
|
@ -2232,6 +2260,14 @@ def intervention_link_assets(request, intervention_id):
|
|||
InterventionAsset.objects.bulk_create(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:
|
||||
messages.error(request, f"{_("Erreur de synchro")}: {e}")
|
||||
return redirect("interventions:intervention_detail", intervention_id)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ from dateutil.relativedelta import relativedelta
|
|||
from common.private_files.helpers import build_private_url
|
||||
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,
|
||||
InterventionLink, InterventionSubType,
|
||||
ContractPauseReasonAssignment,
|
||||
|
|
@ -1595,6 +1600,9 @@ def _sync_intervention_assets_data(
|
|||
to_add = target_pairs - existing_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
|
||||
deleted_count = 0
|
||||
operations_deleted = 0
|
||||
|
|
@ -1641,6 +1649,15 @@ def _sync_intervention_assets_data(
|
|||
InterventionAsset.objects.bulk_create(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
|
||||
if to_add:
|
||||
assets_with_ct = _resolve_assets_from_pairs(to_add)
|
||||
|
|
@ -2457,13 +2474,20 @@ def _handle_notes_update(request, intervention, notes_data):
|
|||
continue
|
||||
|
||||
try:
|
||||
InterventionNote.objects.create(
|
||||
note_inst = InterventionNote.objects.create(
|
||||
intervention=intervention,
|
||||
content=content,
|
||||
note_type=note_type,
|
||||
note_author=request.user,
|
||||
note_time=timezone.now()
|
||||
)
|
||||
record_note_event(
|
||||
intervention=intervention,
|
||||
user=request.user,
|
||||
action='create',
|
||||
note_type=note_type,
|
||||
content=content,
|
||||
)
|
||||
success = True
|
||||
except Exception as 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'))
|
||||
continue
|
||||
|
||||
old_note_type = note.note_type
|
||||
old_content = note.content
|
||||
|
||||
note.content = content
|
||||
note.note_type = note_type
|
||||
note.updated_by = request.user
|
||||
note.updated_at = timezone.now()
|
||||
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
|
||||
except InterventionNote.DoesNotExist:
|
||||
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'))
|
||||
continue
|
||||
|
||||
del_note_type = note.note_type
|
||||
del_content = note.content
|
||||
note.delete()
|
||||
|
||||
record_note_event(
|
||||
intervention=intervention,
|
||||
user=request.user,
|
||||
action='delete',
|
||||
note_type=del_note_type,
|
||||
content=del_content,
|
||||
)
|
||||
success = True
|
||||
except InterventionNote.DoesNotExist:
|
||||
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ from dateutil.relativedelta import relativedelta
|
|||
from common.private_files.helpers import build_private_url
|
||||
from common.utils import get_short_name
|
||||
|
||||
from interventions.services.timeline_service import record_document_event
|
||||
from interventions import views
|
||||
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
||||
InterventionLink,
|
||||
|
|
@ -1214,6 +1215,13 @@ def mobile_upload_photo_api(request, intervention_id):
|
|||
file=upload,
|
||||
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:
|
||||
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)
|
||||
|
|
@ -1259,8 +1267,15 @@ def mobile_delete_photo_api(request, intervention_id, document_id):
|
|||
status=403
|
||||
)
|
||||
|
||||
filename = getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
|
||||
try:
|
||||
doc.delete()
|
||||
record_document_event(
|
||||
intervention=intervention,
|
||||
user=request.user,
|
||||
action='delete',
|
||||
filenames=[filename],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Erreur lors de la suppression du document mobile")
|
||||
return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ from dateutil.relativedelta import relativedelta
|
|||
from common.private_files.helpers import build_private_url
|
||||
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,
|
||||
InterventionLink,
|
||||
ContractPauseReasonAssignment,
|
||||
|
|
@ -171,6 +175,14 @@ def intervention_add_note(request, intervention_id):
|
|||
note_instance.note_time = now()
|
||||
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({
|
||||
'success': True,
|
||||
'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):
|
||||
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)
|
||||
if form.is_valid():
|
||||
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.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({
|
||||
'success': True,
|
||||
'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):
|
||||
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer cette note.')}, status=403)
|
||||
|
||||
note_type = note.note_type
|
||||
content = note.content
|
||||
note.delete()
|
||||
|
||||
record_note_event(
|
||||
intervention=intervention,
|
||||
user=request.user,
|
||||
action='delete',
|
||||
note_type=note_type,
|
||||
content=content,
|
||||
)
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
|
||||
|
|
@ -220,8 +256,17 @@ def intervention_add_document(request, intervention_id):
|
|||
form = InterventionDocumentForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
files = form.cleaned_data["documents"]
|
||||
filenames = []
|
||||
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 !"))
|
||||
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):
|
||||
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:
|
||||
document.file.delete(save=False)
|
||||
if document.thumbnail:
|
||||
document.thumbnail.delete(save=False)
|
||||
|
||||
document.delete()
|
||||
|
||||
record_document_event(
|
||||
intervention=intervention,
|
||||
user=request.user,
|
||||
action='delete',
|
||||
filenames=[filename],
|
||||
)
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue