From 79697f392c38ce755574f4208913e1dfe0caaafe Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 9 Sep 2026 15:14:25 +0200 Subject: [PATCH] feat: enable multi-team support for company members via updated history service and synchronization logic --- loko/contracts/services_teams.py | 239 ++++++++++------ .../templates/contracts/teams_index.html | 264 ++++++++++++++++-- loko/contracts/test_planning.py | 136 +++++++++ loko/contracts/views_teams.py | 44 ++- .../calendar/interventions_calendar.js | 7 + loko/interventions/views/calendar.py | 10 + 6 files changed, 588 insertions(+), 112 deletions(-) diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py index 718b3a5..619bc99 100644 --- a/loko/contracts/services_teams.py +++ b/loko/contracts/services_teams.py @@ -18,10 +18,11 @@ from contracts.models import ( ) -def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[CompanyTeam]: +def get_member_teams_for_date(member: CompanyMember, target_date: date) -> List[CompanyTeam]: """ - Retourne l'équipe à laquelle appartient le membre à la date donnée (selon CompanyMemberHistory). - Retourne None si le membre n'est dans aucune équipe ce jour-là. + Retourne la liste des équipes auxquelles appartient le membre à la date donnée (selon CompanyMemberHistory). + Retourne [] si le membre n'est dans aucune équipe ce jour-là. + Prend en compte la continuité (report théorique si leave_date is None). """ histories = list( CompanyMemberHistory.objects @@ -31,15 +32,24 @@ def get_member_team_for_date(member: CompanyMember, target_date: date) -> Option .order_by('-join_date', '-id') ) if not histories: - return None - # Parmi les historiques ayant la date de début la plus récente (join_date maximal) - max_join = histories[0].join_date - candidates = [h for h in histories if h.join_date == max_join] - if len(candidates) > 1: - continuing = [h for h in candidates if h.leave_date is None or h.leave_date > target_date] - if continuing: - return continuing[0].team - return candidates[0].team + return [] + + teams = [] + seen_team_ids = set() + for h in histories: + if h.team and h.team_id not in seen_team_ids: + seen_team_ids.add(h.team_id) + teams.append(h.team) + return teams + + +def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[CompanyTeam]: + """ + Retourne la première équipe active à laquelle appartient le membre à la date donnée. + Conservé pour rétro-compatibilité. Utiliser get_member_teams_for_date pour le support multi-équipes. + """ + teams = get_member_teams_for_date(member, target_date) + return teams[0] if teams else None @transaction.atomic @@ -49,56 +59,107 @@ def set_member_team_schedule( target_date: date, changed_by=None, sync_current: bool = True, + action: str = 'move', + unassign_team_id: Optional[int] = None, ) -> Optional[CompanyMemberHistory]: """ Planifie l'appartenance d'un membre à une équipe à partir de `target_date`. - - Nettoie tout enregistrement existant commençant à `target_date`. - - Clôture les historiques antérieurs la veille (`target_date - 1`). - - Crée le nouvel enregistrement pour `target_date` jusqu'au prochain jalon futur. - - Si team_id est None, le membre est désassigné pour target_date. + - Si team_id is None : + - si unassign_team_id est fourni : désassigne uniquement de cette équipe à target_date. + - sinon : désassigne de toutes les équipes à target_date. + - Si team_id is not None : + - action='move' (défaut) : clôture/retire les autres équipes à target_date et affecte team_id. + - action='add' : ajoute team_id sans impacter les autres équipes existantes (multi-équipes). """ today = timezone.now().date() - if target_date > today: - # 1. Date future : supprimer tout enregistrement existant débutant à target_date - CompanyMemberHistory.objects.filter( - member=member, - join_date=target_date, - ).delete() - else: - # 1. Aujourd'hui ou passé : clôturer l'enregistrement commençant à target_date - if team_id is None: + if team_id is None: + # Désassignation + if unassign_team_id is not None: + # Désassignation ciblée d'une seule équipe + if target_date > today: + CompanyMemberHistory.objects.filter( + member=member, + team_id=unassign_team_id, + join_date=target_date, + ).delete() + else: + CompanyMemberHistory.objects.filter( + member=member, + team_id=unassign_team_id, + join_date=target_date, + ).delete() + prev_leave = target_date - timedelta(days=1) + CompanyMemberHistory.objects.filter( + member=member, + team_id=unassign_team_id, + join_date__lt=target_date, + ).filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) + ).update(leave_date=prev_leave) + else: + # Désassignation globale de toutes les équipes + if target_date > today: + CompanyMemberHistory.objects.filter( + member=member, + join_date=target_date, + ).delete() + else: + CompanyMemberHistory.objects.filter( + member=member, + join_date=target_date, + ).delete() + prev_leave = target_date - timedelta(days=1) + CompanyMemberHistory.objects.filter( + member=member, + join_date__lt=target_date, + ).filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) + ).update(leave_date=prev_leave) + + _sync_teams_if_needed(member, target_date, today, sync_current) + return None + + # team_id is not None + if action == 'move': + # En mode 'move', on remplace/clôture les affectations des autres équipes + if target_date > today: CompanyMemberHistory.objects.filter( member=member, join_date=target_date, - ).delete() + ).exclude(team_id=team_id).delete() else: CompanyMemberHistory.objects.filter( member=member, join_date=target_date, - ).exclude(team_id=team_id).update(leave_date=target_date) + ).exclude(team_id=team_id).delete() + prev_leave = target_date - timedelta(days=1) + CompanyMemberHistory.objects.filter( + member=member, + join_date__lt=target_date, + ).exclude(team_id=team_id).filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) + ).update(leave_date=prev_leave) - # 2. Clôturer les enregistrements antérieurs encore actifs à target_date - prev_leave = target_date - timedelta(days=1) - CompanyMemberHistory.objects.filter( + # Vérifier s'il existe déjà un enregistrement pour cette équipe débutant à target_date + existing_hist = CompanyMemberHistory.objects.filter( member=member, - join_date__lt=target_date, - ).filter( - Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) - ).update(leave_date=prev_leave) + team_id=team_id, + join_date=target_date, + ).first() - # 3. Trouver le prochain jalon futur - next_hist = ( - CompanyMemberHistory.objects - .filter(member=member, 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 + if existing_hist: + new_history = existing_hist + 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 - # 4. Créer le nouvel enregistrement s'il y a une équipe - new_history = None - if team_id is not None: new_history = CompanyMemberHistory.objects.create( member=member, team_id=team_id, @@ -119,7 +180,7 @@ def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = None): """ - Synchronise le champ ManyToMany member.teams avec l'équipe active + Synchronise le champ ManyToMany member.teams avec l'ensemble des équipes actives dans CompanyMemberHistory pour la date spécifiée (par défaut aujourd'hui). """ if for_date is None: @@ -127,8 +188,6 @@ def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = has_history = CompanyMemberHistory.objects.filter(member=member).exists() if not has_history: - # Si le membre n'a aucun historique d'assignation mais a des équipes configurées dans member.teams, - # on initialise son historique pour préserver ses affectations. current_teams = list(member.teams.all()) if current_teams: for t in current_teams: @@ -140,19 +199,18 @@ def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = ) return - active_team = get_member_team_for_date(member, for_date) + active_teams = get_member_teams_for_date(member, for_date) current_team_ids = set(member.teams.values_list('id', flat=True)) - target_team_ids = {active_team.id} if active_team else set() + target_team_ids = {t.id for t in active_teams} if current_team_ids != target_team_ids: - # Utiliser .set() pour synchroniser member.teams.set(list(target_team_ids)) def sync_all_members_current_teams(for_date: Optional[date] = None) -> int: """ Synchronise le champ ManyToMany member.teams pour tous les membres - selon l'équipe active dans CompanyMemberHistory à la date donnée (aujourd'hui par défaut). + selon les équipes actives dans CompanyMemberHistory à la date donnée (aujourd'hui par défaut). Retourne le nombre de membres synchronisés. """ if for_date is None: @@ -175,7 +233,8 @@ def get_teams_composition_for_period( Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date]. - Pour aujourd'hui et les jours passés : utilise les affectations actives courantes et historiques. - Pour les jours futurs : n'affiche que les journées qui ont été explicitement planifiées ou dupliquées (join_date == d). - Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id}] + Prend en charge la multi-appartenance (un membre peut être dans plusieurs équipes le même jour). + Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id, 'is_multi_team': bool, 'other_teams': [{'id': t_id, 'name': t_name}]}] """ today = timezone.now().date() histories = ( @@ -203,6 +262,8 @@ def get_teams_composition_for_period( num_days = (end_date - start_date).days + 1 team_compositions_sets = defaultdict(set) + member_teams_by_day = defaultdict(lambda: defaultdict(set)) + teams_map = {t.id: t.name for t in CompanyTeam.objects.filter(id__in=team_ids)} for i in range(num_days): d = start_date + timedelta(days=i) @@ -222,26 +283,37 @@ def get_teams_composition_for_period( if not active: continue - # En cas de multiplicité, privilégier un historique continuant (leave_date > d ou None) - if len(active) > 1: - continuing = [h for h in active if h['leave_date'] is None or h['leave_date'] > d] - if continuing: - active = continuing - active.sort(key=lambda x: (x['join_date'], x.get('id', 0)), reverse=True) - active = [active[0]] - + # 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( + active_by_team[h['team_id']].append(h) + + for t_id in active_by_team.keys(): + team_compositions_sets[(t_id, d)].add( (member.id, member.name, getattr(member.user, 'id', None)) ) + member_teams_by_day[d][member.id].add(t_id) result = defaultdict(list) for (t_id, d), m_set in team_compositions_sets.items(): sorted_members = sorted(list(m_set), key=lambda x: x[1]) - result[(t_id, d)] = [ - {'id': mid, 'name': mname, 'user_id': uid} - for mid, mname, uid in sorted_members - ] + day_members = [] + for mid, mname, uid in sorted_members: + all_teams_for_m = member_teams_by_day[d].get(mid, set()) + is_multi = len(all_teams_for_m) > 1 + other_teams = [ + {'id': other_id, 'name': teams_map.get(other_id, '')} + for other_id in all_teams_for_m + if other_id != t_id + ] + day_members.append({ + 'id': mid, + 'name': mname, + 'user_id': uid, + 'is_multi_team': is_multi, + 'other_teams': other_teams, + }) + result[(t_id, d)] = day_members return result @@ -319,22 +391,27 @@ def duplicate_day_schedule( absences_count = 0 for member in all_members: - team = get_member_team_for_date(member, source_date) - if team: - if allowed_team_ids is not None and team.id not in allowed_team_ids: - continue - set_member_team_schedule( - member=member, - team_id=team.id, - target_date=target_date, - changed_by=changed_by, - sync_current=False, - ) - assigned_count += 1 + source_teams = get_member_teams_for_date(member, source_date) + if allowed_team_ids is not None: + source_teams = [t for t in source_teams if t.id in allowed_team_ids] + + if source_teams: + for idx, team in enumerate(source_teams): + # La première équipe réinitialise les anciennes affectations, les suivantes s'ajoutent + action = 'move' if idx == 0 else 'add' + set_member_team_schedule( + member=member, + team_id=team.id, + target_date=target_date, + changed_by=changed_by, + sync_current=False, + action=action, + ) + assigned_count += 1 else: # Membre non assigné le jour source : le désassigner aussi sur le jour cible s'il était dans une équipe autorisée - target_team = get_member_team_for_date(member, target_date) - if target_team and (allowed_team_ids is None or target_team.id in allowed_team_ids): + target_teams = get_member_teams_for_date(member, target_date) + if any(t.id in allowed_team_ids for t in target_teams) if allowed_team_ids else bool(target_teams): set_member_team_schedule( member=member, team_id=None, @@ -348,7 +425,7 @@ def duplicate_day_schedule( if allowed_team_ids is not None: allowed_member_ids = { m.id for m in all_members - if (get_member_team_for_date(m, source_date) and get_member_team_for_date(m, source_date).id in allowed_team_ids) + if any(t.id in allowed_team_ids for t in get_member_teams_for_date(m, source_date)) } source_absences = source_absences.filter(member_id__in=allowed_member_ids) diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index c93478b..fcf9e1e 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -589,7 +589,14 @@ {% endif %}
- {{ member.name }} +
+ {{ member.name }} + {% if member.teams.count > 1 %} + + {% trans "Multi-équipes" %} + + {% endif %} +
{% if member.user %} {{ member.user.get_full_name|default:member.user.username }} {% endif %} @@ -597,15 +604,23 @@ {% if can_edit %}
+ {% if member.teams.count > 1 %} + + {% else %} + {% endif %}
{% endif %} @@ -766,26 +781,68 @@ +
+ + + + +
+ +
+
+ + +
+
+ + +
+
+
`; @@ -1719,6 +1869,7 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) { card.innerHTML = `
${escapeHtml(member.name)} + ${multiTeamBadge} ${absenceBadge}
${actionsHtml} @@ -1727,6 +1878,26 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) { return card; } +let _pendingPlanningAssign = null; + +function executePlanningAssign(memberId, targetTeamId, targetDate, action) { + postJSON( + '/contracts/teams/planning/assign/', + { + member_id: parseInt(memberId), + team_id: parseInt(targetTeamId), + date: targetDate, + action: action || 'move', + }, + function(resp) { + loadPlanningData(); + }, + function(err) { + alert(err); + } + ); +} + function initPlanningDragAndDrop() { if (!CAN_EDIT_TEAMS) return; @@ -1760,26 +1931,60 @@ function initPlanningDragAndDrop() { hoverClass: 'drag-over', tolerance: 'pointer', drop: function(event, ui) { - const memberId = ui.draggable.data('memberId'); - const targetTeamId = $(this).data('teamId'); + const memberId = parseInt(ui.draggable.data('memberId') || ui.draggable.data('member-id')); + const memberName = ui.draggable.data('memberName') || ui.draggable.data('member-name') || ui.draggable.find('.member-name-text, .fw-semibold').first().text().trim(); + const targetTeamId = parseInt($(this).data('teamId') || $(this).data('team-id')); const targetDate = $(this).data('date'); if (!memberId || !targetTeamId || !targetDate) return; - postJSON( - '/contracts/teams/planning/assign/', - { - member_id: parseInt(memberId), - team_id: parseInt(targetTeamId), - date: targetDate, - }, - function(resp) { - loadPlanningData(); - }, - function(err) { - alert(err); - } - ); + // Vérifier si le membre est déjà planifié dans des équipes ce jour-là + const otherAssignedTeams = []; + let alreadyInTargetTeam = false; + if (_planningState && _planningState.data && _planningState.data.teams) { + _planningState.data.teams.forEach(t => { + if (t.days && t.days[targetDate]) { + const found = t.days[targetDate].some(m => m.id === memberId); + if (found) { + if (t.id === targetTeamId) { + alreadyInTargetTeam = true; + } else { + otherAssignedTeams.push(t); + } + } + } + }); + } + + if (alreadyInTargetTeam) { + // Déjà dans cette équipe ce jour-là + return; + } + + if (otherAssignedTeams.length > 0) { + // Membre déjà dans une autre équipe : avertir et proposer Déplacer ou Ajouter aussi + const otherNames = otherAssignedTeams.map(t => t.name).join(', '); + const targetTeamObj = (_planningState.data.teams || []).find(t => t.id === targetTeamId); + const targetName = targetTeamObj ? targetTeamObj.name : `Équipe #${targetTeamId}`; + + $('#multiTeamMemberName').text(memberName); + $('#multiTeamDateDisplay').text(targetDate); + $('#multiTeamExistingTeamName').text(otherNames); + $('#multiTeamTargetTeamName').text(targetName); + + _pendingPlanningAssign = { + memberId: memberId, + targetTeamId: targetTeamId, + targetDate: targetDate + }; + + const modalEl = document.getElementById('planningMultiTeamConfirmModal'); + const modal = new bootstrap.Modal(modalEl); + modal.show(); + } else { + // Pas encore assigné ce jour-là -> assignation directe + executePlanningAssign(memberId, targetTeamId, targetDate, 'move'); + } } }); } @@ -1857,13 +2062,14 @@ function filterSidebarMembers() { initPlanningDragAndDrop(); } -function unassignMemberFromDate(memberId, dateStr) { +function unassignMemberFromDate(memberId, dateStr, teamId) { postJSON( '/contracts/teams/planning/assign/', { member_id: parseInt(memberId), team_id: null, date: dateStr, + unassign_team_id: teamId ? parseInt(teamId) : null, }, function(resp) { loadPlanningData(); diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index b2bc96e..0a99be5 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -8,6 +8,7 @@ from django.utils import timezone from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence from contracts.services_teams import ( get_member_team_for_date, + get_member_teams_for_date, set_member_team_schedule, get_teams_composition_for_period, get_member_absences_for_period, @@ -115,6 +116,65 @@ class TeamPlanningServicesTests(TestCase): # Vérifier que le mercredi de la semaine 2, Alice est bien en Team A self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 16)), self.team_a) + def test_multi_team_scheduling_same_day(self): + """Tester l'affectation d'un membre à deux équipes le même jour avec action='add'.""" + target_date = date(2026, 9, 2) + + # 1. Affecter Alice à Team A + set_member_team_schedule(self.member_alice, self.team_a.id, target_date) + teams = get_member_teams_for_date(self.member_alice, target_date) + self.assertEqual(len(teams), 1) + self.assertEqual(teams[0], self.team_a) + + # 2. Affecter aussi Alice à Team B avec action='add' + set_member_team_schedule(self.member_alice, self.team_b.id, target_date, action='add') + teams = get_member_teams_for_date(self.member_alice, target_date) + self.assertEqual(len(teams), 2) + self.assertIn(self.team_a, teams) + self.assertIn(self.team_b, teams) + + # 3. Vérifier les compositions pour la matrice + comp = get_teams_composition_for_period({self.team_a.id, self.team_b.id}, target_date, target_date) + team_a_members = comp.get((self.team_a.id, target_date), []) + team_b_members = comp.get((self.team_b.id, target_date), []) + + alice_in_a = next((m for m in team_a_members if m['id'] == self.member_alice.id), None) + alice_in_b = next((m for m in team_b_members if m['id'] == self.member_alice.id), None) + + self.assertIsNotNone(alice_in_a) + self.assertIsNotNone(alice_in_b) + self.assertTrue(alice_in_a['is_multi_team']) + self.assertTrue(alice_in_b['is_multi_team']) + self.assertEqual(alice_in_a['other_teams'][0]['id'], self.team_b.id) + self.assertEqual(alice_in_b['other_teams'][0]['id'], self.team_a.id) + + def test_multi_team_unassign_specific_team(self): + """Tester la désassignation ciblée d'une seule équipe lorsqu'un membre en a plusieurs.""" + target_date = date(2026, 9, 2) + set_member_team_schedule(self.member_alice, self.team_a.id, target_date) + set_member_team_schedule(self.member_alice, self.team_b.id, target_date, action='add') + + # Désassigner Alice uniquement de Team A + set_member_team_schedule(self.member_alice, None, target_date, unassign_team_id=self.team_a.id) + teams = get_member_teams_for_date(self.member_alice, target_date) + self.assertEqual(len(teams), 1) + self.assertEqual(teams[0], self.team_b) + + def test_multi_team_duplicate_day_schedule(self): + """Tester que la duplication d'une journée duplique toutes les équipes d'un membre multi-équipes.""" + day1 = date(2026, 9, 7) + day2 = date(2026, 9, 8) + set_member_team_schedule(self.member_alice, self.team_a.id, day1) + set_member_team_schedule(self.member_alice, self.team_b.id, day1, action='add') + + res = duplicate_day_schedule(day1, day2) + self.assertGreaterEqual(res['assigned_count'], 2) + + teams_day2 = get_member_teams_for_date(self.member_alice, day2) + self.assertEqual(len(teams_day2), 2) + self.assertIn(self.team_a, teams_day2) + self.assertIn(self.team_b, teams_day2) + class TeamPlanningAjaxViewsTests(TestCase): def setUp(self): @@ -347,3 +407,79 @@ class TeamPlanningAjaxViewsTests(TestCase): comp2 = get_teams_composition_for_period({self.team_a.id}, target_d, target_d) self.assertNotIn(self.member_alice.id, [m['id'] for m in comp2.get((self.team_a.id, target_d), [])]) + def test_multi_team_planning_assign_member_ajax_add_and_move(self): + """Tester l'endpoint AJAX de planification avec action='add' puis action='move'.""" + today = timezone.now().date() + target_date = today + timedelta(days=2) + + # 1. Affecter à Team A + url = reverse('contracts:team_planning_assign_member_ajax') + resp = self.client.post(url, data=json.dumps({ + 'member_id': self.member_alice.id, + 'team_id': self.team_a.id, + 'date': target_date.isoformat(), + 'action': 'move', + }), content_type='application/json') + self.assertEqual(resp.status_code, 200) + + # 2. Affecter aussi à Team B avec action='add' + resp = self.client.post(url, data=json.dumps({ + 'member_id': self.member_alice.id, + 'team_id': self.team_b.id, + 'date': target_date.isoformat(), + 'action': 'add', + }), content_type='application/json') + self.assertEqual(resp.status_code, 200) + + teams = get_member_teams_for_date(self.member_alice, target_date) + self.assertEqual(len(teams), 2) + self.assertIn(self.team_a, teams) + self.assertIn(self.team_b, teams) + + # 3. Déplacer vers Team A avec action='move' -> retire Team B + resp = self.client.post(url, data=json.dumps({ + 'member_id': self.member_alice.id, + 'team_id': self.team_a.id, + 'date': target_date.isoformat(), + 'action': 'move', + }), content_type='application/json') + self.assertEqual(resp.status_code, 200) + + teams_after = get_member_teams_for_date(self.member_alice, target_date) + self.assertEqual(len(teams_after), 1) + self.assertEqual(teams_after[0], self.team_a) + + def test_multi_team_teams_move_member_immediate(self): + """Tester le déplacement immédiat en multi-équipes via teams_move_member.""" + today = timezone.now().date() + url = reverse('contracts:teams_move_member', args=[self.member_alice.id]) + + # Assigner à Team A + self.client.post(url, data=json.dumps({ + 'team_id': self.team_a.id, + 'old_team_id': None, + 'action': 'move', + }), content_type='application/json') + + # Assigner aussi à Team B + self.client.post(url, data=json.dumps({ + 'team_id': self.team_b.id, + 'old_team_id': self.team_a.id, + 'action': 'add', + }), content_type='application/json') + + self.member_alice.refresh_from_db() + self.assertEqual(self.member_alice.teams.count(), 2) + self.assertIn(self.team_a, self.member_alice.teams.all()) + self.assertIn(self.team_b, self.member_alice.teams.all()) + + # Retirer de Team A uniquement + self.client.post(url, data=json.dumps({ + 'team_id': None, + 'old_team_id': self.team_a.id, + }), content_type='application/json') + + self.member_alice.refresh_from_db() + self.assertEqual(self.member_alice.teams.count(), 1) + self.assertEqual(self.member_alice.teams.first(), self.team_b) + diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py index 768b932..a0bbfce 100644 --- a/loko/contracts/views_teams.py +++ b/loko/contracts/views_teams.py @@ -154,8 +154,24 @@ def teams_move_member(request, member_id): if old_team_id is not None: old_team = get_object_or_404(CompanyTeam, id=old_team_id) + action = data.get("action", "move") today = timezone.now().date() - set_member_team_schedule(member, new_team_id, today, changed_by=request.user) + if new_team_id is None and old_team_id is not None: + set_member_team_schedule( + member=member, + team_id=None, + target_date=today, + changed_by=request.user, + unassign_team_id=old_team_id, + ) + else: + set_member_team_schedule( + member=member, + team_id=new_team_id, + target_date=today, + changed_by=request.user, + action=action, + ) member.save() @@ -334,7 +350,23 @@ def teams_update_member(request, member_id): # Effectuer le transfert via le service de planification (date d'aujourd'hui) today = timezone.now().date() - set_member_team_schedule(member, new_team_id, today, changed_by=request.user) + action = data.get("action", "move") + if new_team_id is None and old_team_id is not None: + set_member_team_schedule( + member, + None, + today, + changed_by=request.user, + unassign_team_id=old_team_id, + ) + else: + set_member_team_schedule( + member, + new_team_id, + today, + changed_by=request.user, + action=action, + ) member.name = new_name member.save() @@ -493,6 +525,8 @@ def team_planning_data_ajax(request): 'user_id': m.get('user_id'), 'is_absent': abs_info is not None, 'absence': abs_info, + 'is_multi_team': m.get('is_multi_team', False), + 'other_teams': m.get('other_teams', []), }) days_dict[d_str] = members_on_day @@ -583,6 +617,10 @@ def team_planning_assign_member_ajax(request): if not user_can_edit_teams(request.user): raise PermissionDenied("Droits d'édition insuffisants.") + action = data.get("action", "move") + raw_unassign_team_id = data.get("unassign_team_id") + unassign_team_id = int(raw_unassign_team_id) if raw_unassign_team_id is not None else None + # Effectuer la planification set_member_team_schedule( member=member, @@ -590,6 +628,8 @@ def team_planning_assign_member_ajax(request): target_date=target_date, changed_by=request.user, sync_current=True, + action=action, + unassign_team_id=unassign_team_id, ) return JsonResponse({ diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index af26fe3..6b5a008 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -533,11 +533,18 @@ document.addEventListener('DOMContentLoaded', function () { const id = typeof m === 'object' && m ? m.id : null; const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false; const absenceReason = typeof m === 'object' && m ? (m.absence_reason || '') : ''; + const isMultiTeam = typeof m === 'object' && m ? Boolean(m.is_multi_team) : false; + const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : []; let memberNameHtml = escapeHtml(name); if (isMemberHistoryAllowed && id) { memberNameHtml = `${escapeHtml(name)}`; } + if (isMultiTeam) { + const otherNames = otherTeams.map(t => t.name).join(', '); + const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour'); + memberNameHtml += ` `; + } if (isAbsent) { return `${memberNameHtml} ${escapeHtml(absenceReason || gettext('Absent'))}`; } diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py index 392f3f2..83410fd 100644 --- a/loko/interventions/views/calendar.py +++ b/loko/interventions/views/calendar.py @@ -965,6 +965,7 @@ def _interventions_events_by_teams_daily( # Map of (team_id, date) -> set of member names team_compositions_sets = defaultdict(set) + member_teams_by_day = defaultdict(lambda: defaultdict(set)) # For each day of the week today = timezone.now().date() @@ -997,6 +998,7 @@ def _interventions_events_by_teams_daily( # Add to team_compositions_sets for the remaining team(s) 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']) # 5.b Récupérer les absences pour cette semaine absences_map = { @@ -1004,6 +1006,8 @@ def _interventions_events_by_teams_daily( for a in CompanyMemberAbsence.objects.filter(date__range=(start_date, end_date)) } + teams_name_map = {t.id: t.name for t in allowed_teams if t.id is not None} + # Convert sets to sorted lists of dicts for consistency and default to empty list on missing access team_compositions = defaultdict(list) for (t_id, d_val), members_set in team_compositions_sets.items(): @@ -1013,6 +1017,12 @@ def _interventions_events_by_teams_daily( 'name': mname, 'is_absent': (mid, d_val) in absences_map, 'absence_reason': absences_map.get((mid, d_val)), + 'is_multi_team': len(member_teams_by_day[d_val][mid]) > 1, + 'other_teams': [ + {'id': ot_id, 'name': teams_name_map.get(ot_id, '')} + for ot_id in member_teams_by_day[d_val][mid] + if ot_id != t_id + ], } for mid, mname in sorted(list(members_set), key=lambda x: x[1]) ]