From f52fde0d9801bbeba08eb114e56b3a3fdd53814d Mon Sep 17 00:00:00 2001 From: Adam Coldrick Date: Thu, 28 Apr 2016 21:49:17 +0000 Subject: [PATCH] Allow the creation of private stories This commit allows stories to be made "private". Private stories are only visible to people who have been granted permissions to view the story. Change-Id: Ibd99032611ba1fd82de706e4d25acb1e2b98808c --- storyboard/api/v1/due_dates.py | 6 +- storyboard/api/v1/search/sqlalchemy_impl.py | 46 +++++- storyboard/api/v1/stories.py | 59 ++++++-- storyboard/api/v1/subscriptions.py | 3 +- storyboard/api/v1/tags.py | 18 ++- storyboard/api/v1/tasks.py | 40 ++++-- storyboard/api/v1/wmodels.py | 95 ++++++++----- storyboard/api/v1/worklists.py | 47 +++++- storyboard/db/api/due_dates.py | 43 +++++- storyboard/db/api/stories.py | 149 ++++++++++++++++---- storyboard/db/api/subscriptions.py | 29 +++- storyboard/db/api/tasks.py | 60 +++++++- storyboard/db/api/worklists.py | 72 ++++++++-- storyboard/db/models.py | 11 +- 14 files changed, 550 insertions(+), 128 deletions(-) diff --git a/storyboard/api/v1/due_dates.py b/storyboard/api/v1/due_dates.py index 388489a1..03090665 100644 --- a/storyboard/api/v1/due_dates.py +++ b/storyboard/api/v1/due_dates.py @@ -242,14 +242,16 @@ class DueDatesController(rest.RestController): tasks = due_date_dict.pop('tasks') db_tasks = [] for task in tasks: - db_tasks.append(tasks_api.task_get(task.id)) + db_tasks.append(tasks_api.task_get( + task.id, current_user=request.current_user_id)) due_date_dict['tasks'] = db_tasks if 'stories' in due_date_dict: stories = due_date_dict.pop('stories') db_stories = [] for story in stories: - db_stories.append(stories_api.story_get_simple(story.id)) + db_stories.append(stories_api.story_get_simple( + story.id, current_user=request.current_user_id)) due_date_dict['stories'] = db_stories board = None diff --git a/storyboard/api/v1/search/sqlalchemy_impl.py b/storyboard/api/v1/search/sqlalchemy_impl.py index d8293438..afa7eb22 100644 --- a/storyboard/api/v1/search/sqlalchemy_impl.py +++ b/storyboard/api/v1/search/sqlalchemy_impl.py @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from sqlalchemy import and_, or_ from sqlalchemy.orm import subqueryload +from sqlalchemy.sql.expression import false, true from sqlalchemy_fulltext import FullTextSearch import sqlalchemy_fulltext.modes as FullTextMode @@ -54,10 +56,29 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine): return query.all() def stories_query(self, q, marker=None, offset=None, - limit=None, **kwargs): + limit=None, current_user=None, **kwargs): session = api_base.get_session() subquery = api_base.model_query(models.Story, session) + + # Filter out stories that the current user can't see + subquery = subquery.outerjoin(models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + subquery = subquery.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + subquery = subquery.filter(models.Story.private == false()) + subquery = self._build_fulltext_search(models.Story, subquery, q) subquery = self._apply_pagination(models.Story, subquery, marker, offset, limit) @@ -72,9 +93,30 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine): stories = query.all() return stories - def tasks_query(self, q, marker=None, offset=None, limit=None, **kwargs): + def tasks_query(self, q, marker=None, offset=None, limit=None, + current_user=None, **kwargs): session = api_base.get_session() query = api_base.model_query(models.Task, session) + + # Filter out tasks or stories that the current user can't see + query = query.outerjoin(models.Story, + models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + query = query.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + query = query.filter(models.Story.private == false()) + query = self._build_fulltext_search(models.Task, query, q) query = self._apply_pagination( models.Task, query, marker, offset, limit) diff --git a/storyboard/api/v1/stories.py b/storyboard/api/v1/stories.py index fc121b48..ad670a14 100644 --- a/storyboard/api/v1/stories.py +++ b/storyboard/api/v1/stories.py @@ -1,4 +1,5 @@ # Copyright (c) 2013 Mirantis Inc. +# Copyright (c) 2016 Codethink Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -35,6 +36,7 @@ from storyboard.common import decorators from storyboard.common import exception as exc from storyboard.db.api import stories as stories_api from storyboard.db.api import timeline_events as events_api +from storyboard.db.api import users as users_api from storyboard.openstack.common.gettextutils import _ # noqa @@ -46,6 +48,10 @@ SEARCH_ENGINE = search_engine.get_engine() def create_story_wmodel(story): story_model = wmodels.Story.from_db_model(story) story_model.summarize_task_statuses(story) + if story.permissions: + story_model.resolve_users(story) + else: + story_model.users = [] return story_model @@ -65,7 +71,8 @@ class StoriesController(rest.RestController): :param story_id: An ID of the story. """ - story = stories_api.story_get(story_id) + story = stories_api.story_get( + story_id, current_user=request.current_user_id) if story: return create_story_wmodel(story) @@ -108,7 +115,8 @@ class StoriesController(rest.RestController): # Resolve the marker record. marker_story = None if marker: - marker_story = stories_api.story_get(marker) + marker_story = stories_api.story_get( + marker, current_user=request.current_user_id) stories = stories_api \ .story_get_all(title=title, @@ -123,8 +131,10 @@ class StoriesController(rest.RestController): marker=marker_story, offset=offset, tags_filter_type=tags_filter_type, - limit=limit, sort_field=sort_field, - sort_dir=sort_dir) + limit=limit, + sort_field=sort_field, + sort_dir=sort_dir, + current_user=request.current_user_id) story_count = stories_api \ .story_get_count(title=title, description=description, @@ -135,7 +145,8 @@ class StoriesController(rest.RestController): project_id=project_id, subscriber_id=subscriber_id, tags=tags, - tags_filter_type=tags_filter_type) + tags_filter_type=tags_filter_type, + current_user=request.current_user_id) # Apply the query response headers. if limit: @@ -181,9 +192,18 @@ class StoriesController(rest.RestController): if "due_dates" in story_dict: del story_dict['due_dates'] + users = [] + if "users" in story_dict: + users = story_dict.pop("users") + if users is None: + users = [wmodels.User.from_db_model(users_api.user_get(user_id))] + created_story = stories_api.story_create(story_dict) events_api.story_created_event(created_story.id, user_id, story.title) + if story.private: + stories_api.create_permission(created_story, users) + return wmodels.Story.from_db_model(created_story) @decorators.db_exceptions @@ -202,7 +222,8 @@ class StoriesController(rest.RestController): abort(400, _("Now you can't change story type to %s.") % story.story_type_id) - original_story = stories_api.story_get_simple(story_id) + original_story = stories_api.story_get_simple( + story_id, current_user=request.current_user_id) if not original_story: raise exc.NotFound(_("Story %s not found") % story_id) @@ -224,9 +245,26 @@ class StoriesController(rest.RestController): if 'tags' in story_dict: story_dict.pop('tags') + users = story_dict.get("users", []) + ids = [user.id for user in users] + if story.private: + if request.current_user_id not in ids \ + and not original_story.permissions: + users.append(wmodels.User.from_db_model( + users_api.user_get(request.current_user_id))) + if not original_story.permissions: + stories_api.create_permission(original_story, users) + updated_story = stories_api.story_update( story_id, - story_dict) + story_dict, + current_user=request.current_user_id) + + if users == [] and updated_story.private: + abort(400, _("Can't make a private story with no users")) + + if story.private: + stories_api.update_permission(updated_story, users) user_id = request.current_user_id events_api.story_details_changed_event(story_id, user_id, @@ -242,7 +280,8 @@ class StoriesController(rest.RestController): :param story_id: An ID of the story. """ - stories_api.story_delete(story_id) + stories_api.story_delete( + story_id, current_user=request.current_user_id) comments = CommentsController() events = TimeLineEventsController() @@ -260,10 +299,12 @@ class StoriesController(rest.RestController): :return: List of Stories matching the query. """ + user = request.current_user_id stories = SEARCH_ENGINE.stories_query(q=q, marker=marker, offset=offset, - limit=limit) + limit=limit, + current_user=user) return [create_story_wmodel(story) for story in stories] diff --git a/storyboard/api/v1/subscriptions.py b/storyboard/api/v1/subscriptions.py index 49a9004d..e7df08a2 100644 --- a/storyboard/api/v1/subscriptions.py +++ b/storyboard/api/v1/subscriptions.py @@ -158,7 +158,8 @@ class SubscriptionsController(rest.RestController): # Data sanity check: The resource must exist. resource = subscription_api.subscription_get_resource( target_type=subscription.target_type, - target_id=subscription.target_id) + target_id=subscription.target_id, + current_user=request.current_user_id) if not resource: abort(400, _('You cannot subscribe to a nonexistent resource.')) diff --git a/storyboard/api/v1/tags.py b/storyboard/api/v1/tags.py index d0a9a77e..d8201343 100644 --- a/storyboard/api/v1/tags.py +++ b/storyboard/api/v1/tags.py @@ -63,7 +63,8 @@ class TagsController(rest.RestController): return [wmodels.Tag.from_db_model(t) for t in tags] - story = stories_api.story_get(story_id) + story = stories_api.story_get( + story_id, current_user=request.current_user_id) if not story: raise exc.NotFound("Story %s not found" % story_id) @@ -78,16 +79,19 @@ class TagsController(rest.RestController): :param tags: A list of tags to be added. """ - story = stories_api.story_get(story_id) + story = stories_api.story_get( + story_id, current_user=request.current_user_id) if not story: raise exc.NotFound("Story %s not found" % story_id) for tag in tags: - stories_api.story_add_tag(story_id, tag) + 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) + story = stories_api.story_get( + story_id, current_user=request.current_user_id) events_api.tags_added_event(story_id=story_id, author_id=request.current_user_id, story_title=story.title, @@ -115,12 +119,14 @@ class TagsController(rest.RestController): :param tags: A list of tags to be removed. """ - story = stories_api.story_get(story_id) + story = stories_api.story_get( + story_id, current_user=request.current_user_id) if not story: raise exc.NotFound("Story %s not found" % story_id) for tag in tags: - stories_api.story_remove_tag(story_id, tag) + stories_api.story_remove_tag( + story_id, tag, current_user=request.current_user_id) events_api.tags_deleted_event(story_id=story_id, author_id=request.current_user_id, diff --git a/storyboard/api/v1/tasks.py b/storyboard/api/v1/tasks.py index 3355aef4..76cefb0c 100644 --- a/storyboard/api/v1/tasks.py +++ b/storyboard/api/v1/tasks.py @@ -90,7 +90,8 @@ def story_is_valid(task, branch): """Check that branch is restricted if story type is restricted. """ - story = stories_api.story_get(task.story_id) + story = stories_api.story_get( + task.story_id, current_user=request.current_user_id) if not story: raise exc.NotFound("Story %s not found." % task.story_id) @@ -260,7 +261,8 @@ class TasksPrimaryController(rest.RestController): :param task_id: An ID of the task. """ - task = tasks_api.task_get(task_id) + task = tasks_api.task_get( + task_id, current_user=request.current_user_id) if task: return wmodels.Task.from_db_model(task) @@ -314,7 +316,8 @@ class TasksPrimaryController(rest.RestController): sort_field=sort_field, sort_dir=sort_dir, marker=marker_task, - limit=limit) + limit=limit, + current_user=request.current_user_id) task_count = tasks_api \ .task_get_count(title=title, link=link, @@ -325,7 +328,8 @@ class TasksPrimaryController(rest.RestController): branch_id=branch_id, milestone_id=milestone_id, status=status, - priority=priority) + priority=priority, + current_user=request.current_user_id) # Apply the query response headers. if limit: @@ -374,7 +378,8 @@ class TasksPrimaryController(rest.RestController): :param task: A task within the request body. """ - original_task = copy.deepcopy(tasks_api.task_get(task_id)) + original_task = copy.deepcopy( + tasks_api.task_get(task_id, current_user=request.current_user_id)) if not original_task: raise exc.NotFound(_("Task %s not found.") % task_id) @@ -395,7 +400,8 @@ class TasksPrimaryController(rest.RestController): :param task_id: An ID of the task. """ - original_task = copy.deepcopy(tasks_api.task_get(task_id)) + original_task = copy.deepcopy( + tasks_api.task_get(task_id, current_user=request.current_user_id)) if not original_task: raise exc.NotFound(_("Task %s not found.") % task_id) @@ -419,10 +425,12 @@ class TasksPrimaryController(rest.RestController): :return: List of Tasks matching the query. """ + user = request.current_user_id tasks = SEARCH_ENGINE.tasks_query(q=q, marker=marker, offset=offset, - limit=limit) + limit=limit, + current_user=user) return [wmodels.Task.from_db_model(task) for task in tasks] @@ -442,7 +450,8 @@ class TasksNestedController(rest.RestController): :param story_id: An ID of the story. :param task_id: An ID of the task. """ - task = tasks_api.task_get(task_id) + task = tasks_api.task_get( + task_id, current_user=request.current_user_id) if task: if task.story_id != story_id: @@ -482,7 +491,8 @@ class TasksNestedController(rest.RestController): limit = max(0, limit) # Resolve the marker record. - marker_task = tasks_api.task_get(marker) + marker_task = tasks_api.task_get( + marker, current_user=request.current_user_id) tasks = tasks_api \ .task_get_all(title=title, @@ -498,7 +508,8 @@ class TasksNestedController(rest.RestController): sort_field=sort_field, sort_dir=sort_dir, marker=marker_task, - limit=limit) + limit=limit, + current_user=request.current_user_id) task_count = tasks_api \ .task_get_count(title=title, link=link, @@ -509,7 +520,8 @@ class TasksNestedController(rest.RestController): branch_id=branch_id, milestone_id=milestone_id, status=status, - priority=priority) + priority=priority, + current_user=request.current_user_id) # Apply the query response headers. response.headers['X-Limit'] = str(limit) @@ -565,7 +577,8 @@ class TasksNestedController(rest.RestController): :param task: a task within the request body. """ - original_task = copy.deepcopy(tasks_api.task_get(task_id)) + original_task = copy.deepcopy( + tasks_api.task_get(task_id, current_user=request.current_user_id)) if not original_task: raise exc.NotFound(_("Task %s not found") % task_id) @@ -591,7 +604,8 @@ class TasksNestedController(rest.RestController): :param task_id: An ID of the task. """ - original_task = copy.deepcopy(tasks_api.task_get(task_id)) + original_task = copy.deepcopy( + tasks_api.task_get(task_id, current_user=request.current_user_id)) if not original_task: raise exc.NotFound(_("Task %s not found.") % task_id) diff --git a/storyboard/api/v1/wmodels.py b/storyboard/api/v1/wmodels.py index 822749b8..a010fc09 100644 --- a/storyboard/api/v1/wmodels.py +++ b/storyboard/api/v1/wmodels.py @@ -1,4 +1,5 @@ # Copyright (c) 2014 Mirantis Inc. +# Copyright (c) 2016 Codethink Ltd # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -125,6 +126,39 @@ class TaskStatusCount(base.APIBase): count = int +class User(base.APIBase): + """Represents a user.""" + + full_name = wtypes.text + """Full (Display) name.""" + + openid = wtypes.text + """The unique identifier, returned by an OpneId provider""" + + email = wtypes.text + """Email Address.""" + + # Todo(nkonovalov): use teams to define superusers + is_superuser = bool + + last_login = datetime + """Date of the last login.""" + + enable_login = bool + """Whether this user is permitted to log in.""" + + @classmethod + def sample(cls): + return cls( + full_name="Bart Simpson", + openid="https://login.launchpad.net/+id/Abacaba", + email="skinnerstinks@springfield.net", + is_staff=False, + is_active=True, + is_superuser=True, + last_login=datetime(2014, 1, 1, 16, 42)) + + class Story(base.APIBase): """The Story is the main element of StoryBoard. It represents a user story (generally a bugfix or a feature) that needs to be implemented. It will be @@ -158,6 +192,10 @@ class Story(base.APIBase): due_dates = wtypes.ArrayType(int) + private = bool + + users = wtypes.ArrayType(User) + @classmethod def sample(cls): return cls( @@ -178,6 +216,11 @@ class Story(base.APIBase): key=task_status, count=getattr(story_summary, task_status)) self.task_statuses.append(task_count) + def resolve_users(self, story): + """Resolve the people who can see the story.""" + self.users = [User.from_db_model(user) + for user in story.permissions[0].users] + class Tag(base.APIBase): @@ -390,39 +433,6 @@ class TimeLineEvent(base.APIBase): return event_resolvers.tags_deleted(event) -class User(base.APIBase): - """Represents a user.""" - - full_name = wtypes.text - """Full (Display) name.""" - - openid = wtypes.text - """The unique identifier, returned by an OpneId provider""" - - email = wtypes.text - """Email Address.""" - - # Todo(nkonovalov): use teams to define superusers - is_superuser = bool - - last_login = datetime - """Date of the last login.""" - - enable_login = bool - """Whether this user is permitted to log in.""" - - @classmethod - def sample(cls): - return cls( - full_name="Bart Simpson", - openid="https://login.launchpad.net/+id/Abacaba", - email="skinnerstinks@springfield.net", - is_staff=False, - is_active=True, - is_superuser=True, - last_login=datetime(2014, 1, 1, 16, 42)) - - class RefreshToken(base.APIBase): """Represents a user refresh token.""" @@ -560,15 +570,18 @@ class DueDate(base.APIBase): def resolve_count_in_board(self, due_date, board): self.count = 0 for lane in board.lanes: - for card in lane.worklist.items: + for card in worklists_api.get_visible_items( + lane.worklist, current_user=request.current_user_id): if card.display_due_date == due_date.id: self.count += 1 def resolve_items(self, due_date): """Resolve the various lists for the due date.""" - self.tasks = [Task.from_db_model(task) for task in due_date.tasks] + stories, tasks = due_dates_api.get_visible_items( + due_date, current_user=request.current_user_id) + self.tasks = [Task.from_db_model(task) for task in tasks] self.stories = [Story.from_db_model(story) - for story in due_date.stories] + for story in stories] self.count = len(self.tasks) + len(self.stories) def resolve_permissions(self, due_date, user=None): @@ -619,7 +632,8 @@ class WorklistItem(base.APIBase): def resolve_item(self, item): user_id = request.current_user_id if item.item_type == 'story': - story = stories_api.story_get(item.item_id) + story = stories_api.story_get( + item.item_id, current_user=request.current_user_id) if story is None: return False self.story = Story.from_db_model(story) @@ -627,7 +641,8 @@ class WorklistItem(base.APIBase): 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) + task = tasks_api.task_get( + item.item_id, current_user=request.current_user_id) if task is None or task.story is None: return False self.task = Task.from_db_model(task) @@ -740,8 +755,10 @@ class Lane(base.APIBase): if resolve_items: self.worklist.resolve_items(lane.worklist) else: + items = worklists_api.get_visible_items( + lane.worklist, current_user=request.current_user_id) self.worklist.items = [WorklistItem.from_db_model(item) - for item in lane.worklist.items] + for item in items] class Board(base.APIBase): diff --git a/storyboard/api/v1/worklists.py b/storyboard/api/v1/worklists.py index 641bac4f..cdf34d17 100644 --- a/storyboard/api/v1/worklists.py +++ b/storyboard/api/v1/worklists.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015 Codethink Limited +# Copyright (c) 2015-2016 Codethink Limited # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ from storyboard.api.auth import authorization_checks as checks from storyboard.api.v1 import wmodels from storyboard.common import decorators from storyboard.common import exception as exc +from storyboard.db.api import stories as stories_api +from storyboard.db.api import tasks as tasks_api from storyboard.db.api import worklists as worklists_api from storyboard.openstack.common.gettextutils import _ # noqa @@ -214,8 +216,12 @@ class ItemsSubcontroller(rest.RestController): worklist.items.sort(key=lambda i: i.list_position) - return [wmodels.WorklistItem.from_db_model(item) - for item in worklist.items] + visible_items = worklists_api.get_visible_items( + worklist, current_user=request.current_user_id) + return [ + wmodels.WorklistItem.from_db_model(item) + for item in visible_items + ] @decorators.db_exceptions @secure(checks.authenticated) @@ -233,7 +239,20 @@ class ItemsSubcontroller(rest.RestController): if not worklists_api.editable_contents(worklists_api.get(id), user_id): raise exc.NotFound(_("Worklist %s not found") % id) - worklists_api.add_item(id, item_id, item_type, list_position) + item = None + if item_type == 'story': + item = stories_api.story_get( + item_id, current_user=request.current_user_id) + elif item_type == 'task': + item = tasks_api.task_get( + item_id, current_user=request.current_user_id) + if item is None: + raise exc.NotFound(_("Item %s refers to a non-existent task or " + "story.") % item_id) + + worklists_api.add_item( + id, item_id, item_type, list_position, + current_user=request.current_user_id) return wmodels.WorklistItem.from_db_model( worklists_api.get_item_at_position(id, list_position)) @@ -257,9 +276,23 @@ class ItemsSubcontroller(rest.RestController): if not worklists_api.editable_contents(worklists_api.get(id), user_id): raise exc.NotFound(_("Worklist %s not found") % id) - if worklists_api.get_item_by_id(item_id) is None: + card = worklists_api.get_item_by_id(item_id) + if card is None: raise exc.NotFound(_("Item %s seems to have been deleted, " "try refreshing your page.") % item_id) + + item = None + if card.item_type == 'story': + item = stories_api.story_get( + card.item_id, current_user=request.current_user_id) + elif card.item_type == 'task': + item = tasks_api.task_get( + card.item_id, current_user=request.current_user_id) + + if item is None: + raise exc.NotFound(_("Item %s refers to a non-existent task or " + "story.") % item_id) + worklists_api.move_item(id, item_id, list_position, list_id) if display_due_date is not None: @@ -364,9 +397,11 @@ class WorklistsController(rest.RestController): worklist.archived == archived): worklist_model = wmodels.Worklist.from_db_model(worklist) worklist_model.resolve_permissions(worklist) + visible_items = worklists_api.get_visible_items( + worklist, request.current_user_id) worklist_model.items = [ wmodels.WorklistItem.from_db_model(item) - for item in worklist.items + for item in visible_items ] visible_worklists.append(worklist_model) diff --git a/storyboard/db/api/due_dates.py b/storyboard/db/api/due_dates.py index bf895a5f..938573cf 100644 --- a/storyboard/db/api/due_dates.py +++ b/storyboard/db/api/due_dates.py @@ -13,7 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from sqlalchemy import func +from sqlalchemy import and_, func, or_ +from sqlalchemy.sql.expression import false, true from wsme.exc import ClientSideError from storyboard.db.api import base as api_base @@ -109,6 +110,46 @@ def update(id, values): return api_base.entity_update(models.DueDate, id, values) +def get_visible_items(due_date, current_user=None): + stories = due_date.stories.outerjoin(models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + due_date.stories = due_date.stories.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + due_date.stories = due_date.stories.filter( + models.Story.private == false()) + + tasks = due_date.tasks.outerjoin(models.Story, + models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + tasks = tasks.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + tasks = tasks.filter(models.Story.private == false()) + + return stories, tasks + + def get_owners(due_date): for permission in due_date.permissions: if permission.codename == 'edit_date': diff --git a/storyboard/db/api/stories.py b/storyboard/db/api/stories.py index 97375092..63548dc4 100644 --- a/storyboard/db/api/stories.py +++ b/storyboard/db/api/stories.py @@ -13,36 +13,76 @@ # See the License for the specific language governing permissions and # limitations under the License. +from sqlalchemy import and_, or_ from sqlalchemy.orm import subqueryload +from sqlalchemy.sql.expression import false, true from storyboard.common import exception as exc from storyboard.db.api import base as api_base from storyboard.db.api import story_tags from storyboard.db.api import story_types +from storyboard.db.api import users as users_api from storyboard.db import models from storyboard.openstack.common.gettextutils import _ # noqa -def story_get_simple(story_id, session=None): - return api_base.model_query(models.Story, session) \ +def story_get_simple(story_id, session=None, current_user=None): + query = api_base.model_query(models.Story, session) \ .options(subqueryload(models.Story.tags)) \ - .filter_by(id=story_id).first() + .filter_by(id=story_id) + + # Filter out stories that the current user can't see + query = query.outerjoin(models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + query = query.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + query = query.filter(models.Story.private == false()) + + return query.first() -def story_get(story_id, session=None): - story_query = api_base.model_query(models.StorySummary, session) - story_summary = story_query\ - .options(subqueryload(models.StorySummary.tags))\ - .filter_by(id=story_id).first() +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 story_summary + # Filter out stories that the current user can't see + query = query.outerjoin(models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + query = query.filter( + or_( + and_( + models.User.id == current_user, + models.StorySummary.private == true() + ), + models.StorySummary.private == false() + ) + ) + else: + query = query.filter(models.StorySummary.private == false()) + + return query.first() def story_get_all(title=None, description=None, status=None, assignee_id=None, creator_id=None, project_group_id=None, project_id=None, subscriber_id=None, tags=None, marker=None, offset=None, limit=None, tags_filter_type="all", sort_field='id', - sort_dir='asc'): + sort_dir='asc', current_user=None): # Sanity checks, in case someone accidentally explicitly passes in 'None' if not sort_field: sort_field = 'id' @@ -60,7 +100,8 @@ def story_get_all(title=None, description=None, status=None, assignee_id=None, project_group_id=project_group_id, project_id=project_id, tags=tags, - tags_filter_type=tags_filter_type) + tags_filter_type=tags_filter_type, + current_user=current_user) # Filter by subscriber ID if subscriber_id is not None: @@ -100,7 +141,7 @@ def story_get_all(title=None, description=None, status=None, assignee_id=None, 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, - tags_filter_type="all"): + tags_filter_type="all", current_user=None): query = _story_build_query(title=title, description=description, assignee_id=assignee_id, @@ -108,7 +149,8 @@ def story_get_count(title=None, description=None, status=None, project_group_id=project_group_id, project_id=project_id, tags=tags, - tags_filter_type=tags_filter_type) + tags_filter_type=tags_filter_type, + current_user=current_user) # Filter by subscriber ID if subscriber_id is not None: @@ -134,7 +176,7 @@ def story_get_count(title=None, description=None, status=None, def _story_build_query(title=None, description=None, assignee_id=None, creator_id=None, project_group_id=None, project_id=None, - tags=None, tags_filter_type='all'): + tags=None, tags_filter_type='all', current_user=None): # First build a standard story query. query = api_base.model_query(models.Story.id).distinct() @@ -145,6 +187,24 @@ def _story_build_query(title=None, description=None, assignee_id=None, description=description, creator_id=creator_id) + # Filter out stories that the current user can't see + query = query.outerjoin(models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user: + query = query.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + query = query.filter(models.Story.private == false()) + # Filtering by tags if tags: if tags_filter_type == 'all': @@ -158,8 +218,9 @@ def _story_build_query(title=None, description=None, assignee_id=None, # Are we filtering by project group? if project_group_id: - query = query.join(models.Task, - models.Project, + 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) @@ -167,7 +228,8 @@ def _story_build_query(title=None, description=None, assignee_id=None, # 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) + 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: @@ -180,12 +242,12 @@ def story_create(values): return api_base.entity_create(models.Story, values) -def story_update(story_id, values): +def story_update(story_id, values, current_user=None): api_base.entity_update(models.Story, story_id, values) - return story_get(story_id) + return story_get(story_id, current_user=current_user) -def story_add_tag(story_id, tag_name): +def story_add_tag(story_id, tag_name, current_user=None): session = api_base.get_session() with session.begin(subtransactions=True): @@ -195,7 +257,8 @@ def story_add_tag(story_id, tag_name): if not tag: tag = story_tags.tag_create({"name": tag_name}) - story = story_get_simple(story_id, session=session) + story = story_get_simple( + story_id, session=session, current_user=current_user) if not story: raise exc.NotFound(_("%(name)s %(id)s not found") % {'name': "Story", 'id': story_id}) @@ -210,12 +273,13 @@ def story_add_tag(story_id, tag_name): session.expunge(story) -def story_remove_tag(story_id, tag_name): +def story_remove_tag(story_id, tag_name, current_user=None): session = api_base.get_session() with session.begin(subtransactions=True): - story = story_get_simple(story_id, session=session) + story = story_get_simple( + story_id, session=session, current_user=current_user) if not story: raise exc.NotFound(_("%(name)s %(id)s not found") % {'name': "Story", 'id': story_id}) @@ -231,8 +295,8 @@ def story_remove_tag(story_id, tag_name): session.expunge(story) -def story_delete(story_id): - story = story_get(story_id) +def story_delete(story_id, current_user=None): + story = story_get(story_id, current_user=current_user) if story: api_base.entity_hard_delete(models.Story, story_id) @@ -302,3 +366,38 @@ def story_can_mutate(story, new_story_type_id): return True return False + + +def create_permission(story, users, session=None): + story = api_base.model_query(models.Story, session) \ + .options(subqueryload(models.Story.tags)) \ + .filter_by(id=story.id).first() + permission_dict = { + 'name': 'view_story_%d' % story.id, + 'codename': 'view_story' + } + permission = api_base.entity_create(models.Permission, permission_dict) + story.permissions.append(permission) + for user in users: + user = users_api.user_get(user.id) + user.permissions.append(permission) + return permission + + +def update_permission(story, users, session=None): + story = api_base.model_query(models.Story, session) \ + .options(subqueryload(models.Story.tags)) \ + .filter_by(id=story.id).first() + if not story.permissions: + raise exc.NotFound(_("Permissions for story %d not found.") + % story.id) + permission = story.permissions[0] + permission_dict = { + 'name': permission.name, + 'codename': permission.codename, + 'users': [users_api.user_get(user.id) for user in users] + } + + return api_base.entity_update(models.Permission, + permission.id, + permission_dict) diff --git a/storyboard/db/api/subscriptions.py b/storyboard/db/api/subscriptions.py index ec1c4cb4..99d0c82b 100644 --- a/storyboard/db/api/subscriptions.py +++ b/storyboard/db/api/subscriptions.py @@ -14,8 +14,11 @@ # limitations under the License. from sqlalchemy import distinct +from sqlalchemy.orm import subqueryload from storyboard.db.api import base as api_base +from storyboard.db.api import stories as stories_api +from storyboard.db.api import tasks as tasks_api from storyboard.db import models from storyboard.db.models import TimeLineEvent @@ -42,9 +45,13 @@ def subscription_get_all_by_target(target_type, target_id): target_id=target_id) -def subscription_get_resource(target_type, target_id): +def subscription_get_resource(target_type, target_id, current_user=None): if target_type not in SUPPORTED_TYPES: return None + if target_type == 'story': + return stories_api.story_get(target_id, current_user=current_user) + elif target_type == 'task': + return tasks_api.task_get(target_id, current_user=current_user) return api_base.entity_get(SUPPORTED_TYPES[target_type], target_id) @@ -109,9 +116,19 @@ def subscription_get_all_subscriber_ids(resource, resource_id, session=None): # Make sure the requested resource is going to be handled. affected[resource].add(resource_id) + users = None + # Resolve either from story->task or from task->story, so the root # resource id remains pristine. if resource == 'story': + # If the story is private, make a whitelist of users to notify. + story = api_base.model_query(models.Story, session) \ + .options(subqueryload(models.Story.permissions)) \ + .filter_by(id=resource_id).first() + + if story.private: + users = [user.id for user in story.permissions[0].users] + # Get this story's tasks query = api_base.model_query(models.Task.id, session=session) \ .filter(models.Task.story_id.in_(affected['story'])) @@ -125,6 +142,13 @@ def subscription_get_all_subscriber_ids(resource, resource_id, session=None): affected['story'].add(query.first().story_id) + story = api_base.model_query(models.Story, session) \ + .options(subqueryload(models.Story.permissions)) \ + .filter_by(id=query.first().story_id).first() + + if story.private: + users = [user.id for user in story.permissions[0].users] + # If there are tasks, there will also be projects. if affected['task']: # Get all the tasks's projects @@ -155,6 +179,9 @@ def subscription_get_all_subscriber_ids(resource, resource_id, session=None): .filter(models.Subscription.target_type == affected_type) \ .filter(models.Subscription.target_id.in_(affected[affected_type])) + if users is not None: + query = query.filter(models.Subscription.user_id.in_(users)) + results = query.all() subscribers = subscribers.union(r for (r,) in results) diff --git a/storyboard/db/api/tasks.py b/storyboard/db/api/tasks.py index d73c6153..c43209ae 100644 --- a/storyboard/db/api/tasks.py +++ b/storyboard/db/api/tasks.py @@ -13,16 +13,41 @@ # See the License for the specific language governing permissions and # limitations under the License. +from sqlalchemy import and_, or_ +from sqlalchemy.sql.expression import false, true + from storyboard.db.api import base as api_base from storyboard.db import models -def task_get(task_id): - return api_base.entity_get(models.Task, task_id) +def task_get(task_id, session=None, current_user=None): + query = api_base.model_query(models.Task, session) + query = query.filter(models.Task.id == task_id) + + # Filter out tasks or stories that the current user can't see + query = query.outerjoin(models.Story, + models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + query = query.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + query = query.filter(models.Story.private == false()) + + return query.first() def task_get_all(marker=None, limit=None, sort_field=None, sort_dir=None, - project_group_id=None, **kwargs): + project_group_id=None, current_user=None, **kwargs): # Sanity checks, in case someone accidentally explicitly passes in 'None' if not sort_field: sort_field = 'id' @@ -30,7 +55,8 @@ def task_get_all(marker=None, limit=None, sort_field=None, sort_dir=None, sort_dir = 'asc' # Construct the query - query = task_build_query(project_group_id, **kwargs) + query = task_build_query( + project_group_id, current_user=current_user, **kwargs) query = api_base.paginate_query(query=query, model=models.Task, @@ -43,8 +69,9 @@ def task_get_all(marker=None, limit=None, sort_field=None, sort_dir=None, return query.all() -def task_get_count(project_group_id=None, **kwargs): - query = task_build_query(project_group_id, **kwargs) +def task_get_count(project_group_id=None, current_user=None, **kwargs): + query = task_build_query( + project_group_id, current_user=current_user, **kwargs) return query.count() @@ -63,7 +90,7 @@ def task_delete(task_id): api_base.entity_hard_delete(models.Task, task_id) -def task_build_query(project_group_id, **kwargs): +def task_build_query(project_group_id, current_user=None, **kwargs): # Construct the query query = api_base.model_query(models.Task) @@ -78,6 +105,25 @@ def task_build_query(project_group_id, **kwargs): model=models.Task, **kwargs) + # Filter out tasks or stories that the current user can't see + query = query.outerjoin(models.Story, + models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + query = query.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + query = query.filter(models.Story.private == false()) + return query diff --git a/storyboard/db/api/worklists.py b/storyboard/db/api/worklists.py index e3618466..28530d26 100644 --- a/storyboard/db/api/worklists.py +++ b/storyboard/db/api/worklists.py @@ -13,14 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from sqlalchemy.orm import subqueryload +from sqlalchemy import and_, or_ +from sqlalchemy.sql.expression import false, true from wsme.exc import ClientSideError from storyboard.common import exception as exc from storyboard.db.api import base as api_base from storyboard.db.api import boards -from storyboard.db.api import stories -from storyboard.db.api import tasks +from storyboard.db.api import stories as stories_api +from storyboard.db.api import tasks as tasks_api from storyboard.db.api import users as users_api from storyboard.db import models from storyboard.openstack.common.gettextutils import _ # noqa @@ -29,9 +30,7 @@ from storyboard.openstack.common.gettextutils import _ # noqa def _worklist_get(id, session=None): if not session: session = api_base.get_session() - query = session.query(models.Worklist).options( - subqueryload(models.Worklist.items)).filter_by(id=id) - + query = session.query(models.Worklist).filter_by(id=id) return query.first() @@ -99,6 +98,51 @@ def get_count(**kwargs): return api_base.entity_get_count(models.Worklist, **kwargs) +def get_visible_items(worklist, current_user=None): + stories = worklist.items.filter(models.WorklistItem.item_type == 'story') + stories = stories.join( + (models.Story, models.Story.id == models.WorklistItem.item_id)) + stories = stories.outerjoin(models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + stories = stories.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + stories = stories.filter(models.Story.private == false()) + + tasks = worklist.items.filter(models.WorklistItem.item_type == 'task') + tasks = tasks.join( + (models.Task, models.Task.id == models.WorklistItem.item_id)) + tasks = tasks.outerjoin(models.Story, + models.story_permissions, + models.Permission, + models.user_permissions, + models.User) + if current_user is not None: + tasks = tasks.filter( + or_( + and_( + models.User.id == current_user, + models.Story.private == true() + ), + models.Story.private == false() + ) + ) + else: + tasks = tasks.filter(models.Story.private == false()) + + return stories.union(tasks) + + def create(values): return api_base.entity_create(models.Worklist, values) @@ -114,7 +158,8 @@ def has_item(worklist, item_type, item_id): return False -def add_item(worklist_id, item_id, item_type, list_position): +def add_item(worklist_id, item_id, item_type, list_position, + current_user=None): worklist = _worklist_get(worklist_id) if worklist is None: raise exc.NotFound(_("Worklist %s not found") % worklist_id) @@ -146,9 +191,9 @@ def add_item(worklist_id, item_id, item_type, list_position): # Create a new card if item_type == 'story': - item = stories.story_get(item_id) + item = stories_api.story_get(item_id, current_user=current_user) elif item_type == 'task': - item = tasks.task_get(item_id) + item = tasks_api.task_get(item_id, current_user=current_user) else: raise ClientSideError(_("An item in a worklist must be either a " "story or a task")) @@ -216,12 +261,14 @@ def move_item(worklist_id, item_id, list_position, list_id=None): # Move the item and clean up the positions. new_list = _worklist_get(list_id) old_list.items.remove(item) - old_list.items.sort(key=lambda x: x.list_position) + old_list.items = old_list.items.order_by( + models.WorklistItem.list_position) modified = old_list.items[old_pos:] for list_item in modified: list_item.list_position -= 1 - new_list.items.sort(key=lambda x: x.list_position) + new_list.items = new_list.items.order_by( + models.WorklistItem.list_position) modified = new_list.items[list_position:] for list_item in modified: list_item.list_position += 1 @@ -230,7 +277,8 @@ def move_item(worklist_id, item_id, list_position, list_id=None): # Item has changed position in the list. # Update the position of every item between the original # position and the final position. - old_list.items.sort(key=lambda x: x.list_position) + old_list.items = old_list.items.order_by( + models.WorklistItem.list_position) if old_pos > list_position: direction = 'down' modified = old_list.items[list_position:old_pos + 1] diff --git a/storyboard/db/models.py b/storyboard/db/models.py index 0fb6bd4e..bc2d2e0a 100644 --- a/storyboard/db/models.py +++ b/storyboard/db/models.py @@ -33,7 +33,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 import relationship +from sqlalchemy.orm import backref, relationship from sqlalchemy import schema from sqlalchemy import select import sqlalchemy.sql.expression as expr @@ -468,6 +468,7 @@ class StorySummary(Base): __table__ = _story_build_summary_query() tags = relationship('StoryTag', secondary='story_storytags') due_dates = relationship('DueDate', secondary='story_due_dates') + permissions = relationship('Permission', secondary='story_permissions') def as_dict(self): d = super(StorySummary, self).as_dict() @@ -546,6 +547,7 @@ class WorklistItem(ModelBuilder, Base): ForeignKey('due_dates.id'), nullable=True) archived = Column(Boolean, default=False) + list = relationship('Worklist', backref=backref('items', lazy="dynamic")) _public_fields = ["id", "list_id", "list_position", "item_type", "item_id"] @@ -586,7 +588,6 @@ class Worklist(FullText, ModelBuilder, Base): private = Column(Boolean, default=False) archived = Column(Boolean, default=False) automatic = Column(Boolean, default=False) - items = relationship(WorklistItem) filters = relationship(WorklistFilter) permissions = relationship("Permission", secondary="worklist_permissions") @@ -645,10 +646,12 @@ class DueDate(FullText, ModelBuilder, Base): permissions = relationship('Permission', secondary='due_date_permissions') tasks = relationship('Task', secondary='task_due_dates', - backref='due_dates') + backref='due_dates', + lazy="dynamic") stories = relationship('Story', secondary='story_due_dates', - backref='due_dates') + backref='due_dates', + lazy="dynamic") boards = relationship('Board', secondary='board_due_dates', backref='due_dates')