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"
|
msgid "Champs modifiés : %(fields)s"
|
||||||
msgstr "Modified fields: %(fields)s"
|
msgstr "Modified fields: %(fields)s"
|
||||||
|
|
||||||
|
#: interventions/models.py:1259
|
||||||
|
msgid "Lien"
|
||||||
|
msgstr "Link"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:417
|
||||||
|
#, python-format
|
||||||
|
msgid "Note ajoutée (%(type)s)"
|
||||||
|
msgstr "Note added (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:418
|
||||||
|
msgid "Ajout"
|
||||||
|
msgstr "Addition"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:423
|
||||||
|
#, python-format
|
||||||
|
msgid "Note modifiée (%(type)s)"
|
||||||
|
msgstr "Note updated (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:433
|
||||||
|
msgid "Mise à jour"
|
||||||
|
msgstr "Update"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:435
|
||||||
|
#, python-format
|
||||||
|
msgid "Note supprimée (%(type)s)"
|
||||||
|
msgstr "Note deleted (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:436
|
||||||
|
msgid "Suppression"
|
||||||
|
msgstr "Deletion"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:441
|
||||||
|
#, python-format
|
||||||
|
msgid "Note : %(action)s"
|
||||||
|
msgstr "Note: %(action)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:465
|
||||||
|
#, python-format
|
||||||
|
msgid "Document ajouté : %(name)s"
|
||||||
|
msgstr "Document added: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:467
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d documents ajoutés : %(names)s"
|
||||||
|
msgstr "%(count)d documents added: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:472
|
||||||
|
msgid "Document(s) ajouté(s)"
|
||||||
|
msgstr "Added document(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:478
|
||||||
|
#, python-format
|
||||||
|
msgid "Document supprimé : %(name)s"
|
||||||
|
msgstr "Document deleted: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:480
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d documents supprimés : %(names)s"
|
||||||
|
msgstr "%(count)d documents deleted: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:485
|
||||||
|
msgid "Document(s) supprimé(s)"
|
||||||
|
msgstr "Deleted document(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:490
|
||||||
|
#, python-format
|
||||||
|
msgid "Document : %(action)s"
|
||||||
|
msgstr "Document: %(action)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:549
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipement lié : %(name)s"
|
||||||
|
msgstr "Equipment linked: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:551
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d équipements liés : %(names)s"
|
||||||
|
msgstr "%(count)d equipment linked: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:557
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipement délié : %(name)s"
|
||||||
|
msgstr "Equipment unlinked: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:559
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d équipements déliés : %(names)s"
|
||||||
|
msgstr "%(count)d equipment unlinked: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:564
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipements modifiés (+%(added)d, -%(removed)d)"
|
||||||
|
msgstr "Equipment updated (+%(added)d, -%(removed)d)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:570
|
||||||
|
msgid "Équipement(s) lié(s)"
|
||||||
|
msgstr "Linked equipment"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:576
|
||||||
|
msgid "Équipement(s) délié(s)"
|
||||||
|
msgstr "Unlinked equipment"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:603
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s liée : %(target)s"
|
||||||
|
msgstr "Linked %(relation)s: %(target)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:608
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d %(relation)ss liées : %(targets)s"
|
||||||
|
msgstr "%(count)d linked %(relation)ss: %(targets)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:614
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s liée(s)"
|
||||||
|
msgstr "Linked %(relation)s(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:620
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s déliée : %(target)s"
|
||||||
|
msgstr "Unlinked %(relation)s: %(target)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:625
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d %(relation)ss déliées : %(targets)s"
|
||||||
|
msgstr "%(count)d unlinked %(relation)ss: %(targets)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:631
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s déliée(s)"
|
||||||
|
msgstr "Unlinked %(relation)s(s)"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -8347,3 +8347,137 @@ msgstr "Mise à jour de l'assignation"
|
||||||
msgid "Champs modifiés : %(fields)s"
|
msgid "Champs modifiés : %(fields)s"
|
||||||
msgstr "Champs modifiés : %(fields)s"
|
msgstr "Champs modifiés : %(fields)s"
|
||||||
|
|
||||||
|
#: interventions/models.py:1259
|
||||||
|
msgid "Lien"
|
||||||
|
msgstr "Lien"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:417
|
||||||
|
#, python-format
|
||||||
|
msgid "Note ajoutée (%(type)s)"
|
||||||
|
msgstr "Note ajoutée (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:418
|
||||||
|
msgid "Ajout"
|
||||||
|
msgstr "Ajout"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:423
|
||||||
|
#, python-format
|
||||||
|
msgid "Note modifiée (%(type)s)"
|
||||||
|
msgstr "Note modifiée (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:433
|
||||||
|
msgid "Mise à jour"
|
||||||
|
msgstr "Mise à jour"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:435
|
||||||
|
#, python-format
|
||||||
|
msgid "Note supprimée (%(type)s)"
|
||||||
|
msgstr "Note supprimée (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:436
|
||||||
|
msgid "Suppression"
|
||||||
|
msgstr "Suppression"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:441
|
||||||
|
#, python-format
|
||||||
|
msgid "Note : %(action)s"
|
||||||
|
msgstr "Note : %(action)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:465
|
||||||
|
#, python-format
|
||||||
|
msgid "Document ajouté : %(name)s"
|
||||||
|
msgstr "Document ajouté : %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:467
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d documents ajoutés : %(names)s"
|
||||||
|
msgstr "%(count)d documents ajoutés : %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:472
|
||||||
|
msgid "Document(s) ajouté(s)"
|
||||||
|
msgstr "Document(s) ajouté(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:478
|
||||||
|
#, python-format
|
||||||
|
msgid "Document supprimé : %(name)s"
|
||||||
|
msgstr "Document supprimé : %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:480
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d documents supprimés : %(names)s"
|
||||||
|
msgstr "%(count)d documents supprimés : %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:485
|
||||||
|
msgid "Document(s) supprimé(s)"
|
||||||
|
msgstr "Document(s) supprimé(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:490
|
||||||
|
#, python-format
|
||||||
|
msgid "Document : %(action)s"
|
||||||
|
msgstr "Document : %(action)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:549
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipement lié : %(name)s"
|
||||||
|
msgstr "Équipement lié : %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:551
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d équipements liés : %(names)s"
|
||||||
|
msgstr "%(count)d équipements liés : %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:557
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipement délié : %(name)s"
|
||||||
|
msgstr "Équipement délié : %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:559
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d équipements déliés : %(names)s"
|
||||||
|
msgstr "%(count)d équipements déliés : %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:564
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipements modifiés (+%(added)d, -%(removed)d)"
|
||||||
|
msgstr "Équipements modifiés (+%(added)d, -%(removed)d)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:570
|
||||||
|
msgid "Équipement(s) lié(s)"
|
||||||
|
msgstr "Équipement(s) lié(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:576
|
||||||
|
msgid "Équipement(s) délié(s)"
|
||||||
|
msgstr "Équipement(s) délié(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:603
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s liée : %(target)s"
|
||||||
|
msgstr "%(relation)s liée : %(target)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:608
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d %(relation)ss liées : %(targets)s"
|
||||||
|
msgstr "%(count)d %(relation)ss liées : %(targets)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:614
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s liée(s)"
|
||||||
|
msgstr "%(relation)s liée(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:620
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s déliée : %(target)s"
|
||||||
|
msgstr "%(relation)s déliée : %(target)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:625
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d %(relation)ss déliées : %(targets)s"
|
||||||
|
msgstr "%(count)d %(relation)ss déliées : %(targets)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:631
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s déliée(s)"
|
||||||
|
msgstr "%(relation)s déliée(s)"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -8281,3 +8281,136 @@ msgstr "Toewijzing bijgewerkt"
|
||||||
msgid "Champs modifiés : %(fields)s"
|
msgid "Champs modifiés : %(fields)s"
|
||||||
msgstr "Gewijzigde velden: %(fields)s"
|
msgstr "Gewijzigde velden: %(fields)s"
|
||||||
|
|
||||||
|
#: interventions/models.py:1259
|
||||||
|
msgid "Lien"
|
||||||
|
msgstr "Koppeling"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:417
|
||||||
|
#, python-format
|
||||||
|
msgid "Note ajoutée (%(type)s)"
|
||||||
|
msgstr "Notitie toegevoegd (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:418
|
||||||
|
msgid "Ajout"
|
||||||
|
msgstr "Toevoeging"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:423
|
||||||
|
#, python-format
|
||||||
|
msgid "Note modifiée (%(type)s)"
|
||||||
|
msgstr "Notitie gewijzigd (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:433
|
||||||
|
msgid "Mise à jour"
|
||||||
|
msgstr "Bijwerking"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:435
|
||||||
|
#, python-format
|
||||||
|
msgid "Note supprimée (%(type)s)"
|
||||||
|
msgstr "Notitie verwijderd (%(type)s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:436
|
||||||
|
msgid "Suppression"
|
||||||
|
msgstr "Verwijdering"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:441
|
||||||
|
#, python-format
|
||||||
|
msgid "Note : %(action)s"
|
||||||
|
msgstr "Notitie: %(action)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:465
|
||||||
|
#, python-format
|
||||||
|
msgid "Document ajouté : %(name)s"
|
||||||
|
msgstr "Document toegevoegd: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:467
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d documents ajoutés : %(names)s"
|
||||||
|
msgstr "%(count)d documenten toegevoegd: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:472
|
||||||
|
msgid "Document(s) ajouté(s)"
|
||||||
|
msgstr "Toegevoegd(e) document(en)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:478
|
||||||
|
#, python-format
|
||||||
|
msgid "Document supprimé : %(name)s"
|
||||||
|
msgstr "Document verwijderd: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:480
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d documents supprimés : %(names)s"
|
||||||
|
msgstr "%(count)d documenten verwijderd: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:485
|
||||||
|
msgid "Document(s) supprimé(s)"
|
||||||
|
msgstr "Verwijderd(e) document(en)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:490
|
||||||
|
#, python-format
|
||||||
|
msgid "Document : %(action)s"
|
||||||
|
msgstr "Document: %(action)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:549
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipement lié : %(name)s"
|
||||||
|
msgstr "Apparaat gekoppeld: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:551
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d équipements liés : %(names)s"
|
||||||
|
msgstr "%(count)d apparaten gekoppeld: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:557
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipement délié : %(name)s"
|
||||||
|
msgstr "Apparaat ontkoppeld: %(name)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:559
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d équipements déliés : %(names)s"
|
||||||
|
msgstr "%(count)d apparaten ontkoppeld: %(names)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:564
|
||||||
|
#, python-format
|
||||||
|
msgid "Équipements modifiés (+%(added)d, -%(removed)d)"
|
||||||
|
msgstr "Apparaten gewijzigd (+%(added)d, -%(removed)d)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:570
|
||||||
|
msgid "Équipement(s) lié(s)"
|
||||||
|
msgstr "Gekoppeld(e) appara(a)t(en)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:576
|
||||||
|
msgid "Équipement(s) délié(s)"
|
||||||
|
msgstr "Ontkoppeld(e) appara(a)t(en)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:603
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s liée : %(target)s"
|
||||||
|
msgstr "%(relation)s gekoppeld: %(target)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:608
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d %(relation)ss liées : %(targets)s"
|
||||||
|
msgstr "%(count)d %(relation)ss gekoppeld: %(targets)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:614
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s liée(s)"
|
||||||
|
msgstr "Gekoppelde %(relation)s(s)"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:620
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s déliée : %(target)s"
|
||||||
|
msgstr "%(relation)s ontkoppeld: %(target)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:625
|
||||||
|
#, python-format
|
||||||
|
msgid "%(count)d %(relation)ss déliées : %(targets)s"
|
||||||
|
msgstr "%(count)d %(relation)ss ontkoppeld: %(targets)s"
|
||||||
|
|
||||||
|
#: interventions/services/timeline_service.py:631
|
||||||
|
#, python-format
|
||||||
|
msgid "%(relation)s déliée(s)"
|
||||||
|
msgstr "Ontkoppelde %(relation)s(s)"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
'transfer': _('Transfert'),
|
||||||
'assignment': _('Assignation'),
|
'assignment': _('Assignation'),
|
||||||
'modification': _('Modification'),
|
'modification': _('Modification'),
|
||||||
|
'note': _('Note'),
|
||||||
|
'document': _('Document'),
|
||||||
|
'link': _('Lien'),
|
||||||
}
|
}
|
||||||
|
|
||||||
STATUS_COLOR_MAP = {
|
STATUS_COLOR_MAP = {
|
||||||
|
|
@ -1295,6 +1298,18 @@ class InterventionTimeLine(models.Model):
|
||||||
def is_modification(self):
|
def is_modification(self):
|
||||||
return self.event_type == 'modification'
|
return self.event_type == 'modification'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_note(self):
|
||||||
|
return self.event_type == 'note'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_document(self):
|
||||||
|
return self.event_type == 'document'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_link(self):
|
||||||
|
return self.event_type == 'link'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_status_change(self):
|
def is_status_change(self):
|
||||||
return self.event_type == 'status_change'
|
return self.event_type == 'status_change'
|
||||||
|
|
@ -1320,6 +1335,9 @@ class InterventionTimeLine(models.Model):
|
||||||
'assignment': 'bg-info text-dark',
|
'assignment': 'bg-info text-dark',
|
||||||
'transfer': 'bg-warning text-dark',
|
'transfer': 'bg-warning text-dark',
|
||||||
'modification': 'bg-secondary',
|
'modification': 'bg-secondary',
|
||||||
|
'note': 'bg-info text-dark',
|
||||||
|
'document': 'bg-secondary',
|
||||||
|
'link': 'bg-dark',
|
||||||
}.get(self.event_type, 'bg-secondary')
|
}.get(self.event_type, 'bg-secondary')
|
||||||
|
|
||||||
def get_icon(self):
|
def get_icon(self):
|
||||||
|
|
@ -1329,6 +1347,9 @@ class InterventionTimeLine(models.Model):
|
||||||
'assignment': 'bi-person-check-fill',
|
'assignment': 'bi-person-check-fill',
|
||||||
'transfer': 'bi-arrow-left-right',
|
'transfer': 'bi-arrow-left-right',
|
||||||
'modification': 'bi-pencil-fill',
|
'modification': 'bi-pencil-fill',
|
||||||
|
'note': 'bi-chat-left-text-fill',
|
||||||
|
'document': 'bi-paperclip',
|
||||||
|
'link': 'bi-link-45deg',
|
||||||
}.get(self.event_type, 'bi-dot')
|
}.get(self.event_type, 'bi-dot')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
|
||||||
from interventions.models import (
|
from interventions.models import (
|
||||||
Intervention,
|
Intervention,
|
||||||
InterventionTimeLine,
|
InterventionTimeLine,
|
||||||
|
InterventionNote,
|
||||||
MAINTAIN_CHOICES,
|
MAINTAIN_CHOICES,
|
||||||
ORIGIN_TYPE_CHOICES,
|
ORIGIN_TYPE_CHOICES,
|
||||||
STATUS_CHOICES,
|
STATUS_CHOICES,
|
||||||
|
|
@ -391,3 +392,291 @@ class InterventionChangeTracker:
|
||||||
)
|
)
|
||||||
|
|
||||||
return assignment_event, modification_event
|
return assignment_event, modification_event
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_text(text: Optional[str], max_len: int = 120) -> str:
|
||||||
|
"""Tronque un texte pour un affichage concis dans l'historique."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = text.strip().replace('\r\n', ' ').replace('\n', ' ')
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
return text[:max_len - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def record_note_event(
|
||||||
|
intervention: Intervention,
|
||||||
|
user,
|
||||||
|
action: str, # 'create', 'update', 'delete'
|
||||||
|
note_type: str,
|
||||||
|
content: str,
|
||||||
|
old_note_type: Optional[str] = None,
|
||||||
|
old_content: Optional[str] = None,
|
||||||
|
event_time=None,
|
||||||
|
) -> InterventionTimeLine:
|
||||||
|
"""Enregistre un événement lié aux notes dans la chronologie."""
|
||||||
|
type_choices = dict(InterventionNote.NoteType.choices)
|
||||||
|
type_label = type_choices.get(note_type, note_type)
|
||||||
|
old_type_label = type_choices.get(old_note_type, old_note_type) if old_note_type else None
|
||||||
|
|
||||||
|
changes = {}
|
||||||
|
if action == 'create':
|
||||||
|
desc = _("Note ajoutée (%(type)s)") % {'type': type_label}
|
||||||
|
changes['note_action'] = {'label': str(_("Action")), 'old': None, 'new': str(_("Ajout"))}
|
||||||
|
changes['note_type'] = {'label': str(_("Type de note")), 'old': None, 'new': str(type_label)}
|
||||||
|
if content:
|
||||||
|
changes['content'] = {'label': str(_("Contenu")), 'old': None, 'new': truncate_text(content)}
|
||||||
|
elif action == 'update':
|
||||||
|
desc = _("Note modifiée (%(type)s)") % {'type': type_label}
|
||||||
|
if old_note_type and old_note_type != note_type:
|
||||||
|
changes['note_type'] = {'label': str(_("Type de note")), 'old': str(old_type_label), 'new': str(type_label)}
|
||||||
|
if old_content is not None and old_content != content:
|
||||||
|
changes['content'] = {
|
||||||
|
'label': str(_("Contenu")),
|
||||||
|
'old': truncate_text(old_content),
|
||||||
|
'new': truncate_text(content),
|
||||||
|
}
|
||||||
|
if not changes:
|
||||||
|
changes['note_action'] = {'label': str(_("Action")), 'old': None, 'new': str(_("Mise à jour"))}
|
||||||
|
elif action == 'delete':
|
||||||
|
desc = _("Note supprimée (%(type)s)") % {'type': type_label}
|
||||||
|
changes['note_action'] = {'label': str(_("Action")), 'old': str(_("Suppression")), 'new': None}
|
||||||
|
changes['note_type'] = {'label': str(_("Type de note")), 'old': str(type_label), 'new': None}
|
||||||
|
if content:
|
||||||
|
changes['content'] = {'label': str(_("Contenu")), 'old': truncate_text(content), 'new': None}
|
||||||
|
else:
|
||||||
|
desc = _("Note : %(action)s") % {'action': action}
|
||||||
|
|
||||||
|
return record_timeline_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=user,
|
||||||
|
event_type='note',
|
||||||
|
description=str(desc),
|
||||||
|
field_changes=changes,
|
||||||
|
event_time=event_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_document_event(
|
||||||
|
intervention: Intervention,
|
||||||
|
user,
|
||||||
|
action: str, # 'upload', 'delete'
|
||||||
|
filenames: list,
|
||||||
|
event_time=None,
|
||||||
|
) -> Optional[InterventionTimeLine]:
|
||||||
|
"""Enregistre un événement de document (téléversement ou suppression)."""
|
||||||
|
if not filenames:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import os
|
||||||
|
clean_filenames = [os.path.basename(str(f)) for f in filenames if f]
|
||||||
|
if not clean_filenames:
|
||||||
|
return None
|
||||||
|
|
||||||
|
changes = {}
|
||||||
|
names_str = ", ".join(clean_filenames)
|
||||||
|
|
||||||
|
if action == 'upload':
|
||||||
|
if len(clean_filenames) == 1:
|
||||||
|
desc = _("Document ajouté : %(name)s") % {'name': clean_filenames[0]}
|
||||||
|
else:
|
||||||
|
desc = _("%(count)d documents ajoutés : %(names)s") % {
|
||||||
|
'count': len(clean_filenames),
|
||||||
|
'names': names_str,
|
||||||
|
}
|
||||||
|
changes['documents'] = {
|
||||||
|
'label': str(_("Document(s) ajouté(s)")),
|
||||||
|
'old': None,
|
||||||
|
'new': names_str,
|
||||||
|
}
|
||||||
|
elif action == 'delete':
|
||||||
|
if len(clean_filenames) == 1:
|
||||||
|
desc = _("Document supprimé : %(name)s") % {'name': clean_filenames[0]}
|
||||||
|
else:
|
||||||
|
desc = _("%(count)d documents supprimés : %(names)s") % {
|
||||||
|
'count': len(clean_filenames),
|
||||||
|
'names': names_str,
|
||||||
|
}
|
||||||
|
changes['documents'] = {
|
||||||
|
'label': str(_("Document(s) supprimé(s)")),
|
||||||
|
'old': names_str,
|
||||||
|
'new': None,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
desc = _("Document : %(action)s") % {'action': action}
|
||||||
|
|
||||||
|
return record_timeline_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=user,
|
||||||
|
event_type='document',
|
||||||
|
description=str(desc),
|
||||||
|
field_changes=changes,
|
||||||
|
event_time=event_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_asset_label(asset_obj, ct=None) -> str:
|
||||||
|
"""Retourne une désignation lisible d'un équipement."""
|
||||||
|
if not asset_obj:
|
||||||
|
return ""
|
||||||
|
if hasattr(asset_obj, 'get_name') and callable(asset_obj.get_name):
|
||||||
|
name = asset_obj.get_name()
|
||||||
|
elif hasattr(asset_obj, 'name') and asset_obj.name:
|
||||||
|
name = str(asset_obj.name)
|
||||||
|
else:
|
||||||
|
name = str(asset_obj)
|
||||||
|
|
||||||
|
if ct and hasattr(ct, 'name') and ct.name:
|
||||||
|
return f"{name} ({ct.name})"
|
||||||
|
return str(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_labels_from_pairs(pairs) -> list:
|
||||||
|
"""Résout une collection de (content_type_id, object_id) en liste de désignations lisibles."""
|
||||||
|
if not pairs:
|
||||||
|
return []
|
||||||
|
from collections import defaultdict
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
by_ct = defaultdict(set)
|
||||||
|
for ct_id, obj_id in pairs:
|
||||||
|
by_ct[ct_id].add(obj_id)
|
||||||
|
|
||||||
|
labels = []
|
||||||
|
ct_map = {ct.id: ct for ct in ContentType.objects.filter(id__in=by_ct.keys())}
|
||||||
|
for ct_id, obj_ids in by_ct.items():
|
||||||
|
ct = ct_map.get(ct_id)
|
||||||
|
if not ct:
|
||||||
|
continue
|
||||||
|
model_class = ct.model_class()
|
||||||
|
if not model_class:
|
||||||
|
continue
|
||||||
|
obj_map = {obj.pk: obj for obj in model_class.objects.filter(pk__in=obj_ids)}
|
||||||
|
for obj_id in obj_ids:
|
||||||
|
asset_obj = obj_map.get(obj_id)
|
||||||
|
if asset_obj:
|
||||||
|
labels.append(resolve_asset_label(asset_obj, ct))
|
||||||
|
else:
|
||||||
|
labels.append(f"{ct.model} #{obj_id}")
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def record_asset_link_event(
|
||||||
|
intervention: Intervention,
|
||||||
|
user,
|
||||||
|
added_asset_names: Optional[list] = None,
|
||||||
|
removed_asset_names: Optional[list] = None,
|
||||||
|
event_time=None,
|
||||||
|
) -> Optional[InterventionTimeLine]:
|
||||||
|
"""Enregistre un événement de liaison / déliaison d'équipements (assets)."""
|
||||||
|
added_names = [str(n) for n in (added_asset_names or []) if n]
|
||||||
|
removed_names = [str(n) for n in (removed_asset_names or []) if n]
|
||||||
|
|
||||||
|
if not added_names and not removed_names:
|
||||||
|
return None
|
||||||
|
|
||||||
|
changes = {}
|
||||||
|
if added_names and not removed_names:
|
||||||
|
if len(added_names) == 1:
|
||||||
|
desc = _("Équipement lié : %(name)s") % {'name': added_names[0]}
|
||||||
|
else:
|
||||||
|
desc = _("%(count)d équipements liés : %(names)s") % {
|
||||||
|
'count': len(added_names),
|
||||||
|
'names': ", ".join(added_names),
|
||||||
|
}
|
||||||
|
elif removed_names and not added_names:
|
||||||
|
if len(removed_names) == 1:
|
||||||
|
desc = _("Équipement délié : %(name)s") % {'name': removed_names[0]}
|
||||||
|
else:
|
||||||
|
desc = _("%(count)d équipements déliés : %(names)s") % {
|
||||||
|
'count': len(removed_names),
|
||||||
|
'names': ", ".join(removed_names),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
desc = _("Équipements modifiés (+%(added)d, -%(removed)d)") % {
|
||||||
|
'added': len(added_names),
|
||||||
|
'removed': len(removed_names),
|
||||||
|
}
|
||||||
|
|
||||||
|
if added_names:
|
||||||
|
changes['linked_assets'] = {
|
||||||
|
'label': str(_("Équipement(s) lié(s)")),
|
||||||
|
'old': None,
|
||||||
|
'new': ", ".join(added_names),
|
||||||
|
}
|
||||||
|
if removed_names:
|
||||||
|
changes['unlinked_assets'] = {
|
||||||
|
'label': str(_("Équipement(s) délié(s)")),
|
||||||
|
'old': ", ".join(removed_names),
|
||||||
|
'new': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return record_timeline_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=user,
|
||||||
|
event_type='link',
|
||||||
|
description=str(desc),
|
||||||
|
field_changes=changes,
|
||||||
|
event_time=event_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_relation_link_event(
|
||||||
|
intervention: Intervention,
|
||||||
|
user,
|
||||||
|
relation_label: str,
|
||||||
|
target_labels: list,
|
||||||
|
action: str = 'link',
|
||||||
|
event_time=None,
|
||||||
|
) -> Optional[InterventionTimeLine]:
|
||||||
|
"""Enregistre un événement de liaison avec un autre élément (ex: intervention liée)."""
|
||||||
|
targets = [str(t) for t in target_labels if t]
|
||||||
|
if not targets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
targets_str = ", ".join(targets)
|
||||||
|
changes = {}
|
||||||
|
|
||||||
|
if action == 'link':
|
||||||
|
if len(targets) == 1:
|
||||||
|
desc = _("%(relation)s liée : %(target)s") % {
|
||||||
|
'relation': relation_label,
|
||||||
|
'target': targets[0],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
desc = _("%(count)d %(relation)ss liées : %(targets)s") % {
|
||||||
|
'count': len(targets),
|
||||||
|
'relation': relation_label,
|
||||||
|
'targets': targets_str,
|
||||||
|
}
|
||||||
|
changes['linked_items'] = {
|
||||||
|
'label': str(_("%(relation)s liée(s)") % {'relation': relation_label}),
|
||||||
|
'old': None,
|
||||||
|
'new': targets_str,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if len(targets) == 1:
|
||||||
|
desc = _("%(relation)s déliée : %(target)s") % {
|
||||||
|
'relation': relation_label,
|
||||||
|
'target': targets[0],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
desc = _("%(count)d %(relation)ss déliées : %(targets)s") % {
|
||||||
|
'count': len(targets),
|
||||||
|
'relation': relation_label,
|
||||||
|
'targets': targets_str,
|
||||||
|
}
|
||||||
|
changes['unlinked_items'] = {
|
||||||
|
'label': str(_("%(relation)s déliée(s)") % {'relation': relation_label}),
|
||||||
|
'old': targets_str,
|
||||||
|
'new': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return record_timeline_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=user,
|
||||||
|
event_type='link',
|
||||||
|
description=str(desc),
|
||||||
|
field_changes=changes,
|
||||||
|
event_time=event_time,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -200,11 +200,24 @@
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item-modification .history-timeline-card {
|
.history-item-modification .history-timeline-card,
|
||||||
|
.history-item-detailed .history-timeline-card {
|
||||||
background-color: #fafbfc;
|
background-color: #fafbfc;
|
||||||
border-left: 3px solid #94a3b8;
|
border-left: 3px solid #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-timeline-item[data-event-type="note"] .history-timeline-card {
|
||||||
|
border-left: 3px solid #0dcaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-timeline-item[data-event-type="document"] .history-timeline-card {
|
||||||
|
border-left: 3px solid #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-timeline-item[data-event-type="link"] .history-timeline-card {
|
||||||
|
border-left: 3px solid #212529;
|
||||||
|
}
|
||||||
|
|
||||||
.history-changes {
|
.history-changes {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@
|
||||||
onclick="switchHistoryTimeline('full')"
|
onclick="switchHistoryTimeline('full')"
|
||||||
title="{% translate 'Afficher toutes les modifications détaillées' %}">
|
title="{% translate 'Afficher toutes les modifications détaillées' %}">
|
||||||
<i class="bi bi-clock-history me-1"></i>{% translate "Historique complet" %}
|
<i class="bi bi-clock-history me-1"></i>{% translate "Historique complet" %}
|
||||||
{% if timeline_modification_count %}
|
{% if timeline_detailed_count or timeline_modification_count %}
|
||||||
<span class="badge bg-secondary ms-1">+{{ timeline_modification_count }}</span>
|
<span class="badge bg-secondary ms-1">+{{ timeline_detailed_count|default:timeline_modification_count }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
{% if events_list %}
|
{% if events_list %}
|
||||||
<div class="history-timeline">
|
<div class="history-timeline">
|
||||||
{% for event in events_list %}
|
{% for event in events_list %}
|
||||||
<div class="history-timeline-item {% if event.is_modification %}history-item-modification d-none{% endif %}"
|
<div class="history-timeline-item {% if not event.is_default_visible %}history-item-detailed history-item-modification d-none{% endif %}"
|
||||||
data-event-type="{{ event.event_type }}">
|
data-event-type="{{ event.event_type }}">
|
||||||
<!-- Node Icon -->
|
<!-- Node Icon -->
|
||||||
<div class="history-timeline-node {{ event.get_badge_class }}">
|
<div class="history-timeline-node {{ event.get_badge_class }}">
|
||||||
|
|
@ -79,6 +79,15 @@
|
||||||
{% elif event.event_type == 'modification' %}
|
{% elif event.event_type == 'modification' %}
|
||||||
<span class="badge bg-secondary">{% translate "Modification" %}</span>
|
<span class="badge bg-secondary">{% translate "Modification" %}</span>
|
||||||
<span class="fw-semibold text-secondary">{{ event.event_description|default:_("Attributs modifiés") }}</span>
|
<span class="fw-semibold text-secondary">{{ event.event_description|default:_("Attributs modifiés") }}</span>
|
||||||
|
{% elif event.event_type == 'note' %}
|
||||||
|
<span class="badge bg-info text-dark">{% translate "Note" %}</span>
|
||||||
|
<span class="fw-semibold text-dark">{{ event.event_description }}</span>
|
||||||
|
{% elif event.event_type == 'document' %}
|
||||||
|
<span class="badge bg-secondary">{% translate "Document" %}</span>
|
||||||
|
<span class="fw-semibold text-dark">{{ event.event_description }}</span>
|
||||||
|
{% elif event.event_type == 'link' %}
|
||||||
|
<span class="badge bg-dark">{% translate "Lien" %}</span>
|
||||||
|
<span class="fw-semibold text-dark">{{ event.event_description }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">{{ event.get_event_type_display }}</span>
|
<span class="badge bg-secondary">{{ event.get_event_type_display }}</span>
|
||||||
{% if event.to_status %}
|
{% if event.to_status %}
|
||||||
|
|
@ -104,16 +113,16 @@
|
||||||
{% for field_key, change in event.field_changes.items %}
|
{% for field_key, change in event.field_changes.items %}
|
||||||
<div class="history-field-row d-flex flex-wrap align-items-center py-1 small">
|
<div class="history-field-row d-flex flex-wrap align-items-center py-1 small">
|
||||||
<span class="badge bg-light text-dark border me-2 py-1 px-2 fw-semibold">{{ change.label|default:field_key }}</span>
|
<span class="badge bg-light text-dark border me-2 py-1 px-2 fw-semibold">{{ change.label|default:field_key }}</span>
|
||||||
{% if change.old %}
|
{% if change.old and change.new %}
|
||||||
<span class="text-muted text-decoration-line-through me-1">{{ change.old }}</span>
|
<span class="text-muted text-decoration-line-through me-1">{{ change.old }}</span>
|
||||||
{% else %}
|
|
||||||
<span class="text-muted fst-italic me-1">({% translate "vide" %})</span>
|
|
||||||
{% endif %}
|
|
||||||
<i class="bi bi-arrow-right text-muted mx-1"></i>
|
<i class="bi bi-arrow-right text-muted mx-1"></i>
|
||||||
{% if change.new %}
|
|
||||||
<span class="fw-bold text-dark ms-1">{{ change.new }}</span>
|
<span class="fw-bold text-dark ms-1">{{ change.new }}</span>
|
||||||
|
{% elif change.new %}
|
||||||
|
<span class="fw-bold text-dark">{{ change.new }}</span>
|
||||||
|
{% elif change.old %}
|
||||||
|
<span class="text-muted text-decoration-line-through">{{ change.old }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted fst-italic ms-1">({% translate "vide" %})</span>
|
<span class="text-muted fst-italic">({% translate "vide" %})</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -138,7 +147,7 @@ function switchHistoryTimeline(mode) {
|
||||||
const defaultBtn = document.getElementById('btnHistoryDefault');
|
const defaultBtn = document.getElementById('btnHistoryDefault');
|
||||||
const fullBtn = document.getElementById('btnHistoryFull');
|
const fullBtn = document.getElementById('btnHistoryFull');
|
||||||
const badge = document.getElementById('historyCounterBadge');
|
const badge = document.getElementById('historyCounterBadge');
|
||||||
const modItems = document.querySelectorAll('.history-item-modification');
|
const detailedItems = document.querySelectorAll('.history-item-detailed, .history-item-modification');
|
||||||
const section = document.getElementById('section-chronologie');
|
const section = document.getElementById('section-chronologie');
|
||||||
const defaultCount = section ? section.dataset.defaultCount : '';
|
const defaultCount = section ? section.dataset.defaultCount : '';
|
||||||
const totalCount = section ? section.dataset.totalCount : '';
|
const totalCount = section ? section.dataset.totalCount : '';
|
||||||
|
|
@ -146,12 +155,12 @@ function switchHistoryTimeline(mode) {
|
||||||
if (mode === 'full') {
|
if (mode === 'full') {
|
||||||
defaultBtn?.classList.remove('active');
|
defaultBtn?.classList.remove('active');
|
||||||
fullBtn?.classList.add('active');
|
fullBtn?.classList.add('active');
|
||||||
modItems.forEach(el => el.classList.remove('d-none'));
|
detailedItems.forEach(el => el.classList.remove('d-none'));
|
||||||
if (badge && totalCount) badge.textContent = totalCount;
|
if (badge && totalCount) badge.textContent = totalCount;
|
||||||
} else {
|
} else {
|
||||||
fullBtn?.classList.remove('active');
|
fullBtn?.classList.remove('active');
|
||||||
defaultBtn?.classList.add('active');
|
defaultBtn?.classList.add('active');
|
||||||
modItems.forEach(el => el.classList.add('d-none'));
|
detailedItems.forEach(el => el.classList.add('d-none'));
|
||||||
if (badge && defaultCount) badge.textContent = defaultCount;
|
if (badge && defaultCount) badge.textContent = defaultCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -546,11 +546,24 @@
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item-modification .history-timeline-card {
|
.history-item-modification .history-timeline-card,
|
||||||
|
.history-item-detailed .history-timeline-card {
|
||||||
background-color: #fafbfc;
|
background-color: #fafbfc;
|
||||||
border-left: 3px solid #94a3b8;
|
border-left: 3px solid #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-timeline-item[data-event-type="note"] .history-timeline-card {
|
||||||
|
border-left: 3px solid #0dcaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-timeline-item[data-event-type="document"] .history-timeline-card {
|
||||||
|
border-left: 3px solid #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-timeline-item[data-event-type="link"] .history-timeline-card {
|
||||||
|
border-left: 3px solid #212529;
|
||||||
|
}
|
||||||
|
|
||||||
.history-changes {
|
.history-changes {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,30 @@ from django.contrib.auth import get_user_model
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
from common.models import UserConfig, Role, Thematic
|
from common.models import UserConfig, Role, Thematic
|
||||||
from contracts.models import Company, CompanyTeam, CompanyMember
|
from contracts.models import Company, CompanyTeam, CompanyMember
|
||||||
from interventions.models import Intervention, InterventionTimeLine
|
from interventions.models import (
|
||||||
|
Intervention,
|
||||||
|
InterventionTimeLine,
|
||||||
|
InterventionNote,
|
||||||
|
InterventionDocument,
|
||||||
|
InterventionAsset,
|
||||||
|
InterventionLink,
|
||||||
|
)
|
||||||
|
from assets.models import TrafficLightIntersection
|
||||||
from interventions.services.timeline_service import (
|
from interventions.services.timeline_service import (
|
||||||
record_timeline_event,
|
record_timeline_event,
|
||||||
record_assignment_event,
|
record_assignment_event,
|
||||||
|
record_note_event,
|
||||||
|
record_document_event,
|
||||||
|
record_asset_link_event,
|
||||||
|
record_relation_link_event,
|
||||||
InterventionChangeTracker,
|
InterventionChangeTracker,
|
||||||
)
|
)
|
||||||
|
from interventions.views.helpers import _sync_intervention_assets_data, _handle_notes_update
|
||||||
|
|
||||||
|
|
||||||
class TimelineHistoryTests(TestCase):
|
class TimelineHistoryTests(TestCase):
|
||||||
|
|
@ -233,3 +249,221 @@ class TimelineHistoryTests(TestCase):
|
||||||
with translation.override('fr'):
|
with translation.override('fr'):
|
||||||
response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr')
|
response_fr = self.client.get(url, HTTP_ACCEPT_LANGUAGE='fr')
|
||||||
self.assertContains(response_fr, 'Historique complet')
|
self.assertContains(response_fr, 'Historique complet')
|
||||||
|
|
||||||
|
def test_timeline_model_new_event_types(self):
|
||||||
|
"""Test properties, badge classes, and icons for note, document, and link event types."""
|
||||||
|
# Note event
|
||||||
|
event_note = record_note_event(
|
||||||
|
intervention=self.intervention,
|
||||||
|
user=self.user,
|
||||||
|
action='create',
|
||||||
|
note_type='work_done',
|
||||||
|
content='Remplacement du contrôleur effectué.',
|
||||||
|
)
|
||||||
|
self.assertTrue(event_note.is_note)
|
||||||
|
self.assertFalse(event_note.is_default_visible)
|
||||||
|
self.assertEqual(event_note.get_badge_class(), 'bg-info text-dark')
|
||||||
|
self.assertEqual(event_note.get_icon(), 'bi-chat-left-text-fill')
|
||||||
|
self.assertIn('note_type', event_note.field_changes)
|
||||||
|
self.assertIn('content', event_note.field_changes)
|
||||||
|
|
||||||
|
# Document event
|
||||||
|
event_doc = record_document_event(
|
||||||
|
intervention=self.intervention,
|
||||||
|
user=self.user,
|
||||||
|
action='upload',
|
||||||
|
filenames=['rapport_technique.pdf', 'photo_avant.jpg'],
|
||||||
|
)
|
||||||
|
self.assertTrue(event_doc.is_document)
|
||||||
|
self.assertFalse(event_doc.is_default_visible)
|
||||||
|
self.assertEqual(event_doc.get_badge_class(), 'bg-secondary')
|
||||||
|
self.assertEqual(event_doc.get_icon(), 'bi-paperclip')
|
||||||
|
self.assertIn('rapport_technique.pdf', event_doc.event_description)
|
||||||
|
|
||||||
|
# Link event
|
||||||
|
event_link = record_asset_link_event(
|
||||||
|
intervention=self.intervention,
|
||||||
|
user=self.user,
|
||||||
|
added_asset_names=['Carrefour Louise (Carrefour)'],
|
||||||
|
removed_asset_names=['Carrefour Trône (Carrefour)'],
|
||||||
|
)
|
||||||
|
self.assertTrue(event_link.is_link)
|
||||||
|
self.assertFalse(event_link.is_default_visible)
|
||||||
|
self.assertEqual(event_link.get_badge_class(), 'bg-dark')
|
||||||
|
self.assertEqual(event_link.get_icon(), 'bi-link-45deg')
|
||||||
|
self.assertIn('linked_assets', event_link.field_changes)
|
||||||
|
self.assertIn('unlinked_assets', event_link.field_changes)
|
||||||
|
|
||||||
|
def test_note_views_timeline_recording(self):
|
||||||
|
"""Test that adding, updating, and deleting notes via views records timeline events."""
|
||||||
|
self.client.login(username='agent_test', password='secret_password')
|
||||||
|
|
||||||
|
# 1. Add Note
|
||||||
|
add_url = reverse('interventions:intervention_add_note', args=[self.intervention.id])
|
||||||
|
resp = self.client.post(add_url, {
|
||||||
|
'note_type': 'important_info',
|
||||||
|
'content': 'Attention au câble sous tension',
|
||||||
|
})
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
note_event = self.intervention.events.filter(event_type='note').last()
|
||||||
|
self.assertIsNotNone(note_event)
|
||||||
|
self.assertEqual(note_event.event_user, self.user)
|
||||||
|
self.assertIn('Attention au câble', note_event.field_changes['content']['new'])
|
||||||
|
|
||||||
|
created_note = InterventionNote.objects.filter(intervention=self.intervention).last()
|
||||||
|
|
||||||
|
# 2. Update Note
|
||||||
|
update_url = reverse('interventions:intervention_update_note', args=[self.intervention.id, created_note.id])
|
||||||
|
resp_update = self.client.post(update_url, {
|
||||||
|
'note_type': 'important_info',
|
||||||
|
'content': 'Attention au câble sous tension (mis à jour)',
|
||||||
|
})
|
||||||
|
self.assertEqual(resp_update.status_code, 200)
|
||||||
|
|
||||||
|
update_event = self.intervention.events.filter(event_type='note').last()
|
||||||
|
self.assertIsNotNone(update_event)
|
||||||
|
self.assertIn('old', update_event.field_changes['content'])
|
||||||
|
self.assertIn('mis à jour', update_event.field_changes['content']['new'])
|
||||||
|
|
||||||
|
# 3. Delete Note
|
||||||
|
delete_url = reverse('interventions:intervention_delete_note', args=[self.intervention.id, created_note.id])
|
||||||
|
resp_del = self.client.post(delete_url)
|
||||||
|
self.assertEqual(resp_del.status_code, 200)
|
||||||
|
|
||||||
|
del_event = self.intervention.events.filter(event_type='note').last()
|
||||||
|
self.assertIsNotNone(del_event)
|
||||||
|
self.assertIn(del_event.field_changes['note_action']['old'], ['Suppression', 'Deletion'])
|
||||||
|
|
||||||
|
def test_batch_notes_update_timeline_recording(self):
|
||||||
|
"""Test _handle_notes_update records timeline events for notes in batch mode."""
|
||||||
|
from django.test.client import RequestFactory
|
||||||
|
factory = RequestFactory()
|
||||||
|
request = factory.post('/fake-url')
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
# Create note directly first
|
||||||
|
existing_note = InterventionNote.objects.create(
|
||||||
|
intervention=self.intervention,
|
||||||
|
note_type='work_done',
|
||||||
|
content='Note initiale',
|
||||||
|
note_author=self.user,
|
||||||
|
note_time=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
notes_data = {
|
||||||
|
'new_notes': [{'note_type': 'comment', 'content': 'Nouvelle note en batch'}],
|
||||||
|
'updated_notes': [{'id': existing_note.id, 'note_type': 'comment', 'content': 'Note modifiée en batch'}],
|
||||||
|
'deleted_notes': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
success, errors = _handle_notes_update(request, self.intervention, notes_data)
|
||||||
|
self.assertTrue(success)
|
||||||
|
self.assertEqual(len(errors), 0)
|
||||||
|
|
||||||
|
note_events = list(self.intervention.events.filter(event_type='note'))
|
||||||
|
self.assertGreaterEqual(len(note_events), 2)
|
||||||
|
descriptions = [e.event_description for e in note_events]
|
||||||
|
self.assertTrue(any('Note ajoutée' in d or 'Note added' in d for d in descriptions))
|
||||||
|
self.assertTrue(any('Note modifiée' in d or 'Note updated' in d for d in descriptions))
|
||||||
|
|
||||||
|
def test_document_views_timeline_recording(self):
|
||||||
|
"""Test adding and deleting a document logs document events in timeline."""
|
||||||
|
self.client.login(username='agent_test', password='secret_password')
|
||||||
|
|
||||||
|
# 1. Add document
|
||||||
|
add_url = reverse('interventions:intervention_add_document', args=[self.intervention.id])
|
||||||
|
test_file = SimpleUploadedFile("schema_test.pdf", b"file_content_binary", content_type="application/pdf")
|
||||||
|
resp = self.client.post(add_url, {'documents': [test_file]})
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
doc_event = self.intervention.events.filter(event_type='document').last()
|
||||||
|
self.assertIsNotNone(doc_event)
|
||||||
|
self.assertEqual(doc_event.event_user, self.user)
|
||||||
|
self.assertIn('schema_test.pdf', doc_event.event_description)
|
||||||
|
|
||||||
|
created_doc = InterventionDocument.objects.filter(intervention=self.intervention).last()
|
||||||
|
|
||||||
|
# 2. Delete document
|
||||||
|
del_url = reverse('interventions:intervention_delete_document', args=[self.intervention.id, created_doc.id])
|
||||||
|
resp_del = self.client.post(del_url)
|
||||||
|
self.assertEqual(resp_del.status_code, 200)
|
||||||
|
|
||||||
|
del_event = self.intervention.events.filter(event_type='document').last()
|
||||||
|
self.assertIsNotNone(del_event)
|
||||||
|
self.assertTrue('Document supprimé' in del_event.event_description or 'Document deleted' in del_event.event_description)
|
||||||
|
|
||||||
|
def test_asset_link_timeline_recording(self):
|
||||||
|
"""Test linking and unlinking an asset records link events."""
|
||||||
|
asset = TrafficLightIntersection.objects.create(code='TL-001', name_fr='Carrefour Arts-Loi')
|
||||||
|
ct = ContentType.objects.get_for_model(TrafficLightIntersection)
|
||||||
|
|
||||||
|
# Sync asset to intervention
|
||||||
|
_sync_intervention_assets_data(
|
||||||
|
intervention=self.intervention,
|
||||||
|
normalized_assets=[{'model': 'trafficlightintersection', 'object_id': asset.id}],
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
link_event = self.intervention.events.filter(event_type='link').last()
|
||||||
|
self.assertIsNotNone(link_event)
|
||||||
|
self.assertEqual(link_event.event_user, self.user)
|
||||||
|
self.assertIn('Carrefour Arts-Loi', link_event.event_description)
|
||||||
|
self.assertIn('linked_assets', link_event.field_changes)
|
||||||
|
|
||||||
|
# Unlink asset
|
||||||
|
_sync_intervention_assets_data(
|
||||||
|
intervention=self.intervention,
|
||||||
|
normalized_assets=[],
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
unlink_event = self.intervention.events.filter(event_type='link').last()
|
||||||
|
self.assertIsNotNone(unlink_event)
|
||||||
|
self.assertTrue('délié' in unlink_event.event_description or 'unlinked' in unlink_event.event_description.lower())
|
||||||
|
self.assertIn('unlinked_assets', unlink_event.field_changes)
|
||||||
|
|
||||||
|
def test_intervention_link_timeline_recording(self):
|
||||||
|
"""Test linking two interventions records link events on both sides."""
|
||||||
|
target_intervention = Intervention.objects.create(
|
||||||
|
title='Target Linked Intervention',
|
||||||
|
status='in_preparation',
|
||||||
|
thematic=self.thematic,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username='agent_test', password='secret_password')
|
||||||
|
url = reverse('interventions:link_existing_interventions', args=[self.intervention.id])
|
||||||
|
resp = self.client.post(url, {'codes': target_intervention.code})
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
|
||||||
|
# Event on origin intervention
|
||||||
|
origin_link_event = self.intervention.events.filter(event_type='link').last()
|
||||||
|
self.assertIsNotNone(origin_link_event)
|
||||||
|
self.assertEqual(origin_link_event.event_user, self.user)
|
||||||
|
self.assertIn(target_intervention.code, origin_link_event.event_description)
|
||||||
|
|
||||||
|
# Event on target intervention
|
||||||
|
target_link_event = target_intervention.events.filter(event_type='link').last()
|
||||||
|
self.assertIsNotNone(target_link_event)
|
||||||
|
self.assertEqual(target_link_event.event_user, self.user)
|
||||||
|
self.assertIn(self.intervention.code, target_link_event.event_description)
|
||||||
|
|
||||||
|
def test_detail_view_shows_all_in_full_history(self):
|
||||||
|
"""Test that notes, documents, and links appear as detailed events in timeline."""
|
||||||
|
record_note_event(self.intervention, self.user, action='create', note_type='comment', content='Note test')
|
||||||
|
record_document_event(self.intervention, self.user, action='upload', filenames=['doc_test.png'])
|
||||||
|
record_relation_link_event(self.intervention, self.user, relation_label='Intervention', target_labels=['I999999'])
|
||||||
|
|
||||||
|
self.client.login(username='agent_test', password='secret_password')
|
||||||
|
url = reverse('interventions:intervention_detail', args=[self.intervention.id])
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertIn('timeline_detailed_count', response.context)
|
||||||
|
# Note, document, link are all non-default-visible (detailed)
|
||||||
|
self.assertGreaterEqual(response.context['timeline_detailed_count'], 3)
|
||||||
|
self.assertContains(response, 'history-item-detailed')
|
||||||
|
self.assertContains(response, 'data-event-type="note"')
|
||||||
|
self.assertContains(response, 'data-event-type="document"')
|
||||||
|
self.assertContains(response, 'data-event-type="link"')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,12 @@ from interventions.models import (InterventionSubscription, Intervention, Interv
|
||||||
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment)
|
InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment)
|
||||||
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices
|
from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices
|
||||||
from interventions.services.document_sequencing import get_sequenced_intervention_documents
|
from interventions.services.document_sequencing import get_sequenced_intervention_documents
|
||||||
from interventions.services.timeline_service import InterventionChangeTracker
|
from interventions.services.timeline_service import (
|
||||||
|
InterventionChangeTracker,
|
||||||
|
record_asset_link_event,
|
||||||
|
record_relation_link_event,
|
||||||
|
get_asset_labels_from_pairs,
|
||||||
|
)
|
||||||
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
|
from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm,
|
||||||
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
|
InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm,
|
||||||
LinkExistingInterventionsForm)
|
LinkExistingInterventionsForm)
|
||||||
|
|
@ -678,7 +683,8 @@ def intervention_detail(request, intervention_id):
|
||||||
timeline_events.insert(insert_idx, synth_event)
|
timeline_events.insert(insert_idx, synth_event)
|
||||||
|
|
||||||
timeline_default_count = sum(1 for e in timeline_events if e.is_default_visible)
|
timeline_default_count = sum(1 for e in timeline_events if e.is_default_visible)
|
||||||
timeline_modification_count = sum(1 for e in timeline_events if e.is_modification)
|
timeline_detailed_count = sum(1 for e in timeline_events if not e.is_default_visible)
|
||||||
|
timeline_modification_count = timeline_detailed_count
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"intervention": intervention,
|
"intervention": intervention,
|
||||||
|
|
@ -763,6 +769,7 @@ def intervention_detail(request, intervention_id):
|
||||||
# Timeline / Chronologie
|
# Timeline / Chronologie
|
||||||
'timeline_events': timeline_events,
|
'timeline_events': timeline_events,
|
||||||
'timeline_default_count': timeline_default_count,
|
'timeline_default_count': timeline_default_count,
|
||||||
|
'timeline_detailed_count': timeline_detailed_count,
|
||||||
'timeline_modification_count': timeline_modification_count,
|
'timeline_modification_count': timeline_modification_count,
|
||||||
'timeline_total_count': len(timeline_events),
|
'timeline_total_count': len(timeline_events),
|
||||||
# Historique des envois d'emails (ordre chronologique)
|
# Historique des envois d'emails (ordre chronologique)
|
||||||
|
|
@ -885,6 +892,24 @@ def link_existing_interventions(request, intervention_id):
|
||||||
already_codes.append(target.code)
|
already_codes.append(target.code)
|
||||||
|
|
||||||
if created_codes:
|
if created_codes:
|
||||||
|
record_relation_link_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
relation_label=str(_("Intervention")),
|
||||||
|
target_labels=created_codes,
|
||||||
|
action='link',
|
||||||
|
)
|
||||||
|
for target_code in created_codes:
|
||||||
|
target = found_map.get(target_code)
|
||||||
|
if target:
|
||||||
|
record_relation_link_event(
|
||||||
|
intervention=target,
|
||||||
|
user=request.user,
|
||||||
|
relation_label=str(_("Intervention")),
|
||||||
|
target_labels=[intervention.code],
|
||||||
|
action='link',
|
||||||
|
)
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
ngettext(
|
ngettext(
|
||||||
|
|
@ -2204,6 +2229,9 @@ def intervention_link_assets(request, intervention_id):
|
||||||
to_add = target_pairs - existing_pairs
|
to_add = target_pairs - existing_pairs
|
||||||
to_del = existing_pairs - target_pairs
|
to_del = existing_pairs - target_pairs
|
||||||
|
|
||||||
|
removed_labels = get_asset_labels_from_pairs(to_del) if to_del else []
|
||||||
|
added_labels = get_asset_labels_from_pairs(to_add) if to_add else []
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
operations_deleted = 0
|
operations_deleted = 0
|
||||||
|
|
@ -2232,6 +2260,14 @@ def intervention_link_assets(request, intervention_id):
|
||||||
InterventionAsset.objects.bulk_create(objs)
|
InterventionAsset.objects.bulk_create(objs)
|
||||||
created_count = len(objs)
|
created_count = len(objs)
|
||||||
|
|
||||||
|
if to_add or to_del:
|
||||||
|
record_asset_link_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
added_asset_names=added_labels,
|
||||||
|
removed_asset_names=removed_labels,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"{_("Erreur de synchro")}: {e}")
|
messages.error(request, f"{_("Erreur de synchro")}: {e}")
|
||||||
return redirect("interventions:intervention_detail", intervention_id)
|
return redirect("interventions:intervention_detail", intervention_id)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ from dateutil.relativedelta import relativedelta
|
||||||
from common.private_files.helpers import build_private_url
|
from common.private_files.helpers import build_private_url
|
||||||
from common.utils import get_short_name
|
from common.utils import get_short_name
|
||||||
|
|
||||||
|
from interventions.services.timeline_service import (
|
||||||
|
record_note_event,
|
||||||
|
record_asset_link_event,
|
||||||
|
get_asset_labels_from_pairs,
|
||||||
|
)
|
||||||
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
||||||
InterventionLink, InterventionSubType,
|
InterventionLink, InterventionSubType,
|
||||||
ContractPauseReasonAssignment,
|
ContractPauseReasonAssignment,
|
||||||
|
|
@ -1595,6 +1600,9 @@ def _sync_intervention_assets_data(
|
||||||
to_add = target_pairs - existing_pairs
|
to_add = target_pairs - existing_pairs
|
||||||
to_remove = existing_pairs - target_pairs
|
to_remove = existing_pairs - target_pairs
|
||||||
|
|
||||||
|
removed_labels = get_asset_labels_from_pairs(to_remove) if to_remove else []
|
||||||
|
added_labels = get_asset_labels_from_pairs(to_add) if to_add else []
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
operations_deleted = 0
|
operations_deleted = 0
|
||||||
|
|
@ -1641,6 +1649,15 @@ def _sync_intervention_assets_data(
|
||||||
InterventionAsset.objects.bulk_create(new_relations)
|
InterventionAsset.objects.bulk_create(new_relations)
|
||||||
created_count = len(new_relations)
|
created_count = len(new_relations)
|
||||||
|
|
||||||
|
if to_add or to_remove:
|
||||||
|
event_user = user or (request.user if request and getattr(request, 'user', None) else None)
|
||||||
|
record_asset_link_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=event_user,
|
||||||
|
added_asset_names=added_labels,
|
||||||
|
removed_asset_names=removed_labels,
|
||||||
|
)
|
||||||
|
|
||||||
operations_created = 0
|
operations_created = 0
|
||||||
if to_add:
|
if to_add:
|
||||||
assets_with_ct = _resolve_assets_from_pairs(to_add)
|
assets_with_ct = _resolve_assets_from_pairs(to_add)
|
||||||
|
|
@ -2457,13 +2474,20 @@ def _handle_notes_update(request, intervention, notes_data):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
InterventionNote.objects.create(
|
note_inst = InterventionNote.objects.create(
|
||||||
intervention=intervention,
|
intervention=intervention,
|
||||||
content=content,
|
content=content,
|
||||||
note_type=note_type,
|
note_type=note_type,
|
||||||
note_author=request.user,
|
note_author=request.user,
|
||||||
note_time=timezone.now()
|
note_time=timezone.now()
|
||||||
)
|
)
|
||||||
|
record_note_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='create',
|
||||||
|
note_type=note_type,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
success = True
|
success = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(_('Error creating note: %(error)s') % {'error': str(e)})
|
errors.append(_('Error creating note: %(error)s') % {'error': str(e)})
|
||||||
|
|
@ -2487,11 +2511,24 @@ def _handle_notes_update(request, intervention, notes_data):
|
||||||
errors.append(_('You do not have permission to edit this note'))
|
errors.append(_('You do not have permission to edit this note'))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
old_note_type = note.note_type
|
||||||
|
old_content = note.content
|
||||||
|
|
||||||
note.content = content
|
note.content = content
|
||||||
note.note_type = note_type
|
note.note_type = note_type
|
||||||
note.updated_by = request.user
|
note.updated_by = request.user
|
||||||
note.updated_at = timezone.now()
|
note.updated_at = timezone.now()
|
||||||
note.save()
|
note.save()
|
||||||
|
|
||||||
|
record_note_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='update',
|
||||||
|
note_type=note_type,
|
||||||
|
content=content,
|
||||||
|
old_note_type=old_note_type,
|
||||||
|
old_content=old_content,
|
||||||
|
)
|
||||||
success = True
|
success = True
|
||||||
except InterventionNote.DoesNotExist:
|
except InterventionNote.DoesNotExist:
|
||||||
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})
|
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})
|
||||||
|
|
@ -2510,7 +2547,17 @@ def _handle_notes_update(request, intervention, notes_data):
|
||||||
errors.append(_('You do not have permission to delete this note'))
|
errors.append(_('You do not have permission to delete this note'))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
del_note_type = note.note_type
|
||||||
|
del_content = note.content
|
||||||
note.delete()
|
note.delete()
|
||||||
|
|
||||||
|
record_note_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='delete',
|
||||||
|
note_type=del_note_type,
|
||||||
|
content=del_content,
|
||||||
|
)
|
||||||
success = True
|
success = True
|
||||||
except InterventionNote.DoesNotExist:
|
except InterventionNote.DoesNotExist:
|
||||||
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})
|
errors.append(_('Note not found: %(note_id)s') % {'note_id': note_id})
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ from dateutil.relativedelta import relativedelta
|
||||||
from common.private_files.helpers import build_private_url
|
from common.private_files.helpers import build_private_url
|
||||||
from common.utils import get_short_name
|
from common.utils import get_short_name
|
||||||
|
|
||||||
|
from interventions.services.timeline_service import record_document_event
|
||||||
from interventions import views
|
from interventions import views
|
||||||
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
||||||
InterventionLink,
|
InterventionLink,
|
||||||
|
|
@ -1214,6 +1215,13 @@ def mobile_upload_photo_api(request, intervention_id):
|
||||||
file=upload,
|
file=upload,
|
||||||
uploaded_by=request.user,
|
uploaded_by=request.user,
|
||||||
)
|
)
|
||||||
|
filename = getattr(upload, 'name', '') or getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
|
||||||
|
record_document_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='upload',
|
||||||
|
filenames=[filename],
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Erreur lors de l\'upload de la photo mobile")
|
logger.exception("Erreur lors de l\'upload de la photo mobile")
|
||||||
return JsonResponse({'success': False, 'error': _('Erreur lors de l\'enregistrement de la photo.')}, status=500)
|
return JsonResponse({'success': False, 'error': _('Erreur lors de l\'enregistrement de la photo.')}, status=500)
|
||||||
|
|
@ -1259,8 +1267,15 @@ def mobile_delete_photo_api(request, intervention_id, document_id):
|
||||||
status=403
|
status=403
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filename = getattr(doc, 'filename', None) or getattr(doc.file, 'name', '') or str(doc.id)
|
||||||
try:
|
try:
|
||||||
doc.delete()
|
doc.delete()
|
||||||
|
record_document_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='delete',
|
||||||
|
filenames=[filename],
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Erreur lors de la suppression du document mobile")
|
logger.exception("Erreur lors de la suppression du document mobile")
|
||||||
return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500)
|
return JsonResponse({'success': False, 'error': _('Erreur lors de la suppression du document.')}, status=500)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ from dateutil.relativedelta import relativedelta
|
||||||
from common.private_files.helpers import build_private_url
|
from common.private_files.helpers import build_private_url
|
||||||
from common.utils import get_short_name
|
from common.utils import get_short_name
|
||||||
|
|
||||||
|
from interventions.services.timeline_service import (
|
||||||
|
record_note_event,
|
||||||
|
record_document_event,
|
||||||
|
)
|
||||||
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
from interventions.models import (InterventionSubscription, Intervention, InterventionAsset, InterventionTimeLine, InterventionContractPost, InterventionDocument, InterventionNote, InterventionLocation,
|
||||||
InterventionLink,
|
InterventionLink,
|
||||||
ContractPauseReasonAssignment,
|
ContractPauseReasonAssignment,
|
||||||
|
|
@ -171,6 +175,14 @@ def intervention_add_note(request, intervention_id):
|
||||||
note_instance.note_time = now()
|
note_instance.note_time = now()
|
||||||
note_instance.save()
|
note_instance.save()
|
||||||
|
|
||||||
|
record_note_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='create',
|
||||||
|
note_type=note_instance.note_type,
|
||||||
|
content=note_instance.content,
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
'note': _serialize_note(note_instance, request.user)
|
'note': _serialize_note(note_instance, request.user)
|
||||||
|
|
@ -186,6 +198,9 @@ def intervention_update_note(request, intervention_id, note_id):
|
||||||
if not can_edit_note(request.user, request.user.config, note):
|
if not can_edit_note(request.user, request.user.config, note):
|
||||||
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas modifier cette note.')}, status=403)
|
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas modifier cette note.')}, status=403)
|
||||||
|
|
||||||
|
old_note_type = note.note_type
|
||||||
|
old_content = note.content
|
||||||
|
|
||||||
form = InterventionNoteForm(request.POST, instance=note)
|
form = InterventionNoteForm(request.POST, instance=note)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
note_instance = form.save(commit=False)
|
note_instance = form.save(commit=False)
|
||||||
|
|
@ -193,6 +208,16 @@ def intervention_update_note(request, intervention_id, note_id):
|
||||||
note_instance.save(update_fields=['content', 'note_type', 'updated_at', 'updated_by'])
|
note_instance.save(update_fields=['content', 'note_type', 'updated_at', 'updated_by'])
|
||||||
note.refresh_from_db(fields=['content', 'note_type', 'updated_at', 'updated_by'])
|
note.refresh_from_db(fields=['content', 'note_type', 'updated_at', 'updated_by'])
|
||||||
|
|
||||||
|
record_note_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='update',
|
||||||
|
note_type=note.note_type,
|
||||||
|
content=note.content,
|
||||||
|
old_note_type=old_note_type,
|
||||||
|
old_content=old_content,
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'success': True,
|
'success': True,
|
||||||
'note': _serialize_note(note, request.user)
|
'note': _serialize_note(note, request.user)
|
||||||
|
|
@ -208,7 +233,18 @@ def intervention_delete_note(request, intervention_id, note_id):
|
||||||
if not can_edit_note(request.user, request.user.config, note):
|
if not can_edit_note(request.user, request.user.config, note):
|
||||||
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer cette note.')}, status=403)
|
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer cette note.')}, status=403)
|
||||||
|
|
||||||
|
note_type = note.note_type
|
||||||
|
content = note.content
|
||||||
note.delete()
|
note.delete()
|
||||||
|
|
||||||
|
record_note_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='delete',
|
||||||
|
note_type=note_type,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({'success': True})
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -220,8 +256,17 @@ def intervention_add_document(request, intervention_id):
|
||||||
form = InterventionDocumentForm(request.POST, request.FILES)
|
form = InterventionDocumentForm(request.POST, request.FILES)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
files = form.cleaned_data["documents"]
|
files = form.cleaned_data["documents"]
|
||||||
|
filenames = []
|
||||||
for file in files:
|
for file in files:
|
||||||
InterventionDocument.objects.create(intervention=intervention, file=file, uploaded_by=request.user)
|
doc = InterventionDocument.objects.create(intervention=intervention, file=file, uploaded_by=request.user)
|
||||||
|
filenames.append(getattr(file, 'name', '') or str(doc.id))
|
||||||
|
|
||||||
|
record_document_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='upload',
|
||||||
|
filenames=filenames,
|
||||||
|
)
|
||||||
|
|
||||||
messages.success(request, _("Document(s) sauvegardé(s) avec succès !"))
|
messages.success(request, _("Document(s) sauvegardé(s) avec succès !"))
|
||||||
return redirect_with_variant('interventions:intervention_detail', variant=variant, url_kwargs={'intervention_id': intervention_id})
|
return redirect_with_variant('interventions:intervention_detail', variant=variant, url_kwargs={'intervention_id': intervention_id})
|
||||||
|
|
@ -249,12 +294,22 @@ def intervention_delete_document(request, intervention_id, document_id):
|
||||||
if not can_delete_document(request.user, user_config, document):
|
if not can_delete_document(request.user, user_config, document):
|
||||||
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer ce document.')}, status=403)
|
return JsonResponse({'success': False, 'error': _('Vous ne pouvez pas supprimer ce document.')}, status=403)
|
||||||
|
|
||||||
|
filename = getattr(document.file, 'name', '') or str(document.id)
|
||||||
|
|
||||||
if document.file:
|
if document.file:
|
||||||
document.file.delete(save=False)
|
document.file.delete(save=False)
|
||||||
if document.thumbnail:
|
if document.thumbnail:
|
||||||
document.thumbnail.delete(save=False)
|
document.thumbnail.delete(save=False)
|
||||||
|
|
||||||
document.delete()
|
document.delete()
|
||||||
|
|
||||||
|
record_document_event(
|
||||||
|
intervention=intervention,
|
||||||
|
user=request.user,
|
||||||
|
action='delete',
|
||||||
|
filenames=[filename],
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse({'success': True})
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue