398 lines
16 KiB
Python
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')
|