wcs/wcs/statistics/views.py

398 lines
16 KiB
Python

# w.c.s. - web application for online forms
# Copyright (C) 2005-2021 Entr'ouvert
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
import collections
from django.http import HttpResponseBadRequest, HttpResponseForbidden, JsonResponse
from django.urls import reverse
from django.views.generic import View
from wcs import sql
from wcs.api_utils import is_url_signed
from wcs.backoffice.data_management import CardPage
from wcs.backoffice.management import FormPage
from wcs.carddef import CardDef
from wcs.categories import Category
from wcs.formdef import FormDef
from wcs.qommon import _, misc
from wcs.qommon.misc import C_
from wcs.qommon.storage import Equal, Or, StrictNotEqual
class RestrictedView(View):
def dispatch(self, *args, **kwargs):
if not is_url_signed():
return HttpResponseForbidden()
return super().dispatch(*args, **kwargs)
class IndexView(RestrictedView):
def get(self, request, *args, **kwargs):
categories = Category.select()
categories.sort(key=lambda x: misc.simplify(x.name))
category_options = [{'id': '_all', 'label': C_('categories|All')}] + [
{'id': x.url_name, 'label': x.name} for x in categories
]
return JsonResponse(
{
'data': [
{
'name': _('Forms Count'),
'url': request.build_absolute_uri(reverse('api-statistics-forms-count')),
'id': 'forms_counts',
'filters': [
{
'id': 'time_interval',
'label': _('Interval'),
'options': [
{
'id': 'month',
'label': _('Month'),
},
{
'id': 'year',
'label': _('Year'),
},
{
'id': 'weekday',
'label': _('Week day'),
},
{
'id': 'hour',
'label': _('Hour'),
},
],
'required': True,
'default': 'month',
},
{
'id': 'category',
'label': _('Category'),
'options': category_options,
'required': True,
'default': '_all',
'deprecated': True,
'deprecation_hint': _(
'Category should now be selected using the Form field below.'
),
},
{
'id': 'form',
'label': _('Form'),
'options': self.get_form_options(FormDef),
'required': True,
'default': '_all',
'has_subfilters': True,
},
],
},
{
'name': _('Cards Count'),
'url': request.build_absolute_uri(reverse('api-statistics-cards-count')),
'id': 'cards_counts',
'filters': [
{
'id': 'time_interval',
'label': _('Interval'),
'options': [
{
'id': 'month',
'label': _('Month'),
},
{
'id': 'year',
'label': _('Year'),
},
{
'id': 'weekday',
'label': _('Week day'),
},
{
'id': 'hour',
'label': _('Hour'),
},
],
'required': True,
'default': 'month',
},
{
'id': 'form',
'label': _('Card'),
'options': self.get_form_options(CardDef, include_all_option=False),
'required': True,
'has_subfilters': True,
},
],
},
]
}
)
@staticmethod
def get_form_options(formdef_class, include_all_option=True):
all_forms_option = [{'id': '_all', 'label': _('All Forms')}]
forms = formdef_class.select(lightweight=True)
forms.sort(key=lambda x: misc.simplify(x.name))
forms_with_category = [x for x in forms if x.category]
if not forms_with_category:
form_options = [{'id': x.url_name, 'label': x.name} for x in forms]
return all_forms_option + form_options if include_all_option else form_options
form_options = collections.defaultdict(list)
for x in forms_with_category:
if x.category.name not in form_options and include_all_option:
form_options[x.category.name] = [
{
'id': 'category:' + x.category.url_name,
'label': _('All forms of category %s') % x.category.name,
}
]
form_options[x.category.name].append({'id': x.url_name, 'label': x.name})
form_options = sorted(
((category, forms) for category, forms in form_options.items()), key=lambda x: misc.simplify(x[0])
)
forms_without_category_options = [
{'id': x.url_name, 'label': x.name} for x in forms if not x.category
]
if forms_without_category_options:
form_options.append((_('Misc'), forms_without_category_options))
if include_all_option:
form_options = [(None, all_forms_option)] + form_options
return form_options
class FormsCountView(RestrictedView):
formdef_class = FormDef
formpage_class = FormPage
has_global_count_support = True
label = _('Forms Count')
def get(self, request, *args, **kwargs):
time_interval = request.GET.get('time_interval', 'month')
totals_kwargs = {
'period_start': request.GET.get('start'),
'period_end': request.GET.get('end'),
'criterias': [],
}
category_slug = request.GET.get('category', '_all')
formdef_slug = request.GET.get('form', '_all' if self.has_global_count_support else '_nothing')
group_by = request.GET.get('group-by')
subfilters = []
if formdef_slug != '_all' and not formdef_slug.startswith('category:'):
try:
formdef = self.formdef_class.get_by_urlname(formdef_slug, ignore_migration=True)
except KeyError:
return HttpResponseBadRequest('invalid form')
form_page = self.formpage_class(formdef=formdef, update_breadcrumbs=False)
# formdef_klass is a fake criteria, it will be used in time interval functions
# to switch to appropriate class, it must appear before formdef_id.
totals_kwargs['criterias'].append(Equal('formdef_klass', self.formdef_class))
totals_kwargs['criterias'].append(Equal('formdef_id', formdef.id))
totals_kwargs['criterias'].extend(self.get_filters_criterias(formdef, form_page))
if group_by:
group_by_field = self.get_group_by_field(form_page, group_by)
if group_by_field and group_by_field.type == 'status':
totals_kwargs['group_by'] = 'status'
elif group_by_field:
totals_kwargs['group_by'] = sql.get_field_id(group_by_field)
subfilters = self.get_subfilters(form_page)
else:
totals_kwargs['criterias'].append(StrictNotEqual('status', 'draft'))
if formdef_slug.startswith('category:'):
category_slug = formdef_slug.split(':', 1)[1]
if category_slug != '_all':
try:
category = Category.get_by_urlname(category_slug)
except KeyError:
if category_slug.isdigit(): # legacy
totals_kwargs['criterias'].append(Equal('category_id', category_slug))
else:
return HttpResponseBadRequest('invalid category')
else:
totals_kwargs['criterias'].append(Equal('category_id', category.id))
time_interval_methods = {
'month': sql.get_monthly_totals,
'year': sql.get_yearly_totals,
'weekday': sql.get_weekday_totals,
'hour': sql.get_hour_totals,
}
if time_interval in time_interval_methods:
totals = time_interval_methods[time_interval](**totals_kwargs)
else:
return HttpResponseBadRequest('invalid time_interval parameter')
if 'group_by' not in totals_kwargs:
x_labels = [x[0] for x in totals]
series = [{'label': self.label, 'data': [x[1] for x in totals]}]
else:
x_labels, series = self.get_grouped_data(totals, group_by_field, formdef, form_page)
return JsonResponse(
{'data': {'x_labels': x_labels, 'series': series, 'subfilters': subfilters}, 'err': 0}
)
def get_filters_criterias(self, formdef, form_page):
criterias = form_page.get_criterias_from_query()
selected_status = self.request.GET.get('filter-status')
applied_filters = None
if selected_status and selected_status != '_all':
if selected_status == 'pending':
applied_filters = ['wf-%s' % x.id for x in formdef.workflow.get_not_endpoint_status()]
elif selected_status == 'done':
applied_filters = ['wf-%s' % x.id for x in formdef.workflow.get_endpoint_status()]
else:
try:
formdef.workflow.get_status(selected_status)
applied_filters = ['wf-%s' % selected_status]
except KeyError:
pass
if applied_filters:
criterias.append(Or([Equal('status', x) for x in applied_filters]))
else:
criterias = [StrictNotEqual('status', 'draft')] + criterias
return criterias
@staticmethod
def get_subfilters(form_page):
subfilters = []
field_choices = []
for field in form_page.get_formdef_fields():
if not getattr(field, 'include_in_statistics', False) or not field.contextual_varname:
continue
field_key = 'filter-%s' % field.contextual_varname
field.required = False
if field.type == 'status':
waitpoint_status = form_page.formdef.workflow.get_waitpoint_status()
if not waitpoint_status:
continue
field.required = True
field.default_filter_value = '_all'
options = [
('_all', _('All')),
('pending', C_('statistics|Open')),
('done', _('Done')),
]
for status in waitpoint_status:
options.append((status.id, status.name))
elif field.type in ('item', 'items'):
options = form_page.get_item_filter_options(field, selected_filter='all', anonymised=True)
if not options:
continue
elif field.type == 'bool':
options = [('true', _('Yes')), ('false', _('No'))]
else:
continue
filter_description = {
'id': field_key,
'label': field.label,
'options': [{'id': x[0], 'label': x[1]} for x in options],
'required': field.required,
}
if hasattr(field, 'default_filter_value'):
filter_description['default'] = field.default_filter_value
subfilters.append(filter_description)
if not hasattr(field, 'block_field'):
field_choices.append((field.contextual_varname, field.label))
if field_choices:
subfilters.insert(
0,
{
'id': 'group-by',
'label': _('Group by'),
'options': [{'id': x[0], 'label': x[1]} for x in field_choices],
},
)
return subfilters
def get_group_by_field(self, form_page, group_by):
fields = [
x for x in form_page.get_formdef_fields() if getattr(x, 'contextual_varname', None) == group_by
]
if fields:
if not hasattr(fields[0], 'block_field'): # block fields are not supported
return fields[0]
def get_grouped_data(self, totals, group_by_field, formdef, form_page):
totals_by_time = collections.OrderedDict(
# time1: {group1: total_11, group2: total_12},
# time2: {group1: total_21}
)
seen_group_values = set(
# group1, group2
)
for total in totals:
totals_by_group = totals_by_time.setdefault(total[0], collections.Counter())
if len(total) == 2:
# ignore empty value used to fill time gaps
continue
groups = total[1]
if not isinstance(groups, list):
groups = [groups]
for group in groups:
totals_by_group[group] += total[2]
seen_group_values.add(group)
totals_by_group = {
# group1: [total_11, total_21],
# group2: [total_12, None],
}
for group in seen_group_values:
totals_by_group[group] = [totals.get(group) for totals in totals_by_time.values()]
group_labels = {}
if group_by_field.type == 'status':
group_labels = {'wf-%s' % status.id: status.name for status in formdef.workflow.possible_status}
elif group_by_field.type == 'bool':
group_labels = {True: _('Yes'), False: _('No')}
elif group_by_field.type in ('item', 'items'):
options = form_page.get_item_filter_options(
group_by_field, selected_filter='all', anonymised=True
)
group_labels = {option[0]: option[1] for option in options}
group_labels[None] = _('None')
x_labels = list(totals_by_time)
series = [
{'label': group_labels.get(group, group), 'data': data} for group, data in totals_by_group.items()
]
series.sort(key=lambda x: x['label'].lower())
return x_labels, series
class CardsCountView(FormsCountView):
formdef_class = CardDef
formpage_class = CardPage
has_global_count_support = False
label = _('Cards Count')