Projet

Général

Profil

0001-api-add-bookings-count-statistics-52846.patch

Valentin Deniaud, 14 avril 2021 16:20

Télécharger (11,8 ko)

Voir les différences:

Subject: [PATCH] api: add bookings count statistics (#52846)

 chrono/api/urls.py  |   2 +
 chrono/api/views.py | 155 ++++++++++++++++++++++++++++++++++++++++++--
 tests/test_api.py   |  96 +++++++++++++++++++++++++++
 3 files changed, 249 insertions(+), 4 deletions(-)
chrono/api/urls.py
63 63
    url(r'^booking/(?P<booking_pk>\w+)/suspend/$', views.suspend_booking, name='api-suspend-booking'),
64 64
    url(r'^booking/(?P<booking_pk>\w+)/resize/$', views.resize_booking, name='api-resize-booking'),
65 65
    url(r'^booking/(?P<booking_pk>\w+)/ics/$', views.booking_ics, name='api-booking-ics'),
66
    url(r'^statistics/$', views.statistics_list, name='api-statistics-list'),
67
    url(r'^statistics/bookings/$', views.bookings_statistics, name='api-statistics-bookings'),
66 68
]
chrono/api/views.py
19 19
import itertools
20 20
import uuid
21 21

  
22
from django.db import transaction
23
from django.db.models import Prefetch, Q
22
from django.db import transaction, models
23
from django.db.models import Prefetch, Q, Count
24 24
from django.http import Http404, HttpResponse
25
from django.db.models.functions import Trunc
25 26
from django.shortcuts import get_object_or_404
26 27
from django.urls import reverse
27 28
from django.utils.dateparse import parse_date, parse_datetime
......
31 32
from django.utils.translation import gettext_noop
32 33
from django.utils.translation import ugettext_lazy as _
33 34

  
35
from dateutil.relativedelta import relativedelta
34 36
from django_filters import rest_framework as filters
35 37
from rest_framework import permissions, serializers, status
36 38
from rest_framework.exceptions import ValidationError
......
38 40
from rest_framework.views import APIView
39 41

  
40 42
from chrono.api.utils import Response, APIError
41
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk, BookingColor
42
from ..interval import IntervalSet
43
from chrono.agendas.models import (
44
    Agenda,
45
    Event,
46
    Booking,
47
    MeetingType,
48
    TimePeriodException,
49
    Desk,
50
    BookingColor,
51
    Category,
52
)
53
from chrono.interval import IntervalSet
43 54

  
44 55

  
45 56
def format_response_datetime(dt):
......
1808 1819

  
1809 1820

  
1810 1821
booking_ics = BookingICS.as_view()
1822

  
1823

  
1824
TIME_INTERVAL_CHOICES = [('day', _('Day')), ('month', _('Month')), ('year', _('Year'))]
1825

  
1826

  
1827
class StatisticsList(APIView):
1828
    permission_classes = (permissions.IsAuthenticated,)
1829

  
1830
    def get(self, request, *args, **kwargs):
1831
        categories = Category.objects.all()
1832
        category_options = [{'id': '_all', 'label': _('All')}] + [
1833
            {'id': x.slug, 'label': x.label} for x in categories
1834
        ]
1835
        return Response(
1836
            {
1837
                'data': [
1838
                    {
1839
                        'name': _('Bookings Count'),
1840
                        'url': request.build_absolute_uri(reverse('api-statistics-bookings')),
1841
                        'id': 'bookings_count',
1842
                        'filters': [
1843
                            {
1844
                                'id': 'time_interval',
1845
                                'label': _('Interval'),
1846
                                'options': [
1847
                                    {'id': key, 'label': label} for key, label in TIME_INTERVAL_CHOICES
1848
                                ],
1849
                                'required': True,
1850
                                'default': 'month',
1851
                            },
1852
                            {
1853
                                'id': 'category',
1854
                                'label': _('Category'),
1855
                                'options': category_options,
1856
                                'required': False,
1857
                                'default': '_all',
1858
                            },
1859
                        ],
1860
                    }
1861
                ]
1862
            }
1863
        )
