Louis Mazin 4db822aebe big maj
2026-10-05 17:52:28 +02:00

341 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import date, timedelta
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import generics, serializers, status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from users.permissions import (
has_management_permission, is_admin, management_target_ids,
require_management_permission,
)
from notes.serializers import NoteUserSerializer
from .models import PlanningState, WorkSchedule, ScheduleException, ExceptionSlot, DayChangeRequest
from .serializers import ScheduleInput, ScheduleSerializer, ExceptionInput, ExceptionSerializer, RequestInput, RequestSerializer, checked_dates
from .services import (PlanningConflict, lock_users, check_revision, bump_revision, save_schedule, save_exception,
effective_period, request_changes, snapshot, validate_calendar)
def admin_only(user):
if not is_admin(user):
raise PermissionDenied('Cette action est réservée aux administrateurs.')
def requested_user(request, admin=False, management_permission=None):
if admin:
admin_only(request.user)
raw = request.query_params.get('user_id', request.user.pk)
user_id = serializers.IntegerField(min_value=1).run_validation(raw)
if user_id != request.user.pk and not is_admin(request.user):
if management_permission:
require_management_permission(
request.user, management_permission, user_id,
'Vous n’avez pas accès à l’emploi du temps de ce salarié.',
)
else:
raise PermissionDenied('Vous pouvez uniquement consulter votre emploi du temps.')
return get_object_or_404(get_user_model(), pk=user_id)
def snapshot_matches(stored, current):
"""Compare aussi les demandes créées avant l'ajout des nouveaux champs de planning."""
return all(current.get(key) == value for key, value in stored.items())
def snapshot_list_matches(stored, current):
return len(stored) == len(current) and all(snapshot_matches(old, new) for old, new in zip(stored, current))
class PeopleView(generics.ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = NoteUserSerializer
queryset = get_user_model().objects.filter(is_active=True).order_by('first_name', 'last_name', 'username')
pagination_class = None
class ConfigurationView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
user = requested_user(request, management_permission='manage_schedules')
state = PlanningState.objects.filter(user=user).first()
return Response(dict(user=NoteUserSerializer(user).data, revision=state.revision if state else 0,
schedules=ScheduleSerializer(WorkSchedule.objects.filter(user=user).prefetch_related('slots'), many=True).data,
exceptions=ExceptionSerializer(ScheduleException.objects.filter(user=user).prefetch_related('slots'), many=True).data))
class EffectiveView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
user = requested_user(request)
field = serializers.DateField()
start = field.run_validation(request.query_params.get('start', timezone.localdate().isoformat()))
end = field.run_validation(request.query_params.get('end', (start + timedelta(days=6)).isoformat()))
checked_dates(start, end)
if (end - start).days > 365:
raise ValidationError('La période demandée dépasse 366 jours.')
return Response(effective_period(user.pk, start, end))
class ScheduleView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
data = ScheduleInput(data=request.data)
data.is_valid(raise_exception=True)
require_management_permission(
request.user, 'manage_schedules', data.validated_data['user'].pk,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
result = save_schedule(request.user, data.validated_data)
return Response(ScheduleSerializer(result).data, status=status.HTTP_201_CREATED)
class ExceptionView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
data = ExceptionInput(data=request.data)
data.is_valid(raise_exception=True)
require_management_permission(
request.user, 'manage_schedules', data.validated_data['user'].pk,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
result = save_exception(request.user, data.validated_data)
return Response(ExceptionSerializer(result).data, status=status.HTTP_201_CREATED)
def put(self, request, pk):
instance = get_object_or_404(ScheduleException, pk=pk)
require_management_permission(
request.user, 'manage_schedules', instance.user_id,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
data = ExceptionInput(data=request.data)
data.is_valid(raise_exception=True)
if data.validated_data['user'].pk != instance.user_id:
raise ValidationError('Une exception ne peut pas changer de salarié.')
result = save_exception(request.user, data.validated_data, instance)
return Response(ExceptionSerializer(result).data)
@transaction.atomic
def delete(self, request, pk):
instance = get_object_or_404(ScheduleException, pk=pk)
require_management_permission(
request.user, 'manage_schedules', instance.user_id,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
lock_users([instance.user_id])
revision = serializers.IntegerField(min_value=0).run_validation(request.data.get('expected_revision'))
check_revision(instance.user_id, revision)
user_id = instance.user_id
instance.delete()
validate_calendar(user_id)
bump_revision(user_id)
return Response(status=status.HTTP_204_NO_CONTENT)
class RequestPagination(PageNumberPagination):
page_size = 20
class RequestListView(generics.ListCreateAPIView):
permission_classes = [IsAuthenticated]
serializer_class = RequestSerializer
pagination_class = RequestPagination
def get_queryset(self):
rows = DayChangeRequest.objects.select_related('user', 'target_user', 'reviewed_by')
if not is_admin(self.request.user):
approval_targets = management_target_ids(self.request.user, 'approve_requests')
review_mode = self.request.query_params.get('review') in ('1', 'true', 'yes')
personal = Q(user=self.request.user) | Q(target_user=self.request.user)
if review_mode:
if approval_targets is None:
pass
elif approval_targets:
rows = rows.filter(user_id__in=approval_targets)
else:
rows = rows.none()
elif approval_targets is None:
pass
elif approval_targets:
rows = rows.filter(personal | Q(user_id__in=approval_targets))
else:
rows = rows.filter(personal)
state = self.request.query_params.get('status')
kind = self.request.query_params.get('kind')
user_id = self.request.query_params.get('user_id')
if state:
state = serializers.ChoiceField(choices=DayChangeRequest.Status.choices).run_validation(state)
rows = rows.filter(status=state)
if kind:
kind = serializers.ChoiceField(choices=DayChangeRequest.Kind.choices).run_validation(kind)
rows = rows.filter(kind=kind)
if user_id:
user_id = serializers.IntegerField(min_value=1).run_validation(user_id)
rows = rows.filter(Q(user_id=user_id) | Q(target_user_id=user_id))
return rows
@transaction.atomic
def create(self, request, *args, **kwargs):
data = RequestInput(data=request.data, context={'request': request})
data.is_valid(raise_exception=True)
values = data.validated_data
ids = [request.user.pk]
if values.get('target_user'):
ids.append(values['target_user'].pk)
lock_users(ids)
expected_before = values.pop('expected_before', None)
before, after = request_changes(request.user, values)
if expected_before is not None and expected_before != before:
raise PlanningConflict('Le planning a changé depuis l’aperçu. Vérifiez à nouveau votre demande.')
duplicate = DayChangeRequest.objects.filter(
user=request.user,
status='pending',
kind=values['kind'],
before=before,
after=after,
target_user=values.get('target_user'),
target_date=values.get('target_date'),
)
if duplicate.exists():
raise PlanningConflict('Une demande identique est déjà en attente.')
row = DayChangeRequest.objects.create(user=request.user, before=before, after=after, **values)
return Response(RequestSerializer(row).data, status=status.HTTP_201_CREATED)
class RequestPreviewView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
data = RequestInput(data=request.data, context={'request': request})
data.is_valid(raise_exception=True)
before, after = request_changes(request.user, data.validated_data)
return Response(dict(before=before, after=after))
class RequestSwapTargetDayView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, pk):
row = get_object_or_404(
DayChangeRequest.objects.select_related('target_user'),
pk=pk,
kind=DayChangeRequest.Kind.SWAP,
)
if not has_management_permission(request.user, 'approve_requests', row.user_id):
raise PermissionDenied('Vous n’avez pas l’autorisation de traiter cette demande.')
if not row.target_user_id:
raise ValidationError('Le collègue de cet échange n’est plus disponible.')
day = serializers.DateField().run_validation(request.query_params.get('date'))
checked_dates(day, None)
return Response(effective_period(row.target_user_id, day, day))
class RequestDecisionView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
def post(self, request, pk):
action = serializers.ChoiceField(choices=['approve', 'reject', 'cancel']).run_validation(request.data.get('action'))
response = serializers.CharField(max_length=2000, allow_blank=True).run_validation(request.data.get('response', ''))
row = get_object_or_404(DayChangeRequest, pk=pk)
if action == 'cancel' and row.user_id != request.user.pk:
raise PermissionDenied('Seul l’auteur peut annuler cette demande.')
if action != 'cancel' and not has_management_permission(request.user, 'approve_requests', row.user_id):
raise PermissionDenied('Vous n’avez pas l’autorisation de traiter les demandes de ce salarié.')
ids = {row.user_id}
if row.target_user_id:
ids.add(row.target_user_id)
ids.update(item['user_id'] for item in row.before)
lock_users(ids)
row = DayChangeRequest.objects.select_for_update().get(pk=pk)
if row.status != 'pending':
raise PlanningConflict('Cette demande a déjà été traitée.')
if action == 'approve':
before = row.before
after = row.after
if row.kind == DayChangeRequest.Kind.SWAP:
raw_target_date = request.data.get('target_date') or row.target_date
if not raw_target_date:
raise ValidationError({'target_date': 'Choisissez la journée de l’autre employé à échanger avant d’accepter la demande.'})
target_date = serializers.DateField().run_validation(raw_target_date)
checked_dates(target_date, None)
if not row.target_user_id:
raise ValidationError('Le collègue concerné par cet échange n’est plus disponible.')
# La journée demandée par le salarié doit être restée identique depuis l’envoi.
current_source = snapshot(row.user_id, row.start_date)
if not row.before or not snapshot_matches(row.before[0], current_source):
raise PlanningConflict('Le planning du salarié a changé depuis cette demande. Faites annuler puis renouveler la demande.')
values = dict(
kind=DayChangeRequest.Kind.SWAP,
start_date=row.start_date,
end_date=row.end_date,
target_date=target_date,
target_user=row.target_user,
reason=row.reason,
proposed_slots=[],
)
before, after = request_changes(row.user, values)
row.target_date = target_date
row.before = before
row.after = after
ids = {item['user_id'] for item in before}
else:
current = [snapshot(item['user_id'], date.fromisoformat(item['date'])) for item in row.before]
if not snapshot_list_matches(row.before, current):
raise PlanningConflict('Le planning a changé depuis cette demande. Faites annuler puis renouveler la demande avec les horaires actuels.')
ids = {item['user_id'] for item in row.before}
if get_user_model().objects.filter(pk__in=ids, is_active=True).count() != len(ids):
raise ValidationError('Un des salariés concernés n’est plus actif.')
for item in after:
schedule_mode = item.get('schedule_mode', 'precise')
target_minutes = item.get('target_minutes')
exception_mode = (
ScheduleException.Mode.DAILY_HOURS
if item['kind'] == 'work' and schedule_mode == 'daily_hours' and not item.get('slots')
else ScheduleException.Mode.PRECISE
)
exception = ScheduleException.objects.create(
user_id=item['user_id'],
start_date=item['date'],
end_date=item['date'],
kind=item['kind'],
mode=exception_mode,
target_minutes=target_minutes if exception_mode == ScheduleException.Mode.DAILY_HOURS else None,
label=row.get_kind_display(),
request=row,
created_by=request.user,
)
ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=exception, **slot) for slot in item.get('slots', [])])
for user_id in sorted(ids):
validate_calendar(user_id)
bump_revision(user_id)
row.status = 'approved'
else:
row.status = 'rejected' if action == 'reject' else 'cancelled'
row.reviewed_by = request.user
row.reviewed_at = timezone.now()
row.response = response
update_fields = ['status', 'reviewed_by', 'reviewed_at', 'response', 'updated_at']
if action == 'approve' and row.kind == DayChangeRequest.Kind.SWAP:
update_fields.extend(['target_date', 'before', 'after'])
row.save(update_fields=update_fields)
return Response(RequestSerializer(row).data)