# 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 . import collections import copy import datetime import itertools import json import uuid from django.db import IntegrityError, transaction from django.db.models import BooleanField, Count, ExpressionWrapper, F, Func, Prefetch, Q from django.db.models.expressions import RawSQL from django.db.models.functions import TruncDay from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist from django.urls import reverse from django.utils.dates import WEEKDAYS from django.utils.encoding import force_str from django.utils.formats import date_format from django.utils.timezone import localtime, make_aware, now from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_noop as N_ from django.utils.translation import pgettext from django_filters import rest_framework as filters from rest_framework import permissions from rest_framework.exceptions import ValidationError from rest_framework.generics import ListAPIView from rest_framework.views import APIView from chrono.agendas.models import ( Agenda, Booking, BookingColor, Category, Desk, Event, MeetingType, SharedCustodyAgenda, Subscription, TimePeriodException, ) from chrono.api import serializers from chrono.api.utils import APIError, APIErrorBadRequest, Response from chrono.interval import IntervalSet from chrono.utils.publik_urls import translate_to_publik_url def format_response_datetime(dt): return localtime(dt).strftime('%Y-%m-%d %H:%M:%S') def format_response_date(dt): return localtime(dt).strftime('%Y-%m-%d') def get_min_datetime(agenda, start_datetime=None): if agenda.minimal_booking_delay is None: return start_datetime if start_datetime is None: return agenda.min_booking_datetime return max(agenda.min_booking_datetime, start_datetime) def get_max_datetime(agenda, end_datetime=None): if agenda.maximal_booking_delay is None: return end_datetime if end_datetime is None: return agenda.max_booking_datetime return min(agenda.max_booking_datetime, end_datetime) TimeSlot = collections.namedtuple( 'TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk', 'booked_for_external_user'] ) def get_all_slots( base_agenda, meeting_type, resources=None, unique=False, start_datetime=None, end_datetime=None, 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() max_meeting_duration_td = datetime.timedelta(minutes=base_agenda.get_max_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 if base_agenda.minimal_booking_delay is None: used_min_datetime = get_min_datetime(agenda, start_datetime) used_max_datetime = base_max_datetime if base_agenda.maximal_booking_delay is None: used_max_datetime = 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) .select_related('desk') .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) if base_min_datetime: min_datetime = max(min_datetime, base_min_datetime) max_datetime = base.max() if base_max_datetime: max_datetime = min(max_datetime, base_max_datetime) 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 - max_meeting_duration_td, start_datetime__lte=used_max_datetime, ) .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 base_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 - max_meeting_duration_td, start_datetime__lte=used_max_datetime, ) .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 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 - max_meeting_duration_td, start_datetime__lte=used_max_datetime, booking__user_external_id=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(base_min_datetime, base_max_datetime): 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 booked_for_external_user = user_bookings.overlaps(start_datetime, end_datetime) booked = booked or booked_for_external_user # 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, booked_for_external_user=booked_for_external_user, ) 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, 'edit_role': agenda.edit_role.name if agenda.edit_role else None, 'view_role': agenda.view_role.name if agenda.view_role else None, 'category': agenda.category.slug if agenda.category else None, 'category_label': agenda.category.label if agenda.category else None, } 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['events_type'] = agenda.events_type.slug if agenda.events_type else None agenda_detail['minimal_booking_delay_in_working_days'] = agenda.minimal_booking_delay_in_working_days 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().filter(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}) ), } if agenda.kind == 'meetings': agenda_detail['api'].update( { 'resources_url': request.build_absolute_uri( reverse('api-agenda-resources', kwargs={'agenda_identifier': agenda.slug}) ), } ) agenda_detail['api']['fillslots_url'] = request.build_absolute_uri( reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug}) ) agenda_detail['api']['backoffice_url'] = request.build_absolute_uri( reverse('chrono-manager-agenda-view', kwargs={'pk': agenda.pk}) ) return agenda_detail def get_event_places(event): available = event.remaining_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.booked_waiting_list_places places['waiting_list_available'] = event.remaining_waiting_list_places places['waiting_list_activated'] = event.booked_waiting_list_places > 0 or available <= 0 # 'waiting_list_activated' means next booking will go into the waiting list return places def is_event_disabled(event, min_places=1, disable_booked=True, bookable_events=None, bypass_delays=False): if disable_booked and getattr(event, 'user_places_count', 0) > 0: return True if event.start_datetime < now(): # event is past if bookable_events in ['all', 'past']: # but we want to book past events, and it's always ok return False # we just want to show past events, but they are not bookable return True elif ( not bypass_delays and event.agenda.min_booking_datetime and event.start_datetime < event.agenda.min_booking_datetime ): # event is out of minimal delay and we don't want to bypass delays return True if event.remaining_places < min_places and event.remaining_waiting_list_places < min_places: return True return False def get_event_text(event, agenda, day=None): event_text = force_str(event) if agenda.event_display_template: try: event_text = Template(agenda.event_display_template).render( Context({'event': event}, autoescape=False) ) except (VariableDoesNotExist, TemplateSyntaxError): pass elif event.label and event.primary_event_id is not None: event_text = '%s (%s)' % ( event.label, date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'), ) elif day is not None: event_text = _('%(weekday)s: %(event)s') % { 'weekday': WEEKDAYS[day].capitalize(), 'event': event_text, } return event_text # pylint: disable=too-many-arguments def get_event_detail( request, event, booking=None, agenda=None, min_places=1, booked_user_external_id=None, bookable_events=None, multiple_agendas=False, disable_booked=True, bypass_delays=False, with_status=False, ): agenda = agenda or event.agenda details = { 'id': '%s@%s' % (agenda.slug, event.slug) if multiple_agendas else event.slug, 'slug': event.slug, # kept for compatibility 'text': get_event_text(event, agenda), 'label': event.label or '', 'agenda_label': agenda.label, 'date': format_response_date(event.start_datetime), 'datetime': format_response_datetime(event.start_datetime), 'description': event.description, 'pricing': event.pricing, 'url': event.url, 'duration': event.duration, 'checked': event.checked, } for key, value in event.get_custom_fields().items(): details['custom_field_%s' % key] = value if booking: details['booking'] = { 'id': booking.pk, 'api': { 'booking_url': request.build_absolute_uri( reverse('api-booking', kwargs={'booking_pk': booking.id}) ), 'cancel_url': request.build_absolute_uri( reverse('api-cancel-booking', kwargs={'booking_pk': booking.id}) ), 'ics_url': request.build_absolute_uri( reverse('api-booking-ics', kwargs={'booking_pk': booking.id}) ), 'anonymize_url': request.build_absolute_uri( reverse('api-anonymize-booking', kwargs={'booking_pk': booking.id}) ), }, } if event.recurrence_days: details.update( { 'recurrence_days': event.recurrence_days, 'recurrence_week_interval': event.recurrence_week_interval, 'recurrence_end_date': event.recurrence_end_date, } ) else: backoffice_url = request.build_absolute_uri( reverse('chrono-manager-event-view', kwargs={'pk': agenda.pk, 'event_pk': event.pk}) ) details.update( { 'disabled': is_event_disabled( event, min_places=min_places, disable_booked=disable_booked, bookable_events=bookable_events, bypass_delays=bypass_delays, ), '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}, ) ), 'check_url': request.build_absolute_uri( reverse( 'api-event-check', kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug}, ) ), 'backoffice_url': backoffice_url, }, 'places': get_event_places(event), } ) if bookable_events is not None: details['api']['fillslot_url'] += '?events=%s' % bookable_events if booked_user_external_id: if getattr(event, 'user_places_count', 0) > 0: details['booked_for_external_user'] = 'main-list' elif getattr(event, 'user_waiting_places_count', 0) > 0: details['booked_for_external_user'] = 'waiting-list' if with_status and booked_user_external_id: if getattr(event, 'user_absence_count', 0) > 0: details['status'] = 'absence' elif getattr(event, 'user_places_count', 0) > 0 or getattr(event, 'user_waiting_places_count', 0) > 0: details['status'] = 'booked' elif getattr(event, 'user_cancelled_count', 0) > 0: details['status'] = 'cancelled' else: details['status'] = 'free' if hasattr(event, 'overlaps'): details['overlaps'] = event.overlaps return details def get_events_meta_detail( request, events, agenda=None, min_places=1, bookable_events=None, multiple_agendas=False, bypass_delays=False, ): bookable_datetimes_number_total = 0 bookable_datetimes_number_available = 0 first_bookable_slot = None for event in events: bookable_datetimes_number_total += 1 if not is_event_disabled( event, min_places=min_places, bookable_events=bookable_events, bypass_delays=bypass_delays ): bookable_datetimes_number_available += 1 if not first_bookable_slot: first_bookable_slot = get_event_detail( request, event, agenda=agenda, min_places=min_places, bookable_events=bookable_events, multiple_agendas=multiple_agendas, bypass_delays=bypass_delays, ) 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_events_from_slots(slots, request, agenda, payload): user_external_id = payload.get('user_external_id') or None exclude_user = payload.get('exclude_user') book_events = payload.get('events') or request.query_params.get('events') or 'future' book_past = book_events in ['all', 'past'] book_future = book_events in ['all', 'future'] bypass_delays = payload.get('bypass_delays') try: events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime') except ValueError: events = get_objects_from_slugs(slots, qs=agenda.event_set).order_by('start_datetime') for event in events: if event.start_datetime >= now(): if not book_future or not event.in_bookable_period(bypass_delays=bypass_delays): raise APIError(N_('event %s is not bookable'), event.slug, err_class='event not bookable') else: if not book_past: raise APIError(N_('event %s is not bookable'), event.slug, err_class='event not bookable') if event.cancelled: raise APIError(N_('event %s is cancelled'), event.slug, err_class='event is cancelled') if exclude_user and user_external_id: if event.booking_set.filter( user_external_id=user_external_id, cancellation_datetime__isnull=True ).exists(): raise APIError( N_('event %s is already booked by user'), event.slug, err_class='event is already booked by user', ) if event.recurrence_days: raise APIError( N_('event %s is recurrent, direct booking is forbidden'), event.slug, err_class='event is recurrent', ) if slots and not events.exists(): raise APIErrorBadRequest(N_('unknown event identifiers or slugs')) return events 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] return list(get_objects_from_slugs(resources_slugs, qs=agenda.resources)) def get_objects_from_slugs(slugs, qs, prefix=''): slugs = set(slugs) objects = qs.filter(slug__in=slugs) if len(objects) != len(slugs): unknown_slugs = sorted(slugs - {obj.slug for obj in objects}) unknown_slugs = ', '.join('%s%s' % (prefix, s) for s in unknown_slugs) raise APIErrorBadRequest(N_('invalid slugs: %s'), unknown_slugs) return objects def get_start_and_end_datetime_from_request(request): serializer = serializers.DateRangeSerializer(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) return serializer.validated_data.get('date_start'), serializer.validated_data.get('date_end') def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None): out_of_min_delay = False if event.agenda.min_booking_datetime and event.start_datetime < event.agenda.min_booking_datetime: out_of_min_delay = True return Booking( event_id=event.pk, in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list), primary_booking=primary_booking, label=payload.get('label', ''), user_external_id=payload.get('user_external_id', ''), user_first_name=payload.get('user_first_name', ''), user_last_name=payload.get('user_last_name') or payload.get('user_name') or '', user_email=payload.get('user_email', ''), user_phone_number=payload.get('user_phone_number', ''), out_of_min_delay=out_of_min_delay, form_url=translate_to_publik_url(payload.get('form_url', '')), backoffice_url=translate_to_publik_url(payload.get('backoffice_url', '')), cancel_callback_url=translate_to_publik_url(payload.get('cancel_callback_url', '')), user_display_label=payload.get('user_display_label', ''), extra_emails=payload.get('extra_emails', []), extra_phone_numbers=payload.get('extra_phone_numbers', []), extra_data=extra_data, color=color, ) class Agendas(APIView): serializer_class = serializers.AgendaSerializer def get_permissions(self): if self.request.method == 'GET': return [] return [permissions.IsAuthenticated()] def get(self, request, format=None): agendas_queryset = ( Agenda.objects.all() .select_related('category', 'edit_role', 'view_role', 'events_type') .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 agendas_queryset = Agenda.prefetch_events(agendas_queryset) 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({'err': 0, 'data': agendas}) def post(self, request, format=None): serializer = self.serializer_class(data=request.data) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) agenda = serializer.save() if agenda.kind == 'events': desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') desk.import_timeperiod_exceptions_from_settings() return Response({'err': 0, 'data': [get_agenda_detail(request, agenda)]}) agendas = Agendas.as_view() class AgendaAPI(APIView): permission_classes = (permissions.IsAuthenticatedOrReadOnly,) 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)}) def delete(self, request, agenda_identifier): agenda = get_object_or_404(Agenda, slug=agenda_identifier) has_bookings = Booking.objects.filter( event__agenda=agenda, event__start_datetime__gt=now(), cancellation_datetime__isnull=True ).exists() if has_bookings: raise APIError(_('This cannot be removed as there are bookings for a future date.')) agenda.delete() return Response({'err': 0}) agenda = AgendaAPI.as_view() class Datetimes(APIView): permission_classes = () serializer_class = serializers.DatetimesSerializer def get(self, request, agenda_identifier=None, format=None): agenda_qs = Agenda.objects.select_related('events_type') try: agenda = agenda_qs.get(slug=agenda_identifier) except Agenda.DoesNotExist: try: # legacy access by agenda id agenda = agenda_qs.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') serializer = self.serializer_class(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id') disable_booked = bool(payload.get('exclude_user_external_id')) bookable_events_raw = payload.get('events') bookable_events = bookable_events_raw or 'future' book_past = bookable_events in ['all', 'past'] book_future = bookable_events in ['all', 'future'] entries = Event.objects.none() if book_past: entries |= agenda.get_past_events( min_start=payload.get('date_start'), max_start=payload.get('date_end'), ) if book_future: entries |= agenda.get_open_events( min_start=payload.get('date_start'), max_start=payload.get('date_end'), bypass_delays=payload.get('bypass_delays'), ) entries = Event.annotate_queryset_for_user(entries, user_external_id) if payload['hide_disabled']: entries = [ e for e in entries if not is_event_disabled( e, payload['min_places'], disable_booked=disable_booked, bookable_events=bookable_events, bypass_delays=payload.get('bypass_delays'), ) ] response = { 'data': [ get_event_detail( request, x, agenda=agenda, min_places=payload['min_places'], booked_user_external_id=payload.get('user_external_id'), bookable_events=bookable_events_raw, disable_booked=disable_booked, bypass_delays=payload.get('bypass_delays'), ) for x in entries ], 'meta': get_events_meta_detail( request, entries, agenda=agenda, min_places=payload['min_places'], bookable_events=bookable_events_raw, ), } return Response(response) datetimes = Datetimes.as_view() class MultipleAgendasDatetimes(APIView): permission_classes = () serializer_class = serializers.MultipleAgendasDatetimesSerializer def get(self, request): serializer = self.serializer_class(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data agendas = payload['agendas'] user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id') disable_booked = bool(payload.get('exclude_user_external_id')) guardian_external_id = payload.get('guardian_external_id') show_past_events = bool(payload.get('show_past_events')) show_only_subscribed = bool('subscribed' in payload) with_status = bool(payload.get('with_status')) check_overlaps = bool(payload.get('check_overlaps')) entries = Event.objects.none() for agenda in agendas: if show_past_events: entries |= agenda.get_past_events( min_start=payload.get('date_start'), max_start=payload.get('date_end'), ) entries |= agenda.get_open_events( min_start=payload.get('date_start'), max_start=payload.get('date_end'), bypass_delays=payload.get('bypass_delays'), show_out_of_minimal_delay=show_past_events, ) entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status) if check_overlaps: entries = Event.annotate_queryset_with_overlaps(entries) if show_only_subscribed: entries = entries.filter( agenda__subscriptions__user_external_id=user_external_id, agenda__subscriptions__date_start__lte=F('start_datetime'), agenda__subscriptions__date_end__gt=F('start_datetime'), ) if guardian_external_id: entries = Agenda.filter_for_guardian( entries, guardian_external_id, user_external_id, min_start=payload.get('date_start'), max_start=payload.get('date_end'), ) entries = list(entries) if 'agendas' in request.query_params: agenda_querystring_indexes = { agenda_slug: i for i, agenda_slug in enumerate(payload['agenda_slugs']) } entries.sort( key=lambda event: ( event.start_datetime, agenda_querystring_indexes[event.agenda.slug], event.slug, ) ) elif 'subscribed' in request.query_params: category_querystring_indexes = {category: i for i, category in enumerate(payload['subscribed'])} sort_by_category = bool(payload['subscribed'] != ['all']) entries.sort( key=lambda event: ( event.start_datetime, category_querystring_indexes[event.agenda.category.slug] if sort_by_category else None, event.agenda.slug, event.slug, ) ) response = { 'data': [ get_event_detail( request, x, min_places=payload['min_places'], booked_user_external_id=payload.get('user_external_id'), multiple_agendas=True, disable_booked=disable_booked, bypass_delays=payload.get('bypass_delays'), with_status=with_status, ) for x in entries ], 'meta': get_events_meta_detail( request, entries, min_places=payload['min_places'], multiple_agendas=True ), } return Response(response) agendas_datetimes = MultipleAgendasDatetimes.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, end_datetime = get_start_and_end_datetime_from_request(request) booked_user_external_id = request.GET.get('user_external_id') or None excluded_user_external_id = request.GET.get('exclude_user_external_id') or None if ( booked_user_external_id and excluded_user_external_id and booked_user_external_id != excluded_user_external_id ): raise APIErrorBadRequest( N_('user_external_id and exclude_user_external_id have different values') ) # 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, user_external_id=booked_user_external_id or excluded_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: if request.GET.get('hide_disabled') and slot.full: continue # 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, 'date': format_response_date(slot.start_datetime), '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)}, } if booked_user_external_id and slot.booked_for_external_user: slot_data['booked_for_external_user'] = True 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 RecurringEventsList(APIView): permission_classes = () def get(self, request, agenda_identifier=None, format=None): serializer = serializers.RecurringEventsListSerializer(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) data = serializer.validated_data check_overlaps = bool(data.get('check_overlaps')) guardian_external_id = data.get('guardian_external_id') if guardian_external_id: agendas = Agenda.prefetch_events( data['agendas'], user_external_id=data.get('user_external_id'), guardian_external_id=guardian_external_id, annotate_for_user=False, ) days_by_event = collections.defaultdict(set) for agenda in agendas: for event in agenda.prefetched_events: if event.primary_event_id: days_by_event[event.primary_event_id].add(event.start_datetime.weekday()) recurring_events = Event.objects.filter(pk__in=days_by_event).select_related( 'agenda', 'agenda__category' ) if check_overlaps: recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events) events = [] for event in recurring_events: for day in days_by_event[event.pk]: event = copy.copy(event) event.day = day events.append(event) else: agendas = Agenda.prefetch_recurring_events(data['agendas'], with_overlaps=check_overlaps) events = [] for agenda in agendas: for event in agenda.get_open_recurring_events(): for day in event.recurrence_days: event = copy.copy(event) event.day = day events.append(event) if check_overlaps: for event in events: event.overlaps = [ '%s:%s' % (x['slug'], day) for x in event.overlaps for day in x['days'] if day == event.day ] if 'agendas' in request.query_params: agenda_querystring_indexes = { agenda_slug: i for i, agenda_slug in enumerate(data['agenda_slugs']) } events.sort( key=lambda event: ( event.day if data.get('sort') == 'day' else event.start_datetime, event.start_datetime.time(), agenda_querystring_indexes[event.agenda.slug], event.slug, ) ) elif 'subscribed' in request.query_params: category_querystring_indexes = {category: i for i, category in enumerate(data['subscribed'])} sort_by_category = bool(data['subscribed'] != ['all']) events.sort( key=lambda event: ( event.day if data.get('sort') == 'day' else event.start_datetime, event.start_datetime.time(), category_querystring_indexes[event.agenda.category.slug] if sort_by_category else None, event.agenda.slug, event.slug, ) ) return Response( { 'data': [ { 'id': '%s@%s:%s' % (event.agenda.slug, event.slug, event.day), 'text': get_event_text(event, event.agenda, event.day), 'slug': event.slug, 'label': event.label or '', 'day': WEEKDAYS[event.day].capitalize(), 'date': format_response_date(event.start_datetime), 'datetime': format_response_datetime(event.start_datetime), 'description': event.description, 'pricing': event.pricing, 'url': event.url, 'overlaps': event.overlaps if check_overlaps else None, } for event in events ] } ) recurring_events_list = RecurringEventsList.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 = [] exclude = request.GET.get('exclude') or '' exclude = [x.strip() for x in exclude.split(',')] for meeting_type in agenda.iter_meetingtypes(): if meeting_type.slug in exclude: continue 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 AgendaResourceList(APIView): permission_classes = () def get(self, request, agenda_identifier=None, format=None): agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='meetings') resources = [ {'id': x.slug, 'text': x.label, 'description': x.description} for x in agenda.resources.all() ] return Response({'data': resources}) agenda_resource_list = AgendaResourceList.as_view() class Fillslots(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.FillSlotsSerializer 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=None, format=None, retry=False): slots = slots or [] 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() known_body_params = set(request.query_params).intersection( {'label', 'user_name', 'backoffice_url', 'user_display_label'} ) if known_body_params: params = ', '.join(sorted(list(known_body_params))) raise APIErrorBadRequest( N_('parameters "%s" must be included in request body, not query'), params ) serializer = self.serializer_class(data=request.data, partial=True) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data if 'slots' in payload: slots = payload['slots'] 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 APIErrorBadRequest(N_('invalid value for count (%s)'), request.query_params['count']) else: places_count = 1 if places_count <= 0: raise APIErrorBadRequest(N_('count cannot be less than or equal to zero')) 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 APIErrorBadRequest(N_('cancel_booking_id is not an integer')) 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 = N_('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 = N_('cancel booking: count is different') except Booking.DoesNotExist: cancel_error = N_('cancel booking: booking does no exist') if cancel_error: raise APIError(N_(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 user_external_id = payload.get('user_external_id') or None exclude_user = payload.get('exclude_user') 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 APIErrorBadRequest(N_('invalid slot: %s'), slot) if meeting_type_id_ != meeting_type_id: raise APIErrorBadRequest( N_('all slots must have the same meeting type id (%s)'), meeting_type_id ) try: datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))) except ValueError: raise APIErrorBadRequest(N_('bad datetime format: %s'), datetime_str) 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 APIErrorBadRequest(N_('invalid meeting type id: %s'), meeting_type_id) all_slots = sorted( get_all_slots( agenda, meeting_type, resources=resources, user_external_id=user_external_id if exclude_user else None, start_datetime=min(datetimes), end_datetime=max(datetimes) + datetime.timedelta(minutes=meeting_type.duration), ), 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(N_('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: events.append( Event( 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, ) ) in_waiting_list = False else: events = get_events_from_slots(slots, request, agenda, payload) # search free places. Switch to waiting list if necessary. in_waiting_list = False for event in events: if event.start_datetime > now(): if payload.get('force_waiting_list') and not event.waiting_list_places: raise APIError(N_('no waiting list')) if event.waiting_list_places: if ( payload.get('force_waiting_list') or (event.booked_places + places_count) > event.places or event.booked_waiting_list_places ): # if this is full or there are people waiting, put new bookings # in the waiting list. in_waiting_list = True if (event.booked_waiting_list_places + places_count) > event.waiting_list_places: raise APIError(N_('sold out')) else: if (event.booked_places + places_count) > event.places: raise APIError(N_('sold out')) try: 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: if agenda.accept_meetings(): event.save() if resources: event.resources.add(*resources) for dummy in range(places_count): new_booking = make_booking( event, payload, extra_data, primary_booking, in_waiting_list, color ) new_booking.save() if primary_booking is None: primary_booking = new_booking except IntegrityError as e: if 'tstzrange_constraint' in str(e): # "optimistic concurrency control", between our availability # check with get_all_slots() and now, new event can have been # created and conflict with the events we want to create, and # so we get an IntegrityError exception. In this case we # restart the fillslot() from the begginning to redo the # availability check and return a proper error to the client. # # To prevent looping, we raise an APIError during the second run # of fillslot(). if retry: raise APIError(N_('no more desk available')) return self.fillslot(request, agenda_identifier=agenda_identifier, slots=slots, retry=True) raise 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 = serializers.FillSlotSerializer 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 RecurringFillslots(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.RecurringFillslotsSerializer def post(self, request): serializer = serializers.RecurringFillslotsQueryStringSerializer( data=request.query_params, context={'user_external_id': request.data.get('user_external_id')} ) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) data = serializer.validated_data guardian_external_id = data.get('guardian_external_id') start_datetime, end_datetime = data.get('date_start'), data.get('date_end') if not start_datetime or start_datetime < now(): start_datetime = now() context = { 'allowed_agenda_slugs': data['agenda_slugs'], 'agendas': Agenda.prefetch_recurring_events(data['agendas']), } serializer = self.serializer_class(data=request.data, partial=True, context=context) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data user_external_id = payload['user_external_id'] agendas = Agenda.prefetch_events(data['agendas'], user_external_id=user_external_id) agendas_by_id = {x.id: x for x in agendas} if data['action'] == 'update': events_to_book = self.get_event_recurrences( agendas, payload['slots'], start_datetime, end_datetime, user_external_id, guardian_external_id, ) events_to_unbook = self.get_events_to_unbook(agendas, events_to_book) elif data['action'] == 'book': events_to_book = self.get_event_recurrences( agendas, payload['slots'], start_datetime, end_datetime, user_external_id, guardian_external_id, ) events_to_unbook = [] elif data['action'] == 'unbook': events_to_book = Event.objects.none() events_to_unbook = self.get_event_recurrences( agendas, payload['slots'], start_datetime, end_datetime, user_external_id, guardian_external_id, ).values_list('pk', flat=True) if payload.get('check_overlaps'): self.check_for_overlaps(events_to_book, serializer.initial_slots) # outdated bookings to remove (cancelled bookings to replace by an active booking) events_cancelled_to_delete = events_to_book.filter( booking__user_external_id=user_external_id, booking__cancellation_datetime__isnull=False, full=False, ) # book only events without active booking for the user events_to_book = events_to_book.exclude( pk__in=Booking.objects.filter( event__in=events_to_book, user_external_id=user_external_id, cancellation_datetime__isnull=True, ).values('event') ) # exclude full events full_events = list(events_to_book.filter(full=True)) # don't reload agendas and events types for event in full_events: event.agenda = agendas_by_id[event.agenda_id] events_to_book = events_to_book.filter(full=False) events_to_book = events_to_book.annotate( in_waiting_list=ExpressionWrapper( Q(booked_places__gte=F('places')) | Q(booked_waiting_list_places__gt=0), output_field=BooleanField(), ) ) extra_data = {k: v for k, v in request.data.items() if k not in payload} # don't reload agendas and events types for event in events_to_book: event.agenda = agendas_by_id[event.agenda_id] bookings = [make_booking(event, payload, extra_data) for event in events_to_book] bookings_to_cancel = Booking.objects.filter( user_external_id=user_external_id, event__in=events_to_unbook, cancellation_datetime__isnull=True ) with transaction.atomic(): # cancel existing bookings cancellation_datetime = now() Booking.objects.filter(primary_booking__in=bookings_to_cancel).update( cancellation_datetime=cancellation_datetime ) cancelled_count = bookings_to_cancel.update(cancellation_datetime=cancellation_datetime) # and delete outdated cancelled bookings Booking.objects.filter( user_external_id=user_external_id, event__in=events_cancelled_to_delete ).delete() # create missing bookings created_bookings = Booking.objects.bulk_create(bookings) response = { 'err': 0, 'booking_count': len(bookings), 'cancelled_booking_count': cancelled_count, 'full_events': [get_event_detail(request, x, multiple_agendas=True) for x in full_events], } if payload.get('include_booked_events_detail'): events_to_book_by_id = {x.id: x for x in events_to_book} response['booked_events'] = [ get_event_detail(request, events_to_book_by_id[x.event_id], booking=x, multiple_agendas=True) for x in created_bookings ] return Response(response) def get_event_recurrences( self, agendas, slots, start_datetime, end_datetime, user_external_id, guardian_external_id ): event_filter = Q() agendas_by_slug = {a.slug: a for a in agendas} for agenda_slug, days_by_event in slots.items(): agenda = agendas_by_slug[agenda_slug] for event_slug, days in days_by_event.items(): lookups = { 'agenda__slug': agenda_slug, 'primary_event__slug': event_slug, 'start_datetime__week_day__in': days, } if agenda.minimal_booking_delay: lookups.update({'start_datetime__gte': agenda.min_booking_datetime}) if agenda.maximal_booking_delay: lookups.update({'start_datetime__lte': agenda.max_booking_datetime}) if 'subscribed' in self.request.query_params: lookups.update( { 'agenda__subscriptions__user_external_id': user_external_id, 'agenda__subscriptions__date_start__lte': F('start_datetime'), 'agenda__subscriptions__date_end__gt': F('start_datetime'), } ) event_filter |= Q(**lookups) events = Event.objects.filter(event_filter) if event_filter else Event.objects.none() events = events.filter(start_datetime__gte=start_datetime, cancelled=False) if end_datetime: events = events.filter(start_datetime__lte=end_datetime) if guardian_external_id: events = Agenda.filter_for_guardian( events, guardian_external_id, user_external_id, min_start=start_datetime, max_start=end_datetime, ) return events def get_events_to_unbook(self, agendas, events_to_book): events_to_book_ids = set(events_to_book.values_list('pk', flat=True)) events_to_unbook = [ e.pk for agenda in agendas for e in agenda.prefetched_events if (e.user_places_count or e.user_waiting_places_count) and e.primary_event_id and e.pk not in events_to_book_ids and (not agenda.minimal_booking_delay or e.start_datetime >= agenda.min_booking_datetime) and (not agenda.maximal_booking_delay or e.start_datetime <= agenda.max_booking_datetime) ] return events_to_unbook @staticmethod def check_for_overlaps(events, slots): def get_slug(event, day): slug = event['slug'] if isinstance(event, dict) else '%s@%s' % (event.agenda.slug, event.slug) return '%s:%s' % (slug, day) recurring_events = Event.objects.filter(pk__in=events.values('primary_event_id')) recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events) overlaps = set() for event in recurring_events.select_related('agenda'): overlaps.update( tuple(sorted((get_slug(event, d), get_slug(x, d)))) for x in event.overlaps for d in x['days'] if get_slug(x, d) in slots and get_slug(event, d) in slots ) if overlaps: raise APIError( N_('Some events occur at the same time: %s') % ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps)) ) recurring_fillslots = RecurringFillslots.as_view() class EventsFillslots(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.EventsFillSlotsSerializer serializer_extra_context = None multiple_agendas = False def post(self, request, agenda_identifier): self.agenda = get_object_or_404( Agenda.objects.select_related('events_type'), slug=agenda_identifier, kind='events' ) return self.fillslots(request) def fillslots(self, request): start_datetime, end_datetime = get_start_and_end_datetime_from_request(request) serializer = self.serializer_class( data=request.data, partial=True, context=self.serializer_extra_context ) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data user_external_id = payload['user_external_id'] bypass_delays = payload.get('bypass_delays') check_overlaps = payload.get('check_overlaps') events = self.get_events(request, payload, start_datetime, end_datetime) if check_overlaps: overlapping_events = Event.annotate_queryset_with_overlaps(events).filter(has_overlap=True) if overlapping_events: raise APIError( N_('Some events occur at the same time: %s'), ', '.join(sorted(str(x) for x in overlapping_events)), ) already_booked_events = self.get_already_booked_events(user_external_id) already_booked_events = already_booked_events.filter(start_datetime__gt=now()) if start_datetime: already_booked_events = already_booked_events.filter(start_datetime__gte=start_datetime) if end_datetime: already_booked_events = already_booked_events.filter(start_datetime__lt=end_datetime) agendas_by_ids = self.get_agendas_by_ids() events_to_unbook = [] events_to_unbook_out_of_min_delay = [] events_in_request_ids = [e.pk for e in events] for event in already_booked_events: if event.pk in events_in_request_ids: continue agenda = agendas_by_ids[event.agenda_id] out_of_min_delay = False if agenda.min_booking_datetime and event.start_datetime < agenda.min_booking_datetime: if not bypass_delays: continue out_of_min_delay = True if agenda.max_booking_datetime and event.start_datetime > agenda.max_booking_datetime: continue if out_of_min_delay: events_to_unbook_out_of_min_delay.append(event) else: events_to_unbook.append(event) # outdated bookings to remove (cancelled bookings to replace by an active booking) events_cancelled_to_delete = events.filter( booking__user_external_id=user_external_id, booking__cancellation_datetime__isnull=False, ) # book only events without active booking for the user events = events.exclude( pk__in=Booking.objects.filter( event__in=events, user_external_id=user_external_id, cancellation_datetime__isnull=True ).values('event') ) full_events = [str(event) for event in events.filter(full=True)] if full_events: raise APIError( N_('some events are full: %s'), ', '.join(full_events), err_class='some events are full' ) events = events.annotate( in_waiting_list=ExpressionWrapper( Q(booked_places__gte=F('places')) | Q(booked_waiting_list_places__gt=0), output_field=BooleanField(), ) ) waiting_list_event_ids = [event.pk for event in events if event.in_waiting_list] extra_data = {k: v for k, v in request.data.items() if k not in payload} bookings = [make_booking(event, payload, extra_data) for event in events] bookings_to_cancel_out_of_min_delay = Booking.objects.filter( user_external_id=user_external_id, event__in=events_to_unbook_out_of_min_delay, cancellation_datetime__isnull=True, ) bookings_to_cancel = Booking.objects.filter( user_external_id=user_external_id, event__in=events_to_unbook, cancellation_datetime__isnull=True ) with transaction.atomic(): # cancel existing bookings cancellation_datetime = now() Booking.objects.filter(primary_booking__in=bookings_to_cancel_out_of_min_delay).update( cancellation_datetime=cancellation_datetime, out_of_min_delay=True, ) cancelled_count = bookings_to_cancel_out_of_min_delay.update( cancellation_datetime=cancellation_datetime, out_of_min_delay=True ) Booking.objects.filter(primary_booking__in=bookings_to_cancel).update( cancellation_datetime=cancellation_datetime, out_of_min_delay=False ) cancelled_count += bookings_to_cancel.update( cancellation_datetime=cancellation_datetime, out_of_min_delay=False ) # and delete outdated cancelled bookings Booking.objects.filter( user_external_id=user_external_id, event__in=events_cancelled_to_delete ).delete() # create missing bookings created_bookings = Booking.objects.bulk_create(bookings) # don't reload agendas and events types for event in events: event.agenda = agendas_by_ids[event.agenda_id] events_by_id = {x.id: x for x in events} response = { 'err': 0, 'booking_count': len(bookings), 'booked_events': [ get_event_detail( request, events_by_id[x.event_id], booking=x, multiple_agendas=self.multiple_agendas ) for x in created_bookings if x.event_id not in waiting_list_event_ids ], 'waiting_list_events': [ get_event_detail( request, events_by_id[x.event_id], booking=x, multiple_agendas=self.multiple_agendas ) for x in created_bookings if x.event_id in waiting_list_event_ids ], 'cancelled_booking_count': cancelled_count, } return Response(response) def get_events(self, request, payload, start_datetime, end_datetime): return get_events_from_slots(payload['slots'], request, self.agenda, payload) def get_already_booked_events(self, user_external_id): return self.agenda.event_set.filter( booking__user_external_id=user_external_id, booking__cancellation_datetime__isnull=True ) def get_agendas_by_ids(self): return {self.agenda.pk: self.agenda} events_fillslots = EventsFillslots.as_view() class MultipleAgendasEventsFillslots(EventsFillslots): serializer_class = serializers.MultipleAgendasEventsFillSlotsSerializer multiple_agendas = True def post(self, request): serializer = serializers.AgendaOrSubscribedSlugsSerializer( data=request.query_params, context={'user_external_id': request.data.get('user_external_id')} ) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) data = serializer.validated_data self.agendas = data['agendas'] self.agenda_slugs = data['agenda_slugs'] self.guardian_external_id = data.get('guardian_external_id') return self.fillslots(request) def get_events(self, request, payload, start_datetime, end_datetime): events_by_agenda = collections.defaultdict(list) for slot in payload['slots']: agenda, event = slot.split('@') events_by_agenda[agenda].append(event) agendas_by_slug = get_objects_from_slugs( events_by_agenda.keys(), qs=Agenda.objects.filter(kind='events') ).in_bulk(field_name='slug') events = Event.objects.none() for agenda_slug, event_slugs in events_by_agenda.items(): events |= get_events_from_slots(event_slugs, request, agendas_by_slug[agenda_slug], payload) if 'subscribed' in request.query_params: events_outside_subscriptions = events.difference( events.filter( agenda__subscriptions__user_external_id=payload['user_external_id'], agenda__subscriptions__date_start__lte=F('start_datetime'), agenda__subscriptions__date_end__gt=F('start_datetime'), ) ) # workaround exclude method bug https://code.djangoproject.com/ticket/29697 if events_outside_subscriptions.exists(): event_slugs = ', '.join( '%s@%s' % (event.agenda.slug, event.slug) for event in events_outside_subscriptions ) raise APIErrorBadRequest(N_('Some events are outside user subscriptions: %s'), event_slugs) if self.guardian_external_id: events_outside_custody = events.exclude( pk__in=Agenda.filter_for_guardian( events, self.guardian_external_id, payload['user_external_id'], min_start=start_datetime, max_start=end_datetime, ).values('pk') ) if events_outside_custody.exists(): event_slugs = ', '.join( '%s@%s' % (event.agenda.slug, event.slug) for event in events_outside_custody ) raise APIErrorBadRequest(N_('Some events are outside guardian custody: %s'), event_slugs) return events def get_already_booked_events(self, user_external_id): return Event.objects.filter( agenda__in=self.agendas, booking__user_external_id=user_external_id, booking__cancellation_datetime__isnull=True, ) def get_agendas_by_ids(self): return {a.pk: a for a in self.agendas} @property def serializer_extra_context(self): return {'allowed_agenda_slugs': self.agenda_slugs} agendas_events_fillslots = MultipleAgendasEventsFillslots.as_view() class MultipleAgendasEvents(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.SlotsSerializer def get(self, request): serializer = self.serializer_class(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1) slots = serializer.validated_data['slots'] events_by_agenda = collections.defaultdict(list) for slot in slots: agenda, event = slot.split('@') events_by_agenda[agenda].append(event) agendas = get_objects_from_slugs(events_by_agenda.keys(), qs=Agenda.objects.filter(kind='events')) agendas_by_slug = {a.slug: a for a in agendas} events = [] for agenda_slug, event_slugs in events_by_agenda.items(): events += get_objects_from_slugs( event_slugs, qs=agendas_by_slug[agenda_slug] .event_set.filter(cancelled=False, recurrence_days__isnull=True) .prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by())) .order_by(), prefix='%s@' % agenda_slug, ) data = [] event_querystring_indexes = {event_slug: i for i, event_slug in enumerate(slots)} events.sort(key=lambda event: (event_querystring_indexes['%s@%s' % (event.agenda.slug, event.slug)],)) for event in events: data.append(serializers.EventSerializer(event).data) return Response({'err': 0, 'data': data}) agendas_events = MultipleAgendasEvents.as_view() class MultipleAgendasEventsCheckStatus(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.MultipleAgendasEventsCheckStatusSerializer def get(self, request): serializer = self.serializer_class(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1) agendas = serializer.validated_data['agendas'] agendas_by_id = {a.pk: a for a in agendas} user_external_id = serializer.validated_data['user_external_id'] date_start = serializer.validated_data['date_start'] date_end = serializer.validated_data['date_end'] events = Event.objects.filter( agenda__in=agendas, agenda__subscriptions__user_external_id=user_external_id, agenda__subscriptions__date_start__lte=F('start_datetime'), agenda__subscriptions__date_end__gt=F('start_datetime'), recurrence_days__isnull=True, cancelled=False, start_datetime__gte=date_start, start_datetime__lt=date_end, ).prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by())) booking_queryset = Booking.objects.filter( event__in=events, user_external_id=user_external_id, ) bookings_by_event_id = collections.defaultdict(list) for booking in booking_queryset: bookings_by_event_id[booking.event_id].append(booking) data = [] for event in events: event.agenda = agendas_by_id[event.agenda_id] # agenda is already fetched, reuse it check_status = {} booking = None if not event.checked: check_status = {'status': 'error', 'error_reason': 'event-not-checked'} elif not bookings_by_event_id[event.pk]: check_status = {'status': 'not-booked'} elif len(bookings_by_event_id[event.pk]) > 1: check_status = {'status': 'error', 'error_reason': 'too-many-bookings-found'} else: booking = bookings_by_event_id[event.pk][0] booking.event = event # prevent db calls if booking.cancellation_datetime is not None: check_status = {'status': 'cancelled'} elif booking.user_was_present is None: check_status = {'status': 'error', 'error_reason': 'booking-not-checked'} else: check_status = { 'status': 'presence' if booking.user_was_present else 'absence', 'check_type': booking.user_check_type_slug, } data.append( { 'event': serializers.EventSerializer(event).data, 'check_status': check_status, 'booking': serializers.BookingSerializer(booking).data if booking else {}, } ) return Response({'err': 0, 'data': data}) agendas_events_check_status = MultipleAgendasEventsCheckStatus.as_view() class SubscriptionFilter(filters.FilterSet): date_start = filters.DateFilter(lookup_expr='gte') date_end = filters.DateFilter(lookup_expr='lt') class Meta: model = Subscription fields = [ 'user_external_id', 'date_start', 'date_end', ] class SubscriptionsAPI(ListAPIView): filter_backends = (filters.DjangoFilterBackend,) serializer_class = serializers.SubscriptionSerializer filterset_class = SubscriptionFilter permission_classes = (permissions.IsAuthenticated,) def get_agenda(self, agenda_identifier): return get_object_or_404(Agenda, slug=agenda_identifier, kind='events') def get(self, request, agenda_identifier): self.agenda = self.get_agenda(agenda_identifier) try: subscriptions = self.filter_queryset(self.get_queryset()) except ValidationError as e: raise APIErrorBadRequest(N_('invalid filters'), errors=e.detail) serializer = self.serializer_class(subscriptions, many=True) return Response({'err': 0, 'data': serializer.data}) def get_queryset(self): return self.agenda.subscriptions.order_by('date_start', 'date_end', 'user_external_id', 'pk') def post(self, request, agenda_identifier): self.agenda = self.get_agenda(agenda_identifier) serializer = self.serializer_class(data=request.data) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) extra_data = {k: v for k, v in request.data.items() if k not in serializer.validated_data} date_start = serializer.validated_data['date_start'] date_end = serializer.validated_data['date_end'] overlapping_subscription_qs = Subscription.objects.filter( agenda=self.agenda, user_external_id=serializer.validated_data['user_external_id'], ).extra( where=["(date_start, date_end) OVERLAPS (%s, %s)"], params=[date_start, date_end], ) if overlapping_subscription_qs.exists(): raise APIErrorBadRequest(N_('another subscription overlapping this period already exists')) subscription = Subscription.objects.create( agenda=self.agenda, extra_data=extra_data, **serializer.validated_data ) return Response({'err': 0, 'id': subscription.pk}) subscriptions = SubscriptionsAPI.as_view() class SubscriptionAPI(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.SubscriptionSerializer def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) self.subscription = get_object_or_404( Subscription, pk=kwargs.get('subscription_pk'), agenda__kind='events', agenda__slug=kwargs.get('agenda_identifier'), ) def get(self, request, *args, **kwargs): serializer = self.serializer_class(self.subscription) response = serializer.data response.update({'err': 0}) return Response(response) def delete_out_of_period_bookings(self, date_start, date_end): booking_qs = Booking.objects.filter( # remove user bookings for this agenda event__agenda=self.subscription.agenda, user_external_id=self.subscription.user_external_id, # in the requested period event__start_datetime__gte=date_start, event__start_datetime__lt=date_end, ).filter( # but only in the future event__start_datetime__gt=now(), ) booking_qs.delete() def patch(self, request, *args, **kwargs): serializer = self.serializer_class(self.subscription, data=request.data, partial=True) old_date_start = self.subscription.date_start old_date_end = self.subscription.date_end if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=4) date_start = serializer.validated_data.get('date_start') or old_date_start date_end = serializer.validated_data.get('date_end') or old_date_end overlapping_subscription_qs = ( Subscription.objects.filter( agenda=self.subscription.agenda, user_external_id=self.subscription.user_external_id, ) .exclude(pk=self.subscription.pk) .extra( where=["(date_start, date_end) OVERLAPS (%s, %s)"], params=[date_start, date_end], ) ) if ( old_date_start != date_start or old_date_end != date_end ) and overlapping_subscription_qs.exists(): raise APIErrorBadRequest(N_('another subscription overlapping this period already exists')) if ( 'user_external_id' in serializer.validated_data and serializer.validated_data['user_external_id'] != self.subscription.user_external_id ): raise APIErrorBadRequest(N_('it is not possible to change user_external_id value')) serializer.save() if old_date_start > self.subscription.date_end or old_date_end < self.subscription.date_start: # new period does not overlaps the old one, delete all bookings in the old period self.delete_out_of_period_bookings(old_date_start, old_date_end) else: if old_date_start < self.subscription.date_start: # date start has been postponed, remove all bookings from old start to new start self.delete_out_of_period_bookings(old_date_start, self.subscription.date_start) if old_date_end > self.subscription.date_end: # date end has been brought forward, remove all bookings from new end to old end self.delete_out_of_period_bookings(self.subscription.date_end, old_date_end) extra_data = {k: v for k, v in request.data.items() if k not in serializer.validated_data} if extra_data: self.subscription.extra_data = self.subscription.extra_data or {} self.subscription.extra_data.update(extra_data) self.subscription.save() # update bookings inside the new period (other bookings were deleted) Booking.objects.filter( # remove user bookings for this agenda event__agenda=self.subscription.agenda, user_external_id=self.subscription.user_external_id, # in the period of the subscription event__start_datetime__gte=self.subscription.date_start, event__start_datetime__lt=self.subscription.date_end, ).filter( # but only in the future event__start_datetime__gt=now(), ).update( extra_data=RawSQL("COALESCE(extra_data, '{}'::jsonb) || %s::jsonb", (json.dumps(extra_data),)) ) return self.get(request, *args, **kwargs) def delete(self, request, *args, **kwargs): self.delete_out_of_period_bookings(self.subscription.date_start, self.subscription.date_end) self.subscription.delete() response = {'err': 0} return Response(response) subscription = SubscriptionAPI.as_view() class BookingFilter(filters.FilterSet): agenda = filters.CharFilter(field_name='event__agenda__slug', lookup_expr='exact') event = filters.CharFilter(method='filter_event') 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', lookup_expr='lt') user_absence_reason = filters.CharFilter(method='filter_user_absence_reason') user_presence_reason = filters.CharFilter(method='filter_user_presence_reason') def filter_event(self, queryset, name, value): # we want to include bookings of event recurrences return queryset.filter(Q(event__slug=value) | Q(event__primary_event__slug=value)) def filter_user_absence_reason(self, queryset, name, value): return queryset.filter( Q(user_check_type_slug=value) | Q(user_check_type_label=value), user_was_present=False, ) def filter_user_presence_reason(self, queryset, name, value): return queryset.filter( Q(user_check_type_slug=value) | Q(user_check_type_label=value), user_was_present=True, ) class Meta: model = Booking fields = [ 'user_external_id', 'agenda', 'category', 'date_start', 'date_end', 'user_was_present', 'user_absence_reason', 'user_presence_reason', 'in_waiting_list', ] class BookingsAPI(ListAPIView): filter_backends = (filters.DjangoFilterBackend,) serializer_class = serializers.BookingSerializer filterset_class = BookingFilter def get(self, request, *args, **kwargs): if not request.GET.get('user_external_id'): raise APIError(N_('missing param user_external_id')) try: bookings = self.filter_queryset(self.get_queryset()) except ValidationError as e: raise APIErrorBadRequest(N_('invalid payload'), errors=e.detail) data = [] for booking in bookings: serialized_booking = self.serializer_class(booking).data if booking.event.agenda.kind == 'events': serialized_booking['event'] = get_event_detail(request, booking.event) data.append(serialized_booking) return Response({'err': 0, 'data': data}) def get_queryset(self): return ( Booking.objects.filter(primary_booking__isnull=True, cancellation_datetime__isnull=True) .select_related('event', 'event__agenda', 'event__desk') .order_by('event__start_datetime', 'event__slug', 'event__agenda__slug', 'pk') ) bookings = BookingsAPI.as_view() class BookingAPI(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.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: raise APIError(N_('booking is cancelled')) if self.booking.primary_booking is not None: raise APIError(N_('secondary booking'), err=2) if check_waiting_list and self.booking.in_waiting_list: raise APIError(N_('booking is in waiting list'), err=3) def get(self, request, *args, **kwargs): self.check_booking() 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): self.check_booking(check_waiting_list=True) serializer = self.serializer_class(self.booking, data=request.data, partial=True) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=4) if self.booking.event.agenda.kind != 'events' and ( 'user_was_present' in request.data or 'user_absence_reason' in request.data or 'user_presence_reason' in request.data ): raise APIErrorBadRequest(N_('can not set check fields for non events agenda'), err=7) if ( self.booking.event.checked and self.booking.event.agenda.disable_check_update and ( 'user_was_present' in request.data or 'user_absence_reason' in request.data or 'user_presence_reason' in request.data ) ): raise APIErrorBadRequest(N_('event is marked as checked'), err=5) user_was_present = serializer.validated_data.get('user_was_present', self.booking.user_was_present) if user_was_present is True and 'user_absence_reason' in request.data: raise APIErrorBadRequest(N_('user is marked as present, can not set absence reason'), err=6) if user_was_present is False and 'user_presence_reason' in request.data: raise APIErrorBadRequest(N_('user is marked as absent, can not set presence reason'), err=6) serializer.save() extra_data = {k: v for k, v in request.data.items() if k not in serializer.validated_data} if extra_data: self.booking.extra_data = self.booking.extra_data or {} self.booking.extra_data.update(extra_data) self.booking.save() secondary_bookings_update = {} for key in [ 'user_was_present', 'user_first_name', 'user_last_name', 'user_email', 'user_phone_number', ]: if key in request.data: secondary_bookings_update[key] = getattr(self.booking, key) if 'use_color_for' in request.data: secondary_bookings_update['color'] = self.booking.color if 'user_absence_reason' in request.data or 'user_presence_reason' in request.data: secondary_bookings_update['user_check_type_slug'] = self.booking.user_check_type_slug secondary_bookings_update['user_check_type_label'] = self.booking.user_check_type_label if extra_data: secondary_bookings_update['extra_data'] = self.booking.extra_data if secondary_bookings_update: self.booking.secondary_booking_set.update(**secondary_bookings_update) if 'user_was_present' in request.data: self.booking.event.set_is_checked() response = {'err': 0, 'booking_id': self.booking.pk} return Response(response) def delete(self, request, *args, **kwargs): self.check_booking() 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: raise APIError(N_('already cancelled')) if booking.primary_booking is not None: raise APIError(N_('secondary booking'), err=2) 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: raise APIError(N_('booking is cancelled')) if booking.primary_booking is not None: raise APIError(N_('secondary booking'), err=2) if not booking.in_waiting_list: raise APIError(N_('booking is not in waiting list'), err=3) 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: raise APIError(N_('booking is cancelled')) if booking.primary_booking is not None: raise APIError(N_('secondary booking'), err=2) if booking.in_waiting_list: raise APIError(N_('booking is already in waiting list'), err=3) 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 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 = serializers.ResizeSerializer def post(self, request, booking_pk=None, format=None): serializer = self.serializer_class(data=request.data) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data booking = get_object_or_404(Booking, pk=booking_pk, event__agenda__kind='events') event = booking.event if booking.cancellation_datetime: raise APIError(N_('booking is cancelled')) if booking.primary_booking is not None: raise APIError(N_('secondary booking'), err=2) event_ids = {event.pk} in_waiting_list = {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: raise APIError(N_('can not resize multi event booking'), err=4) if len(in_waiting_list) > 1: raise APIError(N_('can not resize booking: waiting list inconsistency'), err=5) # 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.booked_waiting_list_places 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 raise APIError(N_('sold out'), err=3) if event.booked_places <= event.places: # in main list and no overbooking for the moment: can not be overbooked raise APIError(N_('sold out'), err=3) 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 dummy 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 EventsAPI(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.EventSerializer def post(self, request, agenda_identifier): agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') event = Event(agenda=agenda) serializer = self.serializer_class(data=request.data, instance=event) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) event = serializer.save() if event.recurrence_days: event.create_all_recurrences() return Response({'err': 0, 'data': get_event_detail(request, event)}) events = EventsAPI.as_view() class EventAPI(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.EventSerializer def get_object(self, agenda_identifier, event_identifier): agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') try: return agenda.event_set.get(slug=event_identifier) except Event.DoesNotExist: raise Http404() def patch(self, request, agenda_identifier=None, event_identifier=None, format=None): event = self.get_object(agenda_identifier, event_identifier) serializer = self.serializer_class(event, data=request.data, partial=True) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data changed_data = [] for field in serializer.fields.keys(): if field in payload and payload[field] != getattr(event, field): changed_data.append(field) if event.primary_event: for field in changed_data: if field in ( 'recurrence_end_date', 'publication_datetime', 'recurrence_days', 'recurrence_week_interval', ): raise APIErrorBadRequest(N_('%s cannot be modified on an event recurrence'), field) protected_fields = ['start_datetime', 'recurrence_days', 'recurrence_week_interval'] if event.recurrence_days and event.has_recurrences_booked(): for field in changed_data: if field in protected_fields: raise APIErrorBadRequest( N_('%s cannot be modified because some recurrences have bookings attached to them.') % field ) if 'recurrence_end_date' in changed_data and event.has_recurrences_booked( after=payload['recurrence_end_date'] ): raise APIErrorBadRequest( N_('recurrence_end_date cannot be modified because bookings exist after this date.') ) with event.update_recurrences( changed_data, payload, protected_fields, protected_fields + ['recurrence_end_date'] ): event = serializer.save() return Response({'err': 0, 'data': get_event_detail(request, event)}) def delete(self, request, agenda_identifier, event_identifier): event = self.get_object(agenda_identifier, event_identifier) cannot_delete = bool( event.booking_set.filter(cancellation_datetime__isnull=True).exists() and event.start_datetime > now() or event.has_recurrences_booked() ) if cannot_delete: raise APIError(_('This cannot be removed as there are bookings for a future date.')) event.delete() return Response({'err': 0}) event = EventAPI.as_view() class EventStatus(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() 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) event_status = EventStatus.as_view() class EventCheck(APIView): permission_classes = (permissions.IsAuthenticated,) def get_object(self, agenda_identifier, event_identifier): agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') try: return agenda.event_set.get(slug=event_identifier) except Event.DoesNotExist: raise Http404() def post(self, request, agenda_identifier=None, event_identifier=None, format=None): event = self.get_object(agenda_identifier, event_identifier) if not event.checked: event.checked = True event.save(update_fields=['checked']) response = { 'err': 0, } return Response(response) event_check = EventCheck.as_view() class EventBookings(APIView): permission_classes = (permissions.IsAuthenticated,) def get_object(self, agenda_identifier, 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'): raise APIError(N_('missing param user_external_id')) 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) event_bookings = EventBookings.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() class SharedCustodyAgendas(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.SharedCustodyAgendaCreateSerializer def post(self, request): serializer = self.serializer_class(data=request.data) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) agenda = serializer.save() response = { 'id': agenda.pk, 'settings_url': request.build_absolute_uri(agenda.get_settings_url()), 'backoffice_url': request.build_absolute_uri(agenda.get_absolute_url()), } return Response({'err': 0, 'data': response}) shared_custody_agendas = SharedCustodyAgendas.as_view() class SharedCustodyAgendaAPI(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.SharedCustodyAgendaSerializer def patch(self, request, agenda_pk): agenda = get_object_or_404(SharedCustodyAgenda, pk=agenda_pk) serializer = self.serializer_class(agenda, data=request.data) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) agenda = serializer.save() return Response({'err': 0}) shared_custody_agenda = SharedCustodyAgendaAPI.as_view() class StatisticsList(APIView): permission_classes = (permissions.IsAuthenticated,) def get(self, request, *args, **kwargs): categories = Category.objects.all() category_options = [{'id': '_all', 'label': pgettext('categories', 'All')}] + [ {'id': x.slug, 'label': x.label} for x in categories ] return Response( { 'data': [ { 'name': _('Bookings Count'), 'url': request.build_absolute_uri(reverse('api-statistics-bookings')), 'id': 'bookings_count', 'future_data': True, 'filters': [ { 'id': 'time_interval', 'label': _('Interval'), 'options': [{'id': 'day', 'label': _('Day')}], 'required': True, 'default': 'day', }, { 'id': 'category', 'label': _('Category'), 'options': category_options, 'required': True, 'default': '_all', 'has_subfilters': True, 'deprecated': True, 'deprecation_hint': _( 'Category should now be selected using the Agenda field below.' ), }, { 'id': 'agenda', 'label': _('Agenda'), 'options': self.get_agenda_options(), 'required': True, 'default': '_all', 'has_subfilters': True, }, ], } ] } ) @staticmethod def get_agenda_options(): all_agendas_option = [{'id': '_all', 'label': pgettext('agendas', 'All')}] agendas = Agenda.objects.all().order_by('category__name') agendas_with_category = [x for x in agendas if x.category] if not agendas_with_category: return all_agendas_option + [{'id': x.slug, 'label': x.label} for x in agendas] agenda_options = {None: all_agendas_option} for agenda in agendas_with_category: if agenda.category.label not in agenda_options: agenda_options[agenda.category.label] = [ { 'id': 'category:' + agenda.category.slug, 'label': _('All agendas of category %s') % agenda.category.label, } ] agenda_options[agenda.category.label].append({'id': agenda.slug, 'label': agenda.label}) agendas_without_category_options = [ {'id': x.slug, 'label': x.label} for x in agendas if not x.category ] if agendas_without_category_options: agenda_options[_('Misc')] = agendas_without_category_options return list(agenda_options.items()) statistics_list = StatisticsList.as_view() class BookingsStatistics(APIView): permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.StatisticsFiltersSerializer def get(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.query_params) if not serializer.is_valid(): raise APIErrorBadRequest(N_('invalid statistics filters'), errors=serializer.errors) data = serializer.validated_data subfilters = [] bookings = Booking.objects.filter(cancellation_datetime__isnull=True) if 'start' in data: bookings = bookings.filter(event__start_datetime__gte=data['start']) if 'end' in data: bookings = bookings.filter(event__start_datetime__lte=data['end']) agenda_slug = data.get('agenda', '_all') category_slug = data.get('category', '_all') if agenda_slug.startswith('category:'): category_slug = agenda_slug.split(':', 1)[1] if category_slug != '_all': bookings = bookings.filter(event__agenda__category__slug=category_slug) subfilters = self.get_subfilters(agendas=Agenda.objects.filter(category__slug=category_slug)) elif agenda_slug != '_all': bookings = bookings.filter(event__agenda__slug=agenda_slug) subfilters = self.get_subfilters(agendas=Agenda.objects.filter(slug=agenda_slug)) bookings = bookings.annotate(day=TruncDay('event__start_datetime')) if 'group_by' not in data: bookings = bookings.values('day').annotate(total=Count('id')).order_by('day') days = [booking['day'] for booking in bookings] if bookings: series = [{'label': _('Bookings Count'), 'data': [booking['total'] for booking in bookings]}] else: series = [] else: group_by = data['group_by'] if not isinstance(group_by, list): # legacy support group_by = [group_by] lookups = [ 'extra_data__%s' % field if field != 'user_was_present' else field for field in group_by ] bookings = bookings.values('day', *lookups).annotate(total=Count('id')).order_by('day') days = bookings_by_day = collections.OrderedDict( # day1: {group1: total_11, group2: total_12}, # day2: {group1: total_21} ) seen_group_values = set( # group1, group2 ) for booking in bookings: totals_by_group = bookings_by_day.setdefault(booking['day'], {}) group_value = tuple(booking[field] for field in lookups) totals_by_group[group_value] = booking['total'] seen_group_values.add(group_value) bookings_by_group = { # group1: [total_11, total_21], # group2: [total_12, None], } for group in seen_group_values: bookings_by_group[group] = [bookings.get(group) for bookings in bookings_by_day.values()] def build_label(group): group_labels = [] for field, value in zip(group_by, group): if field == 'user_was_present': label = {None: gettext('Booked'), True: gettext('Present'), False: gettext('Absent')}[ value ] else: label = value or gettext('None') group_labels.append(label) return ' / '.join(group_labels) series = [ {'label': build_label(k), 'data': data} for k, data in bookings_by_group.items() if any(data) ] series.sort(key=lambda x: x['label']) return Response( { 'data': { 'x_labels': [day.strftime('%Y-%m-%d') for day in days], 'series': series, 'subfilters': subfilters, }, 'err': 0, } ) def get_subfilters(self, agendas): extra_data_keys = ( Booking.objects.filter(event__agenda__in=agendas) .annotate(extra_data_keys=Func('extra_data', function='jsonb_object_keys')) .distinct('extra_data_keys') .order_by('extra_data_keys') .values_list('extra_data_keys', flat=True) ) group_by_options = [{'id': 'user_was_present', 'label': _('Presence/Absence')}] + [ {'id': x, 'label': x.capitalize()} for x in extra_data_keys ] return [ { 'id': 'group_by', 'label': _('Group by'), 'options': group_by_options, 'required': False, 'multiple': True, }, ] bookings_statistics = BookingsStatistics.as_view()