Louis Mazin 6a18a3caff clean
2026-10-02 17:10:37 +02:00

222 lines
9.8 KiB
Python
Raw Permalink 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.

"""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