Add an API endpoint for boards

Add an API endpoint for managing boards and their contents. Basic
CRUD functionality is exposed on `/v1/boards`. Also add a `board_id`
parameter to the `/v1/worklists` endpoint to allow easy access to
the worklists in a given board.

Change-Id: I24c434d2f36650edcdf7005b2b704be08ed4a4c2
This commit is contained in:
Adam Coldrick 2015-09-10 16:07:47 +00:00
parent 8723b48cf7
commit f7c2d2320c
7 changed files with 401 additions and 7 deletions

241
storyboard/api/v1/boards.py Normal file
View File

@ -0,0 +1,241 @@
# Copyright (c) 2015 Codethink Limited
#
# 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 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.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 boards as boards_api
from storyboard.db.api import worklists as worklists_api
from storyboard.openstack.common.gettextutils import _ # noqa
CONF = cfg.CONF
def visible(board, user=None):
if not board:
return False
if user and board.private:
# TODO(SotK): Permissions
return user == board.creator_id
return not board.private
def editable(board, user=None):
if not board:
return False
if not user:
return False
# TODO(SotK): Permissions
return user == board.creator_id
def get_lane(list_id, board):
for lane in board['lanes']:
if lane.list_id == list_id:
return lane
def update_lanes(board_dict, board_id):
if 'lanes' not in board_dict:
return
board = boards_api.get(board_id)
new_list_ids = [lane.list_id for lane in board_dict['lanes']]
existing_list_ids = [lane.list_id for lane in board.lanes]
for lane in board.lanes:
if lane.list_id in new_list_ids:
new_lane = get_lane(lane.list_id, board_dict)
if lane.position != new_lane.position:
boards_api.update_lane(
board_id, lane, new_lane.as_dict(omit_unset=True))
for lane in board_dict['lanes']:
if lane.list_id not in existing_list_ids:
boards_api.add_lane(board_id, lane.as_dict(omit_unset=True))
board = boards_api.get(board_id)
del board_dict['lanes']
class BoardsController(rest.RestController):
"""Manages operations on boards."""
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose(wmodels.Board, int)
def get_one(self, id):
"""Retrieve details about one board.
:param id: The ID of the board.
"""
board = boards_api.get(id)
user_id = request.current_user_id
if visible(board, user_id):
board_model = wmodels.Board.from_db_model(board)
board_model.lanes = [wmodels.Lane.from_db_model(lane)
for lane in board.lanes]
return board_model
else:
raise exc.NotFound(_("Board %s not found") % id)
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Board], wtypes.text, int, int,
bool, wtypes.text, wtypes.text)
def get_all(self, title=None, creator_id=None, project_id=None,
archived=False, sort_field='id', sort_dir='asc'):
"""Retrieve definitions of all of the boards.
:param title: A string to filter the title by.
:param creator_id: Filter boards by their creator.
:param project_id: Filter boards by project ID.
:param archived: Filter boards by whether they are archived or not.
:param sort_field: The name of the field to sort on.
:param sort_dir: Sort direction for results (asc, desc).
"""
boards = boards_api.get_all(title=title,
creator_id=creator_id,
project_id=project_id,
sort_field=sort_field,
sort_dir=sort_dir)
visible_boards = []
user_id = request.current_user_id
for board in boards:
if visible(board, user_id) and board.archived == archived:
board_model = wmodels.Board.from_db_model(board)
board_model.lanes = [wmodels.Lane.from_db_model(lane)
for lane in board.lanes]
visible_boards.append(board_model)
# Apply the query response headers
response.headers['X-Total'] = str(len(visible_boards))
return visible_boards
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.Board, body=wmodels.Board)
def post(self, board):
"""Create a new board.
:param board: A board within the request body.
"""
board_dict = board.as_dict()
user_id = request.current_user_id
if board.creator_id and board.creator_id != user_id:
abort(400, _("You can't select the creator of a board."))
board_dict.update({"creator_id": user_id})
lanes = board_dict.pop('lanes')
created_board = boards_api.create(board_dict)
for lane in lanes:
boards_api.add_lane(created_board.id, lane.as_dict())
return wmodels.Board.from_db_model(created_board)
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(wmodels.Board, int, body=wmodels.Board)
def put(self, id, board):
"""Modify this board.
:param id: The ID of the board.
:param board: The new board within the request body.
"""
user_id = request.current_user_id
if not editable(boards_api.get(id), user_id):
raise exc.NotFound(_("Board %s not found") % id)
board_dict = board.as_dict(omit_unset=True)
update_lanes(board_dict, id)
boards_api.update(id, board_dict)
if visible(board, user_id):
board_model = wmodels.Board.from_db_model(board)
if board.lanes:
board_model.lanes = [wmodels.Lane.from_db_model(lane)
for lane in board.lanes]
return board_model
else:
raise exc.NotFound(_("Board %s not found") % id)
@decorators.db_exceptions
@secure(checks.authenticated)
@wsme_pecan.wsexpose(None, int, status_code=204)
def delete(self, id):
"""Archive this board.
:param id: The ID of the board to be archived.
"""
board = boards_api.get(id)
user_id = request.current_user_id
if not editable(board, user_id):
raise exc.NotFound(_("Board %s not found") % id)
board_dict = wmodels.Board.from_db_model(board).as_dict(
omit_unset=True)
board_dict['lanes'] = board.lanes
board_dict.update({"archived": True})
boards_api.update(id, board_dict)
for lane in board_dict['lanes']:
worklist = lane.worklist
worklist_dict = wmodels.Worklist.from_db_model(worklist).as_dict(
omit_unset=True)
worklist_dict.update({'archived': True})
worklists_api.update(worklist.id, worklist_dict)
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose(wtypes.DictType(str, bool), int)
def permissions(self, id):
"""Get the permissions the current user has for the board.
:param id: The ID of the board to check permissions for.
"""
board = boards_api.get(id)
user_id = request.current_user_id
return {
'edit_board': editable(board, user_id),
'move_cards': editable(board, user_id) # TODO(SotK): check lanes
}
@expose()
def _route(self, args, request):
if request.method == 'GET' and len(args) > 1:
if args[1] == "permissions":
# Request to a permissions endpoint
return self.permissions, [args[0]]
return super(BoardsController, self)._route(args, request)

