Added REST API for tasks

There are several major changes in this commit:
* db api refactored to extract common code to common methods
* added REST API layer for tasks
* creation of story now triggers creation of default associated task
* it is possible to filter stories by project.id
* it is possible to filter tasks by story.id
* all new features have functional test coverage
* updated rest api docs to include only functioning endpoints

Change-Id: Ice2ebfa174e35944bebd71e3cf4b809a3e825761
This commit is contained in:
Ruslan Kamaldinov 2014-02-14 15:47:01 +04:00
parent b56316a4bb
commit c45a36175a
8 changed files with 269 additions and 153 deletions

View File

@ -11,11 +11,6 @@ Projects
.. rest-controller:: storyboard.api.v1.projects:ProjectsController .. rest-controller:: storyboard.api.v1.projects:ProjectsController
:webprefix: /v1/projects :webprefix: /v1/projects
Project Groups
==============
.. rest-controller:: storyboard.api.v1.project_groups:ProjectGroupsController
:webprefix: /v1/projects
Stories Stories
======= =======
.. rest-controller:: storyboard.api.v1.stories:StoriesController .. rest-controller:: storyboard.api.v1.stories:StoriesController
@ -26,62 +21,24 @@ Tasks
.. rest-controller:: storyboard.api.v1.tasks:TasksController .. rest-controller:: storyboard.api.v1.tasks:TasksController
:webprefix: /v1/projects :webprefix: /v1/projects
Teams
=====
.. rest-controller:: storyboard.api.v1.teams:TeamsController
:webprefix: /v1/projects
Users
=====
.. rest-controller:: storyboard.api.v1.users:UsersController
:webprefix: /v1/projects
############ ############
Object model Object model
############ ############
Comment
=======
.. autotype:: storyboard.api.v1.wsme_models.Comment
:members:
Permission
==========
.. autotype:: storyboard.api.v1.wsme_models.Permission
:members:
Project Project
======= =======
.. autotype:: storyboard.api.v1.projects.Project .. autotype:: storyboard.api.v1.projects.Project
:members: :members:
ProjectGroup
============
.. autotype:: storyboard.api.v1.wsme_models.ProjectGroup
:members:
Story Story
===== =====
.. autotype:: storyboard.api.v1.stories.Story .. autotype:: storyboard.api.v1.stories.Story
:members: :members:
StoryTag
========
.. autotype:: storyboard.api.v1.wsme_models.StoryTag
:members:
Task Task
==== ====
.. autotype:: storyboard.api.v1.wsme_models.Task .. autotype:: storyboard.api.v1.tasks.Task
:members:
Team
====
.. autotype:: storyboard.api.v1.wsme_models.Team
:members:
User
====
.. autotype:: storyboard.api.v1.wsme_models.User
:members: :members:

View File

