storyboard/storyboard/api/v1/stories.py

579 lines
21 KiB
Python

# Copyright (c) 2013 Mirantis Inc.
# Copyright (c) 2016, 2019 Codethink Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime
from oslo_config import cfg
from pecan import abort
from pecan import expose
from pecan import request
from pecan import response
from pecan import rest
from pecan.secure import secure
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from storyboard._i18n import _
from storyboard.api.auth import authorization_checks as checks
from storyboard.api.v1.attachments import AttachmentsController
from storyboard.api.v1.search import search_engine
from storyboard.api.v1.tags import TagsController
from storyboard.api.v1.tasks import TasksNestedController
from storyboard.api.v1.timeline import CommentsController
from storyboard.api.v1.timeline import NestedTimeLineEventsController
from storyboard.api.v1 import validations
from storyboard.api.v1 import wmodels
from storyboard.common import decorators
from storyboard.common import exception as exc
from storyboard.db.api import base as api_base
from storyboard.db.api import stories as stories_api
from storyboard.db.api import subscriptions as subscription_api
from storyboard.db.api import teams as teams_api
from storyboard.db.api import timeline_events as events_api
from storyboard.db.api import users as users_api
CONF = cfg.CONF
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)
story_model.resolve_teams(story)
else:
story_model.users = []
story_model.teams = []
return story_model
class UsersSubcontroller(rest.RestController):
"""Manage Users who can access the Story."""
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.User], int)
def get(self, story_id):
"""Get users with access to a story.
Example::
curl https://my.example.org/api/v1/stories/1/users
:param story_id: ID of the story to get users for.
"""
story = stories_api.story_get_simple(
story_id, current_user=request.current_user_id)
if not story:
raise exc.NotFound(_("Story %s not found") % story_id)
if not story.permissions:
return []
permission = story.permissions[0]
users = [api_base._filter_non_public_fields(user, user._public_fields)
for user in permission.users]
return [wmodels.User.from_db_model(user) for user in users]
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.User, int, int)
def put(self, story_id, user_id):
"""Add a user to a story.
Example::
TODO
:param story_id: ID of the story to add a user to.
:param user_id: ID of the user.
"""
stories_api.add_user(story_id, user_id, request.current_user_id)
user = users_api.user_get(user_id)
user = api_base._filter_non_public_fields(user, user._public_fields)
return wmodels.User.from_db_model(user)
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(None, int, int, status_code=204)
def delete(self, story_id, user_id):
"""Delete a user from a team.
Example::
TODO
:param team_id: An ID of the team.
:param user_id: An ID of the user.
"""
stories_api.delete_user(story_id, user_id, request.current_user_id)
class TeamsSubcontroller(rest.RestController):
"""Manage Teams who can access the story."""
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Team], int)
def get(self, story_id):
"""Get users inside a team.
Example::
curl https://my.example.org/api/v1/teams/1/users
:param team_id: An ID of the team.
"""
story = stories_api.story_get_simple(
story_id, current_user=request.current_user_id)
if not story:
raise exc.NotFound(_("Story %s not found") % story_id)
if not story.permissions:
return []
permission = story.permissions[0]
return [wmodels.Team.from_db_model(team) for team in permission.teams]
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.Team, int, int)
def put(self, story_id, team_id):
"""Add a team to a story.
Example::
TODO
:param story_id: ID of the story to add a team to.
:param team_id: ID of the team.
"""
stories_api.add_team(story_id, team_id, request.current_user_id)
team = teams_api.team_get(team_id)
return wmodels.Team.from_db_model(team)
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(None, int, int, status_code=204)
def delete(self, story_id, team_id):
"""Delete a team from a story.
Example::
TODO
:param story_id: ID of the story to remove a team from.
:param team_id: ID of the team.
"""
stories_api.delete_team(story_id, team_id, request.current_user_id)
class StoriesController(rest.RestController):
"""Manages operations on stories."""
_custom_actions = {"search": ["GET"]}
validation_post_schema = validations.STORIES_POST_SCHEMA
validation_put_schema = validations.STORIES_PUT_SCHEMA
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose(wmodels.Story, int)
def get_one(self, story_id):
"""Retrieve details about one story.
Example::
curl https://my.example.org/api/v1/stories/11
:param story_id: An ID of the story.
"""
story = stories_api.story_get(
story_id, current_user=request.current_user_id)
if story:
return create_story_wmodel(story)
else:
raise exc.NotFound(_("Story %s not found") % story_id)
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Story], wtypes.text, wtypes.text,
[wtypes.text], int, int, int, int, int, [wtypes.text],
datetime, int, int, int, wtypes.text,
wtypes.text, wtypes.text)
def get_all(self, title=None, description=None, status=None,
assignee_id=None, creator_id=None, project_group_id=None,
project_id=None, subscriber_id=None, tags=None,
updated_since=None, marker=None, offset=None, limit=None,
tags_filter_type='all', sort_field='id', sort_dir='asc'):
"""Retrieve definitions of all of the stories.
Example::
curl https://my.example.org/api/v1/stories
:param title: A string to filter the title by.
:param description: A string to filter the description by.
:param status: Only show stories with this particular status.
:param assignee_id: Filter stories by who they are assigned to.
:param creator_id: Filter stories by who created them.
:param project_group_id: Filter stories by project group.
:param project_id: Filter stories by project ID.
:param subscriber_id: Filter stories by subscriber ID.
:param tags: A list of tags to filter by.
:param updated_since: Filter stories by last updated time.
:param marker: The resource id where the page should begin.
:param offset: The offset to start the page at.
:param limit: The number of stories to retrieve.
:param tags_filter_type: Type of tags filter.
:param sort_field: The name of the field to sort on.
:param sort_dir: Sort direction for results (asc, desc).
"""
# Boundary check on limit.
if limit is not None:
limit = max(0, limit)
# Resolve the marker record.
marker_story = None
if marker:
marker_story = stories_api.story_get(
marker, current_user=request.current_user_id)
stories = stories_api \
.story_get_all(title=title,
description=description,
status=status,
assignee_id=assignee_id,
creator_id=creator_id,
project_group_id=project_group_id,
project_id=project_id,
subscriber_id=subscriber_id,
tags=tags,
updated_since=updated_since,
marker=marker_story,
offset=offset,
tags_filter_type=tags_filter_type,
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,
status=status,
assignee_id=assignee_id,
creator_id=creator_id,
project_group_id=project_group_id,
project_id=project_id,
subscriber_id=subscriber_id,
tags=tags,
updated_since=updated_since,
tags_filter_type=tags_filter_type,
current_user=request.current_user_id)
# Apply the query response headers.
if limit:
response.headers['X-Limit'] = str(limit)
response.headers['X-Total'] = str(story_count)
if marker_story:
response.headers['X-Marker'] = str(marker_story.id)
if offset is not None:
response.headers['X-Offset'] = str(offset)
return [create_story_wmodel(s) for s in stories]
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.Story, body=wmodels.Story)
def post(self, story):
"""Create a new story.
Example::
curl 'https://my.example.org/api/v1/stories' \\
-H 'Authorization: Bearer MY_ACCESS_TOKEN' \\
-H 'Content-Type: application/json;charset=UTF-8' \\
--data-binary '{"title":"Test Story","description":"A test story."}'
:param story: A story within the request body.
"""
# Reject private story types while ACL is not created.
if (story.story_type_id
and (story.story_type_id == 3 or story.story_type_id == 4)):
abort(400, _("Now you can't add story with type %s.") %
story.story_type_id)
story_dict = story.as_dict()
user_id = request.current_user_id
if story.creator_id and story.creator_id != user_id:
abort(400, _("You can't select author of story."))
story_dict.update({"creator_id": user_id})
if not stories_api.story_can_create_story(story.story_type_id):
abort(400, _("Can't create story of this type."))
if "tags" not in story_dict or not story_dict["tags"]:
story_dict["tags"] = []
# We can't set due dates when creating stories at the moment.
if "due_dates" in story_dict:
del story_dict['due_dates']
users = None
teams = None
# We make sure that a user cannot remove all users and teams
# from the permissions list for a story
# This should be reworked so that users can be removed if there
# are teams, and vice versa
if "teams" in story_dict:
teams = story_dict.pop("teams")
if teams is None:
teams = []
if "users" in story_dict:
users = story_dict.pop("users")
if users is None or (users == [] and teams == []):
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, teams)
subscription_api.subscription_create({
"target_type": "story",
"target_id": created_story.id,
"user_id": user_id
})
return wmodels.Story.from_db_model(created_story)
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.Story, int, body=wmodels.Story)
def put(self, story_id, story):
"""Modify this story.
Example::
curl 'https://my.example.org/api/v1/stories/19' -X PUT \\
-H 'Authorization: Bearer MY_ACCESS_TOKEN' \\
-H 'Content-Type: application/json;charset=UTF-8' \\
--data-binary '{"title":"Modified","description":"New description."}'
: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 (story.story_type_id == 3 or story.story_type_id == 4)):
abort(400, _("Now you can't change story type to %s.") %
story.story_type_id)
original_story = stories_api.story_get_simple(
story_id, current_user=user_id)
if not original_story:
raise exc.NotFound(_("Story %s not found") % story_id)
if story.creator_id and story.creator_id != original_story.creator_id:
abort(400, _("You can't change author of story."))
story_dict = story.as_dict(omit_unset=True)
stories_api.story_check_story_type_id(story_dict)
if not stories_api.story_can_mutate(original_story,
story.story_type_id):
abort(400, _("Can't change story type."))
# This is not the place to update tags, including them in
# story_dict causes the story/tag relationship to attempt to
# update with a list of unicode strings rather than objects
# from the database.
if 'tags' in story_dict:
story_dict.pop('tags')
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 == [] and not teams):
valid = False
elif teams == [] and (original_users == [] and not 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, teams)
updated_story = stories_api.story_update(
story_id,
story_dict,
current_user=user_id)
# 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)
events_api.story_details_changed_event(story_id, user_id,
updated_story.title)
return create_story_wmodel(updated_story)
@decorators.db_exceptions
@secure(checks.superuser)
@wsme_pecan.wsexpose(None, int, status_code=204)
def delete(self, story_id):
"""Delete this story. This command is only available to Admin users.
Example::
curl 'https://my.example.org/api/v1/stories/5' -X DELETE \\
-H 'Authorization: Bearer MY_ACCESS_TOKEN'
:param story_id: An ID of the story.
"""
stories_api.story_delete(
story_id, current_user=request.current_user_id)
if CONF.attachments.enable_attachments:
attachments = AttachmentsController()
comments = CommentsController()
events = NestedTimeLineEventsController()
tasks = TasksNestedController()
tags = TagsController()
teams = TeamsSubcontroller()
users = UsersSubcontroller()
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Story], wtypes.text,
[wtypes.text], int, int, int, int, int, [wtypes.text],
datetime, int, int, int, wtypes.text,
wtypes.text, wtypes.text)
def search(self, q="", status=None, assignee_id=None, creator_id=None,
project_group_id=None, project_id=None, subscriber_id=None,
tags=None, updated_since=None, marker=None, offset=None,
limit=None, tags_filter_type='all', sort_field='id',
sort_dir='asc'):
"""The search endpoint for stories.
Example::
curl https://my.example.org/api/v1/stories/search?q=pep8
:param q: The query string.
:param status: Only show stories with this particular status.
:param assignee_id: Filter stories by who they are assigned to.
:param creator_id: Filter stories by who created them.
:param project_group_id: Filter stories by project group.
:param project_id: Filter stories by project ID.
:param subscriber_id: Filter stories by subscriber ID.
:param tags: A list of tags to filter by.
:param updated_since: Filter stories by last updated time.
:param marker: The resource id where the page should begin.
:param offset: The offset to start the page at.
:param limit: The number of stories to retrieve.
:param tags_filter_type: Type of tags filter.
:param sort_field: The name of the field to sort on.
:param sort_dir: Sort direction for results (asc, desc).
:return: List of Stories matching the query and any other filters.
"""
user = request.current_user_id
stories, count = SEARCH_ENGINE.stories_query(
q,
status=status,
assignee_id=assignee_id,
creator_id=creator_id,
project_group_id=project_group_id,
project_id=project_id,
subscriber_id=subscriber_id,
tags=tags,
updated_since=updated_since,
offset=offset,
tags_filter_type=tags_filter_type,
limit=limit,
sort_field=sort_field,
sort_dir=sort_dir,
current_user=user)
# Apply the query response headers.
if limit:
response.headers['X-Limit'] = str(limit)
response.headers['X-Total'] = str(count)
if offset is not None:
response.headers['X-Offset'] = str(offset)
return [create_story_wmodel(story) for story in stories]
@expose()
def _route(self, args, request):
if request.method == 'GET' and len(args) > 0:
# It's a request by a name or id
something = args[0]
if something == "search":
# Request to a search endpoint
return self.search, args
if something == "get_by_tags":
# Request by a list of tags
return self.get_by_tags, args
return super(StoriesController, self)._route(args, request)