feat: add CompanyMemberAbsence model and integrate absence display into the team planning calendar view
This commit is contained in:
parent
9429402292
commit
faafa8ec23
9 changed files with 2301 additions and 222 deletions
|
|
@ -15,6 +15,7 @@ from .models import (
|
|||
CompanyTeam,
|
||||
CompanyMember,
|
||||
CompanyMemberHistory,
|
||||
CompanyMemberAbsence,
|
||||
ContractOrderQuote,
|
||||
ContractOrderQuoteItem,
|
||||
ContractOrderProgressState,
|
||||
|
|
@ -383,6 +384,14 @@ class CompanyMemberHistoryAdmin(admin.ModelAdmin):
|
|||
return False
|
||||
|
||||
|
||||
@admin.register(CompanyMemberAbsence)
|
||||
class CompanyMemberAbsenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('member', 'date', 'reason', 'created_by', 'created_at')
|
||||
list_filter = ('reason', 'date')
|
||||
search_fields = ('member__name', 'comment')
|
||||
date_hierarchy = 'date'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commandes directes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
34
loko/contracts/migrations/0005_companymemberabsence.py
Normal file
34
loko/contracts/migrations/0005_companymemberabsence.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 6.0.7 on 2026-09-02 08:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contracts', '0004_contract_blocked_at_contract_blocked_reason_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CompanyMemberAbsence',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(verbose_name="Date d'absence")),
|
||||
('reason', models.CharField(blank=True, choices=[('conge', 'Congé / RTT'), ('maladie', 'Maladie / Arrêt'), ('formation', 'Formation'), ('autre', 'Autre')], default='conge', max_length=50, verbose_name="Motif d'absence")),
|
||||
('comment', models.TextField(blank=True, verbose_name='Commentaire')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_absences_recorded', to=settings.AUTH_USER_MODEL, verbose_name='Enregistré par')),
|
||||
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='contracts.companymember', verbose_name='Membre')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': "Absence d'un membre",
|
||||
'verbose_name_plural': 'Absences des membres',
|
||||
'ordering': ['-date', 'member'],
|
||||
'unique_together': {('member', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -207,6 +207,50 @@ class CompanyMemberHistory(models.Model):
|
|||
ordering = ['member', '-join_date']
|
||||
|
||||
|
||||
class CompanyMemberAbsence(models.Model):
|
||||
"""Enregistrement d'une absence journalière pour un membre d'équipe."""
|
||||
ABSENCE_REASON_CHOICES = [
|
||||
('conge', _('Congé / RTT')),
|
||||
('maladie', _('Maladie / Arrêt')),
|
||||
('formation', _('Formation')),
|
||||
('autre', _('Autre')),
|
||||
]
|
||||
|
||||
member = models.ForeignKey(
|
||||
CompanyMember,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='absences',
|
||||
verbose_name=_("Membre"),
|
||||
)
|
||||
date = models.DateField(verbose_name=_("Date d'absence"))
|
||||
reason = models.CharField(
|
||||
max_length=50,
|
||||
choices=ABSENCE_REASON_CHOICES,
|
||||
default='conge',
|
||||
blank=True,
|
||||
verbose_name=_("Motif d'absence"),
|
||||
)
|
||||
comment = models.TextField(blank=True, verbose_name=_("Commentaire"))
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='member_absences_recorded',
|
||||
verbose_name=_("Enregistré par"),
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.member.name} - {self.get_reason_display()} ({self.date.strftime('%d/%m/%Y')})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Absence d'un membre")
|
||||
verbose_name_plural = _("Absences des membres")
|
||||
unique_together = ('member', 'date')
|
||||
ordering = ['-date', 'member']
|
||||
|
||||
|
||||
class UserCompanyTeamAccess(models.Model):
|
||||
"""Permissions d'accès d'un utilisateur à une équipe de société/organisme."""
|
||||
user_config = models.ForeignKey(
|
||||
|
|
|
|||
357
loko/contracts/services_teams.py
Normal file
357
loko/contracts/services_teams.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"""
|
||||
Service de gestion temporelle et de planification des équipes.
|
||||
Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta, date
|
||||
from typing import Optional, List, Dict, Set, Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from contracts.models import (
|
||||
CompanyMember,
|
||||
CompanyTeam,
|
||||
CompanyMemberHistory,
|
||||
CompanyMemberAbsence,
|
||||
)
|
||||
|
||||
|
||||
def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[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à.
|
||||
"""
|
||||
histories = list(
|
||||
CompanyMemberHistory.objects
|
||||
.filter(member=member, join_date__lte=target_date)
|
||||
.filter(Q(leave_date__isnull=True) | Q(leave_date__gte=target_date))
|
||||
.select_related('team')
|
||||
.order_by('-join_date', '-id')
|
||||
)
|
||||
if not histories:
|
||||
return None
|
||||
# Si un historique est ouvert (leave_date IS NULL) ou se termine strictement après target_date,
|
||||
# on le préfère à un historique se terminant exactement à target_date (transfert en cours de journée)
|
||||
continuing = [h for h in histories if h.leave_date is None or h.leave_date > target_date]
|
||||
if continuing:
|
||||
return continuing[0].team
|
||||
return histories[0].team
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def set_member_team_schedule(
|
||||
member: CompanyMember,
|
||||
team_id: Optional[int],
|
||||
target_date: date,
|
||||
changed_by=None,
|
||||
sync_current: bool = True,
|
||||
) -> Optional[CompanyMemberHistory]:
|
||||
"""
|
||||
Planifie l'appartenance d'un membre à une équipe à partir de `target_date`.
|
||||
Règle de persistance : le membre reste dans cette équipe à partir de `target_date`
|
||||
jusqu'à une éventuelle réassignation future.
|
||||
|
||||
Si team_id est None, le membre devient non assigné à partir de target_date.
|
||||
"""
|
||||
today = timezone.now().date()
|
||||
|
||||
# 1. Récupérer les enregistrements d'historique actifs ou futurs
|
||||
active_histories = list(
|
||||
CompanyMemberHistory.objects
|
||||
.filter(member=member)
|
||||
.filter(
|
||||
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
|
||||
)
|
||||
.order_by('join_date')
|
||||
)
|
||||
|
||||
# Trouver l'historique actif à la date target_date
|
||||
current_at_target = None
|
||||
for h in active_histories:
|
||||
if h.join_date <= target_date and (h.leave_date is None or h.leave_date >= target_date):
|
||||
current_at_target = h
|
||||
break
|
||||
|
||||
# Si le membre est déjà dans la même équipe à cette date exacte, rien à faire
|
||||
if current_at_target and current_at_target.team_id == team_id:
|
||||
return current_at_target
|
||||
if not current_at_target and team_id is None:
|
||||
return None
|
||||
|
||||
# Trouver le prochain historique qui commence STRICTEMENT après target_date (s'il y en a)
|
||||
next_hist = None
|
||||
for h in active_histories:
|
||||
if h.join_date > target_date:
|
||||
if next_hist is None or h.join_date < next_hist.join_date:
|
||||
next_hist = h
|
||||
|
||||
next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None
|
||||
|
||||
# 2. Clôturer ou ajuster l'historique courant (actif à target_date)
|
||||
if current_at_target:
|
||||
if current_at_target.join_date == target_date:
|
||||
current_at_target.leave_date = target_date
|
||||
current_at_target.save(update_fields=['leave_date'])
|
||||
else:
|
||||
# L'historique commençait avant target_date : on le clôture la veille
|
||||
prev_leave = target_date - timedelta(days=1)
|
||||
current_at_target.leave_date = prev_leave
|
||||
current_at_target.save(update_fields=['leave_date'])
|
||||
else:
|
||||
# Aucun historique actif à target_date (membre non assigné)
|
||||
pass
|
||||
|
||||
# 3. Créer le nouvel enregistrement s'il y a une nouvelle équipe
|
||||
new_history = None
|
||||
if team_id is not None:
|
||||
new_history = CompanyMemberHistory.objects.create(
|
||||
member=member,
|
||||
team_id=team_id,
|
||||
join_date=target_date,
|
||||
leave_date=next_leave_bound,
|
||||
changed_by=changed_by,
|
||||
)
|
||||
|
||||
_sync_teams_if_needed(member, target_date, today, sync_current)
|
||||
return new_history
|
||||
|
||||
|
||||
def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool):
|
||||
"""Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui."""
|
||||
if sync_current and target_date <= today:
|
||||
sync_member_current_teams(member, today)
|
||||
|
||||
|
||||
def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = None):
|
||||
"""
|
||||
Synchronise le champ ManyToMany member.teams avec l'équipe active
|
||||
dans CompanyMemberHistory pour la date spécifiée (par défaut aujourd'hui).
|
||||
"""
|
||||
if for_date is None:
|
||||
for_date = timezone.now().date()
|
||||
|
||||
active_team = get_member_team_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()
|
||||
|
||||
if current_team_ids != target_team_ids:
|
||||
# Utiliser .set() pour synchroniser
|
||||
member.teams.set(list(target_team_ids))
|
||||
|
||||
|
||||
def get_teams_composition_for_period(
|
||||
team_ids: Set[int],
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> Dict[tuple, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date].
|
||||
Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id, 'role': role}]
|
||||
"""
|
||||
histories = (
|
||||
CompanyMemberHistory.objects
|
||||
.filter(
|
||||
team_id__in=team_ids,
|
||||
join_date__lte=end_date,
|
||||
)
|
||||
.filter(
|
||||
Q(leave_date__isnull=True) | Q(leave_date__gte=start_date)
|
||||
)
|
||||
.select_related('member', 'member__user')
|
||||
)
|
||||
|
||||
# Récupérer les rôles et informations utiles
|
||||
member_histories = defaultdict(lambda: {'member': None, 'histories': []})
|
||||
for h in histories:
|
||||
member_histories[h.member_id]['member'] = h.member
|
||||
member_histories[h.member_id]['histories'].append({
|
||||
'team_id': h.team_id,
|
||||
'join_date': h.join_date,
|
||||
'leave_date': h.leave_date,
|
||||
})
|
||||
|
||||
num_days = (end_date - start_date).days + 1
|
||||
team_compositions_sets = defaultdict(set)
|
||||
|
||||
for i in range(num_days):
|
||||
d = start_date + timedelta(days=i)
|
||||
for m_id, m_data in member_histories.items():
|
||||
member = m_data['member']
|
||||
# Trouver les historiques actifs à la date d
|
||||
active = []
|
||||
for h in m_data['histories']:
|
||||
if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d):
|
||||
active.append(h)
|
||||
|
||||
if not active:
|
||||
continue
|
||||
|
||||
# En cas de multiplicité, privilégier celui ayant join_date le plus récent
|
||||
if len(active) > 1:
|
||||
active.sort(key=lambda x: x['join_date'], reverse=True)
|
||||
active = [active[0]]
|
||||
|
||||
for h in active:
|
||||
team_compositions_sets[(h['team_id'], d)].add(
|
||||
(member.id, member.name, getattr(member.user, 'id', None))
|
||||
)
|
||||
|
||||
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
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def get_member_absences_for_period(
|
||||
member_ids: Optional[Set[int]],
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> Dict[tuple, Dict[str, Any]]:
|
||||
"""
|
||||
Récupère toutes les absences sur la période [start_date, end_date].
|
||||
Retourne un dict: (member_id, date) -> {'reason': str, 'reason_display': str, 'comment': str}
|
||||
"""
|
||||
qs = CompanyMemberAbsence.objects.filter(
|
||||
date__range=(start_date, end_date)
|
||||
)
|
||||
if member_ids is not None:
|
||||
qs = qs.filter(member_id__in=member_ids)
|
||||
|
||||
absences = {}
|
||||
for a in qs:
|
||||
absences[(a.member_id, a.date)] = {
|
||||
'id': a.id,
|
||||
'reason': a.reason,
|
||||
'reason_display': a.get_reason_display(),
|
||||
'comment': a.comment,
|
||||
}
|
||||
return absences
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def toggle_member_absence(
|
||||
member: CompanyMember,
|
||||
target_date: date,
|
||||
is_absent: bool = True,
|
||||
reason: str = 'conge',
|
||||
comment: str = '',
|
||||
created_by=None,
|
||||
) -> Optional[CompanyMemberAbsence]:
|
||||
"""Active ou désactive l'absence d'un membre pour une date donnée."""
|
||||
if is_absent:
|
||||
obj, _ = CompanyMemberAbsence.objects.update_or_create(
|
||||
member=member,
|
||||
date=target_date,
|
||||
defaults={
|
||||
'reason': reason or 'conge',
|
||||
'comment': comment or '',
|
||||
'created_by': created_by,
|
||||
}
|
||||
)
|
||||
return obj
|
||||
else:
|
||||
CompanyMemberAbsence.objects.filter(member=member, date=target_date).delete()
|
||||
return None
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def duplicate_day_schedule(
|
||||
source_date: date,
|
||||
target_date: date,
|
||||
allowed_team_ids: Optional[Set[int]] = None,
|
||||
copy_absences: bool = False,
|
||||
changed_by=None,
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Duplique l'affectation des équipes d'une journée source vers une journée cible.
|
||||
Si allowed_team_ids est fourni, seules les équipes autorisées sont dupliquées.
|
||||
"""
|
||||
# 1. Trouver tous les membres et leur équipe sur source_date
|
||||
all_members = CompanyMember.objects.all()
|
||||
if allowed_team_ids is not None:
|
||||
# Membres qui étaient dans les équipes autorisées ou non assignés
|
||||
pass
|
||||
|
||||
assigned_count = 0
|
||||
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
|
||||
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):
|
||||
set_member_team_schedule(
|
||||
member=member,
|
||||
team_id=None,
|
||||
target_date=target_date,
|
||||
changed_by=changed_by,
|
||||
sync_current=False,
|
||||
)
|
||||
|
||||
if copy_absences:
|
||||
source_absences = CompanyMemberAbsence.objects.filter(date=source_date)
|
||||
for a in source_absences:
|
||||
CompanyMemberAbsence.objects.update_or_create(
|
||||
member=a.member,
|
||||
date=target_date,
|
||||
defaults={
|
||||
'reason': a.reason,
|
||||
'comment': a.comment,
|
||||
'created_by': changed_by,
|
||||
}
|
||||
)
|
||||
absences_count += 1
|
||||
|
||||
# Synchronisation globale pour aujourd'hui
|
||||
today = timezone.now().date()
|
||||
if target_date <= today:
|
||||
for m in all_members:
|
||||
sync_member_current_teams(m, today)
|
||||
|
||||
return {'assigned_count': assigned_count, 'absences_count': absences_count}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def duplicate_week_schedule(
|
||||
source_monday: date,
|
||||
target_monday: date,
|
||||
allowed_team_ids: Optional[Set[int]] = None,
|
||||
copy_absences: bool = False,
|
||||
changed_by=None,
|
||||
) -> Dict[str, int]:
|
||||
"""Duplique une semaine complète (7 jours) vers une autre semaine."""
|
||||
total_assigned = 0
|
||||
total_absences = 0
|
||||
|
||||
for i in range(7):
|
||||
s_day = source_monday + timedelta(days=i)
|
||||
t_day = target_monday + timedelta(days=i)
|
||||
res = duplicate_day_schedule(
|
||||
source_date=s_day,
|
||||
target_date=t_day,
|
||||
allowed_team_ids=allowed_team_ids,
|
||||
copy_absences=copy_absences,
|
||||
changed_by=changed_by,
|
||||
)
|
||||
total_assigned += res['assigned_count']
|
||||
total_absences += res['absences_count']
|
||||
|
||||
return {'assigned_count': total_assigned, 'absences_count': total_absences}
|
||||
File diff suppressed because it is too large
Load diff
204
loko/contracts/test_planning.py
Normal file
204
loko/contracts/test_planning.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import json
|
||||
from datetime import date, timedelta
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence
|
||||
from contracts.services_teams import (
|
||||
get_member_team_for_date,
|
||||
set_member_team_schedule,
|
||||
get_teams_composition_for_period,
|
||||
get_member_absences_for_period,
|
||||
toggle_member_absence,
|
||||
duplicate_day_schedule,
|
||||
duplicate_week_schedule,
|
||||
sync_member_current_teams,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TeamPlanningServicesTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="alice", password="password123")
|
||||
self.company = Company.objects.create(name="Acme Corp")
|
||||
self.team_a = CompanyTeam.objects.create(name="Team A", company=self.company)
|
||||
self.team_b = CompanyTeam.objects.create(name="Team B", company=self.company)
|
||||
self.member_alice = CompanyMember.objects.create(name="Alice", user=self.user)
|
||||
|
||||
def test_schedule_future_team_change_preserves_today(self):
|
||||
"""
|
||||
Scénario clé :
|
||||
- Alice est dans l'équipe A aujourd'hui (02/09).
|
||||
- Le coordinateur planifie Alice dans l'équipe B pour demain (03/09).
|
||||
- Alice doit rester dans l'équipe A aujourd'hui et être dans l'équipe B demain.
|
||||
"""
|
||||
today = date(2026, 9, 2)
|
||||
tomorrow = date(2026, 9, 3)
|
||||
|
||||
# Alice est affectée à l'équipe A depuis le 01/09
|
||||
set_member_team_schedule(self.member_alice, self.team_a.id, date(2026, 9, 1))
|
||||
|
||||
# Vérifier qu'elle est bien dans l'équipe A aujourd'hui
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, today), self.team_a)
|
||||
|
||||
# Planifier l'équipe B pour demain
|
||||
set_member_team_schedule(self.member_alice, self.team_b.id, tomorrow)
|
||||
|
||||
# Vérifier : aujourd'hui elle est TOUJOURS dans l'équipe A
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, today), self.team_a)
|
||||
# Vérifier : demain elle sera dans l'équipe B
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, tomorrow), self.team_b)
|
||||
# Vérifier : après-demain elle reste dans l'équipe B (persistance)
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 4)), self.team_b)
|
||||
|
||||
def test_schedule_unassign_for_future_date(self):
|
||||
"""Tester la désassignation à une date future."""
|
||||
d1 = date(2026, 9, 1)
|
||||
d2 = date(2026, 9, 5)
|
||||
|
||||
set_member_team_schedule(self.member_alice, self.team_a.id, d1)
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 3)), self.team_a)
|
||||
|
||||
# Désassigner à partir du 5 septembre
|
||||
set_member_team_schedule(self.member_alice, None, d2)
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 4)), self.team_a)
|
||||
self.assertIsNone(get_member_team_for_date(self.member_alice, d2))
|
||||
self.assertIsNone(get_member_team_for_date(self.member_alice, date(2026, 9, 10)))
|
||||
|
||||
def test_toggle_absence(self):
|
||||
"""Tester l'activation et désactivation d'une absence."""
|
||||
target_date = date(2026, 9, 3)
|
||||
|
||||
# Activer l'absence
|
||||
absence = toggle_member_absence(self.member_alice, target_date, is_absent=True, reason='maladie', comment='Grippe')
|
||||
self.assertIsNotNone(absence)
|
||||
self.assertEqual(CompanyMemberAbsence.objects.filter(member=self.member_alice, date=target_date).count(), 1)
|
||||
|
||||
absences = get_member_absences_for_period({self.member_alice.id}, target_date, target_date)
|
||||
self.assertIn((self.member_alice.id, target_date), absences)
|
||||
self.assertEqual(absences[(self.member_alice.id, target_date)]['reason'], 'maladie')
|
||||
|
||||
# Désactiver l'absence
|
||||
toggle_member_absence(self.member_alice, target_date, is_absent=False)
|
||||
self.assertEqual(CompanyMemberAbsence.objects.filter(member=self.member_alice, date=target_date).count(), 0)
|
||||
|
||||
def test_duplicate_day_schedule(self):
|
||||
"""Tester la duplication de configuration d'un jour vers un autre."""
|
||||
monday = date(2026, 9, 7)
|
||||
tuesday = date(2026, 9, 8)
|
||||
|
||||
# Lundi : Alice en Team A et absente
|
||||
set_member_team_schedule(self.member_alice, self.team_a.id, monday)
|
||||
toggle_member_absence(self.member_alice, monday, is_absent=True, reason='conge')
|
||||
|
||||
# Dupliquer vers mardi avec copie des absences
|
||||
res = duplicate_day_schedule(monday, tuesday, copy_absences=True)
|
||||
self.assertGreaterEqual(res['assigned_count'], 1)
|
||||
self.assertEqual(res['absences_count'], 1)
|
||||
|
||||
# Vérifier l'affectation du mardi
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, tuesday), self.team_a)
|
||||
self.assertTrue(CompanyMemberAbsence.objects.filter(member=self.member_alice, date=tuesday).exists())
|
||||
|
||||
def test_duplicate_week_schedule(self):
|
||||
"""Tester la duplication d'une semaine complète vers la semaine suivante."""
|
||||
w1_monday = date(2026, 9, 7)
|
||||
w2_monday = date(2026, 9, 14)
|
||||
|
||||
set_member_team_schedule(self.member_alice, self.team_a.id, w1_monday)
|
||||
res = duplicate_week_schedule(w1_monday, w2_monday, copy_absences=False)
|
||||
|
||||
self.assertGreaterEqual(res['assigned_count'], 1)
|
||||
# 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)
|
||||
|
||||
|
||||
class TeamPlanningAjaxViewsTests(TestCase):
|
||||
def setUp(self):
|
||||
from common.models import UserConfig, Role
|
||||
import json
|
||||
|
||||
self.user = User.objects.create_user(username="admin_user", password="password123")
|
||||
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
|
||||
admin_role = Role.objects.create(name="admin")
|
||||
self.user_config.roles.add(admin_role)
|
||||
|
||||
self.company = Company.objects.create(name="Acme Corp")
|
||||
self.team_a = CompanyTeam.objects.create(name="Team A", company=self.company)
|
||||
self.team_b = CompanyTeam.objects.create(name="Team B", company=self.company)
|
||||
|
||||
self.tech_user = User.objects.create_user(username="alice", password="password123")
|
||||
self.member_alice = CompanyMember.objects.create(name="Alice", user=self.tech_user)
|
||||
|
||||
self.client.login(username="admin_user", password="password123")
|
||||
|
||||
def test_planning_data_ajax(self):
|
||||
url = reverse('contracts:team_planning_data_ajax')
|
||||
response = self.client.get(url, {'date': '2026-09-02', 'view_mode': 'week'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn('days', data)
|
||||
self.assertEqual(len(data['days']), 7)
|
||||
self.assertIn('teams', data)
|
||||
self.assertIn('members', data)
|
||||
|
||||
def test_planning_assign_member_ajax(self):
|
||||
url = reverse('contracts:team_planning_assign_member_ajax')
|
||||
payload = {
|
||||
'member_id': self.member_alice.id,
|
||||
'team_id': self.team_b.id,
|
||||
'date': '2026-09-03',
|
||||
}
|
||||
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()['success'])
|
||||
|
||||
# Vérifier l'affectation
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 3)), self.team_b)
|
||||
|
||||
def test_planning_toggle_absence_ajax(self):
|
||||
url = reverse('contracts:team_planning_toggle_absence_ajax')
|
||||
payload = {
|
||||
'member_id': self.member_alice.id,
|
||||
'date': '2026-09-04',
|
||||
'is_absent': True,
|
||||
'reason': 'maladie',
|
||||
'comment': 'Rhume',
|
||||
}
|
||||
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()['is_absent'])
|
||||
self.assertEqual(CompanyMemberAbsence.objects.filter(member=self.member_alice, date='2026-09-04').count(), 1)
|
||||
|
||||
def test_planning_duplicate_ajax(self):
|
||||
# Assigner Alice le 07/09
|
||||
set_member_team_schedule(self.member_alice, self.team_a.id, date(2026, 9, 7))
|
||||
|
||||
url = reverse('contracts:team_planning_duplicate_ajax')
|
||||
payload = {
|
||||
'mode': 'day',
|
||||
'source_date': '2026-09-07',
|
||||
'target_date': '2026-09-08',
|
||||
'copy_absences': False,
|
||||
}
|
||||
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()['success'])
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 8)), self.team_a)
|
||||
|
||||
def test_teams_move_member_syncs_history(self):
|
||||
url = reverse('contracts:teams_move_member', args=[self.member_alice.id])
|
||||
payload = {
|
||||
'team_id': self.team_a.id,
|
||||
'old_team_id': None,
|
||||
}
|
||||
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()['success'])
|
||||
|
||||
today = timezone.now().date()
|
||||
self.assertEqual(get_member_team_for_date(self.member_alice, today), self.team_a)
|
||||
|
||||
|
|
@ -13,6 +13,11 @@ urlpatterns = [
|
|||
path('teams/members/<int:member_id>/update/', views_teams.teams_update_member, name='teams_update_member'),
|
||||
path('teams/members/<int:member_id>/delete/', views_teams.teams_delete_member, name='teams_delete_member'),
|
||||
path('teams/<int:team_id>/interventions/', views_teams.team_interventions_json, name='team_interventions_json'),
|
||||
# Planification des équipes (Calendrier)
|
||||
path('teams/planning/data/', views_teams.team_planning_data_ajax, name='team_planning_data_ajax'),
|
||||
path('teams/planning/assign/', views_teams.team_planning_assign_member_ajax, name='team_planning_assign_member_ajax'),
|
||||
path('teams/planning/absence/', views_teams.team_planning_toggle_absence_ajax, name='team_planning_toggle_absence_ajax'),
|
||||
path('teams/planning/duplicate/', views_teams.team_planning_duplicate_ajax, name='team_planning_duplicate_ajax'),
|
||||
path("", views.contracts_index, name="contracts_index"),
|
||||
path('<int:contract_id>/', views.contracts_detail, name='contracts_detail'),
|
||||
path('orders/available/', views.available_orders, name='available_orders'),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from datetime import date, timedelta, datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
|
@ -6,15 +7,28 @@ from django.core.exceptions import PermissionDenied
|
|||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from contracts.models import CompanyMember, CompanyTeam
|
||||
from contracts.models import CompanyMember, CompanyTeam, CompanyMemberAbsence
|
||||
from contracts.permissions import (
|
||||
get_viewable_teams_for_user,
|
||||
user_can_edit_specific_team,
|
||||
user_can_edit_teams,
|
||||
user_can_view_teams,
|
||||
)
|
||||
from contracts.services_teams import (
|
||||
set_member_team_schedule,
|
||||
get_member_team_for_date,
|
||||
get_teams_composition_for_period,
|
||||
get_member_absences_for_period,
|
||||
toggle_member_absence,
|
||||
duplicate_day_schedule,
|
||||
duplicate_week_schedule,
|
||||
sync_member_current_teams,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -135,10 +149,9 @@ def teams_move_member(request, member_id):
|
|||
old_team = None
|
||||
if old_team_id is not None:
|
||||
old_team = get_object_or_404(CompanyTeam, id=old_team_id)
|
||||
member.teams.remove(old_team)
|
||||
|
||||
if new_team_id is not None:
|
||||
member.teams.add(new_team)
|
||||
today = timezone.now().date()
|
||||
set_member_team_schedule(member, new_team_id, today, changed_by=request.user)
|
||||
|
||||
member.save()
|
||||
|
||||
|
|
@ -315,12 +328,9 @@ def teams_update_member(request, member_id):
|
|||
else:
|
||||
new_team = None
|
||||
|
||||
# Effectuer le transfert
|
||||
if old_team_id is not None:
|
||||
old_team = get_object_or_404(CompanyTeam, id=old_team_id)
|
||||
member.teams.remove(old_team)
|
||||
if new_team_id is not None:
|
||||
member.teams.add(new_team)
|
||||
# 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)
|
||||
|
||||
member.name = new_name
|
||||
member.save()
|
||||
|
|
@ -369,3 +379,334 @@ def team_interventions_json(request, team_id):
|
|||
})
|
||||
|
||||
return JsonResponse({'interventions': data, 'team_name': team.name})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vues AJAX de planification avancée des équipes (Onglet Calendrier)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def team_planning_data_ajax(request):
|
||||
"""
|
||||
Retourne la matrice complète de planification des équipes sur une période.
|
||||
Paramètres GET :
|
||||
- date : date d'ancrage (YYYY-MM-DD), par défaut aujourd'hui
|
||||
- view_mode : 'week' (7j, défaut), 'two_weeks' (14j), 'work_week' (5j)
|
||||
- company_id : filtrer sur une société spécifique (optionnel)
|
||||
"""
|
||||
if not user_can_view_teams(request.user):
|
||||
raise PermissionDenied("Vous n'avez pas accès à la gestion des équipes.")
|
||||
|
||||
# 1. Déterminer la période
|
||||
date_str = request.GET.get('date')
|
||||
anchor_date = parse_date(date_str) if date_str else timezone.now().date()
|
||||
if not anchor_date:
|
||||
anchor_date = timezone.now().date()
|
||||
|
||||
view_mode = request.GET.get('view_mode', 'week')
|
||||
monday = anchor_date - timedelta(days=anchor_date.weekday())
|
||||
|
||||
if view_mode == 'work_week':
|
||||
num_days = 5
|
||||
start_date = monday
|
||||
end_date = monday + timedelta(days=4)
|
||||
elif view_mode == 'two_weeks':
|
||||
num_days = 14
|
||||
start_date = monday
|
||||
end_date = monday + timedelta(days=13)
|
||||
else: # 'week' (default)
|
||||
num_days = 7
|
||||
start_date = monday
|
||||
end_date = monday + timedelta(days=6)
|
||||
|
||||
today = timezone.now().date()
|
||||
|
||||
# Construire la liste des jours
|
||||
DAYS_FR_SHORT = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
DAYS_FR_FULL = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']
|
||||
MONTHS_FR = ['', 'Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc']
|
||||
|
||||
days_list = []
|
||||
for i in range(num_days):
|
||||
d = start_date + timedelta(days=i)
|
||||
w_idx = d.weekday()
|
||||
days_list.append({
|
||||
'date': d.isoformat(),
|
||||
'day_name': DAYS_FR_FULL[w_idx],
|
||||
'day_short': DAYS_FR_SHORT[w_idx],
|
||||
'day_num': f"{d.day:02d}/{d.month:02d}",
|
||||
'formatted': f"{DAYS_FR_SHORT[w_idx]} {d.day:02d} {MONTHS_FR[d.month]}",
|
||||
'is_today': d == today,
|
||||
'is_weekend': w_idx >= 5,
|
||||
})
|
||||
|
||||
# 2. Récupérer les équipes autorisées
|
||||
teams_qs = get_viewable_teams_for_user(request.user).select_related('company')
|
||||
raw_company_id = request.GET.get('company_id')
|
||||
if raw_company_id:
|
||||
try:
|
||||
teams_qs = teams_qs.filter(company_id=int(raw_company_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
teams = list(teams_qs.order_by('company__name', 'name'))
|
||||
team_ids = {t.id for t in teams}
|
||||
|
||||
# 3. Récupérer la composition des équipes sur la période
|
||||
compositions = get_teams_composition_for_period(team_ids, start_date, end_date)
|
||||
|
||||
# 4. Récupérer tous les membres disponibles pour la sidebar
|
||||
is_admin_user = _is_admin(request.user)
|
||||
user_company = _get_user_company(request.user)
|
||||
|
||||
all_members_qs = CompanyMember.objects.select_related('user', 'user__config').order_by('name')
|
||||
if not is_admin_user and user_company is not None:
|
||||
all_members_qs = all_members_qs.filter(user__config__company=user_company)
|
||||
|
||||
all_members = list(all_members_qs)
|
||||
all_member_ids = {m.id for m in all_members}
|
||||
|
||||
# 5. Récupérer les absences
|
||||
absences = get_member_absences_for_period(all_member_ids, start_date, end_date)
|
||||
|
||||
# 6. Structurer les données des équipes
|
||||
teams_data = []
|
||||
for t in teams:
|
||||
can_edit_this = user_can_edit_specific_team(request.user, t.id)
|
||||
days_dict = {}
|
||||
for i in range(num_days):
|
||||
d = start_date + timedelta(days=i)
|
||||
d_str = d.isoformat()
|
||||
members_on_day = []
|
||||
for m in compositions.get((t.id, d), []):
|
||||
m_id = m['id']
|
||||
abs_info = absences.get((m_id, d))
|
||||
members_on_day.append({
|
||||
'id': m_id,
|
||||
'name': m['name'],
|
||||
'user_id': m.get('user_id'),
|
||||
'is_absent': abs_info is not None,
|
||||
'absence': abs_info,
|
||||
})
|
||||
days_dict[d_str] = members_on_day
|
||||
|
||||
teams_data.append({
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'company_id': t.company_id,
|
||||
'company_name': t.company.name if t.company else '',
|
||||
'can_edit': can_edit_this,
|
||||
'days': days_dict,
|
||||
})
|
||||
|
||||
# 7. Structurer les membres pour le volet latéral (Sidebar)
|
||||
members_data = []
|
||||
for m in all_members:
|
||||
roles = []
|
||||
if m.user and hasattr(m.user, 'config'):
|
||||
roles = list(m.user.config.roles.values_list('name', flat=True))
|
||||
|
||||
members_data.append({
|
||||
'id': m.id,
|
||||
'name': m.name,
|
||||
'user_display': m.user.get_full_name() or m.user.username if m.user else '',
|
||||
'roles': roles,
|
||||
'company_ids': list(m.teams.values_list('company_id', flat=True)),
|
||||
})
|
||||
|
||||
# 8. Récupérer les sociétés pour le filtre de la toolbar
|
||||
companies_dict = {}
|
||||
for t in teams:
|
||||
if t.company_id and t.company:
|
||||
companies_dict[t.company_id] = t.company.name
|
||||
|
||||
companies_list = [{'id': cid, 'name': cname} for cid, cname in sorted(companies_dict.items(), key=lambda x: x[1])]
|
||||
|
||||
return JsonResponse({
|
||||
'days': days_list,
|
||||
'teams': teams_data,
|
||||
'members': members_data,
|
||||
'companies': companies_list,
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat(),
|
||||
'anchor_date': anchor_date.isoformat(),
|
||||
'view_mode': view_mode,
|
||||
'can_edit': user_can_edit_teams(request.user),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def team_planning_assign_member_ajax(request):
|
||||
"""
|
||||
Assigne ou déplace un membre vers une équipe à une date donnée (AJAX).
|
||||
Payload JSON : { member_id, team_id, date } (team_id peut être null pour désassigner)
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({"error": "JSON invalide"}, status=400)
|
||||
|
||||
member_id = data.get("member_id")
|
||||
raw_team_id = data.get("team_id")
|
||||
date_str = data.get("date")
|
||||
|
||||
if not member_id or not date_str:
|
||||
return JsonResponse({"error": "Paramètres member_id et date requis."}, status=400)
|
||||
|
||||
target_date = parse_date(date_str)
|
||||
if not target_date:
|
||||
return JsonResponse({"error": "Date invalide."}, status=400)
|
||||
|
||||
member = get_object_or_404(CompanyMember, id=member_id)
|
||||
viewable = get_viewable_teams_for_user(request.user)
|
||||
|
||||
team_id = int(raw_team_id) if raw_team_id is not None else None
|
||||
if team_id is not None:
|
||||
if not viewable.filter(id=team_id).exists():
|
||||
raise PermissionDenied("Accès refusé.")
|
||||
if not user_can_edit_specific_team(request.user, team_id):
|
||||
raise PermissionDenied("Droits d'édition insuffisants.")
|
||||
else:
|
||||
if not user_can_edit_teams(request.user):
|
||||
raise PermissionDenied("Droits d'édition insuffisants.")
|
||||
|
||||
# Effectuer la planification
|
||||
set_member_team_schedule(
|
||||
member=member,
|
||||
team_id=team_id,
|
||||
target_date=target_date,
|
||||
changed_by=request.user,
|
||||
sync_current=True,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
"success": True,
|
||||
"member_id": member.id,
|
||||
"member_name": member.name,
|
||||
"team_id": team_id,
|
||||
"date": target_date.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def team_planning_toggle_absence_ajax(request):
|
||||
"""
|
||||
Active ou désactive l'absence d'un membre pour une date donnée (AJAX).
|
||||
Payload JSON : { member_id, date, is_absent, reason, comment }
|
||||
"""
|
||||
if not user_can_edit_teams(request.user):
|
||||
raise PermissionDenied("Droits d'édition insuffisants.")
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({"error": "JSON invalide"}, status=400)
|
||||
|
||||
member_id = data.get("member_id")
|
||||
date_str = data.get("date")
|
||||
is_absent = bool(data.get("is_absent", True))
|
||||
reason = data.get("reason", "conge")
|
||||
comment = data.get("comment", "")
|
||||
|
||||
if not member_id or not date_str:
|
||||
return JsonResponse({"error": "Paramètres member_id et date requis."}, status=400)
|
||||
|
||||
target_date = parse_date(date_str)
|
||||
if not target_date:
|
||||
return JsonResponse({"error": "Date invalide."}, status=400)
|
||||
|
||||
member = get_object_or_404(CompanyMember, id=member_id)
|
||||
|
||||
absence_obj = toggle_member_absence(
|
||||
member=member,
|
||||
target_date=target_date,
|
||||
is_absent=is_absent,
|
||||
reason=reason,
|
||||
comment=comment,
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
"success": True,
|
||||
"member_id": member.id,
|
||||
"date": target_date.isoformat(),
|
||||
"is_absent": is_absent,
|
||||
"reason": absence_obj.reason if absence_obj else None,
|
||||
"reason_display": absence_obj.get_reason_display() if absence_obj else None,
|
||||
"comment": absence_obj.comment if absence_obj else "",
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def team_planning_duplicate_ajax(request):
|
||||
"""
|
||||
Duplique la configuration d'une journée ou d'une semaine vers une autre date cible (AJAX).
|
||||
Payload JSON : { mode: 'day'|'week', source_date, target_date, copy_absences }
|
||||
"""
|
||||
if not user_can_edit_teams(request.user):
|
||||
raise PermissionDenied("Droits d'édition insuffisants.")
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({"error": "JSON invalide"}, status=400)
|
||||
|
||||
mode = data.get("mode", "day")
|
||||
source_date_str = data.get("source_date")
|
||||
target_date_str = data.get("target_date")
|
||||
copy_absences = bool(data.get("copy_absences", False))
|
||||
|
||||
if not source_date_str or not target_date_str:
|
||||
return JsonResponse({"error": "Dates source et cible requises."}, status=400)
|
||||
|
||||
source_date = parse_date(source_date_str)
|
||||
target_date = parse_date(target_date_str)
|
||||
|
||||
if not source_date or not target_date:
|
||||
return JsonResponse({"error": "Format de date invalide."}, status=400)
|
||||
|
||||
# Récupérer les équipes que l'utilisateur a le droit d'éditer
|
||||
viewable = get_viewable_teams_for_user(request.user)
|
||||
if not _is_admin(request.user):
|
||||
from contracts.models import UserCompanyTeamAccess
|
||||
editable_ids = set(
|
||||
UserCompanyTeamAccess.objects.filter(
|
||||
user_config__user=request.user,
|
||||
can_edit=True
|
||||
).values_list('team_id', flat=True)
|
||||
)
|
||||
allowed_team_ids = set(viewable.filter(id__in=editable_ids).values_list('id', flat=True))
|
||||
else:
|
||||
allowed_team_ids = set(viewable.values_list('id', flat=True))
|
||||
|
||||
if mode == "week":
|
||||
# S'assurer de pointer sur les lundis
|
||||
source_monday = source_date - timedelta(days=source_date.weekday())
|
||||
target_monday = target_date - timedelta(days=target_date.weekday())
|
||||
res = duplicate_week_schedule(
|
||||
source_monday=source_monday,
|
||||
target_monday=target_monday,
|
||||
allowed_team_ids=allowed_team_ids,
|
||||
copy_absences=copy_absences,
|
||||
changed_by=request.user,
|
||||
)
|
||||
else:
|
||||
res = duplicate_day_schedule(
|
||||
source_date=source_date,
|
||||
target_date=target_date,
|
||||
allowed_team_ids=allowed_team_ids,
|
||||
copy_absences=copy_absences,
|
||||
changed_by=request.user,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
"success": True,
|
||||
"mode": mode,
|
||||
"assigned_count": res['assigned_count'],
|
||||
"absences_count": res['absences_count'],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ from interventions.views.drafts import InterventionDraftManager
|
|||
from interventions.utils import resolve_intervention_localisations
|
||||
|
||||
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
|
||||
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory
|
||||
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory, CompanyMemberAbsence
|
||||
from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order
|
||||
from projects.models import Project, ProjectIntervention
|
||||
from assets.models import (
|
||||
|
|
@ -990,10 +990,24 @@ def _interventions_events_by_teams_daily(
|
|||
for h in active:
|
||||
team_compositions_sets[(h['team_id'], d)].add((m_id, member_name))
|
||||
|
||||
# 5.b Récupérer les absences pour cette semaine
|
||||
absences_map = {
|
||||
(a.member_id, a.date): a.get_reason_display()
|
||||
for a in CompanyMemberAbsence.objects.filter(date__range=(start_date, end_date))
|
||||
}
|
||||
|
||||
# Convert sets to sorted lists of dicts for consistency and default to empty list on missing access
|
||||
team_compositions = defaultdict(list)
|
||||
for key, members_set in team_compositions_sets.items():
|
||||
team_compositions[key] = [{'id': mid, 'name': mname} for mid, mname in sorted(list(members_set), key=lambda x: x[1])]
|
||||
for (t_id, d_val), members_set in team_compositions_sets.items():
|
||||
team_compositions[(t_id, d_val)] = [
|
||||
{
|
||||
'id': mid,
|
||||
'name': mname,
|
||||
'is_absent': (mid, d_val) in absences_map,
|
||||
'absence_reason': absences_map.get((mid, d_val)),
|
||||
}
|
||||
for mid, mname in sorted(list(members_set), key=lambda x: x[1])
|
||||
]
|
||||
|
||||
# 6. Retrieve assignments for the 7 days
|
||||
# Filter assignments by the interventions list (which already has user view permissions and search filters applied!)
|
||||
|
|
|
|||
Loading…
Reference in a new issue