3179 lines
127 KiB
Python
3179 lines
127 KiB
Python
# chrono - agendas system
|
|
# Copyright (C) 2016 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import collections
|
|
import copy
|
|
import datetime
|
|
import json
|
|
import uuid
|
|
|
|
from django.conf import settings
|
|
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.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,
|
|
Desk,
|
|
Event,
|
|
MeetingType,
|
|
SharedCustodyAgenda,
|
|
Subscription,
|
|
)
|
|
from chrono.api import serializers
|
|
from chrono.api.utils import APIError, APIErrorBadRequest, Response
|
|
from chrono.utils.publik_urls import translate_to_publik_url
|
|
from chrono.utils.timezone import localtime, make_aware, now
|
|
|
|
|
|
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_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,
|
|
'minimal_booking_time': agenda.minimal_booking_time,
|
|
'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,
|
|
):
|
|
details = get_short_event_detail(
|
|
request=request,
|
|
event=event,
|
|
agenda=agenda,
|
|
multiple_agendas=multiple_agendas,
|
|
)
|
|
agenda = agenda or event.agenda
|
|
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_short_event_detail(
|
|
request,
|
|
event,
|
|
agenda=None,
|
|
multiple_agendas=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),
|
|
'end_datetime': format_response_datetime(event.end_datetime) if event.end_datetime else '',
|
|
'description': event.description,
|
|
'pricing': event.pricing,
|
|
'url': event.url,
|
|
'duration': event.duration,
|
|
'checked': event.checked,
|
|
'check_locked': event.check_locked,
|
|
'invoiced': event.invoiced,
|
|
}
|
|
for key, value in event.get_custom_fields().items():
|
|
details['custom_field_%s' % key] = value
|
|
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 get_minutes_from_request(request):
|
|
serializer = serializers.MinutesSerializer(data=request.query_params)
|
|
if not serializer.is_valid():
|
|
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
|
return serializer.validated_data.get('minutes')
|
|
|
|
|
|
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', '')),
|
|
presence_callback_url=translate_to_publik_url(payload.get('presence_callback_url', '')),
|
|
absence_callback_url=translate_to_publik_url(payload.get('absence_callback_url', '')),
|
|
user_display_label=payload.get('user_display_label', ''),
|
|
extra_emails=payload.get('extra_emails', []),
|
|
extra_phone_numbers=payload.get('extra_phone_numbers', []),
|
|
start_time=payload.get('start_time'),
|
|
end_time=payload.get('end_time'),
|
|
extra_data=extra_data,
|
|
color=color,
|
|
)
|
|
|
|
|
|
def get_extra_data(request, payload):
|
|
extra_data = {}
|
|
for k, v in request.data.items():
|
|
if k not in payload:
|
|
if isinstance(v, (list, dict)):
|
|
raise APIErrorBadRequest(N_('wrong type for extra_data %s value') % k)
|
|
extra_data[k] = v
|
|
return extra_data
|
|
|
|
|
|
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,)
|
|
serializer_class = serializers.AgendaSerializer
|
|
|
|
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})
|
|
|
|
def patch(self, request, agenda_identifier):
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier)
|
|
serializer = self.serializer_class(agenda, data=request.data, partial=True)
|
|
|
|
if not serializer.is_valid():
|
|
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
|
|
|
if 'kind' in serializer.validated_data and serializer.validated_data['kind'] != agenda.kind:
|
|
raise APIErrorBadRequest(N_('it is not possible to change kind value'))
|
|
|
|
serializer.save()
|
|
return self.get(request, agenda_identifier)
|
|
|
|
|
|
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)
|
|
entries = entries.order_by('start_datetime', 'duration', 'label')
|
|
|
|
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()
|
|
if agendas:
|
|
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)
|
|
minutes = get_minutes_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.
|
|
if agenda.kind == 'meetings':
|
|
|
|
def unique_slots():
|
|
last_slot = None
|
|
all_slots = list(
|
|
agenda.get_all_slots(
|
|
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, None
|
|
|
|
elif agenda.kind == 'virtual':
|
|
|
|
def unique_slots():
|
|
all_slots = list(
|
|
agenda.get_all_slots(
|
|
meeting_type,
|
|
resources=resources,
|
|
unique=False,
|
|
start_datetime=start_datetime,
|
|
end_datetime=end_datetime,
|
|
user_external_id=booked_user_external_id or excluded_user_external_id,
|
|
)
|
|
)
|
|
last_slot, slot_agendas = None, set()
|
|
for slot in sorted(all_slots, key=lambda slot: slot[:3]):
|
|
if slot.start_datetime < now_datetime:
|
|
continue
|
|
if last_slot is None:
|
|
last_slot = slot
|
|
elif last_slot[:2] != slot[:2]:
|
|
yield last_slot, slot_agendas
|
|
last_slot = slot
|
|
slot_agendas = set()
|
|
if not slot.full:
|
|
slot_agendas.add(slot.desk.agenda)
|
|
if last_slot:
|
|
yield last_slot, slot_agendas
|
|
|
|
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, slot_agendas in generator_of_unique_slots:
|
|
if request.GET.get('hide_disabled') and slot.full:
|
|
continue
|
|
if minutes and slot.start_datetime.minute not in minutes:
|
|
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),
|
|
'end_datetime': format_response_datetime(slot.end_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
|
|
if slot_agendas is not None:
|
|
slot_data['agendas'] = [
|
|
{
|
|
'id': agenda.id,
|
|
'text': agenda.label,
|
|
'slug': agenda.slug,
|
|
'api': {
|
|
'fillslot_url': request.build_absolute_uri(
|
|
reverse(
|
|
'api-fillslot',
|
|
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': slot_id},
|
|
)
|
|
)
|
|
},
|
|
}
|
|
for agenda in sorted(slot_agendas, key=lambda a: a.label)
|
|
]
|
|
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 = data.get('check_overlaps')
|
|
|
|
start_datetime, end_datetime = data.get('date_start'), data.get('date_end')
|
|
if not start_datetime or start_datetime < now():
|
|
start_datetime = now()
|
|
|
|
guardian_external_id = data.get('guardian_external_id')
|
|
if guardian_external_id:
|
|
user_external_id = data['user_external_id']
|
|
agendas = Agenda.prefetch_events(
|
|
data['agendas'],
|
|
user_external_id=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)
|
|
recurring_events = Event.annotate_recurring_events_with_booking_overlaps(
|
|
recurring_events, check_overlaps, user_external_id, start_datetime, end_datetime
|
|
)
|
|
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'], check_overlaps, data.get('user_external_id'), start_datetime, end_datetime
|
|
)
|
|
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
|
|
]
|
|
event.has_booking_overlaps = bool(event.day in event.days_with_booking_overlaps)
|
|
|
|
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,
|
|
'has_booking_overlaps': event.has_booking_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):
|
|
if not settings.LEGACY_FILLSLOTS_ENABLED:
|
|
raise APIErrorBadRequest(N_('deprecated call'))
|
|
|
|
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 = get_extra_data(request, serializer.validated_data)
|
|
|
|
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(
|
|
agenda.get_all_slots(
|
|
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,
|
|
)
|
|
|
|
if payload.get('check_overlaps'):
|
|
self.check_for_overlaps(events_to_book, serializer.initial_slots)
|
|
events_to_book = Event.annotate_queryset_with_booked_event_overlaps(
|
|
events_to_book,
|
|
payload['check_overlaps'],
|
|
user_external_id,
|
|
start_datetime,
|
|
end_datetime,
|
|
exclude_events=events_to_unbook,
|
|
)
|
|
events_to_book = events_to_book.exclude(has_overlap=True)
|
|
|
|
# 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 = get_extra_data(request, 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
|
|
)
|
|
|
|
events_by_id = {x.id: x for x in list(events_to_book) + list(events_to_unbook)}
|
|
with transaction.atomic():
|
|
# cancel existing bookings
|
|
cancellation_datetime = now()
|
|
Booking.objects.filter(primary_booking__in=bookings_to_cancel).update(
|
|
cancellation_datetime=cancellation_datetime
|
|
)
|
|
if payload.get('include_booked_events_detail'):
|
|
cancelled_events = [
|
|
get_short_event_detail(
|
|
request,
|
|
events_by_id[x.event_id],
|
|
multiple_agendas=True,
|
|
)
|
|
for x in bookings_to_cancel
|
|
]
|
|
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'):
|
|
response['booked_events'] = [
|
|
get_event_detail(request, events_by_id[x.event_id], booking=x, multiple_agendas=True)
|
|
for x in created_bookings
|
|
]
|
|
response['cancelled_events'] = cancelled_events
|
|
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}
|
|
agenda_slugs = set()
|
|
event_slugs = set()
|
|
for agenda_slug, days_by_event in slots.items():
|
|
agenda = agendas_by_slug[agenda_slug]
|
|
agenda_slugs.add(agenda_slug)
|
|
for event_slug, days in days_by_event.items():
|
|
event_slugs.add(event_slug)
|
|
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()
|
|
if agenda_slugs:
|
|
events = events.filter(agenda__slug__in=agenda_slugs, primary_event__slug__in=event_slugs)
|
|
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
|
|
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 = get_extra_data(request, 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
|
|
)
|
|
|
|
events_by_id = {
|
|
x.id: x for x in (list(events) + events_to_unbook + events_to_unbook_out_of_min_delay)
|
|
}
|
|
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_events = [
|
|
get_short_event_detail(
|
|
request,
|
|
events_by_id[x.event_id],
|
|
multiple_agendas=self.multiple_agendas,
|
|
)
|
|
for x in bookings_to_cancel_out_of_min_delay
|
|
]
|
|
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_events += [
|
|
get_short_event_detail(
|
|
request,
|
|
events_by_id[x.event_id],
|
|
multiple_agendas=self.multiple_agendas,
|
|
)
|
|
for x in bookings_to_cancel
|
|
]
|
|
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]
|
|
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,
|
|
'cancelled_events': cancelled_events,
|
|
}
|
|
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:
|
|
try:
|
|
agenda, event = slot.split('@')
|
|
except ValueError:
|
|
raise APIErrorBadRequest(N_('invalid slugs: %s'), slot)
|
|
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)
|
|
.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 MultipleAgendasEventsCheckLock(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = serializers.MultipleAgendasEventsCheckLockSerializer
|
|
|
|
def post(self, request):
|
|
serializer = self.serializer_class(data=request.data)
|
|
|
|
if not serializer.is_valid():
|
|
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1)
|
|
agendas = serializer.validated_data['agendas']
|
|
date_start = serializer.validated_data['date_start']
|
|
date_end = serializer.validated_data['date_end']
|
|
check_locked = serializer.validated_data['check_locked']
|
|
|
|
events = Event.objects.filter(
|
|
agenda__in=agendas,
|
|
recurrence_days__isnull=True,
|
|
cancelled=False,
|
|
start_datetime__gte=date_start,
|
|
start_datetime__lt=date_end,
|
|
)
|
|
events.update(check_locked=check_locked)
|
|
|
|
return Response({'err': 0})
|
|
|
|
|
|
agendas_events_check_lock = MultipleAgendasEventsCheckLock.as_view()
|
|
|
|
|
|
class MultipleAgendasEventsInvoiced(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = serializers.MultipleAgendasEventsInvoicedSerializer
|
|
|
|
def post(self, request):
|
|
serializer = self.serializer_class(data=request.data)
|
|
|
|
if not serializer.is_valid():
|
|
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1)
|
|
agendas = serializer.validated_data['agendas']
|
|
date_start = serializer.validated_data['date_start']
|
|
date_end = serializer.validated_data['date_end']
|
|
invoiced = serializer.validated_data['invoiced']
|
|
|
|
events = Event.objects.filter(
|
|
agenda__in=agendas,
|
|
recurrence_days__isnull=True,
|
|
cancelled=False,
|
|
start_datetime__gte=date_start,
|
|
start_datetime__lt=date_end,
|
|
)
|
|
events.update(invoiced=invoiced)
|
|
|
|
return Response({'err': 0})
|
|
|
|
|
|
agendas_events_invoiced = MultipleAgendasEventsInvoiced.as_view()
|
|
|
|
|
|
class SubscriptionFilter(filters.FilterSet):
|
|
date_start = filters.DateFilter(method='do_nothing')
|
|
date_end = filters.DateFilter(method='do_nothing')
|
|
|
|
class Meta:
|
|
model = Subscription
|
|
fields = [
|
|
'user_external_id',
|
|
'date_start',
|
|
'date_end',
|
|
]
|
|
|
|
def do_nothing(self, queryset, name, value):
|
|
return queryset
|
|
|
|
def filter_queryset(self, queryset):
|
|
queryset = super().filter_queryset(queryset)
|
|
overlaps = {k: self.form.cleaned_data[k] for k in ['date_start', 'date_end']}
|
|
if any(overlaps.values()):
|
|
if not all(overlaps.values()):
|
|
missing = [k for k, v in overlaps.items() if not v][0]
|
|
not_missing = [k for k, v in overlaps.items() if v][0]
|
|
raise ValidationError(
|
|
{missing: _('This filter is required when using "%s" filter.') % not_missing}
|
|
)
|
|
queryset = queryset.extra(
|
|
where=["(date_start, date_end) OVERLAPS (%s, %s)"],
|
|
params=[
|
|
self.form.cleaned_data['date_start'],
|
|
self.form.cleaned_data['date_end'],
|
|
],
|
|
)
|
|
return queryset
|
|
|
|
|
|
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 = get_extra_data(request, 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 = get_extra_data(request, 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'])
|
|
event.async_notify_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, context={'request': request})
|
|
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, context={'request': request})
|
|
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):
|
|
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': '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', 'label')
|
|
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')
|
|
if agenda_slug != '_all':
|
|
if agenda_slug.startswith('category:'):
|
|
category_slug = agenda_slug.split(':', 1)[1]
|
|
agendas = Agenda.objects.filter(category__slug=category_slug)
|
|
else:
|
|
agendas = Agenda.objects.filter(slug=agenda_slug)
|
|
|
|
if not agendas:
|
|
raise APIErrorBadRequest(_('No agendas found.'))
|
|
|
|
bookings = bookings.filter(event__agenda__in=agendas)
|
|
subfilters = self.get_subfilters(agendas=agendas)
|
|
|
|
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': x, 'label': x.capitalize()} for x in extra_data_keys]
|
|
|
|
if any(x.kind == 'events' for x in agendas):
|
|
group_by_options = [{'id': 'user_was_present', 'label': _('Presence/Absence')}] + group_by_options
|
|
|
|
if not group_by_options:
|
|
return []
|
|
|
|
return [
|
|
{
|
|
'id': 'group_by',
|
|
'label': _('Group by'),
|
|
'options': group_by_options,
|
|
'required': False,
|
|
'multiple': True,
|
|
},
|
|
]
|
|
|
|
|
|
bookings_statistics = BookingsStatistics.as_view()
|