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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user