@ -39,6 +39,9 @@ class Story(base.APIBase):
Allowed values: ['Undefined', 'Low', 'Medium', 'High', 'Critical']. Allowed values: ['Undefined', 'Low', 'Medium', 'High', 'Critical'].
""" """
project_id = int
"""Optional parameter"""
@classmethod @classmethod
def sample(cls): def sample(cls):
return cls( return cls(
@ -51,7 +54,7 @@ class Story(base.APIBase):
class StoriesController(rest.RestController): class StoriesController(rest.RestController):
"""Manages operations on stories.""" """Manages operations on stories."""
@wsme_pecan.wsexpose(Story, unicode) @wsme_pecan.wsexpose(Story, int)
def get_one(self, story_id): def get_one(self, story_id):
"""Retrieve details about one story. """Retrieve details about one story.
@ -60,10 +63,16 @@ class StoriesController(rest.RestController):
story = dbapi.story_get(story_id) story = dbapi.story_get(story_id)
return Story.from_db_model(story) return Story.from_db_model(story)
@wsme_pecan.wsexpose([Story]) @wsme_pecan.wsexpose([Story], int)
def get(self): def get_all(self, project_id=None):
"""Retrieve definitions of all of the stories.""" """Retrieve definitions of all of the stories.
stories = dbapi.story_get_all()
:param project_id: filter stories by project ID
"""
if project_id:
stories = dbapi.story_get_all_in_project(project_id)
else:
stories = dbapi.story_get_all()
return [Story.from_db_model(s) for s in stories] return [Story.from_db_model(s) for s in stories]
@wsme_pecan.wsexpose(Story, body=Story) @wsme_pecan.wsexpose(Story, body=Story)
@ -72,7 +81,18 @@ class StoriesController(rest.RestController):
:param story: a story within the request body. :param story: a story within the request body.
""" """
created_story = dbapi.story_create(story.as_dict()) args = story.as_dict()
project_id = args.pop('project_id', None)
created_story = dbapi.story_create(args)
# Create default task for this story
task = {
'title': created_story['title'],
'status': 'Todo',
'story_id': created_story['id'],
'project_id': project_id
}
dbapi.task_create(task)
return Story.from_db_model(created_story) return Story.from_db_model(created_story)
@wsme_pecan.wsexpose(Story, int, body=Story) @wsme_pecan.wsexpose(Story, int, body=Story)

View File

@ -14,41 +14,69 @@
# limitations under the License. # limitations under the License.
from pecan import rest from pecan import rest
from wsme.exc import ClientSideError from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan import wsmeext.pecan as wsme_pecan
import storyboard.api.v1.wsme_models as wsme_models from storyboard.api.v1 import base
from storyboard.db import api as dbapi
class Task(base.APIBase):
"""Represents a task within a story."""
title = wtypes.text
"""A descriptive label for this tracker to show in listings."""
# TODO(ruhe): replace with enum
status = wtypes.text
"""Status.
Allowed values: ['Todo', 'In review', 'Landed'].
"""
story_id = int
"""An ID of corresponding user story"""
project_id = int
"""An ID of project this task is assigned to"""
class TasksController(rest.RestController): class TasksController(rest.RestController):
"""Manages tasks.""" """Manages tasks."""
@wsme_pecan.wsexpose(wsme_models.Task, unicode) @wsme_pecan.wsexpose(Task, int)
def get_one(self, id): def get_one(self, task_id):
"""Retrieve details about one task. """Retrieve details about one task.
:param id: An ID of the task. :param task_id: An ID of the task.
""" """
task = wsme_models.Task.get(id=id) task = dbapi.task_get(task_id)
if not task: return Task.from_db_model(task)
raise ClientSideError("Task %s not found" % id,
status_code=404)
return task
@wsme_pecan.wsexpose([wsme_models.Task]) @wsme_pecan.wsexpose([Task], int)
def get(self): def get_all(self, story_id=None):
"""Retrieve definitions of all of the tasks.""" """Retrieve definitions of all of the tasks.
tasks = wsme_models.Task.get_all()
return tasks
@wsme_pecan.wsexpose(wsme_models.Task, unicode, wsme_models.Task) :param story_id: filter tasks by story ID
"""
tasks = dbapi.task_get_all(story_id=story_id)
return [Task.from_db_model(s) for s in tasks]
@wsme_pecan.wsexpose(Task, body=Task)
def post(self, task):
"""Create a new task.
:param task: a task within the request body.
"""
created_task = dbapi.task_create(task.as_dict())
return Task.from_db_model(created_task)
@wsme_pecan.wsexpose(Task, int, body=Task)
def put(self, task_id, task): def put(self, task_id, task):
"""Modify this task. """Modify this task.
:param task_id: An ID of the task. :param task_id: An ID of the task.
:param task: a task within the request body. :param task: a task within the request body.
""" """
updated_task = wsme_models.Task.update("id", task_id, task) updated_task = dbapi.task_update(task_id,
if not updated_task: task.as_dict(omit_unset=True))
raise ClientSideError("Could not update story %s" % task_id) return Task.from_db_model(updated_task)
return updated_task

View File

@ -215,11 +215,6 @@ class Permission(_Base):
pass pass
class Task(_Base):
"""Represents a task within a story."""
pass
class StoryTag(_Base): class StoryTag(_Base):
"""Tags are used classifying user-stories.""" """Tags are used classifying user-stories."""
pass pass
@ -326,7 +321,6 @@ SQLALCHEMY_TO_WSME = {
sqlalchemy_models.User: User, sqlalchemy_models.User: User,
sqlalchemy_models.ProjectGroup: ProjectGroup, sqlalchemy_models.ProjectGroup: ProjectGroup,
sqlalchemy_models.Permission: Permission, sqlalchemy_models.Permission: Permission,
sqlalchemy_models.Task: Task,
sqlalchemy_models.Comment: Comment, sqlalchemy_models.Comment: Comment,
sqlalchemy_models.StoryTag: StoryTag sqlalchemy_models.StoryTag: StoryTag
} }

View File

@ -37,89 +37,110 @@ def model_query(model, session=None):
return query return query
## BEGIN Projects def __entity_get(kls, entity_id, session):
query = model_query(kls, session)
def _project_get(project_id, session): return query.filter_by(id=entity_id).first()
query = model_query(models.Project, session)
return query.filter_by(id=project_id).first()
def project_get(project_id): def _entity_get(kls, entity_id):
return _project_get(project_id, get_session()) return __entity_get(kls, entity_id, get_session())
def project_get_all(**kwargs): def _entity_get_all(kls, **kwargs):
query = model_query(models.Project) kwargs = dict((k, v) for k, v in kwargs.iteritems() if v)
query = model_query(kls)
return query.filter_by(**kwargs).all() return query.filter_by(**kwargs).all()
def project_create(values): def _entity_create(kls, values):
project = models.Project() entity = kls()
project.update(values.copy()) entity.update(values.copy())
session = get_session() session = get_session()
with session.begin(): with session.begin():
try: try:
project.save(session=session) entity.save(session=session)
except db_exc.DBDuplicateEntry as e: except db_exc.DBDuplicateEntry as e:
raise exc.DuplicateEntry("Duplicate entry for Project: %s" raise exc.DuplicateEntry("Duplicate etnry for : %s"
% e.columns) % (kls.__name__, e.colums))
return project return entity
def project_update(project_id, values): def entity_update(kls, entity_id, values):
session = get_session() session = get_session()
with session.begin(): with session.begin():
project = _project_get(project_id, session) entity = __entity_get(kls, entity_id, session)
if project is None: if entity is None:
raise exc.NotFound("Project %s not found" % project_id) raise exc.NotFound("%s %s not found" % (kls.__name__, entity_id))
project.update(values.copy()) entity.update(values.copy())
return project return entity
## BEGIN Projects
def project_get(project_id):
return _entity_get(models.Project, project_id)
def project_get_all(**kwargs):
return _entity_get_all(models.Project, **kwargs)
def project_create(values):
return _entity_create(models.Project, values)
def project_update(project_id, values):
return entity_update(models.Project, project_id, values)
## BEGIN Stories ## BEGIN Stories
def _story_get(story_id, session):
query = model_query(models.Story, session)
return query.filter_by(id=story_id).first()
def story_get_all(**kwargs):
query = model_query(models.Story)
return query.filter_by(**kwargs).all()
def story_get(story_id): def story_get(story_id):
return _story_get(story_id, get_session()) return _entity_get(models.Story, story_id)
def story_get_all(project_id=None):
if project_id:
return story_get_all_in_project(project_id)
else:
return _entity_get_all(models.Story)
def story_get_all_in_project(project_id):
session = get_session()
query = model_query(models.Story, session).join(models.Task)
return query.filter_by(project_id=project_id)
def story_create(values): def story_create(values):
story = models.Story() return _entity_create(models.Story, values)
story.update(values.copy())
session = get_session()
with session.begin():
try:
story.save(session)
except db_exc.DBDuplicateEntry as e:
raise exc.DuplicateEntry("Duplicate etnry for Story: %s"
% e.colums)
return story
def story_update(story_id, values): def story_update(story_id, values):
session = get_session() return entity_update(models.Story, story_id, values)
with session.begin():
story = _story_get(story_id, session)
if story is None:
raise exc.NotFound("Story %s not found" % story_id)
story.update(values.copy()) # BEGIN Tasks
return story def task_get(task_id):
return _entity_get(models.Task, task_id)
def task_get_all(story_id=None):
return _entity_get_all(models.Task, story_id=story_id)
def task_create(values):
return _entity_create(models.Task, values)
def task_update(task_id, values):
return entity_update(models.Task, task_id, values)

View File

@ -30,7 +30,7 @@ class TestStories(base.FunctionalTest):
} }
def test_stories_endpoint(self): def test_stories_endpoint(self):
response = self.get_json(self.resource) response = self.get_json(self.resource, project_id=1)
self.assertEqual([], response) self.assertEqual([], response)
def test_create(self): def test_create(self):
@ -65,3 +65,26 @@ class TestStories(base.FunctionalTest):
self.assertNotEqual(updated['title'], original['title']) self.assertNotEqual(updated['title'], original['title'])
self.assertNotEqual(updated['description'], self.assertNotEqual(updated['description'],
original['description']) original['description'])
def test_complete_workflow(self):
ref = self.story_01
ref['project_id'] = 2
resp = self.post_json('/stories', ref)
saved_story = json.loads(resp.body)
saved_task = self.get_json('/tasks', story_id=saved_story['id'])[0]
self.assertEqual(saved_story['id'], saved_task['story_id'])
self.assertEqual(ref['title'], saved_task['title'])
stories = self.get_json('/stories', project_id=ref['project_id'])
self.assertEqual(1, len(stories))
new_task = {
'title': 'StoryBoard',
'status': 'Todo',
'story_id': saved_story['id']
}
self.post_json('/tasks', new_task)
tasks = self.get_json('/tasks', story_id=saved_story['id'])
self.assertEqual(2, len(tasks))

