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