Allow permissions to be set for teams on stories

This commit allows people to add teams to the ACL for viewing
and editing a private story. This will make managing who can
see stories much easier, since it allows grouping of users such
that only one change needs to be made if someone is added to
or removed from the group.

Previously the list of users needed to be sent every time the
story was updated, this commit fixes that such that if no list
of users is sent, no change is made to the users who can see
the story.

Story: 2000568
Task: 2985
Change-Id: Idb7f881c4d772d373b583ecc2f59231be8f7f01b
This commit is contained in:
Adam Coldrick 2016-09-12 15:17:17 +00:00
parent 4926b471e2
commit 28f204d9be
6 changed files with 137 additions and 55 deletions

View File

@ -58,10 +58,6 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine):
session = api_base.get_session()
subquery = api_base.model_query(models.Story, session)
# Filter out stories that the current user can't see
subquery = api_base.filter_private_stories(subquery, current_user)
subquery = self._build_fulltext_search(models.Story, subquery, q)
subquery = self._apply_pagination(models.Story,
subquery, marker, offset, limit)
@ -73,6 +69,9 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine):
query = query.join(subquery,
models.StorySummary.id == subquery.c.id)
# Filter out stories that the current user can't see
query = api_base.filter_private_stories(query, current_user)
stories = query.all()
return stories
@ -81,11 +80,12 @@ class SqlAlchemySearchImpl(search_engine.SearchEngine):
session = api_base.get_session()
query = api_base.model_query(models.Task, session)
query = self._build_fulltext_search(models.Task, query, q)
# Filter out tasks or stories that the current user can't see
query = query.outerjoin(models.Story)
query = api_base.filter_private_stories(query, current_user)
query = self._build_fulltext_search(models.Task, query, q)
query = self._apply_pagination(
models.Task, query, marker, offset, limit)

View File

@ -50,8 +50,10 @@ def create_story_wmodel(story):
story_model.summarize_task_statuses(story)
if story.permissions:
story_model.resolve_users(story)
story_model.resolve_teams(story)
else:
story_model.users = []
story_model.teams = []
return story_model
@ -207,17 +209,22 @@ class StoriesController(rest.RestController):
if "due_dates" in story_dict:
del story_dict['due_dates']
users = []
users = None
teams = None
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))]
if "teams" in story_dict:
teams = story_dict.pop("teams")
if teams is None:
teams = []
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)
stories_api.create_permission(created_story, users, teams)
return wmodels.Story.from_db_model(created_story)
@ -237,6 +244,7 @@ class StoriesController(rest.RestController):
:param story_id: An ID of the story.
:param story: A story within the request body.
"""
user_id = request.current_user_id
# Reject private story types while ACL is not created.
if (story.story_type_id and
@ -245,7 +253,7 @@ class StoriesController(rest.RestController):
story.story_type_id)
original_story = stories_api.story_get_simple(
story_id, current_user=request.current_user_id)
story_id, current_user=user_id)
if not original_story:
raise exc.NotFound(_("Story %s not found") % story_id)
@ -267,28 +275,54 @@ 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)))
users = story_dict.get("users")
teams = story_dict.get("teams")
private = story_dict.get("private", original_story.private)
if private:
# If trying to make a story private with no permissions set, add
# the user making the change to the permission so that at least
# the story isn't lost to everyone.
if not users and not teams and not original_story.permissions:
users = [wmodels.User.from_db_model(
users_api.user_get(user_id))]
original_teams = None
original_users = None
if original_story.permissions:
original_teams = original_story.permissions[0].teams
original_users = original_story.permissions[0].users
# Don't allow both permission lists to be deliberately emptied
# on a private story, to make sure the story remains visible to
# at least someone.
valid = True
if users == [] and teams == []:
valid = False
elif users == [] and original_teams == []:
valid = False
elif teams == [] and original_users == []:
valid = False
if not valid and original_story.private:
abort(400,
_("Can't make a private story have no users or teams"))
# If the story doesn't already have permissions, create them.
if not original_story.permissions:
stories_api.create_permission(original_story, users)
stories_api.create_permission(original_story, users, teams)
updated_story = stories_api.story_update(
story_id,
story_dict,
current_user=request.current_user_id)
current_user=user_id)
if users == [] and updated_story.private:
abort(400, _("Can't make a private story with no users"))
# If the story is private and already has some permissions, update
# them as needed. This is done after updating the story in case the
# request is trying to both update some story fields and also remove
# the user making the change from the ACL.
if private and original_story.permissions:
stories_api.update_permission(updated_story, users, teams)
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,
updated_story.title)

View File

@ -182,6 +182,24 @@ class User(base.APIBase):
last_login=datetime(2014, 1, 1, 16, 42))
class Team(base.APIBase):
"""The Team is a group of Users with a fixed set of permissions."""
name = NameType()
"""The Team unique name. This name will be displayed in the URL.
At least 3 alphanumeric symbols. Minus and dot symbols are allowed as
separators."""
description = wtypes.text
"""Details about the team."""
@classmethod
def sample(cls):
return cls(
name="StoryBoard-core",
description="Core reviewers of StoryBoard team.")
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
@ -222,6 +240,9 @@ class Story(base.APIBase):
users = wtypes.ArrayType(User)
"""The set of users with permission to see this story if it is private."""
teams = wtypes.ArrayType(Team)
"""The set of teams with permission to see this story if it is private."""
@classmethod
def sample(cls):
return cls(
@ -252,6 +273,12 @@ class Story(base.APIBase):
for user in story.permissions[0].users]
self.users = [User.from_db_model(user) for user in users]
@nodoc
def resolve_teams(self, story):
"""Resolve the teams who can see the story."""
self.teams = [Team.from_db_model(team)
for team in story.permissions[0].teams]
class Tag(base.APIBase):
@ -389,24 +416,6 @@ class Milestone(base.APIBase):
)
class Team(base.APIBase):
"""The Team is a group of Users with a fixed set of permissions."""
name = NameType()
"""The Team unique name. This name will be displayed in the URL.
At least 3 alphanumeric symbols. Minus and dot symbols are allowed as
separators."""
description = wtypes.text
"""Details about the team."""
@classmethod
def sample(cls):
return cls(
name="StoryBoard-core",
description="Core reviewers of StoryBoard team.")
class TimeLineEvent(base.APIBase):
"""An event object should be created each time a story or a task state
changes.