View File

@ -14,6 +14,7 @@
# limitations under the License.
from storyboard.api.v1.auth import AuthController
from storyboard.api.v1.boards import BoardsController
from storyboard.api.v1.branches import BranchesController
from storyboard.api.v1.milestones import MilestonesController
from storyboard.api.v1.project_groups import ProjectGroupsController
@ -46,5 +47,6 @@ class V1Controller(object):
subscription_events = SubscriptionEventsController()
systeminfo = SystemInfoController()
worklists = WorklistsController()
boards = BoardsController()
openid = AuthController()

View File

@ -508,3 +508,44 @@ class WorklistItem(base.APIBase):
list_position = int
"""The position of this item in the Worklist."""
class Lane(base.APIBase):
"""Represents a lane in a kanban board."""
board_id = int
"""The ID of the board containing the lane."""
list_id = int
"""The ID of the worklist which represents the lane."""
position = int
"""The position of the lane in the board."""
class Board(base.APIBase):
"""Represents a kanban board made up of worklists."""
title = wtypes.text
"""The title of the board."""
description = wtypes.text
"""The description of the board."""
creator_id = int
"""The ID of the User who created this board."""
project_id = int
"""The ID of the Project this board is associated with."""
permission_id = int
"""The ID of the Permission which defines who can edit this board."""
private = bool
"""A flag to identify whether this is a private or public board."""
archived = bool
"""A flag to identify whether or not the board has been archived."""
lanes = wtypes.ArrayType(Lane)
"""A list containing the representions of the lanes in this board."""

View File

