[trello] (Almost) complete plugin rewrite.

- Merged `trello` plugin and backend into a single plugin.

- Removed legacy `Response` objects, replaced with data classes and
  schemas.

- Fixed the Websocket connection flow to reflect the new authentication
  protocol.

Closes: #307
This commit is contained in:
Fabio Manganiello 2023-11-17 02:05:11 +01:00
parent 39b4483401
commit c919cf0cd8
Signed by: blacklight
GPG key ID: D90FBA7F76362774
11 changed files with 1076 additions and 591 deletions

View file

@ -39,7 +39,6 @@ Backends
platypush/backend/stt.picovoice.speech.rst
platypush/backend/tcp.rst
platypush/backend/todoist.rst
platypush/backend/trello.rst
platypush/backend/weather.buienradar.rst
platypush/backend/weather.darksky.rst
platypush/backend/weather.openweathermap.rst

View file

@ -1,5 +0,0 @@
``trello``
============================
.. automodule:: platypush.backend.trello
:members:

View file

@ -1,5 +0,0 @@
``trello``
=====================================
.. automodule:: platypush.message.response.trello
:members:

View file

@ -19,5 +19,4 @@ Responses
platypush/responses/tensorflow.rst
platypush/responses/todoist.rst
platypush/responses/translate.rst
platypush/responses/trello.rst
platypush/responses/weather.buienradar.rst

View file

@ -1,217 +0,0 @@
import json
from threading import Event
from typing import List
from websocket import WebSocketApp
from platypush.backend import Backend
from platypush.context import get_plugin
from platypush.message.event.trello import (
MoveCardEvent,
NewCardEvent,
ArchivedCardEvent,
UnarchivedCardEvent,
)
from platypush.plugins.trello import TrelloPlugin
class TrelloBackend(Backend):
"""
This backend listens for events on a remote Trello account through websocket interface..
Note that the Trello websocket interface
`is not officially supported <https://community.developer.atlassian.com/t/websocket-interface/34586/4>`_
and it requires a different token from the one you use for the Trello API (and for the Trello plugin).
To get the websocket token:
1. Open https://trello.com in your browser.
2. Open the developer tools (F12), go to the Network tab, select 'Websocket' or 'WS' in the filter bar
and refresh the page.
3. You should see an entry in the format ``wss://trello.com/1/Session/socket?token=<token>``.
4. Copy the ``<token>`` in the configuration of this backend.
Requires:
* The :class:`platypush.plugins.trello.TrelloPlugin` configured.
"""
_websocket_url_base = 'wss://trello.com/1/Session/socket?token={token}'
def __init__(self, boards: List[str], token: str, **kwargs):
"""
:param boards: List of boards to subscribe, by ID or name.
:param token: Trello web client API token.
"""
super().__init__(**kwargs)
self._plugin: TrelloPlugin = get_plugin('trello')
self.token = token
self._req_id = 0
self._boards_by_id = {}
self._boards_by_name = {}
for b in boards:
b = self._plugin.get_board(b).board
# noinspection PyUnresolvedReferences
self._boards_by_id[b.id] = b
# noinspection PyUnresolvedReferences
self._boards_by_name[b.name] = b
self.url = self._websocket_url_base.format(token=self.token)
self._connected = Event()
self._items = {}
self._event_handled = False
def _initialize_connection(self, ws: WebSocketApp):
for board_id in self._boards_by_id.keys():
self._send(
ws,
{
'type': 'subscribe',
'modelType': 'Board',
'idModel': board_id,
'tags': ['clientActions', 'updates'],
'invitationTokens': [],
},
)
self.logger.info('Trello boards subscribed')
def _on_msg(self):
def hndl(*args):
if len(args) < 2:
self.logger.warning(
'Missing websocket argument - make sure that you are using '
'a version of websocket-client < 0.53.0 or >= 0.58.0'
)
return
ws, msg = args[:2]
if not msg:
# Reply back with an empty message when the server sends an empty message
ws.send('')
return
# noinspection PyBroadException
try:
msg = json.loads(msg)
except Exception as e:
self.logger.warning(
'Received invalid JSON message from Trello: {}: {}'.format(msg, e)
)
return
if 'error' in msg:
self.logger.warning('Trello error: {}'.format(msg['error']))
return
if msg.get('reqid') == 0:
self.logger.debug('Ping response received, subscribing boards')
self._initialize_connection(ws)
return
notify = msg.get('notify')
if not notify:
return
if notify['event'] != 'updateModels' or notify['typeName'] != 'Action':
return
for delta in notify['deltas']:
args = {
'card_id': delta['data']['card']['id'],
'card_name': delta['data']['card']['name'],
'list_id': (
delta['data'].get('list') or delta['data'].get('listAfter', {})
).get('id'),
'list_name': (
delta['data'].get('list') or delta['data'].get('listAfter', {})
).get('name'),
'board_id': delta['data']['board']['id'],
'board_name': delta['data']['board']['name'],
'closed': delta.get('closed'),
'member_id': delta['memberCreator']['id'],
'member_username': delta['memberCreator']['username'],
'member_fullname': delta['memberCreator']['fullName'],
'date': delta['date'],
}
if delta.get('type') == 'createCard':
self.bus.post(NewCardEvent(**args))
elif delta.get('type') == 'updateCard':
if 'listBefore' in delta['data']:
args.update(
{
'old_list_id': delta['data']['listBefore']['id'],
'old_list_name': delta['data']['listBefore']['name'],
}
)
self.bus.post(MoveCardEvent(**args))
elif 'closed' in delta['data'].get('old', {}):
cls = (
UnarchivedCardEvent
if delta['data']['old']['closed']
else ArchivedCardEvent
)
self.bus.post(cls(**args))
return hndl
def _on_error(self):
def hndl(*args):
error = args[1] if len(args) > 1 else args[0]
self.logger.warning('Trello websocket error: {}'.format(error))
return hndl
def _on_close(self):
def hndl(*_):
self.logger.warning('Trello websocket connection closed')
self._connected.clear()
self._req_id = 0
while True:
try:
self._connect()
self._connected.wait(timeout=20)
break
except TimeoutError:
continue
return hndl
def _on_open(self):
def hndl(*args):
ws = args[0] if args else None
self._connected.set()
if ws:
self._send(ws, {'type': 'ping'})
self.logger.info('Trello websocket connected')
return hndl
def _send(self, ws: WebSocketApp, msg: dict):
msg['reqid'] = self._req_id
ws.send(json.dumps(msg))
self._req_id += 1
def _connect(self) -> WebSocketApp:
return WebSocketApp(
self.url,
on_open=self._on_open(),
on_message=self._on_msg(),
on_error=self._on_error(),
on_close=self._on_close(),
)
def run(self):
super().run()
self.logger.info('Started Todoist backend')
ws = self._connect()
ws.run_forever()
# vim:sw=4:ts=4:et:

