From e02caab1208f8898a43a3299829d3c115182a8fd Mon Sep 17 00:00:00 2001 From: "seahaven-openswe[bot]" <296972425+seahaven-openswe[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:47:04 +0000 Subject: [PATCH] Add Slack admin modals + App Home admin section Replace the two most error-prone positional admin commands with Block Kit modals (override and holiday-add) opened from a new App Home admin section, while keeping the typed subcommands as a fallback. Validation and side effects are factored into shared helpers so the modal and command paths can't drift, and every action/view handler re-checks is_admin against get_admin_users() so a modal opened from Home can't bypass authorization. Adds Schedule.list_overrides for the upcoming-overrides overview. Refs: #136 --- CHANGELOG.md | 17 ++ src/shared/shared/blocks.py | 202 ++++++++++++++- src/shared/shared/schedule.py | 37 +++ src/slack-bot/CHANGELOG.md | 17 ++ src/slack-bot/app.py | 365 +++++++++++++++++++++------ tests/shared/test_blocks.py | 76 ++++++ tests/shared/test_schedule.py | 35 +++ tests/slack_bot/test_admin_modals.py | 189 ++++++++++++++ tests/slack_bot/test_app_home.py | 32 +++ 9 files changed, 891 insertions(+), 79 deletions(-) create mode 100644 tests/slack_bot/test_admin_modals.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df3339c..0d69a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ fine and still supported. --- +## v1.13.0 — June 26, 2026 + +**Point-and-click admin actions, right inside Slack.** Admins no longer have to +remember the exact word order of `/oncall admin …` commands for the two most +fiddly tasks: + +- **Set an override or add a holiday from a form.** The bot's *Home* tab now has + an **Admin** section with two buttons — *Set override* and *Add holiday* — that + open a small form with a date picker, an employee dropdown, and clearly + labelled fields (the holiday pay multiplier is its own box instead of a + squeezed-in `x2`). Pick the values, submit, and the change lands exactly as the + typed command would — same phone-routing update for same-day changes, same + schedule-post refresh — with mistakes flagged on the field instead of a wall of + usage text. The typed `/oncall admin …` commands still work as before. +- **See what's coming up.** The Admin section also lists the upcoming overrides + and holidays so you can scan the next couple of months at a glance. + ## v1.12.0 — June 26, 2026 **Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life diff --git a/src/shared/shared/blocks.py b/src/shared/shared/blocks.py index 6ff7987..5151b5f 100644 --- a/src/shared/shared/blocks.py +++ b/src/shared/shared/blocks.py @@ -447,6 +447,165 @@ def build_pickup_resolved_blocks(text: str) -> list[dict]: return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] +# ── Admin modals + App Home admin section ─────────────────────────────── +# +# These ids are read back from ``view.state.values`` in the slack-bot's +# ``@app.view`` handlers, so the builders here and the readers there must agree. + +OPEN_OVERRIDE_MODAL_ACTION = "admin_open_override_modal" +OPEN_HOLIDAY_MODAL_ACTION = "admin_open_holiday_modal" +OVERRIDE_MODAL_CALLBACK = "admin_override_submit" +HOLIDAY_ADD_MODAL_CALLBACK = "admin_holiday_add_submit" + + +def _shift_option(value: str) -> dict: + return {"text": {"type": "plain_text", "text": value.capitalize()}, "value": value} + + +def build_override_modal(roster: list[dict]) -> dict: + """Block Kit modal for setting a date override (admin only). + + The employee picker is a ``static_select`` built from the roster so the + extension can't be mistyped. Block/action ids ('date', 'extension', + 'shift_type') are read back in the ``view_submission`` handler. + """ + options = [ + { + "text": { + "type": "plain_text", + "text": f"{e.get('name') or e['extension']} (Ext {e['extension']})", + }, + "value": e["extension"], + } + for e in sorted(roster, key=lambda e: (e.get("name") or "").lower()) + ] + extension_element = { + "type": "static_select", + "action_id": "extension", + "placeholder": {"type": "plain_text", "text": "Choose an employee"}, + } + if options: + extension_element["options"] = options + return { + "type": "modal", + "callback_id": OVERRIDE_MODAL_CALLBACK, + "title": {"type": "plain_text", "text": "Set Override"}, + "submit": {"type": "plain_text", "text": "Set override"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "date", + "label": {"type": "plain_text", "text": "Date"}, + "element": {"type": "datepicker", "action_id": "date"}, + }, + { + "type": "input", + "block_id": "extension", + "label": {"type": "plain_text", "text": "Employee"}, + "element": extension_element, + }, + { + "type": "input", + "block_id": "shift_type", + "label": {"type": "plain_text", "text": "Shift"}, + "element": { + "type": "static_select", + "action_id": "shift_type", + "initial_option": _shift_option("night"), + "options": [_shift_option("night"), _shift_option("day")], + }, + }, + ], + } + + +def build_holiday_add_modal() -> dict: + """Block Kit modal for scheduling a holiday day shift (admin only). + + The multiplier is its own optional field (not a parsed ``x`` token). + Block/action ids ('date', 'slots', 'multiplier', 'label') are read back in + the ``view_submission`` handler. + """ + return { + "type": "modal", + "callback_id": HOLIDAY_ADD_MODAL_CALLBACK, + "title": {"type": "plain_text", "text": "Add Holiday"}, + "submit": {"type": "plain_text", "text": "Add holiday"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "date", + "label": {"type": "plain_text", "text": "Date"}, + "element": {"type": "datepicker", "action_id": "date"}, + }, + { + "type": "input", + "block_id": "slots", + "label": {"type": "plain_text", "text": "Slots"}, + "element": { + "type": "plain_text_input", + "action_id": "slots", + "placeholder": {"type": "plain_text", "text": "e.g. 2"}, + }, + }, + { + "type": "input", + "block_id": "multiplier", + "optional": True, + "label": {"type": "plain_text", "text": "Pay multiplier"}, + "element": { + "type": "plain_text_input", + "action_id": "multiplier", + "placeholder": {"type": "plain_text", "text": "Defaults to 1.5"}, + }, + }, + { + "type": "input", + "block_id": "label", + "label": {"type": "plain_text", "text": "Label"}, + "element": { + "type": "plain_text_input", + "action_id": "label", + "placeholder": { + "type": "plain_text", + "text": "e.g. Independence Day", + }, + }, + }, + ], + } + + +def build_admin_overview( + upcoming_overrides: list[dict], upcoming_holidays: list[dict] +) -> dict: + """A compact 'upcoming overrides + holidays' section for the App Home admin tab.""" + lines = ["*Upcoming overrides & holidays*"] + if not upcoming_overrides and not upcoming_holidays: + lines.append("_Nothing scheduled in the next 60 days._") + for o in upcoming_overrides: + dt = datetime.strptime(o["date"], "%Y-%m-%d") + shift = "Day" if o["shift_type"] == "day" else "Night" + who = ( + "Open" + if o.get("extension") == "OPEN" + else f"{o.get('name') or o['extension']} (Ext {o['extension']})" + ) + lines.append(f"• {dt.strftime('%a %b %-d')} ({shift}) — {who}") + for h in upcoming_holidays: + dt = datetime.strptime(h["SK"], "%Y-%m-%d") + slots = int(h.get("slots", 0)) + filled = len(h.get("assignees", {}) or {}) + mult = f"{float(h.get('multiplier', 1.5)):g}x" + lines.append( + f"• {dt.strftime('%a %b %-d')} — _{h.get('label', 'Holiday')}_ " + f"({filled}/{slots} filled, {mult})" + ) + return {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}} + + def build_help_blocks(is_admin: bool = False) -> list[dict]: """Build help message blocks.""" text = ( @@ -477,19 +636,28 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]: "`/oncall admin holiday add