from datetime import date from django.contrib.auth import get_user_model from rest_framework import serializers from notes.serializers import NoteUserSerializer from users.permissions import require_permission from .models import WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot, DayChangeRequest def checked_dates(start, end): if start < date(2000, 1, 1) or start > date(2099, 1, 1) or (end and (end < start or end > date(2099, 12, 31))): raise serializers.ValidationError('Choisissez des dates valides, dans l’ordre, entre 2000 et 2099.') class SlotInput(serializers.Serializer): start_time = serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']) end_time = serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S']) end_day_offset = serializers.IntegerField(min_value=0, max_value=1, default=0) def validate(self, attrs): start, end = attrs['start_time'], attrs['end_time'] if start.second or end.second or start.microsecond or end.microsecond: raise serializers.ValidationError('Les horaires doivent être précis à la minute.') duration = (end.hour * 60 + end.minute) + attrs['end_day_offset'] * 1440 - (start.hour * 60 + start.minute) if not 0 < duration <= 1440: raise serializers.ValidationError('Une plage de travail doit durer entre une minute et 24 heures. Vérifiez le jour et l’heure de fin.') return attrs class WeeklySlotInput(SlotInput): weekday = serializers.IntegerField(min_value=0, max_value=6) week_index = serializers.IntegerField(min_value=0, max_value=51, default=0) class DayTargetInput(serializers.Serializer): weekday = serializers.IntegerField(min_value=0, max_value=6) week_index = serializers.IntegerField(min_value=0, max_value=51, default=0) minutes = serializers.IntegerField(min_value=0, max_value=1440) class WeekTargetInput(serializers.Serializer): week_index = serializers.IntegerField(min_value=0, max_value=51) minutes = serializers.IntegerField(min_value=0, max_value=10080) class ScheduleInput(serializers.Serializer): user_id = serializers.PrimaryKeyRelatedField(source='user', queryset=get_user_model().objects.filter(is_active=True)) valid_from = serializers.DateField() cycle_weeks = serializers.IntegerField(min_value=1, max_value=52, default=1) mode = serializers.ChoiceField(choices=WorkSchedule.Mode.choices, default=WorkSchedule.Mode.PRECISE) day_targets = DayTargetInput(many=True, allow_empty=True, max_length=364, default=list) week_targets = WeekTargetInput(many=True, allow_empty=True, max_length=52, default=list) expected_revision = serializers.IntegerField(min_value=0) slots = WeeklySlotInput(many=True, allow_empty=True, max_length=3640) def validate(self, attrs): checked_dates(attrs['valid_from'], None) counts = {} for slot in attrs['slots']: if slot['week_index'] >= attrs['cycle_weeks']: raise serializers.ValidationError('Une plage appartient à une semaine absente du cycle.') key = (slot['week_index'], slot['weekday']) counts[key] = counts.get(key, 0) + 1 if counts[key] > 10: raise serializers.ValidationError('Une journée peut contenir au maximum 10 plages de travail.') day_keys = set() for target in attrs['day_targets']: if target['week_index'] >= attrs['cycle_weeks']: raise serializers.ValidationError({'day_targets': 'Un objectif journalier appartient à une semaine absente du cycle.'}) key = (target['week_index'], target['weekday']) if key in day_keys: raise serializers.ValidationError({'day_targets': 'Un jour ne peut avoir qu’un seul nombre d’heures prévu.'}) day_keys.add(key) week_keys = set() for target in attrs['week_targets']: if target['week_index'] >= attrs['cycle_weeks']: raise serializers.ValidationError({'week_targets': 'Un objectif hebdomadaire appartient à une semaine absente du cycle.'}) if target['week_index'] in week_keys: raise serializers.ValidationError({'week_targets': 'Une semaine ne peut avoir qu’un seul total d’heures prévu.'}) week_keys.add(target['week_index']) return attrs class SlotSerializer(serializers.ModelSerializer): class Meta: model = WorkScheduleSlot fields = ['week_index', 'weekday', 'start_time', 'end_time', 'end_day_offset'] class ScheduleSerializer(serializers.ModelSerializer): slots = SlotSerializer(many=True, read_only=True) class Meta: model = WorkSchedule fields = ['id', 'user_id', 'valid_from', 'valid_to', 'cycle_weeks', 'mode', 'day_targets', 'week_targets', 'slots', 'updated_at'] class ExceptionSlotSerializer(serializers.ModelSerializer): class Meta: model = ExceptionSlot fields = ['start_time', 'end_time', 'end_day_offset'] class ExceptionSerializer(serializers.ModelSerializer): slots = ExceptionSlotSerializer(many=True, read_only=True) class Meta: model = ScheduleException fields = ['id', 'user_id', 'label', 'kind', 'mode', 'target_minutes', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks', 'slots', 'request_id'] class ExceptionInput(serializers.Serializer): user_id = serializers.PrimaryKeyRelatedField(source='user', queryset=get_user_model().objects.filter(is_active=True)) expected_revision = serializers.IntegerField(min_value=0) label = serializers.CharField(max_length=160, allow_blank=True, default='') kind = serializers.ChoiceField(choices=ScheduleException.Kind.choices) start_date = serializers.DateField() end_date = serializers.DateField(allow_null=True, default=None) recurring = serializers.BooleanField(default=False) weekdays = serializers.ListField(child=serializers.IntegerField(min_value=0, max_value=6), max_length=7, default=list) every_weeks = serializers.IntegerField(min_value=1, max_value=4, default=1) slots = SlotInput(many=True, allow_empty=True, max_length=10, default=list) def validate(self, attrs): if not attrs['recurring'] and not attrs['end_date']: attrs['end_date'] = attrs['start_date'] checked_dates(attrs['start_date'], attrs['end_date']) if attrs['recurring'] and not attrs['weekdays']: raise serializers.ValidationError({'weekdays': 'Choisissez au moins un jour de répétition.'}) attrs['weekdays'] = sorted(set(attrs['weekdays'])) if attrs['recurring'] else [] if attrs['kind'] == 'work' and not attrs['slots']: raise serializers.ValidationError({'slots': 'Ajoutez au moins un créneau.'}) if attrs['kind'] != 'work' and attrs['slots']: raise serializers.ValidationError({'slots': 'Un repos ou un congé ne comporte aucun créneau de travail.'}) return attrs class RequestInput(serializers.Serializer): expected_before = serializers.JSONField(required=False, write_only=True) kind = serializers.ChoiceField(choices=DayChangeRequest.Kind.choices) start_date = serializers.DateField() end_date = serializers.DateField(required=False) target_date = serializers.DateField(required=False, allow_null=True) target_user_id = serializers.PrimaryKeyRelatedField(source='target_user', queryset=get_user_model().objects.filter(is_active=True), required=False, allow_null=True) reason = serializers.CharField(max_length=2000, allow_blank=True, default='') proposed_slots = SlotInput(many=True, allow_empty=True, max_length=10, default=list) def validate(self, attrs): if attrs['kind'] == 'leave': require_permission(self.context['request'].user, 'request_leave') attrs['end_date'] = attrs.get('end_date') or attrs['start_date'] checked_dates(attrs['start_date'], attrs['end_date']) if (attrs['end_date'] - attrs['start_date']).days > 365: raise serializers.ValidationError('Une demande peut couvrir au maximum 366 jours.') if attrs['kind'] == 'swap': if attrs['end_date'] != attrs['start_date']: raise serializers.ValidationError('Un échange concerne une seule journée de votre planning.') other = attrs.get('target_user') if not other or other.pk == self.context['request'].user.pk: raise serializers.ValidationError({'target_user_id': 'Choisissez un autre employé pour l’échange.'}) if attrs.get('target_date'): checked_dates(attrs['target_date'], None) attrs['target_user'] = other elif attrs['kind'] == 'move': if attrs['end_date'] != attrs['start_date'] or not attrs.get('target_date'): raise serializers.ValidationError('Un déplacement concerne une journée de départ et une journée d’arrivée précises.') checked_dates(attrs['target_date'], None) if attrs['target_date'] == attrs['start_date']: raise serializers.ValidationError({'target_date': 'Choisissez un autre jour pour déplacer ces heures.'}) if attrs.get('target_user'): raise serializers.ValidationError({'target_user_id': 'Un déplacement reste sur votre propre planning.'}) elif attrs.get('target_date') or attrs.get('target_user'): raise serializers.ValidationError('La seconde journée est réservée aux échanges et déplacements.') if attrs['kind'] in ('hours', 'move') and not attrs['proposed_slots']: raise serializers.ValidationError({'proposed_slots': 'Ajoutez les horaires souhaités.'}) if attrs['kind'] not in ('hours', 'move') and attrs['proposed_slots']: raise serializers.ValidationError('Ces créneaux ne sont pas utilisés pour ce type de demande.') # Le JSON stocké utilise les mêmes horaires locaux que le planning. attrs['proposed_slots'] = [dict(start_time=s['start_time'].strftime('%H:%M'), end_time=s['end_time'].strftime('%H:%M'), end_day_offset=s['end_day_offset']) for s in attrs['proposed_slots']] return attrs class RequestSerializer(serializers.ModelSerializer): user = NoteUserSerializer(read_only=True) target_user = NoteUserSerializer(read_only=True) reviewed_by = NoteUserSerializer(read_only=True) class Meta: model = DayChangeRequest fields = ['id', 'user', 'kind', 'start_date', 'end_date', 'target_date', 'target_user', 'reason', 'proposed_slots', 'before', 'after', 'status', 'reviewed_by', 'reviewed_at', 'response', 'created_at', 'updated_at'] read_only_fields = fields