Skip to content

Commit

Permalink
Added intuitive time estimation.
Browse files Browse the repository at this point in the history
Using a custom IntuitiveDurationField.
  • Loading branch information
Eraldo committed Aug 21, 2015
1 parent ac4c881 commit 58a1ce1
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 7 deletions.
22 changes: 22 additions & 0 deletions colegend/lib/formfields.py
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
104 changes: 104 additions & 0 deletions colegend/lib/intuitive_duration.py
Original file line number Diff line number Diff line change
@@ -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<years>\d+?)\s?(?:Y|year(?:s)?))?',
months=r'((?P<months>\d+?)\s?(?:M|month(?:s)?))?',
weeks=r'((?P<weeks>\d+?)\s?(?:w|week(?:s)?))?',
days=r'((?P<days>\d+?)\s?(?:d|day(?:s)?))?',
hours=r'((?P<hours>\d+?)\s?(?:h|hour(?:s)?))?',
minutes=r'((?P<minutes>\d+?)\s?(?:m|minute(?:s)?))?',
seconds=r'((?P<seconds>\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
46 changes: 45 additions & 1 deletion colegend/lib/modelfields.py
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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)
5 changes: 3 additions & 2 deletions colegend/lib/templates/lib/_time_estimate_field.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{% load icons %}
{% load intuitive_duration %}

<div class="time-estimate-field inline {% if not time_estimate %}field-placeholder{% endif %}">
{% icon "time-estimate" %}
{% if time_estimate %}
<span class="time-estimate" data-toggle="tooltip" title="Estimated Time: {{ time_estimate }}">
{{ time_estimate }}
<span class="time-estimate" data-toggle="tooltip" title="Estimated Time: {{ time_estimate|intuitive_duration }}">
{{ time_estimate|intuitive_duration }}
</span>
{% endif %}
</div>
4 changes: 3 additions & 1 deletion colegend/lib/templatetags/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

register = template.Library()


@register.filter(name='due_date')
def get_due_date_string(value):
delta = value - timezone.now().date()
Expand All @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions colegend/lib/templatetags/intuitive_duration.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions colegend/tasks/migrations/0018_auto_20150821_1700.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
6 changes: 3 additions & 3 deletions colegend/tasks/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand Down

0 comments on commit 58a1ce1

Please sign in to comment.