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
This commit is contained in:
Adam Coldrick 2016-04-28 21:49:17 +00:00
parent d3dac693b8
commit f52fde0d98
14 changed files with 550 additions and 128 deletions

View File

@ -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

View File

@ -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)

View File

@ -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]

View File

@ -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.'))

View File

@ -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,

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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':

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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]

View File

@ -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')