Add API functionality for automatic worklist filters

Automatic worklists are worklists whose contents are automatically
generated on the fly based on filters set by the owners of the worklist.

This can be used to create a list which automatically updates its
contents, eg. "All the unmerged tasks assigned to Adam Coldrick".

Change-Id: I5ea21808cc5aad058b1309e22d22f409b09b5c0a
This commit is contained in:
Adam Coldrick 2015-10-28 16:14:55 +00:00
parent fea6d30859
commit 428bd522f2
6 changed files with 493 additions and 42 deletions

View File

@ -472,6 +472,45 @@ class TaskStatus(base.APIBase):
name = wtypes.text
class FilterCriterion(base.APIBase):
"""Represents a filter used to construct an automatic worklist."""
type = wtypes.text
"""The type of objects to filter, Story or Task."""
title = wtypes.text
"""The title of the criterion, as displayed in the UI."""
filter_id = int
"""The ID of the WorklistFilter this criterion is for."""
negative = bool
"""Whether to return all items matching or not matching the criterion."""
value = wtypes.text
"""The value to use as a criterion."""
field = wtypes.text
"""The field to filter by."""
class WorklistFilter(base.APIBase):
"""Represents a set of criteria to filter items using AND."""
type = wtypes.text
"""The type of objects to filter, Story or Task."""
list_id = int
"""The ID of the Worklist this filter is for."""
filter_criteria = wtypes.ArrayType(FilterCriterion)
"""The list of criteria to apply."""
def resolve_criteria(self, filter):
self.filter_criteria = [FilterCriterion.from_db_model(criterion)
for criterion in filter.criteria]
class DueDate(base.APIBase):
"""Represents a due date for tasks/stories."""
@ -540,24 +579,6 @@ class DueDate(base.APIBase):
self.assignable = due_dates_api.assignable(due_date, user)
# NOTE(SotK): Criteria/Criterion is used as the existing code in the webclient
# refers to such filters as Criteria.
class WorklistCriterion(base.APIBase):
"""Represents a filter used to construct an automatic worklist."""
title = wtypes.text
"""The title of the filter, as displayed in the UI."""
list_id = int
"""The ID of the Worklist this filter is for."""
value = wtypes.text
"""The value to use as a filter."""
field = wtypes.text
"""The field to filter by."""
class WorklistItem(base.APIBase):
"""Represents an item in a worklist.
@ -595,6 +616,26 @@ class WorklistItem(base.APIBase):
resolved = DueDate.from_db_model(due_date)
self.resolved_due_date = resolved
def resolve_item(self, item):
user_id = request.current_user_id
if item.item_type == 'story':
story = stories_api.story_get(item.item_id)
if story is None:
return False
self.story = Story.from_db_model(story)
due_dates = [date.id for date in story.due_dates
if due_dates_api.visible(date, user_id)]
self.story.due_dates = due_dates
elif item.item_type == 'task':
task = tasks_api.task_get(item.item_id)
if task is None or task.story is None:
return False
self.task = Task.from_db_model(task)
due_dates = [date.id for date in task.due_dates
if due_dates_api.visible(date, user_id)]
self.task.due_dates = due_dates
return True
class Worklist(base.APIBase):
"""Represents a worklist."""
@ -621,6 +662,9 @@ class Worklist(base.APIBase):
"""A flag to identify whether the contents are obtained by a filter or are
stored in the database."""
filters = wtypes.ArrayType(WorklistFilter)
"""A list of filters used if this is an "automatic" worklist."""
owners = wtypes.ArrayType(int)
"""A list of the IDs of the users who have full permissions."""
@ -634,26 +678,29 @@ class Worklist(base.APIBase):
"""Resolve the contents of this worklist."""
self.items = []
user_id = request.current_user_id
if worklist.automatic:
self._resolve_automatic_items(worklist, user_id)
else:
self._resolve_set_items(worklist, user_id)
def _resolve_automatic_items(self, worklist, user_id):
for item in worklists_api.filter_items(worklist):
item_model = WorklistItem(**item)
valid = item_model.resolve_item(item_model)
if not valid:
continue
item_model.resolve_due_date(item_model)
self.items.append(item_model)
self.items.sort(key=lambda x: x.list_position)
def _resolve_set_items(self, worklist, user_id):
for item in worklist.items:
if item.archived:
continue
item_model = WorklistItem.from_db_model(item)
if item.item_type == 'story':
story = stories_api.story_get(item.item_id)
if story is None:
continue
item_model.story = Story.from_db_model(story)
due_dates = [date.id for date in story.due_dates
if due_dates_api.visible(date, user_id)]
item_model.story.due_dates = due_dates
elif item.item_type == 'task':
task = tasks_api.task_get(item.item_id)
if task is None or task.story is None:
continue
item_model.task = Task.from_db_model(task)
due_dates = [date.id for date in task.due_dates
if due_dates_api.visible(date, user_id)]
item_model.task.due_dates = due_dates
valid = item_model.resolve_item(item)
if not valid:
continue
item_model.resolve_due_date(item)
self.items.append(item_model)
self.items.sort(key=lambda x: x.list_position)
@ -662,6 +709,13 @@ class Worklist(base.APIBase):
self.owners = worklists_api.get_owners(worklist)
self.users = worklists_api.get_users(worklist)
def resolve_filters(self, worklist):
self.filters = []
for filter in worklist.filters:
model = WorklistFilter.from_db_model(filter)
model.resolve_criteria(filter)
self.filters.append(model)
class Lane(base.APIBase):
"""Represents a lane in a kanban board."""

View File

@ -88,6 +88,106 @@ class PermissionsController(rest.RestController):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
class FilterSubcontroller(rest.RestController):
"""Manages filters on automatic worklists."""
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose(wmodels.WorklistFilter, int, int)
def get_one(self, worklist_id, filter_id):
"""Get a single filter for the worklist.
:param worklist_id: The ID of the worklist.
:param filter_id: The ID of the filter.
"""
worklist = worklists_api.get(worklist_id)
user_id = request.current_user_id
if not worklist or not worklists_api.visible(worklist, user_id):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
filter = worklists_api.get_filter(worklist, filter_id)
return wmodels.WorklistFilter.from_db_model(filter)
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.WorklistFilter], int)
def get(self, worklist_id):
"""Get filters for an automatic worklist.
:param worklist_id: The ID of the worklist.
"""
worklist = worklists_api.get(worklist_id)
user_id = request.current_user_id
if not worklist or not worklists_api.visible(worklist, user_id):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
return [wmodels.WorklistFilter.from_db_model(filter)
for filter in worklist.filters]
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.WorklistFilter, int,
body=wmodels.WorklistFilter)
def post(self, worklist_id, filter):
"""Create a new filter for the worklist.
:param worklist_id: The ID of the worklist to set the filter on.
:param filter: The filter to set.
"""
worklist = worklists_api.get(worklist_id)
user_id = request.current_user_id
if not worklists_api.editable(worklist, user_id):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
created = worklists_api.create_filter(worklist_id, filter.as_dict())
model = wmodels.WorklistFilter.from_db_model(created)
model.resolve_criteria(created)
return model
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.WorklistFilter, int, int,
body=wmodels.WorklistFilter)
def put(self, worklist_id, filter_id, filter):
"""Update a filter on the worklist.
:param worklist_id: The ID of the worklist.
:param filter_id: The ID of the filter to be updated.
:param filter: The new contents of the filter.
"""
worklist = worklists_api.get(worklist_id)
user_id = request.current_user_id
if not worklists_api.editable(worklist, user_id):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
update_dict = filter.as_dict(omit_unset=True)
updated = worklists_api.update_filter(filter_id, update_dict)
return wmodels.WorklistFilter.from_db_model(updated)
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(None, int, int)
def delete(self, worklist_id, filter_id):
"""Delete a filter from a worklist.
:param worklist_id: The ID of the worklist.
:param filter_id: The ID of the filter to be deleted.
"""
worklist = worklists_api.get(worklist_id)
user_id = request.current_user_id
if not worklists_api.editable(worklist, user_id):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
worklists_api.delete_filter(filter_id)
class ItemsSubcontroller(rest.RestController):
"""Manages operations on the items in worklists."""
@ -105,6 +205,10 @@ class ItemsSubcontroller(rest.RestController):
if not worklist or not worklists_api.visible(worklist, user_id):
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
if worklist.automatic:
return [wmodels.WorklistItem(**item)
for item in worklists_api.filter_items(worklist)]
if worklist.items is None:
return []
@ -211,6 +315,7 @@ class WorklistsController(rest.RestController):
worklist_model = wmodels.Worklist.from_db_model(worklist)
worklist_model.resolve_items(worklist)
worklist_model.resolve_permissions(worklist)
worklist_model.resolve_filters(worklist)
return worklist_model
else:
raise exc.NotFound(_("Worklist %s not found") % worklist_id)
@ -281,6 +386,8 @@ class WorklistsController(rest.RestController):
worklist_dict.update({"creator_id": user_id})
if 'items' in worklist_dict:
del worklist_dict['items']
filters = worklist_dict.pop('filters')
owners = worklist_dict.pop('owners')
users = worklist_dict.pop('users')
if not owners:
@ -303,6 +410,11 @@ class WorklistsController(rest.RestController):
worklists_api.create_permission(created_worklist.id, edit_permission)
worklists_api.create_permission(created_worklist.id, move_permission)
if worklist_dict['automatic']:
for filter in filters:
worklists_api.create_filter(created_worklist.id,
filter.as_dict())
return wmodels.Worklist.from_db_model(created_worklist)
@decorators.db_exceptions
@ -323,8 +435,13 @@ class WorklistsController(rest.RestController):
if worklist.items:
del worklist.items
updated_worklist = worklists_api.update(
id, worklist.as_dict(omit_unset=True))
# We don't use this endpoint to update the worklist's filters either
if worklist.filters:
del worklist.filters
worklist_dict = worklist.as_dict(omit_unset=True)
updated_worklist = worklists_api.update(id, worklist_dict)
if worklists_api.visible(updated_worklist, user_id):
worklist_model = wmodels.Worklist.from_db_model(updated_worklist)
@ -352,3 +469,4 @@ class WorklistsController(rest.RestController):
items = ItemsSubcontroller()
permissions = PermissionsController()
filters = FilterSubcontroller()

View File

@ -49,6 +49,9 @@ def story_get_all(title=None, description=None, status=None, assignee_id=None,
if not sort_dir:
sort_dir = 'asc'
if not isinstance(status, list) and status is not None:
status = [status]
# Build the query.
subquery = _story_build_query(title=title,
description=description,

View File

@ -314,3 +314,183 @@ def editable_contents(worklist, user=None):
permissions = get_permissions(worklist, user)
return any(name in permissions
for name in ['edit_worklist', 'move_items'])
def create_filter(worklist_id, filter_dict):
criteria = filter_dict.pop('filter_criteria')
filter_dict['list_id'] = worklist_id
filter = api_base.entity_create(models.WorklistFilter, filter_dict)
filter = api_base.entity_get(models.WorklistFilter, filter.id)
filter.criteria = []
for criterion in criteria:
criterion_dict = criterion.as_dict()
criterion_dict['filter_id'] = filter.id
filter.criteria.append(
api_base.entity_create(models.FilterCriterion, criterion_dict))
return filter
def update_filter(filter_id, update):
old_filter = api_base.entity_get(models.WorklistFilter, filter_id)
if 'filter_criteria' in update:
new_ids = [criterion.id for criterion in update['filter_criteria']]
for criterion in update['filter_criteria']:
criterion_dict = criterion.as_dict(omit_unset=True)
if 'id' in criterion_dict:
existing = api_base.entity_get(models.FilterCriterion,
criterion['id'])
if existing.as_dict() != criterion_dict:
api_base.entity_update(models.FilterCriterion,
criterion_dict['id'],
criterion_dict)
else:
created = api_base.entity_create(models.FilterCriterion,
criterion_dict)
old_filter.criteria.append(created)
for criterion in old_filter.criteria:
if criterion.id not in new_ids:
old_filter.criteria.remove(criterion)
del update['filter_criteria']
return api_base.entity_update(models.WorklistFilter, filter_id, update)
def delete_filter(filter_id):
filter = api_base.entity_get(models.WorklistFilter, filter_id)
for criterion in filter.criteria:
api_base.entity_hard_delete(models.FilterCriterion, criterion.id)
api_base.entity_hard_delete(models.WorklistFilter, filter_id)
def translate_criterion_to_field(criterion):
criterion_fields = {
'Project': 'project_id',
'ProjectGroup': 'project_group_id',
'Story': 'story_id',
'User': 'assignee_id',
'StoryStatus': 'status',
'Tags': 'tags',
'TaskStatus': 'status',
'Text': 'title'
}
if criterion.field not in criterion_fields:
return None
return criterion_fields[criterion.field]
def filter_stories(worklist, filters):
filter_queries = []
for filter in filters:
subquery = api_base.model_query(models.Story.id).distinct().subquery()
query = api_base.model_query(models.StorySummary)
query = query.join(subquery, models.StorySummary.id == subquery.c.id)
query = query.join(models.Task,
models.Project,
models.project_group_mapping,
models.ProjectGroup)
for criterion in filter.criteria:
attr = translate_criterion_to_field(criterion)
if hasattr(models.StorySummary, attr):
model = models.StorySummary
else:
if attr in ('assignee_id', 'project_id'):
model = models.Task
elif attr == 'project_group_id':
model = models.ProjectGroup
attr = 'id'
else:
continue
if attr == 'tags':
if criterion.negative:
query = query.filter(
~models.StorySummary.tags.any(
models.StoryTag.name.in_([criterion.value])))
else:
query = query.filter(
models.StorySummary.tags.any(
models.StoryTag.name.in_([criterion.value])))
continue
if criterion.negative:
query = query.filter(
getattr(model, attr) != criterion.value)
else:
query = query.filter(
getattr(model, attr) == criterion.value)
filter_queries.append(query)
if len(filter_queries) > 1:
query = filter_queries[0]
query = query.union(*filter_queries[1:])
return query.all()
elif len(filter_queries) == 1:
return filter_queries[0].all()
else:
return []
def filter_tasks(worklist, filters):
filter_queries = []
for filter in filters:
query = api_base.model_query(models.Task)
query = query.join(models.Project,
models.project_group_mapping,
models.ProjectGroup)
for criterion in filter.criteria:
attr = translate_criterion_to_field(criterion)
if hasattr(models.Task, attr):
model = models.Task
elif attr == 'project_group_id':
model = models.ProjectGroup
attr = 'id'
else:
continue
if criterion.negative:
query = query.filter(getattr(model, attr) != criterion.value)
else:
query = query.filter(getattr(model, attr) == criterion.value)
filter_queries.append(query)
if len(filter_queries) > 1:
query = filter_queries[0]
query = query.union(*filter_queries[1:])
return query.all()
elif len(filter_queries) == 1:
return filter_queries[0].all()
else:
return []
def filter_items(worklist):
story_filters = [f for f in worklist.filters if f.type == 'Story']
task_filters = [f for f in worklist.filters if f.type == 'Task']
filtered_stories = []
filtered_tasks = []
if story_filters:
filtered_stories = filter_stories(worklist, story_filters)
if task_filters:
filtered_tasks = filter_tasks(worklist, task_filters)
items = []
for story in filtered_stories:
items.append({
'list_id': worklist.id,
'item_id': story.id,
'item_type': 'story',
'list_position': 0,
'display_due_date': None
})
for task in filtered_tasks:
items.append({
'list_id': worklist.id,
'item_id': task.id,
'item_type': 'task',
'list_position': 0,
'display_due_date': None
})
return items

View File

@ -0,0 +1,83 @@
# 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.
#
"""Use filters with multiple criteria for automatic worklists
Revision ID: 056
Revises: 055
Create Date: 2016-03-04 13:31:01.600372
"""
# revision identifiers, used by Alembic.
revision = '056'
down_revision = '055'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
import storyboard
def upgrade(active_plugins=None, options=None):
op.create_table(
'worklist_filters',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', storyboard.db.decorators.UTCDateTime(),
nullable=True),
sa.Column('updated_at', storyboard.db.decorators.UTCDateTime(),
nullable=True),
sa.Column('type', sa.Unicode(length=50), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['list_id'], ['worklists.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table(
'filter_criteria',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', storyboard.db.decorators.UTCDateTime(),
nullable=True),
sa.Column('updated_at', storyboard.db.decorators.UTCDateTime(),
nullable=True),
sa.Column('title', sa.Unicode(length=100), nullable=False),
sa.Column('value', sa.Unicode(length=50), nullable=False),
sa.Column('field', sa.Unicode(length=50), nullable=False),
sa.Column('negative', sa.Boolean(), nullable=False),
sa.Column('filter_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['filter_id'], ['worklist_filters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_table('worklist_criteria')
def downgrade(active_plugins=None, options=None):
op.create_table(
'worklist_criteria',
sa.Column('id', mysql.INTEGER(display_width=11), nullable=False),
sa.Column('created_at', mysql.DATETIME(), nullable=True),
sa.Column('updated_at', mysql.DATETIME(), nullable=True),
sa.Column('title', mysql.VARCHAR(length=100), nullable=False),
sa.Column('list_id', mysql.INTEGER(display_width=11),
autoincrement=False, nullable=False),
sa.Column('value', mysql.VARCHAR(length=50), nullable=False),
sa.Column('field', mysql.VARCHAR(length=50), nullable=False),
sa.ForeignKeyConstraint(['list_id'], [u'worklists.id'],
name=u'worklist_criteria_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset=u'latin1',
mysql_engine=u'InnoDB'
)
op.drop_table('filter_criteria')
op.drop_table('worklist_filters')

View File

@ -541,16 +541,29 @@ class WorklistItem(ModelBuilder, Base):
"item_id"]
class WorklistCriteria(FullText, ModelBuilder, Base):
__tablename__ = "worklist_criteria"
class FilterCriterion(FullText, ModelBuilder, Base):
__tablename__ = "filter_criteria"
__fulltext_columns__ = ['title']
title = Column(Unicode(CommonLength.top_middle_length), nullable=True)
list_id = Column(Integer, ForeignKey('worklists.id'), nullable=False)
title = Column(Unicode(CommonLength.top_middle_length), nullable=False)
value = Column(Unicode(CommonLength.top_short_length), nullable=False)
field = Column(Unicode(CommonLength.top_short_length), nullable=False)
negative = Column(Boolean, default=False, nullable=False)
filter_id = Column(Integer, ForeignKey('worklist_filters.id'),
nullable=False)
filter = relationship('WorklistFilter', backref='criteria')
_public_fields = ["id", "title", "list_id", "value", "field"]
_public_fields = ["id", "title", "value", "field", "negative",
"filter_id"]
class WorklistFilter(ModelBuilder, Base):
__tablename__ = "worklist_filters"
type = Column(Unicode(CommonLength.top_short_length), nullable=False)
list_id = Column(Integer, ForeignKey('worklists.id'), nullable=False)
_public_fields = ["id", "list_id", "type"]
class Worklist(FullText, ModelBuilder, Base):
@ -564,7 +577,7 @@ class Worklist(FullText, ModelBuilder, Base):
archived = Column(Boolean, default=False)
automatic = Column(Boolean, default=False)
items = relationship(WorklistItem)
criteria = relationship(WorklistCriteria)
filters = relationship(WorklistFilter)
permissions = relationship("Permission", secondary="worklist_permissions")
_public_fields = ["id", "title", "creator_id", "project_id",