diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py index 619bc99..b443ba1 100644 --- a/loko/contracts/services_teams.py +++ b/loko/contracts/services_teams.py @@ -141,25 +141,35 @@ def set_member_team_schedule( Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) ).update(leave_date=prev_leave) + # Trouver le prochain jalon futur pour cette équipe + next_hist = ( + CompanyMemberHistory.objects + .filter(member=member, team_id=team_id, join_date__gt=target_date) + .order_by('join_date') + .first() + ) + next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None + # Vérifier s'il existe déjà un enregistrement pour cette équipe débutant à target_date - existing_hist = CompanyMemberHistory.objects.filter( - member=member, - team_id=team_id, - join_date=target_date, - ).first() + existing_hists = list( + CompanyMemberHistory.objects.filter( + member=member, + team_id=team_id, + join_date=target_date, + ).order_by('id') + ) - if existing_hist: - new_history = existing_hist + if existing_hists: + new_history = existing_hists[0] + # Nettoyer les doublons éventuels + if len(existing_hists) > 1: + for extra in existing_hists[1:]: + extra.delete() + # S'assurer que leave_date est cohérent avec le prochain jalon futur + if new_history.leave_date != next_leave_bound: + new_history.leave_date = next_leave_bound + new_history.save(update_fields=['leave_date']) else: - # Trouver le prochain jalon futur pour cette équipe - next_hist = ( - CompanyMemberHistory.objects - .filter(member=member, team_id=team_id, join_date__gt=target_date) - .order_by('join_date') - .first() - ) - next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None - new_history = CompanyMemberHistory.objects.create( member=member, team_id=team_id, diff --git a/loko/contracts/signals.py b/loko/contracts/signals.py index 04930d6..ffdfb78 100644 --- a/loko/contracts/signals.py +++ b/loko/contracts/signals.py @@ -45,6 +45,7 @@ def update_order_total_on_quote_item_delete(sender, instance, **kwargs): order.save(update_fields=['total_amount']) +from django.db.models import Q from django.db.models.signals import m2m_changed from contracts.models import CompanyMember, CompanyMemberHistory from django.utils import timezone @@ -57,44 +58,65 @@ def update_member_team_history(sender, instance, action, reverse, pk_set, **kwar # instance est CompanyMember, pk_set contient les team_id if action == "post_add" and pk_set: for team_id in pk_set: - CompanyMemberHistory.objects.get_or_create( + has_active = CompanyMemberHistory.objects.filter( member=instance, team_id=team_id, - leave_date__isnull=True, - defaults={'join_date': today} - ) + join_date__lte=today, + ).filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=today) + ).exists() + if not has_active: + CompanyMemberHistory.objects.create( + member=instance, + team_id=team_id, + join_date=today, + leave_date=None, + ) elif action == "post_remove" and pk_set: for team_id in pk_set: CompanyMemberHistory.objects.filter( member=instance, team_id=team_id, - leave_date__isnull=True + join_date__lte=today, + leave_date__isnull=True, ).update(leave_date=today) elif action == "pre_clear": CompanyMemberHistory.objects.filter( member=instance, - leave_date__isnull=True + join_date__lte=today, + leave_date__isnull=True, ).update(leave_date=today) else: # instance est CompanyTeam, pk_set contient les member_id if action == "post_add" and pk_set: for member_id in pk_set: - CompanyMemberHistory.objects.get_or_create( + has_active = CompanyMemberHistory.objects.filter( member_id=member_id, team=instance, - leave_date__isnull=True, - defaults={'join_date': today} - ) + join_date__lte=today, + ).filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=today) + ).exists() + if not has_active: + CompanyMemberHistory.objects.create( + member_id=member_id, + team=instance, + join_date=today, + leave_date=None, + ) elif action == "post_remove" and pk_set: for member_id in pk_set: CompanyMemberHistory.objects.filter( member_id=member_id, team=instance, - leave_date__isnull=True + join_date__lte=today, + leave_date__isnull=True, ).update(leave_date=today) elif action == "pre_clear": CompanyMemberHistory.objects.filter( team=instance, - leave_date__isnull=True + join_date__lte=today, + leave_date__isnull=True, ).update(leave_date=today) + diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index 13f064e..80bc724 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -483,13 +483,81 @@ class TeamPlanningAjaxViewsTests(TestCase): self.assertEqual(self.member_alice.teams.count(), 1) self.assertEqual(self.member_alice.teams.first(), self.team_b) - def test_can_view_member_history_in_teams_views(self): - resp = self.client.get(reverse('contracts:teams_index')) + def test_m2m_signal_does_not_corrupt_future_histories(self): + """Vérifier que les signaux m2m_changed à aujourd'hui ne corrompent pas les historiques futurs.""" + today = timezone.now().date() + future_date = today + timedelta(days=5) + + member = CompanyMember.objects.create(name="Test Member", user=self.user) + # Créer une affectation future avec leave_date=None + future_hist = CompanyMemberHistory.objects.create( + member=member, + team=self.team_a, + join_date=future_date, + leave_date=None, + ) + + # Modifier les équipes du membre aujourd'hui (déclenche m2m_changed pre_clear / post_add) + member.teams.set([self.team_b]) + + future_hist.refresh_from_db() + # La leave_date de l'historique futur ne doit PAS avoir été écrasée par today ! + self.assertIsNone(future_hist.leave_date) + + def test_reproduce_and_fix_add_member_with_corrupted_leave_date(self): + """ + Tester le scénario réel : + - Un membre a un historique existant au 14 avec leave_date antérieure (ex: corrupted 11). + - Un jalon au 15 existe. + - L'ajout via l'API AJAX doit réparer leave_date à 14 et faire apparaître le membre dans la modale et la composition. + """ + today = timezone.now().date() + date_14 = today + timedelta(days=2) + date_15 = today + timedelta(days=3) + + member_sebastien = CompanyMember.objects.create(name="Sébastien", user=self.user) + set_member_team_schedule(member_sebastien, self.team_a.id, date_14, action='move') + + member_olivier = CompanyMember.objects.create(name="Olivier Buissette", user=self.user) + # Jalon au 15 + CompanyMemberHistory.objects.create( + member=member_olivier, + team=self.team_a, + join_date=date_15, + leave_date=None, + ) + # Historique corrompu au 14 (leave_date < join_date) + corrupted_hist = CompanyMemberHistory.objects.create( + member=member_olivier, + team=self.team_a, + join_date=date_14, + leave_date=today - timedelta(days=1), + ) + + # Avant : Olivier n'est pas dans la composition du 14 car leave_date < 14 + comp_before = get_teams_composition_for_period({self.team_a.id}, date_14, date_14) + self.assertNotIn(member_olivier.id, [m['id'] for m in comp_before.get((self.team_a.id, date_14), [])]) + + # L'utilisateur l'ajoute le 14 via l'API AJAX + url = reverse('contracts:team_planning_assign_member_ajax') + resp = self.client.post(url, data=json.dumps({ + 'member_id': member_olivier.id, + 'team_id': self.team_a.id, + 'date': date_14.isoformat(), + 'action': 'add', + }), content_type='application/json') self.assertEqual(resp.status_code, 200) - self.assertTrue(resp.context['can_view_member_history']) - data_resp = self.client.get(reverse('contracts:team_planning_data_ajax')) - self.assertEqual(data_resp.status_code, 200) - self.assertTrue(data_resp.json()['can_view_member_history']) + corrupted_hist.refresh_from_db() + # leave_date doit être réparée et bornée au 14 (date_15 - 1 jour) + self.assertEqual(corrupted_hist.leave_date, date_14) + + # Vérifier dans l'API de composition journalière (modale) + url_modal = reverse('interventions:team_daily_members_ajax', args=[self.team_a.id]) + resp_modal = self.client.get(url_modal, {'date': date_14.isoformat()}) + self.assertEqual(resp_modal.status_code, 200) + modal_member_ids = [m['id'] for m in resp_modal.json()['current_members']] + self.assertIn(member_olivier.id, modal_member_ids) + diff --git a/loko/contracts/tests.py b/loko/contracts/tests.py index 53147c2..8bf0da5 100644 --- a/loko/contracts/tests.py +++ b/loko/contracts/tests.py @@ -97,6 +97,9 @@ class TeamViewsAjaxTests(TestCase): self.member = CompanyMember.objects.create(name="Tech A", user=self.user) self.member.teams.add(self.team_a) + today = timezone.now().date() + yesterday = today - timezone.timedelta(days=1) + CompanyMemberHistory.objects.filter(member=self.member, team=self.team_a).update(join_date=yesterday) def test_teams_move_member_transfer(self): """Test moving a member transfers them (removes old, adds new).""" @@ -119,8 +122,9 @@ class TeamViewsAjaxTests(TestCase): # Verify history was updated today = timezone.now().date() + yesterday = today - timezone.timedelta(days=1) h_old = CompanyMemberHistory.objects.get(member=self.member, team=self.team_a) - self.assertEqual(h_old.leave_date, today) + self.assertEqual(h_old.leave_date, yesterday) h_new = CompanyMemberHistory.objects.get(member=self.member, team=self.team_b) self.assertEqual(h_new.join_date, today) self.assertIsNone(h_new.leave_date) diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index db93f3b..08b80bb 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -5397,16 +5397,18 @@ function loadTeamDailyMembersModalData(teamId, dateStr) { const candidateSelect = document.getElementById('team-daily-candidate-select'); let url; + const cacheBuster = `_t=${Date.now()}`; if (typeof teamDailyMembersUrlPattern !== 'undefined') { - url = teamDailyMembersUrlPattern.replace('/0/', `/${teamId}/`) + `?date=${encodeURIComponent(dateStr)}`; + url = teamDailyMembersUrlPattern.replace('/0/', `/${teamId}/`) + `?date=${encodeURIComponent(dateStr)}&${cacheBuster}`; } else if (typeof teamDailyMembersBaseUrl !== 'undefined') { const base = teamDailyMembersBaseUrl.replace(/\/members\/?$/, '/'); - url = `${base.replace(/\/+$/, '')}/${teamId}/members/?date=${encodeURIComponent(dateStr)}`; + url = `${base.replace(/\/+$/, '')}/${teamId}/members/?date=${encodeURIComponent(dateStr)}&${cacheBuster}`; } else { - url = `/interventions/calendar/team/${teamId}/members/?date=${encodeURIComponent(dateStr)}`; + url = `/interventions/calendar/team/${teamId}/members/?date=${encodeURIComponent(dateStr)}&${cacheBuster}`; } fetch(url, { + cache: 'no-store', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) .then(res => { diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py index 89bdb17..51f30dd 100644 --- a/loko/interventions/views/calendar.py +++ b/loko/interventions/views/calendar.py @@ -11,6 +11,7 @@ from django.contrib.gis.measure import D from django.contrib.contenttypes.models import ContentType from django.contrib import messages from django.views.decorators.http import require_POST, require_GET, require_http_methods +from django.views.decorators.cache import never_cache from django.contrib.auth.decorators import login_required from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive from django.utils import timezone as django_timezone @@ -990,18 +991,15 @@ def _interventions_events_by_teams_daily( if not active: continue - - if len(active) > 1: - # Resolve conflicts: - # Exclude those ending on day d if there is at least one that does not end on day d - non_ending = [h for h in active if h['leave_date'] != d] - if non_ending: - active = non_ending - - # Add to team_compositions_sets for the remaining team(s) + + # Grouper par équipe pour éliminer les doublons au sein d'une même équipe + active_by_team = defaultdict(list) for h in active: - team_compositions_sets[(h['team_id'], d)].add((m_id, member_name)) - member_teams_by_day[d][m_id].add(h['team_id']) + active_by_team[h['team_id']].append(h) + + for t_id in active_by_team.keys(): + team_compositions_sets[(t_id, d)].add((m_id, member_name)) + member_teams_by_day[d][m_id].add(t_id) # 5.b Récupérer les absences pour cette semaine absences_map = { @@ -1635,6 +1633,7 @@ def member_history_ajax(request, member_id): }) +@never_cache def team_daily_members_ajax(request, team_id): """ Renvoie la composition d'une équipe à une date donnée, ainsi que les