Optimise the Story browsing query
The `story_get_all` function currently constructs a query which perfoms very poorly, making search in StoryBoard painful to the point of being almost unusable with only ~30000 stories in the database. There are a number of reasons for the poor performance, but the most impactful is the fact that we join two subqueries, one of which contains the entire stories table, and the other which contains a filtered list of story IDs to implement most of the filtering. This commit removes all of the subquerying, and just does simple filtering in a single WHERE clause. Basic comparison of the two queries shows around an 85% - 90% speedup in the execution time using the new approach, from ~100ms for the current query, and ~10-15ms for the new query (using a test dataset containing 10000 stories). This approach adds a bit of code complexity around filtering stories by story status, but this is a cost worth paying for the performance improvement. The new approach will also allow extra flexibility in the meaning of the story statuses, making it easier to have per-project statuses for example. Story: 2005845 Task: 33627 Change-Id: I7c19372506017726e4fc61ffb09d3cfb9c6b36d3
This commit is contained in:
parent
983bb8e2d7
commit
63a003af7f
@ -1,7 +1,7 @@
|
||||
version: '2'
|
||||
services:
|
||||
mysql:
|
||||
image: "mysql:latest"
|
||||
image: "mysql:5.7"
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
|
@ -14,7 +14,6 @@
|
||||
# limitations under the License.
|
||||
|
||||
from oslo_db.exception import DBError
|
||||
from sqlalchemy.orm import subqueryload
|
||||
from sqlalchemy_fulltext import FullTextSearch
|
||||
import sqlalchemy_fulltext.modes as FullTextMode
|
||||
|
||||
@ -74,33 +73,16 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine):
|
||||
|
||||
return query.all()
|
||||
|
||||
def _story_fulltext_query(self, query, q, status, marker, offset,
|
||||
def _story_fulltext_query(self, query, q, status, offset,
|
||||
limit, mode, sort_field, sort_dir):
|
||||
clean_query = self._build_fulltext_search(
|
||||
query = self._build_fulltext_search(
|
||||
models.Story, query, q, mode=mode)
|
||||
count = query.count()
|
||||
|
||||
# Turn the whole shebang into a subquery.
|
||||
clean_query = clean_query.subquery('filtered_stories')
|
||||
query = stories_api._order_and_paginate(
|
||||
query, offset, limit, [sort_field], sort_dir)
|
||||
|
||||
# Return the story summary.
|
||||
query = api_base.model_query(models.StorySummary)\
|
||||
.options(subqueryload(models.StorySummary.tags))
|
||||
id_col = tuple(clean_query.c)[0]
|
||||
query = query.join(clean_query,
|
||||
models.StorySummary.id == id_col)
|
||||
|
||||
if status:
|
||||
query = query.filter(models.StorySummary.status.in_(status))
|
||||
|
||||
query = self._apply_pagination(models.StorySummary,
|
||||
query,
|
||||
marker,
|
||||
offset,
|
||||
limit,
|
||||
sort_field=sort_field,
|
||||
sort_dir=sort_dir)
|
||||
|
||||
return query.all()
|
||||
return [stories_api._StorySummary(row) for row in query.all()], count
|
||||
|
||||
def stories_query(self, q, status=None, assignee_id=None,
|
||||
creator_id=None, project_group_id=None, project_id=None,
|
||||
@ -108,43 +90,29 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine):
|
||||
marker=None, offset=None,
|
||||
limit=None, tags_filter_type="all", sort_field='id',
|
||||
sort_dir='asc', current_user=None):
|
||||
session = api_base.get_session()
|
||||
|
||||
subquery = stories_api._story_build_query(
|
||||
query = stories_api._story_build_query(
|
||||
status=status,
|
||||
assignee_id=assignee_id,
|
||||
creator_id=creator_id,
|
||||
project_group_id=project_group_id,
|
||||
project_id=project_id,
|
||||
subscriber_id=subscriber_id,
|
||||
tags=tags,
|
||||
updated_since=updated_since,
|
||||
tags_filter_type=tags_filter_type,
|
||||
current_user=current_user,
|
||||
session=session)
|
||||
|
||||
# Filter by subscriber ID
|
||||
if subscriber_id is not None:
|
||||
subs = api_base.model_query(models.Subscription)
|
||||
subs = api_base.apply_query_filters(query=subs,
|
||||
model=models.Subscription,
|
||||
target_type='story',
|
||||
user_id=subscriber_id)
|
||||
subs = subs.subquery()
|
||||
subquery = subquery.join(subs, subs.c.target_id == models.Story.id)
|
||||
|
||||
# Make a query that isn't full of aliases so that fulltext works
|
||||
query = api_base.model_query(models.Story)
|
||||
query = api_base.apply_query_filters(
|
||||
query=query,
|
||||
model=models.Story,
|
||||
id=[story.id for story in subquery.all()])
|
||||
current_user=current_user)
|
||||
|
||||
# Attempt a boolean search first, and if there's an error
|
||||
# try a natural language search instead. This saves having to
|
||||
# validate whether a search query is a valid boolean search, and
|
||||
# makes failure less common.
|
||||
try:
|
||||
return self._story_fulltext_query(
|
||||
query, q, status, marker, offset, limit, FullTextMode.BOOLEAN,
|
||||
query, q, status, offset, limit, FullTextMode.BOOLEAN,
|
||||
sort_field, sort_dir)
|
||||
except DBError:
|
||||
return self._story_fulltext_query(
|
||||
query, q, status, marker, offset, limit, FullTextMode.NATURAL,
|
||||
query, q, status, offset, limit, FullTextMode.NATURAL,
|
||||
sort_field, sort_dir)
|
||||
|
||||
def tasks_query(self, q, story_id=None, assignee_id=None, project_id=None,
|
||||
|
@ -535,7 +535,7 @@ class StoriesController(rest.RestController):
|
||||
|
||||
"""
|
||||
user = request.current_user_id
|
||||
stories = SEARCH_ENGINE.stories_query(
|
||||
stories, count = SEARCH_ENGINE.stories_query(
|
||||
q,
|
||||
status=status,
|
||||
assignee_id=assignee_id,
|
||||
@ -552,6 +552,13 @@ class StoriesController(rest.RestController):
|
||||
sort_dir=sort_dir,
|
||||
current_user=user)
|
||||
|
||||
# Apply the query response headers.
|
||||
if limit:
|
||||
response.headers['X-Limit'] = str(limit)
|
||||
response.headers['X-Total'] = str(count)
|
||||
if offset is not None:
|
||||
response.headers['X-Offset'] = str(offset)
|
||||
|
||||
return [create_story_wmodel(story) for story in stories]
|
||||
|
||||
@expose()
|
||||
|
@ -114,7 +114,7 @@ class TagsController(rest.RestController):
|
||||
"""
|
||||
tags = (tags or []) + (body or [])
|
||||
|
||||
story = stories_api.story_get(
|
||||
story = stories_api.story_get_simple(
|
||||
story_id, current_user=request.current_user_id)
|
||||
if not story:
|
||||
raise exc.NotFound("Story %s not found" % story_id)
|
||||
@ -122,8 +122,6 @@ class TagsController(rest.RestController):
|
||||
for tag in tags:
|
||||
stories_api.story_add_tag(
|
||||
story_id, tag, current_user=request.current_user_id)
|
||||
# For some reason the story gets cached and the tags do not appear.
|
||||
stories_api.api_base.get_session().expunge(story)
|
||||
|
||||
story = stories_api.story_get(
|
||||
story_id, current_user=request.current_user_id)
|
||||
|
@ -28,4 +28,4 @@ class NameType(types.StringType):
|
||||
_name_regex = r'^[a-zA-Z0-9]+([_\-\./]?[a-zA-Z0-9]+)*$'
|
||||
|
||||
def __init__(self):
|
||||
super(NameType, self).__init__(min_length=3, pattern=self._name_regex)
|
||||
super(NameType, self).__init__(min_length=1, pattern=self._name_regex)
|
||||
|
@ -387,14 +387,14 @@ def filter_private_stories(query, current_user, story_model=models.Story):
|
||||
query = query.filter(
|
||||
or_(
|
||||
story_model.permissions.any(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
),
|
||||
story_model.permissions.any(
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
or_(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
),
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
@ -422,6 +422,9 @@ def filter_private_worklists(query, current_user, hide_lanes=True):
|
||||
are lanes in a board.
|
||||
|
||||
"""
|
||||
# TODO(SotK): This query doesn't need to have a UNION, we can just
|
||||
# filter more sensibly.
|
||||
|
||||
# Lanes inherit board permissions and ignore their own, so need to be
|
||||
# handled separately. If we're not hiding all lanes from the result,
|
||||
# these two queries are combined at the end.
|
||||
@ -436,14 +439,14 @@ def filter_private_worklists(query, current_user, hide_lanes=True):
|
||||
models.Worklist.boards.any(
|
||||
or_(
|
||||
models.Board.permissions.any(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
),
|
||||
models.Board.permissions.any(
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
or_(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
),
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
@ -455,14 +458,14 @@ def filter_private_worklists(query, current_user, hide_lanes=True):
|
||||
lists = lists.filter(
|
||||
or_(
|
||||
models.Worklist.permissions.any(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
),
|
||||
models.Worklist.permissions.any(
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
or_(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
),
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
@ -505,14 +508,14 @@ def filter_private_boards(query, current_user):
|
||||
query = query.filter(
|
||||
or_(
|
||||
models.Board.permissions.any(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
),
|
||||
models.Board.permissions.any(
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
or_(
|
||||
models.Permission.users.any(
|
||||
models.User.id == current_user
|
||||
),
|
||||
models.Permission.teams.any(
|
||||
models.Team.users.any(
|
||||
models.User.id == current_user
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
@ -19,6 +19,9 @@ import pytz
|
||||
from oslo_db import api as oslo_db_api
|
||||
|
||||
from sqlalchemy.orm import subqueryload
|
||||
import sqlalchemy.sql.expression as expr
|
||||
import sqlalchemy.sql.functions as func
|
||||
from sqlalchemy import Integer, and_, asc, desc, or_
|
||||
from wsme.exc import ClientSideError
|
||||
|
||||
from storyboard._i18n import _
|
||||
@ -32,6 +35,50 @@ from storyboard.db.api import users as users_api
|
||||
from storyboard.db import models
|
||||
|
||||
|
||||
STORY_STATUSES = {
|
||||
'active': ['todo', 'inprogress', 'review'],
|
||||
'merged': ['merged'],
|
||||
'invalid': ['invalid']
|
||||
}
|
||||
|
||||
|
||||
class _StorySummary(object):
|
||||
|
||||
"""Abstraction for a Story's calculated status and task counts.
|
||||
|
||||
Takes a row of results from a query for both the Story model and some
|
||||
extra data about its status and count of tasks per-status. The status
|
||||
is calculated at query time, hence needing this abstraction.
|
||||
|
||||
This was previously implemented as a different database model, but
|
||||
that approach was inflexible about status calculations, and caused
|
||||
queries with very poor performance at scale to be generated.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, row):
|
||||
self._summary_dict = {}
|
||||
|
||||
story_obj = row.Story
|
||||
if story_obj:
|
||||
self._summary_dict.update(story_obj.as_dict())
|
||||
|
||||
self._summary_dict['status'] = row.status
|
||||
for task_status in models.Task.TASK_STATUSES:
|
||||
self._summary_dict[task_status] = getattr(row, task_status)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr in self._summary_dict:
|
||||
return self._summary_dict[attr]
|
||||
raise AttributeError(attr)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._summary_dict[key]
|
||||
|
||||
def as_dict(self):
|
||||
return self._summary_dict
|
||||
|
||||
|
||||
def story_get_simple(story_id, session=None, current_user=None,
|
||||
no_permissions=False):
|
||||
"""Return a story with the given ID.
|
||||
@ -59,15 +106,88 @@ def story_get_simple(story_id, session=None, current_user=None,
|
||||
|
||||
|
||||
def story_get(story_id, session=None, current_user=None):
|
||||
query = api_base.model_query(models.StorySummary, session)
|
||||
query = query.options(subqueryload(models.StorySummary.tags))\
|
||||
.filter_by(id=story_id)
|
||||
"""Return a summary object for the story with the given ID.
|
||||
|
||||
The object returned from this function isn't a SQLAlchemy model, and
|
||||
so shouldn't be used with an expectation of modifications being reflected
|
||||
in the underlying database.
|
||||
|
||||
If trying to query for an actual ``models.Story`` object,
|
||||
``story_get_simple`` should be used instead.
|
||||
|
||||
:param story_id: ID of the story to return.
|
||||
:param session: DB session to use.
|
||||
:param current_user: The ID of the user making the request.
|
||||
:return: A summary object for the story, or None if nothing found.
|
||||
|
||||
"""
|
||||
query = _story_query_base()
|
||||
query = query.filter(models.Story.id == story_id)
|
||||
|
||||
query = query.options(subqueryload(models.Story.due_dates))
|
||||
query = query.options(subqueryload(models.Story.tags))
|
||||
query = query.options(subqueryload(models.Story.permissions))
|
||||
|
||||
# Filter out stories that the current user can't see
|
||||
query = api_base.filter_private_stories(query, current_user,
|
||||
story_model=models.StorySummary)
|
||||
query = api_base.filter_private_stories(query, current_user)
|
||||
|
||||
return query.first()
|
||||
print(query.statement.compile(compile_kwargs={"literal_binds": True}))
|
||||
story = query.first()
|
||||
if story and story[0] is not None:
|
||||
story = _StorySummary(story)
|
||||
else:
|
||||
story = None
|
||||
return story
|
||||
|
||||
|
||||
def _order_and_paginate(query, offset, limit, sort_keys, sort_dir=None,
|
||||
sort_dirs=None):
|
||||
# NOTE(SotK): This is inspired by the `paginate_query` function in
|
||||
# oslo_db/sqlalchemy/utils.py, however it implements offset/limit
|
||||
# pagination rather than using a marker.
|
||||
#
|
||||
# Even if we supported marker-based pagination we'd still need a
|
||||
# custom implementation here, since we want to be able to do
|
||||
# pagination on fields that aren't part of the database model
|
||||
# (namely `status`). The oslo.db implementation requires that
|
||||
# all the sort keys are part of the ORM model, and also implements
|
||||
# comparison in a way that won't work for our use-case.
|
||||
if sort_dir and sort_dirs:
|
||||
raise AssertionError('Only one of sort_dir and sort_dirs can be set')
|
||||
|
||||
if sort_dir is None and sort_dirs is None:
|
||||
sort_dir = 'asc'
|
||||
|
||||
if sort_dirs is None:
|
||||
sort_dirs = [sort_dir for _key in sort_keys]
|
||||
|
||||
if len(sort_dirs) != len(sort_keys):
|
||||
raise AssertionError(
|
||||
'sort_dirs and sort_keys must have the same length')
|
||||
|
||||
for current_key, current_dir in zip(sort_keys, sort_dirs):
|
||||
try:
|
||||
sort_key_attr = getattr(models.Story, current_key)
|
||||
except AttributeError:
|
||||
# Fallback to ordering by the raw sort_key provided.
|
||||
# TODO(SotK): make sure that this is only an expected field?
|
||||
sort_key_attr = current_key
|
||||
sort_dir_func = {
|
||||
'asc': asc,
|
||||
'desc': desc
|
||||
}[current_dir]
|
||||
query = query.order_by(sort_dir_func(sort_key_attr))
|
||||
|
||||
# TODO(SotK): We should switch to using marker-based pagination
|
||||
# as soon as we can, it just needs the web client to understand
|
||||
# what to do with a marker id.
|
||||
if offset is not None:
|
||||
query = query.offset(offset)
|
||||
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def story_get_all(title=None, description=None, status=None, assignee_id=None,
|
||||
@ -84,105 +204,91 @@ def story_get_all(title=None, description=None, status=None, assignee_id=None,
|
||||
if not isinstance(status, list) and status is not None:
|
||||
status = [status]
|
||||
|
||||
# Build the query.
|
||||
subquery = _story_build_query(title=title,
|
||||
description=description,
|
||||
assignee_id=assignee_id,
|
||||
creator_id=creator_id,
|
||||
project_group_id=project_group_id,
|
||||
project_id=project_id,
|
||||
tags=tags,
|
||||
updated_since=updated_since,
|
||||
board_id=board_id,
|
||||
worklist_id=worklist_id,
|
||||
current_user=current_user,
|
||||
tags_filter_type=tags_filter_type)
|
||||
query = _story_build_query(title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
assignee_id=assignee_id,
|
||||
creator_id=creator_id,
|
||||
project_group_id=project_group_id,
|
||||
project_id=project_id,
|
||||
subscriber_id=subscriber_id,
|
||||
tags=tags,
|
||||
updated_since=updated_since,
|
||||
board_id=board_id,
|
||||
worklist_id=worklist_id,
|
||||
tags_filter_type=tags_filter_type,
|
||||
current_user=current_user)
|
||||
|
||||
# Filter by subscriber ID
|
||||
if subscriber_id is not None:
|
||||
subs = api_base.model_query(models.Subscription)
|
||||
subs = api_base.apply_query_filters(query=subs,
|
||||
model=models.Subscription,
|
||||
target_type='story',
|
||||
user_id=subscriber_id)
|
||||
subs = subs.subquery()
|
||||
subquery = subquery.join(subs, subs.c.target_id == models.Story.id)
|
||||
query = _order_and_paginate(
|
||||
query, offset, limit, [sort_field], sort_dir=sort_dir)
|
||||
|
||||
# Turn the whole shebang into a subquery.
|
||||
subquery = subquery.subquery('filtered_stories')
|
||||
query = query.options(subqueryload(models.Story.due_dates))
|
||||
query = query.options(subqueryload(models.Story.tags))
|
||||
query = query.options(subqueryload(models.Story.permissions))
|
||||
|
||||
# Return the story summary.
|
||||
query = api_base.model_query(models.StorySummary)\
|
||||
.options(subqueryload(models.StorySummary.tags))
|
||||
id_col = tuple(subquery.c)[0]
|
||||
query = query.join(subquery,
|
||||
models.StorySummary.id == id_col)
|
||||
|
||||
if status:
|
||||
query = query.filter(models.StorySummary.status.in_(status))
|
||||
|
||||
# paginate the query
|
||||
query = api_base.paginate_query(query=query,
|
||||
model=models.StorySummary,
|
||||
limit=limit,
|
||||
sort_key=sort_field,
|
||||
marker=marker,
|
||||
offset=offset,
|
||||
sort_dir=sort_dir)
|
||||
|
||||
raw_stories = query.all()
|
||||
return raw_stories
|
||||
results = query.all()
|
||||
return [_StorySummary(row) for row in results]
|
||||
|
||||
|
||||
def story_get_count(title=None, description=None, status=None,
|
||||
assignee_id=None, creator_id=None,
|
||||
project_group_id=None, project_id=None,
|
||||
subscriber_id=None, tags=None, updated_since=None,
|
||||
board_id=None, worklist_id=None,
|
||||
tags_filter_type="all", current_user=None):
|
||||
query = _story_build_query(title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
assignee_id=assignee_id,
|
||||
creator_id=creator_id,
|
||||
project_group_id=project_group_id,
|
||||
project_id=project_id,
|
||||
updated_since=updated_since,
|
||||
subscriber_id=subscriber_id,
|
||||
tags=tags,
|
||||
updated_since=updated_since,
|
||||
board_id=board_id,
|
||||
worklist_id=worklist_id,
|
||||
tags_filter_type=tags_filter_type,
|
||||
current_user=current_user)
|
||||
|
||||
# Filter by subscriber ID
|
||||
if subscriber_id is not None:
|
||||
subs = api_base.model_query(models.Subscription)
|
||||
subs = api_base.apply_query_filters(query=subs,
|
||||
model=models.Subscription,
|
||||
target_type='story',
|
||||
user_id=subscriber_id)
|
||||
subs = subs.subquery()
|
||||
query = query.join(subs, subs.c.target_id == models.Story.id)
|
||||
|
||||
# Turn the whole shebang into a subquery.
|
||||
subquery = query.subquery('filtered_stories')
|
||||
|
||||
# Return the story summary.
|
||||
query = api_base.model_query(models.StorySummary)\
|
||||
.options(subqueryload(models.StorySummary.tags))
|
||||
id_col = tuple(subquery.c)[0]
|
||||
query = query.join(subquery,
|
||||
models.StorySummary.id == id_col)
|
||||
|
||||
if status:
|
||||
query = query.filter(models.StorySummary.status.in_(status))
|
||||
|
||||
return query.count()
|
||||
|
||||
|
||||
def _story_build_query(title=None, description=None, assignee_id=None,
|
||||
creator_id=None, project_group_id=None,
|
||||
project_id=None, updated_since=None, tags=None,
|
||||
board_id=None, worklist_id=None, tags_filter_type='all',
|
||||
current_user=None, session=None):
|
||||
# First build a standard story query.
|
||||
query = api_base.model_query(models.Story.id, session=session).distinct()
|
||||
def _story_query_base():
|
||||
session = api_base.get_session()
|
||||
select_items = []
|
||||
select_items.append(models.Story)
|
||||
select_items.append(
|
||||
expr.case(
|
||||
[(func.sum(models.Task.status.in_(STORY_STATUSES['active'])) > 0,
|
||||
'active'),
|
||||
((func.sum(models.Task.status == 'merged')) > 0, 'merged')],
|
||||
else_='invalid'
|
||||
).label('status')
|
||||
)
|
||||
for task_status in models.Task.TASK_STATUSES:
|
||||
select_items.append(expr.cast(
|
||||
func.sum(models.Task.status == task_status), Integer
|
||||
).label(task_status))
|
||||
query = session.query(*select_items)
|
||||
|
||||
# This join is needed to be able to do the status calculations
|
||||
query = query.outerjoin(
|
||||
(models.Task, models.Task.story_id == models.Story.id))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def _story_build_query(title=None, description=None, status=None,
|
||||
assignee_id=None, creator_id=None,
|
||||
project_group_id=None, project_id=None,
|
||||
subscriber_id=None, tags=None, updated_since=None,
|
||||
board_id=None, worklist_id=None,
|
||||
tags_filter_type="all", current_user=None):
|
||||
# Get a basic story query, containing task summaries
|
||||
query = _story_query_base()
|
||||
|
||||
# Get rid of anything the requester can't see
|
||||
query = api_base.filter_private_stories(query, current_user)
|
||||
|
||||
# Apply basic filters
|
||||
query = api_base.apply_query_filters(query=query,
|
||||
@ -190,12 +296,11 @@ def _story_build_query(title=None, description=None, assignee_id=None,
|
||||
title=title,
|
||||
description=description,
|
||||
creator_id=creator_id)
|
||||
|
||||
# Only get stories that have been updated since a given datetime
|
||||
if updated_since:
|
||||
query = query.filter(models.Story.updated_at > updated_since)
|
||||
|
||||
# Filter out stories that the current user can't see
|
||||
query = api_base.filter_private_stories(query, current_user)
|
||||
|
||||
# Filtering by tags
|
||||
if tags:
|
||||
if tags_filter_type == 'all':
|
||||
@ -207,47 +312,97 @@ def _story_build_query(title=None, description=None, assignee_id=None,
|
||||
else:
|
||||
raise exc.NotFound("Tags filter not found.")
|
||||
|
||||
# Are we filtering by project group?
|
||||
# Filtering by project group
|
||||
if project_group_id:
|
||||
query = query.join(
|
||||
(models.Task, models.Task.story_id == models.Story.id))
|
||||
query = query.join(models.Project,
|
||||
models.project_group_mapping,
|
||||
models.ProjectGroup)
|
||||
query = query.filter(models.ProjectGroup.id == project_group_id)
|
||||
# The project_group_mapping table contains all the information we need
|
||||
# here; namely the project id (to map to tasks) and the project group
|
||||
# id (which we're actually filtering by). There's no need to actually
|
||||
# join either of the projects or project_groups tables.
|
||||
pgm = models.project_group_mapping
|
||||
query = query.join((pgm, pgm.c.project_id == models.Task.project_id))
|
||||
query = query.filter(pgm.c.project_group_id == project_group_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, models.Task.story_id == models.Story.id))
|
||||
if assignee_id:
|
||||
query = query.filter(models.Task.assignee_id == assignee_id)
|
||||
if project_id:
|
||||
query = query.filter(models.Task.project_id == project_id)
|
||||
# Filtering by task assignee
|
||||
if assignee_id:
|
||||
query = query.filter(models.Task.assignee_id == assignee_id)
|
||||
|
||||
# Filtering by project
|
||||
if project_id:
|
||||
query = query.filter(models.Task.project_id == project_id)
|
||||
|
||||
# Filtering by subscriber
|
||||
if subscriber_id:
|
||||
on_clause = and_(
|
||||
models.Subscription.target_id == models.Story.id,
|
||||
models.Subscription.target_type == 'story'
|
||||
)
|
||||
query = query.join((models.Subscription, on_clause))
|
||||
query = query.filter(models.Subscription.user_id == subscriber_id)
|
||||
|
||||
# Filtering by either worklist or board requires joining the worklists
|
||||
# table, via worklist_items
|
||||
if worklist_id or board_id:
|
||||
query = query.outerjoin(
|
||||
(models.WorklistItem,
|
||||
models.WorklistItem.item_id == models.Story.id))
|
||||
query = query.filter(models.WorklistItem.item_type == "story")
|
||||
query = query.outerjoin(models.Worklist)
|
||||
on_clause = and_(
|
||||
models.WorklistItem.item_id == models.Story.id,
|
||||
models.WorklistItem.item_type == 'story'
|
||||
)
|
||||
query = query.join((models.WorklistItem, on_clause), models.Worklist)
|
||||
|
||||
# Filter by worklist
|
||||
if worklist_id:
|
||||
query = query.filter(models.Worklist.id == worklist_id)
|
||||
query = api_base.filter_private_worklists(
|
||||
query, current_user, hide_lanes=False)
|
||||
query = query.filter(models.Worklist.id == worklist_id)
|
||||
|
||||
# Filter by board
|
||||
if board_id:
|
||||
query = query.outerjoin(models.BoardWorklist, models.Board)
|
||||
query = query.join(models.BoardWorklist, models.Board)
|
||||
query = api_base.filter_private_boards(query, current_user)
|
||||
query = query.filter(models.Board.id == board_id)
|
||||
query = api_base.filter_private_worklists(
|
||||
query, current_user, hide_lanes=False)
|
||||
|
||||
return query.distinct()
|
||||
# We need to do GROUP BY to allow the status calculation to work correctly
|
||||
query = query.group_by(models.Story.id)
|
||||
|
||||
# Filter by story status. This duplicates the definition of the different
|
||||
# story statuses from the SELECT clause of the query, but means we can
|
||||
# do all the filtering without a subquery.
|
||||
if status is not None:
|
||||
if any(s not in STORY_STATUSES for s in status):
|
||||
raise ValueError(
|
||||
f'Story status must be in {STORY_STATUSES.keys()}')
|
||||
criteria = []
|
||||
if 'active' in status:
|
||||
# A story is active if it has at least one unmerged valid task,
|
||||
# so the check is simple
|
||||
criteria.append(func.sum(
|
||||
models.Task.status.in_(STORY_STATUSES['active'])) > 0
|
||||
)
|
||||
if 'merged' in status:
|
||||
# A story is merged if it has at least one merged task, and also
|
||||
# doesn't meet the criteria to be active
|
||||
criteria.append(and_(
|
||||
func.sum(models.Task.status.in_(STORY_STATUSES['merged'])) > 0,
|
||||
func.sum(models.Task.status.in_(STORY_STATUSES['active'])) == 0
|
||||
))
|
||||
if 'invalid' in status:
|
||||
# A story is invalid if it only has invalid tasks, or no tasks
|
||||
criteria.append(or_(
|
||||
and_(
|
||||
func.sum(
|
||||
models.Task.status.in_(STORY_STATUSES['invalid'])
|
||||
) >= 0,
|
||||
func.sum(
|
||||
models.Task.status.in_(
|
||||
STORY_STATUSES['active']
|
||||
+ STORY_STATUSES['merged']
|
||||
)
|
||||
) == 0
|
||||
),
|
||||
func.sum(models.Task.id) == None # noqa
|
||||
))
|
||||
query = query.having(or_(*criteria))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def story_create(values):
|
||||
@ -344,7 +499,7 @@ def story_remove_tag(story_id, tag_name, current_user=None):
|
||||
|
||||
|
||||
def story_delete(story_id, current_user=None):
|
||||
story = story_get(story_id, current_user=current_user)
|
||||
story = story_get_simple(story_id, current_user=current_user)
|
||||
|
||||
if story:
|
||||
api_base.entity_hard_delete(models.Story, story_id)
|
||||
@ -417,9 +572,7 @@ def story_can_mutate(story, new_story_type_id):
|
||||
|
||||
|
||||
def create_permission(story, users, teams, session=None):
|
||||
story = api_base.model_query(models.Story, session) \
|
||||
.options(subqueryload(models.Story.tags)) \
|
||||
.filter_by(id=story.id).first()
|
||||
story = story_get_simple(story.id, session=session, no_permissions=True)
|
||||
permission_dict = {
|
||||
'name': 'view_story_%d' % story.id,
|
||||
'codename': 'view_story'
|
||||
@ -438,9 +591,7 @@ def create_permission(story, users, teams, session=None):
|
||||
|
||||
|
||||
def update_permission(story, users, teams, session=None):
|
||||
story = api_base.model_query(models.Story, session) \
|
||||
.options(subqueryload(models.Story.tags)) \
|
||||
.filter_by(id=story.id).first()
|
||||
story = story_get_simple(story.id, session=session, no_permissions=True)
|
||||
if not story.permissions:
|
||||
raise exc.NotFound(_("Permissions for story %d not found.")
|
||||
% story.id)
|
||||
|
@ -34,6 +34,7 @@ from sqlalchemy.ext import declarative
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy.orm.collections import attribute_mapped_collection
|
||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||
from sqlalchemy.orm import backref, relationship
|
||||
from sqlalchemy import schema
|
||||
from sqlalchemy import select
|
||||
@ -333,6 +334,23 @@ class Story(FullText, ModelBuilder, Base):
|
||||
_public_fields = ["id", "creator_id", "title", "description", "is_bug",
|
||||
"tasks", "events", "tags"]
|
||||
|
||||
def as_dict(self):
|
||||
d = super(Story, self).as_dict()
|
||||
try:
|
||||
d["due_dates"] = [due_date for due_date in self.due_dates]
|
||||
except DetachedInstanceError:
|
||||
pass
|
||||
try:
|
||||
d["permissions"] = [permission for permission in self.permissions]
|
||||
except DetachedInstanceError:
|
||||
pass
|
||||
try:
|
||||
d["tags"] = [t.name for t in self.tags]
|
||||
except DetachedInstanceError:
|
||||
pass
|
||||
|
||||
return d
|
||||
|
||||
|
||||
class Task(FullText, ModelBuilder, Base):
|
||||
__fulltext_columns__ = ['title']
|
||||
|
@ -418,16 +418,16 @@ class TestStorySearch(base.FunctionalTest):
|
||||
result = results.json[0]
|
||||
self.assertEqual(1, result['id'])
|
||||
|
||||
def test_search_marker(self):
|
||||
def test_search_offset(self):
|
||||
url = self.build_search_url({
|
||||
'title': 'foo',
|
||||
'marker': 1 # Last item in previous list.
|
||||
'offset': 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-Marker'])
|
||||
self.assertEqual('1', results.headers['X-Offset'])
|
||||
|
||||
result = results.json[0]
|
||||
self.assertEqual(3, result['id'])
|
||||
|
Loading…
Reference in New Issue
Block a user