Skip to content

Commit

Permalink
Dynamic todo template (#458)
Browse files Browse the repository at this point in the history
* Add a mechanism to filter out some todos when adding todos from a checklist template

* Minor syntax fixes

* Pass the project to the conditional properties function

* Add a migration file

* Update text

* Propagate skipped_datetime to children of skipped parants

* Use the operator module for comparison

* Expand the regex operator description

* Implement detecting or and and logics

* Add doc comment

* Lint

* Update documentation

* Check for an empty condition

* Change the field type of the description field of Todo to accommodate long text

* Make adding todos from a template atomic

* Add a migration

* Use the atomic decorator

* Change add_todos to update_todos
  • Loading branch information
paopow committed Jun 29, 2018
1 parent 3ce65b3 commit 472c642
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 12 deletions.
44 changes: 44 additions & 0 deletions orchestra/json_schemas/todos.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,54 @@
import jsl


class PredicateSchema(jsl.Document):
"""
A predicate schema
Attributes:
operator (str):
Specify the compare operator of predicate.
Supported predicates are >, <, >=, <=, !=, and ==.
value (number, bool, sring, or None):
The value to compare with of the predicate.
"""
operator = jsl.StringField(required=True, pattern='[!=><]=|[><]')
value = jsl.AnyOfField([
jsl.NumberField(),
jsl.BooleanField(),
jsl.StringField(),
jsl.NullField()], required=True)


class TodoSchema(jsl.Document):
"""
A Todo schema
Attributes:
id (int):
A unique id for the todo.
description (str):
A text description of the todo.
items (array):
An array of sub-todos of this todo.
skip_if (array):
An array of conditions to skip this todo. If any of the
condition is true, the todo is skipped. Each condition is a
dictionary of attributes and predicates which get ANDed together.
remove_if (array):
An array of conditions to remove this todo. If any of the
condition is true, the todo is removed. Each condition is a
dictionary of attributes and predicates which get ANDed together.
"""
id = jsl.IntField(required=True)
description = jsl.StringField(required=True)
items = jsl.ArrayField(jsl.DocumentField('TodoSchema'))
skip_if = jsl.ArrayField(
jsl.DictField(
pattern_properties={'.*': jsl.DocumentField('PredicateSchema')}))
remove_if = jsl.ArrayField(
jsl.DictField(
pattern_properties={'.*': jsl.DocumentField('PredicateSchema')}))


class TodoListSchema(jsl.Document):
Expand Down
26 changes: 26 additions & 0 deletions orchestra/migrations/0077_auto_20180629_1827.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-29 18:27
from __future__ import unicode_literals

from django.db import migrations, models
import jsonfield.fields


class Migration(migrations.Migration):

dependencies = [
('orchestra', '0076_auto_20180615_1606'),
]

operations = [
migrations.AddField(
model_name='todolisttemplate',
name='conditional_property_function',
field=jsonfield.fields.JSONField(default={}),
),
migrations.AlterField(
model_name='todo',
name='description',
field=models.TextField(),
),
]
6 changes: 5 additions & 1 deletion orchestra/models/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,9 @@ class TodoListTemplate (TodoListTemplateMixin, BaseModel):
if it is generated by the system.
todos (str)
A JSON blob that describe the todos in the todo list template.
conditional_property_function (str)
A JSON blob containing the path to and name of a python method
that will return the preconditions to prune the created todos.
"""
class Meta:
app_label = 'orchestra'
Expand All @@ -603,6 +606,7 @@ class Meta:
Worker, null=True, blank=True,
related_name='creator', on_delete=models.SET_NULL)
todos = JSONField(default={'items': []})
conditional_property_function = JSONField(default={})


class Todo(TodoMixin, BaseModel):
Expand Down Expand Up @@ -642,7 +646,7 @@ class Meta:

task = models.ForeignKey(
Task, related_name='todos', on_delete=models.CASCADE)
description = models.CharField(max_length=200)
description = models.TextField()
completed = models.BooleanField(default=False)
start_by_datetime = models.DateTimeField(null=True, blank=True)
due_datetime = models.DateTimeField(null=True, blank=True)
Expand Down
78 changes: 78 additions & 0 deletions orchestra/tests/test_todos.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json

from django.utils import timezone
from dateutil.parser import parse
from django.core.urlresolvers import reverse

Expand Down Expand Up @@ -33,6 +34,13 @@ def _todo_data(task, description, completed,
}


def _get_test_conditional_props(project):
return {
'prop1': True,
'prop2': False
}


class TodosEndpointTests(EndpointTestCase):

def setUp(self):
Expand Down Expand Up @@ -277,6 +285,10 @@ def _verify_todo_content(self, todo, expected_todo):
todo = dict(todo)
created_at = todo.pop('created_at')
todo_id = todo.pop('id')
todo_skipped = bool(todo.pop('skipped_datetime', None))
expected_skipped = bool(expected_todo.pop('skipped_datetime', None))

self.assertEqual(todo_skipped, expected_skipped)
self.assertEqual(todo, expected_todo)
self.assertGreater(len(created_at), 0)
self.assertGreaterEqual(todo_id, 0)
Expand Down Expand Up @@ -369,3 +381,69 @@ def test_update_todos_from_todolist_template_invalid_todolist_template(
})

self.assertEqual(resp.status_code, 400)

def test_conditional_skip_remove_todos_from_template(self):
update_todos_from_todolist_template_url = \
reverse('orchestra:todos:update_todos_from_todolist_template')

todolist_template = TodoListTemplateFactory(
slug=self.todolist_template_slug,
name=self.todolist_template_name,
description=self.todolist_template_description,
conditional_property_function={
'path': 'orchestra.tests.test_todos'
'._get_test_conditional_props'
},
todos={'items': [
{
'id': 1,
'description': 'todo parent 1',
'items': [{
'id': 2,
'description': 'todo child 1',
'items': []
}],
'remove_if': [{
'prop1': {
'operator': '==',
'value': True
}
}]
}, {
'id': 3,
'description': 'todo parent 2',
'items': [{
'id': 4,
'description': 'todo child 2',
'items': [],
'skip_if': [{
'prop2': {
'operator': '!=',
'value': True
}
}]
}]
}]},
)
resp = self.request_client.post(
update_todos_from_todolist_template_url,
{
'todolist_template': todolist_template.slug,
'task': self.task.id,
})
self.assertEqual(resp.status_code, 200)
todos = load_encoded_json(resp.content)

expected_todos = [
_todo_data(self.task, 'todo child 2', False,
template=todolist_template.id,
parent_todo=todos[1]['id'],
skipped_datetime=timezone.now()),
_todo_data(self.task, 'todo parent 2', False,
template=todolist_template.id,
parent_todo=todos[2]['id']),
_todo_data(self.task, self.todolist_template_name,
False, template=todolist_template.id),
]
for todo, expected_todo in zip(todos, expected_todos):
self._verify_todo_content(todo, expected_todo)
84 changes: 73 additions & 11 deletions orchestra/todos/api.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
from django.db import transaction
from django.utils import timezone
import logging
import operator
from pydoc import locate

from orchestra.models import TodoListTemplate
from orchestra.models import Todo
from orchestra.models import Task

logger = logging.getLogger(__name__)

OPERATORS = {
'<': operator.lt,
'<=': operator.le,
'==': operator.eq,
'!=': operator.ne,
'>=': operator.ge,
'>': operator.gt
}


@transaction.atomic
def add_todolist_template(todolist_template_slug, task_id):
todolist_template = TodoListTemplate.objects.get(
slug=todolist_template_slug)

task = Task.objects.get(id=task_id)
template_todos = todolist_template.todos.get('items', [])
root_todo = Todo(
Expand All @@ -17,17 +33,63 @@ def add_todolist_template(todolist_template_slug, task_id):
template=todolist_template
)
root_todo.save()

cond_props = {}
path = todolist_template.conditional_property_function.get(
'path', None)
if path:
try:
get_cond_props = locate(path)
cond_props = get_cond_props(task.project)
except Exception:
logger.exception('Invalid conditional function path.')
for template_todo in template_todos:
_add_template_todo(template_todo, todolist_template, root_todo, task)
_add_template_todo(
template_todo, todolist_template, root_todo, task, cond_props)


def _add_template_todo(template_todo, todolist_template, parent_todo, task):
todo = Todo(
task=task,
description=template_todo['description'],
template=todolist_template,
parent_todo=parent_todo
)
todo.save()
for template_todo_item in template_todo.get('items', []):
_add_template_todo(template_todo_item, todolist_template, todo, task)
def _to_exclude(props, conditions):
"""
The conditions is it a list of conditions that get ORed together,
with predicates in each dictionary getting ANDed.
"""
any_condition_true = False

for condition in conditions:
all_props_true = len(condition) > 0
for prop, predicate in condition.items():
current_value = props.get(prop)
compared_to_value = predicate['value']
compare = OPERATORS[predicate['operator']]
all_props_true = (
all_props_true and
compare(current_value, compared_to_value))
any_condition_true = any_condition_true or all_props_true

return any_condition_true


def _add_template_todo(
template_todo, todolist_template,
parent_todo, task, conditional_props):
remove = _to_exclude(conditional_props, template_todo.get('remove_if', []))
if not remove:
if parent_todo.skipped_datetime:
skipped_datetime = parent_todo.skipped_datetime
else:
to_skip = _to_exclude(
conditional_props, template_todo.get('skip_if', []))
skipped_datetime = timezone.now() if to_skip else None

todo = Todo(
task=task,
description=template_todo['description'],
template=todolist_template,
parent_todo=parent_todo,
skipped_datetime=skipped_datetime
)
todo.save()
for template_todo_item in template_todo.get('items', []):
_add_template_todo(
template_todo_item, todolist_template, todo,
task, conditional_props)

0 comments on commit 472c642

Please sign in to comment.