From 499ffe824e49c04dfcd834ec5ce675b4ccc312b0 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 7 Oct 2026 10:45:37 +0200 Subject: [PATCH] refactor: redesign calendar notes and documents view with chronological sequencing blocks --- .../calendar/interventions_calendar.css | 5 +- .../calendar/interventions_calendar.js | 26 --- .../calendar_intervention_notes_docs.html | 220 +++++++++--------- .../tests/test_calendar_notes_docs.py | 117 ++++++++-- loko/interventions/views/calendar.py | 45 ++-- 5 files changed, 233 insertions(+), 180 deletions(-) diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.css b/loko/interventions/static/interventions/calendar/interventions_calendar.css index e2866af..fc72dd3 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.css +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.css @@ -1182,8 +1182,9 @@ border-color: #b0c4de !important; } -.cal-nd-filter-btn { - transition: all 0.15s ease; +.cal-sequence-block { + background-color: #f8fafc; + border-color: #e2e8f0 !important; } diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index 979b999..218dda1 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -5138,32 +5138,6 @@ document.addEventListener('DOMContentLoaded', function () { if (summaryText) { summaryText.textContent = `${notesText} • ${docsText}`; } - - // Setup filter buttons - const filterBtns = container.querySelectorAll('.cal-nd-filter-btn'); - const notesSection = container.querySelector('#cal-modal-notes-section'); - const docsSection = container.querySelector('#cal-modal-docs-section'); - - filterBtns.forEach(btn => { - btn.addEventListener('click', function () { - filterBtns.forEach(b => { - b.classList.remove('active'); - b.classList.remove('btn-primary'); - b.classList.add('btn-outline-primary'); - }); - this.classList.add('active'); - this.classList.remove('btn-outline-primary'); - this.classList.add('btn-primary'); - const filter = this.getAttribute('data-filter'); - - if (notesSection) { - notesSection.style.display = (filter === 'all' || filter === 'notes') ? '' : 'none'; - } - if (docsSection) { - docsSection.style.display = (filter === 'all' || filter === 'docs') ? '' : 'none'; - } - }); - }); } else { container.innerHTML = `
${gettext('Impossible de charger les notes et documents.')}
`; } diff --git a/loko/interventions/templates/interventions/partials/calendar_intervention_notes_docs.html b/loko/interventions/templates/interventions/partials/calendar_intervention_notes_docs.html index 9deb019..48b9bbc 100644 --- a/loko/interventions/templates/interventions/partials/calendar_intervention_notes_docs.html +++ b/loko/interventions/templates/interventions/partials/calendar_intervention_notes_docs.html @@ -1,141 +1,133 @@ {% load i18n tz short_name interventions_tags document_viewer_tags %}
- +
-
- - - -
-
- {% if total_count > 0 %} - {{ total_count }} {% if total_count > 1 %}{% translate "éléments" %}{% else %}{% translate "élément" %}{% endif %} - {% endif %} -
+
+ {% translate "Notes & Documents" %} +
+ {% if total_count > 0 %} + + {{ total_count }} {% if total_count > 1 %}{% translate "éléments" %}{% else %}{% translate "élément" %}{% endif %} + + {% endif %}
- {% if total_count == 0 %} + {% if not has_items %}

{% translate "Aucune note ni document" %}

{% translate "Cette intervention n'a pas encore de note ni de document associé." %}

{% else %} - - -
-
-
- {% translate "Notes" %} - {{ notes_count }} -
-
- - {% if notes %} -
- {% for note in notes %} -
-
-
-
- - - {{ note.get_note_type_display }} - -
- - {{ note.note_time|timezone:"Europe/Paris"|date:"d/m/Y H:i" }} +
+ {% for block in sequenced_blocks %} + {% if block.has_items %} +
+ +
+
+ + + {{ block.title }} + + + {% if block.documents_count > 0 %}{{ block.documents_count }} {% if block.documents_count > 1 %}{% translate "fichiers" %}{% else %}{% translate "fichier" %}{% endif %}{% endif %} + {% if block.documents_count > 0 and block.notes_count > 0 %}•{% endif %} + {% if block.notes_count > 0 %}{{ block.notes_count }} {% if block.notes_count > 1 %}{% translate "notes" %}{% else %}{% translate "note" %}{% endif %}{% endif %}
+ {% if block.period_display %} + + {{ block.period_display }} + + {% endif %} +
-
{{ note.content }}
- -
- - {{ note.note_author|short_name|default:"—" }} - - {% if note.updated_at %} - - {% translate "Modifié" %} - + + {% if block.documents %} +
+
+ {% for doc in block.documents %} + {% private_file_url doc 'file' as file_url %} + {% if doc.thumbnail %} + {% private_file_url doc 'thumbnail' as thumb_url %} + {% else %} + {% private_file_url doc 'file' as thumb_url %} {% endif %} +
+
+ + {% if doc.is_image %} + {{ doc.filename }} +
+ + + +
+ {% else %} + + {% endif %} +
+
+
+ {{ doc.filename }} +
+
+ + {{ doc.upload_date|timezone:"Europe/Paris"|date:"d/m/Y" }} + + + + +
+
+
+
+ {% endfor %}
-
- {% endfor %} -
- {% else %} -
- {% translate "Aucune note pour cette intervention." %} -
- {% endif %} -
+ {% endif %} - -
-
-
- {% translate "Documents & Photos" %} - {{ documents_count }} -
-
- - {% if documents %} -
- {% for doc in documents %} - {% private_file_url doc 'file' as file_url %} - {% if doc.thumbnail %} - {% private_file_url doc 'thumbnail' as thumb_url %} - {% else %} - {% private_file_url doc 'file' as thumb_url %} - {% endif %} -
-
- - {% if doc.is_image %} - {{ doc.filename }} -
- - + + {% if block.notes %} +
+ {% for note in block.notes %} +
+
+
+
+ + + {{ note.get_note_type_display }} + +
+ + {{ note.note_time|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
- {% else %} - - {% endif %} -
-
-
- {{ doc.filename }} -
-
- - {{ doc.upload_date|timezone:"Europe/Paris"|date:"d/m/Y" }} - - - - + +
{{ note.content }}
+ +
+ + {{ note.note_author|short_name|default:"—" }} + + {% if note.updated_at %} + + {% translate "Modifié" %} + + {% endif %} +
+ {% endfor %}
-
- {% endfor %} -
- {% else %} -
- {% translate "Aucun document pour cette intervention." %} -
- {% endif %} -
+ {% endif %} +
+ {% endif %} + {% endfor %} +
{% endif %}
diff --git a/loko/interventions/tests/test_calendar_notes_docs.py b/loko/interventions/tests/test_calendar_notes_docs.py index bda6f2f..9baba5a 100644 --- a/loko/interventions/tests/test_calendar_notes_docs.py +++ b/loko/interventions/tests/test_calendar_notes_docs.py @@ -1,6 +1,6 @@ import json from unittest import mock -from datetime import datetime +from datetime import datetime, timedelta from django.test import SimpleTestCase, RequestFactory from django.contrib.auth.models import AnonymousUser @@ -114,10 +114,15 @@ class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase): def test_authorized_user_receives_notes_and_documents( self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary ): + now = timezone.now() mock_itv = mock.MagicMock(id=123, title="Intervention Réparation Éclairage") + mock_itv.begin_time = None + mock_itv.end_time = None + mock_itv.creation_time = now + mock_itv.events.filter.return_value.order_by.return_value.first.return_value = None + mock_itv.realized_events.filter.return_value.order_by.return_value.first.return_value = None mock_get_obj.return_value = mock_itv - now = timezone.now() author = DummyUser(id=1, first_name="Jean", last_name="Dupont") # Notes @@ -167,6 +172,7 @@ class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase): ) mock_docs_qs = mock.MagicMock() + mock_docs_qs.all.return_value = mock_docs_qs mock_docs_qs.select_related.return_value = mock_docs_qs mock_docs_qs.order_by.return_value = [doc_img, doc_pdf] mock_itv.documents = mock_docs_qs @@ -174,21 +180,24 @@ class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase): user = DummyUser(id=99) request = self._create_request(user) - response = intervention_calendar_details_ajax(request, intervention_id=123) - self.assertEqual(response.status_code, 200) + from django.utils import translation + with translation.override('fr'): + response = intervention_calendar_details_ajax(request, intervention_id=123) + self.assertEqual(response.status_code, 200) - data = json.loads(response.content.decode('utf-8')) - self.assertTrue(data['success']) - self.assertEqual(data['notes_count'], 2) - self.assertEqual(data['documents_count'], 2) - self.assertEqual(data['total_count'], 4) + data = json.loads(response.content.decode('utf-8')) + self.assertTrue(data['success']) + self.assertEqual(data['notes_count'], 2) + self.assertEqual(data['documents_count'], 2) + self.assertEqual(data['total_count'], 4) - # Content assertions - self.assertIn("Nacelle 15m requise", data['html']) - self.assertIn("Attention présence de câbles haute tension", data['html']) - self.assertIn("photo_degats.jpg", data['html']) - self.assertIn("rapport_securite.pdf", data['html']) - self.assertIn("Jean D.", data['html']) + # Content assertions + self.assertIn("Avant réalisation", data['html']) + self.assertIn("Nacelle 15m requise", data['html']) + self.assertIn("Attention présence de câbles haute tension", data['html']) + self.assertIn("photo_degats.jpg", data['html']) + self.assertIn("rapport_securite.pdf", data['html']) + self.assertIn("Jean D.", data['html']) # Document flags computed by view self.assertTrue(doc_img.is_image) @@ -207,6 +216,11 @@ class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase): self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary ): mock_itv = mock.MagicMock(id=456, title="Intervention Sans Notes") + mock_itv.begin_time = None + mock_itv.end_time = None + mock_itv.creation_time = timezone.now() + mock_itv.events.filter.return_value.order_by.return_value.first.return_value = None + mock_itv.realized_events.filter.return_value.order_by.return_value.first.return_value = None mock_get_obj.return_value = mock_itv mock_filtered_notes = mock.MagicMock() @@ -216,6 +230,7 @@ class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase): mock_notes_qs.filter.return_value = mock_filtered_notes mock_docs_qs = mock.MagicMock() + mock_docs_qs.all.return_value = mock_docs_qs mock_docs_qs.select_related.return_value = mock_docs_qs mock_docs_qs.order_by.return_value = [] mock_itv.documents = mock_docs_qs @@ -232,3 +247,75 @@ class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase): self.assertEqual(data['documents_count'], 0) self.assertEqual(data['total_count'], 0) self.assertIn("Aucune note ni document", data['html']) + + @mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False) + @mock.patch('interventions.views.calendar.can_view_intervention', return_value=True) + @mock.patch('interventions.views.calendar.InterventionNote.objects') + @mock.patch('interventions.views.calendar.get_object_or_404') + def test_chronological_sequencing_multiple_blocks( + self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary + ): + now = timezone.now() + author = DummyUser(id=1, first_name="Jean", last_name="Dupont") + + mock_itv = mock.MagicMock(id=789, title="Intervention Multi Phases") + mock_itv.begin_time = now - timedelta(hours=2) + mock_itv.end_time = now + timedelta(hours=2) + mock_itv.creation_time = now - timedelta(days=2) + mock_itv.events.filter.return_value.order_by.return_value.first.return_value = None + mock_itv.realized_events.filter.return_value.order_by.return_value.first.return_value = None + mock_get_obj.return_value = mock_itv + + # Note during work + note_during = DummyNote( + id=1, + note_type=InterventionNote.NoteType.WORK_DONE, + type_display="Travaux réalisés", + badge_class="text-bg-success text-white", + icon_class="bi-check-circle", + content="Remplacement du ballast effectué avec succès", + note_time=now, + note_author=author, + ) + mock_filtered_notes = mock.MagicMock() + mock_filtered_notes.select_related.return_value = mock_filtered_notes + mock_filtered_notes.annotate.return_value = mock_filtered_notes + mock_filtered_notes.order_by.return_value = [note_during] + mock_notes_qs.filter.return_value = mock_filtered_notes + + # Document before work (yesterday) + doc_before = DummyDoc( + id=20, + filename="constat_initial.jpg", + thumbnail_path="thumbnails/constat_initial.jpg", + upload_date=now - timedelta(days=1), + uploaded_by=author, + ) + mock_docs_qs = mock.MagicMock() + mock_docs_qs.all.return_value = mock_docs_qs + mock_docs_qs.select_related.return_value = mock_docs_qs + mock_docs_qs.order_by.return_value = [doc_before] + mock_itv.documents = mock_docs_qs + + user = DummyUser(id=99) + request = self._create_request(user) + + from django.utils import translation + with translation.override('fr'): + response = intervention_calendar_details_ajax(request, intervention_id=789) + self.assertEqual(response.status_code, 200) + + data = json.loads(response.content.decode('utf-8')) + self.assertTrue(data['success']) + self.assertEqual(data['notes_count'], 1) + self.assertEqual(data['documents_count'], 1) + self.assertEqual(data['total_count'], 2) + + import html as html_module + unescaped_html = html_module.unescape(data['html']) + + # Both chronological blocks are rendered + self.assertIn("Avant réalisation", unescaped_html) + self.assertIn("constat_initial.jpg", unescaped_html) + self.assertIn("Exécution de l'intervention", unescaped_html) + self.assertIn("Remplacement du ballast effectué avec succès", unescaped_html) diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py index 3661a77..d73cd28 100644 --- a/loko/interventions/views/calendar.py +++ b/loko/interventions/views/calendar.py @@ -80,6 +80,7 @@ from interventions.permissions import (intervention_permission_required, interve from interventions.permissions import ALLOWED_TRANSITIONS, get_allowed_transitions, user_allowed_transitions from interventions.views.drafts import InterventionDraftManager from interventions.utils import resolve_intervention_localisations +from interventions.services.document_sequencing import get_sequenced_intervention_documents from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory, CompanyMemberAbsence @@ -1759,7 +1760,8 @@ def team_daily_members_ajax(request, team_id): @require_GET def intervention_calendar_details_ajax(request, intervention_id): """ - Retourne les notes et documents d'une intervention pour la modale du calendrier. + Retourne les notes et documents d'une intervention pour la modale du calendrier, + organisés sous forme chronologique (phases: avant réalisation, exécution, après réalisation). """ intervention = get_object_or_404(Intervention, pk=intervention_id) @@ -1774,35 +1776,32 @@ def intervention_calendar_details_ajax(request, intervention_id): output_field=IntegerField(), ) - notes = list( + notes = ( InterventionNote.objects .filter(intervention=intervention) .select_related('note_author', 'updated_by') .annotate(type_priority_order=note_priority_case) - .order_by('type_priority_order', '-note_time', '-id') + .order_by('type_priority_order', 'note_time', 'id') ) + sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes) + IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'} - documents = list( - intervention.documents - .select_related('uploaded_by') - .order_by('-upload_date', '-id') - ) - - for doc in documents: - fn = getattr(doc, 'filename', '') or '' - ext = os.path.splitext(fn)[1].lower() if fn else '' - doc.ext = ext - doc.is_image = bool(doc.thumbnail or ext in IMAGE_EXTENSIONS) - doc.is_pdf = ext == '.pdf' + for block in sequenced_data['blocks']: + for doc in block['documents']: + fn = getattr(doc, 'filename', '') or '' + ext = os.path.splitext(fn)[1].lower() if fn else '' + doc.ext = ext + doc.is_image = bool(getattr(doc, 'thumbnail', None) or ext in IMAGE_EXTENSIONS) + doc.is_pdf = ext == '.pdf' context = { 'intervention': intervention, - 'notes': notes, - 'documents': documents, - 'notes_count': len(notes), - 'documents_count': len(documents), - 'total_count': len(notes) + len(documents), + 'sequenced_blocks': sequenced_data['blocks'], + 'notes_count': sequenced_data['total_notes_count'], + 'documents_count': sequenced_data['total_documents_count'], + 'total_count': sequenced_data['total_items_count'], + 'has_items': sequenced_data['has_items'], } html = render_to_string( @@ -1813,9 +1812,9 @@ def intervention_calendar_details_ajax(request, intervention_id): return JsonResponse({ 'success': True, - 'notes_count': len(notes), - 'documents_count': len(documents), - 'total_count': len(notes) + len(documents), + 'notes_count': sequenced_data['total_notes_count'], + 'documents_count': sequenced_data['total_documents_count'], + 'total_count': sequenced_data['total_items_count'], 'html': html, })