@ -33,7 +33,10 @@ from storyboard.openstack.common.gettextutils import _ # noqa
CONF = cfg.CONF
def visible(worklist, user=None):
def visible(worklist, user=None, hide_lanes=False):
if hide_lanes:
if worklists_api.is_lane(worklist):
return False
if not worklist:
return False
if user and worklist.private:
@ -153,23 +156,29 @@ class WorklistsController(rest.RestController):
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.Worklist], wtypes.text, int, int,
bool, wtypes.text, wtypes.text)
bool, bool, wtypes.text, wtypes.text, int)
def get_all(self, title=None, creator_id=None, project_id=None,
archived=False, sort_field='id', sort_dir='asc'):
archived=False, hide_lanes=True, sort_field='id',
sort_dir='asc', board_id=None):
"""Retrieve definitions of all of the worklists.
:param title: A string to filter the title by.
:param creator_id: Filter worklists by their creator.
:param project_id: Filter worklists by project ID.
:param archived: Filter worklists by whether they are archived or not.
:param hide_lanes: If true, don't return worklists which are lanes in
a board.
:param sort_field: The name of the field to sort on.
:param sort_dir: Sort direction for results (asc, desc).
:param board_id: Get all worklists in the board with this id. Other
filters are not applied.
"""
worklists = worklists_api.get_all(title=title,
creator_id=creator_id,
project_id=project_id,
archived=archived,
board_id=board_id,
sort_field=sort_field,
sort_dir=sort_dir)
@ -177,7 +186,7 @@ class WorklistsController(rest.RestController):
visible_worklists = [wmodels.Worklist.from_db_model(w)
for w in worklists
if w.archived == archived
and visible(w, user_id)]
and visible(w, user_id, hide_lanes)]
# Apply the query response headers
response.headers['X-Total'] = str(len(visible_worklists))
@ -218,7 +227,8 @@ class WorklistsController(rest.RestController):
if not editable(worklists_api.get(id), user_id):
raise exc.NotFound(_("Worklist %s not found") % id)
updated_worklist = worklists_api.update(id, worklist.as_dict())
updated_worklist = worklists_api.update(
id, worklist.as_dict(omit_unset=True))
return wmodels.Worklist.from_db_model(updated_worklist)

View File

@ -0,0 +1,84 @@
# Copyright (c) 2015 Codethink Limited
#
# 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 sqlalchemy.orm import subqueryload
from wsme.exc import ClientSideError
from storyboard.common import exception as exc
from storyboard.db.api import base as api_base
from storyboard.db import models
from storyboard.openstack.common.gettextutils import _ # noqa
def _board_get(id, session=None):
if not session:
session = api_base.get_session()
query = session.query(models.Board).options(
subqueryload(models.Board.lanes)).filter_by(id=id)
return query.first()
def get(id):
return _board_get(id)
def get_all(title=None, creator_id=None, project_id=None,
sort_field=None, sort_dir=None, **kwargs):
return api_base.entity_get_all(models.Board,
title=title,
creator_id=creator_id,
project_id=project_id,
sort_field=sort_field,
sort_dir=sort_dir,
**kwargs)
def create(values):
board = api_base.entity_create(models.Board, values)
return board
def update(id, values):
return api_base.entity_update(models.Board, id, values)
def add_lane(board_id, lane_dict):
board = _board_get(board_id)
if board is None:
raise exc.NotFound(_("Board %s not found") % board_id)
# Make sure we're adding the lane to the right board
lane_dict['board_id'] = board_id
if lane_dict.get('list_id') is None:
raise ClientSideError(_("A lane must have a worklist_id."))
if lane_dict.get('position') is None:
lane_dict['position'] = len(board.lanes)
api_base.entity_create(models.BoardWorklist, lane_dict)
return board
def update_lane(board_id, lane, new_lane):
# Make sure we aren't messing up the board ID
new_lane['board_id'] = board_id
if new_lane.get('list_id') is None:
raise ClientSideError(_("A lane must have a worklist_id."))
api_base.entity_update(models.BoardWorklist, lane.id, new_lane)

View File

@ -18,6 +18,7 @@ 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 import models
@ -38,7 +39,12 @@ def get(worklist_id):
def get_all(title=None, creator_id=None, project_id=None,
sort_field=None, sort_dir=None, **kwargs):
board_id=None, sort_field=None, sort_dir=None,
**kwargs):
if board_id is not None:
board = boards.get(board_id)
return [lane.worklist for lane in board.lanes]
return api_base.entity_get_all(models.Worklist,
title=title,
creator_id=creator_id,
@ -154,3 +160,11 @@ def remove_item(worklist_id, item_id):
session.delete(item)
return worklist
def is_lane(worklist):
lanes = api_base.entity_get_all(models.BoardWorklist,
list_id=worklist.id)
if lanes:
return True
return False

View File

@ -35,7 +35,8 @@ class_mappings = {'task': [models.Task, wmodels.Task],
'branch': [models.Branch, wmodels.Branch],
'milestone': [models.Milestone, wmodels.Milestone],
'tag': [models.StoryTag, wmodels.Tag],
'worklist': [models.Worklist, wmodels.Worklist]}
'worklist': [models.Worklist, wmodels.Worklist],
'board': [models.Board, wmodels.Board]}
class NotificationHook(hooks.PecanHook):
@ -155,6 +156,7 @@ class NotificationHook(hooks.PecanHook):
'systeminfo': 'systeminfo',
'openid': 'openid',
'worklists': 'worklist',
'boards': 'board',
# Second level resources
'comments': 'comment'