Merge "Allow the creation of private stories"

This commit is contained in:
Jenkins 2016-05-10 14:04:27 +00:00 committed by Gerrit Code Review
commit 3779bacc1b
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')