diff --git a/storyboard/api/v1/wmodels.py b/storyboard/api/v1/wmodels.py index 8ec2f9b9..4d6cd4df 100644 --- a/storyboard/api/v1/wmodels.py +++ b/storyboard/api/v1/wmodels.py @@ -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.""" diff --git a/storyboard/api/v1/worklists.py b/storyboard/api/v1/worklists.py index 84f78c63..c71f40bf 100644 --- a/storyboard/api/v1/worklists.py +++ b/storyboard/api/v1/worklists.py @@ -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() diff --git a/storyboard/db/api/stories.py b/storyboard/db/api/stories.py index b5e2400d..97375092 100644 --- a/storyboard/db/api/stories.py +++ b/storyboard/db/api/stories.py @@ -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, diff --git a/storyboard/db/api/worklists.py b/storyboard/db/api/worklists.py index d462991d..5ba1c985 100644 --- a/storyboard/db/api/worklists.py +++ b/storyboard/db/api/worklists.py @@ -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 diff --git a/storyboard/db/migration/alembic_migrations/versions/056_use_filters_with_multiple_criteria_for_.py b/storyboard/db/migration/alembic_migrations/versions/056_use_filters_with_multiple_criteria_for_.py new file mode 100644 index 00000000..17b36ab3 --- /dev/null +++ b/storyboard/db/migration/alembic_migrations/versions/056_use_filters_with_multiple_criteria_for_.py @@ -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') diff --git a/storyboard/db/models.py b/storyboard/db/models.py index 3066794a..4131535b 100644 --- a/storyboard/db/models.py +++ b/storyboard/db/models.py @@ -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",