diff --git a/loko/interventions/tests/test_general.py b/loko/interventions/tests/test_general.py index c292c33..6b682d4 100644 --- a/loko/interventions/tests/test_general.py +++ b/loko/interventions/tests/test_general.py @@ -1,5 +1,6 @@ import json -from datetime import timedelta +from datetime import datetime, timedelta, date +from django.utils import timezone from unittest import mock from django.test import TestCase @@ -1773,6 +1774,182 @@ class TrafficlightsReprogrammingTypeTests(TestCase): self.assertEqual(self.itv_struct.type, 'fixing') +class InterventionEndDateFilteringTests(TestCase): + def setUp(self): + self.User = get_user_model() + self.user = self.User.objects.create_user(username='date-filter-user', password='pwd') + self.role_admin, _ = Role.objects.get_or_create(name='admin') + + self.config = UserConfig.objects.create(user=self.user, is_intern=True) + self.config.roles.add(self.role_admin) + + self.thematic = Thematic.objects.create( + code='roads_date_test', + name_fr='Voirie Date Test', + name_nl='Wegen Date Test', + allow_planning_without_contract=True + ) + UserThematics.objects.create( + user_config=self.config, + thematic=self.thematic, + can_view_interventions=True, + can_edit_interventions=True, + ) + + from interventions.models import Intervention, InterventionTimeLine + # itv1: passed to 'finished' on 2026-08-15 (end_time is None) + self.itv1 = Intervention.objects.create( + title='Finished on Aug 15', + status='finished', + thematic=self.thematic, + created_by=self.user, + ) + InterventionTimeLine.objects.create( + intervention=self.itv1, + event_user=self.user, + event_time=timezone.make_aware(datetime(2026, 8, 15, 14, 30)), + event_type='status_change', + from_status='in_progress', + to_status='finished', + ) + + # itv2: passed to 'finished' on 2026-08-25 (end_time is None) + self.itv2 = Intervention.objects.create( + title='Finished on Aug 25', + status='finished', + thematic=self.thematic, + created_by=self.user, + ) + InterventionTimeLine.objects.create( + intervention=self.itv2, + event_user=self.user, + event_time=timezone.make_aware(datetime(2026, 8, 25, 10, 0)), + event_type='status_change', + from_status='in_progress', + to_status='finished', + ) + + # itv3: not finished, has planned_end_time on 2026-08-15 + self.itv3 = Intervention.objects.create( + title='Planned Aug 15', + status='in_progress', + thematic=self.thematic, + planned_end_time=timezone.make_aware(datetime(2026, 8, 15, 18, 0)), + created_by=self.user, + ) + + # itv4: passed to 'finished' on 2026-08-15, but has an old/different end_time on 2026-07-01 + self.itv4 = Intervention.objects.create( + title='Finished Aug 15 with old end_time', + status='validated', + end_time=timezone.make_aware(datetime(2026, 7, 1, 9, 0)), + thematic=self.thematic, + created_by=self.user, + ) + InterventionTimeLine.objects.create( + intervention=self.itv4, + event_user=self.user, + event_time=timezone.make_aware(datetime(2026, 8, 15, 16, 0)), + event_type='status_change', + from_status='in_progress', + to_status='finished', + ) + + def test_apply_intervention_filters_uses_finished_timeline_date(self): + from interventions.views import apply_intervention_filters_from_querydict + from django.http import QueryDict + + qs = Intervention.objects.filter(thematic=self.thematic) + + # Range 2026-08-10 to 2026-08-20 + params = QueryDict('end_date_min=2026-08-10&end_date_max=2026-08-20') + filtered = apply_intervention_filters_from_querydict(qs, params, user=self.user) + + # itv1 (finished Aug 15) and itv4 (finished Aug 15) and itv3 (planned Aug 15) should match + self.assertIn(self.itv1, filtered) + self.assertIn(self.itv3, filtered) + self.assertIn(self.itv4, filtered) + # itv2 (finished Aug 25) should NOT match + self.assertNotIn(self.itv2, filtered) + + def test_apply_intervention_filters_prefers_finished_timeline_over_different_end_time(self): + from interventions.views import apply_intervention_filters_from_querydict + from django.http import QueryDict + + qs = Intervention.objects.filter(thematic=self.thematic) + + # Range in July: itv4 has end_time in July but its finished timeline date is in August + params_july = QueryDict('end_date_min=2026-07-01&end_date_max=2026-07-31') + filtered_july = apply_intervention_filters_from_querydict(qs, params_july, user=self.user) + self.assertNotIn(self.itv4, filtered_july) + + # Range in August: itv4 must be included because it was finished on Aug 15 + params_august = QueryDict('end_date_min=2026-08-01&end_date_max=2026-08-31') + filtered_august = apply_intervention_filters_from_querydict(qs, params_august, user=self.user) + self.assertIn(self.itv4, filtered_august) + + def test_interventions_geojson_end_date_filter(self): + self.client.force_login(self.user) + url = reverse('interventions:interventions_geojson') + + # Filter range: 2026-08-10 to 2026-08-20 + response = self.client.get(url, { + 'thematic': 'roads_date_test', + 'end_date_min': '2026-08-10', + 'end_date_max': '2026-08-20', + 'status': 'in_progress,finished,validated', + }) + self.assertEqual(response.status_code, 200) + data = response.json() + feature_ids = [f['id'] for f in data['features']] + + self.assertIn(self.itv1.id, feature_ids) + self.assertIn(self.itv3.id, feature_ids) + self.assertIn(self.itv4.id, feature_ids) + self.assertNotIn(self.itv2.id, feature_ids) + + def test_multiple_finished_transitions_uses_latest_date(self): + from interventions.models import Intervention, InterventionTimeLine + from interventions.views import apply_intervention_filters_from_querydict + from django.http import QueryDict + + # Create an intervention finished in June, then reopened, then finished in August + itv_reopened = Intervention.objects.create( + title='Finished then Reopened then Finished', + status='finished', + thematic=self.thematic, + created_by=self.user, + ) + InterventionTimeLine.objects.create( + intervention=itv_reopened, + event_user=self.user, + event_time=timezone.make_aware(datetime(2026, 6, 10, 10, 0)), + event_type='status_change', + from_status='in_progress', + to_status='finished', + ) + InterventionTimeLine.objects.create( + intervention=itv_reopened, + event_user=self.user, + event_time=timezone.make_aware(datetime(2026, 8, 20, 11, 0)), + event_type='status_change', + from_status='to_be_corrected', + to_status='finished', + ) + + qs = Intervention.objects.filter(pk=itv_reopened.pk) + + # In June: should NOT match because latest finished is in August + params_june = QueryDict('end_date_min=2026-06-01&end_date_max=2026-06-30') + self.assertNotIn(itv_reopened, apply_intervention_filters_from_querydict(qs, params_june, user=self.user)) + + # In August: should match because latest finished is Aug 20 + params_august = QueryDict('end_date_min=2026-08-01&end_date_max=2026-08-31') + self.assertIn(itv_reopened, apply_intervention_filters_from_querydict(qs, params_august, user=self.user)) + + + + diff --git a/loko/interventions/views/detail.py b/loko/interventions/views/detail.py index 7e262a7..6c831cb 100644 --- a/loko/interventions/views/detail.py +++ b/loko/interventions/views/detail.py @@ -430,10 +430,15 @@ def intervention_detail(request, intervention_id): .distinct() ) # Récupérer les interventions des 3 derniers mois + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] related_interventions = ( Intervention.objects .annotate( effective_end=Coalesce( + Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', diff --git a/loko/interventions/views/general.py b/loko/interventions/views/general.py index d95be85..f125ee4 100644 --- a/loko/interventions/views/general.py +++ b/loko/interventions/views/general.py @@ -575,13 +575,18 @@ def interventions_geojson(request): if preparation_states: qs = qs.filter(prep_state__in=preparation_states) + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] + # --- annotate once (DB does geometry + dates) if date_type == 'planned': effective_begin = Coalesce('begin_time', 'planned_begin_time', output_field=DateTimeField()) - effective_end = Coalesce('end_time', 'planned_end_time', output_field=DateTimeField()) + effective_end = Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', output_field=DateTimeField()) else: effective_begin = Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField()) - effective_end = Coalesce('end_time', 'planned_end_time', 'expected_end_time', output_field=DateTimeField()) + effective_end = Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', output_field=DateTimeField()) qs = qs.annotate( geom_geojson=AsGeoJSON(Transform(Centroid('geom'), 4326)), diff --git a/loko/interventions/views/helpers.py b/loko/interventions/views/helpers.py index 3e4253a..fd0b9dc 100644 --- a/loko/interventions/views/helpers.py +++ b/loko/interventions/views/helpers.py @@ -1789,13 +1789,18 @@ def apply_intervention_filters_from_querydict(qs, params, user=None): if followed_only == '1' and user and user.is_authenticated: qs = qs.filter(subscriptions__user=user) + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] + # Date filters (same logic as interventions_geojson but without effective_begin annotation) if date_type == 'planned': eff_begin = Coalesce('begin_time', 'planned_begin_time', output_field=DateTimeField()) - eff_end = Coalesce('end_time', 'planned_end_time', output_field=DateTimeField()) + eff_end = Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', output_field=DateTimeField()) else: eff_begin = Coalesce('begin_time', 'planned_begin_time', 'expected_begin_time', output_field=DateTimeField()) - eff_end = Coalesce('end_time', 'planned_end_time', 'expected_end_time', output_field=DateTimeField()) + eff_end = Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', output_field=DateTimeField()) if begin_date_min or begin_date_max or end_date_min or end_date_max: qs = qs.annotate(_eff_begin=eff_begin, _eff_end=eff_end) diff --git a/loko/interventions/views/mobile.py b/loko/interventions/views/mobile.py index 3b72cb3..b6576da 100644 --- a/loko/interventions/views/mobile.py +++ b/loko/interventions/views/mobile.py @@ -926,10 +926,15 @@ def intervention_detail_mobile(request, intervention_id): .distinct() ) # Récupérer les interventions des 3 derniers mois + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] related_interventions = ( Intervention.objects .annotate( effective_end = Coalesce( + Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', diff --git a/loko/interventions/views/notes_docs.py b/loko/interventions/views/notes_docs.py index c01d9c2..17c42f3 100644 --- a/loko/interventions/views/notes_docs.py +++ b/loko/interventions/views/notes_docs.py @@ -510,10 +510,14 @@ def linked_interventions_summary(request): # Interventions récentes terminées (3 derniers mois) three_months_ago = timezone.now() - timedelta(days=90) + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] total_recent = base_qs.filter( status__in=LINKED_RECENT_STATUSES ).annotate( - effective_end=Coalesce('end_time', 'planned_end_time', 'expected_end_time', 'planned_begin_time', 'creation_time', output_field=DateTimeField()) + effective_end=Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', 'planned_begin_time', 'creation_time', output_field=DateTimeField()) ).filter( effective_end__gte=three_months_ago ).count() @@ -522,9 +526,14 @@ def linked_interventions_summary(request): def intervention_summary_modal(request, intervention_id): + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] intervention = get_object_or_404( Intervention.objects.annotate( effective_end=Coalesce( + Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', @@ -612,10 +621,14 @@ def linked_interventions_list(request): if list_type == 'recent': # Interventions récentes terminées (3 derniers mois) three_months_ago = timezone.now() - timedelta(days=90) + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] filtered_qs = base_qs.filter( status__in=LINKED_RECENT_STATUSES ).annotate( - effective_end=Coalesce('end_time', 'planned_end_time', 'expected_end_time', 'planned_begin_time', 'creation_time', output_field=DateTimeField()) + effective_end=Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', 'planned_begin_time', 'creation_time', output_field=DateTimeField()) ).filter( effective_end__gte=three_months_ago ) @@ -722,12 +735,16 @@ def linked_interventions_geojson(request): ).values('id', 'code', 'title', 'status', 'lon', 'lat')[:500] # Recent (terminated, within 3 months) + _latest_finished_sq = InterventionTimeLine.objects.filter( + intervention=OuterRef('pk'), + to_status='finished', + ).order_by('-event_time').values('event_time')[:1] recent_qs = base_qs.filter( status__in=LINKED_RECENT_STATUSES, lon__isnull=False, lat__isnull=False, ).annotate( - effective_end=Coalesce('end_time', 'planned_end_time', 'expected_end_time', 'planned_begin_time', 'creation_time', output_field=DateTimeField()) + effective_end=Coalesce(Subquery(_latest_finished_sq), 'end_time', 'planned_end_time', 'expected_end_time', 'planned_begin_time', 'creation_time', output_field=DateTimeField()) ).filter(effective_end__gte=three_months_ago).values('id', 'code', 'title', 'status', 'lon', 'lat')[:500] features = []