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.
This commit is contained in:
Benjamin Dauvergne 2016-09-22 10:32:41 +02:00
parent 9fc688c31a
commit 626f5190dc
9 changed files with 262 additions and 15 deletions

123
bijoe/relative_time.py Normal file
View File

@ -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<years>[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<months>[1-9][0-9]*)+ +derniers +mois *',
'truncate': 'month',
'timedelta': {'months': '-months'},
},
{
'pattern': ' *le +mois +prochain *',
'truncate': 'month',
'timedelta': {'months': 1},
},
{
'pattern': ' *les +(?P<months>[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<quarters>[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

View File

@ -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

View File

@ -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);
}

View File

@ -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 += "<script>$(function () { bijoe_date_range('#%s'); });</script>" % _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):

View File

@ -14,14 +14,27 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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')

View File

@ -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])

View File

@ -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

View File

@ -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})

View File

@ -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)