feat: add CompanyMemberAbsence model and integrate absence display into the team planning calendar view

This commit is contained in:
kdeterme 2026-09-02 11:05:16 +02:00
parent 9429402292
commit faafa8ec23
9 changed files with 2301 additions and 222 deletions

View file

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

View 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')},
},
),
]

View file

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

View 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

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

View file

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

View file

@ -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'],
})

View file

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