222 lines
9.8 KiB
Python
222 lines
9.8 KiB
Python
"""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
|