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:
parent
4808fa9ee3
commit
d7efbd8cd4
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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'
|
||||
|
Loading…
x
Reference in New Issue
Block a user