0002-dataviz-handle-new-api-to-get-statistics-from-elsewh.patch
combo/apps/dataviz/__init__.py | ||
---|---|---|
41 | 41 |
if not settings.KNOWN_SERVICES: |
42 | 42 |
return |
43 | 43 | |
44 |
statistics_providers = settings.STATISTICS_PROVIDERS + ['bijoe'] |
|
44 | 45 |
start_update = timezone.now() |
45 |
bijoe_sites = settings.KNOWN_SERVICES.get('bijoe').items() |
|
46 |
for site_key, site_dict in bijoe_sites: |
|
47 |
result = requests.get('/visualization/json/', |
|
48 |
remote_service=site_dict, without_user=True, |
|
49 |
headers={'accept': 'application/json'}).json() |
|
50 |
for stat in result: |
|
51 |
Statistic.objects.update_or_create( |
|
52 |
slug=stat['slug'], |
|
53 |
site_slug=site_key, |
|
54 |
service_slug='bijoe', |
|
55 |
defaults={ |
|
56 |
'label': stat['name'], |
|
57 |
'url': stat['data-url'], |
|
58 |
'site_title': site_dict.get('title', ''), |
|
59 |
'available': True, |
|
60 |
} |
|
61 |
) |
|
46 |
for service in statistics_providers: |
|
47 |
sites = settings.KNOWN_SERVICES.get(service, {}).items() |
|
48 |
for site_key, site_dict in sites: |
|
49 |
if service == 'bijoe': |
|
50 |
result = requests.get('/visualization/json/', |
|
51 |
remote_service=site_dict, without_user=True, |
|
52 |
headers={'accept': 'application/json'}).json() |
|
53 |
else: |
|
54 |
result = requests.get('/api/statistics/', |
|
55 |
remote_service=site_dict, without_user=True, |
|
56 |
headers={'accept': 'application/json'}).json()['data'] |
|
57 | ||
58 |
for stat in result: |
|
59 |
Statistic.objects.update_or_create( |
|
60 |
slug=stat.get('slug') or stat['id'], |
|
61 |
site_slug=site_key, |
|
62 |
service_slug=service, |
|
63 |
defaults={ |
|
64 |
'label': stat['name'], |
|
65 |
'url': stat.get('data-url') or stat['url'], |
|
66 |
'site_title': site_dict.get('title', ''), |
|
67 |
'available': True, |
|
68 |
} |
|
69 |
) |
|
62 | 70 |
Statistic.objects.filter(last_update__lt=start_update).update(available=False) |
63 | 71 | |
64 | 72 |
combo/apps/dataviz/models.py | ||
---|---|---|
177 | 177 | |
178 | 178 |
@classmethod |
179 | 179 |
def is_enabled(self): |
180 |
return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
|
|
180 |
return settings.KNOWN_SERVICES.get('bijoe') or settings.STATISTICS_PROVIDERS
|
|
181 | 181 | |
182 | 182 |
def get_default_form_class(self): |
183 | 183 |
from .forms import ChartNgForm |
... | ... | |
202 | 202 |
else: |
203 | 203 |
ctx['table'] = chart.render_table( |
204 | 204 |
transpose=bool(chart.axis_count == 2), |
205 |
total=chart.compute_sum,
|
|
205 |
total=getattr(chart, 'compute_sum', True),
|
|
206 | 206 |
) |
207 | 207 |
ctx['table'] = ctx['table'].replace('<table>', '<table class="main">') |
208 | 208 |
return ctx |
... | ... | |
211 | 211 |
response = requests.get( |
212 | 212 |
self.statistic.url, |
213 | 213 |
cache_duration=300, |
214 |
remote_service='auto', |
|
215 |
without_user=True, |
|
214 | 216 |
raise_if_not_cached=raise_if_not_cached) |
215 | 217 |
response.raise_for_status() |
216 | 218 |
response = response.json() |
... | ... | |
229 | 231 |
'table': pygal.Bar, |
230 | 232 |
}[self.chart_type](config=pygal.Config(style=copy.copy(style))) |
231 | 233 | |
232 |
x_labels, y_labels, data = self.parse_response(response, chart) |
|
233 |
chart.x_labels = x_labels |
|
234 |
self.prepare_chart(chart, width, height) |
|
234 |
if self.statistic.service_slug == 'bijoe': |
|
235 |
x_labels, y_labels, data = self.parse_response(response, chart) |
|
236 |
chart.x_labels = x_labels |
|
237 |
self.prepare_chart(chart, width, height) |
|
235 | 238 | |
236 |
if chart.axis_count == 1: |
|
237 |
if self.hide_null_values: |
|
238 |
data = self.hide_values(chart, data) |
|
239 |
if self.sort_order != 'none': |
|
240 |
data = self.sort_values(chart, data) |
|
241 |
if chart.compute_sum and self.chart_type == 'table': |
|
242 |
data = self.add_total_to_line_table(chart, data) |
|
243 |
self.add_data_to_chart(chart, data, y_labels) |
|
239 |
if chart.axis_count == 1: |
|
240 |
data = self.process_one_dimensional_data(chart, data) |
|
241 |
self.add_data_to_chart(chart, data, y_labels) |
|
242 |
else: |
|
243 |
data = response['data'] |
|
244 |
chart.x_labels = data['x_labels'] |
|
245 |
chart.axis_count = min(len(data['series']), 2) |
|
246 |
self.prepare_chart(chart, width, height) |
|
247 | ||
248 |
if chart.axis_count == 1: |
|
249 |
data['series'][0]['data'] = self.process_one_dimensional_data( |
|
250 |
chart, data['series'][0]['data'] |
|
251 |
) |
|
252 |
if self.chart_type == 'pie': |
|
253 |
data["series"] = [ |
|
254 |
{"label": label, "data": [data]} |
|
255 |
for label, data in zip(chart.x_labels, data["series"][0]["data"]) |
|
256 |
if data |
|
257 |
] |
|
258 | ||
259 |
for serie in data['series']: |
|
260 |
chart.add(serie['label'], serie['data']) |
|
244 | 261 | |
245 | 262 |
return chart |
246 | 263 | |
... | ... | |
277 | 294 |
else: |
278 | 295 |
chart.axis_count = 2 |
279 | 296 | |
280 |
chart.show_legend = bool(len(response['axis']) > 1) |
|
281 | 297 |
chart.compute_sum = bool(response.get('measure') == 'integer' and chart.axis_count > 0) |
282 | 298 | |
283 | 299 |
formatter = self.get_value_formatter(response.get('unit'), response.get('measure')) |
... | ... | |
296 | 312 |
chart.config.explicit_size = True |
297 | 313 |
chart.config.js = [os.path.join(settings.STATIC_URL, 'js/pygal-tooltips.js')] |
298 | 314 | |
315 |
chart.show_legend = bool(chart.axis_count > 1) |
|
299 | 316 |
chart.truncate_legend = 30 |
300 | 317 |
# matplotlib tab10 palette |
301 | 318 |
chart.config.style.colors = ( |
... | ... | |
319 | 336 |
if width and width < 500: |
320 | 337 |
chart.truncate_legend = 15 |
321 | 338 | |
339 |
def process_one_dimensional_data(self, chart, data): |
|
340 |
if self.hide_null_values: |
|
341 |
data = self.hide_values(chart, data) |
|
342 |
if self.sort_order != 'none': |
|
343 |
data = self.sort_values(chart, data) |
|
344 |
if getattr(chart, 'compute_sum', True) and self.chart_type == 'table': |
|
345 |
data = self.add_total_to_line_table(chart, data) |
|
346 |
return data |
|
347 | ||
322 | 348 |
@staticmethod |
323 | 349 |
def hide_values(chart, data): |
324 | 350 |
x_labels, new_data = zip(*[(label, value) for label, value in zip(chart.x_labels, data) if value]) |
... | ... | |
340 | 366 |
def add_total_to_line_table(chart, data): |
341 | 367 |
# workaround pygal |
342 | 368 |
chart.compute_sum = False |
343 |
data.append(sum(data))
|
|
369 |
data.append(sum(x for x in data if x is not None))
|
|
344 | 370 |
chart.x_labels.append(gettext('Total')) |
345 | 371 |
return data |
346 | 372 |
combo/settings.py | ||
---|---|---|
350 | 350 |
# known services |
351 | 351 |
KNOWN_SERVICES = {} |
352 | 352 | |
353 |
# known services exposing statistics |
|
354 |
STATISTICS_PROVIDERS = [] |
|
355 | ||
353 | 356 |
# PWA Settings |
354 | 357 |
PWA_VAPID_PUBLIK_KEY = None |
355 | 358 |
PWA_VAPID_PRIVATE_KEY = None |
tests/test_dataviz.py | ||
---|---|---|
219 | 219 |
return {'content': json.dumps(response), 'request': request, 'status_code': 404} |
220 | 220 | |
221 | 221 | |
222 |
STATISTICS_LIST = { |
|
223 |
'data': [ |
|
224 |
{ |
|
225 |
'url': 'https://authentic.example.com/api/statistics/one-serie/', |
|
226 |
'name': 'One serie stat', |
|
227 |
'id': 'one-serie', |
|
228 |
}, |
|
229 |
{ |
|
230 |
'url': 'https://authentic.example.com/api/statistics/two-series/', |
|
231 |
'name': 'Two series stat', |
|
232 |
'id': 'two-series', |
|
233 |
}, |
|
234 |
{ |
|
235 |
'url': 'https://authentic.example.com/api/statistics/no-data/', |
|
236 |
'name': 'No data stat', |
|
237 |
'id': 'no-data', |
|
238 |
}, |
|
239 |
{ |
|
240 |
'url': 'https://authentic.example.com/api/statistics/not-found/', |
|
241 |
'name': '404 not found stat', |
|
242 |
'id': 'not-found', |
|
243 |
}, |
|
244 |
] |
|
245 |
} |
|
246 | ||
247 | ||
248 |
def new_api_mock(url, request): |
|
249 |
if url.path == '/visualization/json/': # nothing from bijoe |
|
250 |
return {'content': b'{}', 'request': request, 'status_code': 200} |
|
251 |
if url.path == '/api/statistics/': |
|
252 |
return {'content': json.dumps(STATISTICS_LIST), 'request': request, 'status_code': 200} |
|
253 |
if url.path == '/api/statistics/one-serie/': |
|
254 |
response = { |
|
255 |
'data': { |
|
256 |
'series': [ |
|
257 |
{'data': [None, 16, 2], 'label': 'Serie 1'}, |
|
258 |
], |
|
259 |
'x_labels': ['2020-10', '2020-11', '2020-12'], |
|
260 |
}, |
|
261 |
} |
|
262 |
return {'content': json.dumps(response), 'request': request, 'status_code': 200} |
|
263 |
if url.path == '/api/statistics/two-series/': |
|
264 |
response = { |
|
265 |
'data': { |
|
266 |
'series': [ |
|
267 |
{'data': [None, 16, 2], 'label': 'Serie 1'}, |
|
268 |
{'data': [2, 1, None], 'label': 'Serie 2'}, |
|
269 |
], |
|
270 |
'x_labels': ['2020-10', '2020-11', '2020-12'], |
|
271 |
}, |
|
272 |
} |
|
273 |
return {'content': json.dumps(response), 'request': request, 'status_code': 200} |
|
274 |
if url.path == '/api/statistics/no-data/': |
|
275 |
response = { |
|
276 |
'data': { |
|
277 |
'series': [ |
|
278 |
], |
|
279 |
'x_labels': [], |
|
280 |
}, |
|
281 |
} |
|
282 |
return {'content': json.dumps(response), 'request': request, 'status_code': 200} |
|
283 |
if url.path == '/api/statistics/not-found/': |
|
284 |
return {'content': b'', 'request': request, 'status_code': 404} |
|
285 | ||
286 | ||
222 | 287 |
@pytest.fixture |
223 | 288 |
@with_httmock(bijoe_mock) |
224 | 289 |
def statistics(settings): |
... | ... | |
232 | 297 |
assert Statistic.objects.count() == 11 |
233 | 298 | |
234 | 299 | |
300 |
@pytest.fixture |
|
301 |
@with_httmock(new_api_mock) |
|
302 |
def new_api_statistics(settings): |
|
303 |
settings.KNOWN_SERVICES = { |
|
304 |
'authentic': { |
|
305 |
'connection': { |
|
306 |
'title': 'Connection', |
|
307 |
'url': 'https://authentic.example.com', |
|
308 |
'secret': 'combo', |
|
309 |
'orig': 'combo', |
|
310 |
} |
|
311 |
} |
|
312 |
} |
|
313 |
settings.STATISTICS_PROVIDERS = ['authentic'] |
|
314 |
appconfig = apps.get_app_config('dataviz') |
|
315 |
appconfig.hourly() |
|
316 |
assert Statistic.objects.count() == 4 |
|
317 | ||
318 | ||
235 | 319 |
@with_httmock(bijoe_mock) |
236 | 320 |
def test_chartng_cell(app, statistics): |
237 | 321 |
page = Page(title='One', slug='index') |
... | ... | |
339 | 423 |
chart = cell.get_chart() |
340 | 424 | |
341 | 425 | |
426 |
@with_httmock(new_api_mock) |
|
427 |
def test_chartng_cell_new_api(app, new_api_statistics): |
|
428 |
page = Page.objects.create(title='One', slug='index') |
|
429 |
cell = ChartNgCell(page=page, order=1) |
|
430 |
cell.statistic = Statistic.objects.get(slug='one-serie') |
|
431 |
cell.save() |
|
432 | ||
433 |
chart = cell.get_chart() |
|
434 |
assert chart.__class__.__name__ == 'Bar' |
|
435 |
assert chart.x_labels == ['2020-10', '2020-11', '2020-12'] |
|
436 |
assert chart.raw_series == [([None, 16, 2], {'title': 'Serie 1'},)] |
|
437 | ||
438 |
cell.chart_type = 'pie' |
|
439 |
chart = cell.get_chart() |
|
440 |
assert chart.__class__.__name__ == 'Pie' |
|
441 |
assert chart.x_labels == ['2020-10', '2020-11', '2020-12'] |
|
442 |
assert chart.raw_series == [ |
|
443 |
([16], {'title': '2020-11'}), |
|
444 |
([2], {'title': '2020-12'}), |
|
445 |
] |
|
446 | ||
447 |
cell.statistic = Statistic.objects.get(slug='two-series') |
|
448 |
cell.save() |
|
449 | ||
450 |
chart = cell.get_chart() |
|
451 |
assert chart.x_labels == ['2020-10', '2020-11', '2020-12'] |
|
452 |
assert chart.raw_series == [([None, 16, 2], {'title': 'Serie 1'}), ([2, 1, None], {'title': 'Serie 2'})] |
|
453 | ||
454 |
cell.statistic = Statistic.objects.get(slug='no-data') |
|
455 |
cell.save() |
|
456 | ||
457 |
chart = cell.get_chart() |
|
458 |
assert chart.x_labels == [] |
|
459 |
assert chart.raw_series == [] |
|
460 | ||
461 |
cell.statistic = Statistic.objects.get(slug='not-found') |
|
462 |
cell.save() |
|
463 |
with pytest.raises(HTTPError): |
|
464 |
chart = cell.get_chart() |
|
465 | ||
466 | ||
342 | 467 |
@with_httmock(bijoe_mock) |
343 | 468 |
def test_chartng_cell_hide_null_values(app, statistics): |
344 | 469 |
page = Page(title='One', slug='index') |
... | ... | |
424 | 549 |
] |
425 | 550 | |
426 | 551 | |
552 |
@with_httmock(new_api_mock) |
|
553 |
def test_chartng_cell_hide_null_values_new_api(app, new_api_statistics): |
|
554 |
page = Page.objects.create(title='One', slug='index') |
|
555 |
cell = ChartNgCell(page=page, order=1, hide_null_values=True) |
|
556 |
cell.statistic = Statistic.objects.get(slug='one-serie') |
|
557 |
cell.hide_null_values = True |
|
558 |
cell.save() |
|
559 | ||
560 |
chart = cell.get_chart() |
|
561 |
assert chart.x_labels == ['2020-11', '2020-12'] |
|
562 |
assert chart.raw_series == [([16, 2], {'title': 'Serie 1'},)] |
|
563 | ||
564 | ||
427 | 565 |
@with_httmock(bijoe_mock) |
428 | 566 |
def test_chartng_cell_sort_order_alpha(app, statistics): |
429 | 567 |
page = Page(title='One', slug='index') |
... | ... | |
594 | 732 |
] |
595 | 733 | |
596 | 734 | |
735 |
@with_httmock(new_api_mock) |
|
736 |
def test_chartng_cell_sort_order_new_api(app, new_api_statistics): |
|
737 |
page = Page.objects.create(title='One', slug='index') |
|
738 |
cell = ChartNgCell(page=page, order=1) |
|
739 |
cell.statistic = Statistic.objects.get(slug='one-serie') |
|
740 |
cell.sort_order = 'desc' |
|
741 |
cell.save() |
|
742 | ||
743 |
chart = cell.get_chart() |
|
744 |
assert chart.x_labels == ['2020-11', '2020-12', '2020-10'] |
|
745 |
assert chart.raw_series == [([16, 2, None], {'title': 'Serie 1'},)] |
|
746 | ||
747 | ||
597 | 748 |
@with_httmock(bijoe_mock) |
598 | 749 |
def test_chartng_cell_view(app, normal_user, statistics): |
599 | 750 |
page = Page(title='One', slug='index') |
... | ... | |
698 | 849 |
assert not 'cell' in resp.text |
699 | 850 | |
700 | 851 | |
852 |
@with_httmock(new_api_mock) |
|
853 |
def test_chartng_cell_view_new_api(app, normal_user, new_api_statistics): |
|
854 |
page = Page.objects.create(title='One', slug='index') |
|
855 |
cell = ChartNgCell(page=page, order=1, placeholder='content') |
|
856 |
cell.statistic = Statistic.objects.get(slug='one-serie') |
|
857 |
cell.save() |
|
858 | ||
859 |
location = '/api/dataviz/graph/%s/' % cell.id |
|
860 |
resp = app.get('/') |
|
861 |
assert 'min-height: 250px' in resp.text |
|
862 |
assert location in resp.text |
|
863 | ||
864 |
# table visualization |
|
865 |
cell.chart_type = 'table' |
|
866 |
cell.save() |
|
867 |
resp = app.get('/') |
|
868 |
assert '<td>18</td>' in resp.text |
|
869 | ||
870 |
# deleted visualization |
|
871 |
cell.statistic = Statistic.objects.get(slug='not-found') |
|
872 |
cell.save() |
|
873 |
resp = app.get(location) |
|
874 |
assert 'not found' in resp.text |
|
875 | ||
876 | ||
701 | 877 |
@with_httmock(bijoe_mock) |
702 | 878 |
def test_chartng_cell_manager(app, admin_user, statistics): |
703 | 879 |
page = Page(title='One', slug='index') |
... | ... | |
726 | 902 |
assert 'Unavailable Stat' in resp.text |
727 | 903 | |
728 | 904 | |
905 |
@with_httmock(new_api_mock) |
|
906 |
def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics): |
|
907 |
page = Page.objects.create(title='One', slug='index') |
|
908 |
cell = ChartNgCell(page=page, order=1, placeholder='content') |
|
909 |
cell.statistic = Statistic.objects.get(slug='one-serie') |
|
910 |
cell.save() |
|
911 | ||
912 |
app = login(app) |
|
913 |
resp = app.get('/manage/pages/%s/' % page.id) |
|
914 |
statistics_field = resp.form['cdataviz_chartngcell-%s-statistic' % cell.id] |
|
915 |
assert len(statistics_field.options) == 5 |
|
916 |
assert statistics_field.value == str(cell.statistic.pk) |
|
917 |
assert statistics_field.options[3][2] == 'Connection: One serie stat' |
|
918 | ||
919 | ||
729 | 920 |
@with_httmock(bijoe_mock) |
730 | 921 |
def test_table_cell(app, admin_user, statistics): |
731 | 922 |
page = Page(title='One', slug='index') |
... | ... | |
769 | 960 |
assert resp.text.count('Total') == 0 |
770 | 961 | |
771 | 962 | |
963 |
@with_httmock(new_api_mock) |
|
964 |
def test_table_cell_new_api(app, admin_user, new_api_statistics): |
|
965 |
page = Page.objects.create(title='One', slug='index') |
|
966 |
cell = ChartNgCell(page=page, order=1, placeholder='content') |
|
967 |
cell.statistic = Statistic.objects.get(slug='one-serie') |
|
968 |
cell.chart_type = 'table' |
|
969 |
cell.save() |
|
970 | ||
971 |
app = login(app) |
|
972 |
resp = app.get('/') |
|
973 |
assert resp.text.count('Total') == 1 |
|
974 | ||
975 |
cell.statistic = Statistic.objects.get(slug='two-series') |
|
976 |
cell.save() |
|
977 |
resp = app.get('/') |
|
978 |
assert '21' in resp.text |
|
979 |
assert resp.text.count('Total') == 2 |
|
980 | ||
981 | ||
772 | 982 |
def test_dataviz_hourly_unavailable_statistic(freezer, statistics): |
773 | 983 |
all_stats_count = Statistic.objects.count() |
774 | 984 |
assert Statistic.objects.filter(available=True).count() == all_stats_count |
... | ... | |
853 | 1063 |
assert cell.statistic.site_slug == 'plop' |
854 | 1064 |
assert cell.statistic.service_slug == 'bijoe' |
855 | 1065 |
assert cell.statistic.site_title == 'test' |
1066 | ||
1067 | ||
1068 |
@with_httmock(new_api_mock) |
|
1069 |
def test_dataviz_api_list_statistics(new_api_statistics): |
|
1070 |
statistic = Statistic.objects.get(slug='one-serie') |
|
1071 |
assert statistic.label == 'One serie stat' |
|
1072 |
assert statistic.site_slug == 'connection' |
|
1073 |
assert statistic.service_slug == 'authentic' |
|
1074 |
assert statistic.site_title == 'Connection' |
|
1075 |
assert statistic.url == 'https://authentic.example.com/api/statistics/one-serie/' |
|
1076 |
assert statistic.available |
|
856 |
- |