chrono/chrono/api/views.py

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()