View File

@ -0,0 +1,46 @@
# Copyright (c) 2014 Mirantis Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from storyboard.tests import base
class TestTasks(base.FunctionalTest):
def setUp(self):
super(TestTasks, self).setUp()
self.resource = '/tasks'
self.task_01 = {
'title': 'StoryBoard',
'status': 'Todo',
'story_id': 10
}
def test_tasks_endpoint(self):
response = self.get_json(self.resource)
self.assertEqual([], response)
def test_create(self):
self.post_json(self.resource, self.task_01)
self.task_01['story_id'] = 1000
self.post_json(self.resource, self.task_01)
# No filters here - we should receive both created tasks
all_tasks = self.get_json(self.resource)
self.assertEqual(2, len(all_tasks))
# filter by story_id - we should receive only the one task
tasks_story_10 = self.get_json(self.resource, story_id=10)
self.assertEqual(1, len(tasks_story_10))
self.assertEqual(self.task_01['title'], tasks_story_10[0]['title'])

View File

@ -17,7 +17,29 @@ from storyboard.db import api as dbapi
from storyboard.tests import base from storyboard.tests import base
class ProjectsTest(base.DbTestCase): class BaseDbTestCase(base.DbTestCase):
def setUp(self):
super(BaseDbTestCase, self).setUp()
def _assert_saved_fields(self, expected, actual):
for k in expected.keys():
self.assertEqual(expected[k], actual[k])
def _test_create(self, ref, save_method):
saved = save_method(ref)
self.assertIsNotNone(saved.id)
self._assert_saved_fields(ref, saved)
def _test_update(self, ref, delta, create, update):
saved = create(ref)
updated = update(saved.id, delta)
self.assertEqual(saved.id, updated.id)
self._assert_saved_fields(delta, updated)
class ProjectsTest(BaseDbTestCase):
def setUp(self): def setUp(self):
super(ProjectsTest, self).setUp() super(ProjectsTest, self).setUp()
@ -28,27 +50,18 @@ class ProjectsTest(base.DbTestCase):
} }
def test_save_project(self): def test_save_project(self):
ref = self.project_01 self._test_create(self.project_01, dbapi.project_create)
saved = dbapi.project_create(ref)
self.assertIsNotNone(saved.id)
self.assertEqual(ref['name'], saved.name)
self.assertEqual(ref['description'], saved.description)
def test_update_project(self): def test_update_project(self):
saved = dbapi.project_create(self.project_01)
delta = { delta = {
'name': u'New Name', 'name': u'New Name',
'description': u'New Description' 'description': u'New Description'
} }
updated = dbapi.project_update(saved.id, delta) self._test_update(self.project_01, delta,
dbapi.project_create, dbapi.project_update)
self.assertEqual(saved.id, updated.id)
self.assertEqual(delta['name'], updated.name)
self.assertEqual(delta['description'], updated.description)
class StoriesTest(base.DbTestCase): class StoriesTest(BaseDbTestCase):
def setUp(self): def setUp(self):
super(StoriesTest, self).setUp() super(StoriesTest, self).setUp()
@ -59,22 +72,36 @@ class StoriesTest(base.DbTestCase):
} }
def test_create_story(self): def test_create_story(self):
ref = self.story_01 self._test_create(self.story_01, dbapi.story_create)
saved = dbapi.story_create(self.story_01)
self.assertIsNotNone(saved.id)
self.assertEqual(ref['title'], saved.title)
self.assertEqual(ref['description'], saved.description)
def test_update_story(self): def test_update_story(self):
saved = dbapi.story_create(self.story_01)
delta = { delta = {
'title': u'New Title', 'title': u'New Title',
'description': u'New Description' 'description': u'New Description'
} }
self._test_update(self.story_01, delta,
dbapi.story_create, dbapi.story_update)
updated = dbapi.story_update(saved.id, delta)
self.assertEqual(saved.id, updated.id) class TasksTest(BaseDbTestCase):
self.assertEqual(delta['title'], updated.title)
self.assertEqual(delta['description'], updated.description) def setUp(self):
super(TasksTest, self).setUp()
self.task_01 = {
'title': u'Invent time machine',
'status': 'Todo',
'story_id': 1
}
def test_create_task(self):
self._test_create(self.task_01, dbapi.task_create)
def test_update_task(self):
delta = {
'status': 'In review',
'assignee_id': 1
}
self._test_update(self.task_01, delta,
dbapi.task_create, dbapi.task_update)