chrono/chrono/api/views.py

3227 lines
131 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 itertools
import json
import uuid
from django.db import IntegrityError, transaction
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Func, Prefetch, Q
from django.db.models.expressions import RawSQL
from django.db.models.functions import TruncDay
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist
from django.urls import reverse
from django.utils.dates import WEEKDAYS
from django.utils.encoding import force_str
from django.utils.formats import date_format
from django.utils.timezone import localtime, make_aware, now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop as N_
from django.utils.translation import pgettext
from django_filters import rest_framework as filters
from rest_framework import permissions
from rest_framework.exceptions import ValidationError
from rest_framework.generics import ListAPIView
from rest_framework.views import APIView
from chrono.agendas.models import (
Agenda,
Booking,
BookingColor,
Category,
Desk,
Event,
MeetingType,
SharedCustodyAgenda,
Subscription,
TimePeriodException,
)
from chrono.api import serializers
from chrono.api.utils import APIError, APIErrorBadRequest, Response
from chrono.interval import IntervalSet
from chrono.utils.publik_urls import translate_to_publik_url
def format_response_datetime(dt):
return localtime(dt).strftime('%Y-%m-%d %H:%M:%S')
def format_response_date(dt):
return localtime(dt).strftime('%Y-%m-%d')
def get_min_datetime(agenda, start_datetime=None):
if agenda.minimal_booking_delay is None:
return start_datetime
if start_datetime is None:
return agenda.min_booking_datetime
return max(agenda.min_booking_datetime, start_datetime)
def get_max_datetime(agenda, end_datetime=None):
if agenda.maximal_booking_delay is None:
return end_datetime
if end_datetime is None:
return agenda.max_booking_datetime
return min(agenda.max_booking_datetime, end_datetime)
TimeSlot = collections.namedtuple(
'TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk', 'booked_for_external_user']
)
def get_all_slots(
base_agenda,
meeting_type,
resources=None,
unique=False,
start_datetime=None,
end_datetime=None,
user_external_id=None,
):
"""Get all occupation state of all possible slots for the given agenda (of
its real agendas for a virtual agenda) and the given meeting_type.
The process is done in four phases:
- first phase: aggregate time intervals, during which a meeting is impossible
due to TimePeriodException models, by desk in IntervalSet (compressed
and ordered list of intervals).
- second phase: aggregate time intervals by desk for already booked slots, again
to make IntervalSet,
- third phase: for a meetings agenda, if resources has to be booked,
aggregate time intervals for already booked resources, to make IntervalSet.
- fourth and last phase: generate time slots from each time period based
on the time period definition and on the desk's respective agenda real
min/max_datetime; for each time slot check its status in the exclusion
and bookings sets.
If it is excluded, ignore it completely.
It if is booked, report the slot as full.
"""
resources = resources or []
# virtual agendas have one constraint :
# all the real agendas MUST have the same meetingstypes, the consequence is
# that the base_meeting_duration for the virtual agenda is always the same
# as the base meeting duration of each real agenda.
base_meeting_duration = base_agenda.get_base_meeting_duration()
max_meeting_duration_td = datetime.timedelta(minutes=base_agenda.get_max_meeting_duration())
base_min_datetime = get_min_datetime(base_agenda, start_datetime)
base_max_datetime = get_max_datetime(base_agenda, end_datetime)
meeting_duration = meeting_type.duration
meeting_duration_td = datetime.timedelta(minutes=meeting_duration)
now_datetime = now()
base_date = now_datetime.date()
agendas = base_agenda.get_real_agendas()
# regroup agendas by their opening period
agenda_ids_by_min_max_datetimes = collections.defaultdict(set)
agenda_id_min_max_datetime = {}
for agenda in agendas:
used_min_datetime = base_min_datetime
if base_agenda.minimal_booking_delay is None:
used_min_datetime = get_min_datetime(agenda, start_datetime)
used_max_datetime = base_max_datetime
if base_agenda.maximal_booking_delay is None:
used_max_datetime = get_max_datetime(agenda, end_datetime)
agenda_ids_by_min_max_datetimes[(used_min_datetime, used_max_datetime)].add(agenda.id)
agenda_id_min_max_datetime[agenda.id] = (used_min_datetime, used_max_datetime)
# aggregate time period exceptions by desk as IntervalSet for fast querying
# 1. sort exceptions by start_datetime
# 2. group them by desk
# 3. convert each desk's list of exception to intervals then IntervalSet
desks_exceptions = {
time_period_desk: IntervalSet.from_ordered(
map(TimePeriodException.as_interval, time_period_exceptions)
)
for time_period_desk, time_period_exceptions in itertools.groupby(
TimePeriodException.objects.filter(desk__agenda__in=agendas)
.select_related('desk')
.order_by('desk_id', 'start_datetime', 'end_datetime'),
key=lambda time_period: time_period.desk,
)
}
# add exceptions from unavailability calendar
time_period_exception_queryset = (
TimePeriodException.objects.all()
.select_related('unavailability_calendar')
.prefetch_related(
Prefetch(
'unavailability_calendar__desks',
queryset=Desk.objects.filter(agenda__in=agendas),
to_attr='prefetched_desks',
)
)
.filter(unavailability_calendar__desks__agenda__in=agendas)
.order_by('start_datetime', 'end_datetime')
)
for time_period_exception in time_period_exception_queryset:
# unavailability calendar can be used in all desks;
# ignore desks outside of current agenda(s)
for desk in time_period_exception.unavailability_calendar.prefetched_desks:
if desk not in desks_exceptions:
desks_exceptions[desk] = IntervalSet()
desks_exceptions[desk].add(
time_period_exception.start_datetime, time_period_exception.end_datetime
)
# compute reduced min/max_datetime windows by desks based on exceptions
desk_min_max_datetime = {}
for desk, desk_exception in desks_exceptions.items():
base = IntervalSet([agenda_id_min_max_datetime[desk.agenda_id]])
base = base - desk_exception
if not base:
# ignore this desk, exceptions cover all opening time
# use an empty interval (begin == end) for this
desk_min_max_datetime[desk] = (now_datetime, now_datetime)
continue
min_datetime = base.min().replace(hour=0, minute=0, second=0, microsecond=0)
if base_min_datetime:
min_datetime = max(min_datetime, base_min_datetime)
max_datetime = base.max()
if base_max_datetime:
max_datetime = min(max_datetime, base_max_datetime)
desk_min_max_datetime[desk] = (min_datetime, max_datetime)
# aggregate already booked time intervals by desk
bookings = {}
for (used_min_datetime, used_max_datetime), agenda_ids in agenda_ids_by_min_max_datetimes.items():
booked_events = (
Event.objects.filter(
agenda__in=agenda_ids,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
)
.exclude(booking__cancellation_datetime__isnull=False)
# ordering is important for the later groupby, it works like sort | uniq
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
bookings.update(
(
desk_id,
IntervalSet.from_ordered(
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
for desk_id, event_start_datetime, event_duration in values
),
)
for desk_id, values in itertools.groupby(booked_events, lambda be: be[0])
)
# aggregate already booked time intervals for resources
resources_bookings = IntervalSet()
if base_agenda.kind == 'meetings' and resources:
used_min_datetime, used_max_datetime = agenda_id_min_max_datetime[base_agenda.pk]
event_ids_queryset = Event.resources.through.objects.filter(
resource__in=[r.pk for r in resources]
).values('event')
booked_events = (
Event.objects.filter(
pk__in=event_ids_queryset,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
)
.exclude(booking__cancellation_datetime__isnull=False)
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
# compute exclusion set
resources_bookings = IntervalSet.from_ordered(
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
for event_start_datetime, event_duration in booked_events
)
# aggregate already booked time intervals by excluded_user_external_id
user_bookings = IntervalSet()
if user_external_id:
used_min_datetime, used_max_datetime = (
min(v[0] for v in agenda_id_min_max_datetime.values()),
max(v[1] for v in agenda_id_min_max_datetime.values()),
)
booked_events = (
Event.objects.filter(
agenda__in=agenda_ids,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
booking__user_external_id=user_external_id,
)
.exclude(booking__cancellation_datetime__isnull=False)
# ordering is important for the later groupby, it works like sort | uniq
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
user_bookings = IntervalSet.from_ordered(
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
for event_start_datetime, event_duration in booked_events
)
unique_booked = {}
for time_period in base_agenda.get_effective_time_periods(base_min_datetime, base_max_datetime):
duration = (
datetime.datetime.combine(base_date, time_period.end_time)
- datetime.datetime.combine(base_date, time_period.start_time)
).seconds / 60
if duration < meeting_type.duration:
# skip time period that can't even hold a single meeting
continue
desks_by_min_max_datetime = collections.defaultdict(list)
for desk in time_period.desks:
min_max = desk_min_max_datetime.get(desk, agenda_id_min_max_datetime[desk.agenda_id])
desks_by_min_max_datetime[min_max].append(desk)
# aggregate agendas based on their real min/max_datetime :
# the get_time_slots() result is dependant upon these values, so even
# if we deduplicated a TimePeriod for some desks, if their respective
# agendas have different real min/max_datetime we must unduplicate them
# at time slot generation phase.
for (used_min_datetime, used_max_datetime), desks in desks_by_min_max_datetime.items():
for start_datetime in time_period.get_time_slots(
min_datetime=used_min_datetime,
max_datetime=used_max_datetime,
meeting_duration=meeting_duration,
base_duration=base_meeting_duration,
):
end_datetime = start_datetime + meeting_duration_td
timestamp = start_datetime.timestamp()
# skip generating datetimes if we already know that this
# datetime is available
if unique and unique_booked.get(timestamp) is False:
continue
for desk in sorted(desks, key=lambda desk: desk.label):
# ignore the slot for this desk, if it overlaps and exclusion period for this desk
excluded = desk in desks_exceptions and desks_exceptions[desk].overlaps(
start_datetime, end_datetime
)
if excluded:
continue
# slot is full if an already booked event overlaps it
# check resources first
booked = resources_bookings.overlaps(start_datetime, end_datetime)
# then check user boookings
booked_for_external_user = user_bookings.overlaps(start_datetime, end_datetime)
booked = booked or booked_for_external_user
# then bookings if resources are free
if not booked:
booked = desk.id in bookings and bookings[desk.id].overlaps(
start_datetime, end_datetime
)
if unique and unique_booked.get(timestamp) is booked:
continue
unique_booked[timestamp] = booked
yield TimeSlot(
start_datetime=start_datetime,
end_datetime=end_datetime,
desk=desk,
full=booked,
booked_for_external_user=booked_for_external_user,
)
if unique and not booked:
break
def get_agenda_detail(request, agenda, check_events=False):
agenda_detail = {
'id': agenda.slug,
'slug': agenda.slug, # kept for compatibility
'text': agenda.label,
'kind': agenda.kind,
'minimal_booking_delay': agenda.minimal_booking_delay,
'maximal_booking_delay': agenda.maximal_booking_delay,
'edit_role': agenda.edit_role.name if agenda.edit_role else None,
'view_role': agenda.view_role.name if agenda.view_role else None,
'category': agenda.category.slug if agenda.category else None,
'category_label': agenda.category.label if agenda.category else None,
}
if agenda.kind == 'meetings':
agenda_detail['resources'] = [
{'id': r.slug, 'text': r.label, 'description': r.description} for r in agenda.resources.all()
]
if agenda.kind == 'events':
agenda_detail['events_type'] = agenda.events_type.slug if agenda.events_type else None
agenda_detail['minimal_booking_delay_in_working_days'] = agenda.minimal_booking_delay_in_working_days
agenda_detail['api'] = {
'datetimes_url': request.build_absolute_uri(
reverse('api-agenda-datetimes', kwargs={'agenda_identifier': agenda.slug})
)
}
if check_events:
agenda_detail['opened_events_available'] = bool(agenda.get_open_events().filter(full=False))
elif agenda.accept_meetings():
agenda_detail['api'] = {
'meetings_url': request.build_absolute_uri(
reverse('api-agenda-meetings', kwargs={'agenda_identifier': agenda.slug})
),
'desks_url': request.build_absolute_uri(
reverse('api-agenda-desks', kwargs={'agenda_identifier': agenda.slug})
),
}
if agenda.kind == 'meetings':
agenda_detail['api'].update(
{
'resources_url': request.build_absolute_uri(
reverse('api-agenda-resources', kwargs={'agenda_identifier': agenda.slug})
),
}
)
agenda_detail['api']['fillslots_url'] = request.build_absolute_uri(
reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug})
)
agenda_detail['api']['backoffice_url'] = request.build_absolute_uri(
reverse('chrono-manager-agenda-view', kwargs={'pk': agenda.pk})
)
return agenda_detail
def get_event_places(event):
available = event.remaining_places
places = {
'total': event.places,
'reserved': event.booked_places,
'available': available,
'full': event.full,
'has_waiting_list': False,
}
if event.waiting_list_places:
places['has_waiting_list'] = True
places['waiting_list_total'] = event.waiting_list_places
places['waiting_list_reserved'] = event.booked_waiting_list_places
places['waiting_list_available'] = event.remaining_waiting_list_places
places['waiting_list_activated'] = event.booked_waiting_list_places > 0 or available <= 0
# 'waiting_list_activated' means next booking will go into the waiting list
return places
def is_event_disabled(event, min_places=1, disable_booked=True, bookable_events=None, bypass_delays=False):
if disable_booked and getattr(event, 'user_places_count', 0) > 0:
return True
if event.start_datetime < now():
# event is past
if bookable_events in ['all', 'past']:
# but we want to book past events, and it's always ok
return False
# we just want to show past events, but they are not bookable
return True
elif (
not bypass_delays
and event.agenda.min_booking_datetime
and event.start_datetime < event.agenda.min_booking_datetime
):
# event is out of minimal delay and we don't want to bypass delays
return True
if event.remaining_places < min_places and event.remaining_waiting_list_places < min_places:
return True
return False
def get_event_text(event, agenda, day=None):
event_text = force_str(event)
if agenda.event_display_template:
try:
event_text = Template(agenda.event_display_template).render(
Context({'event': event}, autoescape=False)
)
except (VariableDoesNotExist, TemplateSyntaxError):
pass
elif event.label and event.primary_event_id is not None:
event_text = '%s (%s)' % (
event.label,
date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
)
elif day is not None:
event_text = _('%(weekday)s: %(event)s') % {
'weekday': WEEKDAYS[day].capitalize(),
'event': event_text,
}
return event_text
# pylint: disable=too-many-arguments
def get_event_detail(
request,
event,
booking=None,
agenda=None,
min_places=1,
booked_user_external_id=None,
bookable_events=None,
multiple_agendas=False,
disable_booked=True,
bypass_delays=False,
with_status=False,
):
agenda = agenda or event.agenda
details = {
'id': '%s@%s' % (agenda.slug, event.slug) if multiple_agendas else event.slug,
'slug': event.slug, # kept for compatibility
'text': get_event_text(event, agenda),
'label': event.label or '',
'agenda_label': agenda.label,
'date': format_response_date(event.start_datetime),
'datetime': format_response_datetime(event.start_datetime),
'description': event.description,
'pricing': event.pricing,
'url': event.url,
'duration': event.duration,
'checked': event.checked,
}
for key, value in event.get_custom_fields().items():
details['custom_field_%s' % key] = value
if booking:
details['booking'] = {
'id': booking.pk,
'api': {
'booking_url': request.build_absolute_uri(
reverse('api-booking', kwargs={'booking_pk': booking.id})
),
'cancel_url': request.build_absolute_uri(
reverse('api-cancel-booking', kwargs={'booking_pk': booking.id})
),
'ics_url': request.build_absolute_uri(
reverse('api-booking-ics', kwargs={'booking_pk': booking.id})
),
'anonymize_url': request.build_absolute_uri(
reverse('api-anonymize-booking', kwargs={'booking_pk': booking.id})
),
},
}
if event.recurrence_days:
details.update(
{
'recurrence_days': event.recurrence_days,
'recurrence_week_interval': event.recurrence_week_interval,
'recurrence_end_date': event.recurrence_end_date,
}
)
else:
backoffice_url = request.build_absolute_uri(
reverse('chrono-manager-event-view', kwargs={'pk': agenda.pk, 'event_pk': event.pk})
)
details.update(
{
'disabled': is_event_disabled(
event,
min_places=min_places,
disable_booked=disable_booked,
bookable_events=bookable_events,
bypass_delays=bypass_delays,
),
'api': {
'bookings_url': request.build_absolute_uri(
reverse(
'api-event-bookings',
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
)
),
'fillslot_url': request.build_absolute_uri(
reverse(
'api-fillslot',
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
)
),
'status_url': request.build_absolute_uri(
reverse(
'api-event-status',
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
)
),
'check_url': request.build_absolute_uri(
reverse(
'api-event-check',
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
)
),
'backoffice_url': backoffice_url,
},
'places': get_event_places(event),
}
)
if bookable_events is not None:
details['api']['fillslot_url'] += '?events=%s' % bookable_events
if booked_user_external_id:
if getattr(event, 'user_places_count', 0) > 0:
details['booked_for_external_user'] = 'main-list'
elif getattr(event, 'user_waiting_places_count', 0) > 0:
details['booked_for_external_user'] = 'waiting-list'
if with_status and booked_user_external_id:
if getattr(event, 'user_absence_count', 0) > 0:
details['status'] = 'absence'
elif getattr(event, 'user_places_count', 0) > 0 or getattr(event, 'user_waiting_places_count', 0) > 0:
details['status'] = 'booked'
elif getattr(event, 'user_cancelled_count', 0) > 0:
details['status'] = 'cancelled'
else:
details['status'] = 'free'
if hasattr(event, 'overlaps'):
details['overlaps'] = event.overlaps
return details
def get_events_meta_detail(
request,
events,
agenda=None,
min_places=1,
bookable_events=None,
multiple_agendas=False,
bypass_delays=False,
):
bookable_datetimes_number_total = 0
bookable_datetimes_number_available = 0
first_bookable_slot = None
for event in events:
bookable_datetimes_number_total += 1
if not is_event_disabled(
event, min_places=min_places, bookable_events=bookable_events, bypass_delays=bypass_delays
):
bookable_datetimes_number_available += 1
if not first_bookable_slot:
first_bookable_slot = get_event_detail(
request,
event,
agenda=agenda,
min_places=min_places,
bookable_events=bookable_events,
multiple_agendas=multiple_agendas,
bypass_delays=bypass_delays,
)
return {
'no_bookable_datetimes': bool(bookable_datetimes_number_available == 0),
'bookable_datetimes_number_total': bookable_datetimes_number_total,
'bookable_datetimes_number_available': bookable_datetimes_number_available,
'first_bookable_slot': first_bookable_slot,
}
def get_events_from_slots(slots, request, agenda, payload):
user_external_id = payload.get('user_external_id') or None
exclude_user = payload.get('exclude_user')
book_events = payload.get('events') or request.query_params.get('events') or 'future'
book_past = book_events in ['all', 'past']
book_future = book_events in ['all', 'future']
bypass_delays = payload.get('bypass_delays')
try:
events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
except ValueError:
events = get_objects_from_slugs(slots, qs=agenda.event_set).order_by('start_datetime')
for event in events:
if event.start_datetime >= now():
if not book_future or not event.in_bookable_period(bypass_delays=bypass_delays):
raise APIError(N_('event %s is not bookable'), event.slug, err_class='event not bookable')
else:
if not book_past:
raise APIError(N_('event %s is not bookable'), event.slug, err_class='event not bookable')
if event.cancelled:
raise APIError(N_('event %s is cancelled'), event.slug, err_class='event is cancelled')
if exclude_user and user_external_id:
if event.booking_set.filter(
user_external_id=user_external_id, cancellation_datetime__isnull=True
).exists():
raise APIError(
N_('event %s is already booked by user'),
event.slug,
err_class='event is already booked by user',
)
if event.recurrence_days:
raise APIError(
N_('event %s is recurrent, direct booking is forbidden'),
event.slug,
err_class='event is recurrent',
)
if slots and not events.exists():
raise APIErrorBadRequest(N_('unknown event identifiers or slugs'))
return events
def get_resources_from_request(request, agenda):
if agenda.kind != 'meetings' or 'resources' not in request.GET:
return []
resources_slugs = [s for s in request.GET['resources'].split(',') if s]
return list(get_objects_from_slugs(resources_slugs, qs=agenda.resources))
def get_objects_from_slugs(slugs, qs, prefix=''):
slugs = set(slugs)
objects = qs.filter(slug__in=slugs)
if len(objects) != len(slugs):
unknown_slugs = sorted(slugs - {obj.slug for obj in objects})
unknown_slugs = ', '.join('%s%s' % (prefix, s) for s in unknown_slugs)
raise APIErrorBadRequest(N_('invalid slugs: %s'), unknown_slugs)
return objects
def get_start_and_end_datetime_from_request(request):
serializer = serializers.DateRangeSerializer(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
return serializer.validated_data.get('date_start'), serializer.validated_data.get('date_end')
def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None):
out_of_min_delay = False
if event.agenda.min_booking_datetime and event.start_datetime < event.agenda.min_booking_datetime:
out_of_min_delay = True
return Booking(
event_id=event.pk,
in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list),
primary_booking=primary_booking,
label=payload.get('label', ''),
user_external_id=payload.get('user_external_id', ''),
user_first_name=payload.get('user_first_name', ''),
user_last_name=payload.get('user_last_name') or payload.get('user_name') or '',
user_email=payload.get('user_email', ''),
user_phone_number=payload.get('user_phone_number', ''),
out_of_min_delay=out_of_min_delay,
form_url=translate_to_publik_url(payload.get('form_url', '')),
backoffice_url=translate_to_publik_url(payload.get('backoffice_url', '')),
cancel_callback_url=translate_to_publik_url(payload.get('cancel_callback_url', '')),
user_display_label=payload.get('user_display_label', ''),
extra_emails=payload.get('extra_emails', []),
extra_phone_numbers=payload.get('extra_phone_numbers', []),
extra_data=extra_data,
color=color,
)
class Agendas(APIView):
serializer_class = serializers.AgendaSerializer
def get_permissions(self):
if self.request.method == 'GET':
return []
return [permissions.IsAuthenticated()]
def get(self, request, format=None):
agendas_queryset = (
Agenda.objects.all()
.select_related('category', 'edit_role', 'view_role', 'events_type')
.prefetch_related('resources')
.order_by('label')
)
if 'q' in request.GET:
if not request.GET['q']:
return Response({'data': []})
agendas_queryset = agendas_queryset.filter(slug__icontains=request.GET['q'])
if request.GET.get('category'):
cat_slug = request.GET['category']
if cat_slug == '__none__':
agendas_queryset = agendas_queryset.filter(category__isnull=True)
else:
agendas_queryset = agendas_queryset.filter(category__slug=cat_slug)
with_open_events = request.GET.get('with_open_events') in ['1', 'true']
if with_open_events:
# return only events agenda
agendas_queryset = Agenda.prefetch_events(agendas_queryset)
agendas = []
for agenda in agendas_queryset:
if with_open_events and not any(
not e.full for e in agenda.get_open_events(prefetched_queryset=True)
):
# exclude agendas without open events
continue
agendas.append(get_agenda_detail(request, agenda))
return Response({'err': 0, 'data': agendas})
def post(self, request, format=None):
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
agenda = serializer.save()
if agenda.kind == 'events':
desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
desk.import_timeperiod_exceptions_from_settings()
return Response({'err': 0, 'data': [get_agenda_detail(request, agenda)]})
agendas = Agendas.as_view()
class AgendaAPI(APIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
def get(self, request, agenda_identifier):
agenda = get_object_or_404(Agenda, slug=agenda_identifier)
return Response({'data': get_agenda_detail(request, agenda, check_events=True)})
def delete(self, request, agenda_identifier):
agenda = get_object_or_404(Agenda, slug=agenda_identifier)
has_bookings = Booking.objects.filter(
event__agenda=agenda, event__start_datetime__gt=now(), cancellation_datetime__isnull=True
).exists()
if has_bookings:
raise APIError(_('This cannot be removed as there are bookings for a future date.'))
agenda.delete()
return Response({'err': 0})
agenda = AgendaAPI.as_view()
class Datetimes(APIView):
permission_classes = ()
serializer_class = serializers.DatetimesSerializer
def get(self, request, agenda_identifier=None, format=None):
agenda_qs = Agenda.objects.select_related('events_type')
try:
agenda = agenda_qs.get(slug=agenda_identifier)
except Agenda.DoesNotExist:
try:
# legacy access by agenda id
agenda = agenda_qs.get(id=int(agenda_identifier))
except (ValueError, Agenda.DoesNotExist):
raise Http404()
if agenda.kind != 'events':
raise Http404('agenda found, but it was not an events agenda')
serializer = self.serializer_class(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id')
disable_booked = bool(payload.get('exclude_user_external_id'))
bookable_events_raw = payload.get('events')
bookable_events = bookable_events_raw or 'future'
book_past = bookable_events in ['all', 'past']
book_future = bookable_events in ['all', 'future']
entries = Event.objects.none()
if book_past:
entries |= agenda.get_past_events(
min_start=payload.get('date_start'),
max_start=payload.get('date_end'),
)
if book_future:
entries |= agenda.get_open_events(
min_start=payload.get('date_start'),
max_start=payload.get('date_end'),
bypass_delays=payload.get('bypass_delays'),
)
entries = Event.annotate_queryset_for_user(entries, user_external_id)
if payload['hide_disabled']:
entries = [
e
for e in entries
if not is_event_disabled(
e,
payload['min_places'],
disable_booked=disable_booked,
bookable_events=bookable_events,
bypass_delays=payload.get('bypass_delays'),
)
]
response = {
'data': [
get_event_detail(
request,
x,
agenda=agenda,
min_places=payload['min_places'],
booked_user_external_id=payload.get('user_external_id'),
bookable_events=bookable_events_raw,
disable_booked=disable_booked,
bypass_delays=payload.get('bypass_delays'),
)
for x in entries
],
'meta': get_events_meta_detail(
request,
entries,
agenda=agenda,
min_places=payload['min_places'],
bookable_events=bookable_events_raw,
),
}
return Response(response)
datetimes = Datetimes.as_view()
class MultipleAgendasDatetimes(APIView):
permission_classes = ()
serializer_class = serializers.MultipleAgendasDatetimesSerializer
def get(self, request):
serializer = self.serializer_class(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
agendas = payload['agendas']
user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id')
disable_booked = bool(payload.get('exclude_user_external_id'))
guardian_external_id = payload.get('guardian_external_id')
show_past_events = bool(payload.get('show_past_events'))
show_only_subscribed = bool('subscribed' in payload)
with_status = bool(payload.get('with_status'))
check_overlaps = bool(payload.get('check_overlaps'))
entries = Event.objects.none()
for agenda in agendas:
if show_past_events:
entries |= agenda.get_past_events(
min_start=payload.get('date_start'),
max_start=payload.get('date_end'),
)
entries |= agenda.get_open_events(
min_start=payload.get('date_start'),
max_start=payload.get('date_end'),
bypass_delays=payload.get('bypass_delays'),
show_out_of_minimal_delay=show_past_events,
)
entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status)
if check_overlaps:
entries = Event.annotate_queryset_with_overlaps(entries)
if show_only_subscribed:
entries = entries.filter(
agenda__subscriptions__user_external_id=user_external_id,
agenda__subscriptions__date_start__lte=F('start_datetime'),
agenda__subscriptions__date_end__gt=F('start_datetime'),
)
if guardian_external_id:
entries = Agenda.filter_for_guardian(
entries,
guardian_external_id,
user_external_id,
min_start=payload.get('date_start'),
max_start=payload.get('date_end'),
)
entries = list(entries)
if 'agendas' in request.query_params:
agenda_querystring_indexes = {
agenda_slug: i for i, agenda_slug in enumerate(payload['agenda_slugs'])
}
entries.sort(
key=lambda event: (
event.start_datetime,
agenda_querystring_indexes[event.agenda.slug],
event.slug,
)
)
elif 'subscribed' in request.query_params:
category_querystring_indexes = {category: i for i, category in enumerate(payload['subscribed'])}
sort_by_category = bool(payload['subscribed'] != ['all'])
entries.sort(
key=lambda event: (
event.start_datetime,
category_querystring_indexes[event.agenda.category.slug] if sort_by_category else None,
event.agenda.slug,
event.slug,
)
)
response = {
'data': [
get_event_detail(
request,
x,
min_places=payload['min_places'],
booked_user_external_id=payload.get('user_external_id'),
multiple_agendas=True,
disable_booked=disable_booked,
bypass_delays=payload.get('bypass_delays'),
with_status=with_status,
)
for x in entries
],
'meta': get_events_meta_detail(
request, entries, min_places=payload['min_places'], multiple_agendas=True
),
}
return Response(response)
agendas_datetimes = MultipleAgendasDatetimes.as_view()
class MeetingDatetimes(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, meeting_identifier=None, format=None):
try:
if agenda_identifier is None:
# legacy access by meeting id
meeting_type = MeetingType.objects.get(id=meeting_identifier, deleted=False)
agenda = meeting_type.agenda
else:
agenda = Agenda.objects.get(slug=agenda_identifier)
meeting_type = agenda.get_meetingtype(slug=meeting_identifier)
except (ValueError, MeetingType.DoesNotExist, Agenda.DoesNotExist):
raise Http404()
now_datetime = now()
resources = get_resources_from_request(request, agenda)
start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
booked_user_external_id = request.GET.get('user_external_id') or None
excluded_user_external_id = request.GET.get('exclude_user_external_id') or None
if (
booked_user_external_id
and excluded_user_external_id
and booked_user_external_id != excluded_user_external_id
):
raise APIErrorBadRequest(
N_('user_external_id and exclude_user_external_id have different values')
)
# Generate an unique slot for each possible meeting [start_datetime,
# end_datetime] range.
# First use get_all_slots() to get each possible meeting by desk and
# its current status (full = booked, or not).
# Then order them by (start, end, full) where full is False for
# bookable slot, so bookable slot come first.
# Traverse them and remove duplicates, if a slot is bookable we will
# only see it (since it comes first), so it also remove "full/booked"
# slot from the list if there is still a bookable slot on a desk at the
# same time.
# The generator also remove slots starting before the current time.
def unique_slots():
last_slot = None
all_slots = list(
get_all_slots(
agenda,
meeting_type,
resources=resources,
unique=True,
start_datetime=start_datetime,
end_datetime=end_datetime,
user_external_id=booked_user_external_id or excluded_user_external_id,
)
)
for slot in sorted(all_slots, key=lambda slot: slot[:3]):
if slot.start_datetime < now_datetime:
continue
if last_slot and last_slot[:2] == slot[:2]:
continue
last_slot = slot
yield slot
generator_of_unique_slots = unique_slots()
# create fillslot API URL as a template, to avoid expensive calls
# to request.build_absolute_uri()
fake_event_identifier = '__event_identifier__'
fillslot_url = request.build_absolute_uri(
reverse(
'api-fillslot',
kwargs={'agenda_identifier': agenda.slug, 'event_identifier': fake_event_identifier},
)
)
if resources:
fillslot_url += '?resources=%s' % ','.join(r.slug for r in resources)
bookable_datetimes_number_total = 0
bookable_datetimes_number_available = 0
first_bookable_slot = None
data = []
for slot in generator_of_unique_slots:
if request.GET.get('hide_disabled') and slot.full:
continue
# Make virtual id for a slot, combining meeting_type.id and
# iso-format of date and time.
# (SharedTimePeriod.get_time_slots() generate datetime in fixed local timezone,
# in order to make slot_id stable.)
slot_id = '%s:%s' % (meeting_type.slug, slot.start_datetime.strftime('%Y-%m-%d-%H%M'))
slot_data = {
'id': slot_id,
'date': format_response_date(slot.start_datetime),
'datetime': format_response_datetime(slot.start_datetime),
'text': date_format(slot.start_datetime, format='DATETIME_FORMAT'),
'disabled': bool(slot.full),
'api': {'fillslot_url': fillslot_url.replace(fake_event_identifier, slot_id)},
}
if booked_user_external_id and slot.booked_for_external_user:
slot_data['booked_for_external_user'] = True
data.append(slot_data)
bookable_datetimes_number_total += 1
if not bool(slot.full):
bookable_datetimes_number_available += 1
if not first_bookable_slot:
first_bookable_slot = slot_data
response = {
'data': data,
'meta': {
'no_bookable_datetimes': bool(bookable_datetimes_number_available == 0),
'bookable_datetimes_number_total': bookable_datetimes_number_total,
'bookable_datetimes_number_available': bookable_datetimes_number_available,
'first_bookable_slot': first_bookable_slot,
},
}
return Response(response)
meeting_datetimes = MeetingDatetimes.as_view()
class RecurringEventsList(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, format=None):
serializer = serializers.RecurringEventsListSerializer(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
data = serializer.validated_data
check_overlaps = bool(data.get('check_overlaps'))
guardian_external_id = data.get('guardian_external_id')
if guardian_external_id:
agendas = Agenda.prefetch_events(
data['agendas'],
user_external_id=data.get('user_external_id'),
guardian_external_id=guardian_external_id,
annotate_for_user=False,
)
days_by_event = collections.defaultdict(set)
for agenda in agendas:
for event in agenda.prefetched_events:
if event.primary_event_id:
days_by_event[event.primary_event_id].add(event.start_datetime.weekday())
recurring_events = Event.objects.filter(pk__in=days_by_event).select_related(
'agenda', 'agenda__category'
)
if check_overlaps:
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events)
events = []
for event in recurring_events:
for day in days_by_event[event.pk]:
event = copy.copy(event)
event.day = day
events.append(event)
else:
agendas = Agenda.prefetch_recurring_events(data['agendas'], with_overlaps=check_overlaps)
events = []
for agenda in agendas:
for event in agenda.get_open_recurring_events():
for day in event.recurrence_days:
event = copy.copy(event)
event.day = day
events.append(event)
if check_overlaps:
for event in events:
event.overlaps = [
'%s:%s' % (x['slug'], day)
for x in event.overlaps
for day in x['days']
if day == event.day
]
if 'agendas' in request.query_params:
agenda_querystring_indexes = {
agenda_slug: i for i, agenda_slug in enumerate(data['agenda_slugs'])
}
events.sort(
key=lambda event: (
event.day if data.get('sort') == 'day' else event.start_datetime,
event.start_datetime.time(),
agenda_querystring_indexes[event.agenda.slug],
event.slug,
)
)
elif 'subscribed' in request.query_params:
category_querystring_indexes = {category: i for i, category in enumerate(data['subscribed'])}
sort_by_category = bool(data['subscribed'] != ['all'])
events.sort(
key=lambda event: (
event.day if data.get('sort') == 'day' else event.start_datetime,
event.start_datetime.time(),
category_querystring_indexes[event.agenda.category.slug] if sort_by_category else None,
event.agenda.slug,
event.slug,
)
)
return Response(
{
'data': [
{
'id': '%s@%s:%s' % (event.agenda.slug, event.slug, event.day),
'text': get_event_text(event, event.agenda, event.day),
'slug': event.slug,
'label': event.label or '',
'day': WEEKDAYS[event.day].capitalize(),
'date': format_response_date(event.start_datetime),
'datetime': format_response_datetime(event.start_datetime),
'description': event.description,
'pricing': event.pricing,
'url': event.url,
'overlaps': event.overlaps if check_overlaps else None,
}
for event in events
]
}
)
recurring_events_list = RecurringEventsList.as_view()
class MeetingList(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, format=None):
try:
agenda = Agenda.objects.get(slug=agenda_identifier)
except Agenda.DoesNotExist:
raise Http404()
if not agenda.accept_meetings():
raise Http404('agenda found, but it does not accept meetings')
meeting_types = []
exclude = request.GET.get('exclude') or ''
exclude = [x.strip() for x in exclude.split(',')]
for meeting_type in agenda.iter_meetingtypes():
if meeting_type.slug in exclude:
continue
meeting_types.append(
{
'text': meeting_type.label,
'id': meeting_type.slug,
'duration': meeting_type.duration,
'api': {
'datetimes_url': request.build_absolute_uri(
reverse(
'api-agenda-meeting-datetimes',
kwargs={
'agenda_identifier': agenda.slug,
'meeting_identifier': meeting_type.slug,
},
)
),
},
}
)
return Response({'data': meeting_types})
meeting_list = MeetingList.as_view()
class MeetingInfo(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, meeting_identifier=None, format=None):
try:
agenda = Agenda.objects.get(slug=agenda_identifier)
except Agenda.DoesNotExist:
raise Http404()
if not agenda.accept_meetings():
raise Http404('agenda found, but it does not accept meetings')
try:
meeting_type = agenda.get_meetingtype(slug=meeting_identifier)
except MeetingType.DoesNotExist:
raise Http404()
datetimes_url = request.build_absolute_uri(
reverse(
'api-agenda-meeting-datetimes',
kwargs={'agenda_identifier': agenda.slug, 'meeting_identifier': meeting_type.slug},
)
)
return Response(
{
'data': {
'text': meeting_type.label,
'id': meeting_type.slug,
'duration': meeting_type.duration,
'api': {'datetimes_url': datetimes_url},
}
}
)
meeting_info = MeetingInfo.as_view()
class AgendaDeskList(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, format=None):
try:
agenda = Agenda.objects.get(slug=agenda_identifier)
except Agenda.DoesNotExist:
raise Http404()
if agenda.kind != 'meetings':
raise Http404('agenda found, but it was not a meetings agenda')
desks = [{'id': x.slug, 'text': x.label} for x in agenda.desk_set.all()]
return Response({'data': desks})
agenda_desk_list = AgendaDeskList.as_view()
class AgendaResourceList(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, format=None):
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='meetings')
resources = [
{'id': x.slug, 'text': x.label, 'description': x.description} for x in agenda.resources.all()
]
return Response({'data': resources})
agenda_resource_list = AgendaResourceList.as_view()
class Fillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.FillSlotsSerializer
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
return self.fillslot(request=request, agenda_identifier=agenda_identifier, format=format)
def fillslot(self, request, agenda_identifier=None, slots=None, format=None, retry=False):
slots = slots or []
multiple_booking = bool(not slots)
try:
agenda = Agenda.objects.get(slug=agenda_identifier)
except Agenda.DoesNotExist:
try:
# legacy access by agenda id
agenda = Agenda.objects.get(id=int(agenda_identifier))
except (ValueError, Agenda.DoesNotExist):
raise Http404()
known_body_params = set(request.query_params).intersection(
{'label', 'user_name', 'backoffice_url', 'user_display_label'}
)
if known_body_params:
params = ', '.join(sorted(list(known_body_params)))
raise APIErrorBadRequest(
N_('parameters "%s" must be included in request body, not query'), params
)
serializer = self.serializer_class(data=request.data, partial=True)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
if 'slots' in payload:
slots = payload['slots']
if 'count' in payload:
places_count = payload['count']
elif 'count' in request.query_params:
# legacy: count in the query string
try:
places_count = int(request.query_params['count'])
except ValueError:
raise APIErrorBadRequest(N_('invalid value for count (%s)'), request.query_params['count'])
else:
places_count = 1
if places_count <= 0:
raise APIErrorBadRequest(N_('count cannot be less than or equal to zero'))
to_cancel_booking = None
cancel_booking_id = None
if payload.get('cancel_booking_id'):
try:
cancel_booking_id = int(payload.get('cancel_booking_id'))
except (ValueError, TypeError):
raise APIErrorBadRequest(N_('cancel_booking_id is not an integer'))
if cancel_booking_id is not None:
cancel_error = None
try:
to_cancel_booking = Booking.objects.get(pk=cancel_booking_id)
if to_cancel_booking.cancellation_datetime:
cancel_error = N_('cancel booking: booking already cancelled')
else:
to_cancel_places_count = (
to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count()
+ 1
)
if places_count != to_cancel_places_count:
cancel_error = N_('cancel booking: count is different')
except Booking.DoesNotExist:
cancel_error = N_('cancel booking: booking does no exist')
if cancel_error:
raise APIError(N_(cancel_error))
extra_data = {}
for k, v in request.data.items():
if k not in serializer.validated_data:
extra_data[k] = v
available_desk = None
color = None
user_external_id = payload.get('user_external_id') or None
exclude_user = payload.get('exclude_user')
if agenda.accept_meetings():
# slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
# split them back to get both parts
meeting_type_id = slots[0].split(':')[0]
datetimes = set()
for slot in slots:
try:
meeting_type_id_, datetime_str = slot.split(':')
except ValueError:
raise APIErrorBadRequest(N_('invalid slot: %s'), slot)
if meeting_type_id_ != meeting_type_id:
raise APIErrorBadRequest(
N_('all slots must have the same meeting type id (%s)'), meeting_type_id
)
try:
datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
except ValueError:
raise APIErrorBadRequest(N_('bad datetime format: %s'), datetime_str)
resources = get_resources_from_request(request, agenda)
# get all free slots and separate them by desk
try:
try:
meeting_type = agenda.get_meetingtype(slug=meeting_type_id)
except MeetingType.DoesNotExist:
# legacy access by id
meeting_type = agenda.get_meetingtype(id_=meeting_type_id)
except (MeetingType.DoesNotExist, ValueError):
raise APIErrorBadRequest(N_('invalid meeting type id: %s'), meeting_type_id)
all_slots = sorted(
get_all_slots(
agenda,
meeting_type,
resources=resources,
user_external_id=user_external_id if exclude_user else None,
start_datetime=min(datetimes),
end_datetime=max(datetimes) + datetime.timedelta(minutes=meeting_type.duration),
),
key=lambda slot: slot.start_datetime,
)
all_free_slots = [slot for slot in all_slots if not slot.full]
datetimes_by_desk = collections.defaultdict(set)
for slot in all_free_slots:
datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
color_label = payload.get('use_color_for')
if color_label:
color = BookingColor.objects.get_or_create(label=color_label)[0]
available_desk = None
if agenda.kind == 'virtual':
# Compute fill_rate by agenda/date
fill_rates = collections.defaultdict(dict)
for slot in all_slots:
ref_date = slot.start_datetime.date()
if ref_date not in fill_rates[slot.desk.agenda]:
date_dict = fill_rates[slot.desk.agenda][ref_date] = {'free': 0, 'full': 0}
else:
date_dict = fill_rates[slot.desk.agenda][ref_date]
if slot.full:
date_dict['full'] += 1
else:
date_dict['free'] += 1
for dd in fill_rates.values():
for date_dict in dd.values():
date_dict['fill_rate'] = date_dict['full'] / (date_dict['full'] + date_dict['free'])
# select a desk on the agenda with min fill_rate on the given date
for available_desk_id in sorted(datetimes_by_desk.keys()):
if datetimes.issubset(datetimes_by_desk[available_desk_id]):
desk = Desk.objects.get(id=available_desk_id)
if available_desk is None:
available_desk = desk
available_desk_rate = 0
for dt in datetimes:
available_desk_rate += fill_rates[available_desk.agenda][dt.date()][
'fill_rate'
]
else:
for dt in datetimes:
desk_rate = 0
for dt in datetimes:
desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
if desk_rate < available_desk_rate:
available_desk = desk
available_desk_rate = desk_rate
else:
# meeting agenda
# search first desk where all requested slots are free
for available_desk_id in sorted(datetimes_by_desk.keys()):
if datetimes.issubset(datetimes_by_desk[available_desk_id]):
available_desk = Desk.objects.get(id=available_desk_id)
break
if available_desk is None:
raise APIError(N_('no more desk available'))
# all datetimes are free, book them in order
datetimes = list(datetimes)
datetimes.sort()
# get a real meeting_type for virtual agenda
if agenda.kind == 'virtual':
meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
# booking requires real Event objects (not lazy Timeslots);
# create them now, with data from the slots and the desk we found.
events = []
for start_datetime in datetimes:
events.append(
Event(
agenda=available_desk.agenda,
slug=str(uuid.uuid4()), # set slug to avoid queries during slug generation
meeting_type=meeting_type,
start_datetime=start_datetime,
full=False,
places=1,
desk=available_desk,
)
)
in_waiting_list = False
else:
events = get_events_from_slots(slots, request, agenda, payload)
# search free places. Switch to waiting list if necessary.
in_waiting_list = False
for event in events:
if event.start_datetime > now():
if payload.get('force_waiting_list') and not event.waiting_list_places:
raise APIError(N_('no waiting list'))
if event.waiting_list_places:
if (
payload.get('force_waiting_list')
or (event.booked_places + places_count) > event.places
or event.booked_waiting_list_places
):
# if this is full or there are people waiting, put new bookings
# in the waiting list.
in_waiting_list = True
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
raise APIError(N_('sold out'))
else:
if (event.booked_places + places_count) > event.places:
raise APIError(N_('sold out'))
try:
with transaction.atomic():
if to_cancel_booking:
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
# now we have a list of events, book them.
primary_booking = None
for event in events:
if agenda.accept_meetings():
event.save()
if resources:
event.resources.add(*resources)
for dummy in range(places_count):
new_booking = make_booking(
event, payload, extra_data, primary_booking, in_waiting_list, color
)
new_booking.save()
if primary_booking is None:
primary_booking = new_booking
except IntegrityError as e:
if 'tstzrange_constraint' in str(e):
# "optimistic concurrency control", between our availability
# check with get_all_slots() and now, new event can have been
# created and conflict with the events we want to create, and
# so we get an IntegrityError exception. In this case we
# restart the fillslot() from the begginning to redo the
# availability check and return a proper error to the client.
#
# To prevent looping, we raise an APIError during the second run
# of fillslot().
if retry:
raise APIError(N_('no more desk available'))
return self.fillslot(request, agenda_identifier=agenda_identifier, slots=slots, retry=True)
raise
response = {
'err': 0,
'in_waiting_list': in_waiting_list,
'booking_id': primary_booking.id,
'datetime': format_response_datetime(events[0].start_datetime),
'agenda': {
'label': primary_booking.event.agenda.label,
'slug': primary_booking.event.agenda.slug,
},
'api': {
'booking_url': request.build_absolute_uri(
reverse('api-booking', kwargs={'booking_pk': primary_booking.id})
),
'cancel_url': request.build_absolute_uri(
reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})
),
'ics_url': request.build_absolute_uri(
reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})
),
'anonymize_url': request.build_absolute_uri(
reverse('api-anonymize-booking', kwargs={'booking_pk': primary_booking.id})
),
},
}
if agenda.kind == 'events':
response['api']['accept_url'] = request.build_absolute_uri(
reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.pk})
)
response['api']['suspend_url'] = request.build_absolute_uri(
reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
)
if agenda.accept_meetings():
response['end_datetime'] = format_response_datetime(events[-1].end_datetime)
response['duration'] = (events[-1].end_datetime - events[-1].start_datetime).seconds // 60
if available_desk:
response['desk'] = {'label': available_desk.label, 'slug': available_desk.slug}
if to_cancel_booking:
response['cancelled_booking_id'] = cancelled_booking_id
if agenda.kind == 'events' and not multiple_booking:
event = events[0]
# event.full is not up to date, it might have been changed by previous new_booking.save().
event.refresh_from_db()
response['places'] = get_event_places(event)
if event.end_datetime:
response['end_datetime'] = format_response_datetime(event.end_datetime)
else:
response['end_datetime'] = None
if agenda.kind == 'events' and multiple_booking:
response['events'] = [
{
'slug': x.slug,
'text': str(x),
'datetime': format_response_datetime(x.start_datetime),
'end_datetime': format_response_datetime(x.end_datetime) if x.end_datetime else None,
'description': x.description,
}
for x in events
]
if agenda.kind == 'meetings':
response['resources'] = [r.slug for r in resources]
return Response(response)
fillslots = Fillslots.as_view()
class Fillslot(Fillslots):
serializer_class = serializers.FillSlotSerializer
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
return self.fillslot(
request=request,
agenda_identifier=agenda_identifier,
slots=[event_identifier], # fill a "list on one slot"
format=format,
)
fillslot = Fillslot.as_view()
class RecurringFillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.RecurringFillslotsSerializer
def post(self, request):
serializer = serializers.RecurringFillslotsQueryStringSerializer(
data=request.query_params, context={'user_external_id': request.data.get('user_external_id')}
)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
data = serializer.validated_data
guardian_external_id = data.get('guardian_external_id')
start_datetime, end_datetime = data.get('date_start'), data.get('date_end')
if not start_datetime or start_datetime < now():
start_datetime = now()
context = {
'allowed_agenda_slugs': data['agenda_slugs'],
'agendas': Agenda.prefetch_recurring_events(data['agendas']),
}
serializer = self.serializer_class(data=request.data, partial=True, context=context)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
user_external_id = payload['user_external_id']
agendas = Agenda.prefetch_events(data['agendas'], user_external_id=user_external_id)
agendas_by_id = {x.id: x for x in agendas}
if data['action'] == 'update':
events_to_book = self.get_event_recurrences(
agendas,
payload['slots'],
start_datetime,
end_datetime,
user_external_id,
guardian_external_id,
)
events_to_unbook = self.get_events_to_unbook(agendas, events_to_book)
elif data['action'] == 'book':
events_to_book = self.get_event_recurrences(
agendas,
payload['slots'],
start_datetime,
end_datetime,
user_external_id,
guardian_external_id,
)
events_to_unbook = []
elif data['action'] == 'unbook':
events_to_book = Event.objects.none()
events_to_unbook = self.get_event_recurrences(
agendas,
payload['slots'],
start_datetime,
end_datetime,
user_external_id,
guardian_external_id,
).values_list('pk', flat=True)
if payload.get('check_overlaps'):
self.check_for_overlaps(events_to_book, serializer.initial_slots)
# outdated bookings to remove (cancelled bookings to replace by an active booking)
events_cancelled_to_delete = events_to_book.filter(
booking__user_external_id=user_external_id,
booking__cancellation_datetime__isnull=False,
full=False,
)
# book only events without active booking for the user
events_to_book = events_to_book.exclude(
pk__in=Booking.objects.filter(
event__in=events_to_book,
user_external_id=user_external_id,
cancellation_datetime__isnull=True,
).values('event')
)
# exclude full events
full_events = list(events_to_book.filter(full=True))
# don't reload agendas and events types
for event in full_events:
event.agenda = agendas_by_id[event.agenda_id]
events_to_book = events_to_book.filter(full=False)
events_to_book = events_to_book.annotate(
in_waiting_list=ExpressionWrapper(
Q(booked_places__gte=F('places')) | Q(booked_waiting_list_places__gt=0),
output_field=BooleanField(),
)
)
extra_data = {k: v for k, v in request.data.items() if k not in payload}
# don't reload agendas and events types
for event in events_to_book:
event.agenda = agendas_by_id[event.agenda_id]
bookings = [make_booking(event, payload, extra_data) for event in events_to_book]
bookings_to_cancel = Booking.objects.filter(
user_external_id=user_external_id, event__in=events_to_unbook, cancellation_datetime__isnull=True
)
with transaction.atomic():
# cancel existing bookings
cancellation_datetime = now()
Booking.objects.filter(primary_booking__in=bookings_to_cancel).update(
cancellation_datetime=cancellation_datetime
)
cancelled_count = bookings_to_cancel.update(cancellation_datetime=cancellation_datetime)
# and delete outdated cancelled bookings
Booking.objects.filter(
user_external_id=user_external_id, event__in=events_cancelled_to_delete
).delete()
# create missing bookings
created_bookings = Booking.objects.bulk_create(bookings)
response = {
'err': 0,
'booking_count': len(bookings),
'cancelled_booking_count': cancelled_count,
'full_events': [get_event_detail(request, x, multiple_agendas=True) for x in full_events],
}
if payload.get('include_booked_events_detail'):
events_to_book_by_id = {x.id: x for x in events_to_book}
response['booked_events'] = [
get_event_detail(request, events_to_book_by_id[x.event_id], booking=x, multiple_agendas=True)
for x in created_bookings
]
return Response(response)
def get_event_recurrences(
self, agendas, slots, start_datetime, end_datetime, user_external_id, guardian_external_id
):
event_filter = Q()
agendas_by_slug = {a.slug: a for a in agendas}
for agenda_slug, days_by_event in slots.items():
agenda = agendas_by_slug[agenda_slug]
for event_slug, days in days_by_event.items():
lookups = {
'agenda__slug': agenda_slug,
'primary_event__slug': event_slug,
'start_datetime__week_day__in': days,
}
if agenda.minimal_booking_delay:
lookups.update({'start_datetime__gte': agenda.min_booking_datetime})
if agenda.maximal_booking_delay:
lookups.update({'start_datetime__lte': agenda.max_booking_datetime})
if 'subscribed' in self.request.query_params:
lookups.update(
{
'agenda__subscriptions__user_external_id': user_external_id,
'agenda__subscriptions__date_start__lte': F('start_datetime'),
'agenda__subscriptions__date_end__gt': F('start_datetime'),
}
)
event_filter |= Q(**lookups)
events = Event.objects.filter(event_filter) if event_filter else Event.objects.none()
events = events.filter(start_datetime__gte=start_datetime, cancelled=False)
if end_datetime:
events = events.filter(start_datetime__lte=end_datetime)
if guardian_external_id:
events = Agenda.filter_for_guardian(
events,
guardian_external_id,
user_external_id,
min_start=start_datetime,
max_start=end_datetime,
)
return events
def get_events_to_unbook(self, agendas, events_to_book):
events_to_book_ids = set(events_to_book.values_list('pk', flat=True))
events_to_unbook = [
e.pk
for agenda in agendas
for e in agenda.prefetched_events
if (e.user_places_count or e.user_waiting_places_count)
and e.primary_event_id
and e.pk not in events_to_book_ids
and (not agenda.minimal_booking_delay or e.start_datetime >= agenda.min_booking_datetime)
and (not agenda.maximal_booking_delay or e.start_datetime <= agenda.max_booking_datetime)
]
return events_to_unbook
@staticmethod
def check_for_overlaps(events, slots):
def get_slug(event, day):
slug = event['slug'] if isinstance(event, dict) else '%s@%s' % (event.agenda.slug, event.slug)
return '%s:%s' % (slug, day)
recurring_events = Event.objects.filter(pk__in=events.values('primary_event_id'))
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events)
overlaps = set()
for event in recurring_events.select_related('agenda'):
overlaps.update(
tuple(sorted((get_slug(event, d), get_slug(x, d))))
for x in event.overlaps
for d in x['days']
if get_slug(x, d) in slots and get_slug(event, d) in slots
)
if overlaps:
raise APIError(
N_('Some events occur at the same time: %s')
% ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps))
)
recurring_fillslots = RecurringFillslots.as_view()
class EventsFillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.EventsFillSlotsSerializer
serializer_extra_context = None
multiple_agendas = False
def post(self, request, agenda_identifier):
self.agenda = get_object_or_404(
Agenda.objects.select_related('events_type'), slug=agenda_identifier, kind='events'
)
return self.fillslots(request)
def fillslots(self, request):
start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
serializer = self.serializer_class(
data=request.data, partial=True, context=self.serializer_extra_context
)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
user_external_id = payload['user_external_id']
bypass_delays = payload.get('bypass_delays')
check_overlaps = payload.get('check_overlaps')
events = self.get_events(request, payload, start_datetime, end_datetime)
if check_overlaps:
overlapping_events = Event.annotate_queryset_with_overlaps(events).filter(has_overlap=True)
if overlapping_events:
raise APIError(
N_('Some events occur at the same time: %s'),
', '.join(sorted(str(x) for x in overlapping_events)),
)
already_booked_events = self.get_already_booked_events(user_external_id)
already_booked_events = already_booked_events.filter(start_datetime__gt=now())
if start_datetime:
already_booked_events = already_booked_events.filter(start_datetime__gte=start_datetime)
if end_datetime:
already_booked_events = already_booked_events.filter(start_datetime__lt=end_datetime)
agendas_by_ids = self.get_agendas_by_ids()
events_to_unbook = []
events_to_unbook_out_of_min_delay = []
events_in_request_ids = [e.pk for e in events]
for event in already_booked_events:
if event.pk in events_in_request_ids:
continue
agenda = agendas_by_ids[event.agenda_id]
out_of_min_delay = False
if agenda.min_booking_datetime and event.start_datetime < agenda.min_booking_datetime:
if not bypass_delays:
continue
out_of_min_delay = True
if agenda.max_booking_datetime and event.start_datetime > agenda.max_booking_datetime:
continue
if out_of_min_delay:
events_to_unbook_out_of_min_delay.append(event)
else:
events_to_unbook.append(event)
# outdated bookings to remove (cancelled bookings to replace by an active booking)
events_cancelled_to_delete = events.filter(
booking__user_external_id=user_external_id,
booking__cancellation_datetime__isnull=False,
)
# book only events without active booking for the user
events = events.exclude(
pk__in=Booking.objects.filter(
event__in=events, user_external_id=user_external_id, cancellation_datetime__isnull=True
).values('event')
)
full_events = [str(event) for event in events.filter(full=True)]
if full_events:
raise APIError(
N_('some events are full: %s'), ', '.join(full_events), err_class='some events are full'
)
events = events.annotate(
in_waiting_list=ExpressionWrapper(
Q(booked_places__gte=F('places')) | Q(booked_waiting_list_places__gt=0),
output_field=BooleanField(),
)
)
waiting_list_event_ids = [event.pk for event in events if event.in_waiting_list]
extra_data = {k: v for k, v in request.data.items() if k not in payload}
bookings = [make_booking(event, payload, extra_data) for event in events]
bookings_to_cancel_out_of_min_delay = Booking.objects.filter(
user_external_id=user_external_id,
event__in=events_to_unbook_out_of_min_delay,
cancellation_datetime__isnull=True,
)
bookings_to_cancel = Booking.objects.filter(
user_external_id=user_external_id, event__in=events_to_unbook, cancellation_datetime__isnull=True
)
with transaction.atomic():
# cancel existing bookings
cancellation_datetime = now()
Booking.objects.filter(primary_booking__in=bookings_to_cancel_out_of_min_delay).update(
cancellation_datetime=cancellation_datetime,
out_of_min_delay=True,
)
cancelled_count = bookings_to_cancel_out_of_min_delay.update(
cancellation_datetime=cancellation_datetime, out_of_min_delay=True
)
Booking.objects.filter(primary_booking__in=bookings_to_cancel).update(
cancellation_datetime=cancellation_datetime, out_of_min_delay=False
)
cancelled_count += bookings_to_cancel.update(
cancellation_datetime=cancellation_datetime, out_of_min_delay=False
)
# and delete outdated cancelled bookings
Booking.objects.filter(
user_external_id=user_external_id, event__in=events_cancelled_to_delete
).delete()
# create missing bookings
created_bookings = Booking.objects.bulk_create(bookings)
# don't reload agendas and events types
for event in events:
event.agenda = agendas_by_ids[event.agenda_id]
events_by_id = {x.id: x for x in events}
response = {
'err': 0,
'booking_count': len(bookings),
'booked_events': [
get_event_detail(
request, events_by_id[x.event_id], booking=x, multiple_agendas=self.multiple_agendas
)
for x in created_bookings
if x.event_id not in waiting_list_event_ids
],
'waiting_list_events': [
get_event_detail(
request, events_by_id[x.event_id], booking=x, multiple_agendas=self.multiple_agendas
)
for x in created_bookings
if x.event_id in waiting_list_event_ids
],
'cancelled_booking_count': cancelled_count,
}
return Response(response)
def get_events(self, request, payload, start_datetime, end_datetime):
return get_events_from_slots(payload['slots'], request, self.agenda, payload)
def get_already_booked_events(self, user_external_id):
return self.agenda.event_set.filter(
booking__user_external_id=user_external_id, booking__cancellation_datetime__isnull=True
)
def get_agendas_by_ids(self):
return {self.agenda.pk: self.agenda}
events_fillslots = EventsFillslots.as_view()
class MultipleAgendasEventsFillslots(EventsFillslots):
serializer_class = serializers.MultipleAgendasEventsFillSlotsSerializer
multiple_agendas = True
def post(self, request):
serializer = serializers.AgendaOrSubscribedSlugsSerializer(
data=request.query_params, context={'user_external_id': request.data.get('user_external_id')}
)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
data = serializer.validated_data
self.agendas = data['agendas']
self.agenda_slugs = data['agenda_slugs']
self.guardian_external_id = data.get('guardian_external_id')
return self.fillslots(request)
def get_events(self, request, payload, start_datetime, end_datetime):
events_by_agenda = collections.defaultdict(list)
for slot in payload['slots']:
agenda, event = slot.split('@')
events_by_agenda[agenda].append(event)
agendas_by_slug = get_objects_from_slugs(
events_by_agenda.keys(), qs=Agenda.objects.filter(kind='events')
).in_bulk(field_name='slug')
events = Event.objects.none()
for agenda_slug, event_slugs in events_by_agenda.items():
events |= get_events_from_slots(event_slugs, request, agendas_by_slug[agenda_slug], payload)
if 'subscribed' in request.query_params:
events_outside_subscriptions = events.difference(
events.filter(
agenda__subscriptions__user_external_id=payload['user_external_id'],
agenda__subscriptions__date_start__lte=F('start_datetime'),
agenda__subscriptions__date_end__gt=F('start_datetime'),
)
) # workaround exclude method bug https://code.djangoproject.com/ticket/29697
if events_outside_subscriptions.exists():
event_slugs = ', '.join(
'%s@%s' % (event.agenda.slug, event.slug) for event in events_outside_subscriptions
)
raise APIErrorBadRequest(N_('Some events are outside user subscriptions: %s'), event_slugs)
if self.guardian_external_id:
events_outside_custody = events.exclude(
pk__in=Agenda.filter_for_guardian(
events,
self.guardian_external_id,
payload['user_external_id'],
min_start=start_datetime,
max_start=end_datetime,
).values('pk')
)
if events_outside_custody.exists():
event_slugs = ', '.join(
'%s@%s' % (event.agenda.slug, event.slug) for event in events_outside_custody
)
raise APIErrorBadRequest(N_('Some events are outside guardian custody: %s'), event_slugs)
return events
def get_already_booked_events(self, user_external_id):
return Event.objects.filter(
agenda__in=self.agendas,
booking__user_external_id=user_external_id,
booking__cancellation_datetime__isnull=True,
)
def get_agendas_by_ids(self):
return {a.pk: a for a in self.agendas}
@property
def serializer_extra_context(self):
return {'allowed_agenda_slugs': self.agenda_slugs}
agendas_events_fillslots = MultipleAgendasEventsFillslots.as_view()
class MultipleAgendasEvents(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.SlotsSerializer
def get(self, request):
serializer = self.serializer_class(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1)
slots = serializer.validated_data['slots']
events_by_agenda = collections.defaultdict(list)
for slot in slots:
agenda, event = slot.split('@')
events_by_agenda[agenda].append(event)
agendas = get_objects_from_slugs(events_by_agenda.keys(), qs=Agenda.objects.filter(kind='events'))
agendas_by_slug = {a.slug: a for a in agendas}
events = []
for agenda_slug, event_slugs in events_by_agenda.items():
events += get_objects_from_slugs(
event_slugs,
qs=agendas_by_slug[agenda_slug]
.event_set.filter(cancelled=False, recurrence_days__isnull=True)
.prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by()))
.order_by(),
prefix='%s@' % agenda_slug,
)
data = []
event_querystring_indexes = {event_slug: i for i, event_slug in enumerate(slots)}
events.sort(key=lambda event: (event_querystring_indexes['%s@%s' % (event.agenda.slug, event.slug)],))
for event in events:
data.append(serializers.EventSerializer(event).data)
return Response({'err': 0, 'data': data})
agendas_events = MultipleAgendasEvents.as_view()
class MultipleAgendasEventsCheckStatus(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.MultipleAgendasEventsCheckStatusSerializer
def get(self, request):
serializer = self.serializer_class(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1)
agendas = serializer.validated_data['agendas']
agendas_by_id = {a.pk: a for a in agendas}
user_external_id = serializer.validated_data['user_external_id']
date_start = serializer.validated_data['date_start']
date_end = serializer.validated_data['date_end']
events = Event.objects.filter(
agenda__in=agendas,
agenda__subscriptions__user_external_id=user_external_id,
agenda__subscriptions__date_start__lte=F('start_datetime'),
agenda__subscriptions__date_end__gt=F('start_datetime'),
recurrence_days__isnull=True,
cancelled=False,
start_datetime__gte=date_start,
start_datetime__lt=date_end,
).prefetch_related(Prefetch('primary_event', queryset=Event.objects.all().order_by()))
booking_queryset = Booking.objects.filter(
event__in=events,
user_external_id=user_external_id,
)
bookings_by_event_id = collections.defaultdict(list)
for booking in booking_queryset:
bookings_by_event_id[booking.event_id].append(booking)
data = []
for event in events:
event.agenda = agendas_by_id[event.agenda_id] # agenda is already fetched, reuse it
check_status = {}
booking = None
if not event.checked:
check_status = {'status': 'error', 'error_reason': 'event-not-checked'}
elif not bookings_by_event_id[event.pk]:
check_status = {'status': 'not-booked'}
elif len(bookings_by_event_id[event.pk]) > 1:
check_status = {'status': 'error', 'error_reason': 'too-many-bookings-found'}
else:
booking = bookings_by_event_id[event.pk][0]
booking.event = event # prevent db calls
if booking.cancellation_datetime is not None:
check_status = {'status': 'cancelled'}
elif booking.user_was_present is None:
check_status = {'status': 'error', 'error_reason': 'booking-not-checked'}
else:
check_status = {
'status': 'presence' if booking.user_was_present else 'absence',
'check_type': booking.user_check_type_slug,
}
data.append(
{
'event': serializers.EventSerializer(event).data,
'check_status': check_status,
'booking': serializers.BookingSerializer(booking).data if booking else {},
}
)
return Response({'err': 0, 'data': data})
agendas_events_check_status = MultipleAgendasEventsCheckStatus.as_view()
class SubscriptionFilter(filters.FilterSet):
date_start = filters.DateFilter(lookup_expr='gte')
date_end = filters.DateFilter(lookup_expr='lt')
class Meta:
model = Subscription
fields = [
'user_external_id',
'date_start',
'date_end',
]
class SubscriptionsAPI(ListAPIView):
filter_backends = (filters.DjangoFilterBackend,)
serializer_class = serializers.SubscriptionSerializer
filterset_class = SubscriptionFilter
permission_classes = (permissions.IsAuthenticated,)
def get_agenda(self, agenda_identifier):
return get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
def get(self, request, agenda_identifier):
self.agenda = self.get_agenda(agenda_identifier)
try:
subscriptions = self.filter_queryset(self.get_queryset())
except ValidationError as e:
raise APIErrorBadRequest(N_('invalid filters'), errors=e.detail)
serializer = self.serializer_class(subscriptions, many=True)
return Response({'err': 0, 'data': serializer.data})
def get_queryset(self):
return self.agenda.subscriptions.order_by('date_start', 'date_end', 'user_external_id', 'pk')
def post(self, request, agenda_identifier):
self.agenda = self.get_agenda(agenda_identifier)
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
extra_data = {k: v for k, v in request.data.items() if k not in serializer.validated_data}
date_start = serializer.validated_data['date_start']
date_end = serializer.validated_data['date_end']
overlapping_subscription_qs = Subscription.objects.filter(
agenda=self.agenda,
user_external_id=serializer.validated_data['user_external_id'],
).extra(
where=["(date_start, date_end) OVERLAPS (%s, %s)"],
params=[date_start, date_end],
)
if overlapping_subscription_qs.exists():
raise APIErrorBadRequest(N_('another subscription overlapping this period already exists'))
subscription = Subscription.objects.create(
agenda=self.agenda, extra_data=extra_data, **serializer.validated_data
)
return Response({'err': 0, 'id': subscription.pk})
subscriptions = SubscriptionsAPI.as_view()
class SubscriptionAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.SubscriptionSerializer
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
self.subscription = get_object_or_404(
Subscription,
pk=kwargs.get('subscription_pk'),
agenda__kind='events',
agenda__slug=kwargs.get('agenda_identifier'),
)
def get(self, request, *args, **kwargs):
serializer = self.serializer_class(self.subscription)
response = serializer.data
response.update({'err': 0})
return Response(response)
def delete_out_of_period_bookings(self, date_start, date_end):
booking_qs = Booking.objects.filter(
# remove user bookings for this agenda
event__agenda=self.subscription.agenda,
user_external_id=self.subscription.user_external_id,
# in the requested period
event__start_datetime__gte=date_start,
event__start_datetime__lt=date_end,
).filter(
# but only in the future
event__start_datetime__gt=now(),
)
booking_qs.delete()
def patch(self, request, *args, **kwargs):
serializer = self.serializer_class(self.subscription, data=request.data, partial=True)
old_date_start = self.subscription.date_start
old_date_end = self.subscription.date_end
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=4)
date_start = serializer.validated_data.get('date_start') or old_date_start
date_end = serializer.validated_data.get('date_end') or old_date_end
overlapping_subscription_qs = (
Subscription.objects.filter(
agenda=self.subscription.agenda,
user_external_id=self.subscription.user_external_id,
)
.exclude(pk=self.subscription.pk)
.extra(
where=["(date_start, date_end) OVERLAPS (%s, %s)"],
params=[date_start, date_end],
)
)
if (
old_date_start != date_start or old_date_end != date_end
) and overlapping_subscription_qs.exists():
raise APIErrorBadRequest(N_('another subscription overlapping this period already exists'))
if (
'user_external_id' in serializer.validated_data
and serializer.validated_data['user_external_id'] != self.subscription.user_external_id
):
raise APIErrorBadRequest(N_('it is not possible to change user_external_id value'))
serializer.save()
if old_date_start > self.subscription.date_end or old_date_end < self.subscription.date_start:
# new period does not overlaps the old one, delete all bookings in the old period
self.delete_out_of_period_bookings(old_date_start, old_date_end)
else:
if old_date_start < self.subscription.date_start:
# date start has been postponed, remove all bookings from old start to new start
self.delete_out_of_period_bookings(old_date_start, self.subscription.date_start)
if old_date_end > self.subscription.date_end:
# date end has been brought forward, remove all bookings from new end to old end
self.delete_out_of_period_bookings(self.subscription.date_end, old_date_end)
extra_data = {k: v for k, v in request.data.items() if k not in serializer.validated_data}
if extra_data:
self.subscription.extra_data = self.subscription.extra_data or {}
self.subscription.extra_data.update(extra_data)
self.subscription.save()
# update bookings inside the new period (other bookings were deleted)
Booking.objects.filter(
# remove user bookings for this agenda
event__agenda=self.subscription.agenda,
user_external_id=self.subscription.user_external_id,
# in the period of the subscription
event__start_datetime__gte=self.subscription.date_start,
event__start_datetime__lt=self.subscription.date_end,
).filter(
# but only in the future
event__start_datetime__gt=now(),
).update(
extra_data=RawSQL("COALESCE(extra_data, '{}'::jsonb) || %s::jsonb", (json.dumps(extra_data),))
)
return self.get(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
self.delete_out_of_period_bookings(self.subscription.date_start, self.subscription.date_end)
self.subscription.delete()
response = {'err': 0}
return Response(response)
subscription = SubscriptionAPI.as_view()
class BookingFilter(filters.FilterSet):
agenda = filters.CharFilter(field_name='event__agenda__slug', lookup_expr='exact')
event = filters.CharFilter(method='filter_event')
category = filters.CharFilter(field_name='event__agenda__category__slug', lookup_expr='exact')
date_start = filters.DateFilter(field_name='event__start_datetime', lookup_expr='gte')
date_end = filters.DateFilter(field_name='event__start_datetime', lookup_expr='lt')
user_absence_reason = filters.CharFilter(method='filter_user_absence_reason')
user_presence_reason = filters.CharFilter(method='filter_user_presence_reason')
def filter_event(self, queryset, name, value):
# we want to include bookings of event recurrences
return queryset.filter(Q(event__slug=value) | Q(event__primary_event__slug=value))
def filter_user_absence_reason(self, queryset, name, value):
return queryset.filter(
Q(user_check_type_slug=value) | Q(user_check_type_label=value),
user_was_present=False,
)
def filter_user_presence_reason(self, queryset, name, value):
return queryset.filter(
Q(user_check_type_slug=value) | Q(user_check_type_label=value),
user_was_present=True,
)
class Meta:
model = Booking
fields = [
'user_external_id',
'agenda',
'category',
'date_start',
'date_end',
'user_was_present',
'user_absence_reason',
'user_presence_reason',
'in_waiting_list',
]
class BookingsAPI(ListAPIView):
filter_backends = (filters.DjangoFilterBackend,)
serializer_class = serializers.BookingSerializer
filterset_class = BookingFilter
def get(self, request, *args, **kwargs):
if not request.GET.get('user_external_id'):
raise APIError(N_('missing param user_external_id'))
try:
bookings = self.filter_queryset(self.get_queryset())
except ValidationError as e:
raise APIErrorBadRequest(N_('invalid payload'), errors=e.detail)
data = []
for booking in bookings:
serialized_booking = self.serializer_class(booking).data
if booking.event.agenda.kind == 'events':
serialized_booking['event'] = get_event_detail(request, booking.event)
data.append(serialized_booking)
return Response({'err': 0, 'data': data})
def get_queryset(self):
return (
Booking.objects.filter(primary_booking__isnull=True, cancellation_datetime__isnull=True)
.select_related('event', 'event__agenda', 'event__desk')
.order_by('event__start_datetime', 'event__slug', 'event__agenda__slug', 'pk')
)
bookings = BookingsAPI.as_view()
class BookingAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.BookingSerializer
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
self.booking = get_object_or_404(Booking, pk=kwargs.get('booking_pk'))
def check_booking(self, check_waiting_list=False):
if self.booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
if self.booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
if check_waiting_list and self.booking.in_waiting_list:
raise APIError(N_('booking is in waiting list'), err=3)
def get(self, request, *args, **kwargs):
self.check_booking()
serializer = self.serializer_class(self.booking)
response = serializer.data
response.update(
{
'err': 0,
'booking_id': self.booking.pk,
}
)
return Response(response)
def patch(self, request, *args, **kwargs):
self.check_booking(check_waiting_list=True)
serializer = self.serializer_class(self.booking, data=request.data, partial=True)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=4)
if self.booking.event.agenda.kind != 'events' and (
'user_was_present' in request.data
or 'user_absence_reason' in request.data
or 'user_presence_reason' in request.data
):
raise APIErrorBadRequest(N_('can not set check fields for non events agenda'), err=7)
if (
self.booking.event.checked
and self.booking.event.agenda.disable_check_update
and (
'user_was_present' in request.data
or 'user_absence_reason' in request.data
or 'user_presence_reason' in request.data
)
):
raise APIErrorBadRequest(N_('event is marked as checked'), err=5)
user_was_present = serializer.validated_data.get('user_was_present', self.booking.user_was_present)
if user_was_present is True and 'user_absence_reason' in request.data:
raise APIErrorBadRequest(N_('user is marked as present, can not set absence reason'), err=6)
if user_was_present is False and 'user_presence_reason' in request.data:
raise APIErrorBadRequest(N_('user is marked as absent, can not set presence reason'), err=6)
serializer.save()
extra_data = {k: v for k, v in request.data.items() if k not in serializer.validated_data}
if extra_data:
self.booking.extra_data = self.booking.extra_data or {}
self.booking.extra_data.update(extra_data)
self.booking.save()
secondary_bookings_update = {}
for key in [
'user_was_present',
'user_first_name',
'user_last_name',
'user_email',
'user_phone_number',
]:
if key in request.data:
secondary_bookings_update[key] = getattr(self.booking, key)
if 'use_color_for' in request.data:
secondary_bookings_update['color'] = self.booking.color
if 'user_absence_reason' in request.data or 'user_presence_reason' in request.data:
secondary_bookings_update['user_check_type_slug'] = self.booking.user_check_type_slug
secondary_bookings_update['user_check_type_label'] = self.booking.user_check_type_label
if extra_data:
secondary_bookings_update['extra_data'] = self.booking.extra_data
if secondary_bookings_update:
self.booking.secondary_booking_set.update(**secondary_bookings_update)
if 'user_was_present' in request.data:
self.booking.event.set_is_checked()
response = {'err': 0, 'booking_id': self.booking.pk}
return Response(response)
def delete(self, request, *args, **kwargs):
self.check_booking()
self.booking.cancel()
response = {'err': 0, 'booking_id': self.booking.pk}
return Response(response)
booking = BookingAPI.as_view()
class CancelBooking(APIView):
"""
Cancel a booking.
It will return error codes if the booking was cancelled before (code 1) or
if the booking is not primary (code 2).
"""
permission_classes = (permissions.IsAuthenticated,)
def post(self, request, booking_pk=None, format=None):
booking = get_object_or_404(Booking, id=booking_pk)
if booking.cancellation_datetime:
raise APIError(N_('already cancelled'))
if booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
booking.cancel()
response = {'err': 0, 'booking_id': booking.id}
return Response(response)
cancel_booking = CancelBooking.as_view()
class AcceptBooking(APIView):
"""
Accept a booking currently in the waiting list.
It will return error codes if the booking was cancelled before (code 1),
if the booking is not primary (code 2) or
if the booking was not in waiting list (code 3).
"""
permission_classes = (permissions.IsAuthenticated,)
def post(self, request, booking_pk=None, format=None):
booking = get_object_or_404(Booking, id=booking_pk, event__agenda__kind='events')
if booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
if booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
if not booking.in_waiting_list:
raise APIError(N_('booking is not in waiting list'), err=3)
booking.accept()
event = booking.event
response = {
'err': 0,
'booking_id': booking.pk,
'overbooked_places': max(0, event.booked_places - event.places),
}
return Response(response)
accept_booking = AcceptBooking.as_view()
class SuspendBooking(APIView):
"""
Suspend a accepted booking.
It will return error codes if the booking was cancelled before (code 1)
if the booking is not primary (code 2) or
if the booking is already in waiting list (code 3).
"""
permission_classes = (permissions.IsAuthenticated,)
def post(self, request, booking_pk=None, format=None):
booking = get_object_or_404(Booking, pk=booking_pk, event__agenda__kind='events')
if booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
if booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
if booking.in_waiting_list:
raise APIError(N_('booking is already in waiting list'), err=3)
booking.suspend()
response = {'err': 0, 'booking_id': booking.pk}
return Response(response)
suspend_booking = SuspendBooking.as_view()
class AnonymizeBooking(APIView):
permission_classes = (permissions.IsAuthenticated,)
def post(self, request, booking_pk=None, format=None):
booking = get_object_or_404(Booking, pk=booking_pk)
bookings = Booking.objects.filter(Q(pk=booking.pk) | Q(primary_booking=booking.pk))
Booking.anonymize_bookings(bookings)
response = {'err': 0, 'booking_id': booking.pk}
return Response(response)
anonymize_booking = AnonymizeBooking.as_view()
class ResizeBooking(APIView):
"""
Resize a booking.
It will return error codes if the booking was cancelled before (code 1)
if the booking is not primary (code 2)
if the event is sold out (code 3) or
if the booking is on multi events (code 4).
"""
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.ResizeSerializer
def post(self, request, booking_pk=None, format=None):
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
booking = get_object_or_404(Booking, pk=booking_pk, event__agenda__kind='events')
event = booking.event
if booking.cancellation_datetime:
raise APIError(N_('booking is cancelled'))
if booking.primary_booking is not None:
raise APIError(N_('secondary booking'), err=2)
event_ids = {event.pk}
in_waiting_list = {booking.in_waiting_list}
secondary_bookings = booking.secondary_booking_set.all().order_by('-creation_datetime')
for secondary in secondary_bookings:
event_ids.add(secondary.event_id)
in_waiting_list.add(secondary.in_waiting_list)
if len(event_ids) > 1:
raise APIError(N_('can not resize multi event booking'), err=4)
if len(in_waiting_list) > 1:
raise APIError(N_('can not resize booking: waiting list inconsistency'), err=5)
# total places for the event (in waiting or main list, depending on the primary booking location)
places = event.waiting_list_places if booking.in_waiting_list else event.places
# total booked places for the event (in waiting or main list, depending on the primary booking location)
booked_places = event.booked_waiting_list_places if booking.in_waiting_list else event.booked_places
# places to book for this primary booking
primary_wanted_places = payload['count']
# already booked places for this primary booking
primary_booked_places = 1 + len(secondary_bookings)
if primary_booked_places > primary_wanted_places:
# it is always ok to decrease booking
return self.decrease(booking, secondary_bookings, primary_booked_places, primary_wanted_places)
if primary_booked_places == primary_wanted_places:
# it is always ok to do nothing
return self.success(booking)
# else, increase places if allowed
if booked_places - primary_booked_places + primary_wanted_places > places:
# oversized request
if booking.in_waiting_list:
# booking in waiting list: can not be overbooked
raise APIError(N_('sold out'), err=3)
if event.booked_places <= event.places:
# in main list and no overbooking for the moment: can not be overbooked
raise APIError(N_('sold out'), err=3)
return self.increase(booking, secondary_bookings, primary_booked_places, primary_wanted_places)
def increase(self, booking, secondary_bookings, primary_booked_places, primary_wanted_places):
with transaction.atomic():
bulk_bookings = []
for dummy in range(0, primary_wanted_places - primary_booked_places):
bulk_bookings.append(
booking.clone(
primary_booking=booking,
save=False,
)
)
Booking.objects.bulk_create(bulk_bookings)
return self.success(booking)
def decrease(self, booking, secondary_bookings, primary_booked_places, primary_wanted_places):
with transaction.atomic():
for secondary in secondary_bookings[: primary_booked_places - primary_wanted_places]:
secondary.delete()
return self.success(booking)
def success(self, booking):
response = {'err': 0, 'booking_id': booking.pk}
return Response(response)
resize_booking = ResizeBooking.as_view()
class EventsAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.EventSerializer
def post(self, request, agenda_identifier):
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
event = Event(agenda=agenda)
serializer = self.serializer_class(data=request.data, instance=event)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
event = serializer.save()
if event.recurrence_days:
event.create_all_recurrences()
return Response({'err': 0, 'data': get_event_detail(request, event)})
events = EventsAPI.as_view()
class EventAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.EventSerializer
def get_object(self, agenda_identifier, event_identifier):
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
try:
return agenda.event_set.get(slug=event_identifier)
except Event.DoesNotExist:
raise Http404()
def patch(self, request, agenda_identifier=None, event_identifier=None, format=None):
event = self.get_object(agenda_identifier, event_identifier)
serializer = self.serializer_class(event, data=request.data, partial=True)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
changed_data = []
for field in serializer.fields.keys():
if field in payload and payload[field] != getattr(event, field):
changed_data.append(field)
if event.primary_event:
for field in changed_data:
if field in (
'recurrence_end_date',
'publication_datetime',
'recurrence_days',
'recurrence_week_interval',
):
raise APIErrorBadRequest(N_('%s cannot be modified on an event recurrence'), field)
protected_fields = ['start_datetime', 'recurrence_days', 'recurrence_week_interval']
if event.recurrence_days and event.has_recurrences_booked():
for field in changed_data:
if field in protected_fields:
raise APIErrorBadRequest(
N_('%s cannot be modified because some recurrences have bookings attached to them.')
% field
)
if 'recurrence_end_date' in changed_data and event.has_recurrences_booked(
after=payload['recurrence_end_date']
):
raise APIErrorBadRequest(
N_('recurrence_end_date cannot be modified because bookings exist after this date.')
)
with event.update_recurrences(
changed_data, payload, protected_fields, protected_fields + ['recurrence_end_date']
):
event = serializer.save()
return Response({'err': 0, 'data': get_event_detail(request, event)})
def delete(self, request, agenda_identifier, event_identifier):
event = self.get_object(agenda_identifier, event_identifier)
cannot_delete = bool(
event.booking_set.filter(cancellation_datetime__isnull=True).exists()
and event.start_datetime > now()
or event.has_recurrences_booked()
)
if cannot_delete:
raise APIError(_('This cannot be removed as there are bookings for a future date.'))
event.delete()
return Response({'err': 0})
event = EventAPI.as_view()
class EventStatus(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get_object(self, agenda_identifier, event_identifier):
try:
agenda = Agenda.objects.get(slug=agenda_identifier, kind='events')
except Agenda.DoesNotExist:
try:
# legacy access by agenda id
agenda = Agenda.objects.get(pk=agenda_identifier, kind='events')
except (ValueError, Agenda.DoesNotExist):
raise Http404()
try:
return agenda.event_set.get(slug=event_identifier)
except Event.DoesNotExist:
try:
# legacy access by event id
return agenda.event_set.get(pk=event_identifier)
except (ValueError, Event.DoesNotExist):
raise Http404()
def get(self, request, agenda_identifier=None, event_identifier=None, format=None):
event = self.get_object(agenda_identifier, event_identifier)
response = {
'err': 0,
}
response.update(get_event_detail(request, event))
return Response(response)
event_status = EventStatus.as_view()
class EventCheck(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get_object(self, agenda_identifier, event_identifier):
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
try:
return agenda.event_set.get(slug=event_identifier)
except Event.DoesNotExist:
raise Http404()
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
event = self.get_object(agenda_identifier, event_identifier)
if not event.checked:
event.checked = True
event.save(update_fields=['checked'])
response = {
'err': 0,
}
return Response(response)
event_check = EventCheck.as_view()
class EventBookings(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get_object(self, agenda_identifier, event_identifier):
return get_object_or_404(
Event, slug=event_identifier, agenda__slug=agenda_identifier, agenda__kind='events'
)
def get(self, request, agenda_identifier=None, event_identifier=None, format=None):
if not request.GET.get('user_external_id'):
raise APIError(N_('missing param user_external_id'))
event = self.get_object(agenda_identifier, event_identifier)
booking_queryset = event.booking_set.filter(
user_external_id=request.GET['user_external_id'],
primary_booking__isnull=True,
cancellation_datetime__isnull=True,
).order_by('pk')
response = {
'err': 0,
'data': [{'booking_id': b.pk, 'in_waiting_list': b.in_waiting_list} for b in booking_queryset],
}
return Response(response)
event_bookings = EventBookings.as_view()
class BookingICS(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, booking_pk=None, format=None):
booking = get_object_or_404(Booking, id=booking_pk)
response = HttpResponse(booking.get_ics(request), content_type='text/calendar')
return response
booking_ics = BookingICS.as_view()
class SharedCustodyAgendas(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.SharedCustodyAgendaCreateSerializer
def post(self, request):
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
agenda = serializer.save()
response = {
'id': agenda.pk,
'settings_url': request.build_absolute_uri(agenda.get_settings_url()),
'backoffice_url': request.build_absolute_uri(agenda.get_absolute_url()),
}
return Response({'err': 0, 'data': response})
shared_custody_agendas = SharedCustodyAgendas.as_view()
class SharedCustodyAgendaAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.SharedCustodyAgendaSerializer
def patch(self, request, agenda_pk):
agenda = get_object_or_404(SharedCustodyAgenda, pk=agenda_pk)
serializer = self.serializer_class(agenda, data=request.data)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
agenda = serializer.save()
return Response({'err': 0})
shared_custody_agenda = SharedCustodyAgendaAPI.as_view()
class StatisticsList(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, *args, **kwargs):
categories = Category.objects.all()
category_options = [{'id': '_all', 'label': pgettext('categories', 'All')}] + [
{'id': x.slug, 'label': x.label} for x in categories
]
return Response(
{
'data': [
{
'name': _('Bookings Count'),
'url': request.build_absolute_uri(reverse('api-statistics-bookings')),
'id': 'bookings_count',
'future_data': True,
'filters': [
{
'id': 'time_interval',
'label': _('Interval'),
'options': [{'id': 'day', 'label': _('Day')}],
'required': True,
'default': 'day',
},
{
'id': 'category',
'label': _('Category'),
'options': category_options,
'required': True,
'default': '_all',
'has_subfilters': True,
'deprecated': True,
'deprecation_hint': _(
'Category should now be selected using the Agenda field below.'
),
},
{
'id': 'agenda',
'label': _('Agenda'),
'options': self.get_agenda_options(),
'required': True,
'default': '_all',
'has_subfilters': True,
},
],
}
]
}
)
@staticmethod
def get_agenda_options():
all_agendas_option = [{'id': '_all', 'label': pgettext('agendas', 'All')}]
agendas = Agenda.objects.all().order_by('category__name')
agendas_with_category = [x for x in agendas if x.category]
if not agendas_with_category:
return all_agendas_option + [{'id': x.slug, 'label': x.label} for x in agendas]
agenda_options = {None: all_agendas_option}
for agenda in agendas_with_category:
if agenda.category.label not in agenda_options:
agenda_options[agenda.category.label] = [
{
'id': 'category:' + agenda.category.slug,
'label': _('All agendas of category %s') % agenda.category.label,
}
]
agenda_options[agenda.category.label].append({'id': agenda.slug, 'label': agenda.label})
agendas_without_category_options = [
{'id': x.slug, 'label': x.label} for x in agendas if not x.category
]
if agendas_without_category_options:
agenda_options[_('Misc')] = agendas_without_category_options
return list(agenda_options.items())
statistics_list = StatisticsList.as_view()
class BookingsStatistics(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.StatisticsFiltersSerializer
def get(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.query_params)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid statistics filters'), errors=serializer.errors)
data = serializer.validated_data
subfilters = []
bookings = Booking.objects.filter(cancellation_datetime__isnull=True)
if 'start' in data:
bookings = bookings.filter(event__start_datetime__gte=data['start'])
if 'end' in data:
bookings = bookings.filter(event__start_datetime__lte=data['end'])
agenda_slug = data.get('agenda', '_all')
category_slug = data.get('category', '_all')
if agenda_slug.startswith('category:'):
category_slug = agenda_slug.split(':', 1)[1]
if category_slug != '_all':
bookings = bookings.filter(event__agenda__category__slug=category_slug)
subfilters = self.get_subfilters(agendas=Agenda.objects.filter(category__slug=category_slug))
elif agenda_slug != '_all':
bookings = bookings.filter(event__agenda__slug=agenda_slug)
subfilters = self.get_subfilters(agendas=Agenda.objects.filter(slug=agenda_slug))
bookings = bookings.annotate(day=TruncDay('event__start_datetime'))
if 'group_by' not in data:
bookings = bookings.values('day').annotate(total=Count('id')).order_by('day')
days = [booking['day'] for booking in bookings]
if bookings:
series = [{'label': _('Bookings Count'), 'data': [booking['total'] for booking in bookings]}]
else:
series = []
else:
group_by = data['group_by']
if not isinstance(group_by, list): # legacy support
group_by = [group_by]
lookups = [
'extra_data__%s' % field if field != 'user_was_present' else field for field in group_by
]
bookings = bookings.values('day', *lookups).annotate(total=Count('id')).order_by('day')
days = bookings_by_day = collections.OrderedDict(
# day1: {group1: total_11, group2: total_12},
# day2: {group1: total_21}
)
seen_group_values = set(
# group1, group2
)
for booking in bookings:
totals_by_group = bookings_by_day.setdefault(booking['day'], {})
group_value = tuple(booking[field] for field in lookups)
totals_by_group[group_value] = booking['total']
seen_group_values.add(group_value)
bookings_by_group = {
# group1: [total_11, total_21],
# group2: [total_12, None],
}
for group in seen_group_values:
bookings_by_group[group] = [bookings.get(group) for bookings in bookings_by_day.values()]
def build_label(group):
group_labels = []
for field, value in zip(group_by, group):
if field == 'user_was_present':
label = {None: gettext('Booked'), True: gettext('Present'), False: gettext('Absent')}[
value
]
else:
label = value or gettext('None')
group_labels.append(label)
return ' / '.join(group_labels)
series = [
{'label': build_label(k), 'data': data} for k, data in bookings_by_group.items() if any(data)
]
series.sort(key=lambda x: x['label'])
return Response(
{
'data': {
'x_labels': [day.strftime('%Y-%m-%d') for day in days],
'series': series,
'subfilters': subfilters,
},
'err': 0,
}
)
def get_subfilters(self, agendas):
extra_data_keys = (
Booking.objects.filter(event__agenda__in=agendas)
.annotate(extra_data_keys=Func('extra_data', function='jsonb_object_keys'))
.distinct('extra_data_keys')
.order_by('extra_data_keys')
.values_list('extra_data_keys', flat=True)
)
group_by_options = [{'id': 'user_was_present', 'label': _('Presence/Absence')}] + [
{'id': x, 'label': x.capitalize()} for x in extra_data_keys
]
return [
{
'id': 'group_by',
'label': _('Group by'),
'options': group_by_options,
'required': False,
'multiple': True,
},
]
bookings_statistics = BookingsStatistics.as_view()