Make it possible to get worklist/board timeline events via the API

This commit adds a new endpoint `/v1/events` which provides a list of
all timeline events. It is able to be filtered by story_id, worklist_id,
and board_id.

The existing `/v1/stories/:id/events` endpoint is preserved, but similar
endpoints for worklists and boards don't exist in favour of using something
like `/v1/events?worklist_id=360`.

Change-Id: Ie13f9d71d9a0b736e4184dd36cc1a6ac26a5f109
This commit is contained in:
Adam Coldrick 2016-08-12 12:38:12 +00:00
parent 4808fa9ee3
commit d7efbd8cd4
10 changed files with 227 additions and 42 deletions

View File

@ -38,6 +38,9 @@ Comments and Timeline events
:webprefix: /v1/stories/<story_id>/comments
.. rest-controller:: storyboard.api.v1.timeline:TimeLineEventsController
:webprefix: /v1/events
.. rest-controller:: storyboard.api.v1.timeline:NestedTimeLineEventsController
:webprefix: /v1/stories/<story_id>/events
Tasks

View File

@ -29,7 +29,7 @@ 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 TimeLineEventsController
from storyboard.api.v1.timeline import NestedTimeLineEventsController
from storyboard.api.v1 import validations
from storyboard.api.v1 import wmodels
from storyboard.common import decorators
@ -311,7 +311,7 @@ class StoriesController(rest.RestController):
story_id, current_user=request.current_user_id)
comments = CommentsController()
events = TimeLineEventsController()
events = NestedTimeLineEventsController()
tasks = TasksNestedController()
tags = TagsController()

View File

@ -39,6 +39,101 @@ SEARCH_ENGINE = search_engine.get_engine()
class TimeLineEventsController(rest.RestController):
"""Manages timeline events."""
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose(wmodels.TimeLineEvent, int)
def get_one(self, event_id):
"""Retrieve details about one event.
Example::
curl https://my.example.org/api/v1/events/15994
:param event_id: An ID of the event.
"""
event = events_api.event_get(event_id,
current_user=request.current_user_id)
if events_api.is_visible(event, request.current_user_id):
wsme_event = wmodels.TimeLineEvent.from_db_model(event)
wsme_event = wmodels.TimeLineEvent.resolve_event_values(wsme_event)
return wsme_event
else:
raise exc.NotFound(_("Event %s not found") % event_id)
@decorators.db_exceptions
@secure(checks.guest)
@wsme_pecan.wsexpose([wmodels.TimeLineEvent], int, int, int, [wtypes.text],
int, int, wtypes.text, wtypes.text)
def get_all(self, story_id=None, worklist_id=None, board_id=None,
event_type=None, offset=None, limit=None,
sort_field=None, sort_dir=None):
"""Retrieve a filtered list of all events.
With no filters or limit set this will likely take a long time
and return a very long list. Applying some filters is recommended.
Example::
curl https://my.example.org/api/v1/events
:param story_id: Filter events by story ID.
:param worklist_id: Filter events by worklist ID.
:param board_id: Filter events by board ID.
:param event_type: A selection of event types to get.
:param offset: The offset to start the page at.
:param limit: The number of events to retrieve.
:param sort_field: The name of the field to sort on.
:param sort_dir: Sort direction for results (asc, desc).
"""
current_user = request.current_user_id
# Boundary check on limit.
if limit is not None:
limit = max(0, limit)
# Sanity check on event types.
if event_type:
for r_type in event_type:
if r_type not in event_types.ALL:
msg = _('Invalid event_type requested. Event type must be '
'one of the following: %s')
msg = msg % (', '.join(event_types.ALL),)
abort(400, msg)
events = events_api.events_get_all(story_id=story_id,
worklist_id=worklist_id,
board_id=board_id,
event_type=event_type,
sort_field=sort_field,
sort_dir=sort_dir,
current_user=current_user)
# Apply the query response headers.
if limit:
response.headers['X-Limit'] = str(limit)
if offset is not None:
response.headers['X-Offset'] = str(offset)
visible = [event for event in events
if events_api.is_visible(event, current_user)]
if offset is None:
offset = 0
if limit is None:
limit = len(visible)
response.headers['X-Total'] = str(len(visible))
return [wmodels.TimeLineEvent.resolve_event_values(
wmodels.TimeLineEvent.from_db_model(event))
for event in visible[offset:limit + offset]]
class NestedTimeLineEventsController(rest.RestController):
"""Manages comments."""
@decorators.db_exceptions

