misc: accept different durations for the same plage's date/types
gitea/ants-hub/pipeline/head This commit looks good Details

It is a misconfiguration but to prevent any blocking of the publishing
of available slots we are now accepting it. To allow updating of Plage
objects, Plage.duree is now part of the unique together index with date,
personnes, types_de_rdv and lieu.

If an empty plage is sent by chrono (no property "heure_debut"), all
plages for the same day, type_de_rdv and person number are deleted, in
this case only the duration is ignored.

IntervalSet is imported from chrono to simplify the aggregation of the
time spans (the error "les plages se chevauchent" is no more).
This commit is contained in:
Benjamin Dauvergne 2023-06-13 10:39:13 +02:00
parent 631f060f71
commit 67d129dbf5
4 changed files with 362 additions and 21 deletions

View File

@ -29,6 +29,7 @@ from ants_hub.data.models import (
RendezVous,
TypeDeRdv,
)
from ants_hub.interval import IntervalSet
from ants_hub.timezone import localtime, make_naive, now
from .ants import ANTS_TIMEZONE
@ -184,7 +185,7 @@ class RendezVousDisponibleView(View):
return lieu
def handle_plages_payload(self, lieu, plages, full=False):
by_date_and_type_and_personnes = collections.defaultdict(set)
by_date_and_type_and_personnes_and_duree = collections.defaultdict(IntervalSet)
day_is_full = set()
for plage in plages:
try:
@ -196,7 +197,7 @@ class RendezVousDisponibleView(View):
types_rdv.add(TypeDeRdv.from_ants_name(typ))
personnes = plage.get('personnes', 1)
if not plage.get('heure_debut'):
day_is_full.update((date, t, personnes) for t in types_rdv)
day_is_full.update((date, t, personnes, 0) for t in types_rdv)
continue
heure_debut = datetime.time.fromisoformat(plage['heure_debut'])
heure_fin = datetime.time.fromisoformat(plage['heure_fin'])
@ -210,33 +211,26 @@ class RendezVousDisponibleView(View):
) < datetime.timedelta(minutes=duree):
raise ValueError('différence heure de début et de fin inférieure à la durée')
for t in types_rdv:
by_date_and_type_and_personnes[(date, t, personnes)].add((heure_debut, heure_fin, duree))
by_date_and_type_and_personnes_and_duree[(date, t, personnes, duree)].add(
Horaire(heure_debut, heure_fin)
)
except ValueError as e:
raise ValidationError('plage %s: %s' % (plage, e))
plage_pks = set()
for x in set(by_date_and_type_and_personnes) | day_is_full:
date, type_rdv, personnes = x
if x in day_is_full:
for x in set(by_date_and_type_and_personnes_and_duree) | day_is_full:
date, type_rdv, personnes, duree = x
if (date, type_rdv, personnes, 0) in day_is_full:
_, count_by_model = lieu.plages.filter(
date=date, type_de_rdv=type_rdv, personnes=personnes
).delete()
self.plage_deleted += count_by_model.get('data.Plage', 0)
continue
values = list(by_date_and_type_and_personnes.get(x))
values.sort()
durees = list({duree for heure_debut, heure_fin, duree in values})
if len(durees) > 1:
raise ValidationError(
'plages %s: la durée varie pour les mêmes rendez-vous du même lieu' % values
)
duree = durees[0]
intervals = by_date_and_type_and_personnes_and_duree.get(x)
horaires = HoraireList()
for start, end, _ in values:
try:
horaires.append(Horaire(start=start, end=end))
except ValueError:
raise ValidationError('plages %s-%s: les périodes se chevauchent' % (date, values))
for interval in intervals:
# intervals cannot intersect by definition of IntervalSet
horaires.append(Horaire(start=interval.begin, end=interval.end))
try:
plage = lieu.plages.get(
date=date, type_de_rdv=type_rdv, personnes=personnes, horaires=horaires, duree=duree
@ -246,6 +240,7 @@ class RendezVousDisponibleView(View):
date=date,
type_de_rdv=type_rdv,
personnes=personnes,
duree=duree,
defaults={'horaires': horaires, 'duree': duree},
)
if created:

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.19 on 2023-06-13 08:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('data', '0003_config'),
]
operations = [
migrations.AlterUniqueTogether(
name='plage',
unique_together={('type_de_rdv', 'date', 'lieu', 'personnes', 'duree')},
),
]

