diff --git a/.gitignore b/.gitignore index d20ab48..cbc9cb1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ wheels/ .env # Local development -data/ \ No newline at end of file +data/ + +# AI nonsense +.commandcode/taste/ diff --git a/nephthys/events/app_home_opened.py b/nephthys/events/app_home_opened.py index c89041e..376fb82 100644 --- a/nephthys/events/app_home_opened.py +++ b/nephthys/events/app_home_opened.py @@ -1,5 +1,6 @@ import logging import traceback +from dataclasses import dataclass from typing import Any from prometheus_client import Histogram @@ -20,6 +21,12 @@ from nephthys.views.home.team_tags import get_team_tags_view +@dataclass +class RequestedView: + home_type: AppHomeView + page: int | None = None + + DEFAULT_VIEW = AppHomeView.DASHBOARD @@ -53,11 +60,16 @@ async def on_app_home_opened(event: dict[str, Any], client: AsyncWebClient): # Map of the last-requested view for each Slack user # This prevents a view that took a while to render overwriting the view you want # Entries are deleted once the view is published -last_requested_views: dict[str, AppHomeView] = {} +last_requested_views: dict[str, RequestedView] = {} -async def open_app_home(home_type: AppHomeView, client: AsyncWebClient, user_id: str): - last_requested_views[user_id] = home_type +async def open_app_home( + home_type: AppHomeView, + client: AsyncWebClient, + user_id: str, + page: int | None = None, +): + last_requested_views[user_id] = RequestedView(home_type, page) try: await client.views_publish(view=get_loading_view(home_type), user_id=user_id) @@ -73,7 +85,7 @@ async def open_app_home(home_type: AppHomeView, client: AsyncWebClient, user_id: case AppHomeView.DASHBOARD: view = await get_dashboard_view(slack_user=user_id, db_user=user) case AppHomeView.ASSIGNED_TICKETS: - view = await get_assigned_tickets_view(user) + view = await get_assigned_tickets_view(user, page=page or 1) case AppHomeView.TEAM_TAGS: view = await get_team_tags_view(user) case AppHomeView.CATEGORY_TAGS: @@ -84,8 +96,10 @@ async def open_app_home(home_type: AppHomeView, client: AsyncWebClient, user_id: # Check that the request hasn't been superseded by another request while we were rendering user_last_requested_view = last_requested_views.get(user_id) if user_last_requested_view: - if user_last_requested_view != home_type: - logging.info(f"Ignoring stale view request ({user_id}, {home_type})") + if user_last_requested_view != RequestedView(home_type, page): + logging.info( + f"Ignoring stale view request slack_id={user_id} view={home_type} page={page}" + ) return del last_requested_views[user_id] diff --git a/nephthys/scripts/add_dummy_data.py b/nephthys/scripts/add_dummy_data.py index 3103805..6bef17a 100644 --- a/nephthys/scripts/add_dummy_data.py +++ b/nephthys/scripts/add_dummy_data.py @@ -1,16 +1,25 @@ +import argparse import asyncio import logging from datetime import datetime from datetime import timedelta from random import randint -from sys import argv from nephthys.database.enums import TicketStatus from nephthys.database.tables import Ticket +from nephthys.database.tables import User from nephthys.utils.logging import send_heartbeat -async def create_dummy_records(num_records: int): +async def get_default_user() -> User: + user = await User.objects().first() + if not user: + raise RuntimeError("No users found in database") + return user + + +async def create_dummy_records(num_records: int, assigned_to: int | None = None): + default_user = await get_default_user() # We generate random timestamps from up to 2 weeks ago timestamp_a = datetime.now() - timedelta(weeks=2) timestamp_b = datetime.now() @@ -22,10 +31,10 @@ async def create_dummy_records(num_records: int): description=f"Test ticket number {i}", msg_ts=f"{timestamp}.{i}", ticket_ts=f"{timestamp + 2}.{i}", - opened_by=1, + opened_by=default_user, status=TicketStatus.CLOSED, - closed_by=1, - assigned_to=1, + closed_by=default_user, + assigned_to=assigned_to or default_user, assigned_at=datetime.fromtimestamp(timestamp + 10), closed_at=datetime.fromtimestamp(timestamp + 110), ) @@ -35,8 +44,13 @@ async def create_dummy_records(num_records: int): if __name__ == "__main__": - if len(argv) < 2: - print("Usage: python add_dummy_data.py ") - exit(1) - num_records = int(argv[1]) - asyncio.run(create_dummy_records(num_records)) + parser = argparse.ArgumentParser() + parser.add_argument( + "--count", type=int, required=True, help="number of tickets to create" + ) + parser.add_argument( + "--assigned-to", type=int, help="assign all tickets to this user id" + ) + args = parser.parse_args() + + asyncio.run(create_dummy_records(args.count, args.assigned_to)) diff --git a/nephthys/utils/slack.py b/nephthys/utils/slack.py index 9166f06..6aec8f8 100644 --- a/nephthys/utils/slack.py +++ b/nephthys/utils/slack.py @@ -1,4 +1,5 @@ import logging +import re from typing import Any from typing import Dict @@ -111,6 +112,16 @@ async def manage_home_switcher(ack: AsyncAck, body, client: AsyncWebClient): app.action(view.id)(manage_home_switcher) +@app.action(re.compile(r"assigned-tickets-page-\d+")) +async def handle_assigned_tickets_page( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await ack() + user_id = body["user"]["id"] + page = int(body["actions"][0]["action_id"].rsplit("-", 1)[-1]) + await open_app_home(AppHomeView.ASSIGNED_TICKETS, client, user_id, page=page) + + @app.event("member_joined_channel") async def handle_member_joined_channel(event: Dict[str, Any], client: AsyncWebClient): await channel_join(ack=AsyncAck(), event=event, client=client) diff --git a/nephthys/views/home/assigned.py b/nephthys/views/home/assigned.py index 841b2b3..fb1b922 100644 --- a/nephthys/views/home/assigned.py +++ b/nephthys/views/home/assigned.py @@ -1,4 +1,10 @@ import pytz +from blockkit import Actions +from blockkit import Button +from blockkit import Divider +from blockkit import Header +from blockkit import Home +from blockkit import Section from nephthys.database.enums import TicketStatus from nephthys.database.tables import Ticket @@ -6,11 +12,28 @@ from nephthys.utils.ticket_methods import get_question_message_link from nephthys.views.home import AppHomeView from nephthys.views.home.components.error_screen import error_screen -from nephthys.views.home.components.header import get_header +from nephthys.views.home.components.header import get_header_components +# Each ticket needs 2 blocks, and the heading takes up 5 blocks (I think) +# Maximum Slack blocks per view is 100. +TICKETS_PER_PAGE = 47 -async def get_assigned_tickets_view(user: User | None): - header = get_header(user, AppHomeView.ASSIGNED_TICKETS) + +def pagination_buttons(page: int, total_pages: int) -> Actions: + buttons = Actions() + for p in range(1, total_pages + 1): + buttons.add_element( + Button( + text=f"Page {p}", + action_id=f"assigned-tickets-page-{p}", + style=Button.PRIMARY if p == page else None, + ) + ) + return buttons + + +async def get_assigned_tickets_view(user: User | None, page: int = 1): + header = get_header_components(user, AppHomeView.ASSIGNED_TICKETS) if not user or not user.helper: return error_screen( @@ -19,17 +42,28 @@ async def get_assigned_tickets_view(user: User | None): ":rac_believes_in_theory_about_green_lizards_and_space_lasers: only helpers can be assigned to tickets, so you have none - zero responsibility!", ) - tickets = await Ticket.objects(Ticket.opened_by).where( - (Ticket.assigned_to == user.id) & (Ticket.status != TicketStatus.CLOSED) + total = await Ticket.count().where( + (Ticket.assigned_to == user) & (Ticket.status != TicketStatus.CLOSED) ) - if not tickets: + if total == 0: return error_screen( header, ":rac_cute: no assigned tickets", ":rac_believes_in_theory_about_green_lizards_and_space_lasers: you don't have any assigned tickets right now!", ) + total_pages = (total + TICKETS_PER_PAGE - 1) // TICKETS_PER_PAGE + page = max(1, min(page, total_pages)) + + tickets = ( + await Ticket.objects(Ticket.opened_by) + .where((Ticket.assigned_to == user) & (Ticket.status != TicketStatus.CLOSED)) + .order_by(Ticket.created_at, ascending=True) + .limit(TICKETS_PER_PAGE) + .offset((page - 1) * TICKETS_PER_PAGE) + ) + ticket_blocks = [] for ticket in tickets: unix_ts = int(ticket.created_at.timestamp()) @@ -38,39 +72,25 @@ async def get_assigned_tickets_view(user: User | None): f"<@{ticket.opened_by.slack_id}>" if ticket.opened_by else "unknown user" ) ticket_blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*{ticket.title}*\n _from {opened_by_str}. {time_ago_str}_", - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": ":rac_info: view ticket", - "emoji": True, - }, - "action_id": f"view-ticket-{ticket.msg_ts}", - "url": get_question_message_link(ticket), - "value": ticket.msg_ts, - }, - } + Section( + text=f"*{ticket.title}*\n _from {opened_by_str}. {time_ago_str}_", + accessory=Button( + text=":rac_info: view ticket", + action_id=f"view-ticket-{ticket.msg_ts}", + url=get_question_message_link(ticket), + value=ticket.msg_ts, + ), + ) ) - ticket_blocks.append({"type": "divider"}) + ticket_blocks.append(Divider()) + + if total_pages > 1: + ticket_blocks.append(pagination_buttons(page, total_pages)) - return { - "type": "home", - "blocks": [ + return Home( + [ *header, - { - "type": "header", - "text": { - "type": "plain_text", - "text": ":rac_cute: here are your assigned tickets <3", - "emoji": True, - }, - }, + Header(":rac_cute: here are your assigned tickets <3"), *ticket_blocks, - ], - } + ] + ).build() diff --git a/nephthys/views/home/components/error_screen.py b/nephthys/views/home/components/error_screen.py index fc1e5c4..47b705a 100644 --- a/nephthys/views/home/components/error_screen.py +++ b/nephthys/views/home/components/error_screen.py @@ -1,24 +1,15 @@ -def error_screen(header: list[dict], title: str, message: str) -> dict: +from blockkit import Header +from blockkit import Home +from blockkit import Section +from blockkit.core import ModalBlock + + +def error_screen(header: list[ModalBlock], title: str, message: str) -> dict: """A basic error screen that can be rendered as a view if permissions are missing, or something like that""" - return { - "type": "home", - "blocks": [ + return Home( + [ *header, - { - "type": "header", - "text": { - "type": "plain_text", - "text": title, - "emoji": True, - }, - }, - { - "type": "section", - "text": { - "type": "plain_text", - "text": message, - "emoji": True, - }, - }, - ], - } + Header(title), + Section(message), + ] + ).build()