From 022217218f981e363e002c5f75065478b4a9210f Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 31 Aug 2026 13:39:52 +0200 Subject: [PATCH] feat: introduce 15-minute tolerance for intervention document sequencing and photo validation logic --- .../services/document_sequencing.py | 27 ++++++--- .../static/interventions/timeline.js | 3 +- .../intervention_detail_mobile.html | 3 +- .../tests/test_document_sequencing.py | 59 +++++++++++++++++++ loko/interventions/views/workflow.py | 3 +- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/loko/interventions/services/document_sequencing.py b/loko/interventions/services/document_sequencing.py index 4924b39..b0bcc01 100644 --- a/loko/interventions/services/document_sequencing.py +++ b/loko/interventions/services/document_sequencing.py @@ -1,9 +1,11 @@ from typing import List, Dict, Any, Optional -from datetime import datetime +from datetime import datetime, timedelta from django.utils.translation import gettext as _ from django.utils import timezone from django.utils.dateformat import format as dj_format_date +DEFAULT_TOLERANCE_MINUTES = 15 + def get_intervention_milestones(intervention) -> tuple[Optional[datetime], Optional[datetime]]: """ @@ -68,13 +70,20 @@ def get_intervention_milestones(intervention) -> tuple[Optional[datetime], Optio return start_dt, end_dt -def get_sequenced_intervention_documents(intervention) -> Dict[str, Any]: +def get_sequenced_intervention_documents( + intervention, + tolerance_minutes: int = DEFAULT_TOLERANCE_MINUTES, +) -> 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 de l'intervention (pendant les travaux) 3. after_work : Après réalisation (contrôle, levée de réserves, clôture) + Une tolérance temporelle (par défaut 15 minutes) est appliquée autour des jalons + pour tenir compte des photos ajoutées juste avant de changer le statut ou juste après + avoir terminé. + Retourne un dictionnaire : { 'blocks': [ @@ -99,6 +108,10 @@ def get_sequenced_intervention_documents(intervention) -> Dict[str, Any]: """ start_dt, end_dt = get_intervention_milestones(intervention) + # Seuils effectifs avec tolérance temporelle + start_threshold = (start_dt - timedelta(minutes=tolerance_minutes)) if start_dt else None + end_threshold = (end_dt + timedelta(minutes=tolerance_minutes)) if end_dt else None + docs = list( intervention.documents .all() @@ -113,18 +126,18 @@ def get_sequenced_intervention_documents(intervention) -> Dict[str, Any]: for doc in docs: doc_dt = doc.upload_date or getattr(intervention, 'creation_time', None) - if not start_dt: + if not start_threshold: # 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: + elif doc_dt < start_threshold: before_docs.append(doc) else: - # doc_dt >= start_dt - if not end_dt: + # doc_dt >= start_threshold + if not end_threshold: during_docs.append(doc) - elif doc_dt <= end_dt: + elif doc_dt <= end_threshold: during_docs.append(doc) else: after_docs.append(doc) diff --git a/loko/interventions/static/interventions/timeline.js b/loko/interventions/static/interventions/timeline.js index bb8046c..d5aba28 100644 --- a/loko/interventions/static/interventions/timeline.js +++ b/loko/interventions/static/interventions/timeline.js @@ -574,8 +574,9 @@ $('#statusTimeline').on('click', 'button', function () { let hasDocuments = false; if (docContainer) { const cards = Array.from(docContainer.querySelectorAll('[id^="document-card-"], [id^="doc-thumb-"]')); + const toleranceMs = 15 * 60 * 1000; const since = (typeof photosRequiredSince !== 'undefined' && photosRequiredSince) - ? new Date(photosRequiredSince).getTime() + ? (new Date(photosRequiredSince).getTime() - toleranceMs) : null; if (since) { hasDocuments = cards.some(card => { diff --git a/loko/interventions/templates/interventions/intervention_detail_mobile.html b/loko/interventions/templates/interventions/intervention_detail_mobile.html index dfa42b1..4c16d8a 100644 --- a/loko/interventions/templates/interventions/intervention_detail_mobile.html +++ b/loko/interventions/templates/interventions/intervention_detail_mobile.html @@ -4091,7 +4091,8 @@ document.addEventListener('DOMContentLoaded', function() { const photosRequiredSince = "{{ photos_required_since }}"; function hasPhotos() { - const since = photosRequiredSince ? new Date(photosRequiredSince).getTime() : null; + const toleranceMs = 15 * 60 * 1000; + const since = photosRequiredSince ? (new Date(photosRequiredSince).getTime() - toleranceMs) : null; // Documents déjà enregistrés dans la DB const existingCards = Array.from(document.querySelectorAll('#documentsContainer [id^="document-card-"], #documentsContainer [id^="doc-thumb-"]')); if (existingCards.length > 0) { diff --git a/loko/interventions/tests/test_document_sequencing.py b/loko/interventions/tests/test_document_sequencing.py index d80d5cc..f8e01f7 100644 --- a/loko/interventions/tests/test_document_sequencing.py +++ b/loko/interventions/tests/test_document_sequencing.py @@ -136,6 +136,65 @@ class DocumentSequencingServiceTests(TestCase): self.assertEqual(blocks_map['during_work']['count'], 0) self.assertEqual(blocks_map['after_work']['count'], 0) + def test_sequenced_documents_with_tolerance(self): + # Début à T+2h, Fin à T+4h + t_begin = self.now + timedelta(hours=2) + t_end = self.now + timedelta(hours=4) + self.intervention.begin_time = t_begin + self.intervention.end_time = t_end + self.intervention.save() + + dummy_file = SimpleUploadedFile("photo.jpg", b"fake photo", content_type="image/jpeg") + + # Doc A : 5 min avant le début réel (tolérance 15 min -> classé dans Exécution) + doc_just_before = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_just_before.id).update(upload_date=t_begin - timedelta(minutes=5)) + + # Doc B : 10 min après la fin réelle (tolérance 15 min -> classé dans Exécution) + doc_just_after = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_just_after.id).update(upload_date=t_end + timedelta(minutes=10)) + + # Doc C : 1h avant le début réel (hors tolérance -> Avant réalisation) + doc_early = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_early.id).update(upload_date=t_begin - timedelta(hours=1)) + + # Doc D : 1h après la fin réelle (hors tolérance -> Après réalisation) + doc_late = InterventionDocument.objects.create( + intervention=self.intervention, + file=dummy_file, + uploaded_by=self.user, + ) + InterventionDocument.objects.filter(id=doc_late.id).update(upload_date=t_end + timedelta(hours=1)) + + result = get_sequenced_intervention_documents(self.intervention) + blocks_map = {b['key']: b for b in result['blocks']} + + # Doc C dans Avant réalisation + self.assertEqual(blocks_map['before_work']['count'], 1) + self.assertEqual(blocks_map['before_work']['documents'][0].id, doc_early.id) + + # Docs A et B dans Exécution de l'intervention (grâce à la tolérance) + self.assertEqual(blocks_map['during_work']['count'], 2) + during_ids = [d.id for d in blocks_map['during_work']['documents']] + self.assertIn(doc_just_before.id, during_ids) + self.assertIn(doc_just_after.id, during_ids) + + # Doc D dans Après réalisation + self.assertEqual(blocks_map['after_work']['count'], 1) + self.assertEqual(blocks_map['after_work']['documents'][0].id, doc_late.id) + class DocumentSequencingViewsTests(TestCase): def setUp(self): diff --git a/loko/interventions/views/workflow.py b/loko/interventions/views/workflow.py index 081ed13..a05098e 100644 --- a/loko/interventions/views/workflow.py +++ b/loko/interventions/views/workflow.py @@ -408,7 +408,8 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason= if _is_photo_required_for_user(user, intervention): photos_since = _get_photos_required_since(intervention) if photos_since: - has_photos = intervention.documents.filter(upload_date__gte=photos_since).exists() + tolerance_since = photos_since - timedelta(minutes=15) + has_photos = intervention.documents.filter(upload_date__gte=tolerance_since).exists() else: has_photos = intervention.documents.exists() if not has_photos: