0001-dataviz-aggregate-received-data-by-time-intervals-53.patch
combo/apps/dataviz/forms.py | ||
---|---|---|
21 | 21 |
from django.conf import settings |
22 | 22 |
from django.db import transaction |
23 | 23 |
from django.db.models import Q |
24 |
from django.utils.translation import ugettext_lazy as _ |
|
24 | 25 | |
25 | 26 |
from combo.utils import cache_during_request, requests, spooler |
26 | 27 | |
... | ... | |
54 | 55 | |
55 | 56 |
class ChartNgForm(forms.ModelForm): |
56 | 57 |
blank_choice = ('', '---------') |
58 |
time_intervals = ( |
|
59 |
('_month', _('Month')), |
|
60 |
('_year', _('Year')), |
|
61 |
('_weekday', _('Week day')), |
|
62 |
) |
|
57 | 63 | |
58 | 64 |
class Meta: |
59 | 65 |
model = ChartNgCell |
... | ... | |
109 | 115 | |
110 | 116 |
self.fields = OrderedDict((field_id, self.fields[field_id]) for field_id in field_ids) |
111 | 117 | |
118 |
if 'time_interval' in self.fields: |
|
119 |
self.extend_time_interval_choices() |
|
120 | ||
112 | 121 |
def save(self, *args, **kwargs): |
113 | 122 |
if 'statistic' in self.changed_data: |
114 | 123 |
self.instance.filter_params.clear() |
... | ... | |
122 | 131 |
else: |
123 | 132 |
self.instance.filter_params.pop(field, None) |
124 | 133 |
return super().save(*args, **kwargs) |
134 | ||
135 |
def extend_time_interval_choices(self): |
|
136 |
choice_ids = {choice_id for choice_id, _ in self.fields['time_interval'].choices} |
|
137 |
if 'day' not in choice_ids: |
|
138 |
return |
|
139 |
for choice in self.time_intervals: |
|
140 |
if choice[0].strip('_') not in choice_ids: |
|
141 |
self.fields['time_interval'].choices.append(choice) |
combo/apps/dataviz/models.py | ||
---|---|---|
17 | 17 |
import copy |
18 | 18 |
import os |
19 | 19 |
import sys |
20 |
from datetime import date |
|
20 |
from collections import OrderedDict |
|
21 |
from datetime import date, datetime, timedelta |
|
21 | 22 | |
22 | 23 |
import pygal |
23 | 24 |
import pygal.util |
25 |
from dateutil.relativedelta import relativedelta |
|
24 | 26 |
from django.conf import settings |
25 | 27 |
from django.db import models, transaction |
28 |
from django.template.defaultfilters import date as format_date |
|
26 | 29 |
from django.urls import reverse |
27 | 30 |
from django.utils import timezone |
31 |
from django.utils.dates import WEEKDAYS |
|
28 | 32 |
from django.utils.encoding import force_text |
29 | 33 |
from django.utils.translation import gettext |
30 | 34 |
from django.utils.translation import ugettext_lazy as _ |
... | ... | |
324 | 328 |
self.add_data_to_chart(chart, data, y_labels) |
325 | 329 |
else: |
326 | 330 |
data = response['data'] |
331 | ||
332 |
interval = self.filter_params.get('time_interval', '') |
|
333 |
if interval == 'day' or interval.startswith('_'): |
|
334 |
self.aggregate_data(data, interval) |
|
335 | ||
327 | 336 |
chart.x_labels = data['x_labels'] |
328 | 337 |
chart.axis_count = min(len(data['series']), 2) |
329 | 338 |
self.prepare_chart(chart, width, height) |
... | ... | |
347 | 356 | |
348 | 357 |
return chart |
349 | 358 | |
359 |
@staticmethod |
|
360 |
def aggregate_data(data, interval): |
|
361 |
series_data = [serie['data'] for serie in data['series']] |
|
362 |
dates = [datetime.strptime(label, '%Y-%m-%d') for label in data['x_labels']] |
|
363 |
min_date, max_date = min(dates), max(dates) |
|
364 | ||
365 |
if interval == 'day': |
|
366 |
x_labels = [ |
|
367 |
(min_date + timedelta(days=i)).strftime('%d-%m-%Y') |
|
368 |
for i in range((max_date - min_date).days + 1) |
|
369 |
] |
|
370 |
elif interval == '_month': |
|
371 |
month_difference = max_date.month - min_date.month + (max_date.year - min_date.year) * 12 |
|
372 |
x_labels = [ |
|
373 |
(min_date + relativedelta(months=i)).strftime('%m-%Y') for i in range(month_difference + 1) |
|
374 |
] |
|
375 |
elif interval == '_year': |
|
376 |
x_labels = [str(year) for year in range(min_date.year, max_date.year + 1)] |
|
377 |
elif interval == '_weekday': |
|
378 |
x_labels = [str(label) for label in WEEKDAYS.values()] |
|
379 | ||
380 |
aggregates = OrderedDict((label, [0] * len(series_data)) for label in x_labels) |
|
381 |
date_formats = {'day': 'd-m-Y', '_month': 'm-Y', '_year': 'Y', '_weekday': 'l'} |
|
382 |
for i, date in enumerate(dates): |
|
383 |
key = format_date(date, date_formats[interval]) |
|
384 |
for j in range(len(series_data)): |
|
385 |
aggregates[key][j] += series_data[j][i] or 0 |
|
386 | ||
387 |
data['x_labels'] = x_labels |
|
388 |
for i, serie in enumerate(data['series']): |
|
389 |
serie['data'] = [values[i] for values in aggregates.values()] |
|
390 | ||
350 | 391 |
def get_filter_params(self): |
351 | 392 |
params = self.filter_params.copy() |
352 | 393 |
now = timezone.now().date() |
... | ... | |
365 | 406 |
params['start'] = self.time_range_start |
366 | 407 |
if self.time_range_end: |
367 | 408 |
params['end'] = self.time_range_end |
409 |
if 'time_interval' in params and params['time_interval'].startswith('_'): |
|
410 |
params['time_interval'] = 'day' |
|
368 | 411 |
return params |
369 | 412 | |
370 | 413 |
def parse_response(self, response, chart): |
tests/test_dataviz.py | ||
---|---|---|
318 | 318 |
'name': '404 not found stat', |
319 | 319 |
'id': 'not-found', |
320 | 320 |
}, |
321 |
{ |
|
322 |
'url': 'https://authentic.example.com/api/statistics/daily/', |
|
323 |
'name': 'daily discontinuous serie', |
|
324 |
'id': 'daily', |
|
325 |
"filters": [ |
|
326 |
{ |
|
327 |
"default": "day", |
|
328 |
"id": "time_interval", |
|
329 |
"label": "Time interval", |
|
330 |
"options": [ |
|
331 |
{"id": "day", "label": "Day"}, |
|
332 |
], |
|
333 |
"required": True, |
|
334 |
} |
|
335 |
], |
|
336 |
}, |
|
321 | 337 |
] |
322 | 338 |
} |
323 | 339 | |
... | ... | |
352 | 368 |
return {'content': json.dumps(response), 'request': request, 'status_code': 200} |
353 | 369 |
if url.path == '/api/statistics/not-found/': |
354 | 370 |
return {'content': b'', 'request': request, 'status_code': 404} |
371 |
if url.path == '/api/statistics/daily/': |
|
372 |
response = { |
|
373 |
'data': { |
|
374 |
'series': [ |
|
375 |
{'data': [None, 1, 16, 2], 'label': 'Serie 1'}, |
|
376 |
{'data': [2, 2, 1, None], 'label': 'Serie 2'}, |
|
377 |
], |
|
378 |
'x_labels': ['2020-10-06', '2020-10-13', '2020-11-30', '2022-02-01'], |
|
379 |
}, |
|
380 |
} |
|
381 |
return {'content': json.dumps(response), 'request': request, 'status_code': 200} |
|
355 | 382 | |
356 | 383 | |
357 | 384 |
@pytest.fixture |
... | ... | |
1057 | 1084 |
('day', False, 'Day'), |
1058 | 1085 |
('month', True, 'Month'), |
1059 | 1086 |
('year', False, 'Year'), |
1087 |
('_weekday', False, 'Week day'), |
|
1060 | 1088 |
] |
1061 | 1089 | |
1062 | 1090 |
ou_field = resp.form[field_prefix + 'ou'] |
... | ... | |
1350 | 1378 |
cell.check_validity() |
1351 | 1379 |
validity_info = ValidityInfo.objects.latest('pk') |
1352 | 1380 |
assert validity_info.invalid_reason_code == 'statistic_data_not_found' |
1381 | ||
1382 | ||
1383 |
@with_httmock(new_api_mock) |
|
1384 |
def test_chartng_cell_new_api_aggregation(new_api_statistics, app, admin_user, nocache): |
|
1385 |
page = Page.objects.create(title='One', slug='index') |
|
1386 |
cell = ChartNgCell(page=page, order=1, placeholder='content') |
|
1387 |
cell.statistic = Statistic.objects.get(slug='daily') |
|
1388 |
cell.save() |
|
1389 | ||
1390 |
app = login(app) |
|
1391 |
resp = app.get('/manage/pages/%s/' % page.id) |
|
1392 |
time_interval_field = resp.form['cdataviz_chartngcell-%s-time_interval' % cell.id] |
|
1393 |
assert time_interval_field.value == 'day' |
|
1394 |
assert time_interval_field.options == [ |
|
1395 |
('day', True, 'Day'), |
|
1396 |
('_month', False, 'Month'), |
|
1397 |
('_year', False, 'Year'), |
|
1398 |
('_weekday', False, 'Week day'), |
|
1399 |
] |
|
1400 |
resp.form.submit() |
|
1401 |
cell.refresh_from_db() |
|
1402 | ||
1403 |
chart = cell.get_chart() |
|
1404 |
assert len(chart.x_labels) == 484 |
|
1405 |
assert chart.x_labels[:3] == ['06-10-2020', '07-10-2020', '08-10-2020'] |
|
1406 |
assert chart.x_labels[-3:] == ['30-01-2022', '31-01-2022', '01-02-2022'] |
|
1407 |
assert chart.raw_series[0][0][:8] == [0, 0, 0, 0, 0, 0, 0, 1] |
|
1408 |
assert chart.raw_series[1][0][:8] == [2, 0, 0, 0, 0, 0, 0, 2] |
|
1409 | ||
1410 |
time_interval_field.value = '_month' |
|
1411 |
resp.form.submit() |
|
1412 |
cell.refresh_from_db() |
|
1413 | ||
1414 |
chart = cell.get_chart() |
|
1415 |
assert 'time_interval=day' in new_api_mock.call['requests'][1].url |
|
1416 |
assert len(chart.x_labels) == 17 |
|
1417 |
assert chart.x_labels[:3] == ['10-2020', '11-2020', '12-2020'] |
|
1418 |
assert chart.x_labels[-3:] == ['12-2021', '01-2022', '02-2022'] |
|
1419 |
assert chart.raw_series == [ |
|
1420 |
([1, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2], {'title': 'Serie 1'}), |
|
1421 |
([4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], {'title': 'Serie 2'}), |
|
1422 |
] |
|
1423 | ||
1424 |
time_interval_field.value = '_year' |
|
1425 |
resp.form.submit() |
|
1426 |
cell.refresh_from_db() |
|
1427 | ||
1428 |
chart = cell.get_chart() |
|
1429 |
assert 'time_interval=day' in new_api_mock.call['requests'][2].url |
|
1430 |
assert chart.x_labels == ['2020', '2021', '2022'] |
|
1431 |
assert chart.raw_series == [ |
|
1432 |
([17, 0, 2], {'title': 'Serie 1'}), |
|
1433 |
([5, 0, 0], {'title': 'Serie 2'}), |
|
1434 |
] |
|
1435 | ||
1436 |
time_interval_field.value = '_weekday' |
|
1437 |
resp.form.submit() |
|
1438 |
cell.refresh_from_db() |
|
1439 | ||
1440 |
chart = cell.get_chart() |
|
1441 |
assert 'time_interval=day' in new_api_mock.call['requests'][3].url |
|
1442 |
assert chart.x_labels == ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] |
|
1443 |
assert chart.raw_series == [ |
|
1444 |
([16, 3, 0, 0, 0, 0, 0], {'title': 'Serie 1'}), |
|
1445 |
([1, 4, 0, 0, 0, 0, 0], {'title': 'Serie 2'}), |
|
1446 |
] |
|
1353 |
- |