1864

  
1865

  
1866
statistics_list = StatisticsList.as_view()
1867

  
1868

  
1869
class StatisticsFiltersSerializer(serializers.Serializer):
1870
    time_interval = serializers.ChoiceField(choices=TIME_INTERVAL_CHOICES, default='month')
1871
    start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
1872
    end = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
1873
    category = serializers.SlugField(required=False, allow_blank=False, max_length=256)
1874

  
1875

  
1876
class BookingsStatistics(APIView):
1877
    permission_classes = (permissions.IsAuthenticated,)
1878

  
1879
    def get(self, request, *args, **kwargs):
1880
        serializer = StatisticsFiltersSerializer(data=request.query_params)
1881
        if not serializer.is_valid():
1882
            raise APIError(
1883
                _('invalid statistics filters'),
1884
                err_class='invalid statistics filters',
1885
                errors=serializer.errors,
1886
                http_status=status.HTTP_400_BAD_REQUEST,
1887
            )
1888
        data = serializer.validated_data
1889

  
1890
        bookings = Booking.objects
1891
        if 'start' in data:
1892
            bookings = bookings.filter(creation_datetime__gte=data['start'])
1893
        if 'end' in data:
1894
            bookings = bookings.filter(creation_datetime__lte=data['end'])
1895

  
1896
        if 'category' in data and data['category'] != '_all':
1897
            bookings = bookings.filter(event__agenda__category__slug=data['category'])
1898

  
1899
        time_interval = request.GET.get('time_interval', 'month')
1900
        bookings = bookings.annotate(
1901
            truncated_date=Trunc('creation_datetime', time_interval, output_field=models.DateField())
1902
        )
1903
        bookings = (
1904
            bookings.values('truncated_date', 'user_was_present')
1905
            .annotate(total=Count('id'))
1906
            .order_by('truncated_date')
1907
        )
1908
        bookings = list(bookings)
1909

  
1910
        if not bookings:
1911
            return Response({'err': 0, 'data': {'x_labels': [], 'series': []}})
1912

  
1913
        # fill date gaps
1914
        bookings_by_date = {}
1915
        for booking in bookings:
1916
            totals_by_presence = bookings_by_date.setdefault(booking['truncated_date'], {})
1917
            totals_by_presence[booking['user_was_present']] = booking['total']
1918
        results = [(date, bookings) for date, bookings in bookings_by_date.items()]
1919

  
1920
        deltas = {
1921
            'year': relativedelta(years=1),
1922
            'month': relativedelta(months=1),
1923
            'day': relativedelta(days=1),
1924
        }
1925
        current_date, last_date = bookings[0]['truncated_date'], bookings[-1]['truncated_date']
1926
        while current_date < last_date:
1927
            if current_date not in bookings_by_date:
1928
                results.append((current_date, {}))
1929
            current_date += deltas[time_interval]
1930
        results.sort()
1931

  
1932
        bookings_by_presence = {None: [], True: [], False: []}
1933
        for __, bookings in results:
1934
            for presence, data in bookings_by_presence.items():
1935
                data.append(bookings.get(presence))
1936

  
1937
        y_labels = {None: _('Unknown'), True: _('Present'), False: _('Absent')}
1938
        series = [
1939
            {'label': y_labels[k], 'data': data} for k, data in bookings_by_presence.items() if any(data)
1940
        ]
1941

  
1942
        if len(series) == 1 and series[0]['label'] == _('Unknown'):
1943
            series[0]['label'] = _('Bookings Count')
1944

  
1945
        label_formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'}
1946
        return Response(
1947
            {
1948
                'data': {
1949
                    'x_labels': [x[0].strftime(label_formats[time_interval]) for x in results],
1950
                    'series': series,
1951
                },
1952
                'err': 0,
1953
            }
1954
        )
1955

  
1956

  
1957
bookings_statistics = BookingsStatistics.as_view()
tests/test_api.py
6182 6182
    assert not event.in_bookable_period()
6183 6183
    resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
6184 6184
    assert len(resp.json['data']) == 0
6185

  
6186

  
6187
def test_statistics_list(app, user):
6188
    category_a = Category.objects.create(label='Category A')
6189
    category_b = Category.objects.create(label='Category B')
