diff --git a/loko/interventions/services/document_sequencing.py b/loko/interventions/services/document_sequencing.py new file mode 100644 index 0000000..b8a3137 --- /dev/null +++ b/loko/interventions/services/document_sequencing.py @@ -0,0 +1,198 @@ +from typing import List, Dict, Any, Optional +from datetime import datetime +from django.utils.translation import gettext as _ +from django.utils import timezone +from django.utils.dateformat import format as dj_format_date + + +def get_intervention_milestones(intervention) -> tuple[Optional[datetime], Optional[datetime]]: + """ + Détermine les jalons temporels d'une intervention : + - start_dt (T_début) : Début réel de l'exécution / réalisation + - end_dt (T_fin) : Fin réelle de l'exécution / réalisation + + Logique de résolution : + 1. start_dt : + - intervention.begin_time si défini + - Sinon premier événement timeline avec to_status='in_progress' + - Sinon premier événement InterventionRealizationTimeLine avec event_type='real_begin' + 2. end_dt : + - intervention.end_time si défini + - Sinon premier événement timeline avec to_status in ['finished', 'processed', 'validated', 'invoiced', 'closed'] + (intervenu après start_dt si start_dt existe) + - Sinon premier événement InterventionRealizationTimeLine avec event_type='real_end' + """ + start_dt: Optional[datetime] = intervention.begin_time or None + if not start_dt: + # Recherche dans InterventionTimeLine + first_in_progress = ( + intervention.events + .filter(to_status='in_progress') + .order_by('event_time') + .first() + ) + if first_in_progress: + start_dt = first_in_progress.event_time + else: + # Recherche dans InterventionRealizationTimeLine + first_real_begin = ( + intervention.realized_events + .filter(event_type='real_begin') + .order_by('event_time') + .first() + ) + if first_real_begin: + start_dt = first_real_begin.event_time + + end_dt: Optional[datetime] = intervention.end_time or None + if not end_dt: + # Recherche dans InterventionTimeLine + terminal_statuses = ['finished', 'processed', 'validated', 'invoiced', 'closed'] + events_qs = intervention.events.filter(to_status__in=terminal_statuses) + if start_dt: + events_qs = events_qs.filter(event_time__gte=start_dt) + first_terminal = events_qs.order_by('event_time').first() + if first_terminal: + end_dt = first_terminal.event_time + else: + # Recherche dans InterventionRealizationTimeLine + first_real_end = ( + intervention.realized_events + .filter(event_type='real_end') + .order_by('event_time') + .first() + ) + if first_real_end: + end_dt = first_real_end.event_time + + return start_dt, end_dt + + +def get_sequenced_intervention_documents(intervention) -> Dict[str, Any]: + """ + Organise les documents et photos de l'intervention en 3 blocs temporels : + 1. before_work : Avant réalisation (préparation, constat, signalement) + 2. during_work : Exécution du chantier (pendant les travaux) + 3. after_work : Après réalisation (contrôle, levée de réserves, clôture) + + Retourne un dictionnaire : + { + 'blocks': [ + { + 'key': 'before_work', + 'title': '...', + 'subtitle': '...', + 'icon': '...', + 'badge_class': '...', + 'period_display': '...', + 'modal_id': '...', + 'documents': [...], + 'count': int, + }, + ... + ], + 'total_count': int, + 'has_documents': bool, + 'start_dt': datetime|None, + 'end_dt': datetime|None, + } + """ + start_dt, end_dt = get_intervention_milestones(intervention) + + docs = list( + intervention.documents + .all() + .select_related('uploaded_by') + .order_by('upload_date', 'id') + ) + + before_docs: List[Any] = [] + during_docs: List[Any] = [] + after_docs: List[Any] = [] + + for doc in docs: + doc_dt = doc.upload_date or getattr(intervention, 'creation_time', None) + + if not start_dt: + # L'intervention n'a pas encore démarré : tout est classé en avant-réalisation + before_docs.append(doc) + elif not doc_dt: + before_docs.append(doc) + elif doc_dt < start_dt: + before_docs.append(doc) + else: + # doc_dt >= start_dt + if not end_dt: + during_docs.append(doc) + elif doc_dt <= end_dt: + during_docs.append(doc) + else: + after_docs.append(doc) + + # Libellés temporels formatés + def _fmt(dt: datetime) -> str: + loc_dt = timezone.localtime(dt) if timezone.is_aware(dt) else dt + return dj_format_date(loc_dt, 'd/m/Y H:i') + + if start_dt: + period_before = _("Jusqu'au %(date)s") % {'date': _fmt(start_dt)} + else: + period_before = _("Phase de préparation") + + if start_dt and end_dt: + period_during = _("Du %(start)s au %(end)s") % {'start': _fmt(start_dt), 'end': _fmt(end_dt)} + elif start_dt: + period_during = _("Depuis le %(date)s") % {'date': _fmt(start_dt)} + else: + period_during = "" + + if end_dt: + period_after = _("Après le %(date)s") % {'date': _fmt(end_dt)} + else: + period_after = "" + + blocks = [ + { + 'key': 'before_work', + 'title': _("Avant réalisation"), + 'subtitle': _("Préparation, constat initial et pièces administratives"), + 'icon': 'bi-clipboard-check', + 'badge_class': 'bg-secondary text-white', + 'period_display': period_before, + 'modal_id': 'interventionDocViewerModal_before', + 'documents': before_docs, + 'count': len(before_docs), + }, + { + 'key': 'during_work', + 'title': _("Exécution du chantier"), + 'subtitle': _("Travaux en cours et constat d'achèvement"), + 'icon': 'bi-tools', + 'badge_class': 'bg-primary text-white', + 'period_display': period_during, + 'modal_id': 'interventionDocViewerModal_during', + 'documents': during_docs, + 'count': len(during_docs), + }, + { + 'key': 'after_work', + 'title': _("Après réalisation"), + 'subtitle': _("Contrôle, levée de réserves et clôture"), + 'icon': 'bi-shield-check', + 'badge_class': 'bg-success text-white', + 'period_display': period_after, + 'modal_id': 'interventionDocViewerModal_after', + 'documents': after_docs, + 'count': len(after_docs), + }, + ] + + total_count = len(docs) + + return { + 'blocks': blocks, + 'total_count': total_count, + 'has_documents': total_count > 0, + 'start_dt': start_dt, + 'end_dt': end_dt, + } diff --git a/loko/interventions/templates/interventions/detail/card_documents.html b/loko/interventions/templates/interventions/detail/card_documents.html index dfaef8f..14bdc13 100644 --- a/loko/interventions/templates/interventions/detail/card_documents.html +++ b/loko/interventions/templates/interventions/detail/card_documents.html @@ -1,19 +1,47 @@ {% load document_viewer_tags i18n %}
-

