Skip to content

Commit

Permalink
Feature:support alter relativedelta field on admin
Browse files Browse the repository at this point in the history
  • Loading branch information
ChandlerBent committed May 22, 2019
1 parent 70045be commit 638a06c
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 116 deletions.
18 changes: 18 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ perform arithmetic on them within the database.
Usage
-----

Setup:

.. code:: python
INSTALLED_APPS = [
'...',
'relativedeltafield',
'...'
]
Using the field is straightforward. You can add the field to your
model like so:

Expand Down Expand Up @@ -79,6 +89,14 @@ sure that after a ``save()``, your fields are both normalized
and validated.


You can also use on admin:

.. code:: python
from django.contrib import admin
admin.site.register(MyModel)
Limitations and pitfalls
------------------------

Expand Down
302 changes: 186 additions & 116 deletions relativedeltafield/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.forms.widgets import MultiWidget, TimeInput, NumberInput
from django import forms

from datetime import timedelta
from datetime import time
from dateutil.relativedelta import relativedelta

# This is not quite ISO8601, as it allows the SQL/Postgres extension
Expand All @@ -26,127 +29,194 @@

# Parse ISO8601 timespec
def parse_relativedelta(str):
m = iso8601_duration_re.match(str)
if m:
args = {}
for k, v in m.groupdict().items():
if v is None:
args[k] = 0
elif '.' in v:
args[k] = float(v)
else:
args[k] = int(v)
return relativedelta(**args).normalized() if m else None
m = iso8601_duration_re.match(str)
if m:
args = {}
for k, v in m.groupdict().items():
if v is None:
args[k] = 0
elif '.' in v:
args[k] = float(v)
else:
args[k] = int(v)
return relativedelta(**args).normalized() if m else None

raise ValueError('Not a valid (extended) ISO8601 interval specification')
raise ValueError('Not a valid (extended) ISO8601 interval specification')


# Format ISO8601 timespec
def format_relativedelta(relativedelta):
result_big = ''
# TODO: We could always include all components, but that's kind of
# ugly, since one second would be formatted as 'P0Y0M0W0DT0M1S'
if relativedelta.years:
result_big += '{}Y'.format(relativedelta.years)
if relativedelta.months:
result_big += '{}M'.format(relativedelta.months)
if relativedelta.days:
result_big += '{}D'.format(relativedelta.days)

result_small = ''
if relativedelta.hours:
result_small += '{}H'.format(relativedelta.hours)
if relativedelta.minutes:
result_small += '{}M'.format(relativedelta.minutes)
# Microseconds is allowed here as a convenience, the user may have
# used normalized(), which can result in microseconds
if relativedelta.seconds:
seconds = relativedelta.seconds
if relativedelta.microseconds:
seconds += relativedelta.microseconds / 1000000.0
result_small += '{}S'.format(seconds)

if len(result_small) > 0:
return 'P{}T{}'.format(result_big, result_small)
elif len(result_big) == 0:
return 'P0D' # Doesn't matter much what field is zero, but just 'P' is invalid syntax, and so is ''
else:
return 'P{}'.format(result_big)

result_big = ''
# TODO: We could always include all components, but that's kind of
# ugly, since one second would be formatted as 'P0Y0M0W0DT0M1S'
if relativedelta.years:
result_big += '{}Y'.format(relativedelta.years)
if relativedelta.months:
result_big += '{}M'.format(relativedelta.months)
if relativedelta.days:
result_big += '{}D'.format(relativedelta.days)

result_small = ''
if relativedelta.hours:
result_small += '{}H'.format(relativedelta.hours)
if relativedelta.minutes:
result_small += '{}M'.format(relativedelta.minutes)
# Microseconds is allowed here as a convenience, the user may have
# used normalized(), which can result in microseconds
if relativedelta.seconds:
seconds = relativedelta.seconds
if relativedelta.microseconds:
seconds += relativedelta.microseconds / 1000000.0
result_small += '{}S'.format(seconds)

if len(result_small) > 0:
return 'P{}T{}'.format(result_big, result_small)
elif len(result_big) == 0:
return 'P0D' # Doesn't matter much what field is zero, but just 'P' is invalid syntax, and so is ''
else:
return 'P{}'.format(result_big)



class RelativeDetailInput(MultiWidget):
template_name = 'admin/widgets/relativedelta.html'

def __init__(self, attrs=None):
widgets = (
NumberInput(attrs=attrs),
NumberInput(attrs=attrs),
NumberInput(attrs=attrs),
TimeInput(attrs=attrs),
)
super().__init__(widgets)

def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context.update({
'year_label': _('Year'),
'month_label': _('Month'),
'day_label': _('Day'),
'time_label': _('Time'),
})
return context

def decompress(self, value):
if value:
return [value.years, value.months, value.days,
time(hour=value.hours, minute=value.minutes,
second=value.seconds,
microsecond=value.microseconds)]
return [0, 0, 0, time()]


class RelativeDetailFormField(forms.MultiValueField):
def __init__(self, **kwargs):
fields = [
forms.IntegerField(),
forms.IntegerField(),
forms.IntegerField(),
forms.TimeField(),
]
super().__init__(
fields=fields,
require_all_fields=False, **kwargs
)

def compress(self, data_list):
kwargs = {
'years': data_list[0],
'months': data_list[1],
'days': data_list[2],
'hours': data_list[3].hour,
'minutes': data_list[3].minute,
}
return relativedelta(**kwargs).normalized()