View File

@ -384,12 +384,13 @@ def filter_private_stories(query, current_user, story_model=models.Story):
:param story_model: The database model used for stories in the query.
"""
# First filter based on users with permissions set directly
query = query.outerjoin(models.story_permissions,
models.Permission,
models.user_permissions,
models.User)
if current_user:
query = query.filter(
visible_to_users = query.filter(
or_(
and_(
models.User.id == current_user,
@ -400,14 +401,40 @@ def filter_private_stories(query, current_user, story_model=models.Story):
)
)
else:
query = query.filter(
visible_to_users = query.filter(
or_(
story_model.private == false(),
story_model.id.is_(None)
)
)
return query
# Now filter based on membership of teams with permissions
users = aliased(models.User)
query = query.outerjoin(models.team_permissions,
models.Team,
models.team_membership,
(users,
users.id == models.team_membership.c.user_id))
if current_user:
visible_to_teams = query.filter(
or_(
and_(
users.id == current_user,
story_model.private == true()
),
story_model.private == false(),
story_model.id.is_(None)
)
)
else:
visible_to_teams = query.filter(
or_(
story_model.private == false(),
story_model.id.is_(None)
)
)
return visible_to_users.union(visible_to_teams)
def filter_private_worklists(query, current_user, hide_lanes=True):

View File

@ -19,6 +19,7 @@ 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 teams as teams_api
from storyboard.db.api import users as users_api
from storyboard.db import models
from storyboard.openstack.common.gettextutils import _ # noqa
@ -89,7 +90,7 @@ def story_get_all(title=None, description=None, status=None, assignee_id=None,
query = api_base.model_query(models.StorySummary)\
.options(subqueryload(models.StorySummary.tags))
query = query.join(subquery,
models.StorySummary.id == subquery.c.id)
models.StorySummary.id == subquery.c.stories_id)
if status:
query = query.filter(models.StorySummary.status.in_(status))
@ -137,7 +138,7 @@ def story_get_count(title=None, description=None, status=None,
query = query.subquery()
summary_query = api_base.model_query(models.StorySummary)
summary_query = summary_query \
.join(query, models.StorySummary.id == query.c.id)
.join(query, models.StorySummary.id == query.c.stories_id)
query = summary_query.filter(models.StorySummary.status.in_(status))
return query.count()
@ -189,7 +190,7 @@ def _story_build_query(title=None, description=None, assignee_id=None,
if project_id:
query = query.filter(models.Task.project_id == project_id)
return query
return query.distinct()
def story_create(values):
@ -322,7 +323,7 @@ def story_can_mutate(story, new_story_type_id):
return False
def create_permission(story, users, session=None):
def create_permission(story, users, teams, session=None):
story = api_base.model_query(models.Story, session) \
.options(subqueryload(models.Story.tags)) \
.filter_by(id=story.id).first()
@ -332,13 +333,18 @@ def create_permission(story, users, session=None):
}
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)
if users is not None:
for user in users:
user = users_api.user_get(user.id)
user.permissions.append(permission)
if teams is not None:
for team in teams:
team = teams_api.team_get(team.id)
team.permissions.append(permission)
return permission
def update_permission(story, users, session=None):
def update_permission(story, users, teams, session=None):
story = api_base.model_query(models.Story, session) \
.options(subqueryload(models.Story.tags)) \
.filter_by(id=story.id).first()
@ -348,9 +354,14 @@ def update_permission(story, users, session=None):
permission = story.permissions[0]
permission_dict = {
'name': permission.name,
'codename': permission.codename,
'users': [users_api.user_get(user.id) for user in users]
'codename': permission.codename
}
if users is not None:
permission_dict['users'] = [users_api.user_get(user.id)
for user in users]
if teams is not None:
permission_dict['teams'] = [teams_api.team_get(team.id)
for team in teams]
return api_base.entity_update(models.Permission,
permission.id,

View File

@ -212,7 +212,8 @@ class Team(ModelBuilder, Base):
)
name = Column(Unicode(CommonLength.top_large_length))
users = relationship("User", secondary="team_membership")
permissions = relationship("Permission", secondary="team_permissions")
permissions = relationship("Permission", secondary="team_permissions",
backref="teams")
project_group_mapping = Table(