feat: introduce 15-minute tolerance for intervention document sequencing and photo validation logic

This commit is contained in:
kdeterme 2026-08-31 13:39:52 +02:00
parent 14ab4d15df
commit 022217218f
5 changed files with 85 additions and 10 deletions

View file

@ -1,9 +1,11 @@
from typing import List, Dict, Any, Optional 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.translation import gettext as _
from django.utils import timezone from django.utils import timezone
from django.utils.dateformat import format as dj_format_date from django.utils.dateformat import format as dj_format_date
DEFAULT_TOLERANCE_MINUTES = 15
def get_intervention_milestones(intervention) -> tuple[Optional[datetime], Optional[datetime]]: 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 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 : Organise les documents et photos de l'intervention en 3 blocs temporels :
1. before_work : Avant réalisation (préparation, constat, signalement) 1. before_work : Avant réalisation (préparation, constat, signalement)
2. during_work : Exécution de l'intervention (pendant les travaux) 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) 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 : Retourne un dictionnaire :
{ {
'blocks': [ 'blocks': [
@ -99,6 +108,10 @@ def get_sequenced_intervention_documents(intervention) -> Dict[str, Any]:
""" """
start_dt, end_dt = get_intervention_milestones(intervention) 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( docs = list(
intervention.documents intervention.documents
.all() .all()
@ -113,18 +126,18 @@ def get_sequenced_intervention_documents(intervention) -> Dict[str, Any]:
for doc in docs: for doc in docs:
doc_dt = doc.upload_date or getattr(intervention, 'creation_time', None) 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 # L'intervention n'a pas encore démarré : tout est classé en avant-réalisation
before_docs.append(doc) before_docs.append(doc)
elif not doc_dt: elif not doc_dt:
before_docs.append(doc) before_docs.append(doc)
elif doc_dt < start_dt: elif doc_dt < start_threshold:
before_docs.append(doc) before_docs.append(doc)
else: else:
# doc_dt >= start_dt # doc_dt >= start_threshold
if not end_dt: if not end_threshold:
during_docs.append(doc) during_docs.append(doc)
elif doc_dt <= end_dt: elif doc_dt <= end_threshold:
during_docs.append(doc) during_docs.append(doc)
else: else:
after_docs.append(doc) after_docs.append(doc)

View file

@ -574,8 +574,9 @@ $('#statusTimeline').on('click', 'button', function () {
let hasDocuments = false; let hasDocuments = false;
if (docContainer) { if (docContainer) {
const cards = Array.from(docContainer.querySelectorAll('[id^="document-card-"], [id^="doc-thumb-"]')); const cards = Array.from(docContainer.querySelectorAll('[id^="document-card-"], [id^="doc-thumb-"]'));
const toleranceMs = 15 * 60 * 1000;
const since = (typeof photosRequiredSince !== 'undefined' && photosRequiredSince) const since = (typeof photosRequiredSince !== 'undefined' && photosRequiredSince)
? new Date(photosRequiredSince).getTime() ? (new Date(photosRequiredSince).getTime() - toleranceMs)
: null; : null;
if (since) { if (since) {
hasDocuments = cards.some(card => { hasDocuments = cards.some(card => {

View file

@ -4091,7 +4091,8 @@ document.addEventListener('DOMContentLoaded', function() {
const photosRequiredSince = "{{ photos_required_since }}"; const photosRequiredSince = "{{ photos_required_since }}";
function hasPhotos() { 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 // Documents déjà enregistrés dans la DB
const existingCards = Array.from(document.querySelectorAll('#documentsContainer [id^="document-card-"], #documentsContainer [id^="doc-thumb-"]')); const existingCards = Array.from(document.querySelectorAll('#documentsContainer [id^="document-card-"], #documentsContainer [id^="doc-thumb-"]'));
if (existingCards.length > 0) { if (existingCards.length > 0) {

View file

@ -136,6 +136,65 @@ class DocumentSequencingServiceTests(TestCase):
self.assertEqual(blocks_map['during_work']['count'], 0) self.assertEqual(blocks_map['during_work']['count'], 0)
self.assertEqual(blocks_map['after_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): class DocumentSequencingViewsTests(TestCase):
def setUp(self): def setUp(self):

View file

@ -408,7 +408,8 @@ def attempt_status_update(request, intervention, user, new_status, pause_reason=
if _is_photo_required_for_user(user, intervention): if _is_photo_required_for_user(user, intervention):
photos_since = _get_photos_required_since(intervention) photos_since = _get_photos_required_since(intervention)
if photos_since: 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: else:
has_photos = intervention.documents.exists() has_photos = intervention.documents.exists()
if not has_photos: if not has_photos: