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 %}
- {% trans "Changement d'équipe immédiat" %}
+ {% trans "Affectation d'équipe immédiate" %}
- {% trans "Ce déplacement modifie l'équipe de" %} {% trans "immédiatement pour aujourd'hui." %}
+ {% trans "Vous modifiez l'affectation de" %} {% trans "pour aujourd'hui." %}
{% trans "Pour planifier des affectations d'équipes pour demain ou les jours futurs sans impacter la journée en cours, utilisez l'onglet" %} « {% trans "Planification des équipes" %} ».
-
+
+
+
+
+
+
+
+
+
+ {% trans "Membre déjà planifié ce jour" %}
+
+
+
+
+
+ {% trans "est déjà planifié(e) le" %} {% trans "dans l'équipe" %} :
+ .
+
+
+ {% trans "Vous tentez de l'assigner à l'équipe" %} : .
+ {% trans "Souhaitez-vous déplacer ce membre ou l'affecter aux deux équipes ?" %}
+