From c919cf0cd86c762e0ff7b5fd500c8d30860666e0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 17 Nov 2023 02:05:11 +0100 Subject: [PATCH] [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 --- docs/source/backends.rst | 1 - docs/source/platypush/backend/trello.rst | 5 - docs/source/platypush/responses/trello.rst | 5 - docs/source/responses.rst | 1 - platypush/backend/trello/__init__.py | 217 -------- platypush/backend/trello/manifest.yaml | 10 - platypush/message/response/trello.py | 186 ------- platypush/plugins/trello/__init__.py | 543 ++++++++++++++------- platypush/plugins/trello/_model.py | 163 +++++++ platypush/plugins/trello/manifest.yaml | 6 +- platypush/schemas/trello.py | 530 ++++++++++++++++++++ 11 files changed, 1076 insertions(+), 591 deletions(-) delete mode 100644 docs/source/platypush/backend/trello.rst delete mode 100644 docs/source/platypush/responses/trello.rst delete mode 100644 platypush/backend/trello/__init__.py delete mode 100644 platypush/backend/trello/manifest.yaml delete mode 100644 platypush/message/response/trello.py create mode 100644 platypush/plugins/trello/_model.py create mode 100644 platypush/schemas/trello.py diff --git a/docs/source/backends.rst b/docs/source/backends.rst index 6982db847..142d68787 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -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 diff --git a/docs/source/platypush/backend/trello.rst b/docs/source/platypush/backend/trello.rst deleted file mode 100644 index 60bd951b1..000000000 --- a/docs/source/platypush/backend/trello.rst +++ /dev/null @@ -1,5 +0,0 @@ -``trello`` -============================ - -.. automodule:: platypush.backend.trello - :members: diff --git a/docs/source/platypush/responses/trello.rst b/docs/source/platypush/responses/trello.rst deleted file mode 100644 index a549c8a36..000000000 --- a/docs/source/platypush/responses/trello.rst +++ /dev/null @@ -1,5 +0,0 @@ -``trello`` -===================================== - -.. automodule:: platypush.message.response.trello - :members: diff --git a/docs/source/responses.rst b/docs/source/responses.rst index 8f8d18d75..205f321ad 100644 --- a/docs/source/responses.rst +++ b/docs/source/responses.rst @@ -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 diff --git a/platypush/backend/trello/__init__.py b/platypush/backend/trello/__init__.py deleted file mode 100644 index bfaf87c8a..000000000 --- a/platypush/backend/trello/__init__.py +++ /dev/null @@ -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 `_ - 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=``. - 4. Copy the ```` 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: diff --git a/platypush/backend/trello/manifest.yaml b/platypush/backend/trello/manifest.yaml deleted file mode 100644 index 662cc94c0..000000000 --- a/platypush/backend/trello/manifest.yaml +++ /dev/null @@ -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 diff --git a/platypush/message/response/trello.py b/platypush/message/response/trello.py deleted file mode 100644 index 2e28cbd30..000000000 --- a/platypush/message/response/trello.py +++ /dev/null @@ -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: diff --git a/platypush/plugins/trello/__init__.py b/platypush/plugins/trello/__init__.py index 0eba5c16c..ee5004ea9 100644 --- a/platypush/plugins/trello/__init__.py +++ b/platypush/plugins/trello/__init__.py @@ -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 `. - 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=&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=&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 `. :param api_secret: Trello API secret. You can get it `here `. :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=&name=platypush&response_type=token&expiration=never&scope=read,write + ``https://trello.com/1/connect?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: diff --git a/platypush/plugins/trello/_model.py b/platypush/plugins/trello/_model.py new file mode 100644 index 000000000..ee959b180 --- /dev/null +++ b/platypush/plugins/trello/_model.py @@ -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: diff --git a/platypush/plugins/trello/manifest.yaml b/platypush/plugins/trello/manifest.yaml index c9536915d..c7d63741b 100644 --- a/platypush/plugins/trello/manifest.yaml +++ b/platypush/plugins/trello/manifest.yaml @@ -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 diff --git a/platypush/schemas/trello.py b/platypush/schemas/trello.py new file mode 100644 index 000000000..1752eed44 --- /dev/null +++ b/platypush/schemas/trello.py @@ -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)