341 lines
16 KiB
Python
341 lines
16 KiB
Python
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)
|