# 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 . from collections import defaultdict import datetime from django.db import transaction 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.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) if agenda.kind == 'events' and multiple_booking: response['events'] = [ { 'slug': x.slug, 'text': str(x), 'datetime': format_response_datetime(x.start_datetime), 'description': x.description, } for x in events ] 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()