From 3adb76e33f1dd347bcd7d30e4ccece8a5af0a54e Mon Sep 17 00:00:00 2001 From: Alex Kulikov Date: Mon, 20 May 2024 03:48:27 +0100 Subject: [PATCH] feat: add cmd get_board_state for focalboard state --- src/bot.py | 6 ++ src/focalboard/focalboard_client.py | 27 +++--- src/jobs/__init__.py | 1 + src/jobs/board_state_job.py | 89 +++++++++++++++++++ src/jobs/utils.py | 7 +- src/utils/card_checks_focalboard.py | 131 ++++++++++++++++++++++++++++ 6 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 src/jobs/board_state_job.py create mode 100644 src/utils/card_checks_focalboard.py diff --git a/src/bot.py b/src/bot.py index e62c8ec..f539708 100644 --- a/src/bot.py +++ b/src/bot.py @@ -88,6 +88,12 @@ def init_handlers(self): self.manager_reply_handler("trello_board_state_job"), "получить сводку о состоянии доски", ) + self.add_manager_handler( + "get_board_state", + CommandCategories.SUMMARY, + self.manager_reply_handler("board_state_job"), + "получить сводку о состоянии доски (focalboard)", + ) self.add_manager_handler( "get_editorial_board_stats", CommandCategories.STATS, diff --git a/src/focalboard/focalboard_client.py b/src/focalboard/focalboard_client.py index a453d4e..48a2d30 100644 --- a/src/focalboard/focalboard_client.py +++ b/src/focalboard/focalboard_client.py @@ -235,15 +235,15 @@ def get_custom_fields(self, card_id: str) -> objects.CardCustomFields: card_labels.append(board_label) card_fields.authors = [ - self.get_username(author.strip()) + author.strip() for author in card_fields_dict.get("author", []) ] card_fields.editors = [ - self.get_username(editor.strip()) + editor.strip() for editor in card_fields_dict.get("editor", []) ] card_fields.illustrators = [ - self.get_username(illustrator.strip()) + illustrator.strip() for illustrator in card_fields_dict.get("illustrator", []) ] card_fields.cover = ( @@ -268,7 +268,7 @@ def get_members(self, board_id) -> List[objects.TrelloMember]: logger.debug(f"get_members: {members}") return members - def get_cards(self, list_ids, board_id=None): + def get_cards(self, list_ids=None, board_id=None): if board_id is None: board_id = self.board_id _, data = self._make_request(f"api/v2/boards/{board_id}/blocks?all=true") @@ -281,12 +281,19 @@ def get_cards(self, list_ids, board_id=None): view_id = [card_dict for card_dict in data if card_dict["type"] == "view"][0][ "id" ] - data = [ - card_dict - for card_dict in data - if card_dict["type"] == "card" - and card_dict["fields"]["properties"].get(list_prop, "") in list_ids - ] + if list_ids: + data = [ + card_dict + for card_dict in data + if card_dict["type"] == "card" + and card_dict["fields"]["properties"].get(list_prop, "") in list_ids + ] + else: + data = [ + card_dict + for card_dict in data + if card_dict["type"] == "card" + ] for card_dict in data: card = objects.TrelloCard.from_focalboard_dict(card_dict) card.url = urljoin(self.url, f"{board_id}/{view_id}/{card.id}") diff --git a/src/jobs/__init__.py b/src/jobs/__init__.py index d59f8c3..4575891 100644 --- a/src/jobs/__init__.py +++ b/src/jobs/__init__.py @@ -37,6 +37,7 @@ from .shrug_job import ShrugJob from .site_health_check_job import SiteHealthCheckJob from .tg_analytics_report_job import TgAnalyticsReportJob +from .board_state_job import BoardStateJob from .trello_board_state_job import TrelloBoardStateJob from .trello_board_state_notifications_job import TrelloBoardStateNotificationsJob from .trello_get_articles_arts_job import TrelloGetArticlesArtsJob diff --git a/src/jobs/board_state_job.py b/src/jobs/board_state_job.py new file mode 100644 index 0000000..d9f31d6 --- /dev/null +++ b/src/jobs/board_state_job.py @@ -0,0 +1,89 @@ +import datetime +import logging +from typing import Callable, List + +from ..app_context import AppContext +from ..consts import TrelloCardColor +from ..strings import load +from ..tg.sender import pretty_send +from ..trello.trello_objects import TrelloCard +from ..utils import card_checks_focalboard +from .base_job import BaseJob +from .utils import get_cards_by_curator, retrieve_usernames + +logger = logging.getLogger(__name__) + + +class BoardStateJob(BaseJob): + @staticmethod + def _execute( + app_context: AppContext, send: Callable[[str], None], called_from_handler=False + ): + paragraphs = [ + load("trello_board_state_job__intro") + ] # list of paragraph strings + curator_cards = get_cards_by_curator(app_context, focalboard=True) + for curator, curator_cards in curator_cards.items(): + curator_name, _ = curator + card_paragraphs = [] + curator_cards.sort(key=lambda c: c.due if c.due else datetime.datetime.min) + for card in curator_cards: + card_paragraph = BoardStateJob._format_card( + card, + card_checks_focalboard.make_card_failure_reasons(card, app_context), + app_context, + ) + if card_paragraph: + card_paragraphs.append(card_paragraph) + if card_paragraphs: + paragraphs.append(f"⭐️ Куратор: {curator_name}") + paragraphs += card_paragraphs + pretty_send(paragraphs, send) + + @staticmethod + def _format_card( + card: TrelloCard, failure_reasons: List[str], app_context: AppContext + ) -> str: + if not failure_reasons: + return None + + failure_reasons_formatted = ", ".join(failure_reasons) + labels = ( + load( + "trello_board_state_job__card_labels", + names=", ".join( + # We filter BLACK cards as this is an auxiliary label + label.name + for label in card.labels + if label.color != TrelloCardColor.BLACK + ), + ) + if card.labels + else "" + ) + + # Avoiding message overflow, strip explanations in () + list_name = card.lst.name + "(" + list_name = list_name[: list_name.find("(")].strip() + + members = ( + load( + "trello_board_state_job__card_members", + members=", ".join( + retrieve_usernames(card.members, app_context.db_client) + ), + curators="", + ) + if card.members + else "" + ) + + return load( + "trello_board_state_job__card_2", + failure_reasons=failure_reasons_formatted, + url=card.url, + name=card.name, + labels=labels, + list_name=list_name, + members=members, + ) diff --git a/src/jobs/utils.py b/src/jobs/utils.py index e920f54..c6e1251 100644 --- a/src/jobs/utils.py +++ b/src/jobs/utils.py @@ -271,8 +271,11 @@ def check_trello_card( return True -def get_cards_by_curator(app_context: AppContext): - cards = app_context.trello_client.get_cards() +def get_cards_by_curator(app_context: AppContext, focalboard=False): + if focalboard: + cards = app_context.focalboard_client.get_cards() + else: + cards = app_context.trello_client.get_cards() curator_cards = defaultdict(list) for card in cards: curators = get_curators_by_card(card, app_context.db_client) diff --git a/src/utils/card_checks_focalboard.py b/src/utils/card_checks_focalboard.py new file mode 100644 index 0000000..159692f --- /dev/null +++ b/src/utils/card_checks_focalboard.py @@ -0,0 +1,131 @@ +import datetime +from typing import Tuple + +from ..app_context import AppContext +from ..consts import TrelloCardColor, TrelloListAlias +from ..strings import load +from ..trello.trello_objects import TrelloCard + + +def make_card_failure_reasons(card: TrelloCard, app_context: AppContext): + """ + Returns card description with failure reasons, if any. + If card does not match any of FILTER_TO_FAILURE_REASON, returns None. + """ + failure_reasons = [] + for filter_func, reason_alias in FILTER_TO_FAILURE_REASON.items(): + is_failed, kwargs = filter_func(card, app_context) + if is_failed: + reason = load(reason_alias, **kwargs) + if reason and len(failure_reasons) > 0: + reason = reason[0].lower() + reason[1:] + failure_reasons.append(reason) + return failure_reasons + + +def is_deadline_missed(card: TrelloCard, app_context: AppContext) -> Tuple[bool, dict]: + list_ids = app_context.focalboard_client.get_list_id_from_aliases( + [TrelloListAlias.IN_PROGRESS] + ) + is_missed = ( + card.lst.id in list_ids + and card.due is not None + and card.due.date() < datetime.datetime.now().date() + ) + return is_missed, {"date": card.due.strftime("%d.%m")} if is_missed else {} + + +def is_due_date_missing(card: TrelloCard, app_context: AppContext) -> Tuple[bool, dict]: + if card.due: + return False, {} + list_ids = app_context.focalboard_client.get_list_id_from_aliases( + [TrelloListAlias.IN_PROGRESS] + ) + return card.lst.id in list_ids, {} + + +def is_author_missing(card: TrelloCard, app_context: AppContext) -> Tuple[bool, dict]: + if card.members: + return False, {} + + list_aliases = ( + TrelloListAlias.IN_PROGRESS, + TrelloListAlias.TO_EDITOR, + TrelloListAlias.EDITED_NEXT_WEEK, + TrelloListAlias.TO_SEO_EDITOR, + TrelloListAlias.EDITED_SOMETIMES, + TrelloListAlias.TO_CHIEF_EDITOR, + TrelloListAlias.PROOFREADING, + TrelloListAlias.DONE, + ) + list_ids = app_context.focalboard_client.get_list_id_from_aliases(list_aliases) + return card.lst.id in list_ids, {} + + +def is_tag_missing(card: TrelloCard, app_context: AppContext) -> Tuple[bool, dict]: + if card.labels: + return False, {} + + list_aliases = ( + TrelloListAlias.IN_PROGRESS, + TrelloListAlias.TO_EDITOR, + TrelloListAlias.EDITED_NEXT_WEEK, + TrelloListAlias.TO_SEO_EDITOR, + TrelloListAlias.EDITED_SOMETIMES, + TrelloListAlias.TO_CHIEF_EDITOR, + TrelloListAlias.PROOFREADING, + TrelloListAlias.DONE, + ) + list_ids = app_context.focalboard_client.get_list_id_from_aliases(list_aliases) + return card.lst.id in list_ids, {} + + +def is_doc_missing(card: TrelloCard, app_context: AppContext) -> Tuple[bool, dict]: + list_aliases = ( + TrelloListAlias.TO_EDITOR, + TrelloListAlias.EDITED_NEXT_WEEK, + TrelloListAlias.TO_SEO_EDITOR, + TrelloListAlias.EDITED_SOMETIMES, + TrelloListAlias.TO_CHIEF_EDITOR, + TrelloListAlias.PROOFREADING, + TrelloListAlias.DONE, + ) + list_ids = app_context.focalboard_client.get_list_id_from_aliases(list_aliases) + if card.lst.id not in list_ids: + return False, {} + + doc_url = app_context.focalboard_client.get_custom_fields(card.id).google_doc + return not doc_url, {} + + +def has_no_doc_access(card: TrelloCard, app_context: AppContext) -> Tuple[bool, dict]: + list_aliases = ( + TrelloListAlias.TO_EDITOR, + TrelloListAlias.EDITED_NEXT_WEEK, + TrelloListAlias.TO_SEO_EDITOR, + TrelloListAlias.EDITED_SOMETIMES, + TrelloListAlias.TO_CHIEF_EDITOR, + TrelloListAlias.PROOFREADING, + TrelloListAlias.DONE, + ) + list_ids = app_context.focalboard_client.get_list_id_from_aliases(list_aliases) + if card.lst.id not in list_ids: + return False, {} + + doc_url = app_context.focalboard_client.get_custom_fields(card.id).google_doc + if not doc_url: + # should be handled by is_doc_missing + return False, {} + + is_open_for_edit = app_context.drive_client.is_open_for_edit(doc_url) + return not is_open_for_edit, {} + + +FILTER_TO_FAILURE_REASON = { + is_author_missing: "trello_board_state_job__title_author_missing", + is_due_date_missing: "trello_board_state_job__title_due_date_missing", + is_deadline_missed: "trello_board_state_job__title_due_date_expired", + is_tag_missing: "trello_board_state_job__title_tag_missing", + is_doc_missing: "trello_board_state_job__title_no_doc", + has_no_doc_access: "trello_board_state_job__title_no_doc_access", +}