"""Calcul unique des horaires prévus, partagé par la gestion et le récapitulatif.""" from copy import deepcopy from datetime import date, datetime, time, timedelta from math import lcm from django.contrib.auth import get_user_model from django.db import transaction from rest_framework.exceptions import APIException, ValidationError from .models import PlanningState, WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot class PlanningConflict(APIException): status_code = 409 default_detail = 'Le planning a changé. Actualisez avant de réessayer.' def lock_users(ids): # Ordre stable pour un échange entre deux salariés (évite les interblocages). list(get_user_model().objects.select_for_update().filter(pk__in=sorted(set(ids))).order_by('pk')) def state_for(user_id): return PlanningState.objects.get_or_create(user_id=user_id)[0] def check_revision(user_id, expected): state = state_for(user_id) if state.revision != expected: raise PlanningConflict() return state def bump_revision(user_id): state = state_for(user_id) state.revision += 1 state.save(update_fields=['revision']) def slot_data(slot): return dict(start_time=slot.start_time.strftime('%H:%M'), end_time=slot.end_time.strftime('%H:%M'), end_day_offset=slot.end_day_offset) def date_range(start, end): while start <= end: yield start start += timedelta(days=1) def load_plan(user_id): return ( list(WorkSchedule.objects.filter(user_id=user_id).prefetch_related('slots')), list(ScheduleException.objects.filter(user_id=user_id).prefetch_related('slots')), ) def rule_matches(rule, day): if day < rule.start_date or (rule.end_date and day > rule.end_date): return False if not rule.recurring: return True anchor = rule.start_date - timedelta(days=rule.start_date.weekday()) return day.weekday() in rule.weekdays and ((day - anchor).days // 7) % rule.every_weeks == 0 def effective_day(user_id, day, plan=None): schedules, rules = plan if plan is not None else load_plan(user_id) matches = [r for r in rules if rule_matches(r, day)] # Ponctuel > récurrent > semaine type ; à priorité égale, dernière création. rule = max(matches, key=lambda r: (not r.recurring, r.pk), default=None) if rule: kind, label = rule.kind, rule.label or rule.get_kind_display() slots = [slot_data(s) for s in rule.slots.all()] if kind == 'work' else [] source = 'exception' else: schedule = next((s for s in schedules if s.valid_from <= day and (not s.valid_to or s.valid_to >= day)), None) anchor = schedule.valid_from - timedelta(days=schedule.valid_from.weekday()) if schedule else day week_index = ((day - anchor).days // 7) % schedule.cycle_weeks if schedule else 0 slots = [slot_data(s) for s in schedule.slots.all() if s.weekday == day.weekday() and s.week_index == week_index] if schedule else [] kind = 'work' if slots else ('rest' if schedule else 'undefined') label = 'Emploi du temps habituel' if slots else ('Repos' if schedule else 'Emploi du temps non défini') source = 'schedule' if schedule else 'none' return dict(user_id=user_id, date=day.isoformat(), kind=kind, label=label, source=source, slots=slots) def period(slot, day): return (datetime.combine(day, time.fromisoformat(slot['start_time'])), datetime.combine(day + timedelta(days=slot.get('end_day_offset', 0)), time.fromisoformat(slot['end_time']))) def validate_calendar(user_id): """Vérifie une période complète de répétition entre chaque changement de règle. Le PPCM combine le cycle actif et les exceptions récurrentes. On contrôle aussi la jonction avec le jour précédent et le cycle suivant. """ plan = load_plan(user_id) boundaries = set() for schedule in plan[0]: boundaries.add(schedule.valid_from) if schedule.valid_to: boundaries.add(schedule.valid_to + timedelta(days=1)) for rule in plan[1]: boundaries.add(rule.start_date) if rule.end_date: boundaries.add(rule.end_date + timedelta(days=1)) days = set() ordered = sorted(boundaries) horizon = date(2100, 1, 1) for index, boundary in enumerate(ordered): following = ordered[index + 1] if index + 1 < len(ordered) else horizon active = next((s for s in plan[0] if s.valid_from <= boundary and (not s.valid_to or s.valid_to >= boundary)), None) cycle = active.cycle_weeks if active else 1 for rule in plan[1]: if rule.recurring and rule.start_date <= boundary and (not rule.end_date or rule.end_date >= boundary): cycle = lcm(cycle, rule.every_weeks) finish = min(following, boundary + timedelta(weeks=cycle)) days.update(date_range(boundary - timedelta(days=1), finish)) cache = {} def periods(day): if day not in cache: cache[day] = [period(s, day) for s in effective_day(user_id, day, plan)['slots']] return cache[day] for day in sorted(days): intervals = sorted(periods(day) + periods(day + timedelta(days=1))) for previous, current in zip(intervals, intervals[1:]): if current[0] < previous[1]: raise ValidationError({'slots': f'Chevauchement d’horaires le {current[0]:%d/%m/%Y}. Vérifiez aussi la veille et les horaires après minuit.'}) def effective_period(user_id, start, end): plan = load_plan(user_id) days = [effective_day(user_id, day, plan) for day in date_range(start, end)] segments = [] # Une vacation commencée la veille peut continuer dans la période demandée. for day in date_range(start - timedelta(days=1), end): data = effective_day(user_id, day, plan) for index, slot in enumerate(data['slots']): begin, finish = period(slot, day) cursor = begin while cursor < finish: boundary = datetime.combine(cursor.date() + timedelta(days=1), time.min) stop = min(finish, boundary) if start <= cursor.date() <= end: segments.append(dict( id=f'{user_id}-{day}-{index}-{cursor.date()}', date=cursor.date().isoformat(), start_time=cursor.strftime('%H:%M'), end_time='24:00' if stop == boundary else stop.strftime('%H:%M'), label=data['label'], source=data['source'], user_id=user_id, )) cursor = stop return dict(user_id=user_id, days=days, segments=segments) @transaction.atomic def save_schedule(actor, data): user = data['user'] lock_users([user.pk]) check_revision(user.pk, data['expected_revision']) start = data['valid_from'] schedule = WorkSchedule.objects.filter(user=user, valid_from=start).first() if schedule is None: previous = WorkSchedule.objects.filter(user=user, valid_from__lt=start).order_by('-valid_from').first() following = WorkSchedule.objects.filter(user=user, valid_from__gt=start).order_by('valid_from').first() if previous: previous.valid_to = start - timedelta(days=1) previous.save(update_fields=['valid_to', 'updated_at']) schedule = WorkSchedule.objects.create(user=user, valid_from=start, valid_to=following.valid_from - timedelta(days=1) if following else None, created_by=actor) schedule.slots.all().delete() schedule.cycle_weeks = data.get('cycle_weeks', 1) WorkScheduleSlot.objects.bulk_create([WorkScheduleSlot(schedule=schedule, **slot) for slot in data['slots']]) schedule.save() validate_calendar(user.pk) bump_revision(user.pk) return schedule @transaction.atomic def save_exception(actor, data, instance=None): user = instance.user if instance else data['user'] lock_users([user.pk]) check_revision(user.pk, data['expected_revision']) values = {key: data[key] for key in ['label', 'kind', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks']} if instance: instance = ScheduleException.objects.select_for_update().get(pk=instance.pk) for key, value in values.items(): setattr(instance, key, value) instance.save() else: instance = ScheduleException.objects.create(user=user, created_by=actor, **values) instance.slots.all().delete() ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=instance, **slot) for slot in data['slots']]) validate_calendar(user.pk) bump_revision(user.pk) return instance def snapshot(user_id, day): result = effective_day(user_id, day) # L'identité de la règle ne change pas le sens de la demande. return {key: result[key] for key in ['user_id', 'date', 'kind', 'slots']} def request_changes(user, data): before = [snapshot(user.pk, day) for day in date_range(data['start_date'], data['end_date'])] if data['kind'] == 'swap': other = data.get('target_user') or user other_day = data['target_date'] before.append(snapshot(other.pk, other_day)) if any(row['kind'] in ('absence', 'undefined') for row in before): raise ValidationError('Une inversion nécessite deux journées définies, travaillées ou de repos.') if not any(row['slots'] for row in before): raise ValidationError('Au moins une des deux journées doit être travaillée.') after = deepcopy(before) after[0]['kind'], after[0]['slots'] = before[1]['kind'], before[1]['slots'] after[1]['kind'], after[1]['slots'] = before[0]['kind'], before[0]['slots'] else: after = deepcopy(before) for row in after: row['kind'] = 'absence' if data['kind'] == 'leave' else 'work' row['slots'] = [] if data['kind'] == 'leave' else data['proposed_slots'] return before, after