Collate story metadata for status

This patch extends teh story entity with summary metadata from the
 tasks table, so we can show different status counts for each story
 as well as an aggregate status derived from those tasks. This is the
 first of two steps to improve the UX on the story list page, by creating
 a 'resource' with usable properties we can filter on. For example,
 the UI by default will only select stories with a status of 'active', to show
 stories that need to be worked on.

 Explicit list of items done:
 - Created new 'virtual' resource called StorySummary that joins two
 tables and provides extra useful data for UI display and filtering.
 - Ensured that StorySummary is a left-join, so that stories with no
 active tasks still show up (though they'll have null task counts and be
 flagged as 'invalid')
 - Removed 'is_active' (soft-delete) from stories, as the 'active'-ness
 of a story is now derived from its tasks.
 - Switched the DELETE action over to hard-delete with only admin
 access.
 - Updated stories DB API to use new model and properly attach
 subqueries.
 - Confirmed that project filtering and paging still functions.

Change-Id: Ia2b71195eff081cc5b51f8398402dc00130d43db
This commit is contained in:
Michael Krotscheck
2014-04-09 14:10:44 -07:00
parent 1c7fe9a1a9
commit 3087dcd6d2
4 changed files with 127 additions and 24 deletions

View File

@@ -46,23 +46,40 @@ class Story(base.APIBase):
is_bug = bool
"""Is this a bug or a feature :)"""
is_active = bool
"""Is this an active story, or has it been deleted?"""
project_id = int
"""Optional parameter"""
creator_id = int
"""User ID of the Story creator"""
todo = int
"""The number of tasks remaining to be worked on."""
inprogress = int
"""The number of in-progress tasks for this story."""
review = int
"""The number of tasks in review for this story."""
merged = int
"""The number of merged tasks for this story."""
invalid = int
"""The number of invalid tasks for this story."""
status = unicode
"""The derived status of the story, one of 'active', 'merged', 'invalid'"""
@classmethod
def sample(cls):
return cls(
title="Use Storyboard to manage Storyboard",
description="We should use Storyboard to manage Storyboard.",
is_bug=False,
is_active=True,
creator_id=1)
creator_id=1,
todo=0,
inprogress=1,
review=1,
merged=0,
invalid=0,
status="active")
class StoriesController(rest.RestController):
@@ -150,7 +167,7 @@ class StoriesController(rest.RestController):
raise ClientSideError("Story %s not found" % id,
status_code=404)
@secure(checks.authenticated)
@secure(checks.superuser)
@wsme_pecan.wsexpose(Story, int)
def delete(self, story_id):
"""Delete this story.

View File

@@ -19,7 +19,7 @@ from storyboard.db import models
def story_get(story_id):
return api_base.entity_get(models.Story, story_id)
return api_base.entity_get(models.StorySummary, story_id)
def story_get_all(marker=None, limit=None, project_id=None):
@@ -28,7 +28,7 @@ def story_get_all(marker=None, limit=None, project_id=None):
limit=limit,
project_id=project_id)
else:
return api_base.entity_get_all(models.Story, is_active=True,
return api_base.entity_get_all(models.StorySummary,
marker=marker, limit=limit)
@@ -36,7 +36,7 @@ def story_get_count(project_id=None):
if project_id:
return _story_get_count_in_project(project_id)
else:
return api_base.entity_get_count(models.Story, is_active=True)
return api_base.entity_get_count(models.StorySummary)
def _story_get_all_in_project(project_id, marker=None, limit=None):
@@ -45,14 +45,13 @@ def _story_get_all_in_project(project_id, marker=None, limit=None):
sub_query = api_base.model_query(models.Task.story_id, session) \
.filter_by(project_id=project_id) \
.distinct(True) \
.subquery()
.subquery('project_tasks')
query = api_base.model_query(models.Story, session) \
.filter_by(is_active=True) \
.join(sub_query, models.Story.tasks)
query = api_base.model_query(models.StorySummary, session) \
.join(sub_query, models.StorySummary.id == sub_query.c.story_id)
query = api_base.paginate_query(query=query,
model=models.Story,
model=models.StorySummary,
limit=limit,
sort_keys=['id'],
marker=marker,
@@ -67,11 +66,10 @@ def _story_get_count_in_project(project_id):
sub_query = api_base.model_query(models.Task.story_id, session) \
.filter_by(project_id=project_id) \
.distinct(True) \
.subquery()
.subquery('project_tasks')
query = api_base.model_query(models.Story, session) \
.filter_by(is_active=True) \
.join(sub_query, models.Story.tasks)
query = api_base.model_query(models.StorySummary, session) \
.join(sub_query, models.StorySummary.id == sub_query.c.story_id)
return query.count()
@@ -88,5 +86,4 @@ def story_delete(story_id):
story = story_get(story_id)
if story:
story.is_active = False
api_base.entity_update(models.Story, story_id, story.as_dict())
api_base.entity_hard_delete(models.Story, story_id, story.as_dict())

View File

@@ -0,0 +1,48 @@
# 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.
#
"""Remove is_active from stories.
Revision ID: 015
Revises: 014
Create Date: 2014-04-09 16:52:36.375926
"""
# revision identifiers, used by Alembic.
revision = '015'
down_revision = '014'
from alembic import op
import sqlalchemy as sa
def upgrade(active_plugins=None, options=None):
### commands auto generated by Alembic - please adjust! ###
op.drop_column(u'stories', u'is_active')
### end Alembic commands ###
def downgrade(active_plugins=None, options=None):
### commands auto generated by Alembic - please adjust! ###
op.add_column(u'stories',
sa.Column('is_active', sa.Boolean(), default=True,
server_default="1",
nullable=False))
### end Alembic commands ###

View File

@@ -28,6 +28,9 @@ from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import relationship
from sqlalchemy import schema
from sqlalchemy import select
import sqlalchemy.sql.expression as expr
import sqlalchemy.sql.functions as func
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy import Unicode
@@ -171,7 +174,6 @@ class Story(Base):
tasks = relationship('Task', backref='story')
comments = relationship('Comment', backref='story')
tags = relationship('StoryTag', backref='story')
is_active = Column(Boolean, default=True)
_public_fields = ["id", "creator_id", "title", "description", "is_bug",
"tasks", "comments", "tags"]
@@ -234,3 +236,42 @@ class RefreshToken(Base):
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
refresh_token = Column(Unicode(100), nullable=False)
def _story_build_summary_query():
return select([Story,
func.cast(
func.sum(Task.status == 'todo'), Integer
).label('todo'),
func.cast(
func.sum(Task.status == 'inprogress'), Integer
).label('inprogress'),
func.cast(
func.sum(Task.status == 'review'), Integer
).label('review'),
func.cast(
func.sum(Task.status == 'merged'), Integer
).label('merged'),
func.cast(
func.sum(Task.status == 'invalid'), Integer
).label('invalid'),
expr.case(
[(func.sum(Task.status.in_(
['todo', 'inprogress', 'review'])) > 0,
'active'),
((func.sum(Task.status == 'merged')) > 0, 'merged')],
else_='invalid'
).label('status')],
None,
expr.Join(Story, Task, onclause=Story.id == Task.story_id,
isouter=True)) \
.group_by(Task.story_id) \
.alias('story_summary')
class StorySummary(Base):
__table__ = _story_build_summary_query()
_public_fields = ["id", "creator_id", "title", "description", "is_bug",
"tasks", "comments", "tags", "todo", "inprogress",
"review", "merged", "invalid", "status"]