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) @transaction.atomic def delete(self, request, pk): instance = get_object_or_404(WorkSchedule, 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) instance = WorkSchedule.objects.select_for_update().get(pk=pk) previous = ( WorkSchedule.objects.select_for_update() .filter(user_id=instance.user_id, valid_from__lt=instance.valid_from) .order_by('-valid_from') .first() ) following = ( WorkSchedule.objects.select_for_update() .filter(user_id=instance.user_id, valid_from__gt=instance.valid_from) .order_by('valid_from') .first() ) if previous: previous.valid_to = following.valid_from - timedelta(days=1) if following else None previous.save(update_fields=['valid_to', 'updated_at']) user_id = instance.user_id instance.delete() validate_calendar(user_id) bump_revision(user_id) return Response(status=status.HTTP_204_NO_CONTENT) 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)