6190

  
6191
    # unauthorized
6192
    app.get('/api/statistics/', status=401)
6193

  
6194
    app.authorization = ('Basic', ('john.doe', 'password'))
6195
    resp = app.get('/api/statistics/')
6196
    category_filter = [x for x in resp.json['data'][0]['filters'] if x['id'] == 'category'][0]
6197
    assert len(category_filter['options']) == 3
6198

  
6199

  
6200
def test_statistics_bookings(app, user, freezer):
6201
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
6202
    event = Event.objects.create(start_datetime=now(), places=5, agenda=agenda)
6203

  
6204
    app.authorization = ('Basic', ('john.doe', 'password'))
6205
    resp = app.get('/api/statistics/')
6206
    url = [x for x in resp.json['data'] if x['id'] == 'bookings_count'][0]['url']
6207

  
6208
    resp = app.get(url)
6209
    assert len(resp.json['data']['series']) == 0
6210

  
6211
    freezer.move_to('2020-10-10')
6212
    for _ in range(10):
6213
        Booking.objects.create(event=event)
6214
    freezer.move_to('2020-10-15')
6215
    Booking.objects.create(event=event)
6216

  
6217
    resp = app.get(url + '?time_interval=day')
6218
    assert resp.json['data'] == {
6219
        'x_labels': ['2020-10-10', '2020-10-11', '2020-10-12', '2020-10-13', '2020-10-14', '2020-10-15'],
6220
        'series': [{'label': 'Bookings Count', 'data': [10, None, None, None, None, 1]}],
6221
    }
6222

  
6223
    freezer.move_to('2020-12-15')
6224
    Booking.objects.create(event=event)
6225

  
6226
    # default time_interval is month
6227
    resp = app.get(url)
6228
    assert resp.json['data'] == {
6229
        'x_labels': ['2020-10', '2020-11', '2020-12'],
6230
        'series': [{'label': 'Bookings Count', 'data': [11, None, 1]}],
6231
    }
6232

  
6233
    # period filter
6234
    resp = app.get(url + '?start=2020-10-14&end=2020-10-16')
6235
    assert resp.json['data'] == {
6236
        'x_labels': ['2020-10'],
6237
        'series': [{'label': 'Bookings Count', 'data': [1]}],
6238
    }
6239

  
6240
    freezer.move_to('2022-01-15')
6241
    Booking.objects.create(event=event)
6242
    resp = app.get(url + '?time_interval=year')
6243
    assert resp.json['data'] == {
6244
        'x_labels': ['2020', '2021', '2022'],
6245
        'series': [{'label': 'Bookings Count', 'data': [12, None, 1]}],
6246
    }
6247

  
6248
    category = Category.objects.create(label='Category A', slug='category-a')
6249
    agenda = Agenda.objects.create(label='Foo bar', kind='events', category=category)
6250
    event = Event.objects.create(start_datetime=now(), places=5, agenda=agenda)
6251
    Booking.objects.create(event=event)
6252

  
6253
    # category filter
6254
    resp = app.get(url + '?category=category-a')
6255
    assert resp.json['data'] == {
6256
        'x_labels': ['2022-01'],
6257
        'series': [{'label': 'Bookings Count', 'data': [1]}],
6258
    }
6259

  
6260
    # invalid time_interval
6261
    resp = app.get(url + '?time_interval=week', status=400)
6262
    assert resp.json['err'] == 1
6263
    assert 'time_interval' in resp.json['errors']
6264

  
6265
    # absence/presence
6266
    for i in range(10):
6267
        Booking.objects.create(event=event, user_was_present=bool(i % 2))
6268

  
6269
    freezer.move_to('2020-12-15')
6270
    Booking.objects.create(event=event, user_was_present=True)
6271

  
6272
    resp = app.get(url + '?time_interval=year')
6273
    assert resp.json['data'] == {
6274
        'series': [
6275
            {'data': [12, None, 2], 'label': 'Unknown'},
6276
            {'data': [1, None, 5], 'label': 'Present'},
6277
            {'data': [None, None, 5], 'label': 'Absent'},
6278
        ],
6279
        'x_labels': ['2020', '2021', '2022'],
6280
    }
6185
-