View file

@ -1,10 +0,0 @@
manifest:
events:
platypush.message.event.trello.ArchivedCardEvent: when a card is archived/closed.
platypush.message.event.trello.MoveCardEvent: when a card is moved.
platypush.message.event.trello.UnarchivedCardEvent: when a card is un-archived/opened.
platypush.message.event.trello.NewCardEvent: when a card is created.
install:
pip: []
package: platypush.backend.trello
type: backend

View file

@ -1,186 +0,0 @@
import datetime
from typing import Optional, List, Union
from platypush.message import Mapping
from platypush.message.response import Response
class TrelloResponse(Response):
pass
class TrelloList(Mapping):
def __init__(self,
id: str,
name: str,
closed: bool,
subscribed: bool,
*args, **kwargs):
super().__init__(id=id, name=name, closed=closed, subscribed=subscribed, *args, **kwargs)
class TrelloBoard(Mapping):
def __init__(self,
id: str,
name: str,
url: str,
closed: bool,
lists: Optional[List[TrelloList]] = None,
description: Optional[str] = None,
date_last_activity: Optional[datetime.datetime] = None,
*args, **kwargs):
super().__init__(id=id, name=name, url=url, closed=closed, description=description, lists=lists,
date_last_activity=date_last_activity, *args, **kwargs)
class TrelloBoardResponse(TrelloResponse):
def __init__(self, board: TrelloBoard, **kwargs):
super().__init__(output=board, **kwargs)
self.board = board
class TrelloBoardsResponse(TrelloResponse):
def __init__(self, boards: List[TrelloBoard], **kwargs):
super().__init__(output=boards, **kwargs)
class TrelloListsResponse(TrelloResponse):
def __init__(self, lists: List[TrelloList], **kwargs):
super().__init__(output=lists, **kwargs)
class TrelloLabel(Mapping):
def __init__(self,
id: str,
name: str,
color: Optional[str] = None,
*args, **kwargs):
super().__init__(id=id, name=name, color=color, *args, **kwargs)
class TrelloUser(Mapping):
def __init__(self,
id: str,
username: str,
fullname: str,
initials: Optional[str] = None,
avatar_url: Optional[str] = None,
*args, **kwargs):
super().__init__(id=id, username=username, fullname=fullname, initials=initials,
avatar_url=avatar_url, *args, **kwargs)
class TrelloComment(Mapping):
# noinspection PyShadowingBuiltins
def __init__(self,
id: str,
text: str,
type: str,
creator: TrelloUser,
date: Union[str, datetime.datetime],
*args, **kwargs):
super().__init__(id=id, text=text, type=type, creator=creator, date=date, *args, **kwargs)
class TrelloCard(Mapping):
# noinspection PyShadowingBuiltins
def __init__(self,
id: str,
name: str,
url: str,
closed: bool,
board: TrelloBoard,
is_due_complete: bool,
list: Optional[TrelloList] = None,
comments: Optional[List[TrelloComment]] = None,
labels: Optional[List[TrelloLabel]] = None,
description: Optional[str] = None,
due_date: Optional[Union[datetime.datetime, str]] = None,
latest_card_move_date: Optional[Union[datetime.datetime, str]] = None,
date_last_activity: Optional[Union[datetime.datetime, str]] = None,
*args, **kwargs):
super().__init__(id=id, name=name, url=url, closed=closed, board=board, is_due_complete=is_due_complete,
description=description, date_last_activity=date_last_activity, due_date=due_date, list=list,
comments=comments, labels=labels, latest_card_move_date=latest_card_move_date, *args, **kwargs)
class TrelloCardResponse(TrelloResponse):
def __init__(self, card: TrelloCard, **kwargs):
super().__init__(output=card, **kwargs)
class TrelloCardsResponse(TrelloResponse):
def __init__(self, cards: List[TrelloCard], **kwargs):
super().__init__(output=cards, **kwargs)
class TrelloPreview(Mapping):
# noinspection PyShadowingBuiltins
def __init__(self,
id: str,
scaled: bool,
url: str,
bytes: int,
height: int,
width: int,
*args, **kwargs):
super().__init__(id=id, scaled=scaled, url=url, bytes=bytes, height=height, width=width, *args, **kwargs)
class TrelloAttachment(Mapping):
# noinspection PyShadowingBuiltins
def __init__(self,
id: str,
bytes: int,
date: str,
edge_color: str,
id_member: str,
is_upload: bool,
name: str,
previews: List[TrelloPreview],
url: str,
mime_type: Optional[str] = None,
*args, **kwargs):
super().__init__(id=id, bytes=bytes, date=date, edge_color=edge_color, id_member=id_member, is_upload=is_upload,
name=name, previews=previews, url=url, mime_type=mime_type, *args, **kwargs)
class TrelloChecklistItem(Mapping):
def __init__(self,
id: str,
name: str,
checked: bool,
*args, **kwargs):
super().__init__(id=id, name=name, checked=checked, *args, **kwargs)
class TrelloChecklist(Mapping):
def __init__(self,
id: str,
name: str,
checklist_items: List[TrelloChecklistItem],
*args, **kwargs):
super().__init__(id=id, name=name, checklist_items=checklist_items, *args, **kwargs)
class TrelloMember(Mapping):
def __init__(self,
id: str,
full_name: str,
bio: Optional[str],
url: Optional[str],
username: Optional[str],
initials: Optional[str],
member_type: Optional[str] = None,
*args, **kwargs):
super().__init__(id=id, full_name=full_name, bio=bio, url=url, username=username, initials=initials,
member_type=member_type, *args, **kwargs)
class TrelloMembersResponse(Mapping):
def __init__(self, members: List[TrelloMember], **kwargs):
super().__init__(output=members, **kwargs)
# vim:sw=4:ts=4:et:

