chrono/chrono/api/views.py

703 lines
28 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/>.
from collections import defaultdict
import datetime
from django.core.urlresolvers import reverse
from django.db import transaction
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.dateparse import parse_date
from django.utils.encoding import force_text
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 rest_framework import permissions, serializers, status
from rest_framework.views import APIView
from chrono.api.utils import Response
from ..agendas.models import (Agenda, Event, Booking, MeetingType,
TimePeriod, Desk)
from ..interval import Intervals
def format_response_datetime(dt):
return localtime(dt).strftime('%Y-%m-%d %H:%M:%S')
def get_exceptions_by_desk(agenda):
exceptions_by_desk = {}
for desk in Desk.objects.filter(agenda=agenda).prefetch_related('timeperiodexception_set'):
exceptions_by_desk[desk.id] = [(exc.start_datetime, exc.end_datetime) for exc in desk.timeperiodexception_set.all()]
return exceptions_by_desk
def get_all_slots(agenda, meeting_type):
min_datetime = now() + datetime.timedelta(days=agenda.minimal_booking_delay)
max_datetime = now() + datetime.timedelta(days=agenda.maximal_booking_delay)
min_datetime = min_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
max_datetime = max_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
max_datetime = max_datetime + datetime.timedelta(days=1)
time_period_filters = {
'min_datetime': min_datetime,
'max_datetime': max_datetime,
'meeting_type': meeting_type
}
base_date = now().date()
open_slots_by_desk = defaultdict(lambda: Intervals())
for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
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
for slot in time_period.get_time_slots(**time_period_filters):
slot.full = False
open_slots_by_desk[time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
# remove excluded slot
excluded_slot_by_desk = get_exceptions_by_desk(agenda)
for desk, excluded_interval in excluded_slot_by_desk.items():
for interval in excluded_interval:
begin, end = interval
open_slots_by_desk[desk].remove_overlap(localtime(begin), localtime(end))
for event in agenda.event_set.filter(
agenda=agenda, start_datetime__gte=min_datetime,
start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration)).select_related(
'meeting_type').exclude(
booking__cancellation_datetime__isnull=False):
for slot in open_slots_by_desk[event.desk_id].search_data(event.start_datetime, event.end_datetime):
slot.full = True
slots = []
for desk in open_slots_by_desk:
slots.extend(open_slots_by_desk[desk].iter_data())
slots.sort(key=lambda slot: slot.start_datetime)
return slots
def get_agenda_detail(request, agenda):
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 == 'events':
agenda_detail['api'] = {
'datetimes_url': request.build_absolute_uri(
reverse('api-agenda-datetimes',
kwargs={'agenda_identifier': agenda.slug}))
}
elif agenda.kind == '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):
places = {
'total': event.places,
'reserved': event.booked_places,
'available': event.places - event.booked_places,
}
if event.waiting_list_places:
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)
return places
class Agendas(APIView):
permission_classes = ()
def get(self, request, format=None):
agendas = [get_agenda_detail(request, agenda)
for agenda in Agenda.objects.all().order_by('label')]
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)})
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')
entries = Event.objects.filter(agenda=agenda)
# we never want to allow booking for past events.
entries = entries.filter(start_datetime__gte=localtime(now()))
if agenda.minimal_booking_delay:
entries = entries.filter(
start_datetime__gte=localtime(now() + datetime.timedelta(days=agenda.minimal_booking_delay)).replace(hour=0, minute=0))
if agenda.maximal_booking_delay:
entries = entries.filter(
start_datetime__lt=localtime(now() + datetime.timedelta(days=agenda.maximal_booking_delay)).replace(hour=0, minute=0))
if 'date_start' in request.GET:
entries = entries.filter(start_datetime__gte=make_aware(
datetime.datetime.combine(parse_date(request.GET['date_start']), datetime.time(0, 0))))
if 'date_end' in request.GET:
entries = entries.filter(start_datetime__lt=make_aware(
datetime.datetime.combine(parse_date(request.GET['date_end']), datetime.time(0, 0))))
response = {'data': [{'id': x.id,
'slug': x.slug,
'text': force_text(x),
'datetime': format_response_datetime(x.start_datetime),
'description': x.description,
'disabled': bool(x.full),
'api': {
'fillslot_url': request.build_absolute_uri(
reverse('api-fillslot',
kwargs={
'agenda_identifier': agenda.slug,
'event_identifier': x.slug or x.id,
})),
'status_url': request.build_absolute_uri(
reverse('api-event-status',
kwargs={
'agenda_identifier': agenda.slug,
'event_identifier': x.slug or x.id,
}))
},
} for x in entries]}
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)
else:
meeting_type = MeetingType.objects.get(slug=meeting_identifier,
agenda__slug=agenda_identifier)
except (ValueError, MeetingType.DoesNotExist):
raise Http404()
agenda = meeting_type.agenda
now_datetime = now()
slots = get_all_slots(agenda, meeting_type)
entries = {}
for slot in slots:
if slot.start_datetime < now_datetime:
continue
key = (slot.start_datetime, slot.end_datetime)
if key in entries and slot.full:
continue
entries[key] = slot
slots = sorted(entries.values(), key=lambda x: x.start_datetime)
# 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,
}))
response = {'data': [{'id': x.id,
'datetime': format_response_datetime(x.start_datetime),
'text': force_text(x),
'disabled': bool(x.full),
'api': {
'fillslot_url': fillslot_url.replace(fake_event_identifier, str(x.id)),
},
} for x in slots]}
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 agenda.kind != 'meetings':
raise Http404('agenda found, but it was not a meetings agenda')
meeting_types = []
for meeting_type in agenda.meetingtype_set.all():
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 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_name = serializers.CharField(max_length=250, allow_blank=True)
user_display_label = serializers.CharField(max_length=250, allow_blank=True)
backoffice_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)
class SlotsSerializer(SlotSerializer):
'''
payload to fill multiple slots: same as SlotSerializer, but the
slots list is in the payload.
'''
slots = serializers.ListField(required=True,
child=serializers.CharField(max_length=64, 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():
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
if 'slots' in payload:
slots = payload['slots']
if not slots:
return Response({
'err': 1,
'err_class': 'slots list cannot be empty',
'err_desc': _('slots list cannot be empty'),
}, 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:
return Response({
'err': 1,
'err_class': 'invalid value for count (%s)' % request.query_params['count'],
'err_desc': _('invalid value for count (%s)') % request.query_params['count'],
}, status=status.HTTP_400_BAD_REQUEST)
else:
places_count = 1
if places_count <= 0:
return Response({
'err': 1,
'err_class': 'count cannot be less than or equal to zero',
'err_desc': _('count cannot be less than or equal to zero'),
}, 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):
return Response({
'err': 1,
'err_class': 'cancel_booking_id is not an integer',
'err_desc': _('cancel_booking_id is not an integer'),
}, 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.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:
return Response({
'err': 1,
'err_class': cancel_error,
'err_desc': _(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
if agenda.kind == '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:
return Response({
'err': 1,
'err_class': 'invalid slot: %s' % slot,
'err_desc': _('invalid slot: %s') % slot,
}, status=status.HTTP_400_BAD_REQUEST)
if meeting_type_id_ != meeting_type_id:
return Response({
'err': 1,
'err_class': 'all slots must have the same meeting type id (%s)' % meeting_type_id,
'err_desc': _('all slots must have the same meeting type id (%s)') % meeting_type_id,
}, status=status.HTTP_400_BAD_REQUEST)
datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
# get all free slots and separate them by desk
all_slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id))
all_slots = [slot for slot in all_slots if not slot.full]
datetimes_by_desk = defaultdict(set)
for slot in all_slots:
datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
# 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
else:
return Response({
'err': 1,
'err_class': 'no more desk available',
'err_desc': _('no more desk available'),
})
# all datetimes are free, book them in order
datetimes = list(datetimes)
datetimes.sort()
# 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:
events.append(Event.objects.create(agenda=agenda,
meeting_type_id=meeting_type_id,
start_datetime=start_datetime,
full=False, places=1,
desk=available_desk))
else:
try:
events = Event.objects.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
except ValueError:
events = Event.objects.filter(slug__in=slots).order_by('start_datetime')
# search free places. Switch to waiting list if necessary.
in_waiting_list = False
for event in events:
if event.waiting_list_places:
if (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:
return Response({
'err': 1,
'err_class': 'sold out',
'err_desc': _('sold out'),
})
else:
if (event.booked_places + places_count) > event.places:
return Response({
'err': 1,
'err_class': 'sold out',
'err_desc': _('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_name=payload.get('user_name', ''),
backoffice_url=payload.get('backoffice_url', ''),
user_display_label=payload.get('user_display_label', ''),
extra_data=extra_data)
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),
'api': {
'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})),
}
}
if in_waiting_list:
response['api']['accept_url'] = request.build_absolute_uri(
reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.id}))
if agenda.kind == '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]
response['places'] = get_event_places(event)
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 BookingAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
def initial(self, request, *args, **kwargs):
super(BookingAPI, self).initial(request, *args, **kwargs)
self.booking = Booking.objects.get(id=kwargs.get('booking_pk'),
cancellation_datetime__isnull=True)
def delete(self, request, *args, **kwargs):
self.booking.cancel()
response = {'err': 0, 'booking_id': self.booking.id}
return Response(response)
booking = BookingAPI.as_view()
class CancelBooking(APIView):
'''
Cancel a booking.
It will return an error (code 1) if the booking was already cancelled.
'''
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)
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) and
if the booking was not in waiting list (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': 'booking is cancelled',
'err_desc': _('booking is cancelled'),
}
return Response(response)
if not booking.in_waiting_list:
response = {
'err': 2,
'err_class': 'booking is not in waiting list',
'err_desc': _('booking is not in waiting list'),
}
return Response(response)
booking.accept()
response = {'err': 0, 'booking_id': booking.id}
return Response(response)
accept_booking = AcceptBooking.as_view()
class SlotStatus(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get_object(self, event_identifier):
try:
return Event.objects.get(slug=event_identifier)
except Event.DoesNotExist:
try:
# legacy access by event id
return Event.objects.get(pk=int(event_identifier))
except (ValueError, Event.DoesNotExist):
raise Http404()
def get(self, request, agenda_identifier=None, event_identifier=None, format=None):
event = self.get_object(event_identifier)
response = {
'err': 0,
'places': get_event_places(event),
}
return Response(response)
slot_status = SlotStatus.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()