fix: prevent future history corruption and handle duplicate entries in company member records

This commit is contained in:
kdeterme 2026-09-12 12:08:23 +02:00
parent db70f8fba9
commit c8e8223ffa
6 changed files with 154 additions and 49 deletions

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -991,17 +992,14 @@ 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