View file

@ -1,20 +1,30 @@
import datetime
import json
from threading import Event
from typing import Optional, Dict, List, Union
# noinspection PyPackageRequirements
import trello
from trello.board import Board, List as List_ # type: ignore
from trello.exceptions import ResourceUnavailable # type: ignore
from websocket import WebSocketApp
# noinspection PyPackageRequirements
from trello.board import Board, List as List_
from platypush.context import get_bus
from platypush.message.event.trello import (
MoveCardEvent,
NewCardEvent,
ArchivedCardEvent,
UnarchivedCardEvent,
)
from platypush.plugins import RunnablePlugin, action
from platypush.schemas.trello import (
TrelloBoardSchema,
TrelloCardSchema,
TrelloListSchema,
TrelloMemberSchema,
)
# noinspection PyPackageRequirements
from trello.exceptions import ResourceUnavailable
from platypush.message.response.trello import (
from ._model import (
TrelloBoard,
TrelloBoardsResponse,
TrelloCardsResponse,
TrelloCard,
TrelloAttachment,
TrelloPreview,
@ -24,50 +34,84 @@ from platypush.message.response.trello import (
TrelloComment,
TrelloLabel,
TrelloList,
TrelloBoardResponse,
TrelloListsResponse,
TrelloMembersResponse,
TrelloMember,
TrelloCardResponse,
)
from platypush.plugins import Plugin, action
class TrelloPlugin(Plugin):
class TrelloPlugin(RunnablePlugin):
"""
Trello integration.
You'll need a Trello API key. You can get it `here <https://trello.com/app-key>`.
You'll also need an auth token if you want to view/change private resources. You can generate a permanent token
linked to your account on
https://trello.com/1/connect?key=<KEY>&name=platypush&response_type=token&expiration=never&scope=read,write
You'll also need an auth token if you want to view/change private
resources. You can generate a permanent token linked to your account on
``https://trello.com/1/connect?key=<KEY>&name=platypush&response_type=token&expiration=never&scope=read,write``.
Also, polling of events requires you to follow a separate procedure to
retrieve the Websocket tokens, since Trello uses a different (and
undocumented) authorization mechanism:
1. Open https://trello.com in your browser.
2. Open the developer tools (F12), go to the Network tab, select
'Websocket' or 'WS' in the filter bar and refresh the page.
3. You should see an entry in the format
``wss://trello.com/1/Session/socket?clientVersion=...&x-b3-traceid=...&x-b3-spanid=...``.
4. Copy the ``x-b3-traceid`` and ``x-b3-spanid`` values into the
configuration of this plugin.
5. Go to the Cookies tab
6. Copy the value of the ``cloud.session.token`` cookie.
"""
_websocket_url_base = 'wss://trello.com/1/Session/socket?clientVersion=build-194674'
def __init__(
self,
api_key: str,
api_secret: Optional[str] = None,
token: Optional[str] = None,
**kwargs
cloud_session_token: Optional[str] = None,
boards: Optional[List[str]] = None,
**kwargs,
):
"""
:param api_key: Trello API key. You can get it `here <https://trello.com/app-key>`.
:param api_secret: Trello API secret. You can get it `here <https://trello.com/app-key>`.
:param token: Trello token. It is required if you want to access or modify private resources. You can get
a permanent token on
https://trello.com/1/connect?key=<KEY>&name=platypush&response_type=token&expiration=never&scope=read,write
``https://trello.com/1/connect?key=<KEY>&name=platypush&response_type=token&expiration=never&scope=read,write``.
:param cloud_session_token: Cloud session token. It is required
if you want to monitor your boards for changes. See
:class:`platypush.plugins.trello.TrelloPlugin` for the procedure to
retrieve it.
:param boards: List of boards to subscribe, by ID or name. If
specified, then the plugin will listen for changes only on these boards.
"""
super().__init__(**kwargs)
self.api_key = api_key
self.api_secret = api_secret
self.token = token
self.cloud_session_token = cloud_session_token
self._client = None
self._req_id = 0
self._boards_by_id = {}
self._boards_by_name = {}
self._monitored_boards = boards
self.url = None
def _get_client(self) -> trello.TrelloClient:
if token:
self.url = self._websocket_url_base
self._connected = Event()
self._items = {}
self._event_handled = False
self._ws: Optional[WebSocketApp] = None
def _get_client(self) -> trello.TrelloClient: # type: ignore
if not self._client:
self._client = trello.TrelloClient(
self._client = trello.TrelloClient( # type: ignore
api_key=self.api_key,
api_secret=self.api_secret,
token=self.token,
@ -81,21 +125,25 @@ class TrelloPlugin(Plugin):
return client.get_board(board)
except ResourceUnavailable:
boards = [b for b in client.list_boards() if b.name == board]
assert boards, 'No such board: {}'.format(board)
assert boards, f'No such board: {board}'
return boards[0]
# noinspection PyShadowingBuiltins
def _get_boards(
self, all: bool = False # pylint: disable=redefined-builtin
) -> List[Board]:
client = self._get_client()
return client.list_boards(board_filter='all' if all else 'open')
@action
def get_boards(self, all: bool = False) -> TrelloBoardsResponse:
def get_boards(self, all: bool = False): # pylint: disable=redefined-builtin
"""
Get the list of boards.
:param all: If True, return all the boards included those that have been closed/archived/deleted. Otherwise,
only return open/active boards (default: False).
:param all: If True, return all the boards included those that have
been closed/archived/deleted. Otherwise, only return open/active
boards (default: False).
"""
client = self._get_client()
return TrelloBoardsResponse(
return TrelloBoardSchema().dump(
[
TrelloBoard(
id=b.id,
@ -105,27 +153,28 @@ class TrelloPlugin(Plugin):
date_last_activity=b.date_last_activity,
closed=b.closed,
)
for b in client.list_boards(board_filter='all' if all else 'open')
]
for b in self._get_boards(all=all)
],
many=True,
)
@action
def get_board(self, board: str) -> TrelloBoardResponse:
def get_board(self, board: str):
"""
Get the info about a board.
:param board: Board ID or name
"""
board = self._get_board(board)
return TrelloBoardResponse(
b = self._get_board(board)
return TrelloBoardSchema().dump(
TrelloBoard(
id=board.id,
name=board.name,
url=board.url,
closed=board.closed,
description=board.description,
date_last_activity=board.date_last_activity,
id=b.id,
name=b.name,
url=b.url,
closed=b.closed,
description=b.description,
date_last_activity=b.date_last_activity,
lists=[
TrelloList(
id=ll.id,
@ -133,7 +182,7 @@ class TrelloPlugin(Plugin):
closed=ll.closed,
subscribed=ll.subscribed,
)
for ll in board.list_lists()
for ll in b.list_lists()
],
)
)
@ -145,8 +194,7 @@ class TrelloPlugin(Plugin):
:param board: Board ID or name
"""
board = self._get_board(board)
board.open()
self._get_board(board).open()
@action
def close_board(self, board: str):
@ -155,8 +203,7 @@ class TrelloPlugin(Plugin):
:param board: Board ID or name
"""
board = self._get_board(board)
board.close()
self._get_board(board).close()
@action
def set_board_name(self, board: str, name: str):
@ -166,8 +213,7 @@ class TrelloPlugin(Plugin):
:param board: Board ID or name.
:param name: New name.
"""
board = self._get_board(board)
board.set_name(name)
self._get_board(board).set_name(name)
return self.get_board(name)
@action
@ -178,8 +224,7 @@ class TrelloPlugin(Plugin):
:param board: Board ID or name.
:param description: New description.
"""
board = self._get_board(board)
board.set_description(description)
self._get_board(board).set_description(description)
return self.get_board(description)
@action
@ -191,8 +236,7 @@ class TrelloPlugin(Plugin):
:param name: Label name
:param color: Optional HTML color
"""
board = self._get_board(board)
board.add_label(name=name, color=color)
self._get_board(board).add_label(name=name, color=color)
@action
def delete_label(self, board: str, label: str):
@ -203,16 +247,15 @@ class TrelloPlugin(Plugin):
:param label: Label ID or name
"""
board = self._get_board(board)
b = self._get_board(board)
try:
board.delete_label(label)
b.delete_label(label)
except ResourceUnavailable:
labels = [ll for ll in board.get_labels() if ll.name == label]
assert labels, 'No such label: {}'.format(label)
label = labels[0].id
board.delete_label(label)
label_ = next(iter(ll for ll in b.get_labels() if ll.name == label), None)
assert label_, f'No such label: {label}'
label = label_.id
b.delete_label(label)
@action
def add_member(self, board: str, member_id: str, member_type: str = 'normal'):
@ -223,8 +266,7 @@ class TrelloPlugin(Plugin):
:param member_id: Member ID to add.
:param member_type: Member type - can be 'normal' or 'admin' (default: 'normal').
"""
board = self._get_board(board)
board.add_member(member_id, member_type=member_type)
self._get_board(board).add_member(member_id, member_type=member_type)
@action
def remove_member(self, board: str, member_id: str):
@ -234,20 +276,17 @@ class TrelloPlugin(Plugin):
:param board: Board ID or name.
:param member_id: Member ID to remove.
"""
board = self._get_board(board)
board.remove_member(member_id)
self._get_board(board).remove_member(member_id)
def _get_members(
self, board: str, only_admin: bool = False
) -> TrelloMembersResponse:
board = self._get_board(board)
members = board.admin_members() if only_admin else board.get_members()
def _get_members(self, board: str, only_admin: bool = False):
b = self._get_board(board)
members = b.admin_members() if only_admin else b.get_members()
return TrelloMembersResponse(
return TrelloMemberSchema().dump(
[
TrelloMember(
id=m.id,
full_name=m.full_name,
fullname=m.full_name,
bio=m.bio,
url=m.url,
username=m.username,
@ -255,11 +294,12 @@ class TrelloPlugin(Plugin):
member_type=getattr(m, 'member_type', None),
)
for m in members
]
],
many=True,
)
@action
def get_members(self, board: str) -> TrelloMembersResponse:
def get_members(self, board: str):
"""
Get the list of all the members of a board.
:param board: Board ID or name.
@ -267,16 +307,17 @@ class TrelloPlugin(Plugin):
return self._get_members(board, only_admin=False)
@action
def get_admin_members(self, board: str) -> TrelloMembersResponse:
def get_admin_members(self, board: str):
"""
Get the list of the admin members of a board.
:param board: Board ID or name.
"""
return self._get_members(board, only_admin=True)
# noinspection PyShadowingBuiltins
@action
def get_lists(self, board: str, all: bool = False) -> TrelloListsResponse:
def get_lists(
self, board: str, all: bool = False # pylint: disable=redefined-builtin
):
"""
Get the list of lists on a board.
@ -284,15 +325,14 @@ class TrelloPlugin(Plugin):
:param all: If True, return all the lists, included those that have been closed/archived/deleted. Otherwise,
only return open/active lists (default: False).
"""
board = self._get_board(board)
return TrelloListsResponse(
return TrelloListSchema().dump(
[
TrelloList(
id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed
)
for ll in board.list_lists('all' if all else 'open')
]
for ll in self._get_board(board).list_lists('all' if all else 'open')
],
many=True,
)
@action
@ -304,24 +344,24 @@ class TrelloPlugin(Plugin):
:param name: List name
:param pos: Optional position (default: last)
"""
self._get_board(board).add_list(name=name, pos=pos)
board = self._get_board(board)
board.add_list(name=name, pos=pos)
# noinspection PyShadowingBuiltins
def _get_list(self, board: str, list: str) -> List_:
board = self._get_board(board)
def _get_list(
self, board: str, list: str # pylint: disable=redefined-builtin
) -> List_:
b = self._get_board(board)
try:
return board.get_list(list)
return b.get_list(list)
except ResourceUnavailable:
lists = [ll for ll in board.list_lists() if ll.name == list]
assert lists, 'No such list: {}'.format(list)
lists = [ll for ll in b.list_lists() if ll.name == list]
assert lists, f'No such list: {list}'
return lists[0]
# noinspection PyShadowingBuiltins
@action
def set_list_name(self, board: str, list: str, name: str):
def set_list_name(
self, board: str, list: str, name: str # pylint: disable=redefined-builtin
):
"""
Change the name of a board list.
@ -329,44 +369,43 @@ class TrelloPlugin(Plugin):
:param list: List ID or name
:param name: New name
"""
list = self._get_list(board, list)
list.set_name(name)
self._get_list(board, list).set_name(name)
# noinspection PyShadowingBuiltins
@action
def list_subscribe(self, board: str, list: str):
def list_subscribe(
self, board: str, list: str # pylint: disable=redefined-builtin
):
"""
Subscribe to a list.
:param board: Board ID or name
:param list: List ID or name
"""
list = self._get_list(board, list)
list.subscribe()
self._get_list(board, list).subscribe()
# noinspection PyShadowingBuiltins
@action
def list_unsubscribe(self, board: str, list: str):
def list_unsubscribe(
self, board: str, list: str # pylint: disable=redefined-builtin
):
"""
Unsubscribe from a list.
:param board: Board ID or name
:param list: List ID or name
"""
list = self._get_list(board, list)
list.unsubscribe()
self._get_list(board, list).unsubscribe()
# noinspection PyShadowingBuiltins
@action
def archive_all_cards(self, board: str, list: str):
def archive_all_cards(
self, board: str, list: str # pylint: disable=redefined-builtin
):
"""
Archive all the cards on a list.
:param board: Board ID or name
:param list: List ID or name
"""
list = self._get_list(board, list)
list.archive_all_cards()
self._get_list(board, list).archive_all_cards()
@action
def move_all_cards(self, board: str, src: str, dest: str):
@ -377,37 +416,34 @@ class TrelloPlugin(Plugin):
:param src: Source list
:param dest: Target list
"""
src = self._get_list(board, src)
dest = self._get_list(board, dest)
src.move_all_cards(dest.id)
src_list = self._get_list(board, src)
dest_list = self._get_list(board, dest)
src_list.move_all_cards(dest_list.id)
# noinspection PyShadowingBuiltins
@action
def open_list(self, board: str, list: str):
def open_list(self, board: str, list: str): # pylint: disable=redefined-builtin
"""
Open/un-archive a list.
:param board: Board ID or name
:param list: List ID or name
"""
list = self._get_list(board, list)
list.open()
self._get_list(board, list).open()
# noinspection PyShadowingBuiltins
@action
def close_list(self, board: str, list: str):
def close_list(self, board: str, list: str): # pylint: disable=redefined-builtin
"""
Close/archive a list.
:param board: Board ID or name
:param list: List ID or name
"""
list = self._get_list(board, list)
list.close()
self._get_list(board, list).close()
# noinspection PyShadowingBuiltins
@action
def move_list(self, board: str, list: str, position: int):
def move_list(
self, board: str, list: str, position: int # pylint: disable=redefined-builtin
):
"""
Move a list to another position.
@ -415,15 +451,13 @@ class TrelloPlugin(Plugin):
:param list: List ID or name
:param position: New position index
"""
list = self._get_list(board, list)
list.move(position)
self._get_list(board, list).move(position)
# noinspection PyShadowingBuiltins
@action
def add_card(
self,
board: str,
list: str,
list: str, # pylint: disable=redefined-builtin
name: str,
description: Optional[str] = None,
position: Optional[int] = None,
@ -431,7 +465,7 @@ class TrelloPlugin(Plugin):
due: Optional[Union[str, datetime.datetime]] = None,
source: Optional[str] = None,
assign: Optional[List[str]] = None,
) -> TrelloCardResponse:
):
"""
Add a card to a list.
@ -445,16 +479,16 @@ class TrelloPlugin(Plugin):
:param source: Card ID to clone from
:param assign: List of assignee member IDs
"""
list = self._get_list(board, list)
list_ = self._get_list(board, list)
if labels:
labels = [
ll
for ll in list.board.get_labels()
for ll in list_.board.get_labels()
if ll.id in labels or ll.name in labels
]
card = list.add_card(
card = list_.add_card(
name=name,
desc=description,
labels=labels,
@ -464,19 +498,19 @@ class TrelloPlugin(Plugin):
assign=assign,
)
return TrelloCardResponse(
return TrelloCardSchema().dump(
TrelloCard(
id=card.id,
name=card.name,
url=card.url,
closed=card.closed,
board=TrelloBoard(
id=list.board.id,
name=list.board.name,
url=list.board.url,
closed=list.board.closed,
description=list.board.description,
date_last_activity=list.board.date_last_activity,
id=list_.board.id,
name=list_.board.name,
url=list_.board.url,
closed=list_.board.closed,
description=list_.board.description,
date_last_activity=list_.board.date_last_activity,
),
is_due_complete=card.is_due_complete,
list=None,
@ -558,7 +592,7 @@ class TrelloPlugin(Plugin):
labels = [ll for ll in card.board.get_labels() if ll.name == label]
assert labels, 'No such label: {}'.format(label)
assert labels, f'No such label: {label}'
label = labels[0]
card.add_label(label)
@ -575,7 +609,7 @@ class TrelloPlugin(Plugin):
labels = [ll for ll in card.board.get_labels() if ll.name == label]
assert labels, 'No such label: {}'.format(label)
assert labels, f'No such label: {label}'
label = labels[0]
card.remove_label(label)
@ -637,9 +671,13 @@ class TrelloPlugin(Plugin):
card = client.get_card(card_id)
card.remove_attachment(attachment_id)
# noinspection PyShadowingBuiltins
@action
def change_card_board(self, card_id: str, board: str, list: str = None):
def change_card_board(
self,
card_id: str,
board: str,
list: Optional[str] = None, # pylint: disable=redefined-builtin
):
"""
Move a card to a new board.
@ -649,17 +687,18 @@ class TrelloPlugin(Plugin):
"""
client = self._get_client()
card = client.get_card(card_id)
board = self._get_board(board)
board_id = self._get_board(board).id
list_id = None
if list:
list_id = self._get_list(board.id, list).id
list_id = self._get_list(board_id, list).id
card.change_board(board.id, list_id)
card.change_board(board_id, list_id)
# noinspection PyShadowingBuiltins
@action
def change_card_list(self, card_id: str, list: str):
def change_card_list(
self, card_id: str, list: str # pylint: disable=redefined-builtin
):
"""
Move a card to a new list.
@ -668,8 +707,7 @@ class TrelloPlugin(Plugin):
"""
client = self._get_client()
card = client.get_card(card_id)
list = self._get_list(card.board.id, list)
card.change_list(list.id)
card.change_list(self._get_list(card.board.id, list).id)
@action
def change_card_pos(self, card_id: str, position: int):
@ -827,11 +865,13 @@ class TrelloPlugin(Plugin):
card = client.get_card(card_id)
card.subscribe()
# noinspection PyShadowingBuiltins
@action
def get_cards(
self, board: str, list: Optional[str] = None, all: bool = False
) -> TrelloCardsResponse:
self,
board: str,
list: Optional[str] = None, # pylint: disable=redefined-builtin
all: bool = False, # pylint: disable=redefined-builtin
):
"""
Get the list of cards on a board.
@ -842,12 +882,12 @@ class TrelloPlugin(Plugin):
only return open/active cards (default: False).
"""
board = self._get_board(board)
b = self._get_board(board)
lists: Dict[str, TrelloList] = {
ll.id: TrelloList(
id=ll.id, name=ll.name, closed=ll.closed, subscribed=ll.subscribed
)
for ll in board.list_lists()
for ll in b.list_lists()
}
list_id = None
@ -856,14 +896,16 @@ class TrelloPlugin(Plugin):
if list in lists:
list_id = list
else:
# noinspection PyUnresolvedReferences
ll = [l1 for l1 in lists.values() if l1.name == list]
assert ll, 'No such list ID/name: {}'.format(list)
# noinspection PyUnresolvedReferences
list_id = ll[0].id
ll = next(
iter(
l1 for l1 in lists.values() if l1.name == list # type: ignore
),
None,
)
assert ll, f'No such list ID/name: {list}'
list_id = ll.id # type: ignore
# noinspection PyUnresolvedReferences
return TrelloCardsResponse(
return TrelloCardSchema().dump(
[
TrelloCard(
id=c.id,
@ -882,10 +924,11 @@ class TrelloPlugin(Plugin):
attachments=[
TrelloAttachment(
id=a.get('id'),
bytes=a.get('bytes'),
url=a.get('url'),
size=a.get('bytes'),
date=a.get('date'),
edge_color=a.get('edgeColor'),
id_member=a.get('idMember'),
member_id=a.get('idMember'),
is_upload=a.get('isUpload'),
name=a.get('name'),
previews=[
@ -893,13 +936,12 @@ class TrelloPlugin(Plugin):
id=p.get('id'),
scaled=p.get('scaled'),
url=p.get('url'),
bytes=p.get('bytes'),
size=p.get('bytes'),
height=p.get('height'),
width=p.get('width'),
)
for p in a.get('previews', [])
],
url=a.get('url'),
mime_type=a.get('mimeType'),
)
for a in c.attachments
@ -908,7 +950,7 @@ class TrelloPlugin(Plugin):
TrelloChecklist(
id=ch.id,
name=ch.name,
checklist_items=[
items=[
TrelloChecklistItem(
id=i.get('id'),
name=i.get('name'),
@ -945,10 +987,181 @@ class TrelloPlugin(Plugin):
latest_card_move_date=c.latestCardMove_date,
date_last_activity=c.date_last_activity,
)
for c in board.all_cards()
for c in b.all_cards()
if ((all or not c.closed) and (not list or c.list_id == list_id))
]
],
many=True,
)
def _initialize_connection(self, ws: WebSocketApp):
boards = [
b
for b in (self._get_boards() or [])
if not self._monitored_boards
or b.id in self._monitored_boards
or b.name in self._monitored_boards
]
for b in boards:
self._boards_by_id[b.id] = b
self._boards_by_name[b.name] = b
for board_id in self._boards_by_id.keys():
self._send(
ws,
{
'type': 'subscribe',
'modelType': 'Board',
'idModel': board_id,
'tags': ['clientActions', 'updates'],
'invitationTokens': [],
},
)
self.logger.info('Trello boards subscribed')
def _on_msg(self, *args): # pylint: disable=too-many-return-statements
if len(args) < 2:
self.logger.warning(
'Missing websocket argument - make sure that you are using '
'a version of websocket-client < 0.53.0 or >= 0.58.0'
)
return
ws, msg = args[:2]
if not msg:
# Reply back with an empty message when the server sends an empty message
ws.send('')
return
try:
msg = json.loads(msg)
except Exception as e:
self.logger.warning(
'Received invalid JSON message from Trello: %s: %s', msg, e
)
return
if 'error' in msg:
self.logger.warning('Trello error: %s', msg['error'])
return
if msg.get('reqid') == 0:
self.logger.debug('Ping response received, subscribing boards')
self._initialize_connection(ws)
return
notify = msg.get('notify')
if not notify:
return
if notify['event'] != 'updateModels' or notify['typeName'] != 'Action':
return
for delta in notify['deltas']:
args = {
'card_id': delta['data']['card']['id'],
'card_name': delta['data']['card']['name'],
'list_id': (
delta['data'].get('list') or delta['data'].get('listAfter', {})
).get('id'),
'list_name': (
delta['data'].get('list') or delta['data'].get('listAfter', {})
).get('name'),
'board_id': delta['data']['board']['id'],
'board_name': delta['data']['board']['name'],
'closed': delta.get('closed'),
'member_id': delta['memberCreator']['id'],
'member_username': delta['memberCreator']['username'],
'member_fullname': delta['memberCreator']['fullName'],
'date': delta['date'],
}
if delta.get('type') == 'createCard':
get_bus().post(NewCardEvent(**args))
elif delta.get('type') == 'updateCard':
if 'listBefore' in delta['data']:
args.update(
{
'old_list_id': delta['data']['listBefore']['id'],
'old_list_name': delta['data']['listBefore']['name'],
}
)
get_bus().post(MoveCardEvent(**args))
elif 'closed' in delta['data'].get('old', {}):
cls = (
UnarchivedCardEvent
if delta['data']['old']['closed']
else ArchivedCardEvent
)
get_bus().post(cls(**args))
def _on_error(self, *args):
error = args[1] if len(args) > 1 else args[0]
self.logger.warning('Trello websocket error: %s', error)
def _on_close(self, *_):
self.logger.warning('Trello websocket connection closed')
self._connected.clear()
self._req_id = 0
while not self.should_stop():
try:
self._connect(reconnect=True)
self._connected.wait(timeout=10)
break
except TimeoutError:
continue
def _on_open(self, *args):
ws = args[0] if args else None
self._connected.set()
if ws:
self._send(ws, {'type': 'ping'})
self.logger.info('Trello websocket connected')
def _send(self, ws: WebSocketApp, msg: dict):
msg['reqid'] = self._req_id
ws.send(json.dumps(msg))
self._req_id += 1
def _connect(self, reconnect: bool = False) -> WebSocketApp:
assert self.url, 'Trello websocket URL not set'
if reconnect:
self.stop()
if not self._ws:
self._ws = WebSocketApp(
self.url,
header={
'Cookie': (
f'token={self.token}; '
f'cloud.session.token={self.cloud_session_token}'
)
},
on_open=self._on_open,
on_message=self._on_msg,
on_error=self._on_error,
on_close=self._on_close,
)
return self._ws
def main(self):
if not self.url:
self.logger.info(
"token/cloud_session_token not set: your Trello boards won't be monitored for changes"
)
self.wait_stop()
else:
ws = self._connect()
ws.run_forever()
def stop(self):
if self._ws:
self._ws.close()
self._ws = None
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,163 @@
from dataclasses import dataclass
import datetime
from typing import Optional, List, Union
@dataclass
class TrelloList:
"""
Represents a Trello list.
"""
id: str
name: str
closed: bool
subscribed: bool
@dataclass
class TrelloBoard:
"""
Represents a Trello board.
"""
id: str
name: str
url: str
closed: bool
lists: Optional[List[TrelloList]] = None
description: Optional[str] = None
date_last_activity: Optional[datetime.datetime] = None
@dataclass
class TrelloLabel:
"""
Represents a Trello label.
"""
id: str
name: str
color: Optional[str] = None
@dataclass
class TrelloUser:
"""
Represents a Trello user.
"""
id: str
username: str
fullname: str
initials: Optional[str] = None
avatar_url: Optional[str] = None
@dataclass
class TrelloComment:
"""
Represents a Trello comment.
"""
id: str
text: str
type: str
creator: TrelloUser
date: Union[str, datetime.datetime]
@dataclass
class TrelloPreview:
"""
Represents a Trello preview.
"""
id: str
url: str
scaled: bool
size: int
height: int
width: int
@dataclass
class TrelloAttachment:
"""
Represents a Trello attachment.
"""
id: str
size: int
date: str
edge_color: str
member_id: str
is_upload: bool
name: str
previews: List[TrelloPreview]
url: str
mime_type: Optional[str] = None
@dataclass
class TrelloChecklistItem:
"""
Represents a Trello checklist item.
"""
id: str
name: str
checked: bool
@dataclass
class TrelloChecklist:
"""
Represents a Trello checklist.
"""
id: str
name: str
items: List[TrelloChecklistItem]
@dataclass
class TrelloCard:
"""
Represents a Trello card.
"""
id: str
name: str
url: str
closed: bool
board: TrelloBoard
is_due_complete: bool
description: Optional[str] = None
list: Optional[TrelloList] = None
comments: Optional[List[TrelloComment]] = None
labels: Optional[List[TrelloLabel]] = None
attachments: Optional[List[TrelloAttachment]] = None
checklists: Optional[List[TrelloChecklist]] = None
due_date: Optional[Union[datetime.datetime, str]] = None
latest_card_move_date: Optional[Union[datetime.datetime, str]] = None
date_last_activity: Optional[Union[datetime.datetime, str]] = None
@dataclass
class TrelloMember:
"""
Represents a Trello member.
"""
id: str
fullname: str
bio: Optional[str]
url: Optional[str]
username: Optional[str]
initials: Optional[str]
member_type: Optional[str] = None
# vim:sw=4:ts=4:et:

View file

@ -1,5 +1,9 @@
manifest:
events: {}
events:
- platypush.message.event.trello.ArchivedCardEvent
- platypush.message.event.trello.MoveCardEvent
- platypush.message.event.trello.UnarchivedCardEvent
- platypush.message.event.trello.NewCardEvent
install:
pip:
- py-trello

530
platypush/schemas/trello.py Normal file
View file

@ -0,0 +1,530 @@
from marshmallow import EXCLUDE, fields
from marshmallow.schema import Schema
class TrelloLabelSchema(Schema):
"""
Trello label schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The label's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
required=True,
metadata={
"description": "The label's name.",
"example": "My Label",
},
)
color = fields.String(
metadata={
"description": "The label's color.",
"example": "green",
},
)
class TrelloUserSchema(Schema):
"""
Trello user schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The user's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
username = fields.String(
required=True,
metadata={
"description": "The user's username.",
"example": "myusername",
},
)
fullname = fields.String(
metadata={
"description": "The user's full name.",
"example": "My Full Name",
},
)
initials = fields.String(
metadata={
"description": "The user's initials.",
"example": "MFN",
},
)
avatar_url = fields.Url(
metadata={
"description": "The user's avatar URL.",
"example": "https://trello-avatars.s3.amazonaws.com/5d62808da5d6a95a3a3e4f2f/50.png",
},
)
class TrelloMemberSchema(Schema):
"""
Trello member schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The user's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
username = fields.String(
required=True,
metadata={
"description": "The user's username.",
"example": "myusername",
},
)
fullname = fields.String(
metadata={
"description": "The user's full name.",
"example": "My Full Name",
},
)
initials = fields.String(
metadata={
"description": "The user's initials.",
"example": "MFN",
},
)
bio = fields.String(
metadata={
"description": "The user's bio.",
"example": "My bio.",
},
)
member_type = fields.String(
metadata={
"description": "The user's type.",
"example": "admin",
},
)
class TrelloCommentSchema(Schema):
"""
Trello comment schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The comment's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
text = fields.String(
required=True,
metadata={
"description": "The comment's text.",
"example": "My comment's text.",
},
)
type = fields.String(
required=True,
metadata={
"description": "The comment's type.",
"example": "commentCard",
},
)
creator = fields.Nested(TrelloUserSchema)
date = fields.DateTime(
metadata={
"description": "The comment's date.",
"example": "2019-08-25T15:32:13.000Z",
},
)
class TrelloListSchema(Schema):
"""
Trello list schema.
"""
class Meta: # pylint: disable=too-few-public-methods
"""
Meta class.
"""
unknown = EXCLUDE
id = fields.String(
required=True,
metadata={
"description": "The list's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
required=True,
metadata={
"description": "The list's name.",
"example": "My List",
},
)
closed = fields.Boolean(
missing=False,
metadata={
"description": "Whether the list is closed.",
"example": False,
},
)
subscribed = fields.Boolean(
missing=False,
metadata={
"description": "Whether the list is subscribed.",
"example": False,
},
)
class TrelloBoardSchema(Schema):
"""
Trello board schema.
"""
class Meta: # pylint: disable=too-few-public-methods
"""
Meta class.
"""
unknown = EXCLUDE
id = fields.String(
required=True,
metadata={
"description": "The board's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
required=True,
metadata={
"description": "The board's name.",
"example": "My Board",
},
)
url = fields.Url(
required=True,
metadata={
"description": "The board's URL.",
"example": "https://trello.com/b/5d62808da5d6a95a3a3e4f2f/my-board",
},
)
closed = fields.Boolean(
missing=False,
metadata={
"description": "Whether the board is closed.",
"example": False,
},
)
lists = fields.Nested(
TrelloListSchema,
many=True,
metadata={
"description": "The board's lists.",
},
)
date_last_activity = fields.DateTime(
metadata={
"description": "The board's last activity date.",
"example": "2019-08-25T15:52:45.000Z",
},
)
class TrelloPreviewSchema(Schema):
"""
Trello attachment preview schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The preview's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
url = fields.Url(
required=True,
metadata={
"description": "The preview's URL.",
"example": "https://trello.com/c/5d62808da5d6a95a3a3e4f2f/my-attachment-txt.jpg",
},
)
scaled = fields.Boolean(
metadata={
"description": "Whether the preview is scaled.",
"example": True,
},
)
size = fields.Integer(
metadata={
"description": "The preview's size, in bytes.",
"example": 10000,
},
)
width = fields.Integer(
metadata={
"description": "The preview's width, in pixels.",
"example": 100,
},
)
height = fields.Integer(
metadata={
"description": "The preview's height, in pixels.",
"example": 100,
},
)
class TrelloChecklistItemSchema(Schema):
"""
Trello checklist item schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The checklist item's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
required=True,
metadata={
"description": "The checklist item's name.",
"example": "My Checklist Item",
},
)
checked = fields.Boolean(
metadata={
"description": "Whether the checklist item is checked.",
"example": True,
},
)
class TrelloChecklistSchema(Schema):
"""
Trello checklist schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The checklist's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
metadata={
"description": "The checklist's name.",
"example": "My Checklist",
},
)
items = fields.Nested(TrelloChecklistItemSchema, many=True)
class TrelloAttachmentSchema(Schema):
"""
Trello attachment schema.
"""
id = fields.String(
required=True,
metadata={
"description": "The attachment's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
required=True,
metadata={
"description": "The attachment's name.",
"example": "My Attachment.txt",
},
)
url = fields.Url(
required=True,
metadata={
"description": "The attachment's URL.",
"example": "https://trello.com/c/5d62808da5d6a95a3a3e4f2f/my-attachment.txt",
},
)
size = fields.Integer(
metadata={
"description": "The attachment's size, in bytes.",
"example": 1024,
},
)
date = fields.DateTime(
metadata={
"description": "The attachment's date.",
"example": "2019-08-25T15:32:13.000Z",
},
)
edge_color = fields.String(
metadata={
"description": "The attachment's edge color.",
"example": "#000000",
},
)
member_id = fields.String(
metadata={
"description": "The ID of the member who created the attachment.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
is_upload = fields.Boolean(
metadata={
"description": "Whether the attachment is an upload.",
"example": True,
},
)
mime_type = fields.String(
metadata={
"description": "The attachment's MIME type.",
"example": "text/plain",
},
)
previews = fields.Nested(TrelloPreviewSchema, many=True)
class TrelloCardSchema(Schema):
"""
Trello card schema.
"""
class Meta: # pylint: disable=too-few-public-methods
"""
Meta class.
"""
unknown = EXCLUDE
id = fields.String(
required=True,
metadata={
"description": "The card's unique identifier.",
"example": "5d62808da5d6a95a3a3e4f2f",
},
)
name = fields.String(
required=True,
metadata={
"description": "The card's name.",
"example": "My Card",
},
)
description = fields.String(
metadata={
"description": "The card's description.",
"example": "My card's description.",
},
)
url = fields.Url(
required=True,
metadata={
"description": "The card's URL.",
"example": "https://trello.com/c/5d62808da5d6a95a3a3e4f2f/my-card",
},
)
due_date = fields.DateTime(
metadata={
"description": "The card's due date.",
"example": "2019-08-25T15:52:45.000Z",
},
)
latest_card_move_date = fields.DateTime(
metadata={
"description": "The card's latest move date.",
"example": "2019-08-25T15:52:45.000Z",
},
)
date_last_activity = fields.DateTime(
metadata={
"description": "The card's last activity date.",
"example": "2019-08-25T15:52:45.000Z",
},
)
closed = fields.Boolean(
missing=False,
metadata={
"description": "Whether the card is closed.",
"example": False,
},
)
is_due_complete = fields.Boolean(
metadata={
"description": "Whether the card is due complete.",
"example": False,
},
)
board = fields.Nested(TrelloBoardSchema)
list = fields.Nested(TrelloListSchema)
comments = fields.Nested(TrelloCommentSchema, many=True)
labels = fields.Nested(TrelloLabelSchema, many=True)
attachments = fields.Nested(TrelloAttachmentSchema, many=True)
checklists = fields.Nested(TrelloChecklistSchema, many=True)