diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.mo b/loko/interventions/locale/en/LC_MESSAGES/django.mo index 0e5c610..1dcf27f 100644 Binary files a/loko/interventions/locale/en/LC_MESSAGES/django.mo and b/loko/interventions/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.po b/loko/interventions/locale/en/LC_MESSAGES/django.po index 9e5956f..46bdf93 100644 --- a/loko/interventions/locale/en/LC_MESSAGES/django.po +++ b/loko/interventions/locale/en/LC_MESSAGES/django.po @@ -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)" + + + diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.mo b/loko/interventions/locale/fr/LC_MESSAGES/django.mo index dc55020..7d226c0 100644 Binary files a/loko/interventions/locale/fr/LC_MESSAGES/django.mo and b/loko/interventions/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.po b/loko/interventions/locale/fr/LC_MESSAGES/django.po index 8a2aa86..e5c7136 100644 --- a/loko/interventions/locale/fr/LC_MESSAGES/django.po +++ b/loko/interventions/locale/fr/LC_MESSAGES/django.po @@ -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)" + + + diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.mo b/loko/interventions/locale/nl/LC_MESSAGES/django.mo index 475ac13..3f33f2e 100644 Binary files a/loko/interventions/locale/nl/LC_MESSAGES/django.mo and b/loko/interventions/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.po b/loko/interventions/locale/nl/LC_MESSAGES/django.po index 2263962..e52720a 100644 --- a/loko/interventions/locale/nl/LC_MESSAGES/django.po +++ b/loko/interventions/locale/nl/LC_MESSAGES/django.po @@ -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)" + + diff --git a/loko/interventions/migrations/0015_alter_interventiontimeline_event_type.py b/loko/interventions/migrations/0015_alter_interventiontimeline_event_type.py new file mode 100644 index 0000000..4018d8e --- /dev/null +++ b/loko/interventions/migrations/0015_alter_interventiontimeline_event_type.py @@ -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')]), + ), + ] diff --git a/loko/interventions/models.py b/loko/interventions/models.py index 6bb4156..47c4c3b 100644 --- a/loko/interventions/models.py +++ b/loko/interventions/models.py @@ -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') diff --git a/loko/interventions/services/timeline_service.py b/loko/interventions/services/timeline_service.py index 9dafed7..7008b7e 100644 --- a/loko/interventions/services/timeline_service.py +++ b/loko/interventions/services/timeline_service.py @@ -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, + ) diff --git a/loko/interventions/static/interventions/timeline.css b/loko/interventions/static/interventions/timeline.css index e27fa1d..742d99a 100644 --- a/loko/interventions/static/interventions/timeline.css +++ b/loko/interventions/static/interventions/timeline.css @@ -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; diff --git a/loko/interventions/templates/interventions/detail/card_chronologie.html b/loko/interventions/templates/interventions/detail/card_chronologie.html index 8e97430..ad3ac8e 100644 --- a/loko/interventions/templates/interventions/detail/card_chronologie.html +++ b/loko/interventions/templates/interventions/detail/card_chronologie.html @@ -29,8 +29,8 @@ onclick="switchHistoryTimeline('full')" title="{% translate 'Afficher toutes les modifications détaillées' %}"> {% translate "Historique complet" %} - {% if timeline_modification_count %} - +{{ timeline_modification_count }} + {% if timeline_detailed_count or timeline_modification_count %} + +{{ timeline_detailed_count|default:timeline_modification_count }} {% endif %} @@ -42,7 +42,7 @@ {% if events_list %}
{% for event in events_list %} -
@@ -79,6 +79,15 @@ {% elif event.event_type == 'modification' %} {% translate "Modification" %} {{ event.event_description|default:_("Attributs modifiés") }} + {% elif event.event_type == 'note' %} + {% translate "Note" %} + {{ event.event_description }} + {% elif event.event_type == 'document' %} + {% translate "Document" %} + {{ event.event_description }} + {% elif event.event_type == 'link' %} + {% translate "Lien" %} + {{ event.event_description }} {% else %} {{ event.get_event_type_display }} {% if event.to_status %} @@ -104,16 +113,16 @@ {% for field_key, change in event.field_changes.items %}
{{ change.label|default:field_key }} - {% if change.old %} + {% if change.old and change.new %} {{ change.old }} - {% else %} - ({% translate "vide" %}) - {% endif %} - - {% if change.new %} + {{ change.new }} + {% elif change.new %} + {{ change.new }} + {% elif change.old %} + {{ change.old }} {% else %} - ({% translate "vide" %}) + ({% translate "vide" %}) {% endif %}
{% 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; } } diff --git a/loko/interventions/templates/interventions/detail/styles.html b/loko/interventions/templates/interventions/detail/styles.html index 10de605..fe1cdcc 100644 --- a/loko/interventions/templates/interventions/detail/styles.html +++ b/loko/interventions/templates/interventions/detail/styles.html @@ -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; diff --git a/loko/interventions/tests/test_timeline_history.py b/loko/interventions/tests/test_timeline_history.py index 3b6e9eb..54c3f3a 100644 --- a/loko/interventions/tests/test_timeline_history.py +++ b/loko/interventions/tests/test_timeline_history.py @@ -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"') + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index e6f81ba..0c8eb03 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -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) diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index b62dd00..0c38e89 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -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}) diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 7922ff4..f670019 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -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) diff --git a/loko/interventions/views/notes_docs.py b/loko/interventions/views/notes_docs.py index 17c42f3..358d9e5 100644 --- a/loko/interventions/views/notes_docs.py +++ b/loko/interventions/views/notes_docs.py @@ -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})