misc: accept different durations for the same plage's date/types
gitea/ants-hub/pipeline/head This commit looks good
Details
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:
parent
631f060f71
commit
67d129dbf5
|
@ -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:
|
||||
|
|
|
@ -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')},
|
||||
),
|
||||
]
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue