2560 lines
101 KiB
Python
2560 lines
101 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 datetime
|
|
import itertools
|
|
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Prefetch, Q
|
|
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_text
|
|
from django.utils.formats import date_format
|
|
from django.utils.timezone import localtime, make_aware, now
|
|
from django.utils.translation import gettext_noop
|
|
from django.utils.translation import ugettext_lazy as _
|
|
from django_filters import rest_framework as filters
|
|
from rest_framework import permissions, status
|
|
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,
|
|
TimePeriodException,
|
|
)
|
|
from chrono.api import serializers
|
|
from chrono.api.utils import APIError, 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 or get_min_datetime(agenda, start_datetime)
|
|
used_max_datetime = base_max_datetime or 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()
|
|
start_of_day = max_datetime.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
# move to end of the day if max_datetime is not on a day boundary
|
|
if max_datetime != start_of_day:
|
|
max_datetime = start_of_day + datetime.timedelta(days=1)
|
|
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():
|
|
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,
|
|
}
|
|
|
|
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['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(include_full=False))
|
|
if agenda.absence_reasons_group:
|
|
agenda_detail['absence_reasons'] = [
|
|
{'id': r.slug, 'slug': r.slug, 'text': r.label, 'label': r.label}
|
|
for r in agenda.absence_reasons_group.absence_reasons.all()
|
|
]
|
|
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})
|
|
)
|
|
|
|
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):
|
|
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
|
|
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_text(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
|
|
|
|
|
|
def get_event_detail(
|
|
request,
|
|
event,
|
|
agenda=None,
|
|
min_places=1,
|
|
booked_user_external_id=None,
|
|
bookable_events=None,
|
|
multiple_agendas=False,
|
|
disable_booked=True,
|
|
):
|
|
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 '',
|
|
'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,
|
|
}
|
|
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:
|
|
details.update(
|
|
{
|
|
'disabled': is_event_disabled(
|
|
event,
|
|
min_places=min_places,
|
|
disable_booked=disable_booked,
|
|
bookable_events=bookable_events,
|
|
),
|
|
'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},
|
|
)
|
|
),
|
|
},
|
|
'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'
|
|
|
|
return details
|
|
|
|
|
|
def get_events_meta_detail(
|
|
request, events, agenda=None, min_places=1, bookable_events=None, multiple_agendas=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):
|
|
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,
|
|
)
|
|
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_event_recurrence(agenda, event_identifier):
|
|
event_slug, datetime_str = event_identifier.split(':')
|
|
try:
|
|
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))
|
|
except ValueError:
|
|
raise APIError(
|
|
_('bad datetime format: %s') % datetime_str,
|
|
err_class='bad datetime format: %s' % datetime_str,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
event = agenda.event_set.get(slug=event_slug)
|
|
except Event.DoesNotExist:
|
|
raise APIError(
|
|
_('unknown recurring event slug: %s') % event_slug,
|
|
err_class='unknown recurring event slug: %s' % event_slug,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
return event.get_or_create_event_recurrence(start_datetime)
|
|
except ValueError:
|
|
raise APIError(
|
|
_('invalid datetime for event %s') % event_identifier,
|
|
err_class='invalid datetime for event %s' % event_identifier,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
|
|
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']
|
|
|
|
# convert event recurrence identifiers to real event slugs
|
|
for i, slot in enumerate(slots.copy()):
|
|
if ':' not in slot:
|
|
continue
|
|
event = get_event_recurrence(agenda, slot)
|
|
slots[i] = event.slug
|
|
|
|
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():
|
|
raise APIError(_('event %s is not bookable') % event.slug, err_class='event not bookable')
|
|
else:
|
|
if not book_past:
|
|
raise APIError(_('event %s is not bookable') % event.slug, err_class='event not bookable')
|
|
if event.cancelled:
|
|
raise APIError(_('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).exists():
|
|
raise APIError(
|
|
_('event %s is already booked by user') % event.slug,
|
|
err_class='event is already booked by user',
|
|
)
|
|
if event.recurrence_days:
|
|
raise APIError(
|
|
_('event %s is recurrent, direct booking is forbidden') % event.slug,
|
|
err_class='event is recurrent',
|
|
)
|
|
|
|
if slots and not events.exists():
|
|
raise APIError(
|
|
_('unknown event identifiers or slugs'),
|
|
err_class='unknown event identifiers or slugs',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
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):
|
|
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(unknown_slugs)
|
|
raise APIError(
|
|
_('invalid slugs: %s' % unknown_slugs),
|
|
err_class='invalid slugs: %s' % unknown_slugs,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return objects
|
|
|
|
|
|
def get_start_and_end_datetime_from_request(request):
|
|
serializer = serializers.DateRangeSerializer(data=request.query_params)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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):
|
|
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', ''),
|
|
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_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('absence_reasons_group', 'category', 'edit_role', 'view_role')
|
|
.prefetch_related('resources', 'absence_reasons_group__absence_reasons')
|
|
.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_and_exceptions(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 APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
agenda = serializer.save()
|
|
return Response({'err': 0, 'data': [get_agenda_detail(request, agenda)]})
|
|
|
|
|
|
agendas = Agendas.as_view()
|
|
|
|
|
|
class AgendaDetail(APIView):
|
|
"""
|
|
Retrieve an agenda instance.
|
|
"""
|
|
|
|
permission_classes = ()
|
|
|
|
def get(self, request, agenda_identifier):
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier)
|
|
return Response({'data': get_agenda_detail(request, agenda, check_events=True)})
|
|
|
|
|
|
agenda_detail = AgendaDetail.as_view()
|
|
|
|
|
|
class Datetimes(APIView):
|
|
permission_classes = ()
|
|
serializer_class = serializers.DatetimesSerializer
|
|
|
|
def get(self, request, agenda_identifier=None, format=None):
|
|
try:
|
|
agenda = Agenda.objects.get(slug=agenda_identifier)
|
|
except Agenda.DoesNotExist:
|
|
try:
|
|
# legacy access by agenda id
|
|
agenda = Agenda.objects.get(id=int(agenda_identifier))
|
|
except (ValueError, Agenda.DoesNotExist):
|
|
raise Http404()
|
|
if agenda.kind != 'events':
|
|
raise Http404('agenda found, but it was not an events agenda')
|
|
|
|
serializer = self.serializer_class(data=request.query_params)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
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 = []
|
|
if book_past:
|
|
entries += agenda.get_past_events(
|
|
min_start=payload.get('date_start'),
|
|
max_start=payload.get('date_end'),
|
|
user_external_id=user_external_id,
|
|
)
|
|
if book_future:
|
|
entries += agenda.get_open_events(
|
|
min_start=payload.get('date_start'),
|
|
max_start=payload.get('date_end'),
|
|
user_external_id=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
|
|
)
|
|
]
|
|
|
|
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,
|
|
)
|
|
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 APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
|
|
agenda_slugs = payload['agendas']
|
|
agendas = get_objects_from_slugs(agenda_slugs, qs=Agenda.objects.filter(kind='events'))
|
|
|
|
user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id')
|
|
disable_booked = bool(payload.get('exclude_user_external_id'))
|
|
show_past_events = bool(payload.get('show_past_events'))
|
|
agendas = Agenda.prefetch_events_and_exceptions(
|
|
agendas,
|
|
user_external_id=user_external_id,
|
|
show_past_events=show_past_events,
|
|
min_start=payload.get('date_start'),
|
|
max_start=payload.get('date_end'),
|
|
)
|
|
|
|
entries = []
|
|
for agenda in agendas:
|
|
if show_past_events:
|
|
entries.extend(
|
|
agenda.get_past_events(
|
|
prefetched_queryset=True,
|
|
)
|
|
)
|
|
entries.extend(
|
|
agenda.get_open_events(
|
|
prefetched_queryset=True,
|
|
)
|
|
)
|
|
|
|
agenda_querystring_indexes = {agenda_slug: i for i, agenda_slug in enumerate(agenda_slugs)}
|
|
entries.sort(key=lambda event: (event.start_datetime, agenda_querystring_indexes[event.agenda.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,
|
|
)
|
|
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 APIError(
|
|
_('user_external_id and exclude_user_external_id have different values'),
|
|
err_class='user_external_id and exclude_user_external_id have different values',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# 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):
|
|
if not settings.ENABLE_RECURRING_EVENT_BOOKING:
|
|
raise Http404()
|
|
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
|
|
entries = agenda.get_open_recurring_events()
|
|
|
|
events = []
|
|
for event in entries:
|
|
for day in event.recurrence_days:
|
|
slug = '%s:%s' % (event.slug, day)
|
|
events.append(
|
|
{
|
|
'id': slug,
|
|
'text': get_event_text(event, agenda, day),
|
|
'date': format_response_date(event.start_datetime),
|
|
'datetime': format_response_datetime(event.start_datetime),
|
|
'description': event.description,
|
|
'pricing': event.pricing,
|
|
'url': event.url,
|
|
}
|
|
)
|
|
|
|
return Response({'data': 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 = []
|
|
for meeting_type in agenda.iter_meetingtypes():
|
|
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.SlotsSerializer
|
|
|
|
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
|
|
return self.fillslot(request=request, agenda_identifier=agenda_identifier, format=format)
|
|
|
|
def fillslot(self, request, agenda_identifier=None, slots=None, format=None):
|
|
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 APIError(
|
|
_('parameters "%s" must be included in request body, not query') % params,
|
|
err_class='parameters "%s" must be included in request body, not query' % params,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
serializer = self.serializer_class(data=request.data, partial=True)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
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 APIError(
|
|
_('invalid value for count (%s)') % request.query_params['count'],
|
|
err_class='invalid value for count (%s)' % request.query_params['count'],
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
else:
|
|
places_count = 1
|
|
|
|
if places_count <= 0:
|
|
raise APIError(
|
|
_('count cannot be less than or equal to zero'),
|
|
err_class='count cannot be less than or equal to zero',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
to_cancel_booking = None
|
|
cancel_booking_id = None
|
|
if payload.get('cancel_booking_id'):
|
|
try:
|
|
cancel_booking_id = int(payload.get('cancel_booking_id'))
|
|
except (ValueError, TypeError):
|
|
raise APIError(
|
|
_('cancel_booking_id is not an integer'),
|
|
err_class='cancel_booking_id is not an integer',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if cancel_booking_id is not None:
|
|
cancel_error = None
|
|
try:
|
|
to_cancel_booking = Booking.objects.get(pk=cancel_booking_id)
|
|
if to_cancel_booking.cancellation_datetime:
|
|
cancel_error = gettext_noop('cancel booking: booking already cancelled')
|
|
else:
|
|
to_cancel_places_count = (
|
|
to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count()
|
|
+ 1
|
|
)
|
|
if places_count != to_cancel_places_count:
|
|
cancel_error = gettext_noop('cancel booking: count is different')
|
|
except Booking.DoesNotExist:
|
|
cancel_error = gettext_noop('cancel booking: booking does no exist')
|
|
|
|
if cancel_error:
|
|
raise APIError(_(cancel_error), err_class=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 APIError(
|
|
_('invalid slot: %s') % slot,
|
|
err_class='invalid slot: %s' % slot,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if meeting_type_id_ != meeting_type_id:
|
|
raise APIError(
|
|
_('all slots must have the same meeting type id (%s)') % meeting_type_id,
|
|
err_class='all slots must have the same meeting type id (%s)' % meeting_type_id,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
|
|
except ValueError:
|
|
raise APIError(
|
|
_('bad datetime format: %s') % datetime_str,
|
|
err_class='bad datetime format: %s' % datetime_str,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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 APIError(
|
|
_('invalid meeting type id: %s') % meeting_type_id,
|
|
err_class='invalid meeting type id: %s' % meeting_type_id,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
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(
|
|
_('no more desk available'),
|
|
err_class='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:
|
|
event = Event.objects.create(
|
|
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,
|
|
)
|
|
if resources:
|
|
event.resources.add(*resources)
|
|
events.append(event)
|
|
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(
|
|
_('no waiting list'),
|
|
err_class='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(
|
|
_('sold out'),
|
|
err_class='sold out',
|
|
)
|
|
else:
|
|
if (event.booked_places + places_count) > event.places:
|
|
raise APIError(
|
|
_('sold out'),
|
|
err_class='sold out',
|
|
)
|
|
|
|
with transaction.atomic():
|
|
if to_cancel_booking:
|
|
cancelled_booking_id = to_cancel_booking.pk
|
|
to_cancel_booking.cancel()
|
|
|
|
# now we have a list of events, book them.
|
|
primary_booking = None
|
|
for event in events:
|
|
for 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
|
|
|
|
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.SlotSerializer
|
|
|
|
def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
|
|
return self.fillslot(
|
|
request=request,
|
|
agenda_identifier=agenda_identifier,
|
|
slots=[event_identifier], # fill a "list on one slot"
|
|
format=format,
|
|
)
|
|
|
|
|
|
fillslot = Fillslot.as_view()
|
|
|
|
|
|
class RecurringFillslots(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = serializers.EventsSlotsSerializer
|
|
|
|
def post(self, request, agenda_identifier):
|
|
if not settings.ENABLE_RECURRING_EVENT_BOOKING:
|
|
raise Http404()
|
|
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
|
|
start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
|
|
if not start_datetime or start_datetime < now():
|
|
start_datetime = now()
|
|
|
|
serializer = self.serializer_class(data=request.data, partial=True)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
user_external_id = payload['user_external_id']
|
|
|
|
open_event_slugs = set(agenda.get_open_recurring_events().values_list('slug', flat=True))
|
|
slots = collections.defaultdict(list)
|
|
for slot in payload['slots']:
|
|
try:
|
|
slug, day = slot.split(':')
|
|
day = int(day)
|
|
except ValueError:
|
|
raise APIError(
|
|
_('invalid slot: %s') % slot,
|
|
err_class='invalid slot: %s' % slot,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if slug not in open_event_slugs:
|
|
raise APIError(
|
|
_('event %s is not bookable') % slug,
|
|
err_class='event %s is not bookable' % slug,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
# convert ISO day number to db lookup day number
|
|
day = (day + 1) % 7 + 1
|
|
slots[slug].append(day)
|
|
|
|
event_filter = Q()
|
|
for slug, days in slots.items():
|
|
event_filter |= Q(agenda=agenda, primary_event__slug=slug, start_datetime__week_day__in=days)
|
|
|
|
events_to_book = Event.objects.filter(event_filter) if event_filter else Event.objects.none()
|
|
events_to_book = events_to_book.filter(start_datetime__gte=start_datetime, cancelled=False)
|
|
if end_datetime:
|
|
events_to_book = events_to_book.filter(start_datetime__lte=end_datetime)
|
|
|
|
events_to_unbook = list(
|
|
agenda.event_set.filter(booking__user_external_id=user_external_id, primary_event__isnull=False)
|
|
.exclude(pk__in=events_to_book)
|
|
.values_list('pk', flat=True)
|
|
)
|
|
events_to_book = events_to_book.exclude(booking__user_external_id=user_external_id)
|
|
|
|
full_events = list(events_to_book.filter(full=True))
|
|
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}
|
|
bookings = [make_booking(event, payload, extra_data) for event in events_to_book]
|
|
|
|
with transaction.atomic():
|
|
deleted_count = Booking.objects.filter(
|
|
user_external_id=user_external_id, event__in=events_to_unbook
|
|
).delete()[0]
|
|
Booking.objects.bulk_create(bookings)
|
|
|
|
response = {
|
|
'err': 0,
|
|
'booking_count': len(bookings),
|
|
'cancelled_booking_count': deleted_count,
|
|
'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events],
|
|
}
|
|
return Response(response)
|
|
|
|
|
|
recurring_fillslots = RecurringFillslots.as_view()
|
|
|
|
|
|
class EventsFillslots(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
serializer_class = serializers.EventsSlotsSerializer
|
|
|
|
def post(self, request, agenda_identifier):
|
|
self.agenda = get_object_or_404(Agenda, 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)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
user_external_id = payload['user_external_id']
|
|
|
|
events = self.get_events(request, payload)
|
|
|
|
already_booked_events = self.get_already_booked_events(user_external_id)
|
|
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)
|
|
|
|
events_to_unbook = list(already_booked_events.exclude(pk__in=events).values_list('pk', flat=True))
|
|
events = events.exclude(booking__user_external_id=user_external_id)
|
|
|
|
full_events = [str(event) for event in events.filter(full=True)]
|
|
if full_events:
|
|
raise APIError(
|
|
_('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_events = [event 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]
|
|
|
|
with transaction.atomic():
|
|
deleted_count = Booking.objects.filter(
|
|
user_external_id=user_external_id, event__in=events_to_unbook
|
|
).delete()[0]
|
|
Booking.objects.bulk_create(bookings)
|
|
|
|
response = {
|
|
'err': 0,
|
|
'booking_count': len(bookings),
|
|
'waiting_list_events': [get_event_detail(request, x) for x in waiting_list_events],
|
|
'cancelled_booking_count': deleted_count,
|
|
}
|
|
return Response(response)
|
|
|
|
def get_events(self, request, payload):
|
|
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)
|
|
|
|
|
|
events_fillslots = EventsFillslots.as_view()
|
|
|
|
|
|
class MultipleAgendasEventsFillslots(EventsFillslots):
|
|
def post(self, request):
|
|
self.agendas = None
|
|
if 'agendas' not in request.GET:
|
|
raise APIError(
|
|
_('Missing agendas list in querystring'),
|
|
err_class='Missing agendas list in querystring',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
self.agenda_slugs = [s for s in request.GET.get('agendas', '').split(',') if s]
|
|
self.agendas = get_objects_from_slugs(self.agenda_slugs, qs=Agenda.objects.filter(kind='events'))
|
|
return self.fillslots(request)
|
|
|
|
def get_events(self, request, payload):
|
|
events_by_agenda = collections.defaultdict(list)
|
|
for slot in payload['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 = {agenda.slug: agenda for agenda in agendas}
|
|
|
|
if not set(agendas_by_slug).issubset(self.agenda_slugs):
|
|
extra_agendas = set(agendas_by_slug) - set(self.agenda_slugs)
|
|
extra_agendas = ','.join(extra_agendas)
|
|
raise APIError(
|
|
_('Some events belong to agendas that are not present in querystring: %s' % extra_agendas),
|
|
err_class='Some events belong to agendas that are not present in querystring: %s'
|
|
% extra_agendas,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
|
|
agendas_events_fillslots = MultipleAgendasEventsFillslots.as_view()
|
|
|
|
|
|
class BookingFilter(filters.FilterSet):
|
|
agenda = filters.CharFilter(field_name='event__agenda__slug', lookup_expr='exact')
|
|
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', method='filter_date_end')
|
|
|
|
def filter_date_end(self, queryset, name, value):
|
|
# we want to include all events starting during the targeted day
|
|
lookup = '__'.join([name, 'lt'])
|
|
return queryset.filter(**{lookup: value + datetime.timedelta(days=1)})
|
|
|
|
class Meta:
|
|
model = Booking
|
|
fields = [
|
|
'user_external_id',
|
|
'agenda',
|
|
'category',
|
|
'date_start',
|
|
'date_end',
|
|
'user_was_present',
|
|
'user_absence_reason',
|
|
]
|
|
|
|
|
|
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'):
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'missing param user_external_id',
|
|
'err_desc': _('missing param user_external_id'),
|
|
}
|
|
return Response(response)
|
|
|
|
try:
|
|
response = super().get(request, *args, **kwargs)
|
|
except ValidationError as e:
|
|
return Response(
|
|
{
|
|
'err': 1,
|
|
'err_class': 'invalid payload',
|
|
'err_desc': _('invalid payload'),
|
|
'errors': e.detail,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
return Response({'err': 0, 'data': response.data})
|
|
|
|
def get_queryset(self):
|
|
return Booking.objects.all().order_by('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:
|
|
return Response(
|
|
{'err': 1, 'err_class': 'booking is cancelled', 'err_desc': _('booking is cancelled')}
|
|
)
|
|
|
|
if self.booking.primary_booking is not None:
|
|
return Response({'err': 2, 'err_class': 'secondary booking', 'err_desc': _('secondary booking')})
|
|
|
|
if check_waiting_list and self.booking.in_waiting_list:
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'booking is in waiting list',
|
|
'err_desc': _('booking is in waiting list'),
|
|
}
|
|
return Response(response)
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
response = self.check_booking()
|
|
if response:
|
|
return response
|
|
|
|
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):
|
|
response = self.check_booking(check_waiting_list=True)
|
|
if response:
|
|
return response
|
|
|
|
serializer = self.serializer_class(self.booking, data=request.data, partial=True)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(
|
|
{
|
|
'err': 4,
|
|
'err_class': 'invalid payload',
|
|
'err_desc': _('invalid payload'),
|
|
'errors': serializer.errors,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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)
|
|
):
|
|
return Response(
|
|
{
|
|
'err': 5,
|
|
'err_class': 'event is marked as checked',
|
|
'err_desc': _('event is marked as checked'),
|
|
'errors': serializer.errors,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if 'extra_data' in serializer.validated_data:
|
|
extra_data = self.booking.extra_data or {}
|
|
extra_data.update(serializer.validated_data['extra_data'] or {})
|
|
serializer.validated_data['extra_data'] = extra_data
|
|
|
|
serializer.save()
|
|
if 'user_was_present' in request.data:
|
|
self.booking.secondary_booking_set.update(user_was_present=self.booking.user_was_present)
|
|
self.booking.event.set_is_checked()
|
|
if 'user_absence_reason' in request.data:
|
|
self.booking.secondary_booking_set.update(user_absence_reason=self.booking.user_absence_reason)
|
|
if 'extra_data' in request.data:
|
|
self.booking.secondary_booking_set.update(extra_data=self.booking.extra_data)
|
|
|
|
response = {'err': 0, 'booking_id': self.booking.pk}
|
|
return Response(response)
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
response = self.check_booking()
|
|
if response:
|
|
return response
|
|
|
|
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:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'already cancelled',
|
|
'err_desc': _('already cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
booking.cancel()
|
|
response = {'err': 0, 'booking_id': booking.id}
|
|
return Response(response)
|
|
|
|
|
|
cancel_booking = CancelBooking.as_view()
|
|
|
|
|
|
class AcceptBooking(APIView):
|
|
"""
|
|
Accept a booking currently in the waiting list.
|
|
|
|
It will return error codes if the booking was cancelled before (code 1),
|
|
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:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'booking is cancelled',
|
|
'err_desc': _('booking is cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
if not booking.in_waiting_list:
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'booking is not in waiting list',
|
|
'err_desc': _('booking is not in waiting list'),
|
|
}
|
|
return Response(response)
|
|
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:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'booking is cancelled',
|
|
'err_desc': _('booking is cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
if booking.in_waiting_list:
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'booking is already in waiting list',
|
|
'err_desc': _('booking is already in waiting list'),
|
|
}
|
|
return Response(response)
|
|
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():
|
|
return Response(
|
|
{
|
|
'err': 1,
|
|
'err_class': 'invalid payload',
|
|
'err_desc': _('invalid payload'),
|
|
'errors': serializer.errors,
|
|
},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
|
|
booking = get_object_or_404(Booking, pk=booking_pk, event__agenda__kind='events')
|
|
event = booking.event
|
|
if booking.cancellation_datetime:
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'booking is cancelled',
|
|
'err_desc': _('booking is cancelled'),
|
|
}
|
|
return Response(response)
|
|
if booking.primary_booking is not None:
|
|
response = {
|
|
'err': 2,
|
|
'err_class': 'secondary booking',
|
|
'err_desc': _('secondary booking'),
|
|
}
|
|
return Response(response)
|
|
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:
|
|
response = {
|
|
'err': 4,
|
|
'err_class': 'can not resize multi event booking',
|
|
'err_desc': _('can not resize multi event booking'),
|
|
}
|
|
return Response(response)
|
|
if len(in_waiting_list) > 1:
|
|
response = {
|
|
'err': 5,
|
|
'err_class': 'can not resize booking: waiting list inconsistency',
|
|
'err_desc': _('can not resize booking: waiting list inconsistency'),
|
|
}
|
|
return Response(response)
|
|
|
|
# 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
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'sold out',
|
|
'err_desc': _('sold out'),
|
|
}
|
|
return Response(response)
|
|
if event.booked_places <= event.places:
|
|
# in main list and no overbooking for the moment: can not be overbooked
|
|
response = {
|
|
'err': 3,
|
|
'err_class': 'sold out',
|
|
'err_desc': _('sold out'),
|
|
}
|
|
return Response(response)
|
|
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 Events(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')
|
|
if ':' in event_identifier:
|
|
return get_event_recurrence(agenda, event_identifier)
|
|
try:
|
|
return agenda.event_set.get(slug=event_identifier)
|
|
except Event.DoesNotExist:
|
|
raise Http404()
|
|
|
|
def post(self, request, agenda_identifier):
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
|
|
|
|
serializer = self.serializer_class(data=request.data)
|
|
if not serializer.is_valid():
|
|
raise APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
payload = serializer.validated_data
|
|
event = Event.objects.create(agenda=agenda, **payload)
|
|
if event.recurrence_days and event.recurrence_end_date:
|
|
event.create_all_recurrences()
|
|
return Response({'err': 0, 'data': get_event_detail(request, event)})
|
|
|
|
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 APIError(
|
|
_('invalid payload'),
|
|
err_class='invalid payload',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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 APIError(
|
|
_('%s cannot be modified on an event recurrence') % field,
|
|
err_class='%s cannot be modified on an event recurrence' % field,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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 APIError(
|
|
_('%s cannot be modified because some recurrences have bookings attached to them.')
|
|
% field,
|
|
err_class='%s cannot be modified because some recurrences have bookings attached to them.'
|
|
% field,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if 'recurrence_end_date' in changed_data and event.has_recurrences_booked(
|
|
after=payload['recurrence_end_date']
|
|
):
|
|
raise APIError(
|
|
_('recurrence_end_date cannot be modified because bookings exist after this date.'),
|
|
err_class='recurrence_end_date cannot be modified because bookings exist after this date.',
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
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)})
|
|
|
|
|
|
events = Events.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()
|
|
if ':' in event_identifier:
|
|
return get_event_recurrence(agenda, event_identifier)
|
|
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')
|
|
if ':' in event_identifier:
|
|
return get_event_recurrence(agenda, event_identifier)
|
|
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):
|
|
if ':' in event_identifier:
|
|
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
|
|
return get_event_recurrence(agenda, 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'):
|
|
response = {
|
|
'err': 1,
|
|
'err_class': 'missing param user_external_id',
|
|
'err_desc': _('missing param user_external_id'),
|
|
}
|
|
return Response(response)
|
|
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 StatisticsList(APIView):
|
|
permission_classes = (permissions.IsAuthenticated,)
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
categories = Category.objects.all()
|
|
category_options = [{'id': '_all', 'label': _('All')}] + [
|
|
{'id': x.slug, 'label': x.label} for x in categories
|
|
]
|
|
agendas = Agenda.objects.all()
|
|
agenda_options = [{'id': '_all', 'label': _('All')}] + [
|
|
{'id': x.slug, 'label': x.label} for x in agendas
|
|
]
|
|
booking_check_filters = set()
|
|
for agenda in Agenda.objects.exclude(booking_check_filters=''):
|
|
booking_check_filters.update(agenda.get_booking_check_filters())
|
|
group_by_options = [{'id': 'user_was_present', 'label': _('Presence/Absence')}] + [
|
|
{'id': x, 'label': x.capitalize()} for x in sorted(list(booking_check_filters))
|
|
]
|
|
return Response(
|
|
{
|
|
'data': [
|
|
{
|
|
'name': _('Bookings Count'),
|
|
'url': request.build_absolute_uri(reverse('api-statistics-bookings')),
|
|
'id': 'bookings_count',
|
|
'filters': [
|
|
{
|
|
'id': 'time_interval',
|
|
'label': _('Interval'),
|
|
'options': [{'id': 'day', 'label': _('Day')}],
|
|
'required': True,
|
|
'default': 'day',
|
|
},
|
|
{
|
|
'id': 'category',
|
|
'label': _('Category'),
|
|
'options': category_options,
|
|
'required': False,
|
|
'default': '_all',
|
|
},
|
|
{
|
|
'id': 'agenda',
|
|
'label': _('Agenda'),
|
|
'options': agenda_options,
|
|
'required': False,
|
|
'default': '_all',
|
|
},
|
|
{
|
|
'id': 'group_by',
|
|
'label': _('Group by'),
|
|
'options': group_by_options,
|
|
'required': False,
|
|
},
|
|
],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
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 APIError(
|
|
_('invalid statistics filters'),
|
|
err_class='invalid statistics filters',
|
|
errors=serializer.errors,
|
|
http_status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
data = serializer.validated_data
|
|
|
|
bookings = Booking.objects
|
|
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'])
|
|
|
|
if 'category' in data and data['category'] != '_all':
|
|
bookings = bookings.filter(event__agenda__category__slug=data['category'])
|
|
|
|
if 'agenda' in data and data['agenda'] != '_all':
|
|
bookings = bookings.filter(event__agenda__slug=data['agenda'])
|
|
|
|
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 group_by not in ('user_was_present',):
|
|
group_by = 'extra_data__%s' % group_by
|
|
bookings = bookings.values('day', group_by).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 = booking[group_by]
|
|
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()]
|
|
|
|
if group_by == 'user_was_present':
|
|
labels = {None: _('Booked'), True: _('Present'), False: _('Absent')}
|
|
series = [
|
|
{'label': labels[k], 'data': data} for k, data in bookings_by_group.items() if any(data)
|
|
]
|
|
else:
|
|
series = [
|
|
{'label': k or _('None'), 'data': data}
|
|
for k, data in bookings_by_group.items()
|
|
if any(data)
|
|
]
|
|
|
|
return Response(
|
|
{
|
|
'data': {
|
|
'x_labels': [day.strftime('%Y-%m-%d') for day in days],
|
|
'series': series,
|
|
},
|
|
'err': 0,
|
|
}
|
|
)
|
|
|
|
|
|
bookings_statistics = BookingsStatistics.as_view()
|