0001-api-add-bookings-count-statistics-52846.patch
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 |
- |