View File

@ -47,7 +47,7 @@ class HoraireList(list):
return '\n'.join(str(h) for h in self)
def append(self, value):
assert isinstance(value, Horaire)
assert isinstance(value, Horaire), f'{value} is of type {type(value)} not Horaire'
if self and value.start < self[-1].end:
raise ValueError
super().append(value)
@ -379,7 +379,7 @@ class Plage(models.Model):
verbose_name = 'plage'
verbose_name_plural = 'plages'
unique_together = [
['type_de_rdv', 'date', 'lieu', 'personnes'],
['type_de_rdv', 'date', 'lieu', 'personnes', 'duree'],
]
db_table = 'ants_hub_plage'

329
src/ants_hub/interval.py Normal file
View File

@ -0,0 +1,329 @@
# chrono - agendas system
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import bisect
import typing
class Interval(typing.NamedTuple):
begin: typing.Any
end: typing.Any
def disjoint(self, other):
return self < other or self > other
def overlaps(self, other):
return not self.disjoint(other)
def __lt__(self, other):
return self[1] < other[0]
def __gt__(self, other):
return other[1] < self[0]
def union(self, other):
other = self.cast(other)
assert self.overlaps(other)
return Interval(min(self.begin, other.begin), max(self.end, other.end))
@classmethod
def cast(cls, other):
if isinstance(other, cls):
return other
return cls(*other)
class IntervalSet:
"""Store a set made of an union of disjoint open/closed intervals (it
currently does not really care about their openness), i.e.
S = [a[0], b[0]] [a[n-1], b[n-1]]
where
forall i < n. a_i < b_i
forall i < (n-1). b_i < a[i+1]
"""
__slots__ = ['begin', 'end']
def __init__(self, iterable=(), already_sorted=False):
"""
Initialize a new IntervalSet from a list of Interval or 2-tuple.
Iterable will be sorted, if it's already sorted use the
from_ordered() classmethod.
It's faster than using add() because intervals are merged as we
traverse the list, and self.begin and self.end are built in O(n)
time where len(iterable) = n.
"""
if not already_sorted:
iterable = sorted(iterable)
self.begin = []
self.end = []
last_begin, last_end = None, None
for begin, end in iterable:
# check good order property along the way
if last_begin and not (last_begin < begin or last_begin == begin and last_end <= end):
raise ValueError('not well ordered: ! %s <= %s' % ((last_begin, last_end), (begin, end)))
if self.begin and begin <= self.end[-1]:
self.end[-1] = max(self.end[-1], end)
else:
self.begin.append(begin)
self.end.append(end)
last_begin, last_end = begin, end
@classmethod
def simple(cls, begin, end=Ellipsis):
begin, end = cls._begin_or_interval(begin, end)
if hasattr(begin, 'keep_only_weekday_and_time'):
begin = begin.keep_only_weekday_and_time()
end = end.keep_only_weekday_and_time()
return cls.from_ordered([(begin, end)])
@classmethod
def from_ordered(cls, iterable):
return cls(iterable, already_sorted=True)
def __repr__(self):
return repr(list(map(tuple, self)))
@classmethod
def _begin_or_interval(cls, begin, end=Ellipsis):
if end is Ellipsis:
return begin
else:
return begin, end
def add(self, begin, end=Ellipsis):
"""Add a new interval to the set, eventually merging it with actual
ones.
It uses bisect_left() to maintaint the ordering of intervals.
"""
begin, end = self._begin_or_interval(begin, end)
# insert an interval by merging with previous and following intervals
# if they overlap
i = bisect.bisect_left(self.begin, begin)
# merge with previous intervals
while i > 0 and begin <= self.end[i - 1]:
# [begin, end] overlaps previous interval
# so remove it to merge them.
previous_begin = self.begin.pop(i - 1)
previous_end = self.end.pop(i - 1)
i = i - 1
if previous_begin < begin:
# but it does not include it, so replace if begin by previous.begin
begin = previous_begin
# [begin, end] is completely included in previous interval,
# replace end by previous.end
end = max(end, previous_end)
break
# merge with following
while i < len(self.begin) and self.begin[i] <= end:
# [begin, end] overlaps next interval, so remove it to merge them.
next_end = self.end.pop(i)
self.begin.pop(i)
if end < next_end:
# but it does not include it, so replace end by next.end
end = next_end
# no symetry with the previous "while" loop as .bisect_left()
# garanty that begin is left or equal to next.begin.
break
self.begin.insert(i, begin)
self.end.insert(i, end)
def overlaps(self, begin, end=Ellipsis):
"""
Find if the [begin, end] has a non-empty (or closed) overlaps with
one of the contained intervals.
"""
begin, end = self._begin_or_interval(begin, end)
# look for non-zero size overlap
i = bisect.bisect_left(self.begin, begin)
if i > 0 and begin < self.end[i - 1]:
return True
if i < len(self.begin) and self.begin[i] < end:
return True
return False
def __iter__(self):
"""
Generate the ordered list of included intervals as 2-tuples.
"""
return map(Interval._make, zip(self.begin, self.end))
def __eq__(self, other):
if isinstance(other, self.__class__):
return list(self) == list(other)
if hasattr(other, '__iter__'):
return self == self.cast(other)
return False
def __bool__(self):
return bool(self.begin)
@classmethod
def cast(cls, value):
if isinstance(value, cls):
return value
return cls(value)
def __rsub__(self, other):
return self.cast(other) - self
def __sub__(self, other):
l1 = iter(self)
l2 = iter(self.cast(other))
def gen():
state = 3
while True:
if state & 1:
c1 = next(l1, None)
if state & 2:
c2 = next(l2, None)
if not c1:
break
if not c2:
yield c1
for c1 in l1:
yield c1
break
if c1[1] <= c2[0]:
yield c1
state = 1
elif c2[1] <= c1[0]:
state = 2
elif c1[1] < c2[1]:
if c1[0] < c2[0]:
yield (c1[0], c2[0])
state = 1
elif c2[1] < c1[1]:
if c1[0] < c2[0]:
yield (c1[0], c2[0])
c1 = (c2[1], c1[1])
state = 2
elif c1[1] == c2[1]:
if c1[0] < c2[0]:
yield (c1[0], c2[0])
state = 3
else:
raise AssertionError('not reachable')
return self.__class__.from_ordered(gen())
def __radd__(self, other):
return self.cast(other).__add__(self)
def __add__(self, other):
l1 = iter(self)
l2 = iter(self.cast(other))
def gen():
state = 3
current = None
while True:
if state & 1:
c1 = next(l1, None)
if state & 2:
c2 = next(l2, None)
if current:
if not c1 and not c2:
yield current
break
if not c1:
if current < c2:
yield current
yield c2
break
if c2 < current:
yield c2
yield current
else:
yield current.union(c2)
break
if not c2:
if current < c1:
yield current
yield c1
break
if c1 < current:
yield c1
yield current
else:
yield current.union(c1)
break
if current < c1 and current < c2:
yield current
current = None
elif current.overlaps(c1) and current.overlaps(c2):
yield current.union(c1).union(c2)
current = None
state = 3
continue
elif current < c2:
yield current.union(c1)
current = None
state = 1
continue
else:
yield current.union(c2)
current = None
state = 2
continue
if not c1 and not c2:
# l1 and l2 are empty, stop
break
if not c1:
# l1 is empty, yield c2 and stop
yield c2
break
if not c2:
# l2 is empty, yield c1 and stop
yield c1
break
if c1 < c2:
# l1 is before l2, yield c1 and advance l1 only
yield c1
state = 1
continue
if c2 < c1:
# l2 is before l1, yield c2 and advance l2 only
yield c2
state = 2
continue
current = c1.union(c2)
state = 3
# finish by yielding from the not empty ones
yield from l1
yield from l2
return self.__class__.from_ordered(gen())
def min(self):
if self:
return self.begin[0]
return None
def max(self):
if self:
return self.end[-1]
return None