1798 lines
71 KiB
Python
1798 lines
71 KiB
Python
# chrono - agendas system
|
|
# Copyright (C) 2016 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 collections
|
|
import datetime
|
|
import itertools
|
|
import uuid
|
|
|
|
from django.db import transaction
|
|
from django.db.models import Prefetch, Q
|
|
from django.http import Http404, HttpResponse
|
|
from django.shortcuts import get_object_or_404
|
|
from django.urls import reverse
|
|
from django.utils.dateparse import parse_date
|
|
from django.utils.encoding import force_text
|
|
from django.utils.formats import date_format
|
|
from django.utils.timezone import now, make_aware, localtime
|
|
from django.utils.translation import gettext_noop
|
|
from django.utils.translation import ugettext_lazy as _
|
|
|
|
from django_filters import rest_framework as filters
|
|
from rest_framework import permissions, serializers, status
|
|
from rest_framework.exceptions import ValidationError
|
|
from rest_framework.generics import ListAPIView
|
|
from rest_framework.views import APIView
|
|
|
|
from chrono.api.utils import Response, APIError
|
|
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk, BookingColor
|
|
from ..interval import IntervalSet
|
|
|
|
|
|
def format_response_datetime(dt):
|
|
return localtime(dt).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
def get_min_datetime(agenda, start_datetime=None):
|
|
if agenda.minimal_booking_delay is None:
|
|
return start_datetime
|
|
|
|
min_datetime = localtime(now()) + datetime.timedelta(days=agenda.minimal_booking_delay)
|
|
min_datetime = min_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
if start_datetime is None:
|
|
return min_datetime
|
|
return max(min_datetime, start_datetime)
|
|
|
|
|
|
def get_max_datetime(agenda, end_datetime=None):
|
|
if agenda.maximal_booking_delay is None:
|
|
return end_datetime
|
|
|
|
max_datetime = localtime(now()) + datetime.timedelta(days=agenda.maximal_booking_delay)
|
|
max_datetime = max_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
if end_datetime is None:
|
|
return max_datetime
|
|
return min(max_datetime, end_datetime)
|
|
|
|
|
|
TimeSlot = collections.namedtuple('TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk'])
|
|
|
|
|
|
def get_all_slots(
|
|
base_agenda,
|
|
meeting_type,
|
|
resources=None,
|
|
unique=False,
|
|
start_datetime=None,
|
|
end_datetime=None,
|
|
excluded_user_external_id=None,
|
|
):
|
|
"""Get all occupation state of all possible slots for the given agenda (of
|
|
its real agendas for a virtual agenda) and the given meeting_type.
|
|
|
|
The process is done in four phases:
|
|
- first phase: aggregate time intervals, during which a meeting is impossible
|
|
due to TimePeriodException models, by desk in IntervalSet (compressed
|
|
and ordered list of intervals).
|
|
- second phase: aggregate time intervals by desk for already booked slots, again
|
|
to make IntervalSet,
|
|
- third phase: for a meetings agenda, if resources has to be booked,
|
|
aggregate time intervals for already booked resources, to make IntervalSet.
|
|
- fourth and last phase: generate time slots from each time period based
|
|
on the time period definition and on the desk's respective agenda real
|
|
min/max_datetime; for each time slot check its status in the exclusion
|
|
and bookings sets.
|
|
If it is excluded, ignore it completely.
|
|
It if is booked, report the slot as full.
|
|
"""
|
|
resources = resources or []
|
|
# virtual agendas have one constraint :
|
|
# all the real agendas MUST have the same meetingstypes, the consequence is
|
|
# that the base_meeting_duration for the virtual agenda is always the same
|
|
# as the base meeting duration of each real agenda.
|
|
base_meeting_duration = base_agenda.get_base_meeting_duration()
|
|
base_min_datetime = get_min_datetime(base_agenda, start_datetime)
|
|
base_max_datetime = get_max_datetime(base_agenda, end_datetime)
|
|
|
|
meeting_duration = meeting_type.duration
|
|
meeting_duration_td = datetime.timedelta(minutes=meeting_duration)
|
|
|
|
now_datetime = now()
|
|
base_date = now_datetime.date()
|
|
agendas = base_agenda.get_real_agendas()
|
|
|
|
# regroup agendas by their opening period
|
|
agenda_ids_by_min_max_datetimes = collections.defaultdict(set)
|
|
agenda_id_min_max_datetime = {}
|
|
for agenda in agendas:
|
|
used_min_datetime = base_min_datetime or get_min_datetime(agenda, start_datetime)
|
|
used_max_datetime = base_max_datetime or get_max_datetime(agenda, end_datetime)
|
|
agenda_ids_by_min_max_datetimes[(used_min_datetime, used_max_datetime)].add(agenda.id)
|
|
agenda_id_min_max_datetime[agenda.id] = (used_min_datetime, used_max_datetime)
|
|
|
|
# aggregate time period exceptions by desk as IntervalSet for fast querying
|
|
# 1. sort exceptions by start_datetime
|
|
# 2. group them by desk
|
|
# 3. convert each desk's list of exception to intervals then IntervalSet
|
|
desks_exceptions = {
|
|
time_period_desk: IntervalSet.from_ordered(
|
|
map(TimePeriodException.as_interval, time_period_exceptions)
|
|
)
|
|
for time_period_desk, time_period_exceptions in itertools.groupby(
|
|
TimePeriodException.objects.filter(desk__agenda__in=agendas).order_by(
|
|
'desk_id', 'start_datetime', 'end_datetime'
|
|
),
|
|
key=lambda time_period: time_period.desk,
|
|
)
|
|
}
|
|
|
|
# add exceptions from unavailability calendar
|
|
time_period_exception_queryset = (
|
|
TimePeriodException.objects.all()
|
|
.select_related('unavailability_calendar')
|
|
.prefetch_related(
|
|
Prefetch(
|
|
'unavailability_calendar__desks',
|
|
queryset=Desk.objects.filter(agenda__in=agendas),
|
|
to_attr='prefetched_desks',
|
|
)
|
|
)
|
|
.filter(unavailability_calendar__desks__agenda__in=agendas)
|
|
.order_by('start_datetime', 'end_datetime')
|
|
)
|
|
for time_period_exception in time_period_exception_queryset:
|
|
# unavailability calendar can be used in all desks;
|
|
# ignore desks outside of current agenda(s)
|
|
for desk in time_period_exception.unavailability_calendar.prefetched_desks:
|
|
if desk not in desks_exceptions:
|
|
desks_exceptions[desk] = IntervalSet()
|
|
desks_exceptions[desk].add(
|
|
time_period_exception.start_datetime, time_period_exception.end_datetime
|
|
)
|
|
|
|
# compute reduced min/max_datetime windows by desks based on exceptions
|
|
desk_min_max_datetime = {}
|
|
for desk, desk_exception in desks_exceptions.items():
|
|
base = IntervalSet([agenda_id_min_max_datetime[desk.agenda_id]])
|
|
base = base - desk_exception
|
|
if not base:
|
|
# ignore this desk, exceptions cover all opening time
|
|
# use an empty interval (begin == end) for this
|
|
desk_min_max_datetime[desk] = (now_datetime, now_datetime)
|
|
continue
|
|
min_datetime = base.min().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
max_datetime = base.max()
|
|
start_of_day = max_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
# move to end of the day if max_datetime is not on a day boundary
|
|
if max_datetime != start_of_day:
|
|
max_datetime = start_of_day + datetime.timedelta(days=1)
|
|
desk_min_max_datetime[desk] = (min_datetime, max_datetime)
|
|
|
|
# aggregate already booked time intervals by desk
|
|
bookings = {}
|
|
for (used_min_datetime, used_max_datetime), agenda_ids in agenda_ids_by_min_max_datetimes.items():
|
|
booked_events = (
|
|
Event.objects.filter(
|
|
agenda__in=agenda_ids,
|
|
start_datetime__gte=used_min_datetime,
|
|
start_datetime__lte=used_max_datetime + meeting_duration_td,
|
|
)
|
|
.exclude(booking__cancellation_datetime__isnull=False)
|
|
# ordering is important for the later groupby, it works like sort | uniq
|
|
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
|
|
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
|
|
)
|
|
# compute exclusion set by desk from all bookings, using
|
|
# itertools.groupby() to group them by desk_id
|
|
bookings.update(
|
|
(
|
|
desk_id,
|
|
IntervalSet.from_ordered(
|
|
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
|
|
for desk_id, event_start_datetime, event_duration in values
|
|
),
|
|
)
|
|
for desk_id, values in itertools.groupby(booked_events, lambda be: be[0])
|
|
)
|
|
|
|
# aggregate already booked time intervals for resources
|
|
resources_bookings = IntervalSet()
|
|
if agenda.kind == 'meetings' and resources:
|
|
used_min_datetime, used_max_datetime = agenda_id_min_max_datetime[base_agenda.pk]
|
|
event_ids_queryset = Event.resources.through.objects.filter(
|
|
resource__in=[r.pk for r in resources]
|
|
).values('event')
|
|
booked_events = (
|
|
Event.objects.filter(
|
|
pk__in=event_ids_queryset,
|
|
start_datetime__gte=used_min_datetime,
|
|
start_datetime__lte=used_max_datetime + meeting_duration_td,
|
|
)
|
|
.exclude(booking__cancellation_datetime__isnull=False)
|
|
.order_by('start_datetime', 'meeting_type__duration')
|
|
.values_list('start_datetime', 'meeting_type__duration')
|
|
)
|
|
# compute exclusion set
|
|
resources_bookings = IntervalSet.from_ordered(
|
|
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
|
|
for event_start_datetime, event_duration in booked_events
|
|
)
|
|
|
|
# aggregate already booked time intervals by excluded_user_external_id
|
|
user_bookings = IntervalSet()
|
|
if excluded_user_external_id:
|
|
used_min_datetime, used_max_datetime = (
|
|
min([v[0] for v in agenda_id_min_max_datetime.values()]),
|
|
max([v[1] for v in agenda_id_min_max_datetime.values()]),
|
|
)
|
|
booked_events = (
|
|
Event.objects.filter(
|
|
agenda__in=agenda_ids,
|
|
start_datetime__gte=used_min_datetime,
|
|
start_datetime__lte=used_max_datetime + meeting_duration_td,
|
|
booking__user_external_id=excluded_user_external_id,
|
|
)
|
|
.exclude(booking__cancellation_datetime__isnull=False)
|
|
# ordering is important for the later groupby, it works like sort | uniq
|
|
.order_by('start_datetime', 'meeting_type__duration')
|
|
.values_list('start_datetime', 'meeting_type__duration')
|
|
)
|
|
# compute exclusion set by desk from all bookings, using
|
|
# itertools.groupby() to group them by desk_id
|
|
user_bookings = IntervalSet.from_ordered(
|
|
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
|
|
for event_start_datetime, event_duration in booked_events
|
|
)
|
|
|
|
unique_booked = {}
|
|
for time_period in base_agenda.get_effective_time_periods():
|
|
duration = (
|
|
datetime.datetime.combine(base_date, time_period.end_time)
|
|
- datetime.datetime.combine(base_date, time_period.start_time)
|
|
).seconds / 60
|
|
|
|
if duration < meeting_type.duration:
|
|
# skip time period that can't even hold a single meeting
|
|
continue
|
|
|
|
desks_by_min_max_datetime = collections.defaultdict(list)
|
|
for desk in time_period.desks:
|
|
min_max = desk_min_max_datetime.get(desk, agenda_id_min_max_datetime[desk.agenda_id])
|
|
desks_by_min_max_datetime[min_max].append(desk)
|
|
|
|
# aggregate agendas based on their real min/max_datetime :
|
|
# the get_time_slots() result is dependant upon these values, so even
|
|
# if we deduplicated a TimePeriod for some desks, if their respective
|
|
# agendas have different real min/max_datetime we must unduplicate them
|
|
# at time slot generation phase.
|
|
for (used_min_datetime, used_max_datetime), desks in desks_by_min_max_datetime.items():
|
|
for start_datetime in time_period.get_time_slots(
|
|
min_datetime=used_min_datetime,
|
|
max_datetime=used_max_datetime,
|
|
meeting_duration=meeting_duration,
|
|
base_duration=base_meeting_duration,
|
|
):
|
|
end_datetime = start_datetime + meeting_duration_td
|
|
timestamp = start_datetime.timestamp
|
|
|
|
# skip generating datetimes if we already know that this
|
|
# datetime is available
|
|
if unique and unique_booked.get(timestamp) is False:
|
|
continue
|
|
|
|
for desk in sorted(desks, key=lambda desk: desk.label):
|
|
# ignore the slot for this desk, if it overlaps and exclusion period for this desk
|
|
excluded = desk in desks_exceptions and desks_exceptions[desk].overlaps(
|
|
start_datetime, end_datetime
|
|
)
|
|
if excluded:
|
|
continue
|
|
|
|
# slot is full if an already booked event overlaps it
|
|
# check resources first
|
|
booked = resources_bookings.overlaps(start_datetime, end_datetime)
|
|
# then check user boookings
|
|
if not booked:
|
|
booked = user_bookings.overlaps(start_datetime, end_datetime)
|
|
# then bookings if resources are free
|
|
if not booked:
|
|
booked = desk.id in bookings and bookings[desk.id].overlaps(
|
|
start_datetime, end_datetime
|
|
)
|
|
if unique and unique_booked.get(timestamp) is booked:
|
|
continue
|
|
unique_booked[timestamp] = booked
|
|
yield TimeSlot(
|
|
start_datetime=start_datetime, end_datetime=end_datetime, desk=desk, full=booked
|
|
)
|
|
if unique and not booked:
|
|
break
|
|
|
|
|
|
def get_agenda_detail(request, agenda, check_events=False):
|
|
agenda_detail = {
|
|
'id': agenda.slug,
|
|
'slug': agenda.slug, # kept for compatibility
|
|
'text': agenda.label,
|
|
'kind': agenda.kind,
|
|
'minimal_booking_delay': agenda.minimal_booking_delay,
|
|
'maximal_booking_delay': agenda.maximal_booking_delay,
|
|
}
|
|
|
|
if agenda.kind == 'meetings':
|
|
agenda_detail['resources'] = [
|
|
{'id': r.slug, 'text': r.label, 'description': r.description} for r in agenda.resources.all()
|
|
]
|
|
if agenda.kind == 'events':
|
|
agenda_detail['api'] = {
|
|
'datetimes_url': request.build_absolute_uri(
|
|
reverse('api-agenda-datetimes', kwargs={'agenda_identifier': agenda.slug})
|
|
)
|
|
}
|
|
if check_events:
|
|
agenda_detail['opened_events_available'] = bool(agenda.get_open_events(include_full=False))
|
|
elif agenda.accept_meetings():
|
|
agenda_detail['api'] = {
|
|
'meetings_url': request.build_absolute_uri(
|
|
reverse('api-agenda-meetings', kwargs={'agenda_identifier': agenda.slug})
|
|
),
|
|
'desks_url': request.build_absolute_uri(
|
|
reverse('api-agenda-desks', kwargs={'agenda_identifier': agenda.slug})
|
|
),
|
|
}
|
|
agenda_detail['api']['fillslots_url'] = request.build_absolute_uri(
|
|
reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug})
|
|
)
|
|
|
|
return agenda_detail
|
|
|
|
|
|
def get_event_places(event):
|
|
available = event.places - event.booked_places
|
|
places = {
|
|
'total': event.places,
|
|
'reserved': event.booked_places,
|
|
'available': available,
|
|
'full': event.full,
|
|
'has_waiting_list': False,
|
|
}
|
|
if event.waiting_list_places:
|
|
places['has_waiting_list'] = True
|
|
places['waiting_list_total'] = event.waiting_list_places
|
|
places['waiting_list_reserved'] = event.waiting_list
|
|
places['waiting_list_available'] = event.waiting_list_places - event.waiting_list
|
|
places['waiting_list_activated'] = event.waiting_list > 0 or available <= 0
|
|
# 'waiting_list_activated' means next booking will go into the waiting list
|
|
|
|
return places
|
|
|
|
|
|
def get_event_detail(request, event, agenda=None, min_places=1):
|
|
agenda = agenda or event.agenda
|
|
if event.label and event.primary_event is not None:
|
|
event.label = '%s (%s)' % (
|
|
event.label,
|
|
date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
|
|
)
|
|
full = bool(event.remaining_places < min_places and event.remaining_waiting_list_places < min_places)
|
|
return {
|
|
'id': event.slug,
|
|
'slug': event.slug, # kept for compatibility
|
|
'text': force_text(event),
|
|
'datetime': format_response_datetime(event.start_datetime),
|
|
'description': event.description,
|
|
'pricing': event.pricing,
|
|
'url': event.url,
|
|
'disabled': full or getattr(event, 'user_places_count', 0) > 0,
|
|
'api': {
|
|
'bookings_url': request.build_absolute_uri(
|
|
reverse(
|
|
'api-event-bookings',
|
|
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
|
|
)
|
|
),
|
|
'fillslot_url': request.build_absolute_uri(
|
|
reverse(
|
|
'api-fillslot',
|
|
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
|
|
)
|
|
),
|
|
'status_url': request.build_absolute_uri(
|
|
reverse(
|
|
'api-event-status',
|
|
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
|
|
)
|
|
),
|
|
},
|
|
'places': get_event_places(event),
|
|
}
|
|
|
|
|
|
def get_events_meta_detail(request, events, agenda=None, min_places=1):
|
|
bookable_datetimes_number_total = 0
|
|
bookable_datetimes_number_available = 0
|
|
first_bookable_slot = None
|
|
for event in events:
|
|
bookable_datetimes_number_total += 1
|
|
if bool(event.remaining_places >= min_places or event.remaining_waiting_list_places >= min_places):
|
|
bookable_datetimes_number_available += 1
|
|
if not first_bookable_slot:
|
|
first_bookable_slot = get_event_detail(request, event, agenda=agenda, min_places=min_places)
|
|
return {
|
|
'no_bookable_datetimes': bool(bookable_datetimes_number_available == 0),
|
|
'bookable_datetimes_number_total': bookable_datetimes_number_total,
|
|
'bookable_datetimes_number_available': bookable_datetimes_number_available,
|
|
'first_bookable_slot': first_bookable_slot,
|
|
}
|
|
|
|
|
|
def get_event_recurrence(agenda, event_identifier):
|
|
event_slug, datetime_str = event_identifier.split(':')
|
|
try:
|
|
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))
|
|
except ValueError:
|
|
raise APIError(
|
|
_('bad datetime format: %s') % datetime_str,
|
|
err_class='bad datetime format: %s' % datetime_str,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
event = agenda.event_set.get(slug=event_slug)
|
|
except Event.DoesNotExist:
|
|
raise APIError(
|
|
_('unknown recurring event slug: %s') % event_slug,
|
|
err_class='unknown recurring event slug:' % event_slug,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
return event.get_or_create_event_recurrence(start_datetime)
|
|
except ValueError:
|
|
raise APIError(
|
|
_('invalid datetime for event %s') % event_identifier,
|
|
err_class='invalid datetime for event %s' % event_identifier,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
|
|
def get_resources_from_request(request, agenda):
|
|
if agenda.kind != 'meetings' or 'resources' not in request.GET:
|
|
return []
|
|
resources_slugs = [s for s in request.GET['resources'].split(',') if s]
|
|
resources = list(agenda.resources.filter(slug__in=resources_slugs))
|
|
if len(resources) != len(resources_slugs):
|
|
unknown_slugs = set(resources_slugs) - set([r.slug for r in resources])
|
|
unknown_slugs = sorted(list(unknown_slugs))
|
|
raise APIError(
|
|
_('invalid resource: %s') % ', '.join(unknown_slugs),
|
|
err_class='invalid resource: %s' % ', '.join(unknown_slugs),
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return resources
|
|
|
|
|
|
class Agendas(APIView):
|
|
permission_classes = ()
|
|
|
|
def get(self, request, format=None):
|
|
agendas_queryset = Agenda.objects.all().prefetch_related('resources').order_by('label')
|
|
|
|
if 'q' in request.GET:
|
|
if not request.GET['q']:
|
|
return Response({'data': []})
|
|
agendas_queryset = agendas_queryset.filter(slug__icontains=request.GET['q'])
|
|
|
|
if request.GET.get('category'):
|
|
cat_slug = request.GET['category']
|
|
if cat_slug == '__none__':
|
|
agendas_queryset = agendas_queryset.filter(category__isnull=True)
|
|
else:
|
|
agendas_queryset = agendas_queryset.filter(category__slug=cat_slug)
|
|
|
|
with_open_events = request.GET.get('with_open_events') in ['1', 'true']
|
|
if with_open_events:
|
|
# return only events agenda
|
|
event_queryset = Event.objects.filter(
|
|
Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()),
|
|
cancelled=False,
|
|
start_datetime__gte=localtime(now()),
|
|
).order_by()
|
|
recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False)
|
|
agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related(
|
|
Prefetch(
|
|
'event_set',
|
|
queryset=event_queryset,
|
|
to_attr='prefetched_events',
|
|
),
|
|
Prefetch(
|
|
'event_set',
|
|
queryset=recurring_event_queryset,
|
|
to_attr='prefetched_recurring_events',
|
|
),
|
|
)
|
|
|
|
agendas = []
|
|
for agenda in agendas_queryset:
|
|
if with_open_events and not any(
|
|
not e.full for e in agenda.get_open_events(prefetched_queryset=True)
|
|
):
|
|
# exclude agendas without open events
|
|
continue
|
|
agendas.append(get_agenda_detail(request, agenda))
|
|
|
|
return Response({'data': agendas})
|
|
|
|
|
|
agendas = Agendas.as_view()
|
|
|
|
|
|
class AgendaDetail(APIView):
|
|
"""
|
|
Retrieve an agenda instance.
|
|
"""
|
|
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier):
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier)
|
|
return Response({'data': get_agenda_detail(request, agenda, check_events=True)})
|
|
|
|
|
|
agenda_detail = AgendaDetail.as_view()
|
|
|
|
|
|
class Datetimes(APIView):
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier=None, format=None):
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
except Agenda.DoesNotExist:
|
|
try:
|
|
# legacy access by agenda id
|
|
agenda = Agenda.objects.get(id=int(agenda_identifier))
|
|
except (ValueError, Agenda.DoesNotExist):
|
|
raise Http404()
|
|
if agenda.kind != 'events':
|
|
raise Http404('agenda found, but it was not an events agenda')
|
|
|
|
try:
|
|
min_places = int(request.GET.get('min_places', 1))
|
|
except ValueError:
|
|
raise APIError(
|
|
_('min_places must be a number'),
|
|
err_class='min_places must be a number',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
date_start, date_end = request.GET.get('date_start'), request.GET.get('date_end')
|
|
if date_start:
|
|
try:
|
|
date_start = make_aware(
|
|
datetime.datetime.combine(parse_date(date_start), datetime.time(0, 0))
|
|
)
|
|
except TypeError:
|
|
raise APIError(
|
|
_('date_start format must be YYYY-MM-DD'),
|
|
err_class='date_start format must be YYYY-MM-DD',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if date_end:
|
|
try:
|
|
date_end = make_aware(datetime.datetime.combine(parse_date(date_end), datetime.time(0, 0)))
|
|
except TypeError:
|
|
raise APIError(
|
|
_('date_end format must be YYYY-MM-DD'),
|
|
err_class='date_end format must be YYYY-MM-DD',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
user_external_id = request.GET.get('exclude_user_external_id') or None
|
|
|
|
entries = agenda.get_open_events(
|
|
annotate_queryset=True,
|
|
min_start=date_start,
|
|
max_start=date_end,
|
|
excluded_user_external_id=user_external_id,
|
|
)
|
|
|
|
response = {
|
|
'data': [get_event_detail(request, x, agenda=agenda, min_places=min_places) for x in entries],
|
|
'meta': get_events_meta_detail(request, entries, agenda=agenda, min_places=min_places),
|
|
}
|
|
return Response(response)
|
|
|
|
|
|
datetimes = Datetimes.as_view()
|
|
|
|
|
|
class MeetingDatetimes(APIView):
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier=None, meeting_identifier=None, format=None):
|
|
try:
|
|
if agenda_identifier is None:
|
|
# legacy access by meeting id
|
|
meeting_type = MeetingType.objects.get(id=meeting_identifier, deleted=False)
|
|
agenda = meeting_type.agenda
|
|
else:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
meeting_type = agenda.get_meetingtype(slug=meeting_identifier)
|
|
|
|
except (ValueError, MeetingType.DoesNotExist, Agenda.DoesNotExist):
|
|
raise Http404()
|
|
|
|
now_datetime = now()
|
|
|
|
resources = get_resources_from_request(request, agenda)
|
|
|
|
start_datetime = None
|
|
if 'date_start' in request.GET:
|
|
try:
|
|
start_datetime = make_aware(
|
|
datetime.datetime.combine(parse_date(request.GET['date_start']), datetime.time(0, 0))
|
|
)
|
|
except TypeError:
|
|
raise APIError(
|
|
_('date_start format must be YYYY-MM-DD'),
|
|
err_class='date_start format must be YYYY-MM-DD',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
end_datetime = None
|
|
if 'date_end' in request.GET:
|
|
try:
|
|
end_datetime = make_aware(
|
|
datetime.datetime.combine(parse_date(request.GET['date_end']), datetime.time(0, 0))
|
|
)
|
|
except TypeError:
|
|
raise APIError(
|
|
_('date_end format must be YYYY-MM-DD'),
|
|
err_class='date_end format must be YYYY-MM-DD',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
user_external_id = request.GET.get('exclude_user_external_id') or None
|
|
|
|
# Generate an unique slot for each possible meeting [start_datetime,
|
|
# end_datetime] range.
|
|
# First use get_all_slots() to get each possible meeting by desk and
|
|
# its current status (full = booked, or not).
|
|
# Then order them by (start, end, full) where full is False for
|
|
# bookable slot, so bookable slot come first.
|
|
# Traverse them and remove duplicates, if a slot is bookable we will
|
|
# only see it (since it comes first), so it also remove "full/booked"
|
|
# slot from the list if there is still a bookable slot on a desk at the
|
|
# same time.
|
|
# The generator also remove slots starting before the current time.
|
|
def unique_slots():
|
|
last_slot = None
|
|
all_slots = list(
|
|
get_all_slots(
|
|
agenda,
|
|
meeting_type,
|
|
resources=resources,
|
|
unique=True,
|
|
start_datetime=start_datetime,
|
|
end_datetime=end_datetime,
|
|
excluded_user_external_id=user_external_id,
|
|
)
|
|
)
|
|
for slot in sorted(all_slots, key=lambda slot: slot[:3]):
|
|
if slot.start_datetime < now_datetime:
|
|
continue
|
|
if last_slot and last_slot[:2] == slot[:2]:
|
|
continue
|
|
last_slot = slot
|
|
yield slot
|
|
|
|
generator_of_unique_slots = unique_slots()
|
|
|
|
# create fillslot API URL as a template, to avoid expensive calls
|
|
# to request.build_absolute_uri()
|
|
fake_event_identifier = '__event_identifier__'
|
|
fillslot_url = request.build_absolute_uri(
|
|
reverse(
|
|
'api-fillslot',
|
|
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': fake_event_identifier},
|
|
)
|
|
)
|
|
if resources:
|
|
fillslot_url += '?resources=%s' % ','.join(r.slug for r in resources)
|
|
|
|
bookable_datetimes_number_total = 0
|
|
bookable_datetimes_number_available = 0
|
|
first_bookable_slot = None
|
|
data = []
|
|
for slot in generator_of_unique_slots:
|
|
# Make virtual id for a slot, combining meeting_type.id and
|
|
# iso-format of date and time.
|
|
# (SharedTimePeriod.get_time_slots() generate datetime in fixed local timezone,
|
|
# in order to make slot_id stable.)
|
|
slot_id = '%s:%s' % (meeting_type.slug, slot.start_datetime.strftime('%Y-%m-%d-%H%M'))
|
|
slot_data = {
|
|
'id': slot_id,
|
|
'datetime': format_response_datetime(slot.start_datetime),
|
|
'text': date_format(slot.start_datetime, format='DATETIME_FORMAT'),
|
|
'disabled': bool(slot.full),
|
|
'api': {'fillslot_url': fillslot_url.replace(fake_event_identifier, slot_id)},
|
|
}
|
|
data.append(slot_data)
|
|
|
|
bookable_datetimes_number_total += 1
|
|
if not bool(slot.full):
|
|
bookable_datetimes_number_available += 1
|
|
if not first_bookable_slot:
|
|
first_bookable_slot = slot_data
|
|
|
|
response = {
|
|
'data': data,
|
|
'meta': {
|
|
'no_bookable_datetimes': bool(bookable_datetimes_number_available == 0),
|
|
'bookable_datetimes_number_total': bookable_datetimes_number_total,
|
|
'bookable_datetimes_number_available': bookable_datetimes_number_available,
|
|
'first_bookable_slot': first_bookable_slot,
|
|
},
|
|
}
|
|
return Response(response)
|
|
|
|
|
|
meeting_datetimes = MeetingDatetimes.as_view()
|
|
|
|
|
|
class MeetingList(APIView):
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier=None, format=None):
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
except Agenda.DoesNotExist:
|
|
raise Http404()
|
|
if not agenda.accept_meetings():
|
|
raise Http404('agenda found, but it does not accept meetings')
|
|
|
|
meeting_types = []
|
|
for meeting_type in agenda.iter_meetingtypes():
|
|
meeting_types.append(
|
|
{
|
|
'text': meeting_type.label,
|
|
'id': meeting_type.slug,
|
|
'duration': meeting_type.duration,
|
|
'api': {
|
|
'datetimes_url': request.build_absolute_uri(
|
|
reverse(
|
|
'api-agenda-meeting-datetimes',
|
|
kwargs={
|
|
'agenda_identifier': agenda.slug,
|
|
'meeting_identifier': meeting_type.slug,
|
|
},
|
|
)
|
|
),
|
|
},
|
|
}
|
|
)
|
|
|
|
return Response({'data': meeting_types})
|
|
|
|
|
|
meeting_list = MeetingList.as_view()
|
|
|
|
|
|
class MeetingInfo(APIView):
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier=None, meeting_identifier=None, format=None):
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
except Agenda.DoesNotExist:
|
|
raise Http404()
|
|
if not agenda.accept_meetings():
|
|
raise Http404('agenda found, but it does not accept meetings')
|
|
|
|
try:
|
|
meeting_type = agenda.get_meetingtype(slug=meeting_identifier)
|
|
except MeetingType.DoesNotExist:
|
|
raise Http404()
|
|
|
|
datetimes_url = request.build_absolute_uri(
|
|
reverse(
|
|
'api-agenda-meeting-datetimes',
|
|
kwargs={'agenda_identifier': agenda.slug, 'meeting_identifier': meeting_type.slug},
|
|
)
|
|
)
|
|
return Response(
|
|
{
|
|
'data': {
|
|
'text': meeting_type.label,
|
|
'id': meeting_type.slug,
|
|
'duration': meeting_type.duration,
|
|
'api': {'datetimes_url': datetimes_url},
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
meeting_info = MeetingInfo.as_view()
|
|
|
|
|
|
class AgendaDeskList(APIView):
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier=None, format=None):
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
except Agenda.DoesNotExist:
|
|
raise Http404()
|
|
if agenda.kind != 'meetings':
|
|
raise Http404('agenda found, but it was not a meetings agenda')
|
|
|
|
desks = [{'id': x.slug, 'text': x.label} for x in agenda.desk_set.all()]
|
|
return Response({'data': desks})
|
|
|
|
|
|
agenda_desk_list = AgendaDeskList.as_view()
|
|
|
|
|
|
class SlotSerializer(serializers.Serializer):
|
|
"""
|
|
payload to fill one slot. The slot (event id) is in the URL.
|
|
"""
|
|
|
|
label = serializers.CharField(max_length=250, allow_blank=True)
|
|
user_external_id = serializers.CharField(max_length=250, allow_blank=True)
|
|
user_name = serializers.CharField(max_length=250, allow_blank=True)
|
|
user_display_label = serializers.CharField(max_length=250, allow_blank=True)
|
|
user_email = serializers.CharField(max_length=250, allow_blank=True)
|
|
user_phone_number = serializers.CharField(max_length=16, allow_blank=True)
|
|
form_url = serializers.CharField(max_length=250, allow_blank=True)
|
|
backoffice_url = serializers.URLField(allow_blank=True)
|
|
cancel_callback_url = serializers.URLField(allow_blank=True)
|
|
count = serializers.IntegerField(min_value=1)
|
|
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
|
|
force_waiting_list = serializers.BooleanField(default=False)
|
|
use_color_for = serializers.CharField(max_length=250, allow_blank=True)
|
|
|
|
|
|
class StringOrListField(serializers.ListField):
|
|
def to_internal_value(self, data):
|
|
if isinstance(data, str):
|
|
data = [s.strip() for s in data.split(',')]
|
|
return super(StringOrListField, self).to_internal_value(data)
|
|
|
|
|
|
class SlotsSerializer(SlotSerializer):
|
|
"""
|
|
payload to fill multiple slots: same as SlotSerializer, but the
|
|
slots list is in the payload.
|
|
"""
|
|
|
|
slots = StringOrListField(required=True, child=serializers.CharField(max_length=160, allow_blank=False))
|
|
|
|
|
|
class Fillslots(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = SlotsSerializer
|
|
|
|
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
|
|
return self.fillslot(request=request, agenda_identifier=agenda_identifier, format=format)
|
|
|
|
def fillslot(self, request, agenda_identifier=None, slots=[], format=None):
|
|
multiple_booking = bool(not slots)
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
except Agenda.DoesNotExist:
|
|
try:
|
|
# legacy access by agenda id
|
|
agenda = Agenda.objects.get(id=int(agenda_identifier))
|
|
except (ValueError, Agenda.DoesNotExist):
|
|
raise Http404()
|
|
|
|
serializer = self.serializer_class(data=request.data, partial=True)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
|
|
if 'slots' in payload:
|
|
slots = payload['slots']
|
|
if not slots:
|
|
raise APIError(
|
|
_('slots list cannot be empty'),
|
|
err_class='slots list cannot be empty',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if 'count' in payload:
|
|
places_count = payload['count']
|
|
elif 'count' in request.query_params:
|
|
# legacy: count in the query string
|
|
try:
|
|
places_count = int(request.query_params['count'])
|
|
except ValueError:
|
|
raise APIError(
|
|
_('invalid value for count (%s)') % request.query_params['count'],
|
|
err_class='invalid value for count (%s)' % request.query_params['count'],
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
else:
|
|
places_count = 1
|
|
|
|
if places_count <= 0:
|
|
raise APIError(
|
|
_('count cannot be less than or equal to zero'),
|
|
err_class='count cannot be less than or equal to zero',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
to_cancel_booking = None
|
|
cancel_booking_id = None
|
|
if payload.get('cancel_booking_id'):
|
|
try:
|
|
cancel_booking_id = int(payload.get('cancel_booking_id'))
|
|
except (ValueError, TypeError):
|
|
raise APIError(
|
|
_('cancel_booking_id is not an integer'),
|
|
err_class='cancel_booking_id is not an integer',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if cancel_booking_id is not None:
|
|
cancel_error = None
|
|
try:
|
|
to_cancel_booking = Booking.objects.get(pk=cancel_booking_id)
|
|
if to_cancel_booking.cancellation_datetime:
|
|
cancel_error = gettext_noop('cancel booking: booking already cancelled')
|
|
else:
|
|
to_cancel_places_count = (
|
|
to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count()
|
|
+ 1
|
|
)
|
|
if places_count != to_cancel_places_count:
|
|
cancel_error = gettext_noop('cancel booking: count is different')
|
|
except Booking.DoesNotExist:
|
|
cancel_error = gettext_noop('cancel booking: booking does no exist')
|
|
|
|
if cancel_error:
|
|
raise APIError(_(cancel_error), err_class=cancel_error)
|
|
|
|
extra_data = {}
|
|
for k, v in request.data.items():
|
|
if k not in serializer.validated_data:
|
|
extra_data[k] = v
|
|
|
|
available_desk = None
|
|
color = None
|
|
|
|
if agenda.accept_meetings():
|
|
# slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
|
|
# split them back to get both parts
|
|
meeting_type_id = slots[0].split(':')[0]
|
|
datetimes = set()
|
|
for slot in slots:
|
|
try:
|
|
meeting_type_id_, datetime_str = slot.split(':')
|
|
except ValueError:
|
|
raise APIError(
|
|
_('invalid slot: %s') % slot,
|
|
err_class='invalid slot: %s' % slot,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if meeting_type_id_ != meeting_type_id:
|
|
raise APIError(
|
|
_('all slots must have the same meeting type id (%s)') % meeting_type_id,
|
|
err_class='all slots must have the same meeting type id (%s)' % meeting_type_id,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
|
|
except ValueError:
|
|
raise APIError(
|
|
_('bad datetime format: %s') % datetime_str,
|
|
err_class='bad datetime format: %s' % datetime_str,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
resources = get_resources_from_request(request, agenda)
|
|
|
|
# get all free slots and separate them by desk
|
|
try:
|
|
try:
|
|
meeting_type = agenda.get_meetingtype(slug=meeting_type_id)
|
|
except MeetingType.DoesNotExist:
|
|
# legacy access by id
|
|
meeting_type = agenda.get_meetingtype(id_=meeting_type_id)
|
|
except (MeetingType.DoesNotExist, ValueError):
|
|
raise APIError(
|
|
_('invalid meeting type id: %s') % meeting_type_id,
|
|
err_class='invalid meeting type id: %s' % meeting_type_id,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
all_slots = sorted(
|
|
get_all_slots(agenda, meeting_type, resources=resources),
|
|
key=lambda slot: slot.start_datetime,
|
|
)
|
|
|
|
all_free_slots = [slot for slot in all_slots if not slot.full]
|
|
datetimes_by_desk = collections.defaultdict(set)
|
|
for slot in all_free_slots:
|
|
datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
|
|
|
|
color_label = payload.get('use_color_for')
|
|
if color_label:
|
|
color = BookingColor.objects.get_or_create(label=color_label)[0]
|
|
|
|
available_desk = None
|
|
|
|
if agenda.kind == 'virtual':
|
|
# Compute fill_rate by agenda/date
|
|
fill_rates = collections.defaultdict(dict)
|
|
for slot in all_slots:
|
|
ref_date = slot.start_datetime.date()
|
|
if ref_date not in fill_rates[slot.desk.agenda]:
|
|
date_dict = fill_rates[slot.desk.agenda][ref_date] = {'free': 0, 'full': 0}
|
|
else:
|
|
date_dict = fill_rates[slot.desk.agenda][ref_date]
|
|
if slot.full:
|
|
date_dict['full'] += 1
|
|
else:
|
|
date_dict['free'] += 1
|
|
for dd in fill_rates.values():
|
|
for date_dict in dd.values():
|
|
date_dict['fill_rate'] = date_dict['full'] / (date_dict['full'] + date_dict['free'])
|
|
|
|
# select a desk on the agenda with min fill_rate on the given date
|
|
for available_desk_id in sorted(datetimes_by_desk.keys()):
|
|
if datetimes.issubset(datetimes_by_desk[available_desk_id]):
|
|
desk = Desk.objects.get(id=available_desk_id)
|
|
if available_desk is None:
|
|
available_desk = desk
|
|
available_desk_rate = 0
|
|
for dt in datetimes:
|
|
available_desk_rate += fill_rates[available_desk.agenda][dt.date()][
|
|
'fill_rate'
|
|
]
|
|
else:
|
|
for dt in datetimes:
|
|
desk_rate = 0
|
|
for dt in datetimes:
|
|
desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
|
|
if desk_rate < available_desk_rate:
|
|
available_desk = desk
|
|
available_desk_rate = desk_rate
|
|
|
|
else:
|
|
# meeting agenda
|
|
# search first desk where all requested slots are free
|
|
for available_desk_id in sorted(datetimes_by_desk.keys()):
|
|
if datetimes.issubset(datetimes_by_desk[available_desk_id]):
|
|
available_desk = Desk.objects.get(id=available_desk_id)
|
|
break
|
|
|
|
if available_desk is None:
|
|
raise APIError(
|
|
_('no more desk available'),
|
|
err_class='no more desk available',
|
|
)
|
|
|
|
# all datetimes are free, book them in order
|
|
datetimes = list(datetimes)
|
|
datetimes.sort()
|
|
|
|
# get a real meeting_type for virtual agenda
|
|
if agenda.kind == 'virtual':
|
|
meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
|
|
|
|
# booking requires real Event objects (not lazy Timeslots);
|
|
# create them now, with data from the slots and the desk we found.
|
|
events = []
|
|
for start_datetime in datetimes:
|
|
event = Event.objects.create(
|
|
agenda=available_desk.agenda,
|
|
slug=str(uuid.uuid4()), # set slug to avoid queries during slug generation
|
|
meeting_type=meeting_type,
|
|
start_datetime=start_datetime,
|
|
full=False,
|
|
places=1,
|
|
desk=available_desk,
|
|
)
|
|
if resources:
|
|
event.resources.add(*resources)
|
|
events.append(event)
|
|
else:
|
|
# convert event recurrence identifiers to real event slugs
|
|
for i, slot in enumerate(slots.copy()):
|
|
if not ':' in slot:
|
|
continue
|
|
event = get_event_recurrence(agenda, slot)
|
|
slots[i] = event.slug
|
|
|
|
try:
|
|
events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
|
|
except ValueError:
|
|
events = agenda.event_set.filter(slug__in=slots).order_by('start_datetime')
|
|
|
|
for event in events:
|
|
if not event.in_bookable_period():
|
|
raise APIError(_('event not bookable'), err_class='event not bookable')
|
|
if event.cancelled:
|
|
raise APIError(_('event is cancelled'), err_class='event is cancelled')
|
|
|
|
if not events.count():
|
|
raise APIError(
|
|
_('unknown event identifiers or slugs'),
|
|
err_class='unknown event identifiers or slugs',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# search free places. Switch to waiting list if necessary.
|
|
in_waiting_list = False
|
|
for event in events:
|
|
if payload.get('force_waiting_list') and not event.waiting_list_places:
|
|
raise APIError(
|
|
_('no waiting list'),
|
|
err_class='no waiting list',
|
|
)
|
|
|
|
if event.waiting_list_places:
|
|
if (
|
|
payload.get('force_waiting_list')
|
|
or (event.booked_places + places_count) > event.places
|
|
or event.waiting_list
|
|
):
|
|
# if this is full or there are people waiting, put new bookings
|
|
# in the waiting list.
|
|
in_waiting_list = True
|
|
if (event.waiting_list + places_count) > event.waiting_list_places:
|
|
raise APIError(
|
|
_('sold out'),
|
|
err_class='sold out',
|
|
)
|
|
else:
|
|
if (event.booked_places + places_count) > event.places:
|
|
raise APIError(
|
|
_('sold out'),
|
|
err_class='sold out',
|
|
)
|
|
|
|
with transaction.atomic():
|
|
if to_cancel_booking:
|
|
cancelled_booking_id = to_cancel_booking.pk
|
|
to_cancel_booking.cancel()
|
|
|
|
# now we have a list of events, book them.
|
|
primary_booking = None
|
|
for event in events:
|
|
for i in range(places_count):
|
|
new_booking = Booking(
|
|
event_id=event.id,
|
|
in_waiting_list=in_waiting_list,
|
|
label=payload.get('label', ''),
|
|
user_external_id=payload.get('user_external_id', ''),
|
|
user_name=payload.get('user_name', ''),
|
|
user_email=payload.get('user_email', ''),
|
|
user_phone_number=payload.get('user_phone_number', ''),
|
|
form_url=payload.get('form_url', ''),
|
|
backoffice_url=payload.get('backoffice_url', ''),
|
|
cancel_callback_url=payload.get('cancel_callback_url', ''),
|
|
user_display_label=payload.get('user_display_label', ''),
|
|
extra_data=extra_data,
|
|
color=color,
|
|
)
|
|
if primary_booking is not None:
|
|
new_booking.primary_booking = primary_booking
|
|
new_booking.save()
|
|
if primary_booking is None:
|
|
primary_booking = new_booking
|
|
|
|
response = {
|
|
'err': 0,
|
|
'in_waiting_list': in_waiting_list,
|
|
'booking_id': primary_booking.id,
|
|
'datetime': format_response_datetime(events[0].start_datetime),
|
|
'agenda': {
|
|
'label': primary_booking.event.agenda.label,
|
|
'slug': primary_booking.event.agenda.slug,
|
|
},
|
|
'api': {
|
|
'booking_url': request.build_absolute_uri(
|
|
reverse('api-booking', kwargs={'booking_pk': primary_booking.id})
|
|
),
|
|
'cancel_url': request.build_absolute_uri(
|
|
reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})
|
|
),
|
|
'ics_url': request.build_absolute_uri(
|
|
reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})
|
|
),
|
|
'anonymize_url': request.build_absolute_uri(
|
|
reverse('api-anonymize-booking', kwargs={'booking_pk': primary_booking.id})
|
|
),
|
|
},
|
|
}
|
|
if agenda.kind == 'events':
|
|
response['api']['accept_url'] = request.build_absolute_uri(
|
|
reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.pk})
|
|
)
|
|
response['api']['suspend_url'] = request.build_absolute_uri(
|
|
reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
|
|
)
|
|
if agenda.accept_meetings():
|
|
response['end_datetime'] = format_response_datetime(events[-1].end_datetime)
|
|
response['duration'] = (events[-1].end_datetime - events[-1].start_datetime).seconds // 60
|
|
if available_desk:
|
|
response['desk'] = {'label': available_desk.label, 'slug': available_desk.slug}
|
|
if to_cancel_booking:
|
|
response['cancelled_booking_id'] = cancelled_booking_id
|
|
if agenda.kind == 'events' and not multiple_booking:
|
|
event = events[0]
|
|
# event.full is not up to date, it might have been changed by previous new_booking.save().
|
|
event.refresh_from_db()
|
|
response['places'] = get_event_places(event)
|
|
if event.end_datetime:
|
|
response['end_datetime'] = format_response_datetime(event.end_datetime)
|
|
else:
|
|
response['end_datetime'] = None
|
|
if agenda.kind == 'events' and multiple_booking:
|
|
response['events'] = [
|
|
{
|
|
'slug': x.slug,
|
|
'text': str(x),
|
|
'datetime': format_response_datetime(x.start_datetime),
|
|
'end_datetime': format_response_datetime(x.end_datetime) if x.end_datetime else None,
|
|
'description': x.description,
|
|
}
|
|
for x in events
|
|
]
|
|
if agenda.kind == 'meetings':
|
|
response['resources'] = [r.slug for r in resources]
|
|
|
|
return Response(response)
|
|
|
|
|
|
fillslots = Fillslots.as_view()
|
|
|
|
|
|
class Fillslot(Fillslots):
|
|
serializer_class = SlotSerializer
|
|
|
|
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
|
|
return self.fillslot(
|
|
request=request,
|
|
agenda_identifier=agenda_identifier,
|
|
slots=[event_identifier], # fill a "list on one slot"
|
|
format=format,
|
|
)
|
|
|
|
|
|
fillslot = Fillslot.as_view()
|
|
|
|
|
|
class BookingSerializer(serializers.ModelSerializer):
|
|
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
|
|
|
class Meta:
|
|
model = Booking
|
|
fields = ['id', 'in_waiting_list', 'user_was_present', 'user_absence_reason', 'extra_data']
|
|
read_only_fields = ['id', 'in_waiting_list']
|
|
|
|
def validate_user_absence_reason(self, value):
|
|
if value is None:
|
|
return ''
|
|
return value
|
|
|
|
|
|
class BookingFilter(filters.FilterSet):
|
|
agenda = filters.CharFilter(field_name='event__agenda__slug', lookup_expr='exact')
|
|
category = filters.CharFilter(field_name='event__agenda__category__slug', lookup_expr='exact')
|
|
date_start = filters.DateFilter(field_name='event__start_datetime', lookup_expr='gte')
|
|
date_end = filters.DateFilter(field_name='event__start_datetime', method='filter_date_end')
|
|
|
|
def filter_date_end(self, queryset, name, value):
|
|
# we want to include all events starting during the targeted day
|
|
lookup = '__'.join([name, 'lt'])
|
|
return queryset.filter(**{lookup: value + datetime.timedelta(days=1)})
|
|
|
|
class Meta:
|
|
model = Booking
|
|
fields = [
|
|
'user_external_id',
|
|
'agenda',
|
|
'category',
|
|
'date_start',
|
|
'date_end',
|
|
'user_was_present',
|
|
'user_absence_reason',
|
|
]
|
|
|
|
|
|
class BookingsAPI(ListAPIView):
|
|
filter_backends = (filters.DjangoFilterBackend,)
|
|
serializer_class = BookingSerializer
|
|
filterset_class = BookingFilter
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
if not request.GET.get('user_external_id'):
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'missing param user_external_id',
|
|
'err_desc': _('missing param user_external_id'),
|
|
}
|
|
return Response(response)
|
|
|
|
try:
|
|
response = super().get(request, *args, **kwargs)
|
|
except ValidationError as e:
|
|
return Response(
|
|
{
|
|
'err': 1,
|
|
'err_class': 'invalid payload',
|
|
'err_desc': _('invalid payload'),
|
|
'errors': e.detail,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
return Response({'err': 0, 'data': response.data})
|
|
|
|
def get_queryset(self):
|
|
return Booking.objects.all().order_by('pk')
|
|
|
|
|
|
bookings = BookingsAPI.as_view()
|
|
|
|
|
|
class BookingAPI(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = BookingSerializer
|
|
|
|
def initial(self, request, *args, **kwargs):
|
|
super().initial(request, *args, **kwargs)
|
|
self.booking = get_object_or_404(Booking, pk=kwargs.get('booking_pk'))
|
|
|
|
def check_booking(self, check_waiting_list=False):
|
|
if self.booking.cancellation_datetime:
|
|
return Response(
|
|
{'err': 1, 'err_class': 'booking is cancelled', 'err_desc': _('booking is cancelled')}
|
|
)
|
|
|
|
if self.booking.primary_booking is not None:
|
|
return Response({'err': 2, 'err_class': 'secondary booking', 'err_desc': _('secondary booking')})
|
|
|
|
if check_waiting_list and self.booking.in_waiting_list:
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'booking is in waiting list',
|
|
'err_desc': _('booking is in waiting list'),
|
|
}
|
|
return Response(response)
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
response = self.check_booking()
|
|
if response:
|
|
return response
|
|
|
|
serializer = self.serializer_class(self.booking)
|
|
response = serializer.data
|
|
response.update(
|
|
{
|
|
'err': 0,
|
|
'booking_id': self.booking.pk,
|
|
}
|
|
)
|
|
return Response(response)
|
|
|
|
def patch(self, request, *args, **kwargs):
|
|
response = self.check_booking(check_waiting_list=True)
|
|
if response:
|
|
return response
|
|
|
|
serializer = self.serializer_class(self.booking, data=request.data, partial=True)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(
|
|
{
|
|
'err': 4,
|
|
'err_class': 'invalid payload',
|
|
'err_desc': _('invalid payload'),
|
|
'errors': serializer.errors,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if 'extra_data' in serializer.validated_data:
|
|
extra_data = self.booking.extra_data or {}
|
|
extra_data.update(serializer.validated_data['extra_data'] or {})
|
|
serializer.validated_data['extra_data'] = extra_data
|
|
|
|
serializer.save()
|
|
if 'user_was_present' in request.data:
|
|
self.booking.secondary_booking_set.update(user_was_present=self.booking.user_was_present)
|
|
if 'user_absence_reason' in request.data:
|
|
self.booking.secondary_booking_set.update(user_absence_reason=self.booking.user_absence_reason)
|
|
if 'extra_data' in request.data:
|
|
self.booking.secondary_booking_set.update(extra_data=self.booking.extra_data)
|
|
|
|
response = {'err': 0, 'booking_id': self.booking.pk}
|
|
return Response(response)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
response = self.check_booking()
|
|
if response:
|
|
return response
|
|
|
|
self.booking.cancel()
|
|
response = {'err': 0, 'booking_id': self.booking.pk}
|
|
return Response(response)
|
|
|
|
|
|
booking = BookingAPI.as_view()
|
|
|
|
|
|
class CancelBooking(APIView):
|
|
"""
|
|
Cancel a booking.
|
|
|
|
It will return error codes if the booking was cancelled before (code 1) or
|
|
if the booking is not primary (code 2).
|
|
"""
|
|
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def post(self, request, booking_pk=None, format=None):
|
|
booking = get_object_or_404(Booking, id=booking_pk)
|
|
if booking.cancellation_datetime:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'already cancelled',
|
|
'err_desc': _('already cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
booking.cancel()
|
|
response = {'err': 0, 'booking_id': booking.id}
|
|
return Response(response)
|
|
|
|
|
|
cancel_booking = CancelBooking.as_view()
|
|
|
|
|
|
class AcceptBooking(APIView):
|
|
"""
|
|
Accept a booking currently in the waiting list.
|
|
|
|
It will return error codes if the booking was cancelled before (code 1),
|
|
if the booking is not primary (code 2) or
|
|
if the booking was not in waiting list (code 3).
|
|
"""
|
|
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def post(self, request, booking_pk=None, format=None):
|
|
booking = get_object_or_404(Booking, id=booking_pk, event__agenda__kind='events')
|
|
if booking.cancellation_datetime:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'booking is cancelled',
|
|
'err_desc': _('booking is cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
if not booking.in_waiting_list:
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'booking is not in waiting list',
|
|
'err_desc': _('booking is not in waiting list'),
|
|
}
|
|
return Response(response)
|
|
booking.accept()
|
|
event = booking.event
|
|
response = {
|
|
'err': 0,
|
|
'booking_id': booking.pk,
|
|
'overbooked_places': max(0, event.booked_places - event.places),
|
|
}
|
|
return Response(response)
|
|
|
|
|
|
accept_booking = AcceptBooking.as_view()
|
|
|
|
|
|
class SuspendBooking(APIView):
|
|
"""
|
|
Suspend a accepted booking.
|
|
|
|
It will return error codes if the booking was cancelled before (code 1)
|
|
if the booking is not primary (code 2) or
|
|
if the booking is already in waiting list (code 3).
|
|
"""
|
|
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def post(self, request, booking_pk=None, format=None):
|
|
booking = get_object_or_404(Booking, pk=booking_pk, event__agenda__kind='events')
|
|
if booking.cancellation_datetime:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'booking is cancelled',
|
|
'err_desc': _('booking is cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
if booking.in_waiting_list:
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'booking is already in waiting list',
|
|
'err_desc': _('booking is already in waiting list'),
|
|
}
|
|
return Response(response)
|
|
booking.suspend()
|
|
response = {'err': 0, 'booking_id': booking.pk}
|
|
return Response(response)
|
|
|
|
|
|
suspend_booking = SuspendBooking.as_view()
|
|
|
|
|
|
class AnonymizeBooking(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def post(self, request, booking_pk=None, format=None):
|
|
booking = get_object_or_404(Booking, pk=booking_pk)
|
|
bookings = Booking.objects.filter(Q(pk=booking.pk) | Q(primary_booking=booking.pk))
|
|
Booking.anonymize_bookings(bookings)
|
|
response = {'err': 0, 'booking_id': booking.pk}
|
|
return Response(response)
|
|
|
|
|
|
anonymize_booking = AnonymizeBooking.as_view()
|
|
|
|
|
|
class ResizeSerializer(serializers.Serializer):
|
|
count = serializers.IntegerField(min_value=1)
|
|
|
|
|
|
class ResizeBooking(APIView):
|
|
"""
|
|
Resize a booking.
|
|
|
|
It will return error codes if the booking was cancelled before (code 1)
|
|
if the booking is not primary (code 2)
|
|
if the event is sold out (code 3) or
|
|
if the booking is on multi events (code 4).
|
|
"""
|
|
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = ResizeSerializer
|
|
|
|
def post(self, request, booking_pk=None, format=None):
|
|
serializer = self.serializer_class(data=request.data)
|
|
if not serializer.is_valid():
|
|
return Response(
|
|
{
|
|
'err': 1,
|
|
'err_class': 'invalid payload',
|
|
'err_desc': _('invalid payload'),
|
|
'errors': serializer.errors,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
|
|
booking = get_object_or_404(Booking, pk=booking_pk, event__agenda__kind='events')
|
|
event = booking.event
|
|
if booking.cancellation_datetime:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'booking is cancelled',
|
|
'err_desc': _('booking is cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
event_ids = set([event.pk])
|
|
in_waiting_list = set([booking.in_waiting_list])
|
|
secondary_bookings = booking.secondary_booking_set.all().order_by('-creation_datetime')
|
|
for secondary in secondary_bookings:
|
|
event_ids.add(secondary.event_id)
|
|
in_waiting_list.add(secondary.in_waiting_list)
|
|
if len(event_ids) > 1:
|
|
response = {
|
|
'err': 4,
|
|
'err_class': 'can not resize multi event booking',
|
|
'err_desc': _('can not resize multi event booking'),
|
|
}
|
|
return Response(response)
|
|
if len(in_waiting_list) > 1:
|
|
response = {
|
|
'err': 5,
|
|
'err_class': 'can not resize booking: waiting list inconsistency',
|
|
'err_desc': _('can not resize booking: waiting list inconsistency'),
|
|
}
|
|
return Response(response)
|
|
|
|
# total places for the event (in waiting or main list, depending on the primary booking location)
|
|
places = event.waiting_list_places if booking.in_waiting_list else event.places
|
|
# total booked places for the event (in waiting or main list, depending on the primary booking location)
|
|
booked_places = event.waiting_list if booking.in_waiting_list else event.booked_places
|
|
|
|
# places to book for this primary booking
|
|
primary_wanted_places = payload['count']
|
|
# already booked places for this primary booking
|
|
primary_booked_places = 1 + len(secondary_bookings)
|
|
|
|
if primary_booked_places > primary_wanted_places:
|
|
# it is always ok to decrease booking
|
|
return self.decrease(booking, secondary_bookings, primary_booked_places, primary_wanted_places)
|
|
if primary_booked_places == primary_wanted_places:
|
|
# it is always ok to do nothing
|
|
return self.success(booking)
|
|
|
|
# else, increase places if allowed
|
|
if booked_places - primary_booked_places + primary_wanted_places > places:
|
|
# oversized request
|
|
if booking.in_waiting_list:
|
|
# booking in waiting list: can not be overbooked
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'sold out',
|
|
'err_desc': _('sold out'),
|
|
}
|
|
return Response(response)
|
|
if event.booked_places <= event.places:
|
|
# in main list and no overbooking for the moment: can not be overbooked
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'sold out',
|
|
'err_desc': _('sold out'),
|
|
}
|
|
return Response(response)
|
|
return self.increase(booking, secondary_bookings, primary_booked_places, primary_wanted_places)
|
|
|
|
def increase(self, booking, secondary_bookings, primary_booked_places, primary_wanted_places):
|
|
with transaction.atomic():
|
|
bulk_bookings = []
|
|
for i in range(0, primary_wanted_places - primary_booked_places):
|
|
bulk_bookings.append(
|
|
booking.clone(
|
|
primary_booking=booking,
|
|
save=False,
|
|
)
|
|
)
|
|
Booking.objects.bulk_create(bulk_bookings)
|
|
|
|
return self.success(booking)
|
|
|
|
def decrease(self, booking, secondary_bookings, primary_booked_places, primary_wanted_places):
|
|
with transaction.atomic():
|
|
for secondary in secondary_bookings[: primary_booked_places - primary_wanted_places]:
|
|
secondary.delete()
|
|
|
|
return self.success(booking)
|
|
|
|
def success(self, booking):
|
|
response = {'err': 0, 'booking_id': booking.pk}
|
|
return Response(response)
|
|
|
|
|
|
resize_booking = ResizeBooking.as_view()
|
|
|
|
|
|
class SlotStatus(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def get_object(self, agenda_identifier, event_identifier):
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier, kind='events')
|
|
except Agenda.DoesNotExist:
|
|
try:
|
|
# legacy access by agenda id
|
|
agenda = Agenda.objects.get(pk=agenda_identifier, kind='events')
|
|
except (ValueError, Agenda.DoesNotExist):
|
|
raise Http404()
|
|
if ':' in event_identifier:
|
|
return get_event_recurrence(agenda, event_identifier)
|
|
try:
|
|
return agenda.event_set.get(slug=event_identifier)
|
|
except Event.DoesNotExist:
|
|
try:
|
|
# legacy access by event id
|
|
return agenda.event_set.get(pk=event_identifier)
|
|
except (ValueError, Event.DoesNotExist):
|
|
raise Http404()
|
|
|
|
def get(self, request, agenda_identifier=None, event_identifier=None, format=None):
|
|
event = self.get_object(agenda_identifier, event_identifier)
|
|
response = {
|
|
'err': 0,
|
|
}
|
|
response.update(get_event_detail(request, event))
|
|
return Response(response)
|
|
|
|
|
|
slot_status = SlotStatus.as_view()
|
|
|
|
|
|
class SlotBookings(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def get_object(self, agenda_identifier, event_identifier):
|
|
if ':' in event_identifier:
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
|
|
return get_event_recurrence(agenda, event_identifier)
|
|
return get_object_or_404(
|
|
Event, slug=event_identifier, agenda__slug=agenda_identifier, agenda__kind='events'
|
|
)
|
|
|
|
def get(self, request, agenda_identifier=None, event_identifier=None, format=None):
|
|
if not request.GET.get('user_external_id'):
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'missing param user_external_id',
|
|
'err_desc': _('missing param user_external_id'),
|
|
}
|
|
return Response(response)
|
|
event = self.get_object(agenda_identifier, event_identifier)
|
|
booking_queryset = event.booking_set.filter(
|
|
user_external_id=request.GET['user_external_id'],
|
|
primary_booking__isnull=True,
|
|
cancellation_datetime__isnull=True,
|
|
).order_by('pk')
|
|
response = {
|
|
'err': 0,
|
|
'data': [{'booking_id': b.pk, 'in_waiting_list': b.in_waiting_list} for b in booking_queryset],
|
|
}
|
|
return Response(response)
|
|
|
|
|
|
slot_bookings = SlotBookings.as_view()
|
|
|
|
|
|
class BookingICS(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def get(self, request, booking_pk=None, format=None):
|
|
booking = get_object_or_404(Booking, id=booking_pk)
|
|
response = HttpResponse(booking.get_ics(request), content_type='text/calendar')
|
|
return response
|
|
|
|
|
|
booking_ics = BookingICS.as_view()
|