fix: prevent future history corruption and handle duplicate entries in company member records
This commit is contained in:
parent
db70f8fba9
commit
c8e8223ffa
6 changed files with 154 additions and 49 deletions
|
|
@ -141,25 +141,35 @@ def set_member_team_schedule(
|
||||||
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
|
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
|
||||||
).update(leave_date=prev_leave)
|
).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
|
# Vérifier s'il existe déjà un enregistrement pour cette équipe débutant à target_date
|
||||||
existing_hist = CompanyMemberHistory.objects.filter(
|
existing_hists = list(
|
||||||
member=member,
|
CompanyMemberHistory.objects.filter(
|
||||||
team_id=team_id,
|
member=member,
|
||||||
join_date=target_date,
|
team_id=team_id,
|
||||||
).first()
|
join_date=target_date,
|
||||||
|
).order_by('id')
|
||||||
|
)
|
||||||
|
|
||||||
if existing_hist:
|
if existing_hists:
|
||||||
new_history = existing_hist
|
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:
|
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(
|
new_history = CompanyMemberHistory.objects.create(
|
||||||
member=member,
|
member=member,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ def update_order_total_on_quote_item_delete(sender, instance, **kwargs):
|
||||||
order.save(update_fields=['total_amount'])
|
order.save(update_fields=['total_amount'])
|
||||||
|
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
from django.db.models.signals import m2m_changed
|
from django.db.models.signals import m2m_changed
|
||||||
from contracts.models import CompanyMember, CompanyMemberHistory
|
from contracts.models import CompanyMember, CompanyMemberHistory
|
||||||
from django.utils import timezone
|
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
|
# instance est CompanyMember, pk_set contient les team_id
|
||||||
if action == "post_add" and pk_set:
|
if action == "post_add" and pk_set:
|
||||||
for team_id in pk_set:
|
for team_id in pk_set:
|
||||||
CompanyMemberHistory.objects.get_or_create(
|
has_active = CompanyMemberHistory.objects.filter(
|
||||||
member=instance,
|
member=instance,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
leave_date__isnull=True,
|
join_date__lte=today,
|
||||||
defaults={'join_date': 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:
|
elif action == "post_remove" and pk_set:
|
||||||
for team_id in pk_set:
|
for team_id in pk_set:
|
||||||
CompanyMemberHistory.objects.filter(
|
CompanyMemberHistory.objects.filter(
|
||||||
member=instance,
|
member=instance,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
leave_date__isnull=True
|
join_date__lte=today,
|
||||||
|
leave_date__isnull=True,
|
||||||
).update(leave_date=today)
|
).update(leave_date=today)
|
||||||
elif action == "pre_clear":
|
elif action == "pre_clear":
|
||||||
CompanyMemberHistory.objects.filter(
|
CompanyMemberHistory.objects.filter(
|
||||||
member=instance,
|
member=instance,
|
||||||
leave_date__isnull=True
|
join_date__lte=today,
|
||||||
|
leave_date__isnull=True,
|
||||||
).update(leave_date=today)
|
).update(leave_date=today)
|
||||||
else:
|
else:
|
||||||
# instance est CompanyTeam, pk_set contient les member_id
|
# instance est CompanyTeam, pk_set contient les member_id
|
||||||
if action == "post_add" and pk_set:
|
if action == "post_add" and pk_set:
|
||||||
for member_id in pk_set:
|
for member_id in pk_set:
|
||||||
CompanyMemberHistory.objects.get_or_create(
|
has_active = CompanyMemberHistory.objects.filter(
|
||||||
member_id=member_id,
|
member_id=member_id,
|
||||||
team=instance,
|
team=instance,
|
||||||
leave_date__isnull=True,
|
join_date__lte=today,
|
||||||
defaults={'join_date': 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:
|
elif action == "post_remove" and pk_set:
|
||||||
for member_id in pk_set:
|
for member_id in pk_set:
|
||||||
CompanyMemberHistory.objects.filter(
|
CompanyMemberHistory.objects.filter(
|
||||||
member_id=member_id,
|
member_id=member_id,
|
||||||
team=instance,
|
team=instance,
|
||||||
leave_date__isnull=True
|
join_date__lte=today,
|
||||||
|
leave_date__isnull=True,
|
||||||
).update(leave_date=today)
|
).update(leave_date=today)
|
||||||
elif action == "pre_clear":
|
elif action == "pre_clear":
|
||||||
CompanyMemberHistory.objects.filter(
|
CompanyMemberHistory.objects.filter(
|
||||||
team=instance,
|
team=instance,
|
||||||
leave_date__isnull=True
|
join_date__lte=today,
|
||||||
|
leave_date__isnull=True,
|
||||||
).update(leave_date=today)
|
).update(leave_date=today)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -483,13 +483,81 @@ class TeamPlanningAjaxViewsTests(TestCase):
|
||||||
self.assertEqual(self.member_alice.teams.count(), 1)
|
self.assertEqual(self.member_alice.teams.count(), 1)
|
||||||
self.assertEqual(self.member_alice.teams.first(), self.team_b)
|
self.assertEqual(self.member_alice.teams.first(), self.team_b)
|
||||||
|
|
||||||
def test_can_view_member_history_in_teams_views(self):
|
def test_m2m_signal_does_not_corrupt_future_histories(self):
|
||||||
resp = self.client.get(reverse('contracts:teams_index'))
|
"""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.assertEqual(resp.status_code, 200)
|
||||||
self.assertTrue(resp.context['can_view_member_history'])
|
|
||||||
|
|
||||||
data_resp = self.client.get(reverse('contracts:team_planning_data_ajax'))
|
corrupted_hist.refresh_from_db()
|
||||||
self.assertEqual(data_resp.status_code, 200)
|
# leave_date doit être réparée et bornée au 14 (date_15 - 1 jour)
|
||||||
self.assertTrue(data_resp.json()['can_view_member_history'])
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,9 @@ class TeamViewsAjaxTests(TestCase):
|
||||||
|
|
||||||
self.member = CompanyMember.objects.create(name="Tech A", user=self.user)
|
self.member = CompanyMember.objects.create(name="Tech A", user=self.user)
|
||||||
self.member.teams.add(self.team_a)
|
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):
|
def test_teams_move_member_transfer(self):
|
||||||
"""Test moving a member transfers them (removes old, adds new)."""
|
"""Test moving a member transfers them (removes old, adds new)."""
|
||||||
|
|
@ -119,8 +122,9 @@ class TeamViewsAjaxTests(TestCase):
|
||||||
|
|
||||||
# Verify history was updated
|
# Verify history was updated
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
|
yesterday = today - timezone.timedelta(days=1)
|
||||||
h_old = CompanyMemberHistory.objects.get(member=self.member, team=self.team_a)
|
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)
|
h_new = CompanyMemberHistory.objects.get(member=self.member, team=self.team_b)
|
||||||
self.assertEqual(h_new.join_date, today)
|
self.assertEqual(h_new.join_date, today)
|
||||||
self.assertIsNone(h_new.leave_date)
|
self.assertIsNone(h_new.leave_date)
|
||||||
|
|
|
||||||
|
|
@ -5397,16 +5397,18 @@ function loadTeamDailyMembersModalData(teamId, dateStr) {
|
||||||
const candidateSelect = document.getElementById('team-daily-candidate-select');
|
const candidateSelect = document.getElementById('team-daily-candidate-select');
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
|
const cacheBuster = `_t=${Date.now()}`;
|
||||||
if (typeof teamDailyMembersUrlPattern !== 'undefined') {
|
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') {
|
} else if (typeof teamDailyMembersBaseUrl !== 'undefined') {
|
||||||
const base = teamDailyMembersBaseUrl.replace(/\/members\/?$/, '/');
|
const base = teamDailyMembersBaseUrl.replace(/\/members\/?$/, '/');
|
||||||
url = `${base.replace(/\/+$/, '')}/${teamId}/members/?date=${encodeURIComponent(dateStr)}`;
|
url = `${base.replace(/\/+$/, '')}/${teamId}/members/?date=${encodeURIComponent(dateStr)}&${cacheBuster}`;
|
||||||
} else {
|
} else {
|
||||||
url = `/interventions/calendar/team/${teamId}/members/?date=${encodeURIComponent(dateStr)}`;
|
url = `/interventions/calendar/team/${teamId}/members/?date=${encodeURIComponent(dateStr)}&${cacheBuster}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
|
cache: 'no-store',
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
})
|
})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from django.contrib.gis.measure import D
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.views.decorators.http import require_POST, require_GET, require_http_methods
|
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.contrib.auth.decorators import login_required
|
||||||
from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive
|
from django.utils.timezone import now, localtime, make_aware, get_current_timezone, is_naive
|
||||||
from django.utils import timezone as django_timezone
|
from django.utils import timezone as django_timezone
|
||||||
|
|
@ -990,18 +991,15 @@ def _interventions_events_by_teams_daily(
|
||||||
|
|
||||||
if not active:
|
if not active:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if len(active) > 1:
|
# Grouper par équipe pour éliminer les doublons au sein d'une même équipe
|
||||||
# Resolve conflicts:
|
active_by_team = defaultdict(list)
|
||||||
# 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)
|
|
||||||
for h in active:
|
for h in active:
|
||||||
team_compositions_sets[(h['team_id'], d)].add((m_id, member_name))
|
active_by_team[h['team_id']].append(h)
|
||||||
member_teams_by_day[d][m_id].add(h['team_id'])
|
|
||||||
|
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
|
# 5.b Récupérer les absences pour cette semaine
|
||||||
absences_map = {
|
absences_map = {
|
||||||
|
|
@ -1635,6 +1633,7 @@ def member_history_ajax(request, member_id):
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
def team_daily_members_ajax(request, team_id):
|
def team_daily_members_ajax(request, team_id):
|
||||||
"""
|
"""
|
||||||
Renvoie la composition d'une équipe à une date donnée, ainsi que les
|
Renvoie la composition d'une équipe à une date donnée, ainsi que les
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue