Filter stories by project_id, status, project_group_id, more.

This patch adds various new API filtering options to the browse
endpoint, so that we can build more refined searches. It also
fixes a bug where StorySummary instances without any tasks
would simply not show up via the API.

Functional tests and additional mock test data is provided.

Change-Id: Ibde6cf37dc2c4511787d94f7540d8b021a6d348b
This commit is contained in:
Michael Krotscheck
2014-10-10 14:00:55 -07:00
parent 416d5b9c32
commit 5d6886d960
5 changed files with 313 additions and 81 deletions

View File

@@ -4,7 +4,7 @@
# 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
# 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,
@@ -30,6 +30,7 @@ from storyboard.api.v1 import wmodels
from storyboard.db.api import stories as stories_api
from storyboard.db.api import timeline_events as events_api
CONF = cfg.CONF
SEARCH_ENGINE = search_engine.get_engine()
@@ -56,20 +57,21 @@ class StoriesController(rest.RestController):
status_code=404)
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Story], int, int, int, int, unicode, unicode,
unicode, unicode, unicode)
def get_all(self, project_id=None, assignee_id=None, marker=None,
limit=None, status=None, title=None, description=None,
sort_field='id', sort_dir='asc'):
@wsme_pecan.wsexpose([wmodels.Story], unicode, unicode, [unicode], int,
int, int, int, int, unicode, unicode)
def get_all(self, title=None, description=None, status=None,
assignee_id=None, project_group_id=None, project_id=None,
marker=None, limit=None, sort_field='id', sort_dir='asc'):
"""Retrieve definitions of all of the stories.
:param project_id: filter stories by project ID.
:param assignee_id: filter stories by who they are assigned to.
:param marker: The resource id where the page should begin.
:param limit: The number of stories to retrieve.
:param status: Only show stories with this particular status.
:param title: A string to filter the title by.
:param description: A string to filter the description by.
:param status: Only show stories with this particular status.
:param assignee_id: filter stories by who they are assigned to.
:param project_group_id: filter stories by project group.
:param project_id: filter stories by project ID.
:param marker: The resource id where the page should begin.
:param limit: The number of stories to retrieve.
:param sort_field: The name of the field to sort on.
:param sort_dir: sort direction for results (asc, desc).
"""
@@ -82,31 +84,23 @@ class StoriesController(rest.RestController):
# Resolve the marker record.
marker_story = stories_api.story_get(marker)
if marker_story is None or marker_story.project_id != project_id:
marker_story = None
# Build a dict of all story parameters by which we're filtering
# stories.
story_filters = {
'title': title,
'description': description,
'status': status
}
# Build a dict of all task parameters by which we're filtering stories.
task_filters = {
'project_id': project_id,
'assignee_id': assignee_id
}
stories = stories_api.story_get_all(marker=marker_story,
limit=limit,
story_filters=story_filters,
task_filters=task_filters,
sort_field=sort_field,
sort_dir=sort_dir)
story_count = stories_api.story_get_count(story_filters=story_filters,
task_filters=task_filters)
stories = stories_api \
.story_get_all(title=title,
description=description,
status=status,
assignee_id=assignee_id,
project_group_id=project_group_id,
project_id=project_id,
marker=marker_story,
limit=limit, sort_field=sort_field,
sort_dir=sort_dir)
story_count = stories_api \
.story_get_count(title=title,
description=description,
status=status,
assignee_id=assignee_id,
project_group_id=project_group_id,
project_id=project_id, )
# Apply the query response headers.
response.headers['X-Limit'] = str(limit)

View File

@@ -24,8 +24,8 @@ from storyboard.db import models
def story_get_simple(story_id, session=None):
return api_base.model_query(models.Story, session)\
.options(subqueryload(models.Story.tags))\
return api_base.model_query(models.Story, session) \
.options(subqueryload(models.Story.tags)) \
.filter_by(id=story_id).first()
@@ -41,62 +41,94 @@ def story_get(story_id, session=None):
return simple
def story_get_all(marker=None, limit=None, story_filters=None,
task_filters=None, sort_field='id', sort_dir='asc'):
query = _story_build_query(story_filters=story_filters,
task_filters=task_filters)
def story_get_all(title=None, description=None, status=None, assignee_id=None,
project_group_id=None, project_id=None, marker=None,
limit=None, sort_field='id', sort_dir='asc'):
# Sanity checks, in case someone accidentally explicitly passes in 'None'
if not sort_field:
sort_field = 'id'
if not sort_dir:
sort_dir = 'asc'
# Build the query.
subquery = _story_build_query(title=title,
description=description,
assignee_id=assignee_id,
project_group_id=project_group_id,
project_id=project_id)
# paginate the query
try:
query = api_base.paginate_query(query=query,
model=models.StorySummary,
limit=limit,
sort_keys=[sort_field],
marker=marker,
sort_dir=sort_dir)
subquery = api_base.paginate_query(query=subquery,
model=models.Story,
limit=limit,
sort_keys=[sort_field],
marker=marker,
sort_dir=sort_dir)
except InvalidSortKey:
raise ClientSideError("Invalid sort_field [%s]" % (sort_field,),
status_code=400)
except ValueError as ve:
raise ClientSideError("%s" % (ve,), status_code=400)
# Turn the whole shebang into a subquery.
subquery = subquery.subquery('filtered_stories')
# Return the story summary.
query = api_base.model_query(models.StorySummary)
query = query.join(subquery,
models.StorySummary.id == subquery.c.id)
if status:
query = query.filter(models.StorySummary.status.in_(status))
return query.all()
def story_get_count(story_filters=None, task_filters=None):
query = _story_build_query(story_filters=story_filters,
task_filters=task_filters)
def story_get_count(title=None, description=None, status=None,
assignee_id=None, project_group_id=None, project_id=None):
query = _story_build_query(title=title,
description=description,
assignee_id=assignee_id,
project_group_id=project_group_id,
project_id=project_id)
# If we're also asking for status, we have to attach storysummary here,
# since story status is derived.
if status:
query = query.subquery()
summary_query = api_base.model_query(models.StorySummary)
summary_query = summary_query \
.join(query, models.StorySummary.id == query.c.id)
query = summary_query.filter(models.StorySummary.status.in_(status))
return query.count()
def _story_build_query(story_filters=None, task_filters=None):
# Input sanity checks
if story_filters:
story_filters = dict((k, v) for k, v in story_filters.iteritems() if v)
if task_filters:
task_filters = dict((k, v) for k, v in task_filters.iteritems() if v)
# Build the main story query
query = api_base.model_query(models.StorySummary)
def _story_build_query(title=None, description=None, assignee_id=None,
project_group_id=None, project_id=None):
# First build a standard story query.
query = api_base.model_query(models.Story.id).distinct()
query = api_base.apply_query_filters(query=query,
model=models.StorySummary,
**story_filters)
model=models.Story,
title=title,
description=description)
# Do we have task parameter queries we need to deal with?
if task_filters and len(task_filters) > 0:
subquery = api_base.model_query(models.Task.story_id) \
.filter_by(**task_filters) \
.distinct(True) \
.subquery('project_tasks')
# Are we filtering by project group?
if project_group_id:
query = query.join(models.Task,
models.Project,
models.project_group_mapping)
query = query.filter(project_group_id == project_group_id)
query = query.join(subquery,
models.StorySummary.id == subquery.c.story_id)
# Are we filtering by task?
if assignee_id or project_id:
if not project_group_id: # We may already have joined this table
query = query.join(models.Task)
if assignee_id:
query = query.filter(models.Task.assignee_id == assignee_id)
if project_id:
query = query.filter(models.Task.project_id == project_id)
return query

View File

@@ -289,7 +289,7 @@ def _story_build_summary_query():
None,
expr.Join(Story, Task, onclause=Story.id == Task.story_id,
isouter=True)) \
.group_by(Task.story_id) \
.group_by(Story.id) \
.alias('story_summary')

View File

@@ -4,7 +4,7 @@
# 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
# 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
@@ -13,12 +13,12 @@
# under the License.
import json
from urllib import urlencode
from storyboard.tests import base
class TestStories(base.FunctionalTest):
def setUp(self):
super(TestStories, self).setUp()
self.resource = '/stories'
@@ -30,8 +30,8 @@ class TestStories(base.FunctionalTest):
self.default_headers['Authorization'] = 'Bearer valid_superuser_token'
def test_stories_endpoint(self):
response = self.get_json(self.resource, project_id=1)
self.assertEqual(1, len(response))
response = self.get_json(self.resource)
self.assertEqual(5, len(response))
def test_create(self):
response = self.post_json(self.resource, self.story_01)
@@ -64,3 +64,204 @@ class TestStories(base.FunctionalTest):
self.assertNotEqual(updated['title'], original['title'])
self.assertNotEqual(updated['description'],
original['description'])
class TestStorySearch(base.FunctionalTest):
def setUp(self):
super(TestStorySearch, self).setUp()
def build_search_url(self, params=None, raw=''):
if params:
raw = urlencode(params)
return '/stories?%s' % raw
def test_search(self):
url = self.build_search_url({
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(5, len(results.json))
self.assertEqual('5', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
def test_search_by_title(self):
url = self.build_search_url({
'title': 'foo'
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(2, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
result = results.json[1]
self.assertEqual(3, result['id'])
def test_search_by_description(self):
url = self.build_search_url({
'description': 'foo'
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(2, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
result = results.json[1]
self.assertEqual(3, result['id'])
def test_search_by_status(self):
url = self.build_search_url({
'status': 'active'
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(1, len(results.json))
self.assertEqual('1', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
def test_search_by_statuses(self):
url = self.build_search_url(raw='status=active&status=merged')
results = self.get_json(url, expect_errors=True)
self.assertEqual(2, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
result = results.json[1]
self.assertEqual(2, result['id'])
def test_search_by_assignee_id(self):
url = self.build_search_url({
'assignee_id': 1
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(2, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
result = results.json[1]
self.assertEqual(2, result['id'])
def test_search_by_project_group_id(self):
url = self.build_search_url({
'project_group_id': 2
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(2, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
result = results.json[1]
self.assertEqual(2, result['id'])
def test_search_by_project_id(self):
url = self.build_search_url({
'project_id': 1
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(1, len(results.json))
self.assertEqual('1', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
def test_search_empty_results(self):
url = self.build_search_url({
'title': 'grumpycat'
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(0, len(results.json))
self.assertEqual('0', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
def test_search_limit(self):
url = self.build_search_url({
'title': 'foo',
'limit': 1
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(1, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertEqual('1', results.headers['X-Limit'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
def test_search_marker(self):
url = self.build_search_url({
'title': 'foo',
'marker': 1 # Last item in previous list.
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(1, len(results.json))
self.assertEqual('2', results.headers['X-Total'])
self.assertEqual('1', results.headers['X-Marker'])
result = results.json[0]
self.assertEqual(3, result['id'])
def test_search_direction(self):
url = self.build_search_url({
'sort_field': 'title',
'sort_dir': 'asc'
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(5, len(results.json))
self.assertEqual('5', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(5, result['id'])
result = results.json[1]
self.assertEqual(4, result['id'])
result = results.json[2]
self.assertEqual(3, result['id'])
result = results.json[3]
self.assertEqual(2, result['id'])
result = results.json[4]
self.assertEqual(1, result['id'])
def test_search_direction_desc(self):
url = self.build_search_url({
'sort_field': 'title',
'sort_dir': 'desc'
})
results = self.get_json(url, expect_errors=True)
self.assertEqual(5, len(results.json))
self.assertEqual('5', results.headers['X-Total'])
self.assertFalse('X-Marker' in results.headers)
result = results.json[0]
self.assertEqual(1, result['id'])
result = results.json[1]
self.assertEqual(2, result['id'])
result = results.json[2]
self.assertEqual(3, result['id'])
result = results.json[3]
self.assertEqual(4, result['id'])
result = results.json[4]
self.assertEqual(5, result['id'])

View File

@@ -116,23 +116,28 @@ def load():
load_data([
Story(
id=1,
title="Test story 1 - foo"
title="E Test story 1 - foo",
description="Test Description - foo"
),
Story(
id=2,
title="Test story 2 - bar"
title="D Test story 2 - bar",
description="Test Description - bar"
),
Story(
id=3,
title="Test story 3 - foo"
title="C Test story 3 - foo",
description="Test Description - foo"
),
Story(
id=4,
title="Test story 4 - bar"
title="B Test story 4 - bar",
description="Test Description - bar"
),
Story(
id=5,
title="Test story 5 - oh hai"
title="A Test story 5 - oh hai",
description="Test Description - oh hai"
)
])