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:
parent
9fc688c31a
commit
626f5190dc
|
@ -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
|
|
@ -20,6 +20,8 @@ import datetime
|
||||||
import decimal
|
import decimal
|
||||||
import collections
|
import collections
|
||||||
|
|
||||||
|
from .relative_time import RelativeDate
|
||||||
|
|
||||||
|
|
||||||
TYPE_MAP = {
|
TYPE_MAP = {
|
||||||
'duration': datetime.timedelta,
|
'duration': datetime.timedelta,
|
||||||
|
@ -196,19 +198,24 @@ class Dimension(Base):
|
||||||
|
|
||||||
def build_filter(self, filter_values):
|
def build_filter(self, filter_values):
|
||||||
if self.type == 'date':
|
if self.type == 'date':
|
||||||
assert len(filter_values) == 2
|
assert isinstance(filter_values, dict) and set(filter_values.keys()) == set(['start',
|
||||||
|
'end'])
|
||||||
filters = []
|
filters = []
|
||||||
values = []
|
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:
|
try:
|
||||||
if filter_values[0]:
|
if filter_values['start']:
|
||||||
filters.append('%s >= %%s' % self.value)
|
date_filter('%s >= %s', filter_values['start'])
|
||||||
values.append(filter_values[0])
|
|
||||||
except IndexError:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
if filter_values[1]:
|
if filter_values['end']:
|
||||||
filters.append('%s <= %%s' % self.value)
|
date_filter('%s < %s', filter_values['end'])
|
||||||
values.append(filter_values[1])
|
|
||||||
except IndexError:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
return ' AND '.join(filters), values
|
return ' AND '.join(filters), values
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -18,6 +18,8 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.forms import ModelForm, TextInput
|
from django.forms import ModelForm, TextInput
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from django_select2.forms import Select2MultipleWidget
|
from django_select2.forms import Select2MultipleWidget
|
||||||
|
|
||||||
|
@ -41,6 +43,44 @@ class VisualizationForm(ModelForm):
|
||||||
'name': TextInput,
|
'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):
|
class DateRangeWidget(forms.MultiWidget):
|
||||||
def __init__(self, attrs=None):
|
def __init__(self, attrs=None):
|
||||||
|
@ -53,13 +93,27 @@ class DateRangeWidget(forms.MultiWidget):
|
||||||
widgets = (
|
widgets = (
|
||||||
forms.DateInput(attrs=attrs1),
|
forms.DateInput(attrs=attrs1),
|
||||||
forms.DateInput(attrs=attrs2),
|
forms.DateInput(attrs=attrs2),
|
||||||
|
forms.Select(choices=get_date_range_choices()),
|
||||||
)
|
)
|
||||||
super(DateRangeWidget, self).__init__(widgets, attrs=attrs)
|
super(DateRangeWidget, self).__init__(widgets, attrs=attrs)
|
||||||
|
|
||||||
def decompress(self, value):
|
def decompress(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
return None, None
|
return None, None, None
|
||||||
return value
|
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):
|
class DateRangeField(forms.MultiValueField):
|
||||||
|
@ -70,12 +124,21 @@ class DateRangeField(forms.MultiValueField):
|
||||||
fields = (
|
fields = (
|
||||||
forms.DateField(required=False),
|
forms.DateField(required=False),
|
||||||
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,
|
super(DateRangeField, self).__init__(fields=fields, require_all_fields=False, *args,
|
||||||
**kwargs)
|
**kwargs)
|
||||||
|
|
||||||
def compress(self, values):
|
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):
|
class CubeForm(forms.Form):
|
||||||
|
|
|
@ -14,14 +14,27 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from jsonfield import JSONField
|
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):
|
class Visualization(models.Model):
|
||||||
name = models.TextField(verbose_name=_('name'))
|
name = models.TextField(verbose_name=_('name'))
|
||||||
parameters = JSONField(verbose_name=_('parameters'))
|
parameters = JSONField(verbose_name=_('parameters'), encoder_class=JSONEncoder)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('name', 'id')
|
ordering = ('name', 'id')
|
||||||
|
|
|
@ -80,6 +80,8 @@ class Visualization(object):
|
||||||
l = []
|
l = []
|
||||||
for kw, values in self.filters.iteritems():
|
for kw, values in self.filters.iteritems():
|
||||||
if values:
|
if values:
|
||||||
|
if isinstance(values, dict):
|
||||||
|
values = values.items()
|
||||||
l.append('$'.join([kw] + sorted(map(unicode, values))))
|
l.append('$'.join([kw] + sorted(map(unicode, values))))
|
||||||
l += [dim.name for dim in self.drilldown]
|
l += [dim.name for dim in self.drilldown]
|
||||||
l += [measure.name for measure in self.measures]
|
l += [measure.name for measure in self.measures]
|
||||||
|
@ -109,7 +111,7 @@ class Visualization(object):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def data(self):
|
def data(self):
|
||||||
return self.cube.query(self.filters.iteritems(),
|
return self.cube.query(self.filters.items(),
|
||||||
[dim.name for dim in self.drilldown],
|
[dim.name for dim in self.drilldown],
|
||||||
[measure.name for measure in self.measures])
|
[measure.name for measure in self.measures])
|
||||||
|
|
||||||
|
|
|
@ -151,11 +151,16 @@ class VisualizationView(views.AuthorizationMixin, ODSMixin, CubeDisplayMixin,
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super(VisualizationView, self).get_context_data(**kwargs)
|
ctx = super(VisualizationView, self).get_context_data(**kwargs)
|
||||||
ctx['form'] = forms.CubeForm(cube=self.cube, initial={
|
initial = {
|
||||||
'representation': self.visualization.representation,
|
'representation': self.visualization.representation,
|
||||||
'measures': [m.name for m in self.visualization.measures],
|
'measures': [m.name for m in self.visualization.measures],
|
||||||
'drilldown': [d.name for d in self.visualization.drilldown],
|
'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)
|
path = reverse('visualization-iframe', args=self.args, kwargs=self.kwargs)
|
||||||
signature = hashlib.sha1(path + settings.SECRET_KEY).hexdigest()
|
signature = hashlib.sha1(path + settings.SECRET_KEY).hexdigest()
|
||||||
path += '?signature=' + signature
|
path += '?signature=' + signature
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -55,6 +55,6 @@ setup(name="bijoe",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['requests', 'django', 'psycopg2', 'isodate', 'Django-Select2<5',
|
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'],
|
scripts=['bijoe-ctl'],
|
||||||
cmdclass={'sdist': eo_sdist})
|
cmdclass={'sdist': eo_sdist})
|
||||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue