App home improvements! (#133)

* Show an error message if the views_publish call fails

This means that if our Block Kit has a syntax error and Slack API returns en error for it, a fallback screen is shown instead of indefinite loading

* Include nav buttons in the header

* Fix header formatting error

I think this was a GH Copilot hallucination that I accepted bc it looked very similar to the OG code :(

* Show header on the loading screen

* Make clicking on a button during the loading phase work

* Delete views/home/apps.py

I don't think this view is used anywhere

* Use the new header style everywhere

* Remove buttons.py (no longer used)

* Show "dashboard" tab as selected during first load

This means that the "default" virtual view name no longer exists, but we have a DEFAULT_VIEW constant now

* Add at least some sort of return type to get_header()

* Make the helper and assigned views non-helper-friendly

* Allow non-helpers to access the app home

* Remove useless `or` statement

* Extract error_screen to a component

* Gate the tags view to only helpers

* Make the tags view non-helper-friendly

* Make the stats view non-helper-friendly

* Make the error text a bit nicer

* Fix type error

* Make the infinite loading situation less likely

This could happen if you spam-clicked one of the buttons (I tested with assigned tickets). I think the problem existed before but was made worse by the new `user_last_requested_view` logic - now it's back to normal and the issue is very hard to recreate.

* Remove "unknown user" view and transcripts

* Remove commented code block

* Remove comment the second

* Fix timezone fetching and add more logs
This commit is contained in:
Mish 2025-12-08 11:54:00 +00:00 committed by GitHub
parent 8791e3ec54
commit 43967cf289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 170 additions and 314 deletions

View file

@ -3,6 +3,7 @@ import traceback
from typing import Any
from prometheus_client import Histogram
from slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncWebClient
from nephthys.utils.env import env
@ -14,12 +15,13 @@ from nephthys.views.home.helper import get_helper_view
from nephthys.views.home.loading import get_loading_view
from nephthys.views.home.stats import get_stats_view
from nephthys.views.home.tags import get_manage_tags_view
from nephthys.views.home.unknown_user import get_unknown_user_view
DEFAULT_VIEW = "dashboard"
async def on_app_home_opened(event: dict[str, Any], client: AsyncWebClient):
user_id = event["user"]
await open_app_home("default", client, user_id)
await open_app_home(DEFAULT_VIEW, client, user_id)
APP_HOME_RENDER_DURATION = Histogram(
@ -29,43 +31,40 @@ APP_HOME_RENDER_DURATION = Histogram(
)
# 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, str] = {}
async def open_app_home(home_type: str, client: AsyncWebClient, user_id: str):
last_requested_views[user_id] = home_type
try:
await client.views_publish(view=get_loading_view(), user_id=user_id)
await client.views_publish(view=get_loading_view(home_type), user_id=user_id)
user = await env.db.user.find_unique(where={"slackId": user_id})
if not user or not user.helper:
user_info = await client.users_info(user=user_id) or {}
name = (
user_info.get("user", {}).get("profile", {}).get("display_name")
or user_info.get("user", {}).get("profile", {}).get("real_name")
or "person"
)
view = get_unknown_user_view(name)
else:
logging.info(f"Opening {home_type} for {user_id}")
async with perf_timer(
f"Rendering app home (type={home_type})",
APP_HOME_RENDER_DURATION,
home_type=home_type,
):
match home_type:
case "default" | "dashboard":
view = await get_helper_view(user)
case "assigned-tickets":
view = await get_assigned_tickets_view(user)
case "tags":
view = await get_manage_tags_view(user)
case "my-stats":
view = await get_stats_view(user)
case _:
await send_heartbeat(
f"Attempted to load unknown app home type {home_type} for <@{user_id}>"
)
view = get_error_view(
f"This shouldn't happen, please tell <@{env.slack_maintainer_id}> that app home case `_` was hit with home type `{home_type}`"
)
logging.info(f"Opening {home_type} for {user_id}")
async with perf_timer(
f"Rendering app home (type={home_type})",
APP_HOME_RENDER_DURATION,
home_type=home_type,
):
match home_type:
case "dashboard":
view = await get_helper_view(slack_user=user_id, db_user=user)
case "assigned-tickets":
view = await get_assigned_tickets_view(user)
case "tags":
view = await get_manage_tags_view(user)
case "my-stats":
view = await get_stats_view(user)
case _:
await send_heartbeat(
f"Attempted to load unknown app home type {home_type} for <@{user_id}>"
)
view = get_error_view(
f"This shouldn't happen, please tell <@{env.slack_maintainer_id}> that app home case `_` was hit with home type `{home_type}`"
)
except Exception as e:
logging.error(f"Error opening app home: {e}")
tb = traceback.format_exception(e)
@ -82,4 +81,20 @@ async def open_app_home(home_type: str, client: AsyncWebClient, user_id: str):
messages=[f"```{tb_str}```", f"cc <@{env.slack_maintainer_id}>"],
)
await client.views_publish(user_id=user_id, view=view)
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})")
return
del last_requested_views[user_id]
try:
await client.views_publish(user_id=user_id, view=view)
except SlackApiError as e:
logging.error(f"Error publishing app home view: {e}")
await client.views_publish(
user_id=user_id,
view=get_error_view(
f"A Slack API error occurred while opening the app home:\n{e}",
),
)

View file

@ -95,15 +95,6 @@ class Transcript(BaseModel):
description="Message to be sent when the ship cert queue macro is used (only applies to SoM)",
)
home_unknown_user_title: str = Field(
default=":upside-down_orpheus: woah, stop right there {name}!",
description="Title for unknown user on home page",
)
home_unknown_user_text: str = Field(
default="", description="Text for unknown user on home page"
)
not_allowed_channel: str = Field(
default="", description="Message for unauthorized channel access"
)
@ -158,9 +149,6 @@ if your question has been answered, please hit the button below to mark it as re
if not self.identity_macro:
self.identity_macro = f"hey, (user)! please could you ask questions about identity verification in <#{self.identity_help_channel}>? :rac_cute:\n\nit helps the verification team keep track of questions easier!"
if not self.home_unknown_user_text:
self.home_unknown_user_text = f"heyyyy, heidi here! it looks like i'm not allowed to show ya this. sorry! if you think this is a mistake, please reach out to <@{self.program_owner}> and she'll lmk what to do!"
if not self.not_allowed_channel:
self.not_allowed_channel = f"heya, it looks like you're not supposed to be in that channel, pls talk to <@{self.program_owner}> if that's wrong"

View file

@ -30,14 +30,6 @@ Someone from the team will reply shortly with next steps.
ticket_resolve: str = f"""
Nice this ticket was marked resolved by <@{{user_id}}>.
If you need additional changes or the issue returns, send a message in <#{help_channel}> and we'll take another look.
"""
home_unknown_user_title: str = ":wrench: whoa — access limited"
home_unknown_user_text: str = """
_Checking permissions_
Hey {name}, it looks like you don't have access to this page right now.
If that's a mistake, please ask <@{program_owner}> to grant access and include a short note about why you need it.
"""
not_allowed_channel: str = f"Oops — you don't have permission for that channel. If you think this is wrong, ask <@{program_owner}> to check your access."

View file

@ -47,16 +47,6 @@ Hi (user), would you mind directing any fraud related queries to <@U091HC53CE8>?
It'll keep your case confidential and make it easier for the fraud team to keep track of!
_I've marked this thread as resolved_
"""
home_unknown_user_title: str = ":upside-down_orpheus: woah, wait one sec!"
home_unknown_user_text: str = """
_checks records_
heyy {name}, it doesn't look like you're on the list of people allowed to access this page sorry!
If you think this isn't right, ask <@{program_owner}> and they'll check for you! I'm still new to this \
fancy "role-based access" stuff :P
"""
not_allowed_channel: str = f"hey, it looks like you're not supposed to be in that channel, pls talk to <@{program_owner}> if that's wrong"

View file

@ -23,9 +23,4 @@ if your question has been answered, please hit the button below to mark it as re
resolve_ticket_button: str = "i get it now"
ticket_resolve: str = f"oh, oh! it looks like this post has been marked as resolved by <@{{user_id}}>! if you have any more questions, please make a new post in <#{help_channel}> and someone'll be happy to help you out! not me though, i'm just a silly racoon ^-^"
home_unknown_user_title: str = (
":upside-down_orpheus: woah, stop right there {name}!"
)
home_unknown_user_text: str = f"heyyyy, heidi here! it looks like i'm not allowed to show ya this. sorry! if you think this is a mistake, please reach out to <@{program_owner}> and she'll lmk what to do!"
not_allowed_channel: str = f"heya, it looks like you're not supposed to be in that channel, pls talk to <@{program_owner}> if that's wrong"

View file

@ -27,16 +27,6 @@ class Midnight(Transcript):
ticket_resolve: str = f"""
Aha, this post has just been marked as resolved by <@{{user_id}}>! I'll head back to my castle now, \
but if you need any more help, just send another message in <#{help_channel}> and I'll be right back o/
"""
home_unknown_user_title: str = ":upside-down_orpheus: chat give me a min"
home_unknown_user_text: str = """
_checks records_
heyy {name}, it doesn't look like you're on the list of people allowed to access this page - sorry!
If you think this isn't right, ask <@{program_owner}> and they'll check for you! I'm still new to this \
fancy "role-based access" stuff :P
"""
not_allowed_channel: str = f"hey, it looks like you're not supposed to be in that channel, pls talk to <@{program_owner}> if that's wrong"

View file

@ -27,9 +27,4 @@ if your question has been answered, please hit the button below to mark it as re
"hi (user)! unfortunately, there is a backlog of projects awaiting ship certification; please be patient. \n\n *pssst... voting more will move your project further towards the front of the queue.*"
)
home_unknown_user_title: str = (
":upside-down_orpheus: woah, stop right there {name}!"
)
home_unknown_user_text: str = f"heyyyy, heidi here! it looks like i'm not allowed to show ya this. sorry! if you think this is a mistake, please reach out to <@{program_owner}> and she'll lmk what to do!"
not_allowed_channel: str = f"heya, it looks like you're not supposed to be in that channel, pls talk to <@{program_owner}> if that's wrong"

View file

@ -1,107 +0,0 @@
import logging
from nephthys.utils.env import env
from nephthys.views.home.components.buttons import get_buttons
from prisma.models import User
async def get_manage_tags_view(user: User) -> dict:
btns = get_buttons(user, "tags")
tags = await env.db.tag.find_many(include={"userSubscriptions": True})
blocks = []
if not tags:
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f":rac_nooo: i couldn't scrounge up any tags{', you can make a new one below though' if user.admin else ''}",
},
}
)
for tag in tags:
logging.info(f"Tag {tag.name} with id {tag.id} found in the database")
logging.info(
f"Tag {tag.name} has {len(tag.userSubscriptions) if tag.userSubscriptions else 0} subscriptions"
)
if tag.userSubscriptions:
subIds = [user.userId for user in tag.userSubscriptions]
subUsers = await env.db.user.find_many(where={"id": {"in": subIds}})
subs = [user.slackId for user in subUsers]
else:
subs = []
stringified_subs = [f"<@{user}>" for user in subs]
blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{tag.name}* - {''.join(stringified_subs) if stringified_subs else ':rac_nooo: no subscriptions'}",
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": f":rac_cute: {'subscribe' if user.id not in subs else 'unsubscribe'}",
"emoji": True,
},
"action_id": "tag-subscribe",
"value": f"{tag.id};{tag.name}",
"style": "primary" if user.id not in subs else "danger",
},
}
)
view = {
"type": "home",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":rac_info: Apps",
"emoji": True,
},
},
btns,
{"type": "divider"},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":rac_thumbs: here you can manage tags and your subscriptions"
if user.admin
else ":rac_thumbs: here you can manage your tag subscriptions",
},
},
{"type": "divider"},
*blocks,
],
}
if user.admin:
view["blocks"].append(
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": ":rac_cute: add a tag?",
"emoji": True,
},
"action_id": "create-tag",
"style": "primary",
}
],
}
)
return view

View file

@ -1,59 +1,47 @@
import pytz
from nephthys.utils.env import env
from nephthys.views.home.components.buttons import get_buttons
from nephthys.views.home.components.error_screen import error_screen
from nephthys.views.home.components.header import get_header
from prisma.enums import TicketStatus
from prisma.models import User
async def get_assigned_tickets_view(user: User):
header = get_header()
btns = get_buttons(user, "assigned-tickets")
async def get_assigned_tickets_view(user: User | None):
header = get_header(user, "assigned-tickets")
tickets = (
await env.db.ticket.find_many(
where={"assignedToId": user.id, "NOT": [{"status": TicketStatus.CLOSED}]},
include={"openedBy": True},
if not user or not user.helper:
return error_screen(
header,
":rac_info: you're not a helper!",
":rac_believes_in_theory_about_green_lizards_and_space_lasers: only helpers can be assigned to tickets, so you have none - zero responsibility!",
)
or []
tickets = await env.db.ticket.find_many(
where={"assignedToId": user.id, "NOT": [{"status": TicketStatus.CLOSED}]},
include={"openedBy": True},
)
if not tickets:
return {
"type": "home",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":rac_cute: no assigned tickets",
"emoji": True,
},
},
btns,
{"type": "divider"},
{
"type": "section",
"text": {
"type": "plain_text",
"text": ":rac_believes_in_theory_about_green_lizards_and_space_lasers: you don't have any assigned tickets right now!",
"emoji": True,
},
},
],
}
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!",
)
ticket_blocks = []
for ticket in tickets:
unix_ts = int(ticket.createdAt.timestamp())
time_ago_str = f"<!date^{unix_ts}^opened {{ago}}|at {ticket.createdAt.astimezone(pytz.timezone('Europe/London')).strftime('%H:%M %Z')}>"
opened_by_str = (
f"<@{ticket.openedBy.slackId}>" if ticket.openedBy else "unknown user"
)
ticket_blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{ticket.title}*\n _from <@{ticket.openedBy.slackId}>. {time_ago_str}_",
"text": f"*{ticket.title}*\n _from {opened_by_str}. {time_ago_str}_",
},
"accessory": {
"type": "button",
@ -73,9 +61,7 @@ async def get_assigned_tickets_view(user: User):
return {
"type": "home",
"blocks": [
header,
btns,
{"type": "divider"},
*header,
{
"type": "header",
"text": {

View file

@ -1,44 +0,0 @@
from prisma.models import User
def get_buttons(user: User, current: str = "dashboard"):
buttons = []
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "Dashboard", "emoji": True},
"action_id": "dashboard",
**({"style": "primary"} if current != "dashboard" else {}),
}
)
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "Assigned Tickets", "emoji": True},
"action_id": "assigned-tickets",
**({"style": "primary"} if current != "assigned-tickets" else {}),
}
)
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "Tags", "emoji": True},
"action_id": "tags",
**({"style": "primary"} if current != "tags" else {}),
}
)
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "My Stats", "emoji": True},
"action_id": "my-stats",
**({"style": "primary"} if current != "my-stats" else {}),
}
)
blocks = {"type": "actions", "elements": buttons}
return blocks

View file

@ -1,23 +1,23 @@
from nephthys.utils.env import env
def get_unknown_user_view(name: str):
def error_screen(header: list[dict], 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": [
*header,
{
"type": "header",
"text": {
"type": "plain_text",
"text": env.transcript.home_unknown_user_title.format(name=name),
"text": title,
"emoji": True,
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": env.transcript.home_unknown_user_text,
"type": "plain_text",
"text": message,
"emoji": True,
},
},
],

View file

@ -1,7 +1,51 @@
from nephthys.utils.env import env
from prisma.models import User
def get_header():
def header_buttons(current_view: str):
buttons = []
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "Dashboard", "emoji": True},
"action_id": "dashboard",
**({"style": "primary"} if current_view != "dashboard" else {}),
}
)
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "Assigned Tickets", "emoji": True},
"action_id": "assigned-tickets",
**({"style": "primary"} if current_view != "assigned-tickets" else {}),
}
)
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "Tags", "emoji": True},
"action_id": "tags",
**({"style": "primary"} if current_view != "tags" else {}),
}
)
buttons.append(
{
"type": "button",
"text": {"type": "plain_text", "text": "My Stats", "emoji": True},
"action_id": "my-stats",
**({"style": "primary"} if current_view != "my-stats" else {}),
}
)
blocks = {"type": "actions", "elements": buttons}
return blocks
def title_line():
return {
"type": "header",
"text": {
@ -10,3 +54,11 @@ def get_header():
"emoji": True,
},
}
def get_header(user: User | None, current: str = "dashboard") -> list[dict]:
return [
title_line(),
header_buttons(current),
{"type": "divider"},
]

View file

@ -1,8 +1,9 @@
import logging
import pytz
from nephthys.utils.env import env
from nephthys.utils.performance import perf_timer
from nephthys.views.home.components.buttons import get_buttons
from nephthys.views.home.components.header import get_header
from nephthys.views.home.components.leaderboards import get_leaderboard_view
from nephthys.views.home.components.ticket_status_pie import get_ticket_status_pie_chart
@ -10,14 +11,19 @@ from nephthys.views.home.error import get_error_view
from prisma.models import User
async def get_helper_view(user: User):
async def get_helper_view(slack_user: str, db_user: User | None):
async with perf_timer("Fetching user info"):
user_info = await env.slack_client.users_info(user=user.slackId)
if not user_info or not (slack_user := user_info.get("user")):
user_info_response = await env.slack_client.users_info(user=slack_user)
user_info = user_info_response.get("user")
if not user_info:
logging.error(f"Failed to fetch user={slack_user}: {user_info_response}")
return get_error_view(
":rac_freaking: oops, i couldn't find your info! try again in a bit?"
)
tz_string = slack_user.get("tz", "Europe/London")
tz_string = user_info.get("tz")
if not tz_string:
logging.warning(f"No timezone found user={slack_user}")
tz_string = "Europe/London"
tz = pytz.timezone(tz_string)
async with perf_timer("Rendering pie chart (total time)"):
@ -26,15 +32,12 @@ async def get_helper_view(user: User):
async with perf_timer("Generating leaderboard"):
leaderboard = await get_leaderboard_view()
header = get_header()
btns = get_buttons(user, "dashboard")
header = get_header(db_user, "dashboard")
return {
"type": "home",
"blocks": [
header,
btns,
{"type": "divider"},
*header,
{
"type": "header",
"text": {

View file

@ -1,7 +1,11 @@
def get_loading_view():
from nephthys.views.home.components.header import get_header
def get_loading_view(home_type: str):
return {
"type": "home",
"blocks": [
*get_header(user=None, current=home_type),
{
"type": "section",
"text": {
@ -9,9 +13,6 @@ def get_loading_view():
"text": ":hourglass_flowing_sand: loading...",
},
},
{
"type": "divider",
},
{
"type": "image",
"image_url": "https://hc-cdn.hel1.your-objectstorage.com/s/v3/1c1fc5fb03b8bf46c6ab047c97f962ed930616f0_loading-hugs.gif",

View file

@ -1,13 +1,12 @@
from nephthys.views.home.components.buttons import get_buttons
from nephthys.views.home.components.header import get_header
from prisma.models import User
async def get_stats_view(user: User):
btns = get_buttons(user, "my-stats")
async def get_stats_view(user: User | None):
return {
"type": "home",
"blocks": [
*get_header(user, "my-stats"),
{
"type": "header",
"text": {
@ -16,8 +15,6 @@ async def get_stats_view(user: User):
"emoji": True,
},
},
btns,
{"type": "divider"},
{
"type": "section",
"text": {

View file

@ -1,15 +1,15 @@
import logging
from nephthys.utils.env import env
from nephthys.views.home.components.buttons import get_buttons
from nephthys.views.home.components.header import get_header
from prisma.models import User
async def get_manage_tags_view(user: User) -> dict:
btns = get_buttons(user, "tags")
async def get_manage_tags_view(user: User | None) -> dict:
header = get_header(user, "tags")
is_admin = bool(user and user.admin)
is_helper = bool(user and user.helper)
tags = await env.db.tag.find_many(include={"userSubscriptions": True})
blocks = []
if not tags:
@ -18,7 +18,7 @@ async def get_manage_tags_view(user: User) -> dict:
"type": "section",
"text": {
"type": "mrkdwn",
"text": f":rac_nooo: i couldn't scrounge up any tags{', you can make a new one below though' if user.admin else ''}",
"text": f":rac_nooo: i couldn't scrounge up any tags{', you can make a new one below though' if is_admin else ''}",
},
}
)
@ -54,13 +54,16 @@ async def get_manage_tags_view(user: User) -> dict:
"action_id": "tag-subscribe",
"value": f"{tag.id};{tag.name}",
"style": "primary" if user.id not in subs else "danger",
},
}
if user and is_helper
else {},
}
)
view = {
"type": "home",
"blocks": [
*header,
{
"type": "header",
"text": {
@ -69,23 +72,23 @@ async def get_manage_tags_view(user: User) -> dict:
"emoji": True,
},
},
btns,
{"type": "divider"},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":rac_thumbs: here you can manage tags and your subscriptions"
if user.admin
else ":rac_thumbs: here you can manage your tag subscriptions",
if is_admin
else ":rac_thumbs: here you can manage your tag subscriptions"
if is_helper
else ":rac_thumbs: note: you're not a helper, so you can only view tags",
},
},
{"type": "divider"},
{"type": "section", "text": {"type": "plain_text", "text": " "}},
*blocks,
],
}
if user.admin:
if is_admin:
view["blocks"].append(
{
"type": "actions",