class RelativeDeltaField(models.Field):
"""Stores dateutil.relativedelta.relativedelta objects.
Uses INTERVAL on PostgreSQL.
"""
empty_strings_allowed = False
default_error_messages = {
'invalid': _("'%(value)s' value has an invalid format. It must be in "
"ISO8601 interval format.")
}
description = _("RelativeDelta")


def db_type(self, connection):
if connection.settings_dict['ENGINE'] in ('django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql', 'django.contrib.gis.db.backends.postgis'):
return 'interval'
else:
raise ValueError(_('RelativeDeltaField only supports PostgreSQL for storage'))


def to_python(self, value):
if value is None:
return value
elif isinstance(value, relativedelta):
return value.normalized()
elif isinstance(value, timedelta):
return (relativedelta() + value).normalized()

try:
return parse_relativedelta(value)
except (ValueError, TypeError):
raise ValidationError(
self.error_messages['invalid'],
code='invalid',
params={'value': value},
)


def get_db_prep_value(self, value, connection, prepared=False):
if value is None:
return value
else:
return format_relativedelta(self.to_python(value))


# This is a bit of a mindfuck. We have to cast the output field
# as text to bypass the standard deserialisation of PsycoPg2 to
# datetime.timedelta, which loses information. We then parse it
# ourselves in convert_relativedeltafield_value().
#
# We make it easier for ourselves by doing some formatting here,
# so that we don't need to rely on weird detection logic for the
# current value of IntervalStyle (PsycoPg2 actually gets this
# wrong; it only checks / sets DateStyle, but not IntervalStyle)
#
# We can't simply replace or remove PsycoPg2's parser, because
# that would mess with any existing Django DurationFields, since
# Django assumes PsycoPg2 returns pre-parsed datetime.timedeltas.
def select_format(self, compiler, sql, params):
fmt = 'to_char(%s, \'PYYYY"Y"MM"M"DD"DT"HH24"H"MI"M"SS.US"S"\')' % sql
return fmt, params


def get_db_converters(self, connection):
return [self.convert_relativedeltafield_value]


def convert_relativedeltafield_value(self, value, expression, connection, context):
if value is not None:
return parse_relativedelta(value)


def value_to_string(self, obj):
val = self.value_from_object(obj)
return '' if val is None else format_relativedelta(val)
"""Stores dateutil.relativedelta.relativedelta objects.
Uses INTERVAL on PostgreSQL.
"""
empty_strings_allowed = False
default_error_messages = {
'invalid': _("'%(value)s' value has an invalid format. It must be in "
"ISO8601 interval format.")
}
description = _("RelativeDelta")


def db_type(self, connection):
if connection.settings_dict['ENGINE'] in ('django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql', 'django.contrib.gis.db.backends.postgis'):
return 'interval'
else:
raise ValueError(_('RelativeDeltaField only supports PostgreSQL for storage'))

def to_python(self, value):
if value is None:
return value
elif isinstance(value, relativedelta):
return value.normalized()
elif isinstance(value, timedelta):
return (relativedelta() + value).normalized()
elif isinstance(value, list) and len(value) == 4:
return

try:
return parse_relativedelta(value)
except (ValueError, TypeError):
raise ValidationError(
self.error_messages['invalid'],
code='invalid',
params={'value': value},
)


def get_db_prep_value(self, value, connection, prepared=False):
if value is None:
return value
else:
return format_relativedelta(self.to_python(value))

def get_prep_value(self, value):
return value


# This is a bit of a mindfuck. We have to cast the output field
# as text to bypass the standard deserialisation of PsycoPg2 to
# datetime.timedelta, which loses information. We then parse it
# ourselves in convert_relativedeltafield_value().
#
# We make it easier for ourselves by doing some formatting here,
# so that we don't need to rely on weird detection logic for the
# current value of IntervalStyle (PsycoPg2 actually gets this
# wrong; it only checks / sets DateStyle, but not IntervalStyle)
#
# We can't simply replace or remove PsycoPg2's parser, because
# that would mess with any existing Django DurationFields, since
# Django assumes PsycoPg2 returns pre-parsed datetime.timedeltas.
def select_format(self, compiler, sql, params):
fmt = 'to_char(%s, \'PYYYY"Y"MM"M"DD"DT"HH24"H"MI"M"SS.US"S"\')' % sql
return fmt, params


def get_db_converters(self, connection):
return [self.convert_relativedeltafield_value]


def convert_relativedeltafield_value(self, value, expression, connection, context):
if value is not None:
return parse_relativedelta(value)


def value_to_string(self, obj):
val = self.value_from_object(obj)
return '' if val is None else format_relativedelta(val)


def formfield(self, **kwargs):
return super().formfield(**{
'form_class': RelativeDetailFormField,
'widget': RelativeDetailInput,
**kwargs,
})
6 changes: 6 additions & 0 deletions relativedeltafield/templates/admin/widgets/relativedelta.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<p>
{{ year_label }}: {% with widget=widget.subwidgets.0 %}{% include widget.template_name %}{% endwith %}
{{ month_label }}: {% with widget=widget.subwidgets.1 %}{% include widget.template_name %}{% endwith %}
{{ day_label }}: {% with widget=widget.subwidgets.2 %}{% include widget.template_name %}{% endwith %}<br>
{{ time_label }}: {% with widget=widget.subwidgets.3 %}{% include widget.template_name %}{% endwith %}
</p>

0 comments on commit 638a06c

Please sign in to comment.