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