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
**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}}]
# ── 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]:
"""Build help message blocks."""
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> x<mult> <label>` — …with a custom pay multiplier\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}}]
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:
"""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
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] = [
{
@ -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:
blocks.append({"type": "divider"})
blocks.append(

View file

@ -197,6 +197,35 @@ class ShiftSchedule:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
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 ───────────────────────────────────────────────────
def create_pending_swap(
@ -387,6 +416,14 @@ class ShiftSchedule:
def remove_holiday(self, date_str: str) -> None:
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:
"""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
**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
import boto3
from boto3.dynamodb.conditions import Key
from slack_bolt import App
from shared.blocks import (
HOLIDAY_ADD_MODAL_CALLBACK,
OPEN_HOLIDAY_MODAL_ACTION,
OPEN_OVERRIDE_MODAL_ACTION,
OVERRIDE_MODAL_CALLBACK,
build_help_blocks,
build_holiday_add_modal,
build_holiday_added_blocks,
build_home_view,
build_override_modal,
build_pay_summary_blocks,
build_pickup_request_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")
# ── 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):
if not is_admin:
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 = (
parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night"
)
employee = schedule.get_employee_by_extension(ext)
if not employee:
respond(text=f"Extension `{ext}` not found in the roster.")
employee, error = _resolve_roster_employee(schedule, ext)
if error:
respond(text=error)
return
date_str = date.strftime("%Y-%m-%d")
schedule.set_override(
date_str, employee["extension"], employee["name"], shift_type
message, _repointed = _apply_override(
schedule, date_str, employee, shift_type, client, schedule_channel
)
if is_today(date_str) and _is_active_shift_type(shift_type):
_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)
respond(text=message)
elif subcmd == "open":
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]}`")
return
date_str = date.strftime("%Y-%m-%d")
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
respond(text="You can't schedule a holiday in the past.")
date_error = _validate_holiday_date(date_str)
if date_error:
respond(text=date_error)
return
try:
slots = int(parts[4])
except ValueError:
respond(text=f"Slots must be a whole number, got `{parts[4]}`.")
return
if slots < 1:
respond(text="Slots must be at least 1.")
slots, slots_error = _parse_slots(parts[4])
if slots_error:
respond(text=slots_error)
return
# 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:]
rest = rest[1:]
label = " ".join(rest).strip()
if not label:
respond(text="A holiday label is required.")
label_error = _validate_label(label)
if label_error:
respond(text=label_error)
return
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,
_created, message = _apply_holiday_add(
schedule, date_str, slots, multiplier, label, client, schedule_channel
)
if not created:
# 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)
respond(text=message)
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):
"""`admin holiday list` — show today-and-future scheduled holidays."""
today_str = datetime.now(EASTERN).strftime("%Y-%m-%d")
resp = schedule.table.query(
KeyConditionExpression=Key("PK").eq("HOLIDAY") & Key("SK").gte(today_str)
)
items = resp.get("Items", [])
items = schedule.list_holidays(today_str)
if not items:
respond(text="No upcoming holidays scheduled.")
return
lines = ["*Upcoming Holidays*\n"]
for item in sorted(items, key=lambda x: x["SK"]):
for item in items:
date_str = item["SK"]
dt = datetime.strptime(date_str, "%Y-%m-%d")
slots = int(item.get("slots", 0))
@ -2048,17 +2126,132 @@ def _changelog_text() -> str:
return ""
def publish_home(client, user_id: str, changelog_text: str) -> None:
"""Render and publish the App Home view for ``user_id``."""
_HOME_OVERVIEW_DAYS = 60
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)
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(
version=entry.version if entry else None,
notes=entry.body 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)
# ── 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 ─────────────────────────────────────────────────────────
@ -2105,11 +2298,33 @@ def create_app(
ack()
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")
def handle_app_home_opened(event, client):
# Fires for the Messages tab too; only (re)publish the Home tab.
if event.get("tab") != "home":
return
publish_home(client, event["user"], _changelog_text())
publish_home(client, event["user"], _changelog_text(), schedule)
return app

View file

@ -3,8 +3,11 @@
from freezegun import freeze_time
from shared.blocks import (
build_admin_overview,
build_help_blocks,
build_holiday_add_modal,
build_holiday_added_blocks,
build_override_modal,
build_pay_summary_blocks,
build_pickup_request_blocks,
build_pickup_resolved_blocks,
@ -383,6 +386,79 @@ class TestBuildRosterBlocks:
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:
def test_markdown_bold_becomes_slack_bold(self):
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")
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:
def test_claim_when_no_override_succeeds(self, schedule):
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)."""
from freezegun import freeze_time
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", "")
view = client.views_publish.call_args.kwargs["view"]
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)