{% translate "Documents" %}{% if photos_required %}{% translate "Photos obligatoires" %}{% endif %}

+

+ {% translate "Documents & Photos" %}{% if total_documents_count %}{{ total_documents_count }}{% endif %}{% if photos_required %}{% translate "Photos obligatoires" %}{% endif %} +

{% if 'documents' in allowed_update_fields %} - {% endif %}
-
-
-
- {% render_document_viewer intervention.documents.all modal_id="interventionDocViewerModal" allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %} -
+
+
+ {% if total_documents_count == 0 %} +
+ +

{% translate "Aucun document ou photo pour cette intervention." %}

+
+ {% else %} + {% for block in document_blocks %} + {% if block.count > 0 %} +
+
+
+ + {{ block.title }} + + + {{ block.count }} {% if block.count > 1 %}{% translate "fichiers" %}{% else %}{% translate "fichier" %}{% endif %} + +
+ {% if block.period_display %} + + {{ block.period_display }} + + {% endif %} +
+ {% render_document_viewer block.documents modal_id=block.modal_id allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %} +
+ {% endif %} + {% endfor %} + {% endif %}
diff --git a/loko/interventions/templates/interventions/intervention_detail_mobile.html b/loko/interventions/templates/interventions/intervention_detail_mobile.html index 2012cad..dfa42b1 100644 --- a/loko/interventions/templates/interventions/intervention_detail_mobile.html +++ b/loko/interventions/templates/interventions/intervention_detail_mobile.html @@ -609,47 +609,78 @@
- +
- {% translate "Documents" %} + + {% translate "Documents & Photos" %} + + {% if total_documents_count %} + {{ total_documents_count }} + {% endif %}
{% if 'documents' in allowed_update_fields %}
{% endif %} -
- {% for document in intervention.documents.all %} -
-
- {% if 'documents' in allowed_update_fields and document.id in deletable_document_ids %} - - {% endif %} - {% private_file_url document 'file' as document_file_url %} - -
- {% if document.thumbnail %} - {% private_file_url document 'thumbnail' as document_thumbnail_url %} - {{ document.filename }} - {% else %} - {{ document.filename }} + - {% endfor %} +
+ {% for document in block.documents %} +
+
+ {% if 'documents' in allowed_update_fields and document.id in deletable_document_ids %} + + {% endif %} + {% private_file_url document 'file' as document_file_url %} + +
+ {% if document.thumbnail %} + {% private_file_url document 'thumbnail' as document_thumbnail_url %} + {{ document.filename }} + {% else %} + {{ document.filename }} + {% endif %} +
+
+
+
+ {{ document.upload_date|date:"d/m/Y H:i" }}
+ {{ document.uploaded_by|short_name }} +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} + {% endif %}
diff --git a/loko/interventions/tests/test_document_sequencing.py b/loko/interventions/tests/test_document_sequencing.py new file mode 100644 index 0000000..d8ea8f5 --- /dev/null +++ b/loko/interventions/tests/test_document_sequencing.py @@ -0,0 +1,195 @@ +from datetime import timedelta +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone +from django.urls import reverse + +from common.models import UserConfig, Role, Thematic +from interventions.models import ( + Intervention, + InterventionDocument, + InterventionTimeLine, + InterventionRealizationTimeLine, +) +from interventions.services.document_sequencing import ( + get_intervention_milestones, + get_sequenced_intervention_documents, +) + + +class DocumentSequencingServiceTests(TestCase): + def setUp(self): + self.User = get_user_model() + self.user = self.User.objects.create_user(username='test_user', password='password') + self.thematic = Thematic.objects.create(code='road', name_fr='Route', name_nl='Weg') + self.now = timezone.now() + + self.intervention = Intervention.objects.create( + title='Test Intervention Séquençage', + status='in_preparation', + thematic=self.thematic, + ) + + def test_milestones_explicit_dates(self): + t_begin = self.now + t_end = self.now + timedelta(hours=3) + self.intervention.begin_time = t_begin + self.intervention.end_time = t_end + self.intervention.save() + + start_dt, end_dt = get_intervention_milestones(self.intervention) + self.assertEqual(start_dt, t_begin) + self.assertEqual(end_dt, t_end) + + def test_milestones_from_timeline_events(self): + t_begin = self.now + timedelta(hours=1) + t_end = self.now + timedelta(hours=4) + + InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=t_begin, + event_type='status_change', + from_status='assigned', + to_status='in_progress', + ) + InterventionTimeLine.objects.create( + intervention=self.intervention, + event_user=self.user, + event_time=t_end, + event_type='status_change', + from_status='in_progress', + to_status='finished', + ) + + start_dt, end_dt = get_intervention_milestones(self.intervention) + self.assertEqual(start_dt, t_begin) + self.assertEqual(end_dt, t_end) + + def test_sequenced_documents_three_blocks(self): + t_begin = self.now + timedelta(hours=2) + t_end = self.now + timedelta(hours=5) + self.intervention.begin_time = t_begin + self.intervention.end_time = t_end + self.intervention.save() + + dummy_file = SimpleUploadedFile("photo.jpg", b"fake image content", content_type="image/jpeg") + + # Doc 1: Avant réalisation (T - 1h) + doc_before = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_before.id).update(upload_date=self.now + timedelta(hours=1)) + + # Doc 2: Pendant exécution (T + 3h) + doc_during = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_during.id).update(upload_date=self.now + timedelta(hours=3)) + + # Doc 3: Après réalisation (T + 6h) + doc_after = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_after.id).update(upload_date=self.now + timedelta(hours=6)) + + result = get_sequenced_intervention_documents(self.intervention) + self.assertEqual(result['total_count'], 3) + self.assertTrue(result['has_documents']) + + blocks_map = {b['key']: b for b in result['blocks']} + + self.assertEqual(blocks_map['before_work']['count'], 1) + self.assertEqual(blocks_map['before_work']['documents'][0].id, doc_before.id) + + self.assertEqual(blocks_map['during_work']['count'], 1) + self.assertEqual(blocks_map['during_work']['documents'][0].id, doc_during.id) + + self.assertEqual(blocks_map['after_work']['count'], 1) + self.assertEqual(blocks_map['after_work']['documents'][0].id, doc_after.id) + + def test_sequenced_documents_before_start_only(self): + # Intervention pas encore démarrée + self.intervention.begin_time = None + self.intervention.end_time = None + self.intervention.save() + + dummy_file = SimpleUploadedFile("doc.pdf", b"pdf content", content_type="application/pdf") + doc = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + + result = get_sequenced_intervention_documents(self.intervention) + self.assertEqual(result['total_count'], 1) + + blocks_map = {b['key']: b for b in result['blocks']} + self.assertEqual(blocks_map['before_work']['count'], 1) + self.assertEqual(blocks_map['during_work']['count'], 0) + self.assertEqual(blocks_map['after_work']['count'], 0) + + +class DocumentSequencingViewsTests(TestCase): + def setUp(self): + self.User = get_user_model() + self.user = self.User.objects.create_user(username='admin_user', password='password') + self.role_admin = Role.objects.create(name='admin') + self.user_config = UserConfig.objects.create(user=self.user, is_intern=True) + self.user_config.roles.add(self.role_admin) + + self.thematic = Thematic.objects.create(code='road', name_fr='Route', name_nl='Weg') + self.now = timezone.now() + + self.intervention = Intervention.objects.create( + title='Intervention vue séquencée', + status='in_progress', + thematic=self.thematic, + begin_time=self.now - timedelta(hours=2), + ) + + dummy_file = SimpleUploadedFile("photo1.jpg", b"fake photo", content_type="image/jpeg") + doc1 = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc1.id).update(upload_date=self.now - timedelta(hours=3)) + + doc2 = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc2.id).update(upload_date=self.now - timedelta(hours=1)) + + self.client.force_login(self.user) + + def test_desktop_detail_view_contains_sequenced_blocks(self): + url = reverse('interventions:intervention_detail', args=[self.intervention.id]) + response = self.client.get(url + '?force_desktop=1') + self.assertEqual(response.status_code, 200) + self.assertIn('document_blocks', response.context) + self.assertEqual(response.context['total_documents_count'], 2) + + content = response.content.decode('utf-8') + self.assertIn('Avant réalisation', content) + self.assertIn('Exécution du chantier', content) + + def test_mobile_detail_view_contains_sequenced_blocks(self): + url = reverse('interventions:intervention_detail_mobile', args=[self.intervention.id]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertIn('document_blocks', response.context) + self.assertEqual(response.context['total_documents_count'], 2) + + content = response.content.decode('utf-8') + self.assertIn('Avant réalisation', content) + self.assertIn('Exécution du chantier', content) diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 6c831cb..7f37f14 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -62,6 +62,7 @@ from interventions.models import (InterventionSubscription, Intervention, Interv GuidedOperationTemplate, GuidedOperationData, GuidedOperationMedia, InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES, get_type_choices +from interventions.services.document_sequencing import get_sequenced_intervention_documents from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, LinkExistingInterventionsForm) @@ -741,7 +742,9 @@ def intervention_detail(request, intervention_id): # Discussion 'discussion_messages': discussion_messages, 'can_post_discussion_message': can_post_discussion_message, - # Document permissions + # Document permissions & sequenced blocks + 'document_blocks': get_sequenced_intervention_documents(intervention)['blocks'], + 'total_documents_count': intervention.documents.count(), 'can_delete_documents': 'documents' in allowed_update_fields, 'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]', # Guided operation template diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index b6576da..4f51e08 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -64,6 +64,7 @@ from interventions.models import (InterventionSubscription, Intervention, Interv InterventionPostPeriodQuantity, InterventionEmailLog, InterventionAssignment) from interventions import views from interventions.models import STATUS_CHOICES, STATUS_ORDERS, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PAUSE_REASON_CHOICES +from interventions.services.document_sequencing import get_sequenced_intervention_documents from interventions.views.forms import (InterventionForm, InterventionNoteForm, InterventionDocumentForm, InterventionOccupationForm, PreventiveInterventionForm, AssetsLinkForm, LinkExistingInterventionsForm) @@ -1074,6 +1075,8 @@ def intervention_detail_mobile(request, intervention_id): context = { 'intervention': intervention, + 'document_blocks': get_sequenced_intervention_documents(intervention)['blocks'], + 'total_documents_count': intervention.documents.count(), 'assets': assets, 'notes': notes, 'can_edit_intervention': can_edit_mobile,