mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 06:43:12 +00:00
* 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:
parent
11afaf1f1f
commit
73b295e5eb
10 changed files with 1091 additions and 79 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
**Fixed: the weekly pay summary email to payroll is sending again.** A
|
||||
|
|
|
|||
200
docs/admin-ui-evaluation.md
Normal file
200
docs/admin-ui-evaluation.md
Normal 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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
**Fixed: the weekly pay summary email to payroll is sending again.** A
|
||||
|
|
|
|||
|
|
@ -16,13 +16,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,
|
||||
|
|
@ -1810,6 +1815,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.")
|
||||
|
|
@ -1849,21 +1978,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:
|
||||
|
|
@ -2010,17 +2133,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.
|
||||
|
|
@ -2030,54 +2150,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):
|
||||
|
|
@ -2110,16 +2191,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))
|
||||
|
|
@ -2152,17 +2230,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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -2209,12 +2402,34 @@ 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)
|
||||
|
||||
@app.event("message")
|
||||
def handle_message_event(event, client, request):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
189
tests/slack_bot/test_admin_modals.py
Normal file
189
tests/slack_bot/test_admin_modals.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue