diff --git a/README.rst b/README.rst index e90778b..ca06ff4 100644 --- a/README.rst +++ b/README.rst @@ -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: @@ -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 ------------------------ diff --git a/relativedeltafield/__init__.py b/relativedeltafield/__init__.py index 8c5a91a..c1ff88d 100644 --- a/relativedeltafield/__init__.py +++ b/relativedeltafield/__init__.py @@ -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 @@ -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, + }) diff --git a/relativedeltafield/templates/admin/widgets/relativedelta.html b/relativedeltafield/templates/admin/widgets/relativedelta.html new file mode 100644 index 0000000..a229de4 --- /dev/null +++ b/relativedeltafield/templates/admin/widgets/relativedelta.html @@ -0,0 +1,6 @@ +
+ {{ 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 %}
+ {{ time_label }}: {% with widget=widget.subwidgets.3 %}{% include widget.template_name %}{% endwith %}
+