diff --git a/storyboard/api/v1/due_dates.py b/storyboard/api/v1/due_dates.py index 9b0fb54a..3270a333 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')