View File

@ -28,6 +28,7 @@ from storyboard.api.v1.tags import TagsController
from storyboard.api.v1.task_statuses import TaskStatusesController
from storyboard.api.v1.tasks import TasksPrimaryController
from storyboard.api.v1.teams import TeamsController
from storyboard.api.v1.timeline import TimeLineEventsController
from storyboard.api.v1.users import UsersController
from storyboard.api.v1.worklists import WorklistsController
@ -50,5 +51,6 @@ class V1Controller(object):
worklists = WorklistsController()
boards = BoardsController()
due_dates = DueDatesController()
events = TimeLineEventsController()
openid = AuthController()

View File

@ -22,7 +22,6 @@ from wsme import types as wtypes
from storyboard.api.v1 import base
from storyboard.common.custom_types import NameType
from storyboard.common import event_resolvers
from storyboard.common import event_types
from storyboard.db.api import boards as boards_api
from storyboard.db.api import comments as comments_api
from storyboard.db.api import due_dates as due_dates_api
@ -421,6 +420,12 @@ class TimeLineEvent(base.APIBase):
story_id = int
"""The ID of the corresponding Story."""
worklist_id = int
"""The ID of the corresponding Worklist."""
board_id = int
"""The ID of the corresponding Board."""
author_id = int
"""The ID of User who has left the comment."""
@ -450,38 +455,7 @@ class TimeLineEvent(base.APIBase):
@staticmethod
def _resolve_info(event):
if event.event_type == event_types.STORY_CREATED:
return event_resolvers.story_created(event)
elif event.event_type == event_types.STORY_DETAILS_CHANGED:
return event_resolvers.story_details_changed(event)
elif event.event_type == event_types.USER_COMMENT:
return event_resolvers.user_comment(event)
elif event.event_type == event_types.TASK_CREATED:
return event_resolvers.task_created(event)
elif event.event_type == event_types.TASK_STATUS_CHANGED:
return event_resolvers.task_status_changed(event)
elif event.event_type == event_types.TASK_PRIORITY_CHANGED:
return event_resolvers.task_priority_changed(event)
elif event.event_type == event_types.TASK_ASSIGNEE_CHANGED:
return event_resolvers.task_assignee_changed(event)
elif event.event_type == event_types.TASK_DETAILS_CHANGED:
return event_resolvers.task_details_changed(event)
elif event.event_type == event_types.TASK_DELETED:
return event_resolvers.task_deleted(event)
elif event.event_type == event_types.TAGS_ADDED:
return event_resolvers.tags_added(event)
elif event.event_type == event_types.TAGS_DELETED:
return event_resolvers.tags_deleted(event)
return event_resolvers.resolvers[event.event_type](event)
class RefreshToken(base.APIBase):

View File

