[#136] Add Slack admin modals + App Home admin section (#141)
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / release (push) Has been cancelled

* 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

* Update changelog date to July 02, 2026

* Add point-and-click admin actions in Slack for easier overrides and holidays

* [#136] Add admin UI evaluation spike doc (#140)

Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
This commit is contained in:
seahaven-openswe[bot] 2026-07-02 16:29:42 -04:00 • committed by GitHub
parent 11afaf1f1f
commit 73b295e5eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1091 additions and 79 deletions

View file

@ -10,6 +10,23 @@ fine and still supported.
--- ---
## v1.14.0 — July 2, 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.13.1 — June 29, 2026 ## v1.13.1 — June 29, 2026
**Fixed: the weekly pay summary email to payroll is sending again.** A **Fixed: the weekly pay summary email to payroll is sending again.** A

200
docs/admin-ui-evaluation.md Normal file
View file

@ -0,0 +1,200 @@
# Evaluation: admin web UI vs. clarifying/expanding in-Slack admin commands
_Spike for issue #136 — decide how to make administration easier: a separate
**admin web UI**, or **clarifying/expanding the in-Slack admin commands** (Block
Kit modals + an App Home admin section). This document records the options, a
recommendation, and the scope of the follow-up build. It is a "decide and
recommend" spike — no admin-surface code ships in this PR. The route is left for
sign-off (see "Decision needed" at the end)._
## Current admin surface
Every admin action is a positional-text subcommand of `/oncall admin …`,
authorized by `is_admin = user_id in schedule.get_admin_users()`
(`src/shared/shared/schedule.py`, `get_admin_users`) and handled in
`_handle_admin()` / `_handle_admin_holiday()` (`src/slack-bot/app.py`):
- `admin override <date> <ext> [day|night]` — assign a shift
- `admin open <date> [day|night]` — mark a shift open
- `admin clear <date> [day|night]` — remove an override (revert to weekly)
- `admin roster add|remove|rename <ext> [name]`
- `admin holiday add <date> <slots> [x<mult>] <label>` / `holiday remove <date>` /
`holiday list`
A same-day `override` / `open` / `clear` also repoints 3CX (`_update_3cx_routing`),
and `holiday add` provisions the 08:00/17:00 one-off schedules and may activate
inline — so these commands have real side effects, which makes a confirmation
preview valuable.
### Pain points (from the issue, confirmed in code)
- **Positional and error-prone.** Order-sensitive args — most acutely
`holiday add <date> <slots> [x<mult>] <label>`, which hand-parses an optional
`x<mult>` token out of the middle of the label (`_admin_holiday_add`). On any
mistake the only feedback is a usage string.
- **Discoverability.** The single source of documentation is the static admin
block in `build_help_blocks(is_admin=True)` (`src/shared/shared/blocks.py`).
No pickers, no validation hints, no previews.
- **No structured input.** No date picker, no roster dropdown, no
confirmation/preview before a change lands (and, same-day, repoints 3CX).
- **No overview.** No calendar/grid of upcoming overrides + holidays to scan.
### What already exists (lowers the cost of the Slack-native path)
- **App Home tab is live.** `home_tab_enabled: true` in
`slack-app-manifest.yaml`; `build_home_view()` renders it and
`app_home_opened` re-publishes it (`src/slack-bot/app.py`). It currently shows
only the non-admin help — there is a natural place to add an admin section.
- **Interactivity is wired.** `settings.interactivity.is_enabled: true`, and the
bot already registers `@app.action(...)` handlers (pickup/swap buttons). Adding
`@app.action(...)` for admin buttons and `@app.view(...)` for modal submits is
the same mechanism — **no new hosting, scope, or reinstall** is required for
modals (`views.open`/`view_submission` ride the existing interactivity URL).
- **All mutations already have typed methods** on the `Schedule` class
(`set_override`, `mark_open`, `remove_override`, `add_roster_entry`,
`remove_roster_entry`, `rename_roster_entry`, `create_holiday`,
`remove_holiday`). Modals and a web API would both call the same methods, so
the authorization and side-effect logic does not get duplicated.
## Options considered
### (a) Expand/clarify inside Slack — **recommended (phase 1)**
Add Block Kit **modals** (`views.open`) for the error-prone, high-value actions,
an **admin section in App Home**, and keep the text subcommands as a fallback.
- **Pros:** reuses existing interactivity + App Home; **no new infra, auth,
hosting, or OAuth scope**; native date pickers / dropdowns / numeric inputs
remove the positional-arg footguns; a confirmation preview before same-day
3CX repoints; one deploy target; the modal handlers call the same `Schedule`
methods the text commands do, so authorization stays in one place.
- **Cons:** still lives in Slack; modal layout is constrained (no true
calendar/grid — an upcoming list is the practical "overview"); a small amount
of new view-state plumbing.
### (b) Full admin web UI
A separate hosted app (following the internal-portal pattern: Google OAuth,
CDK-hosted, SHOC design system) talking to the schedule DynamoDB table through a
new API.
- **Pros:** full calendar/grid, bulk edits, the richest UX; not constrained by
Block Kit.
- **Cons:** significant effort and ongoing cost — a new auth path (Google OAuth),
hosting (CDK), an API surface in front of DynamoDB, a **second deploy target**,
and a security / cross-family review. It also **forks the authorization model**:
`get_admin_users()` holds Slack user IDs, so a Google-OAuth UI needs an
identity mapping (Google account → Slack admin) to keep one source of truth.
Likely overkill unless admin volume is high.
### (c) Status quo — keep text-only commands
- **Pros:** zero work.
- **Cons:** every pain point above persists; the `holiday add` arg order keeps
biting.
## Recommendation: phased — (a) now, (b) only if admin volume grows
Adopt **(a)** as phase 1 and treat **(b)** as a deferred phase 2 gated on real
demand. Rationale:
- **Admin actions are low-frequency and well-bounded.** Overrides, opens, clears,
roster edits, and a handful of holidays a year are individually quick. The cost
today is *correctness/discoverability per action* (getting the positional args
right), not throughput — which is exactly what modals fix, and which a web UI
fixes at far greater cost.
- **(a) reuses everything; (b) builds a new stack.** The Slack-native path needs
no new hosting, scope, auth, or review and lands a large UX win for the effort
of a few modals plus an App Home section. (b) is a new auth + hosting + API +
review surface for a marginal gain at current volume, and it splits the
authorization model.
- **(a) does not block (b).** If admin volume later justifies a calendar/grid or
bulk edits, the `Schedule` methods and `get_admin_users()` authorization are
already the clean seam a web API would sit on. Phase 1 is not throwaway.
Either way, **`get_admin_users()` stays the single authorization source of
truth**, and the **text commands stay as a fallback** so no current workflow
breaks.
## Scope of phase 1 (option a) — for the follow-up build
The phase-1 build is intentionally *not* in this PR. Scoped here so the
follow-up issue can be sized:
### Modals to build (`views.open` + `view_submission`)
Start with the two highest-pain actions, then optionally extend:
1. **Override modal** — replaces/supplements `admin override`.
- `datepicker` for the date, a roster **dropdown** (`static_select` built from
`Schedule.get_roster()` so the extension/name can't be mistyped), and a
day/night `radio_buttons`/`static_select`.
- A confirmation line in the submit response when the date is today (it will
repoint 3CX).
2. **Holiday-add modal** — replaces/supplements `admin holiday add` (the worst
positional offender).
- `datepicker` (date), `number_input`/plain-text **slots**, an explicit
**multiplier** input (its own field instead of a parsed `x<mult>` token),
and a **label** input.
- `view_submission` validates (date not in the past, slots ≥ 1) and surfaces
errors as Block Kit field `errors` instead of a usage string.
Optional follow-ons once the pattern is proven: **open**, **clear**, and
**roster add/remove/rename** modals. These are lower-pain (fewer args) so they
can stay text-only initially.
### Handlers / wiring
- A new `@app.action(...)` per admin button to call `client.views_open(...)` with
the modal view (built by new `build_*_modal()` helpers in
`src/shared/shared/blocks.py`, mirroring the existing `build_*` block builders).
- A new `@app.view(...)` per modal `callback_id` to read `view.state.values`,
**re-check `is_admin` against `get_admin_users()`** (modals can be opened from
Home — never trust the surface), call the same `Schedule` method the text
command uses, run the same 3CX/announce side effects, and `ack()` (with field
`errors` on validation failure).
- Reuse `_refresh_schedule_post(...)` after a successful mutation, exactly as the
text handlers do.
### App Home admin section
- In `build_home_view()`, when the viewer is an admin (look them up via
`get_admin_users()` before publishing), append an **Admin** section:
buttons that open the modals above, plus a compact **upcoming
overrides + holidays** list (the practical substitute for a calendar/grid).
- `publish_home(...)` already runs on `app_home_opened`; it needs the viewer's
user id (it has `event["user"]`) to decide whether to include the admin
section.
### Help / validation
- Keep `build_help_blocks(is_admin=True)` as the text fallback reference, and
point it at the new modals ("or open the Admin tab in Home").
### Manifest / scope impact
- **None.** Modals and App Home interactivity use the **already-enabled**
`interactivity` request URL and the existing `app_home` config. No new OAuth
scope and **no reinstall** are required (unlike the `pins:write` change in the
sibling #135 work).
## Scope of phase 2 (option b) — only if later justified
Recorded so the deferral is a decision, not an omission:
- **Auth:** Google OAuth following the internal-portal pattern, plus a Google →
Slack-admin identity mapping so `get_admin_users()` remains the source of
truth (do **not** introduce a parallel admin list).
- **Hosting:** CDK-hosted app on the SHOC design system, a second deploy target
alongside the SAM stack.
- **Data path:** a thin API in front of the schedule DynamoDB table that calls
the same `Schedule` methods (no direct table writes from the browser).
- **Reviews:** security / cross-family review for the new public surface and
auth path.
## Decision needed
Recommendation is **(a) now, (b) later if volume grows**. The build route — ship
phase 1 (Slack-native modals + App Home admin), commit to the full phased path,
or jump straight to a web UI — is left for sign-off on the issue/PR before any
admin-surface code is written.

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.14.0 — July 2, 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.13.1 — June 29, 2026 ## v1.13.1 — June 29, 2026
**Fixed: the weekly pay summary email to payroll is sending again.** A **Fixed: the weekly pay summary email to payroll is sending again.** A

View file

@ -16,13 +16,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,
@ -1810,6 +1815,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.")
@ -1849,21 +1978,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:
@ -2010,17 +2133,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.
@ -2030,54 +2150,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):
@ -2110,16 +2191,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))
@ -2152,17 +2230,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 ─────────────────────────────────────────────────────────
@ -2209,12 +2402,34 @@ 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)
@app.event("message") @app.event("message")
def handle_message_event(event, client, request): def handle_message_event(event, client, request):

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)