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
This commit is contained in:
seahaven-openswe[bot] 2026-06-26 20:47:04 +00:00
parent b8ab4d6b77
commit e02caab120
9 changed files with 891 additions and 79 deletions

View file

@ -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 ## v1.12.0 — June 26, 2026
**Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life **Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life

View file

@ -447,6 +447,165 @@ def build_pickup_resolved_blocks(text: str) -> list[dict]:
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] 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<mult>`` 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]: def build_help_blocks(is_admin: bool = False) -> list[dict]:
"""Build help message blocks.""" """Build help message blocks."""
text = ( text = (
@ -477,19 +636,28 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]:
"`/oncall admin holiday add <date> <slots> <label>` — Schedule a holiday day shift (8am–5pm)\n" "`/oncall admin holiday add <date> <slots> <label>` — Schedule a holiday day shift (8am–5pm)\n"
"`/oncall admin holiday add <date> <slots> x<mult> <label>` — …with a custom pay multiplier\n" "`/oncall admin holiday add <date> <slots> x<mult> <label>` — …with a custom pay multiplier\n"
"`/oncall admin holiday remove <date>` — Remove a scheduled holiday\n" "`/oncall admin holiday remove <date>` — Remove a scheduled holiday\n"
"`/oncall admin holiday list` — List upcoming holidays" "`/oncall admin holiday list` — List upcoming holidays\n\n"
"_Tip: open the *Admin* section of the bot's Home tab to set overrides "
"and add holidays with pickers instead of positional arguments._"
) )
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}] return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_home_view( def build_home_view(
version: str | None = None, notes: str = "", date_label: str = "" version: str | None = None,
notes: str = "",
date_label: str = "",
*,
is_admin: bool = False,
upcoming_overrides: list[dict] | None = None,
upcoming_holidays: list[dict] | None = None,
) -> dict: ) -> dict:
"""Build the App Home tab: what the bot does, the commands, and what's new. """Build the App Home tab: what the bot does, the commands, and what's new.
``version``/``notes``/``date_label`` come from the newest CHANGELOG entry; when ``version``/``notes``/``date_label`` come from the newest CHANGELOG entry; when
absent (e.g. the changelog could not be read) the "What's New" section is absent (e.g. the changelog could not be read) the "What's New" section is
simply omitted. simply omitted. When ``is_admin`` is set, an Admin section with the override /
holiday modal buttons and an upcoming overview is appended.
""" """
blocks: list[dict] = [ blocks: list[dict] = [
{ {
@ -508,9 +676,35 @@ def build_home_view(
), ),
}, },
}, },
*build_help_blocks(is_admin=False), *build_help_blocks(is_admin=is_admin),
] ]
if is_admin:
blocks.append({"type": "divider"})
blocks.append(
{"type": "header", "text": {"type": "plain_text", "text": "Admin"}}
)
blocks.append(
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Set override"},
"action_id": OPEN_OVERRIDE_MODAL_ACTION,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Add holiday"},
"action_id": OPEN_HOLIDAY_MODAL_ACTION,
},
],
}
)
blocks.append(
build_admin_overview(upcoming_overrides or [], upcoming_holidays or [])
)
if version: if version:
blocks.append({"type": "divider"}) blocks.append({"type": "divider"})
blocks.append( blocks.append(

View file

@ -197,6 +197,35 @@ class ShiftSchedule:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": sk}) self.table.delete_item(Key={"PK": "OVERRIDE", "SK": sk})
def list_overrides(self, start_date_str: str, end_date_str: str) -> list[dict]:
"""Return overrides in the inclusive date range, ordered by date/shift.
A bounded SK range query (not a full scan): night overrides key on
``<date>`` and day overrides on ``<date>-DAY``, both of which sort within
``[start, end-DAY]``. Each result carries ``date``, ``shift_type``
('day'/'night'), ``extension`` ('OPEN' for an open shift), and ``name``.
"""
resp = self.table.query(
KeyConditionExpression=Key("PK").eq("OVERRIDE")
& Key("SK").between(start_date_str, f"{end_date_str}-DAY")
)
results = []
for item in sorted(resp.get("Items", []), key=lambda x: x["SK"]):
sk = item["SK"]
if sk.endswith("-DAY"):
date_str, shift_type = sk[:-4], "day"
else:
date_str, shift_type = sk, "night"
results.append(
{
"date": date_str,
"shift_type": shift_type,
"extension": item.get("extension", ""),
"name": item.get("name", ""),
}
)
return results
# ── Swap requests ─────────────────────────────────────────────────── # ── Swap requests ───────────────────────────────────────────────────
def create_pending_swap( def create_pending_swap(
@ -387,6 +416,14 @@ class ShiftSchedule:
def remove_holiday(self, date_str: str) -> None: def remove_holiday(self, date_str: str) -> None:
self.table.delete_item(Key={"PK": "HOLIDAY", "SK": date_str}) self.table.delete_item(Key={"PK": "HOLIDAY", "SK": date_str})
def list_holidays(self, start_date_str: str) -> list[dict]:
"""Return holiday records on or after ``start_date_str``, ordered by date."""
resp = self.table.query(
KeyConditionExpression=Key("PK").eq("HOLIDAY")
& Key("SK").gte(start_date_str)
)
return sorted(resp.get("Items", []), key=lambda x: x["SK"])
def claim_holiday_slot(self, date_str: str, extension: str, name: str) -> bool: def claim_holiday_slot(self, date_str: str, extension: str, name: str) -> bool:
"""Atomically add an assignee to a holiday slot. """Atomically add an assignee to a holiday slot.

View file

@ -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 ## v1.12.0 — June 26, 2026
**Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life **Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life

View file

@ -15,13 +15,18 @@ from datetime import datetime, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import boto3 import boto3
from boto3.dynamodb.conditions import Key
from slack_bolt import App from slack_bolt import App
from shared.blocks import ( from shared.blocks import (
HOLIDAY_ADD_MODAL_CALLBACK,
OPEN_HOLIDAY_MODAL_ACTION,
OPEN_OVERRIDE_MODAL_ACTION,
OVERRIDE_MODAL_CALLBACK,
build_help_blocks, build_help_blocks,
build_holiday_add_modal,
build_holiday_added_blocks, build_holiday_added_blocks,
build_home_view, build_home_view,
build_override_modal,
build_pay_summary_blocks, build_pay_summary_blocks,
build_pickup_request_blocks, build_pickup_request_blocks,
build_pickup_resolved_blocks, build_pickup_resolved_blocks,
@ -1706,6 +1711,130 @@ def handle_pickup_deny(body, respond, client, schedule, schedule_channel):
logger.exception("Failed to DM late-pickup requester on deny") logger.exception("Failed to DM late-pickup requester on deny")
# ── Shared admin validation + side-effect helpers ───────────────────────
#
# These are the single source of the admin mutation rules and side effects, so
# the text subcommands and the Block Kit modal ``view_submission`` handlers can't
# drift. Validators return an error string (or ``None``); apply helpers run the
# ``Schedule`` mutation plus the same 3CX / schedule-post side effects.
def _resolve_roster_employee(schedule, extension):
"""Return (employee, error). ``error`` is set when the extension is unknown."""
employee = schedule.get_employee_by_extension(extension)
if not employee:
return None, f"Extension `{extension}` not found in the roster."
return employee, None
def _validate_holiday_date(date_str: str) -> str | None:
"""Error message if a holiday date is in the past, else None."""
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
return "You can't schedule a holiday in the past."
return None
def _parse_slots(raw):
"""Parse a holiday slot count. Returns (slots, error)."""
try:
slots = int(raw)
except (ValueError, TypeError):
return None, f"Slots must be a whole number, got `{raw}`."
if slots < 1:
return None, "Slots must be at least 1."
return slots, None
def _parse_multiplier(raw):
"""Parse an optional pay multiplier (`2`, `2.0`, or `x2`). Returns (value, error)."""
if raw is None:
return None, None
token = raw.strip()
if not token:
return None, None
match = re.fullmatch(r"x?([0-9]+(?:\.[0-9]+)?)", token, re.IGNORECASE)
if not match:
return None, f"Multiplier must be a number like `2` or `x1.5`, got `{raw}`."
return match.group(1), None
def _validate_label(label: str) -> str | None:
if not label.strip():
return "A holiday label is required."
return None
def _apply_override(schedule, date_str, employee, shift_type, client, schedule_channel):
"""Set the override + same-day 3CX repoint + post refresh.
Returns (message, repointed_3cx). Shared by the text command and the modal.
"""
schedule.set_override(date_str, employee["extension"], employee["name"], shift_type)
repointed = False
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(employee["extension"])
repointed = True
_refresh_schedule_post(schedule, schedule_channel, client)
date = datetime.strptime(date_str, "%Y-%m-%d")
label = "Day" if shift_type == "day" else "Night"
message = (
f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → "
f"{employee['name']} (Ext {employee['extension']})"
)
return message, repointed
def _apply_holiday_add(
schedule, date_str, slots, multiplier, label, client, schedule_channel
):
"""Create the holiday record + one-off schedules + side effects.
Returns (created, message). Shared by the text command and the modal.
"""
schedule_names = _create_holiday_schedules(date_str)
created = schedule.create_holiday(
date_str,
slots=slots,
label=label,
created_by=schedule_channel or "",
multiplier=multiplier,
schedule_names=schedule_names,
)
date = datetime.strptime(date_str, "%Y-%m-%d")
if not created:
# Roll back the schedules we just made for a date that already has one.
_delete_holiday_schedules(schedule_names)
return False, (
f"A holiday already exists on *{date.strftime('%A, %b %-d')}*. "
"Remove it first to recreate."
)
# If the window is already open (admin added it mid-day), the 08:00 schedule
# has passed, so repoint the call flow now via the holiday router.
if _holiday_window_active(date_str):
_activate_holiday_inline(schedule, date_str)
holiday = schedule.get_holiday(date_str)
multiplier_value = holiday["multiplier"] if holiday else (multiplier or "1.5")
if schedule_channel:
try:
client.chat_postMessage(
channel=schedule_channel,
blocks=build_holiday_added_blocks(
date_str, label, slots, multiplier_value
),
text=f"Holiday added: {label} on {date_str}",
)
except Exception:
logger.exception("Failed to post holiday-added notification to channel")
_refresh_schedule_post(schedule, schedule_channel, client)
return True, (
f"Scheduled *{label}* holiday on *{date.strftime('%A, %b %-d')}* — "
f"{slots} slot{'s' if slots != 1 else ''} at {float(multiplier_value):g}x pay."
)
def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_channel): def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_channel):
if not is_admin: if not is_admin:
respond(text="Admin commands are restricted. Contact an administrator.") respond(text="Admin commands are restricted. Contact an administrator.")
@ -1745,21 +1874,15 @@ def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_c
shift_type = ( shift_type = (
parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night" parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night"
) )
employee = schedule.get_employee_by_extension(ext) employee, error = _resolve_roster_employee(schedule, ext)
if not employee: if error:
respond(text=f"Extension `{ext}` not found in the roster.") respond(text=error)
return return
date_str = date.strftime("%Y-%m-%d") date_str = date.strftime("%Y-%m-%d")
schedule.set_override( message, _repointed = _apply_override(
date_str, employee["extension"], employee["name"], shift_type schedule, date_str, employee, shift_type, client, schedule_channel
) )
if is_today(date_str) and _is_active_shift_type(shift_type): respond(text=message)
_update_3cx_routing(employee["extension"])
label = "Day" if shift_type == "day" else "Night"
respond(
text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})"
)
_refresh_schedule_post(schedule, schedule_channel, client)
elif subcmd == "open": elif subcmd == "open":
if len(parts) < 3: if len(parts) < 3:
@ -1906,17 +2029,14 @@ def _admin_holiday_add(respond, schedule, parts, client, schedule_channel):
respond(text=f"Couldn't parse date: `{parts[3]}`") respond(text=f"Couldn't parse date: `{parts[3]}`")
return return
date_str = date.strftime("%Y-%m-%d") date_str = date.strftime("%Y-%m-%d")
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): date_error = _validate_holiday_date(date_str)
respond(text="You can't schedule a holiday in the past.") if date_error:
respond(text=date_error)
return return
try: slots, slots_error = _parse_slots(parts[4])
slots = int(parts[4]) if slots_error:
except ValueError: respond(text=slots_error)
respond(text=f"Slots must be a whole number, got `{parts[4]}`.")
return
if slots < 1:
respond(text="Slots must be at least 1.")
return return
# Optional ``x<mult>`` token before the label. # Optional ``x<mult>`` token before the label.
@ -1926,54 +2046,15 @@ def _admin_holiday_add(respond, schedule, parts, client, schedule_channel):
multiplier = rest[0][1:] multiplier = rest[0][1:]
rest = rest[1:] rest = rest[1:]
label = " ".join(rest).strip() label = " ".join(rest).strip()
if not label: label_error = _validate_label(label)
respond(text="A holiday label is required.") if label_error:
respond(text=label_error)
return return
schedule_names = _create_holiday_schedules(date_str) _created, message = _apply_holiday_add(
created = schedule.create_holiday( schedule, date_str, slots, multiplier, label, client, schedule_channel
date_str,
slots=slots,
label=label,
created_by=schedule_channel or "",
multiplier=multiplier,
schedule_names=schedule_names,
) )
if not created: respond(text=message)
# Roll back the schedules we just made for a date that already has one.
_delete_holiday_schedules(schedule_names)
respond(
text=f"A holiday already exists on *{date.strftime('%A, %b %-d')}*. "
"Remove it first to recreate."
)
return
# If the window is already open (admin added it mid-day), the 08:00 schedule
# has passed, so repoint the call flow now via the holiday router.
if _holiday_window_active(date_str):
_activate_holiday_inline(schedule, date_str)
holiday = schedule.get_holiday(date_str)
multiplier_value = holiday["multiplier"] if holiday else (multiplier or "1.5")
respond(
text=(
f"Scheduled *{label}* holiday on *{date.strftime('%A, %b %-d')}* — "
f"{slots} slot{'s' if slots != 1 else ''} at {float(multiplier_value):g}x pay."
)
)
if schedule_channel:
try:
client.chat_postMessage(
channel=schedule_channel,
blocks=build_holiday_added_blocks(
date_str, label, slots, multiplier_value
),
text=f"Holiday added: {label} on {date_str}",
)
except Exception:
logger.exception("Failed to post holiday-added notification to channel")
_refresh_schedule_post(schedule, schedule_channel, client)
def _admin_holiday_remove(respond, schedule, parts, client, schedule_channel): def _admin_holiday_remove(respond, schedule, parts, client, schedule_channel):
@ -2006,16 +2087,13 @@ def _admin_holiday_remove(respond, schedule, parts, client, schedule_channel):
def _admin_holiday_list(respond, schedule): def _admin_holiday_list(respond, schedule):
"""`admin holiday list` — show today-and-future scheduled holidays.""" """`admin holiday list` — show today-and-future scheduled holidays."""
today_str = datetime.now(EASTERN).strftime("%Y-%m-%d") today_str = datetime.now(EASTERN).strftime("%Y-%m-%d")
resp = schedule.table.query( items = schedule.list_holidays(today_str)
KeyConditionExpression=Key("PK").eq("HOLIDAY") & Key("SK").gte(today_str)
)
items = resp.get("Items", [])
if not items: if not items:
respond(text="No upcoming holidays scheduled.") respond(text="No upcoming holidays scheduled.")
return return
lines = ["*Upcoming Holidays*\n"] lines = ["*Upcoming Holidays*\n"]
for item in sorted(items, key=lambda x: x["SK"]): for item in items:
date_str = item["SK"] date_str = item["SK"]
dt = datetime.strptime(date_str, "%Y-%m-%d") dt = datetime.strptime(date_str, "%Y-%m-%d")
slots = int(item.get("slots", 0)) slots = int(item.get("slots", 0))
@ -2048,17 +2126,132 @@ def _changelog_text() -> str:
return "" return ""
def publish_home(client, user_id: str, changelog_text: str) -> None: _HOME_OVERVIEW_DAYS = 60
"""Render and publish the App Home view for ``user_id``."""
def publish_home(client, user_id: str, changelog_text: str, schedule=None) -> None:
"""Render and publish the App Home view for ``user_id``.
When ``schedule`` is provided and the viewer is an admin (looked up via
``get_admin_users()`` — never trust the surface), the admin section with the
override/holiday modal buttons and an upcoming overview is included.
"""
entry = latest_entry(changelog_text) entry = latest_entry(changelog_text)
is_admin = False
upcoming_overrides = None
upcoming_holidays = None
if schedule is not None and user_id in schedule.get_admin_users():
is_admin = True
today = datetime.now(EASTERN)
start = today.strftime("%Y-%m-%d")
end = (today + timedelta(days=_HOME_OVERVIEW_DAYS)).strftime("%Y-%m-%d")
upcoming_overrides = schedule.list_overrides(start, end)
upcoming_holidays = schedule.list_holidays(start)
view = build_home_view( view = build_home_view(
version=entry.version if entry else None, version=entry.version if entry else None,
notes=entry.body if entry else "", notes=entry.body if entry else "",
date_label=entry.date_label if entry else "", date_label=entry.date_label if entry else "",
is_admin=is_admin,
upcoming_overrides=upcoming_overrides,
upcoming_holidays=upcoming_holidays,
) )
client.views_publish(user_id=user_id, view=view) client.views_publish(user_id=user_id, view=view)
# ── Admin modals (views.open + view_submission) ─────────────────────────
def _notify_admin(client, user_id: str, text: str) -> None:
"""DM the admin a confirmation of a modal-driven change (best-effort)."""
try:
client.chat_postMessage(channel=user_id, text=text)
except Exception:
logger.exception("Failed to send admin modal confirmation DM")
def open_override_modal(body, client, schedule) -> None:
"""Open the override modal — admins only (re-check, never trust the surface)."""
user_id = body["user"]["id"]
if user_id not in schedule.get_admin_users():
return
client.views_open(
trigger_id=body["trigger_id"],
view=build_override_modal(schedule.get_roster()),
)
def open_holiday_add_modal(body, client, schedule) -> None:
"""Open the holiday-add modal — admins only (re-check, never trust surface)."""
user_id = body["user"]["id"]
if user_id not in schedule.get_admin_users():
return
client.views_open(trigger_id=body["trigger_id"], view=build_holiday_add_modal())
def handle_override_submission(ack, body, view, client, schedule, schedule_channel):
"""``view_submission`` for the override modal."""
user_id = body["user"]["id"]
if user_id not in schedule.get_admin_users():
ack(response_action="errors", errors={"date": "Admin access required."})
return
values = view["state"]["values"]
date_str = values["date"]["date"]["selected_date"]
ext = values["extension"]["extension"]["selected_option"]["value"]
shift_type = values["shift_type"]["shift_type"]["selected_option"]["value"]
employee, error = _resolve_roster_employee(schedule, ext)
if error:
ack(response_action="errors", errors={"extension": error})
return
ack()
message, repointed = _apply_override(
schedule, date_str, employee, shift_type, client, schedule_channel
)
if repointed:
message += (
"\n:telephone_receiver: This is today — the phone routing has been "
"repointed now."
)
_notify_admin(client, user_id, message)
def handle_holiday_add_submission(ack, body, view, client, schedule, schedule_channel):
"""``view_submission`` for the holiday-add modal."""
user_id = body["user"]["id"]
if user_id not in schedule.get_admin_users():
ack(response_action="errors", errors={"date": "Admin access required."})
return
values = view["state"]["values"]
date_str = values["date"]["date"]["selected_date"]
slots_raw = values["slots"]["slots"].get("value")
mult_raw = values["multiplier"]["multiplier"].get("value")
label = values["label"]["label"].get("value") or ""
errors = {}
if date_error := _validate_holiday_date(date_str):
errors["date"] = date_error
slots, slots_error = _parse_slots(slots_raw)
if slots_error:
errors["slots"] = slots_error
multiplier, mult_error = _parse_multiplier(mult_raw)
if mult_error:
errors["multiplier"] = mult_error
if label_error := _validate_label(label):
errors["label"] = label_error
if errors:
ack(response_action="errors", errors=errors)
return
ack()
_created, message = _apply_holiday_add(
schedule, date_str, slots, multiplier, label.strip(), client, schedule_channel
)
_notify_admin(client, user_id, message)
# ── App factory ───────────────────────────────────────────────────────── # ── App factory ─────────────────────────────────────────────────────────
@ -2105,11 +2298,33 @@ def create_app(
ack() ack()
handle_swap_decline(body, respond, client, schedule, schedule_channel) handle_swap_decline(body, respond, client, schedule, schedule_channel)
# Admin App Home buttons → open the corresponding Block Kit modal.
@app.action(OPEN_OVERRIDE_MODAL_ACTION)
def handle_open_override_modal(ack, body, client):
ack()
open_override_modal(body, client, schedule)
@app.action(OPEN_HOLIDAY_MODAL_ACTION)
def handle_open_holiday_modal(ack, body, client):
ack()
open_holiday_add_modal(body, client, schedule)
# Admin modal submissions (ack is handled inside, with field errors).
@app.view(OVERRIDE_MODAL_CALLBACK)
def handle_override_view(ack, body, view, client):
handle_override_submission(ack, body, view, client, schedule, schedule_channel)
@app.view(HOLIDAY_ADD_MODAL_CALLBACK)
def handle_holiday_add_view(ack, body, view, client):
handle_holiday_add_submission(
ack, body, view, client, schedule, schedule_channel
)
@app.event("app_home_opened") @app.event("app_home_opened")
def handle_app_home_opened(event, client): def handle_app_home_opened(event, client):
# Fires for the Messages tab too; only (re)publish the Home tab. # Fires for the Messages tab too; only (re)publish the Home tab.
if event.get("tab") != "home": if event.get("tab") != "home":
return return
publish_home(client, event["user"], _changelog_text()) publish_home(client, event["user"], _changelog_text(), schedule)
return app return app

View file

@ -3,8 +3,11 @@
from freezegun import freeze_time from freezegun import freeze_time
from shared.blocks import ( from shared.blocks import (
build_admin_overview,
build_help_blocks, build_help_blocks,
build_holiday_add_modal,
build_holiday_added_blocks, build_holiday_added_blocks,
build_override_modal,
build_pay_summary_blocks, build_pay_summary_blocks,
build_pickup_request_blocks, build_pickup_request_blocks,
build_pickup_resolved_blocks, build_pickup_resolved_blocks,
@ -383,6 +386,79 @@ class TestBuildRosterBlocks:
assert "_not linked_" in text assert "_not linked_" in text
def _block_ids(view):
return {b["block_id"] for b in view["blocks"] if b.get("block_id")}
class TestBuildOverrideModal:
def test_callback_and_input_blocks(self):
view = build_override_modal([{"extension": "114", "name": "Alice"}])
assert view["type"] == "modal"
assert view["callback_id"] == "admin_override_submit"
assert _block_ids(view) == {"date", "extension", "shift_type"}
def test_extension_dropdown_built_from_roster(self):
view = build_override_modal(
[{"extension": "114", "name": "Alice"}, {"extension": "115", "name": "Bob"}]
)
ext_block = next(b for b in view["blocks"] if b["block_id"] == "extension")
values = [o["value"] for o in ext_block["element"]["options"]]
assert values == ["114", "115"]
def test_empty_roster_omits_options(self):
view = build_override_modal([])
ext_block = next(b for b in view["blocks"] if b["block_id"] == "extension")
assert "options" not in ext_block["element"]
class TestBuildHolidayAddModal:
def test_callback_and_input_blocks(self):
view = build_holiday_add_modal()
assert view["callback_id"] == "admin_holiday_add_submit"
assert _block_ids(view) == {"date", "slots", "multiplier", "label"}
def test_multiplier_is_optional(self):
view = build_holiday_add_modal()
mult = next(b for b in view["blocks"] if b["block_id"] == "multiplier")
assert mult["optional"] is True
class TestBuildAdminOverview:
def test_empty_overview(self):
text = build_admin_overview([], [])["text"]["text"]
assert "Nothing scheduled" in text
def test_lists_overrides_and_holidays(self):
overrides = [
{
"date": "2026-12-25",
"shift_type": "night",
"extension": "114",
"name": "Alice",
},
{
"date": "2026-12-26",
"shift_type": "day",
"extension": "OPEN",
"name": "Open",
},
]
holidays = [
{
"SK": "2026-12-25",
"slots": 2,
"assignees": {},
"multiplier": 1.5,
"label": "Christmas",
}
]
text = build_admin_overview(overrides, holidays)["text"]["text"]
assert "Alice (Ext 114)" in text
assert "(Day) — Open" in text
assert "Christmas" in text
assert "0/2 filled" in text
class TestReleaseAnnouncement: class TestReleaseAnnouncement:
def test_markdown_bold_becomes_slack_bold(self): def test_markdown_bold_becomes_slack_bold(self):
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text" assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"

View file

@ -117,6 +117,41 @@ class TestOverrides:
assert schedule.resolve_shift(WED, "Wednesday") == ("114", "Alice", "weekly") assert schedule.resolve_shift(WED, "Wednesday") == ("114", "Alice", "weekly")
class TestListOverrides:
def test_returns_night_and_day_in_range(self, schedule, seed):
seed.override(WED, "115", "Bob")
seed.override(SAT, "200", "DayPerson", shift_type="day")
seed.open_shift(SUN)
result = schedule.list_overrides(WED, SUN)
assert result == [
{"date": WED, "shift_type": "night", "extension": "115", "name": "Bob"},
{
"date": SAT,
"shift_type": "day",
"extension": "200",
"name": "DayPerson",
},
{"date": SUN, "shift_type": "night", "extension": "OPEN", "name": "Open"},
]
def test_excludes_dates_outside_range(self, schedule, seed):
seed.override("2026-05-31", "100", "Before")
seed.override(WED, "115", "Bob")
seed.override("2026-06-30", "300", "After")
result = schedule.list_overrides(WED, SUN)
assert [r["extension"] for r in result] == ["115"]
def test_includes_day_override_on_end_date(self, schedule, seed):
seed.override(SUN, "201", "EndDay", shift_type="day")
result = schedule.list_overrides(WED, SUN)
assert result == [
{"date": SUN, "shift_type": "day", "extension": "201", "name": "EndDay"}
]
def test_empty_when_no_overrides(self, schedule):
assert schedule.list_overrides(WED, SUN) == []
class TestClaimOpenShift: class TestClaimOpenShift:
def test_claim_when_no_override_succeeds(self, schedule): def test_claim_when_no_override_succeeds(self, schedule):
assert schedule.claim_open_shift(WED, "114", "Alice") is True assert schedule.claim_open_shift(WED, "114", "Alice") is True

View file

@ -0,0 +1,189 @@
"""Tests for the admin Block Kit modals — opening (views.open), submitting
(view_submission), shared validation, and non-admin rejection (the IDOR case:
a modal can be opened from Home, so the surface is never trusted)."""
from unittest.mock import MagicMock
import pytest
from freezegun import freeze_time
ADMIN = "U_ADMIN"
INTRUDER = "U_INTRUDER"
# 2026-06-01 is a Monday → night shift; 18:00 ET is inside the night window.
MON_NIGHT = "2026-06-01 18:00:00"
@pytest.fixture
def admin_schedule(schedule, seed):
seed.config(admin_users=[ADMIN])
return schedule
@pytest.fixture
def ack():
return MagicMock(name="ack")
def _override_view(date="2026-12-25", ext="114", shift="night"):
return {
"state": {
"values": {
"date": {"date": {"selected_date": date}},
"extension": {"extension": {"selected_option": {"value": ext}}},
"shift_type": {"shift_type": {"selected_option": {"value": shift}}},
}
}
}
def _holiday_view(date="2026-12-25", slots="2", multiplier="", label="Christmas"):
return {
"state": {
"values": {
"date": {"date": {"selected_date": date}},
"slots": {"slots": {"value": slots}},
"multiplier": {"multiplier": {"value": multiplier or None}},
"label": {"label": {"value": label}},
}
}
}
def _body(user_id=ADMIN, trigger_id="T123"):
return {"user": {"id": user_id}, "trigger_id": trigger_id}
class TestOpenModals:
def test_admin_opens_override_modal(
self, slackbot_app, admin_schedule, seed, client
):
seed.roster("114", "Alice")
slackbot_app.open_override_modal(_body(), client, admin_schedule)
view = client.views_open.call_args.kwargs["view"]
assert view["callback_id"] == "admin_override_submit"
def test_admin_opens_holiday_modal(self, slackbot_app, admin_schedule, client):
slackbot_app.open_holiday_add_modal(_body(), client, admin_schedule)
view = client.views_open.call_args.kwargs["view"]
assert view["callback_id"] == "admin_holiday_add_submit"
def test_non_admin_cannot_open_override(self, slackbot_app, admin_schedule, client):
slackbot_app.open_override_modal(_body(INTRUDER), client, admin_schedule)
client.views_open.assert_not_called()
def test_non_admin_cannot_open_holiday(self, slackbot_app, admin_schedule, client):
slackbot_app.open_holiday_add_modal(_body(INTRUDER), client, admin_schedule)
client.views_open.assert_not_called()
class TestOverrideSubmission:
def test_happy_path(self, slackbot_app, admin_schedule, seed, ack, client):
seed.roster("114", "Alice")
slackbot_app.handle_override_submission(
ack, _body(), _override_view(), client, admin_schedule, None
)
assert admin_schedule.get_override("2026-12-25")["extension"] == "114"
ack.assert_called_once_with()
assert client.chat_postMessage.call_args.kwargs["channel"] == ADMIN
@freeze_time(MON_NIGHT)
def test_same_day_repoints_and_confirms(
self, slackbot_app, admin_schedule, seed, ack, client, routing_spy
):
seed.roster("114", "Alice")
slackbot_app.handle_override_submission(
ack,
_body(),
_override_view(date="2026-06-01"),
client,
admin_schedule,
None,
)
routing_spy.assert_called_once_with("114")
assert "repointed" in client.chat_postMessage.call_args.kwargs["text"].lower()
def test_unknown_extension_surfaces_field_error(
self, slackbot_app, admin_schedule, ack, client
):
slackbot_app.handle_override_submission(
ack, _body(), _override_view(ext="999"), client, admin_schedule, None
)
kwargs = ack.call_args.kwargs
assert kwargs["response_action"] == "errors"
assert "extension" in kwargs["errors"]
assert admin_schedule.get_override("2026-12-25") is None
def test_non_admin_rejected(self, slackbot_app, admin_schedule, seed, ack, client):
seed.roster("114", "Alice")
slackbot_app.handle_override_submission(
ack, _body(INTRUDER), _override_view(), client, admin_schedule, None
)
assert ack.call_args.kwargs["response_action"] == "errors"
assert admin_schedule.get_override("2026-12-25") is None
@freeze_time("2026-06-01 12:00:00")
class TestHolidaySubmission:
def test_happy_path(self, slackbot_app, admin_schedule, ack, client, monkeypatch):
monkeypatch.setattr(slackbot_app, "_activate_holiday_inline", MagicMock())
slackbot_app.handle_holiday_add_submission(
ack, _body(), _holiday_view(multiplier="2"), client, admin_schedule, "C_T"
)
holiday = admin_schedule.get_holiday("2026-12-25")
assert holiday["slots"] == 2
assert holiday["label"] == "Christmas"
assert float(holiday["multiplier"]) == 2.0
ack.assert_called_once_with()
assert client.chat_postMessage.call_args.kwargs["channel"] == ADMIN
def test_default_multiplier_when_blank(
self, slackbot_app, admin_schedule, ack, client, monkeypatch
):
monkeypatch.setattr(slackbot_app, "_activate_holiday_inline", MagicMock())
slackbot_app.handle_holiday_add_submission(
ack, _body(), _holiday_view(), client, admin_schedule, None
)
assert float(admin_schedule.get_holiday("2026-12-25")["multiplier"]) == 1.5
def test_past_date_surfaces_field_error(
self, slackbot_app, admin_schedule, ack, client
):
slackbot_app.handle_holiday_add_submission(
ack, _body(), _holiday_view(date="2026-01-01"), client, admin_schedule, None
)
errors = ack.call_args.kwargs["errors"]
assert "date" in errors and "past" in errors["date"].lower()
assert admin_schedule.get_holiday("2026-01-01") is None
def test_bad_slots_surfaces_field_error(
self, slackbot_app, admin_schedule, ack, client
):
slackbot_app.handle_holiday_add_submission(
ack, _body(), _holiday_view(slots="lots"), client, admin_schedule, None
)
assert "slots" in ack.call_args.kwargs["errors"]
assert admin_schedule.get_holiday("2026-12-25") is None
def test_missing_label_surfaces_field_error(
self, slackbot_app, admin_schedule, ack, client
):
slackbot_app.handle_holiday_add_submission(
ack, _body(), _holiday_view(label=""), client, admin_schedule, None
)
assert "label" in ack.call_args.kwargs["errors"]
def test_bad_multiplier_surfaces_field_error(
self, slackbot_app, admin_schedule, ack, client
):
slackbot_app.handle_holiday_add_submission(
ack, _body(), _holiday_view(multiplier="huge"), client, admin_schedule, None
)
assert "multiplier" in ack.call_args.kwargs["errors"]
def test_non_admin_rejected(self, slackbot_app, admin_schedule, ack, client):
slackbot_app.handle_holiday_add_submission(
ack, _body(INTRUDER), _holiday_view(), client, admin_schedule, None
)
assert ack.call_args.kwargs["response_action"] == "errors"
assert admin_schedule.get_holiday("2026-12-25") is None

View file

@ -1,5 +1,7 @@
"""Tests for the App Home tab (about page + What's New).""" """Tests for the App Home tab (about page + What's New)."""
from freezegun import freeze_time
from shared.blocks import build_home_view from shared.blocks import build_home_view
@ -44,3 +46,33 @@ def test_publish_home_degrades_without_changelog(slackbot_app, client):
slackbot_app.publish_home(client, "U_BOB", "") slackbot_app.publish_home(client, "U_BOB", "")
view = client.views_publish.call_args.kwargs["view"] view = client.views_publish.call_args.kwargs["view"]
assert all("What's New" not in str(b) for b in view["blocks"]) assert all("What's New" not in str(b) for b in view["blocks"])
def test_publish_home_includes_admin_section_for_admin(
slackbot_app, schedule, seed, client
):
seed.config(admin_users=["U_ADMIN"])
seed.override("2026-12-25", "114", "Alice")
seed.holiday("2026-12-25", slots=2, label="Christmas")
with freeze_time("2026-06-01"):
slackbot_app.publish_home(client, "U_ADMIN", "", schedule)
view = client.views_publish.call_args.kwargs["view"]
assert "Admin" in _headers(view)
assert "admin_open_override_modal" in str(view["blocks"])
assert "Christmas" in str(view["blocks"])
def test_publish_home_omits_admin_section_for_non_admin(
slackbot_app, schedule, seed, client
):
seed.config(admin_users=["U_ADMIN"])
slackbot_app.publish_home(client, "U_BOB", "", schedule)
view = client.views_publish.call_args.kwargs["view"]
assert "Admin" not in _headers(view)
assert "admin_open_override_modal" not in str(view["blocks"])
def test_publish_home_without_schedule_has_no_admin_section(slackbot_app, client):
slackbot_app.publish_home(client, "U_ADMIN", "")
view = client.views_publish.call_args.kwargs["view"]
assert "Admin" not in _headers(view)