@ -573,6 +573,7 @@ class ItemsSubcontroller(rest.RestController):
removed = {
"worklist_id": id,
"item_id": card.item_id,
"item_type": card.item_type,
"item_title": item.title
}
events_api.worklist_contents_changed_event(id,

View File

@ -15,6 +15,7 @@
import json
from storyboard.common import event_types
from storyboard.db.api import users as users_api
@ -82,3 +83,73 @@ def tags_added(event):
def tags_deleted(event):
return event
def worklist_created(event):
return event
def worklist_details_changed(event):
return event
def worklist_permission_created(event):
return event
def worklist_permissions_changed(event):
return event
def worklist_filters_changed(event):
return event
def worklist_contents_changed(event):
return event
def board_created(event):
return event
def board_details_changed(event):
return event
def board_permission_created(event):
return event
def board_permissions_changed(event):
return event
def board_lanes_changed(event):
return event
resolvers = {
event_types.STORY_CREATED: story_created,
event_types.STORY_DETAILS_CHANGED: story_details_changed,
event_types.TAGS_ADDED: tags_added,
event_types.TAGS_DELETED: tags_deleted,
event_types.USER_COMMENT: user_comment,
event_types.TASK_CREATED: task_created,
event_types.TASK_DETAILS_CHANGED: task_details_changed,
event_types.TASK_STATUS_CHANGED: task_status_changed,
event_types.TASK_PRIORITY_CHANGED: task_priority_changed,
event_types.TASK_ASSIGNEE_CHANGED: task_assignee_changed,
event_types.TASK_DELETED: task_deleted,
event_types.WORKLIST_CREATED: worklist_created,
event_types.WORKLIST_DETAILS_CHANGED: worklist_details_changed,
event_types.WORKLIST_PERMISSION_CREATED: worklist_permission_created,
event_types.WORKLIST_PERMISSIONS_CHANGED: worklist_permissions_changed,
event_types.WORKLIST_FILTERS_CHANGED: worklist_filters_changed,
event_types.WORKLIST_CONTENTS_CHANGED: worklist_contents_changed,
event_types.BOARD_CREATED: board_created,
event_types.BOARD_DETAILS_CHANGED: board_details_changed,
event_types.BOARD_PERMISSION_CREATED: board_permission_created,
event_types.BOARD_PERMISSIONS_CHANGED: board_permissions_changed,
event_types.BOARD_LANES_CHANGED: board_lanes_changed
}

View File

@ -434,7 +434,7 @@ def filter_private_worklists(query, current_user, hide_lanes=True):
# into the lists which are in boards (`lanes`) and those which
# aren't (`lists`). We then either hide the lanes entirely or
# unify the two queries.
lanes = query.outerjoin(
lanes = query.join(
(board_worklists, models.Worklist.id == board_worklists.list_id))
lanes = (lanes
.outerjoin((boards, boards.id == board_worklists.board_id))
@ -484,7 +484,7 @@ def filter_private_worklists(query, current_user, hide_lanes=True):
lists = lists.filter(
or_(
models.Worklist.private == false(),
models.Worklist.id.is_(None)
models.Worklist.private.is_(None)
)
)

View File

@ -23,6 +23,8 @@ from wsme.rest.json import tojson
from storyboard.api.v1.wmodels import TimeLineEvent
from storyboard.common import event_types
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.notifications.publisher import publish
@ -35,11 +37,15 @@ def event_get(event_id, session=None, current_user=None):
query = query.outerjoin(models.Story)
query = api_base.filter_private_stories(query, current_user)
query = query.outerjoin(models.Worklist)
query = query.outerjoin((
models.Worklist,
models.Worklist.id == models.TimeLineEvent.worklist_id))
query = api_base.filter_private_worklists(
query, current_user, hide_lanes=False)
query = query.outerjoin(models.Board)
query = query.outerjoin((
models.Board,
models.Board.id == models.TimeLineEvent.board_id))
query = api_base.filter_private_boards(query, current_user)
return query.first()
@ -55,11 +61,15 @@ def _events_build_query(current_user=None, **kwargs):
query = query.outerjoin(models.Story)
query = api_base.filter_private_stories(query, current_user)
query = query.outerjoin(models.Worklist)
query = query.outerjoin((
models.Worklist,
models.Worklist.id == models.TimeLineEvent.worklist_id))
query = api_base.filter_private_worklists(
query, current_user, hide_lanes=False)
query = query.outerjoin(models.Board)
query = query.outerjoin((
models.Board,
models.Board.id == models.TimeLineEvent.board_id))
query = api_base.filter_private_boards(query, current_user)
return query
@ -111,6 +121,33 @@ def event_create(values):
return new_event
def is_visible(event, user_id):
if event is None:
return False
if 'worklist_contents' in event.event_type:
event_info = json.loads(event.event_info)
if event_info['updated'] is not None:
info = event_info['updated']['old']
elif event_info['removed'] is not None:
info = event_info['removed']
elif event_info['added'] is not None:
info = event_info['added']
else:
return True
if info.get('item_type') == 'story':
story = stories_api.story_get_simple(
info['item_id'], current_user=user_id)
if story is None:
return False
elif info.get('item_type') == 'task':
task = tasks_api.task_get(
info['item_id'], current_user=user_id)
if task is None:
return False
return True
def story_created_event(story_id, author_id, story_title):
event_info = {
"story_id": story_id,

View File

@ -38,7 +38,8 @@ class_mappings = {'task': [models.Task, wmodels.Task],
'worklist': [models.Worklist, wmodels.Worklist],
'board': [models.Board, wmodels.Board],
'comment': [models.Comment, wmodels.Comment],
'due_date': [models.DueDate, wmodels.DueDate]}
'due_date': [models.DueDate, wmodels.DueDate],
'event': [models.TimeLineEvent, wmodels.TimeLineEvent]}
class NotificationHook(hooks.PecanHook):
@ -170,6 +171,7 @@ class NotificationHook(hooks.PecanHook):
'worklists': 'worklist',
'boards': 'board',
'due_dates': 'due_date',
'events': 'event',
# Second level resources
'comments': 'comment'