diff --git a/colegend/lib/formfields.py b/colegend/lib/formfields.py index 8ca8aedb..4ab3724b 100644 --- a/colegend/lib/formfields.py +++ b/colegend/lib/formfields.py @@ -1,4 +1,8 @@ +import datetime +from django import forms +from django.core.exceptions import ValidationError from floppyforms import CharField +from lib.intuitive_duration import intuitive_duration_string, parse_intuitive_duration from lib.validators import PhoneValidator __author__ = 'eraldo' @@ -9,3 +13,21 @@ class PhoneField(CharField): def __init__(self, *args, **kwargs): super(PhoneField, self).__init__(*args, min_length=10, max_length=16, **kwargs) + + +class IntuitiveDurationFormField(forms.DurationField): + + def prepare_value(self, value): + if isinstance(value, datetime.timedelta): + return intuitive_duration_string(value) + return value + + def to_python(self, value): + if value in self.empty_values: + return None + if isinstance(value, datetime.timedelta): + return value + value = parse_intuitive_duration(value) + if value is None: + raise ValidationError(self.error_messages['invalid'], code='invalid') + return value diff --git a/colegend/lib/intuitive_duration.py b/colegend/lib/intuitive_duration.py new file mode 100644 index 00000000..a9dee945 --- /dev/null +++ b/colegend/lib/intuitive_duration.py @@ -0,0 +1,104 @@ +import datetime +import re +from django.core.exceptions import ValidationError + +__author__ = 'eraldo' + +intuitive_duration_format = "__years __months __weeks __days __hours __minutes __seconds" + + +def parse_intuitive_duration(intuitive_string): + """ + Takes an intuitive duration string returns a duration object. + + + Examples for valid input: + '1w 6d 20h 15m': one week, six days, twenty hours, 15 minutes + '1h' : one hour + + :param intuitive_string: a python string + :return: timedelta or input value + """ + intuitive_duration_regex = re.compile( + r'^{years}(\s+)?{months}(\s+)?{weeks}(\s+)?{days}(\s+)?{hours}(\s+)?{minutes}(\s+)?{seconds}$'.format( + years=r'((?P\d+?)\s?(?:Y|year(?:s)?))?', + months=r'((?P\d+?)\s?(?:M|month(?:s)?))?', + weeks=r'((?P\d+?)\s?(?:w|week(?:s)?))?', + days=r'((?P\d+?)\s?(?:d|day(?:s)?))?', + hours=r'((?P\d+?)\s?(?:h|hour(?:s)?))?', + minutes=r'((?P\d+?)\s?(?:m|minute(?:s)?))?', + seconds=r'((?P\d+?)\s?(?:s|second(?:s)?))?', + ) + ) + + # Only accept strings for further processing. + if not isinstance(intuitive_string, str): + raise ValidationError("Invalid input for a intuitive duration") + + # Remove spaces from start and end. + string = intuitive_string.strip() + + parts = intuitive_duration_regex.match(string) + + # Check if the pattern matches. + if not parts: + raise ValidationError("Invalid input for an intuitive duration. Format: {}".format(intuitive_duration_format)) + + # Separate the string into time parameters. (hours, minutes, etc) + parts = parts.groupdict() + time_parameters = {} + for (name, parameter) in parts.items(): + if parameter: + time_parameters[name] = int(parameter) + + # Convert the weeks and months to days + if 'years' in time_parameters: + time_parameters['days'] = time_parameters.get('days', 0) + time_parameters['years'] * 365 + del time_parameters['years'] + if 'months' in time_parameters: + time_parameters['days'] = time_parameters.get('days', 0) + time_parameters['months'] * 30 + del time_parameters['months'] + + return datetime.timedelta(**time_parameters) + + +def intuitive_duration_string(timedelta): + """ + Takes a time duration and converts it to a string representation. + + The string intervals for representation: + years, months, weeks, days, hours, minutes and seconds. + + Alternative represention with short characters: + Y, M, w, d, h, m and s + + Example: + '1w 6d 20h 15m': one week, six days, twenty hours, 15 minutes + '1h' : one hour + + :param timedelta: python timedelta object + :return: a string 'intuitively' representing the duration + """ + if not isinstance(timedelta, datetime.timedelta): + ValidationError("Invalid input for an intuitive duration. Format: {}".format(intuitive_duration_format)) + + minutes, seconds = divmod(timedelta.seconds, 60) + hours, minutes = divmod(minutes, 60) + + years, days = divmod(timedelta.days, 365) + months, days = divmod(days, 30) + weeks, days = divmod(days, 30) + + template = '{years}{months}{weeks}{days}{hours}{minutes}{seconds}' + + intuitive_string = template.format( + years='{}Y '.format(years) if years else '', + months='{}M '.format(months) if months else '', + weeks='{}w '.format(weeks) if weeks else '', + days='{}d '.format(days) if days else '', + hours='{}h '.format(hours) if hours else '', + minutes='{}m '.format(minutes) if minutes else '', + seconds='{}s '.format(seconds) if seconds else '', + ).rstrip() + + return intuitive_string diff --git a/colegend/lib/modelfields.py b/colegend/lib/modelfields.py index 767bffb1..c85e08bc 100644 --- a/colegend/lib/modelfields.py +++ b/colegend/lib/modelfields.py @@ -1,7 +1,11 @@ from django.core.validators import MinLengthValidator from django.db.models import CharField -from django.utils.translation import ugettext_lazy as _ +from lib.formfields import IntuitiveDurationFormField +from lib.intuitive_duration import parse_intuitive_duration, intuitive_duration_string, intuitive_duration_format from lib.validators import PhoneValidator +import datetime +from django.db import models +from django.utils.translation import ugettext_lazy as _ __author__ = 'eraldo' @@ -14,3 +18,43 @@ class PhoneField(CharField): def __init__(self, *args, **kwargs): kwargs['max_length'] = 16 super(PhoneField, self).__init__(*args, **kwargs) + + +class IntuitiveDurationField(models.DurationField): + """ + Duration field with intuitive human input and output strings. + """ + description = _("Duration field with intuitive human input and output strings.") + + default_error_messages = { + 'invalid': _("'%(value)s' value has an invalid format. Format: {}".format(intuitive_duration_format)) + } + + def to_python(self, value): + if isinstance(value, str): + try: + parsed = parse_intuitive_duration(value) + except ValueError: + pass + else: + return parsed + else: + value = super().to_python(value) + + if isinstance(value, datetime.timedelta): + # remove microseconds + value = datetime.timedelta(days=value.days, seconds=value.seconds) + + return value + + def value_to_string(self, obj): + value = self._get_val_from_obj(obj) + raise Exception(intuitive_duration_string(value)) + return '' if value is None else intuitive_duration_string(value) + + def formfield(self, **kwargs): + defaults = { + 'form_class': IntuitiveDurationFormField, + } + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/colegend/lib/templates/lib/_time_estimate_field.html b/colegend/lib/templates/lib/_time_estimate_field.html index c3ead39f..b4ddcefe 100644 --- a/colegend/lib/templates/lib/_time_estimate_field.html +++ b/colegend/lib/templates/lib/_time_estimate_field.html @@ -1,10 +1,11 @@ {% load icons %} +{% load intuitive_duration %}
{% icon "time-estimate" %} {% if time_estimate %} - - {{ time_estimate }} + + {{ time_estimate|intuitive_duration }} {% endif %}
diff --git a/colegend/lib/templatetags/dates.py b/colegend/lib/templatetags/dates.py index 1725a95e..27aaf714 100644 --- a/colegend/lib/templatetags/dates.py +++ b/colegend/lib/templatetags/dates.py @@ -5,6 +5,7 @@ register = template.Library() + @register.filter(name='due_date') def get_due_date_string(value): delta = value - timezone.now().date() @@ -14,13 +15,14 @@ def get_due_date_string(value): return "Today" elif delta.days < 1: return "%s %s ago" % (abs(delta.days), - ("day" if abs(delta.days) == 1 else "days")) + ("day" if abs(delta.days) == 1 else "days")) elif delta.days == 1: return "Tomorrow" elif delta.days > 1: return "In %s days" % delta.days return value + @register.filter(name='date_tense') def get_date_tense(value): if not value: diff --git a/colegend/lib/templatetags/intuitive_duration.py b/colegend/lib/templatetags/intuitive_duration.py new file mode 100644 index 00000000..455ee239 --- /dev/null +++ b/colegend/lib/templatetags/intuitive_duration.py @@ -0,0 +1,15 @@ +import datetime +from lib.intuitive_duration import intuitive_duration_string +from django import template + +__author__ = 'eraldo' + +register = template.Library() + + +@register.filter(name='intuitive_duration') +def get_intuitive_duration(value): + if not value or not isinstance(value, datetime.timedelta): + return + + return intuitive_duration_string(value) diff --git a/colegend/tasks/migrations/0018_auto_20150821_1700.py b/colegend/tasks/migrations/0018_auto_20150821_1700.py new file mode 100644 index 00000000..3208ff22 --- /dev/null +++ b/colegend/tasks/migrations/0018_auto_20150821_1700.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import lib.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0017_task_time_estimate'), + ] + + operations = [ + migrations.AlterField( + model_name='task', + name='time_estimate', + field=lib.modelfields.IntuitiveDurationField(null=True, blank=True), + ), + ] diff --git a/colegend/tasks/models.py b/colegend/tasks/models.py index f263c7c8..c10a2349 100644 --- a/colegend/tasks/models.py +++ b/colegend/tasks/models.py @@ -1,10 +1,10 @@ -from django.core.exceptions import ValidationError, SuspiciousOperation +from django.core.exceptions import SuspiciousOperation from django.db import models -from django.db.models import DurationField from django.db.models.query import QuerySet from django.utils import timezone from markitup.fields import MarkupField from categories.models import Category +from lib.modelfields import IntuitiveDurationField from lib.models import TrackedBase, AutoUrlMixin, OwnedBase, OwnedQueryMixin, ValidateModelMixin, StatusTrackedBase from projects.models import Project from statuses.models import Status @@ -30,7 +30,7 @@ class Task(ValidateModelMixin, AutoUrlMixin, OwnedBase, StatusTrackedBase, Track status = models.ForeignKey(Status, default=Status.DEFAULT_PK) date = models.DateField(blank=True, null=True, help_text="When will I start/continue?") deadline = models.DateField(blank=True, null=True) - time_estimate = DurationField(blank=True, null=True, help_text='DD HH:MM:SS') + time_estimate = IntuitiveDurationField(blank=True, null=True) category = models.ForeignKey(Category, blank=True, null=True)