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 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
|
||||
|
|
|
@ -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.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):
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
2
setup.py
2
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})
|
||||
|
|
|
@ -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