From 626f5190dc845aadf9bc1e06455bdc142b42afda Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 22 Sep 2016 10:32:41 +0200 Subject: [PATCH] support relative date ranges (fixes #13245) Modify DateRangeWidget so that it can return relative date strings instead of ISO8691 date strings. Those strings are interpreted by a new class bijoe.relative_time.RelativeDate. --- bijoe/relative_time.py | 123 +++++++++++++++++++++++++++++ bijoe/schemas.py | 21 +++-- bijoe/static/js/bijoe.daterange.js | 18 +++++ bijoe/visualization/forms.py | 69 +++++++++++++++- bijoe/visualization/models.py | 15 +++- bijoe/visualization/utils.py | 4 +- bijoe/visualization/views.py | 9 ++- setup.py | 2 +- tests/test_relative_time.py | 16 ++++ 9 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 bijoe/relative_time.py create mode 100644 bijoe/static/js/bijoe.daterange.js create mode 100644 tests/test_relative_time.py diff --git a/bijoe/relative_time.py b/bijoe/relative_time.py new file mode 100644 index 0000000..f4ff279 --- /dev/null +++ b/bijoe/relative_time.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +import re +from datetime import date +from dateutil.relativedelta import relativedelta, MO +import isodate + + +class RelativeDate(date): + __TEMPLATES = [ + { + 'pattern': u' *cette +année *', + 'truncate': 'year', + }, + { + 'pattern': u' *l\'année +dernière *', + 'truncate': 'year', + 'timedelta': {'years': -1}, + }, + { + 'pattern': ' *les +(?P[1-9][0-9]*)+ +dernières +années*', + 'truncate': 'year', + 'timedelta': {'years': '-years'}, + }, + { + 'pattern': ' *ce +mois *', + 'truncate': 'month', + }, + { + 'pattern': ' *le +mois +dernier *', + 'truncate': 'month', + 'timedelta': {'months': -1}, + }, + { + 'pattern': ' *les +(?P[1-9][0-9]*)+ +derniers +mois *', + 'truncate': 'month', + 'timedelta': {'months': '-months'}, + }, + { + 'pattern': ' *le +mois +prochain *', + 'truncate': 'month', + 'timedelta': {'months': 1}, + }, + { + 'pattern': ' *les +(?P[1-9][0-9]*) +prochains +mois *', + 'truncate': 'month', + 'timedelta': {'months': '+months'}, + }, + { + 'pattern': ' *cette +semaine *', + 'truncate': 'week', + }, + { + 'pattern': ' *ce +trimestre *', + 'truncate': 'quarter', + }, + { + 'pattern': ' *le +dernier +trimestre *', + 'truncate': 'quarter', + 'timedelta': {'months': -3}, + }, + { + 'pattern': ' *les +(?P[1-9][0-9]*) +derniers +trimestres *', + 'truncate': 'quarter', + 'timedelta': {'months': '-3*quarters'}, + }, + { + 'pattern': ' *maintenant *', + }, + ] + + def __new__(cls, s, today=None): + s = s.strip() + try: + d = isodate.parse_date(s) + except isodate.ISO8601Error: + for template in cls.__TEMPLATES: + pattern = template['pattern'] + m = re.match(pattern, s) + if not m: + continue + d = today or date.today() + if 'truncate' in template: + if template['truncate'] == 'year': + d = d.replace(month=1, day=1) + if template['truncate'] == 'month': + d = d.replace(day=1) + if template['truncate'] == 'week': + d = d + relativedelta(weekday=MO(-1)) + if template['truncate'] == 'quarter': + d = d.replace(day=1) + d = d + relativedelta(months=-((d.month - 1) % 3)) + if 'timedelta' in template: + timedelta = template['timedelta'] + kwargs = {} + for key, group in timedelta.iteritems(): + if hasattr(group, 'encode'): # string like + sign = 1 + if group.startswith('+'): + group = group[1:] + elif group.startswith('-'): + sign = -1 + group = group[1:] + n = re.match('(\d+)\*(\w+)', group) + try: + if n: + value = int(n.group(1) * m.group(n.group(2))) + else: + value = sign * int(m.group(group)) + kwargs[key] = value + except IndexError: + raise RuntimeError('invalid template %r' % template) + else: + kwargs[key] = group + d += relativedelta(**kwargs) + break + else: + raise ValueError('invalid string %r' % s) + self = date.__new__(cls, d.year, d.month, d.day) + self.__string_repr = s + return self + + def __repr__(self): + return self.__string_repr diff --git a/bijoe/schemas.py b/bijoe/schemas.py index cb0492f..afe119e 100644 --- a/bijoe/schemas.py +++ b/bijoe/schemas.py @@ -20,6 +20,8 @@ import datetime import decimal import collections +from .relative_time import RelativeDate + TYPE_MAP = { 'duration': datetime.timedelta, @@ -196,19 +198,24 @@ class Dimension(Base): def build_filter(self, filter_values): if self.type == 'date': - assert len(filter_values) == 2 + assert isinstance(filter_values, dict) and set(filter_values.keys()) == set(['start', + 'end']) filters = [] values = [] + + def date_filter(tpl, filter_value): + if not isinstance(filter_value, (datetime.date, datetime.datetime)): + filter_value = RelativeDate(filter_value) + filters.append(tpl % (self.value, '%s')) + values.append(filter_value) try: - if filter_values[0]: - filters.append('%s >= %%s' % self.value) - values.append(filter_values[0]) + if filter_values['start']: + date_filter('%s >= %s', filter_values['start']) except IndexError: pass try: - if filter_values[1]: - filters.append('%s <= %%s' % self.value) - values.append(filter_values[1]) + if filter_values['end']: + date_filter('%s < %s', filter_values['end']) except IndexError: pass return ' AND '.join(filters), values diff --git a/bijoe/static/js/bijoe.daterange.js b/bijoe/static/js/bijoe.daterange.js new file mode 100644 index 0000000..b9b0a1e --- /dev/null +++ b/bijoe/static/js/bijoe.daterange.js @@ -0,0 +1,18 @@ +function bijoe_date_range(id) { + var $input1 = $(id + '_0'); + var $input2 = $(id + '_1'); + var $select = $(id + '_2'); + + function update() { + if ($select.val()) { + $input1.prop('disabled', true); + $input2.prop('disabled', true); + } else { + $input1.prop('disabled', false); + $input2.prop('disabled', false); + } + }; + update(); + + $select.on('change', update); +} diff --git a/bijoe/visualization/forms.py b/bijoe/visualization/forms.py index f7a8f52..bcf7191 100644 --- a/bijoe/visualization/forms.py +++ b/bijoe/visualization/forms.py @@ -18,6 +18,8 @@ from django import forms from django.utils.translation import ugettext as _ from django.forms import ModelForm, TextInput +from django.conf import settings + try: from django_select2.forms import Select2MultipleWidget @@ -41,6 +43,44 @@ class VisualizationForm(ModelForm): 'name': TextInput, } +DATE_RANGES = [ + { + 'value': '3_last_months', + 'label': _('3 last months'), + 'start': u"les 3 derniers mois", + 'end': u"maintenant", + }, + { + 'value': 'this_year', + 'label': _('this year'), + 'start': u"cette année", + 'end': u"maintenant", + }, + { + 'value': 'last_year', + 'label': _('last year'), + 'start': u'l\'année dernière', + 'end': u'cette année', + }, + { + 'value': 'this_quarter', + 'label': _('this quarter'), + 'start': u'ce trimestre', + 'end': "maintenant", + }, + { + 'value': 'last_quarter', + 'label': _('last quarter'), + 'start': u'le dernier trimestre', + 'end': u'ce trimestre', + }, +] + + +def get_date_range_choices(): + return [('', '---')] + [(r['value'], r['label']) + for r in getattr(settings, 'BIJOE_DATE_RANGES', DATE_RANGES)] + class DateRangeWidget(forms.MultiWidget): def __init__(self, attrs=None): @@ -53,13 +93,27 @@ class DateRangeWidget(forms.MultiWidget): widgets = ( forms.DateInput(attrs=attrs1), forms.DateInput(attrs=attrs2), + forms.Select(choices=get_date_range_choices()), ) super(DateRangeWidget, self).__init__(widgets, attrs=attrs) def decompress(self, value): if not value: - return None, None - return value + return None, None, None + for date_range in DATE_RANGES: + if (value['start'], value['end']) == (date_range['start'], date_range['end']): + return None, None, date_range['value'] + return value['start'], value['end'], None + + def render(self, name, value, attrs=None): + output = super(DateRangeWidget, self).render(name, value, attrs=attrs) + _id = self.build_attrs(attrs).get('id', None) + if _id: + output += "" % _id + return output + + class Media: + js = ('js/bijoe.daterange.js',) class DateRangeField(forms.MultiValueField): @@ -70,12 +124,21 @@ class DateRangeField(forms.MultiValueField): fields = ( forms.DateField(required=False), forms.DateField(required=False), + forms.ChoiceField(choices=get_date_range_choices(), required=False) ) super(DateRangeField, self).__init__(fields=fields, require_all_fields=False, *args, **kwargs) def compress(self, values): - return values + if not values: + return None + named_range = values[2] + if not named_range: + return {'start': values[0], 'end': values[1]} + for r in DATE_RANGES: + if r['value'] == named_range: + return {'start': r['start'], 'end': r['end']} + return {'start': None, 'end': None} class CubeForm(forms.Form): diff --git a/bijoe/visualization/models.py b/bijoe/visualization/models.py index 31465e2..8140ab7 100644 --- a/bijoe/visualization/models.py +++ b/bijoe/visualization/models.py @@ -14,14 +14,27 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import json +import datetime + from django.db import models from django.utils.translation import ugettext_lazy as _ from jsonfield import JSONField +class JSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, datetime.datetime): + return obj.isoformat() + if isinstance(obj, datetime.date): + return obj.isoformat() + # Let the base class default method raise the TypeError + return json.JSONEncoder.default(self, obj) + + class Visualization(models.Model): name = models.TextField(verbose_name=_('name')) - parameters = JSONField(verbose_name=_('parameters')) + parameters = JSONField(verbose_name=_('parameters'), encoder_class=JSONEncoder) class Meta: ordering = ('name', 'id') diff --git a/bijoe/visualization/utils.py b/bijoe/visualization/utils.py index fd00fda..42cbd17 100644 --- a/bijoe/visualization/utils.py +++ b/bijoe/visualization/utils.py @@ -80,6 +80,8 @@ class Visualization(object): l = [] for kw, values in self.filters.iteritems(): if values: + if isinstance(values, dict): + values = values.items() l.append('$'.join([kw] + sorted(map(unicode, values)))) l += [dim.name for dim in self.drilldown] l += [measure.name for measure in self.measures] @@ -109,7 +111,7 @@ class Visualization(object): return data def data(self): - return self.cube.query(self.filters.iteritems(), + return self.cube.query(self.filters.items(), [dim.name for dim in self.drilldown], [measure.name for measure in self.measures]) diff --git a/bijoe/visualization/views.py b/bijoe/visualization/views.py index 762cd34..7af6423 100644 --- a/bijoe/visualization/views.py +++ b/bijoe/visualization/views.py @@ -151,11 +151,16 @@ class VisualizationView(views.AuthorizationMixin, ODSMixin, CubeDisplayMixin, def get_context_data(self, **kwargs): ctx = super(VisualizationView, self).get_context_data(**kwargs) - ctx['form'] = forms.CubeForm(cube=self.cube, initial={ + initial = { 'representation': self.visualization.representation, 'measures': [m.name for m in self.visualization.measures], 'drilldown': [d.name for d in self.visualization.drilldown], - }) + } + for key, value in self.visualization.filters.iteritems() or []: + if isinstance(value, list): + value = tuple(value) + initial['filter__%s' % key] = value + ctx['form'] = forms.CubeForm(cube=self.cube, initial=initial) path = reverse('visualization-iframe', args=self.args, kwargs=self.kwargs) signature = hashlib.sha1(path + settings.SECRET_KEY).hexdigest() path += '?signature=' + signature diff --git a/setup.py b/setup.py index 362cf08..905d31c 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,6 @@ setup(name="bijoe", packages=find_packages(), include_package_data=True, install_requires=['requests', 'django', 'psycopg2', 'isodate', 'Django-Select2<5', - 'XStatic-ChartNew.js', 'gadjo', 'django-jsonfield<1.0.0'], + 'XStatic-ChartNew.js', 'gadjo', 'django-jsonfield<1.0.0', 'python-dateutil'], scripts=['bijoe-ctl'], cmdclass={'sdist': eo_sdist}) diff --git a/tests/test_relative_time.py b/tests/test_relative_time.py new file mode 100644 index 0000000..ac5e17b --- /dev/null +++ b/tests/test_relative_time.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from datetime import date +from bijoe.relative_time import RelativeDate + + +def test_relative_date(): + today = date(2016, 3, 3) + assert RelativeDate(u'cette année', today=today) == date(2016, 1, 1) + assert RelativeDate(u'ce mois', today=today) == date(2016, 3, 1) + assert RelativeDate(u'le mois dernier', today=today) == date(2016, 2, 1) + assert RelativeDate(u'les 4 derniers mois', today=today) == date(2015, 11, 1) + assert RelativeDate(u'le mois prochain', today=today) == date(2016, 4, 1) + assert RelativeDate(u'les 3 prochains mois', today=today) == date(2016, 6, 1) + assert RelativeDate(u' cette semaine', today=today) == date(2016, 2, 29) + assert RelativeDate(u' maintenant', today=today) == today + assert RelativeDate(u'2016-01-01', today=today) == date(2016, 1, 1)