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 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)

View file

@ -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 => {

View file

@ -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) {

View file

@ -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):

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):
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: