From db13092eeaae26ed614f9dabe9a3032a01b2fc16 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 14 Apr 2021 14:19:05 +0200 Subject: [PATCH] api: add bookings count statistics (#52846) --- chrono/api/urls.py | 2 + chrono/api/views.py | 120 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 79 +++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/chrono/api/urls.py b/chrono/api/urls.py index c181cc0..13f1335 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -63,4 +63,6 @@ urlpatterns = [ url(r'^booking/(?P\w+)/suspend/$', views.suspend_booking, name='api-suspend-booking'), url(r'^booking/(?P\w+)/resize/$', views.resize_booking, name='api-resize-booking'), url(r'^booking/(?P\w+)/ics/$', views.booking_ics, name='api-booking-ics'), + url(r'^statistics/$', views.statistics_list, name='api-statistics-list'), + url(r'^statistics/bookings/$', views.bookings_statistics, name='api-statistics-bookings'), ] diff --git a/chrono/api/views.py b/chrono/api/views.py index b030b79..d8fec1c 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -20,7 +20,8 @@ import itertools import uuid from django.db import transaction -from django.db.models import Prefetch, Q +from django.db.models import Count, 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.urls import reverse @@ -36,10 +37,18 @@ 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.utils import APIError, Response - -from ..agendas.models import Agenda, Booking, BookingColor, Desk, Event, MeetingType, TimePeriodException -from ..interval import IntervalSet +from chrono.interval import IntervalSet def format_response_datetime(dt): @@ -1812,3 +1821,106 @@ class BookingICS(APIView): 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 + ] + 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': 'month', + }, + { + 'id': 'category', + 'label': _('Category'), + 'options': category_options, + 'required': False, + 'default': '_all', + }, + ], + } + ] + } + ) + + +statistics_list = StatisticsList.as_view() + + +class StatisticsFiltersSerializer(serializers.Serializer): + time_interval = serializers.ChoiceField(choices=('day', _('Day')), default='day') + start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d']) + end = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d']) + category = serializers.SlugField(required=False, allow_blank=False, max_length=256) + + +class BookingsStatistics(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request, *args, **kwargs): + serializer = StatisticsFiltersSerializer(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(creation_datetime__gte=data['start']) + if 'end' in data: + bookings = bookings.filter(creation_datetime__lte=data['end']) + + if 'category' in data and data['category'] != '_all': + bookings = bookings.filter(event__agenda__category__slug=data['category']) + + bookings = bookings.annotate(day=TruncDay('creation_datetime')) + bookings = bookings.values('day', 'user_was_present').annotate(total=Count('id')).order_by('day') + + bookings_by_day = collections.OrderedDict() + for booking in bookings: + totals_by_presence = bookings_by_day.setdefault(booking['day'], {}) + totals_by_presence[booking['user_was_present']] = booking['total'] + + bookings_by_presence = {None: [], True: [], False: []} + for bookings in bookings_by_day.values(): + for presence, data in bookings_by_presence.items(): + data.append(bookings.get(presence)) + + labels = {None: _('Unknown'), True: _('Present'), False: _('Absent')} + series = [{'label': labels[k], 'data': data} for k, data in bookings_by_presence.items() if any(data)] + + if len(series) == 1 and series[0]['label'] == _('Unknown'): + series[0]['label'] = _('Bookings Count') + + return Response( + { + 'data': { + 'x_labels': [day.strftime('%Y-%m-%d') for day in bookings_by_day], + 'series': series, + }, + 'err': 0, + } + ) + + +bookings_statistics = BookingsStatistics.as_view() diff --git a/tests/test_api.py b/tests/test_api.py index f687b16..f2645a6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6226,3 +6226,82 @@ def test_datetimes_dst(app, freezer): assert not event.in_bookable_period() resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) assert len(resp.json['data']) == 0 + + +def test_statistics_list(app, user): + category_a = Category.objects.create(label='Category A') + category_b = Category.objects.create(label='Category B') + + # unauthorized + app.get('/api/statistics/', status=401) + + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.get('/api/statistics/') + category_filter = [x for x in resp.json['data'][0]['filters'] if x['id'] == 'category'][0] + assert len(category_filter['options']) == 3 + + +def test_statistics_bookings(app, user, freezer): + agenda = Agenda.objects.create(label='Foo bar', kind='events') + event = Event.objects.create(start_datetime=now(), places=5, agenda=agenda) + + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.get('/api/statistics/') + url = [x for x in resp.json['data'] if x['id'] == 'bookings_count'][0]['url'] + + resp = app.get(url) + assert len(resp.json['data']['series']) == 0 + + freezer.move_to('2020-10-10') + for _ in range(10): + Booking.objects.create(event=event) + freezer.move_to('2020-10-15') + Booking.objects.create(event=event) + + resp = app.get(url + '?time_interval=day') + assert resp.json['data'] == { + 'x_labels': ['2020-10-10', '2020-10-15'], + 'series': [{'label': 'Bookings Count', 'data': [10, 1]}], + } + + # period filter + resp = app.get(url + '?start=2020-10-14&end=2020-10-16') + assert resp.json['data'] == { + 'x_labels': ['2020-10-15'], + 'series': [{'label': 'Bookings Count', 'data': [1]}], + } + + category = Category.objects.create(label='Category A', slug='category-a') + agenda = Agenda.objects.create(label='Foo bar', kind='events', category=category) + event = Event.objects.create(start_datetime=now(), places=5, agenda=agenda) + freezer.move_to('2020-10-25') + Booking.objects.create(event=event) + + # category filter + resp = app.get(url + '?category=category-a') + assert resp.json['data'] == { + 'x_labels': ['2020-10-25'], + 'series': [{'label': 'Bookings Count', 'data': [1]}], + } + + # invalid time_interval + resp = app.get(url + '?time_interval=month', status=400) + assert resp.json['err'] == 1 + assert 'time_interval' in resp.json['errors'] + + # absence/presence + for i in range(10): + Booking.objects.create(event=event, user_was_present=bool(i % 2)) + + freezer.move_to('2020-11-01') + Booking.objects.create(event=event, user_was_present=True) + + resp = app.get(url) + assert resp.json['data'] == { + 'x_labels': ['2020-10-10', '2020-10-15', '2020-10-25', '2020-11-01'], + 'series': [ + {'label': 'Unknown', 'data': [10, 1, 1, None]}, + {'label': 'Present', 'data': [None, None, 5, 1]}, + {'label': 'Absent', 'data': [None, None, 5, None]}, + ], + } -- 2.20.1