Add holiday shifts with 3CX routing and late-pickup approval (#121)
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions

Holiday day-shifts (08:00-17:00 ET) with N slots and 1.5x pay. A new
afterhours-holiday-router Lambda, fired by per-holiday EventBridge Scheduler
one-offs, repoints IVR 800 (key-0 + no-input/timeout) to holiday queue 802 and
sets 802's membership to the day's assignees (ext 100 fallback when unfilled),
reverting at 17:00. Pickups after a shift starts go through an admin Approve/Deny
flow for both regular and holiday shifts. Pay (weekly post + /oncall pay) shows
holiday rates distinctly.

Adds HOLIDAY and PICKUP_REQUEST DynamoDB record types, scheduler IAM scoped to
holiday-* schedules with conditioned PassRole, and the holiday-router function
with a 60-day log group and error alarm.
This commit is contained in:
Adam Moussa 2026-06-17 11:14:29 -04:00 • committed by GitHub
parent 9353b9222b
commit 88e782c205
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 4132 additions and 111 deletions

View file

@ -10,6 +10,31 @@ fine and still supported.
---
## v1.11.0 — June 15, 2026
**Holiday coverage and last-minute pickups.** Two related additions:
- **Holidays now have their own day shift.** An admin can schedule a holiday for
any date with `/oncall admin holiday add` — a single daytime shift (8am-5pm ET)
that can have more than one person on it. The bot shows the holiday in the
schedule with a button to grab an open slot, and holiday slots can be set to
pay a higher rate (1.5x by default). On the holiday itself the after-hours
phones automatically route to whoever claimed a slot, from 8am to 5pm, and
switch back to normal at 5pm — no one has to touch the phone system. If a
holiday goes by with nobody signed up, calls fall back to the default
extension. Admins can also list upcoming holidays and remove one they no longer
need. Holidays take priority over both regular overrides and the standing
weekly schedule for that date.
- **Picking up a shift after it has already started now needs an admin to say
yes.** Before, you could only pick up a shift that hadn't started. Now, if a
shift is already underway (8am for a day/holiday shift, 5pm for a night shift)
but hasn't ended yet, asking to pick it up sends every admin an Approve/Deny
message — the same way swaps are confirmed. The first admin to approve wins,
and once approved the shift is handed over and the phones are repointed right
away. Picking up a shift that hasn't started yet still happens instantly, and
a shift that has already ended can't be picked up at all. This applies to both
regular and holiday shifts.
## v1.10.1 — June 15, 2026
**Payroll summary emails are sending again.** The automated weekly pay summary

View file

@ -21,7 +21,7 @@ The bot also has an **About** page: open the bot in Slack and click its **Home**
|---|---|
| `/oncall` | Show this week's schedule |
| `/oncall next` | Show next week's schedule |
| `/oncall pick <date>` | Pick up an available shift |
| `/oncall pick <date>` | Pick up an available shift — instant if the shift hasn't started; if it's already underway (but not ended) it needs admin approval (see [Late-pickup approval](#late-pickup-approval)) |
| `/oncall drop <date>` | Drop your shift (marks it available) — blocked within 24h of shift start; swap or ask an admin instead |
| `/oncall swap <date> @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept |
| `/oncall register <ext>` | Link your Slack account to your phone extension |
@ -44,9 +44,16 @@ Available to users listed in `admin_users` in the CONFIG record:
| `/oncall admin roster add <ext> <name>` | Add an employee to the roster |
| `/oncall admin roster remove <ext>` | Remove an employee |
| `/oncall admin roster rename <ext> <name>` | Rename an employee |
| `/oncall admin holiday add <date> <slots> [x<mult>] <label>` | Schedule a holiday day shift (8am-5pm ET) with N slots, optional pay multiplier override (e.g. `x2`), and a label |
| `/oncall admin holiday remove <date>` | Remove a scheduled holiday and its activate/deactivate schedules |
| `/oncall admin holiday list` | List today-and-future scheduled holidays |
Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
Example: `/oncall admin holiday add 2026-07-04 2 x2 Independence Day` schedules a
2-slot holiday paying 2x. Omitting the `x<mult>` token uses the default
`holiday_multiplier` from CONFIG (1.5). See [Holidays](#holidays) for the full flow.
## Architecture
- **Runtime**: Python 3.12 on AWS Lambda (arm64)
@ -64,6 +71,7 @@ Dates accept: `today`, `tomorrow`, `monday`-`sunday`, `4/5`, `2026-04-05`
| `afterhours-weekly-post` | EventBridge (Monday 7am ET) | Posts weekly schedule to Slack, sends pay report email |
| `afterhours-roster-sync` | EventBridge (daily 6am ET) | Syncs employee roster from 3CX |
| `afterhours-ring-scheduler` | EventBridge (daily 8am ET + weekend 5pm ET) | Updates 3CX queue routing based on who's on shift |
| `afterhours-holiday-router` | EventBridge Scheduler (per-holiday one-off: 8am activate / 5pm deactivate ET) | Repoints the IVR to the holiday queue and sets queue agents for a holiday day shift; reverts at 5pm (see [Holidays](#holidays)) |
| `afterhours-release-notifier` | Invoked by the Deploy workflow's release job on minor/major releases | Posts a "What's New" announcement to the shift channel |
### Project Layout
@ -74,6 +82,7 @@ src/
weekly-post/ Monday schedule + pay post
roster-sync/ Daily 3CX roster sync
ring-scheduler/ 3CX queue routing updates
holiday-router/ 3CX IVR/queue repoint for holiday day shifts (activate/deactivate)
release-notifier/ Posts release announcements to Slack
shared/ Lambda Layer (schedule, blocks, changelog, 3CX client, secrets)
scripts/ changelog CLI + CI guard + in-package copy sync
@ -90,14 +99,75 @@ Single table with `PK` / `SK` keys:
| `WEEKLY` | `<DayName>` | Default weekly schedule: extension, name |
| `OVERRIDE` | `<YYYY-MM-DD>` | Date override from pickup/drop (or `OPEN`) |
| `SWAP` | `<YYYY-MM-DD>` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) |
| `HOLIDAY` | `<YYYY-MM-DD>` | Holiday day shift (one per date): `slots` (int), `assignees` (MAP keyed by extension — `{"114": {name, claimed_at}}`), `multiplier` (Decimal, defaults to `CONFIG.holiday_multiplier` = 1.5, overridable per holiday), `label`, `created_at`, `created_by`, `activated` (bool), `schedule_names` (list) |
| `PICKUP_REQUEST` | `<YYYY-MM-DD>[-DAY]#<ext>` | Pending late-pickup awaiting admin approval: `requester_ext`, `requester_name`, `requester_slack`, `shift_type`, `is_holiday`, `status`, `created_at`, `expires_at` (TTL = shift end) |
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Monday date key) |
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users |
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users, `ring_group`, `holiday_multiplier` (default holiday pay multiplier, 1.5), `holiday_queue` (3CX queue repointed during holidays, default 802), `ivr_number` (3CX IVR repointed during holidays, default 800), `captured_ivr_routes` (original IVR routes saved at holiday activation, restored at deactivation) |
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps self-clean.
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps and pickup requests self-clean.
Shift priority for any date is **HOLIDAY > OVERRIDE > WEEKLY** — a holiday record wins over a regular override, which wins over the standing weekly schedule.
Holiday slot claims are atomic Map updates so concurrent pickers can't oversubscribe:
- **Claim** — `SET assignees.#ext` guarded by `attribute_not_exists(assignees.#ext) AND size(assignees) < :slots`.
- **Release** — `REMOVE assignees.#ext` guarded by `attribute_exists`.
- **Swap** — `REMOVE #from SET #to` guarded by `attribute_exists(#from) AND attribute_not_exists(#to)`.
**Swap flow:** `/oncall swap` writes a `pending` `SWAP` record and DMs the target Accept/Decline buttons; it does **not** reassign the shift. On Accept, the override is written, 3CX is repointed if it's the active shift, and the record is marked `verified`. On Decline (or once the shift has started) the request is dropped and the shift stays with the original owner.
### Holidays
A **holiday** is a single day-only shift (08:00-17:00 ET), one `HOLIDAY` record per
date, that can hold multiple people (`slots`). Admins manage holidays with
`/oncall admin holiday add|remove|list`. A holiday takes priority over a regular
override and the weekly schedule for that date, and pays at its `multiplier`
(per-holiday override, else `CONFIG.holiday_multiplier`, default 1.5). Open slots
show in the schedule with a pickup button; claims, releases, and swaps are atomic
Map updates on the record (see above) so the slot count can't be oversubscribed.
**Holiday-router + Scheduler flow.** When an admin adds a holiday, the slack-bot
creates two **one-off EventBridge Scheduler** schedules for that date —
`holiday-activate-<YYYYMMDD>` at 08:00 ET and `holiday-deactivate-<YYYYMMDD>` at
17:00 ET — whose names are stored on the record's `schedule_names`. Scheduler
assumes `HolidaySchedulerExecutionRole` to invoke `afterhours-holiday-router`:
- **Activate (08:00):** capture both IVR `ivr_number` (800) routes — key-0 **and**
no-input/timeout — into `CONFIG.captured_ivr_routes` (skipped if they already
point at the holiday queue, so re-runs don't clobber the originals), set
`holiday_queue` (802) agents to the holiday's assignees (or `[fallback_extension]`
= `[100]` when no slots are filled — set **once** here), repoint **both** IVR 800
routes to queue 802, and mark `activated = True`. Idempotent.
- **Deactivate (17:00):** restore both IVR routes from `captured_ivr_routes` (only
routes still pointing at the queue, defensive against manual changes), clear them,
empty queue 802's agents, and mark `activated = False`. Idempotent.
Queue 801 (the daily ring-scheduler queue) is left untouched. If an admin adds a
holiday whose 08:00-17:00 window is already open, the slack-bot **inline-activates**
it immediately (invoking the router) rather than waiting for the 08:00 schedule.
Removing a holiday deletes the record and any outstanding schedules.
### Late-pickup approval
Picking up a shift behaves differently depending on timing, for **both** regular
and holiday shifts:
- **Before the shift starts** — immediate pickup (the prior behaviour, unchanged).
- **After the shift has started but before it ends** (08:00 for a day/holiday shift,
17:00 for a night shift) — the shift is **not** claimed yet. The bot writes a
`pending` `PICKUP_REQUEST` and DMs **every admin** Approve/Deny buttons (mirroring
the verified-swap flow). The **first admin to approve wins** (the claim is
conditional, so a second approval is a safe no-op). On approve, the shift is
claimed (regular: `set_override`; holiday: atomic `claim_holiday_slot`), the
requester and channel are notified, and 3CX is fired if the window is live —
regular shifts call `_update_3cx_routing(picker)` when it's today's active shift;
holidays refresh queue 802's agents to the current assignees. On deny, the request
is cleared and the requester is told.
- **After the shift has ended** — rejected outright; it's too late to pick up.
A slot claimed after the shift has started always needs an admin to approve it.
### Secrets Manager
| Secret | Description |

View file

@ -33,8 +33,10 @@ WEEKLY_SCHEDULE = {
}
CONFIG = {
"ring_group": "800",
"fallback_extension": "100",
"holiday_multiplier": "1.5", # default holiday pay multiplier (Decimal string)
"holiday_queue": "802", # 3CX queue repointed to holiday assignees during a holiday window
"ivr_number": "800", # 3CX IVR whose key-0 + no-input/timeout routes are repointed on a holiday
}
@ -69,17 +71,24 @@ def seed(table_name: str):
)
print(f" Weekly: {day} — {info['name']} (Ext {info['extension']})")
# Config
table.put_item(
Item={
"PK": "CONFIG",
"SK": "CONFIG",
**CONFIG,
}
# Config — set each key only if absent, so re-running never clobbers live
# operational values (admin_users, shift_rate, captured_ivr_routes).
set_clause = ", ".join(f"#{k} = if_not_exists(#{k}, :{k})" for k in CONFIG)
table.update_item(
Key={"PK": "CONFIG", "SK": "CONFIG"},
UpdateExpression="SET " + set_clause,
ExpressionAttributeNames={f"#{k}": k for k in CONFIG},
ExpressionAttributeValues={f":{k}": v for k, v in CONFIG.items()},
)
print(
f" Config (set-if-absent): fallback={CONFIG['fallback_extension']}, "
f"holiday_queue={CONFIG['holiday_queue']}, ivr_number={CONFIG['ivr_number']}, "
f"holiday_multiplier={CONFIG['holiday_multiplier']}"
)
print(f" Config: ring_group={CONFIG['ring_group']}, fallback={CONFIG['fallback_extension']}")
print("\nDone! Employees should run `/oncall register <extension>` in Slack to link their accounts.")
print(
"\nDone! Employees should run `/oncall register <extension>` in Slack to link their accounts."
)
if __name__ == "__main__":

192
src/holiday-router/app.py Normal file
View file

@ -0,0 +1,192 @@
"""Lambda handler — repoints 3CX call flow for a holiday day-shift.
A holiday is a day-only shift (08:00-17:00 ET), one HOLIDAY record per date.
At 08:00 this Lambda is invoked with ``{"action": "activate", "date": ...}`` and
at 17:00 with ``{"action": "deactivate", ...}``.
Activate:
* Capture both IVR 800 routes (key-0 and no-input/timeout) into
``CONFIG.captured_ivr_routes`` — but only if they are NOT already pointed at
the holiday queue (so a re-run never overwrites the real originals).
* Set queue 802's agents to the holiday's assignees, or ``[FALLBACK_EXTENSION]``
("100") when no slots are filled. Membership is set ONCE here.
* Repoint BOTH IVR 800 routes to the holiday queue (802).
* Mark the holiday ``activated = True``.
Idempotent: no-op if the record is gone or already activated.
Deactivate:
* Restore both IVR routes from ``CONFIG.captured_ivr_routes`` — only the routes
that currently point at the queue are reverted (defensive against manual
changes); clear the captured routes afterwards.
* Clear queue 802's agents.
* Mark the holiday ``activated = False``.
Idempotent: no-op if the record is gone or not activated.
"""
import json
import logging
import os
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.secrets import get_secret
from shared.three_cx_client import ThreeCXClient
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def _make_client() -> ThreeCXClient:
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
return ThreeCXClient(
domain=get_secret(f"{secret_prefix}domain"),
auth_mode="oauth",
client_id=get_secret(f"{secret_prefix}client-id"),
client_secret=get_secret(f"{secret_prefix}client-secret"),
)
def _holiday_extensions(holiday: dict) -> list[str]:
"""Assignee extensions for the holiday, or the fallback when none claimed."""
assignees = holiday.get("assignees", {}) or {}
extensions = list(assignees.keys())
return extensions or [FALLBACK_EXTENSION]
def _queue_route(queue_number: str) -> dict:
"""Build a 3CX Route object that forwards to the holiday queue extension."""
return {"To": "Queue", "Number": str(queue_number), "External": ""}
def _activate(schedule: ShiftSchedule, date: str) -> dict:
holiday = schedule.get_holiday(date)
if holiday is None:
logger.info("No holiday record for %s — nothing to activate", date)
return {"action": "activate", "date": date, "skipped": "no_record"}
if holiday.get("activated"):
logger.info("Holiday %s already activated — no-op", date)
return {"action": "activate", "date": date, "skipped": "already_active"}
queue_number = schedule.get_holiday_queue()
ivr_number = schedule.get_ivr_number()
extensions = _holiday_extensions(holiday)
client = _make_client()
# Capture the live IVR routes BEFORE repointing — but guard against storing
# holiday-state routes: if both routes already target the queue, a prior
# activation is in effect, so keep whatever originals we already captured.
ivr = client.get_ivr(ivr_number)
ivr_id = ivr["Id"]
current = client.extract_ivr_routes(ivr)
already_queue = client.route_to_extension(current.get("key0")) == str(
queue_number
) and client.route_to_extension(current.get("timeout")) == str(queue_number)
if already_queue:
logger.info(
"IVR %s already points at queue %s — not re-capturing routes",
ivr_number,
queue_number,
)
else:
schedule.set_captured_ivr_routes(current)
# Set queue membership ONCE, then repoint both IVR routes to the queue.
queue = client.get_queue(queue_number)
client.set_queue_agents(queue["Id"], extensions)
route = _queue_route(queue_number)
client.set_ivr_routes(ivr_id, key0_route=route, timeout_route=route)
schedule.set_holiday_activated(date, True)
logger.info(
"Activated holiday %s: queue %s agents=%s, IVR %s -> queue",
date,
queue_number,
extensions,
ivr_number,
)
return {
"action": "activate",
"date": date,
"queue": str(queue_number),
"ivr": str(ivr_number),
"agents": extensions,
}
def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
holiday = schedule.get_holiday(date)
if holiday is None:
logger.info("No holiday record for %s — nothing to deactivate", date)
return {"action": "deactivate", "date": date, "skipped": "no_record"}
if not holiday.get("activated"):
logger.info("Holiday %s not activated — no-op", date)
return {"action": "deactivate", "date": date, "skipped": "not_active"}
queue_number = schedule.get_holiday_queue()
ivr_number = schedule.get_ivr_number()
captured = schedule.get_captured_ivr_routes() or {}
client = _make_client()
ivr = client.get_ivr(ivr_number)
ivr_id = ivr["Id"]
live = client.extract_ivr_routes(ivr)
# Only restore a route if it currently points at the queue; otherwise leave
# whatever destination it has now (it was changed outside this flow).
def _restore(which: str) -> dict | None:
live_route = live.get(which)
if client.route_to_extension(live_route) == str(queue_number):
# Restore the captured pre-holiday route; if it was lost, leave the
# live route in place rather than blanking the IVR destination.
return captured.get(which) or live_route
return live_route
client.set_ivr_routes(
ivr_id,
key0_route=_restore("key0"),
timeout_route=_restore("timeout"),
)
queue = client.get_queue(queue_number)
client.set_queue_agents(queue["Id"], [])
schedule.set_captured_ivr_routes(None)
schedule.set_holiday_activated(date, False)
logger.info(
"Deactivated holiday %s: restored IVR %s, cleared queue %s",
date,
ivr_number,
queue_number,
)
return {
"action": "deactivate",
"date": date,
"queue": str(queue_number),
"ivr": str(ivr_number),
}
def handler(event, context):
action = event.get("action")
date = event.get("date")
logger.info("Holiday router invoked: action=%s date=%s", action, date)
if not date:
return {"error": True, "reason": "missing_date"}
schedule = ShiftSchedule()
try:
if action == "activate":
result = _activate(schedule, date)
elif action == "deactivate":
result = _deactivate(schedule, date)
else:
return {"error": True, "reason": "unknown_action", "action": action}
except Exception:
logger.exception("Holiday router failed for action=%s date=%s", action, date)
return {"error": True, "action": action, "date": date}
logger.info("Result: %s", json.dumps(result, default=str))
return result

View file

@ -0,0 +1,2 @@
boto3>=1.43.27
requests>=2.34.2

View file

@ -70,6 +70,14 @@ SHIFT_LABELS = {
"night": "Night (5pm–8am)",
}
HOLIDAY_BADGE = ":palm_tree: *Holiday*"
def _format_multiplier(multiplier) -> str:
"""Render a pay multiplier as e.g. ``1.5x`` (dropping a trailing ``.0``)."""
text = f"{float(multiplier):g}"
return f"{text}x"
def _format_shift_line(
date: datetime,
@ -92,6 +100,37 @@ def _format_shift_line(
return f":white_circle: {day_label} — {name} (Ext {ext})"
def _format_holiday_line(date: datetime, is_today: bool, ctx: dict) -> str:
"""Render a holiday day-shift line from a ``get_shift_context`` result.
Shows the Holiday badge, the holiday label, the pay multiplier, every
assignee, and how many slots remain open. Holidays are day-only
(08:00–17:00 ET), so the line is rendered independently of the night row.
"""
day_label = date.strftime("%a %b %-d")
if is_today:
day_label = f"*{day_label} (today)*"
label = ctx.get("label") or ""
name_part = f" — _{label}_" if label else ""
mult = _format_multiplier(ctx.get("multiplier", 1.5))
parts = [f":palm_tree: {day_label}{name_part} ({HOLIDAY_BADGE} · {mult})"]
assignees = ctx.get("assignees", [])
for a in assignees:
parts.append(f" • {a['name']} (Ext {a['extension']})")
open_slots = ctx.get("open_slots", 0)
if open_slots > 0:
plural = "s" if open_slots != 1 else ""
parts.append(
f" • :large_yellow_circle: *{open_slots} open slot{plural}*"
)
return "\n".join(parts)
def build_week_schedule(schedule, start_date: datetime | None = None) -> list[dict]:
"""Build Block Kit blocks showing the two-week schedule.
@ -121,8 +160,16 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
is_past = date_str < today_str
# Weekend day shift (8am–5pm)
if day_name in WEEKEND_DAYS:
# Holiday day-shift (08:00–17:00 ET) takes priority over the weekend
# day shift on its date. It can exist on any weekday, so it is checked
# independently of WEEKEND_DAYS.
ctx = schedule.get_shift_context(date_str, day_name, "day")
if ctx["kind"] == "holiday":
lines.append(_format_holiday_line(date, is_today, ctx))
if ctx["open_slots"] > 0 and not is_past:
open_shifts.append((date_str, "day"))
elif day_name in WEEKEND_DAYS:
# Regular weekend day shift (8am–5pm)
ext, name, source = schedule.resolve_shift(date_str, day_name, "day")
lines.append(_format_shift_line(date, ext, name, source, is_today, "day"))
if source == "available" and not is_past:
@ -155,7 +202,17 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
for date_str, shift_type in open_shifts:
dt = datetime.strptime(date_str, "%Y-%m-%d")
day_name = dt.strftime("%A")
label = f" {SHIFT_LABELS[shift_type]}" if day_name in WEEKEND_DAYS else ""
is_holiday = (
shift_type == "day"
and schedule.get_shift_context(date_str, day_name, "day")["kind"]
== "holiday"
)
if is_holiday:
label = " Holiday"
elif day_name in WEEKEND_DAYS:
label = f" {SHIFT_LABELS[shift_type]}"
else:
label = ""
action_suffix = "_day" if shift_type == "day" else ""
elements.append(
{
@ -254,6 +311,118 @@ def build_swap_resolved_blocks(text: str) -> list[dict]:
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_holiday_added_blocks(
date_str: str,
label: str,
slots: int,
multiplier,
date_label: str = "",
) -> list[dict]:
"""Build the channel post announcing a newly scheduled holiday.
Mirrors :func:`build_release_announcement_blocks`: a header, an optional
context line, then a section describing the holiday day-shift (08:00–17:00
ET), its open slots, and the pay multiplier so people know to pick it up.
Args:
date_str: the holiday date, ``YYYY-MM-DD``.
label: the holiday name, e.g. ``"Independence Day"``.
slots: number of concurrent on-call slots offered.
multiplier: pay multiplier (Decimal/float/str), e.g. ``1.5``.
date_label: human date override; defaults to the formatted ``date_str``.
"""
dt = datetime.strptime(date_str, "%Y-%m-%d")
human_date = date_label or dt.strftime("%A, %b %-d")
mult = _format_multiplier(multiplier)
plural = "s" if slots != 1 else ""
blocks: list[dict] = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"Holiday Added — {label}"},
},
{
"type": "context",
"elements": [{"type": "mrkdwn", "text": human_date}],
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"{HOLIDAY_BADGE} day shift (8am–5pm) on *{human_date}* — "
f"*{slots} slot{plural}* open at *{mult}* pay. "
"Use `/oncall` to pick it up."
),
},
},
]
return blocks
def build_pickup_request_blocks(
requester_slack: str,
requester_name: str,
date_str: str,
shift_type: str,
requester_ext: str,
is_holiday: bool = False,
) -> list[dict]:
"""Build the interactive Approve / Deny message DMed to admins.
Mirrors :func:`build_swap_request_blocks`. Used for *late* pickups — a
request to take a shift after it has already started — which require admin
approval. The action_ids encode the date, shift type, and requester
extension so the first admin to approve resolves the correct request:
``pickup_approve_<date>[_day]_<ext>`` / ``pickup_deny_<date>[_day]_<ext>``.
"""
dt = datetime.strptime(date_str, "%Y-%m-%d")
day_label = dt.strftime("%A, %b %-d")
if is_holiday:
type_label = f" ({HOLIDAY_BADGE})"
elif dt.strftime("%A") in WEEKEND_DAYS:
type_label = f" ({SHIFT_LABELS.get(shift_type, shift_type)})"
else:
type_label = ""
action_suffix = "_day" if shift_type == "day" else ""
action_tail = f"{date_str}{action_suffix}_{requester_ext}"
return [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"<@{requester_slack}> ({requester_name}, Ext {requester_ext}) "
f"wants to pick up the *{day_label}*{type_label} shift, which "
"has already started. Approve to assign it to them."
),
},
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve"},
"action_id": f"pickup_approve_{action_tail}",
"style": "primary",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Deny"},
"action_id": f"pickup_deny_{action_tail}",
"style": "danger",
},
],
},
]
def build_pickup_resolved_blocks(text: str) -> list[dict]:
"""A plain section used to replace a pickup-approval DM's buttons once resolved."""
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_help_blocks(is_admin: bool = False) -> list[dict]:
"""Build help message blocks."""
text = (
@ -280,7 +449,11 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]:
"`/oncall admin clear <date>` — Remove override (revert to weekly)\n"
"`/oncall admin roster add <ext> <name>` — Add a roster entry\n"
"`/oncall admin roster remove <ext>` — Remove a roster entry\n"
"`/oncall admin roster rename <ext> <name>` — Rename an employee"
"`/oncall admin roster rename <ext> <name>` — Rename an employee\n"
"`/oncall admin holiday add <date> <slots> <label>` — Schedule a holiday day shift (8am–5pm)\n"
"`/oncall admin holiday add <date> <slots> x<mult> <label>` — …with a custom pay multiplier\n"
"`/oncall admin holiday remove <date>` — Remove a scheduled holiday\n"
"`/oncall admin holiday list` — List upcoming holidays"
)
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
@ -348,20 +521,33 @@ def build_pay_summary_blocks(
Args:
week_label: e.g. "Mar 30 to Apr 5"
pay_breakdown: list of {date, day, name, extension, rate} per shift
pay_breakdown: list of {date, day, name, extension, rate} per shift. A
holiday shift may also carry ``is_holiday`` (bool) and
``multiplier`` (e.g. 1.5); ``rate`` is the effective (already
multiplied) pay for that shift.
total_by_person: {name: {shifts: int, total: float, extension: str, rate: float}}
"""
lines = []
for entry in pay_breakdown:
lines.append(
f"{entry['day']} {entry['date_label']} — {entry['name']} (Ext {entry['extension']}) — ${entry['rate']:.2f}"
line = (
f"{entry['day']} {entry['date_label']} — {entry['name']} "
f"(Ext {entry['extension']}) — ${entry['rate']:.2f}"
)
if entry.get("is_holiday"):
mult = _format_multiplier(entry.get("multiplier", 1.5))
line += f" ({HOLIDAY_BADGE} · {mult})"
lines.append(line)
lines.append("\n*Totals:*")
for name, info in sorted(total_by_person.items()):
rate_note = f" @ ${info['rate']:.2f}/shift" if info.get("rate") else ""
holiday_note = ""
if info.get("holiday_shifts"):
hs = info["holiday_shifts"]
holiday_note = f" _(incl. {hs} holiday)_"
lines.append(
f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''}{rate_note} — *${info['total']:.2f}*"
f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''}"
f"{rate_note}{holiday_note} — *${info['total']:.2f}*"
)
blocks = [

View file

@ -1,14 +1,17 @@
"""DynamoDB-backed shift schedule management.
Single-table design:
PK=ROSTER SK=<extension> — employee roster entry
PK=WEEKLY SK=<DayName> — default weekly schedule
PK=OVERRIDE SK=<YYYY-MM-DD> — date-specific override (or OPEN)
PK=CONFIG SK=CONFIG — global settings
PK=ROSTER SK=<extension> — employee roster entry
PK=WEEKLY SK=<DayName> — default weekly schedule
PK=OVERRIDE SK=<YYYY-MM-DD> — date-specific override (or OPEN)
PK=HOLIDAY SK=<YYYY-MM-DD> — holiday day-shift record (multi-slot)
PK=PICKUP_REQUEST SK=<date>[-DAY]#<ext> — late-pickup approval request
PK=CONFIG SK=CONFIG — global settings
"""
import os
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
import boto3
@ -18,6 +21,11 @@ EASTERN = ZoneInfo("America/New_York")
WEEKEND_DAYS = {"Saturday", "Sunday"}
FALLBACK_EXTENSION = "100"
# Defaults used when CONFIG omits the holiday-feature settings.
DEFAULT_HOLIDAY_MULTIPLIER = Decimal("1.5")
DEFAULT_HOLIDAY_QUEUE = "802"
DEFAULT_IVR_NUMBER = "800"
def determine_shift_type(now: datetime | None = None) -> str:
"""Return the currently active shift type: 'day' or 'night'.
@ -184,33 +192,308 @@ class ShiftSchedule:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.delete_item(Key={"PK": "SWAP", "SK": sk})
# ── Late-pickup requests ─────────────────────────────────────────────
@staticmethod
def _pickup_sk(date_str: str, shift_type: str, extension: str) -> str:
prefix = f"{date_str}-DAY" if shift_type == "day" else date_str
return f"{prefix}#{extension}"
def create_pickup_request(
self,
date_str: str,
shift_type: str,
requester: dict,
expires_at: int,
is_holiday: bool = False,
) -> None:
"""Create (or supersede) a late-pickup approval request.
One request per (shift, requester) — keyed by SK ``<date>[-DAY]#<ext>``
so each requester's request is independent. ``expires_at`` is an epoch
timestamp (the shift's end) used for DynamoDB TTL.
"""
self.table.put_item(
Item={
"PK": "PICKUP_REQUEST",
"SK": self._pickup_sk(date_str, shift_type, requester["extension"]),
"shift_type": shift_type,
"is_holiday": is_holiday,
"status": "pending",
"requester_ext": requester["extension"],
"requester_name": requester["name"],
"requester_slack": requester.get("slack_user_id", ""),
"created_at": datetime.now(EASTERN).isoformat(),
"expires_at": expires_at,
}
)
def get_pickup_request(
self, date_str: str, shift_type: str, extension: str
) -> dict | None:
resp = self.table.get_item(
Key={
"PK": "PICKUP_REQUEST",
"SK": self._pickup_sk(date_str, shift_type, extension),
}
)
return resp.get("Item")
def clear_pickup_request(
self, date_str: str, shift_type: str, extension: str
) -> None:
self.table.delete_item(
Key={
"PK": "PICKUP_REQUEST",
"SK": self._pickup_sk(date_str, shift_type, extension),
}
)
def approve_pickup_request(
self, date_str: str, shift_type: str, extension: str
) -> bool:
"""Atomically transition a request pending -> approved.
Returns True only for the first caller to win the transition, so two
admins approving the same request concurrently can't both proceed.
Returns False if the request is missing or already resolved.
"""
try:
self.table.update_item(
Key={
"PK": "PICKUP_REQUEST",
"SK": self._pickup_sk(date_str, shift_type, extension),
},
UpdateExpression="SET #s = :approved",
ConditionExpression="#s = :pending",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={
":approved": "approved",
":pending": "pending",
},
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
# ── Holidays ────────────────────────────────────────────────────────
def get_holiday(self, date_str: str) -> dict | None:
"""Return the holiday record for a date, or None.
A holiday is a day-only shift (08:00-17:00 ET), one record per date,
supporting multiple concurrent assignees (slots).
"""
resp = self.table.get_item(Key={"PK": "HOLIDAY", "SK": date_str})
return resp.get("Item")
def create_holiday(
self,
date_str: str,
slots: int,
label: str,
created_by: str = "",
multiplier: Decimal | float | str | None = None,
schedule_names: list[str] | None = None,
) -> bool:
"""Create a holiday record for a date. Returns False if one exists.
``multiplier`` defaults to CONFIG.holiday_multiplier. ``assignees`` is an
empty Map keyed by extension; ``activated`` starts False.
"""
if multiplier is None:
multiplier = self.get_holiday_multiplier()
else:
multiplier = Decimal(str(multiplier))
try:
self.table.put_item(
Item={
"PK": "HOLIDAY",
"SK": date_str,
"slots": int(slots),
"assignees": {},
"multiplier": multiplier,
"label": label,
"created_at": datetime.now(EASTERN).isoformat(),
"created_by": created_by,
"activated": False,
"schedule_names": schedule_names or [],
},
ConditionExpression="attribute_not_exists(PK)",
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
def remove_holiday(self, date_str: str) -> None:
self.table.delete_item(Key={"PK": "HOLIDAY", "SK": date_str})
def claim_holiday_slot(self, date_str: str, extension: str, name: str) -> bool:
"""Atomically add an assignee to a holiday slot.
Succeeds only if the extension is not already an assignee AND the slot
count is below ``slots``. Returns False on a full or duplicate slot,
or if the holiday does not exist.
"""
try:
self.table.update_item(
Key={"PK": "HOLIDAY", "SK": date_str},
UpdateExpression="SET assignees.#ext = :a",
ConditionExpression=(
"attribute_exists(PK) "
"AND attribute_not_exists(assignees.#ext) "
"AND size(assignees) < slots"
),
ExpressionAttributeNames={"#ext": extension},
ExpressionAttributeValues={
":a": {
"name": name,
"claimed_at": datetime.now(EASTERN).isoformat(),
}
},
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
def release_holiday_slot(self, date_str: str, extension: str) -> bool:
"""Atomically remove an assignee from a holiday. False if not assigned."""
try:
self.table.update_item(
Key={"PK": "HOLIDAY", "SK": date_str},
UpdateExpression="REMOVE assignees.#ext",
ConditionExpression="attribute_exists(assignees.#ext)",
ExpressionAttributeNames={"#ext": extension},
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
def swap_holiday_assignee(
self, date_str: str, from_ext: str, to_ext: str, to_name: str
) -> bool:
"""Atomically replace one holiday assignee with another.
Succeeds only if ``from_ext`` is currently assigned and ``to_ext`` is
not. The slot count is preserved, so no ``size`` guard is needed.
"""
try:
self.table.update_item(
Key={"PK": "HOLIDAY", "SK": date_str},
UpdateExpression="REMOVE assignees.#from SET assignees.#to = :a",
ConditionExpression=(
"attribute_exists(assignees.#from) "
"AND attribute_not_exists(assignees.#to)"
),
ExpressionAttributeNames={"#from": from_ext, "#to": to_ext},
ExpressionAttributeValues={
":a": {
"name": to_name,
"claimed_at": datetime.now(EASTERN).isoformat(),
}
},
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
def set_holiday_activated(self, date_str: str, activated: bool) -> None:
self.table.update_item(
Key={"PK": "HOLIDAY", "SK": date_str},
UpdateExpression="SET activated = :v",
ExpressionAttributeValues={":v": activated},
)
# ── Resolution ──────────────────────────────────────────────────────
def resolve_shift(
def get_shift_context(
self, date_str: str, day_name: str, shift_type: str = "night"
) -> tuple[str, str, str]:
"""Return (extension, name, source) for a given date and shift type.
) -> dict:
"""Return full context for a shift, with HOLIDAY > OVERRIDE > WEEKLY.
shift_type: 'night' (after-hours) or 'day' (weekend 8am-5pm)
source is one of: 'override', 'available', 'weekly'.
'available' means no one is assigned — the fallback extension handles
it silently on the 3CX side, but users just see it as open for pickup.
Returns a dict with:
kind: 'holiday' | 'override' | 'available' | 'weekly'
assignees: list of {extension, name} currently assigned
slots: total slots (1 for non-holiday shifts)
open_slots: slots minus filled slots
multiplier: pay multiplier (Decimal('1') for regular shifts)
label: human label (holiday name, or '')
A holiday only applies to the 'day' shift type; night shifts on a
holiday date fall through to override/weekly resolution.
"""
if shift_type == "day":
holiday = self.get_holiday(date_str)
if holiday:
assignees_map = holiday.get("assignees", {}) or {}
assignees = [
{"extension": ext, "name": data.get("name", "Unknown")}
for ext, data in assignees_map.items()
]
slots = int(holiday.get("slots", 0))
return {
"kind": "holiday",
"assignees": assignees,
"slots": slots,
"open_slots": max(slots - len(assignees), 0),
"multiplier": holiday.get("multiplier", DEFAULT_HOLIDAY_MULTIPLIER),
"label": holiday.get("label", ""),
}
override = self.get_override(date_str, shift_type)
if override:
if override["extension"] == "OPEN":
return (FALLBACK_EXTENSION, "Available", "available")
return (override["extension"], override["name"], "override")
return self._regular_context("available", [])
return self._regular_context(
"override",
[{"extension": override["extension"], "name": override["name"]}],
)
weekly = self.get_weekly_schedule()
weekly_key = f"{day_name}-Day" if shift_type == "day" else day_name
if weekly_key in weekly:
item = weekly[weekly_key]
if item["extension"] == FALLBACK_EXTENSION:
return (FALLBACK_EXTENSION, "Available", "available")
return (item["extension"], item.get("name", "Unknown"), "weekly")
return self._regular_context("available", [])
return self._regular_context(
"weekly",
[
{
"extension": item["extension"],
"name": item.get("name", "Unknown"),
}
],
)
return (FALLBACK_EXTENSION, "Available", "available")
return self._regular_context("available", [])
@staticmethod
def _regular_context(kind: str, assignees: list[dict]) -> dict:
return {
"kind": kind,
"assignees": assignees,
"slots": 1,
"open_slots": 1 - len(assignees),
"multiplier": Decimal("1"),
"label": "",
}
def resolve_shift(
self, date_str: str, day_name: str, shift_type: str = "night"
) -> tuple[str, str, str]:
"""Return (extension, name, source) for a given date and shift type.
Backward-compatible wrapper over :meth:`get_shift_context`. For
multi-assignee holidays it returns the first assignee (or 'Available'
when no slot is filled). ``source`` is 'override', 'available',
'weekly', or 'holiday'.
"""
ctx = self.get_shift_context(date_str, day_name, shift_type)
assignees = ctx["assignees"]
if not assignees:
return (FALLBACK_EXTENSION, "Available", "available")
first = assignees[0]
return (first["extension"], first["name"], ctx["kind"])
# ── Schedule post tracking ───────────────────────────────────────────
@ -251,6 +534,51 @@ class ShiftSchedule:
config = self.get_config()
return config.get("admin_users", [])
def get_holiday_multiplier(self) -> Decimal:
"""Holiday pay multiplier from CONFIG (default 1.5)."""
config = self.get_config()
value = config.get("holiday_multiplier")
if value is None:
return DEFAULT_HOLIDAY_MULTIPLIER
return Decimal(str(value))
def get_holiday_queue(self) -> str:
"""3CX queue extension used during holidays (default 802)."""
config = self.get_config()
return str(config.get("holiday_queue", DEFAULT_HOLIDAY_QUEUE))
def get_ivr_number(self) -> str:
"""3CX IVR extension repointed during holidays (default 800)."""
config = self.get_config()
return str(config.get("ivr_number", DEFAULT_IVR_NUMBER))
def get_captured_ivr_routes(self) -> dict | None:
"""Return the IVR routes captured at holiday activation, or None.
Shape mirrors ThreeCXClient.extract_ivr_routes:
``{"key0": <route>, "timeout": <route>}``.
"""
config = self.get_config()
return config.get("captured_ivr_routes")
def set_captured_ivr_routes(self, routes: dict | None) -> None:
"""Persist (or clear, with None) the original IVR routes on CONFIG.
Stored so the holiday router can restore the IVR to its pre-holiday
destination at deactivation regardless of which Lambda invocation runs.
"""
if routes is None:
self.table.update_item(
Key={"PK": "CONFIG", "SK": "CONFIG"},
UpdateExpression="REMOVE captured_ivr_routes",
)
else:
self.table.update_item(
Key={"PK": "CONFIG", "SK": "CONFIG"},
UpdateExpression="SET captured_ivr_routes = :r",
ExpressionAttributeValues={":r": routes},
)
def add_roster_entry(self, extension: str, name: str) -> bool:
"""Add a new roster entry. Returns False if extension already exists."""
try:

View file

@ -40,7 +40,7 @@ class ThreeCXClient:
data = resp.json()
token = data.get("Token", {}).get("access_token") or data.get("access_token")
if not token:
raise ValueError(f"Failed to get access token. Response: {data}")
raise ValueError("Failed to get access token from 3CX login response")
self.session.headers.update({"Authorization": f"Bearer {token}"})
logger.info("Authenticated to 3CX via user credentials")
@ -128,3 +128,70 @@ class ThreeCXClient:
def update_queue_forwarding(self, queue_id: int, closed: str, holiday: str):
return self._update_forwarding("Queues", queue_id, closed, holiday)
def set_queue_agents(self, queue_id: int, extensions: list[str]):
"""Replace the agent membership of a queue with the given extensions.
Membership is set wholesale (not merged): the queue's ``Agents`` list is
overwritten with one entry per extension. Used by the holiday router to
point queue 802 at the holiday assignees (or the fallback extension).
"""
payload = {"Agents": [{"Number": str(ext)} for ext in extensions]}
resp = self.session.patch(
f"{self.base_url}/xapi/v1/Queues({queue_id})",
json=payload,
)
resp.raise_for_status()
logger.info("Set queue %s agents to %s", queue_id, extensions)
return resp.status_code
# ── IVR (auto-attendant) routing ─────────────────────────────────────
def get_ivr(self, extension_number: str) -> dict:
"""Fetch IVR (auto-attendant) config by extension number."""
resp = self.session.get(
f"{self.base_url}/xapi/v1/IVRs/Pbx.GetByNumber(number='{extension_number}')",
)
resp.raise_for_status()
return resp.json()
def set_ivr_routes(self, ivr_id: int, key0_route: dict, timeout_route: dict):
"""Repoint an IVR's key-0 option and no-input/timeout route.
``key0_route`` and ``timeout_route`` are 3CX Route objects (the shape
captured from :meth:`extract_ivr_routes`). The key-0 option is matched
within the IVR's ``Options`` list by ``Digit == "0"``; the timeout route
is written to the IVR's ``TimeoutForward`` field.
"""
payload = {
"Options": [{"Digit": "0", "Route": key0_route}],
"TimeoutForward": timeout_route,
}
resp = self.session.patch(
f"{self.base_url}/xapi/v1/IVRs({ivr_id})",
json=payload,
)
resp.raise_for_status()
logger.info("Set IVR %s key-0 and timeout routes", ivr_id)
return resp.status_code
@staticmethod
def extract_ivr_routes(ivr: dict) -> dict:
"""Pull the key-0 and timeout Route objects out of an IVR payload.
Returns ``{"key0": <route|None>, "timeout": <route|None>}``. The key-0
route is the ``Route`` of the option whose ``Digit`` is ``"0"``.
"""
key0 = None
for option in ivr.get("Options", []) or []:
if str(option.get("Digit")) == "0":
key0 = option.get("Route")
break
return {"key0": key0, "timeout": ivr.get("TimeoutForward")}
@staticmethod
def route_to_extension(route: dict | None) -> str | None:
"""Return the destination extension Number of a Route, or None."""
if not route:
return None
return route.get("Number")

View file

@ -10,6 +10,31 @@ fine and still supported.
---
## v1.11.0 — June 15, 2026
**Holiday coverage and last-minute pickups.** Two related additions:
- **Holidays now have their own day shift.** An admin can schedule a holiday for
any date with `/oncall admin holiday add` — a single daytime shift (8am-5pm ET)
that can have more than one person on it. The bot shows the holiday in the
schedule with a button to grab an open slot, and holiday slots can be set to
pay a higher rate (1.5x by default). On the holiday itself the after-hours
phones automatically route to whoever claimed a slot, from 8am to 5pm, and
switch back to normal at 5pm — no one has to touch the phone system. If a
holiday goes by with nobody signed up, calls fall back to the default
extension. Admins can also list upcoming holidays and remove one they no longer
need. Holidays take priority over both regular overrides and the standing
weekly schedule for that date.
- **Picking up a shift after it has already started now needs an admin to say
yes.** Before, you could only pick up a shift that hadn't started. Now, if a
shift is already underway (8am for a day/holiday shift, 5pm for a night shift)
but hasn't ended yet, asking to pick it up sends every admin an Approve/Deny
message — the same way swaps are confirmed. The first admin to approve wins,
and once approved the shift is handed over and the phones are repointed right
away. Picking up a shift that hasn't started yet still happens instantly, and
a shift that has already ended can't be picked up at all. This applies to both
regular and holiday shifts.
## v1.10.1 — June 15, 2026
**Payroll summary emails are sending again.** The automated weekly pay summary

File diff suppressed because it is too large Load diff

View file

@ -30,37 +30,107 @@ DAY_ORDER = [
]
def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="night"):
"""Resolve a shift and add it to the pay breakdown if assigned."""
date_str = date.strftime("%Y-%m-%d")
ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type)
def _record_pay_line(
breakdown, totals, *, date, day_label, name, ext, effective, base_rate, is_holiday
):
"""Append a single breakdown line and roll it into per-employee totals.
if source == "available" or ext == FALLBACK_EXTENSION:
return
rate = schedule.get_shift_rate(ext)
label_suffix = " (Day)" if shift_type == "day" else ""
breakdown.append(
{
"date": date_str,
"date_label": date.strftime("%b %-d"),
"day": f"{day_name[:3]}{label_suffix}",
"name": name,
"extension": ext,
"rate": Decimal(str(rate)),
}
)
``effective`` is the dollar value actually earned for this one shift
(base_rate * multiplier for holidays, base_rate otherwise). To stay
compatible with the existing Slack ``build_pay_summary_blocks`` consumer —
which treats ``rate`` as the already-multiplied per-shift pay — the line's
``rate`` is set to ``effective``; ``base_rate`` is preserved separately and
``amount`` mirrors ``effective``. All money is stored as ``Decimal``.
"""
line = {
"date": date.strftime("%Y-%m-%d"),
"date_label": date.strftime("%b %-d"),
"day": day_label,
"name": name,
"extension": ext,
"rate": Decimal(str(effective)),
"base_rate": Decimal(str(base_rate)),
"amount": Decimal(str(effective)),
}
breakdown.append(line)
if name not in totals:
totals[name] = {
"shifts": 0,
"total": Decimal("0"),
"extension": ext,
"rate": Decimal(str(rate)),
"rate": Decimal(str(base_rate)),
"holiday_shifts": 0,
}
totals[name]["shifts"] += 1
totals[name]["total"] += Decimal(str(rate))
totals[name]["total"] += Decimal(str(effective))
if is_holiday:
totals[name]["holiday_shifts"] = totals[name].get("holiday_shifts", 0) + 1
return line
def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="night"):
"""Resolve a shift and add it to the pay breakdown if assigned.
Holidays are day-only, multi-assignee shifts paid at base_rate * multiplier;
every assignee gets a distinct breakdown line tagged with the holiday
multiplier and label. Regular shifts have a single assignee at multiplier 1.
"""
date_str = date.strftime("%Y-%m-%d")
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
assignees = ctx.get("assignees") or []
if not assignees:
return
label_suffix = " (Day)" if shift_type == "day" else ""
day_label = f"{day_name[:3]}{label_suffix}"
if ctx["kind"] == "holiday":
multiplier = Decimal(str(ctx.get("multiplier", 1)))
holiday_label = ctx.get("label", "")
for assignee in assignees:
ext = assignee["extension"]
name = assignee["name"]
if ext == FALLBACK_EXTENSION:
continue
base_rate = Decimal(str(schedule.get_shift_rate(ext)))
effective = base_rate * multiplier
line = _record_pay_line(
breakdown,
totals,
date=date,
day_label=day_label,
name=name,
ext=ext,
effective=effective,
base_rate=base_rate,
is_holiday=True,
)
line["multiplier"] = Decimal(str(multiplier))
line["is_holiday"] = True
line["holiday_label"] = holiday_label
return
# Regular (override/weekly) shift — single assignee.
assignee = assignees[0]
ext = assignee["extension"]
name = assignee["name"]
if ext == FALLBACK_EXTENSION:
return
rate = Decimal(str(schedule.get_shift_rate(ext)))
_record_pay_line(
breakdown,
totals,
date=date,
day_label=day_label,
name=name,
ext=ext,
effective=rate,
base_rate=rate,
is_holiday=False,
)
def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict:
@ -72,9 +142,12 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
for i in range(7):
date = week_start + timedelta(days=i)
day_name = DAY_ORDER[i]
date_str = date.strftime("%Y-%m-%d")
# Weekend day shifts (8am–5pm)
if day_name in WEEKEND_DAYS:
# Day shifts (8am–5pm) exist on weekends and on holidays. Holidays are
# day-only and may land on a weekday, so check the day shift whenever a
# holiday record exists for the date, not just on weekend days.
if day_name in WEEKEND_DAYS or schedule.get_holiday(date_str):
_add_shift_to_pay(schedule, breakdown, totals, date, day_name, "day")
# After-hours (night) shift
@ -89,14 +162,48 @@ def _calculate_weekly_pay(schedule: ShiftSchedule, week_start: datetime) -> dict
def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
"""Build an HTML email body for the weekly pay summary."""
"""Build an HTML email body for the weekly pay summary.
Holiday shifts are rendered distinctly: a per-shift breakdown section
highlights holiday rows (shaded, showing the multiplier and label) and the
totals table flags any employee who worked a holiday during the week.
"""
breakdown = pay_record.get("breakdown", [])
totals = pay_record.get("totals", {})
holiday_names = {line["name"] for line in breakdown if line.get("is_holiday")}
totals_rows = ""
for name, info in sorted(pay_record["totals"].items()):
for name, info in sorted(totals.items()):
holiday_tag = (
' <span style="color:#b8860b;">&#9733; holiday</span>'
if name in holiday_names
else ""
)
totals_rows += (
f"<tr><td>{name}</td>"
f"<tr><td>{name}{holiday_tag}</td>"
f"<td>${info.get('rate', 0):.2f}</td><td><strong>${info['total']:.2f}</strong></td></tr>\n"
)
breakdown_rows = ""
for line in breakdown:
if line.get("is_holiday"):
mult = line.get("multiplier", Decimal("1"))
label = line.get("holiday_label", "") or "Holiday"
base = line.get("base_rate", line["rate"])
row_style = ' style="background:#fff8e1;"'
detail = f"{label} &mdash; ${base:.2f} &times; {mult:.2f}x"
else:
row_style = ""
detail = f"${line['rate']:.2f}"
breakdown_rows += (
f"<tr{row_style}><td>{line['date_label']}</td>"
f"<td>{line['day']}</td>"
f"<td>{line['name']}</td>"
f"<td>{detail}</td>"
f"<td><strong>${line['amount']:.2f}</strong></td></tr>\n"
)
return f"""<html>
<body style="font-family: Arial, sans-serif; color: #333;">
<h2>Bonus Pay Summary &mdash; {week_label}</h2>
@ -105,6 +212,12 @@ def _build_pay_email_html(week_label: str, pay_record: dict) -> str:
<tr style="background: #f0f0f0;"><th>Name</th><th>Rate</th><th>Total</th></tr>
{totals_rows}</table>
<h3>Shift Breakdown</h3>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
<tr style="background: #f0f0f0;"><th>Date</th><th>Day</th><th>Name</th><th>Detail</th><th>Amount</th></tr>
{breakdown_rows}</table>
<p style="color: #888; font-size: 12px;">Holiday shifts are shaded and paid at the listed multiplier.</p>
<p style="color: #888; font-size: 12px;">This is an automated report from Sea Haven Industries.</p>
</body>
</html>"""

View file

@ -90,6 +90,10 @@ Resources:
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
QUEUE_NUMBER: !Ref QueueNumber
TZ: !Ref Timezone
# Holiday scheduling: per-holiday one-off schedules target the router,
# passing the scheduler exec role; inline activation invokes it directly.
HOLIDAY_ROUTER_ARN: !GetAtt HolidayRouterFunction.Arn
HOLIDAY_SCHEDULER_ROLE_ARN: !GetAtt HolidaySchedulerExecutionRole.Arn
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ShiftTable
@ -99,6 +103,30 @@ Resources:
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*"
# Manage the per-holiday EventBridge Scheduler one-off schedules
# (08:00 activate / 17:00 deactivate of the holiday router).
- Effect: Allow
Action:
- scheduler:CreateSchedule
- scheduler:DeleteSchedule
- scheduler:GetSchedule
# Predictable names (holiday-activate-/holiday-deactivate-<date>)
# in the default group — scope to those rather than all schedules.
Resource:
- !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/default/holiday-*"
# PassRole only for the holiday scheduler exec role, and only when
# handed to EventBridge Scheduler.
- Effect: Allow
Action: iam:PassRole
Resource: !GetAtt HolidaySchedulerExecutionRole.Arn
Condition:
StringEquals:
iam:PassedToService: scheduler.amazonaws.com
# Inline activation (holiday added mid-window) invokes the router now.
# Unqualified ARN only — we invoke the base function, no alias/version.
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt HolidayRouterFunction.Arn
Events:
SlackEvents:
Type: HttpApi
@ -254,6 +282,92 @@ Resources:
Description: "Update 3CX queue at 5pm EDT weekends"
Enabled: true
# --- Holiday Router (repoints 3CX IVR/queue for a holiday day-shift) ---
# Invoked with {"action": "activate"|"deactivate", "date": "<YYYY-MM-DD>"} at
# 08:00 ET (activate) and 17:00 ET (deactivate) for each holiday date. Both
# operations are idempotent. No standing schedule here — invocation is driven
# per-holiday-date (the holiday record gates the work; off-days are no-ops).
HolidayRouterFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: afterhours-holiday-router
Handler: app.handler
CodeUri: src/holiday-router/
Layers:
- !Ref SharedLayer
Timeout: 60
Environment:
Variables:
SHIFT_TABLE: !Ref ShiftTable
TCX_SECRET_PREFIX: afterhours-shift-manager/3cx-
TZ: !Ref Timezone
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ShiftTable
- Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource:
- !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:afterhours-shift-manager/*"
# Lambda error alarm for the holiday router. Mirrors the account-wide
# operational convention (Lambda-Errors-<fn>, threshold 1 over one 5-min
# period, Sum, missing=notBreaching) and pages the same site-alerts SNS topic
# → AWS Chatbot → Slack as the other afterhours-* functions.
HolidayRouterErrorAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub "Lambda-Errors-${HolidayRouterFunction}"
AlarmDescription: "Holiday router Lambda reported one or more errors"
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref HolidayRouterFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts"
# EventBridge Scheduler execution role. The slack-bot creates one-off
# schedules per holiday date (08:00 activate / 17:00 deactivate); Scheduler
# assumes this role to invoke the holiday router. Auto-named (no RoleName) so
# the deploy's CAPABILITY_IAM suffices — cd-sam does not pass
# CAPABILITY_NAMED_IAM. The permissions boundary is REQUIRED: the scoped
# github-cfn-execution-role gates iam:CreateRole/PutRolePolicy on roles
# carrying exactly this boundary, so the CI deploy is denied without it.
HolidaySchedulerExecutionRole:
Type: AWS::IAM::Role
Properties:
PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: scheduler.amazonaws.com
Action: sts:AssumeRole
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
# Only schedules this stack creates (holiday-* in the default group)
# may assume the role — not any schedule in the account.
ArnLike:
aws:SourceArn: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/default/holiday-*"
Policies:
- PolicyName: invoke-holiday-router
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt HolidayRouterFunction.Arn
# --- Release Notifier (invoked by release.yaml on minor/major releases) ---
ReleaseNotifierFunction:
Type: AWS::Serverless::Function
@ -343,6 +457,12 @@ Resources:
LogGroupName: !Sub "/aws/lambda/${RingSchedulerFunction}"
RetentionInDays: 60
HolidayRouterLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${HolidayRouterFunction}"
RetentionInDays: 60
ReleaseNotifierLogGroup:
Type: AWS::Logs::LogGroup
Properties:
@ -363,6 +483,11 @@ Outputs:
Value: !GetAtt RosterSyncFunction.Arn
RingSchedulerFunctionArn:
Value: !GetAtt RingSchedulerFunction.Arn
HolidayRouterFunctionArn:
Value: !GetAtt HolidayRouterFunction.Arn
HolidaySchedulerExecutionRoleArn:
Description: Role EventBridge Scheduler assumes to invoke the holiday router; the slack-bot passes this when creating per-holiday schedules
Value: !GetAtt HolidaySchedulerExecutionRole.Arn
ReleaseNotifierFunctionArn:
Value: !GetAtt ReleaseNotifierFunction.Arn
ReleaseNotifyInvokeRoleArn:

View file

@ -100,6 +100,25 @@ class Seeder:
def config(self, **kwargs):
self.table.put_item(Item={"PK": "CONFIG", "SK": "CONFIG", **kwargs})
def holiday(self, date_str, slots=2, label="Holiday", assignees=None, **extra):
from decimal import Decimal
self.table.put_item(
Item={
"PK": "HOLIDAY",
"SK": date_str,
"slots": slots,
"assignees": assignees or {},
"multiplier": Decimal("1.5"),
"label": label,
"created_at": "2026-06-01T00:00:00-04:00",
"created_by": "",
"activated": False,
"schedule_names": [],
**extra,
}
)
def schedule_post(self, channel, message_ts, week_start="2026-06-01"):
self.table.put_item(
Item={

View file

@ -0,0 +1,22 @@
"""Load src/holiday-router/app.py under a unique module name."""
import importlib.util
import pathlib
import sys
import pytest
_ROOT = pathlib.Path(__file__).resolve().parents[2]
def _load(name, relpath):
spec = importlib.util.spec_from_file_location(name, _ROOT / relpath)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def holidayrouter_app():
return _load("holidayrouter_app", "src/holiday-router/app.py")

View file

@ -0,0 +1,273 @@
"""Tests for the holiday-router Lambda handler.
DynamoDB is moto-backed (via the shared ``schedule``/``seed`` fixtures); 3CX is
mocked at the HTTP layer with ``responses`` so the real ThreeCXClient code runs.
``get_secret`` is patched so ``_make_client`` resolves the 3CX domain to the test
host without reaching AWS Secrets Manager.
"""
import json
import pytest
import responses
HOL = "2026-07-04"
BASE = "https://test.3cx.us"
QUEUE = "802"
IVR = "800"
ORIG_KEY0 = {"To": "Extension", "Number": "101", "External": ""}
ORIG_TIMEOUT = {"To": "Extension", "Number": "102", "External": ""}
@pytest.fixture
def env(monkeypatch):
monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-")
@pytest.fixture(autouse=True)
def secrets(holidayrouter_app, monkeypatch):
# domain -> test host; client-id/secret -> anything (oauth token is stubbed).
def fake(secret_id):
if secret_id.endswith("domain"):
return "test.3cx.us"
return "x"
monkeypatch.setattr(holidayrouter_app, "get_secret", fake)
def _stub_oauth():
responses.add(
responses.POST,
f"{BASE}/connect/token",
json={"access_token": "tok"},
status=200,
)
def _stub_ivr_get(key0=ORIG_KEY0, timeout=ORIG_TIMEOUT, ivr_id=7):
responses.add(
responses.GET,
f"{BASE}/xapi/v1/IVRs/Pbx.GetByNumber(number='{IVR}')",
json={
"Id": ivr_id,
"Number": IVR,
"Options": [{"Digit": "0", "Route": key0}],
"TimeoutForward": timeout,
},
status=200,
)
def _stub_queue_get(queue_id=9):
responses.add(
responses.GET,
f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='{QUEUE}')",
json={"Id": queue_id, "Number": QUEUE},
status=200,
)
def _stub_patches(ivr_id=7, queue_id=9):
ivr_patch = responses.add(
responses.PATCH, f"{BASE}/xapi/v1/IVRs({ivr_id})", status=200
)
queue_patch = responses.add(
responses.PATCH, f"{BASE}/xapi/v1/Queues({queue_id})", status=200
)
return ivr_patch, queue_patch
def _holiday_assignees(seed):
return {
"114": {"name": "Alice", "claimed_at": "2026-07-01T00:00:00-04:00"},
"115": {"name": "Bob", "claimed_at": "2026-07-01T00:00:00-04:00"},
}
# ── activate ─────────────────────────────────────────────────────────────
@responses.activate
def test_activate_captures_routes_sets_agents_repoints_ivr(
holidayrouter_app, schedule, seed, env
):
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed))
_stub_oauth()
_stub_ivr_get()
_stub_queue_get()
ivr_patch, queue_patch = _stub_patches()
result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None)
assert result["action"] == "activate"
assert set(result["agents"]) == {"114", "115"}
# Captured the live (pre-holiday) routes into CONFIG.
captured = schedule.get_captured_ivr_routes()
assert captured["key0"] == ORIG_KEY0
assert captured["timeout"] == ORIG_TIMEOUT
# Queue agents set to assignees.
qbody = json.loads(queue_patch.calls[0].request.body)
assert {a["Number"] for a in qbody["Agents"]} == {"114", "115"}
# Both IVR routes repointed to the queue.
ibody = json.loads(ivr_patch.calls[0].request.body)
assert ibody["Options"][0]["Digit"] == "0"
assert ibody["Options"][0]["Route"]["Number"] == QUEUE
assert ibody["Options"][0]["Route"]["To"] == "Queue"
assert ibody["TimeoutForward"]["Number"] == QUEUE
assert schedule.get_holiday(HOL)["activated"] is True
@responses.activate
def test_activate_no_assignees_uses_fallback(holidayrouter_app, schedule, seed, env):
seed.holiday(HOL, slots=2, assignees={})
_stub_oauth()
_stub_ivr_get()
_stub_queue_get()
_, queue_patch = _stub_patches()
result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None)
assert result["agents"] == ["100"]
qbody = json.loads(queue_patch.calls[0].request.body)
assert [a["Number"] for a in qbody["Agents"]] == ["100"]
@responses.activate
def test_activate_no_record_is_noop(holidayrouter_app, schedule, env):
result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None)
assert result["skipped"] == "no_record"
# No 3CX calls were made.
assert len(responses.calls) == 0
@responses.activate
def test_activate_already_active_is_noop(holidayrouter_app, schedule, seed, env):
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=True)
result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None)
assert result["skipped"] == "already_active"
assert len(responses.calls) == 0
@responses.activate
def test_activate_guard_does_not_recapture_when_already_queue(
holidayrouter_app, schedule, seed, env
):
# IVR already points at the queue (e.g. a prior run); a fresh activation
# (record somehow not flagged) must NOT overwrite the real originals.
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed))
schedule.set_captured_ivr_routes({"key0": ORIG_KEY0, "timeout": ORIG_TIMEOUT})
queue_route = {"To": "Queue", "Number": QUEUE, "External": ""}
_stub_oauth()
_stub_ivr_get(key0=queue_route, timeout=queue_route)
_stub_queue_get()
_stub_patches()
holidayrouter_app.handler({"action": "activate", "date": HOL}, None)
captured = schedule.get_captured_ivr_routes()
assert captured["key0"] == ORIG_KEY0
assert captured["timeout"] == ORIG_TIMEOUT
# ── deactivate ───────────────────────────────────────────────────────────
@responses.activate
def test_deactivate_restores_routes_and_clears_agents(
holidayrouter_app, schedule, seed, env
):
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=True)
schedule.set_captured_ivr_routes({"key0": ORIG_KEY0, "timeout": ORIG_TIMEOUT})
queue_route = {"To": "Queue", "Number": QUEUE, "External": ""}
_stub_oauth()
_stub_ivr_get(key0=queue_route, timeout=queue_route)
_stub_queue_get()
ivr_patch, queue_patch = _stub_patches()
result = holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None)
assert result["action"] == "deactivate"
# IVR restored to the captured originals.
ibody = json.loads(ivr_patch.calls[0].request.body)
assert ibody["Options"][0]["Route"] == ORIG_KEY0
assert ibody["TimeoutForward"] == ORIG_TIMEOUT
# Queue agents cleared.
qbody = json.loads(queue_patch.calls[0].request.body)
assert qbody["Agents"] == []
assert schedule.get_holiday(HOL)["activated"] is False
assert schedule.get_captured_ivr_routes() is None
@responses.activate
def test_deactivate_only_restores_routes_pointing_at_queue(
holidayrouter_app, schedule, seed, env
):
# key-0 was manually changed away from the queue after activation; leave it,
# only restore the timeout route which still points at the queue.
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=True)
schedule.set_captured_ivr_routes({"key0": ORIG_KEY0, "timeout": ORIG_TIMEOUT})
manual = {"To": "Extension", "Number": "199", "External": ""}
queue_route = {"To": "Queue", "Number": QUEUE, "External": ""}
_stub_oauth()
_stub_ivr_get(key0=manual, timeout=queue_route)
_stub_queue_get()
ivr_patch, _ = _stub_patches()
holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None)
ibody = json.loads(ivr_patch.calls[0].request.body)
# key-0 left as the manual value, timeout restored to the original.
assert ibody["Options"][0]["Route"] == manual
assert ibody["TimeoutForward"] == ORIG_TIMEOUT
@responses.activate
def test_deactivate_no_record_is_noop(holidayrouter_app, schedule, env):
result = holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None)
assert result["skipped"] == "no_record"
assert len(responses.calls) == 0
@responses.activate
def test_deactivate_not_active_is_noop(holidayrouter_app, schedule, seed, env):
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed), activated=False)
result = holidayrouter_app.handler({"action": "deactivate", "date": HOL}, None)
assert result["skipped"] == "not_active"
assert len(responses.calls) == 0
# ── dispatch / errors ────────────────────────────────────────────────────
def test_missing_date_errors(holidayrouter_app, schedule, env):
result = holidayrouter_app.handler({"action": "activate"}, None)
assert result["error"] is True
assert result["reason"] == "missing_date"
def test_unknown_action_errors(holidayrouter_app, schedule, env):
result = holidayrouter_app.handler({"action": "nope", "date": HOL}, None)
assert result["error"] is True
assert result["reason"] == "unknown_action"
@responses.activate
def test_three_cx_failure_returns_error(holidayrouter_app, schedule, seed, env):
seed.holiday(HOL, slots=2, assignees=_holiday_assignees(seed))
_stub_oauth()
responses.add(
responses.GET,
f"{BASE}/xapi/v1/IVRs/Pbx.GetByNumber(number='{IVR}')",
status=500,
)
result = holidayrouter_app.handler({"action": "activate", "date": HOL}, None)
assert result["error"] is True
# The holiday was not flagged activated since the 3CX call failed.
assert schedule.get_holiday(HOL)["activated"] is False

View file

@ -4,7 +4,10 @@ from freezegun import freeze_time
from shared.blocks import (
build_help_blocks,
build_holiday_added_blocks,
build_pay_summary_blocks,
build_pickup_request_blocks,
build_pickup_resolved_blocks,
build_release_announcement_blocks,
build_roster_blocks,
build_shift_change_message,
@ -48,6 +51,55 @@ class TestBuildWeekSchedule:
assert "pickup_2026-06-03" not in _all_action_ids(blocks)
assert "Alice (Ext 114)" in blocks[1]["text"]["text"]
@freeze_time("2026-06-01 12:00:00")
def test_holiday_on_weekday_renders_badge_and_open_slots(self, schedule, seed):
# A holiday can land on a weekday (here a Thursday) and shows the badge,
# the multiplier, and a day-shift pickup button for its open slots.
seed.holiday("2026-06-04", slots=2, label="Test Holiday")
blocks = build_week_schedule(schedule)
text = blocks[1]["text"]["text"]
assert "Holiday" in text
assert "Test Holiday" in text
assert "1.5x" in text
assert "2 open slots" in text
assert "pickup_2026-06-04_day" in _all_action_ids(blocks)
@freeze_time("2026-06-01 12:00:00")
def test_holiday_lists_assignees_and_remaining_slots(self, schedule, seed):
seed.holiday(
"2026-06-04",
slots=2,
label="Test Holiday",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
blocks = build_week_schedule(schedule)
text = blocks[1]["text"]["text"]
assert "Alice (Ext 114)" in text
assert "1 open slot" in text # one of two slots filled
@freeze_time("2026-06-01 12:00:00")
def test_full_holiday_has_no_pickup_button(self, schedule, seed):
seed.holiday(
"2026-06-04",
slots=1,
label="Test Holiday",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
blocks = build_week_schedule(schedule)
assert "pickup_2026-06-04_day" not in _all_action_ids(blocks)
@freeze_time("2026-06-01 12:00:00")
def test_holiday_pickup_button_label_says_holiday(self, schedule, seed):
seed.holiday("2026-06-04", slots=2, label="Test Holiday")
blocks = build_week_schedule(schedule)
labels = [
e["text"]["text"]
for b in blocks
if b.get("type") == "actions"
for e in b["elements"]
]
assert any("Holiday" in lbl for lbl in labels)
class TestBuildShiftChangeMessage:
def test_picked_up_weekday(self):
@ -96,6 +148,53 @@ class TestBuildPaySummaryBlocks:
text = blocks[1]["text"]["text"]
assert "Alice" in text and "$50.00" in text and "1 shift" in text
def test_holiday_row_shows_effective_rate_and_note(self):
breakdown = [
{
"day": "Thu",
"date_label": "Jul 4",
"name": "Alice",
"extension": "114",
"rate": 75.0, # effective (1.5x of 50)
"is_holiday": True,
"multiplier": 1.5,
}
]
totals = {
"Alice": {
"shifts": 1,
"total": 75.0,
"extension": "114",
"rate": 50.0,
"holiday_shifts": 1,
}
}
blocks = build_pay_summary_blocks("Jun 29 to Jul 5", breakdown, totals)
text = blocks[1]["text"]["text"]
assert "$75.00" in text # effective rate on the breakdown line
assert "1.5x" in text
assert "Holiday" in text
assert "incl. 1 holiday" in text # totals note
def test_regular_row_has_no_holiday_decorations(self):
breakdown = [
{
"day": "Mon",
"date_label": "Jun 1",
"name": "Alice",
"extension": "114",
"rate": 50.0,
}
]
totals = {
"Alice": {"shifts": 1, "total": 50.0, "extension": "114", "rate": 50.0}
}
text = build_pay_summary_blocks("Jun 1 to Jun 7", breakdown, totals)[1]["text"][
"text"
]
assert "Holiday" not in text
assert "incl." not in text
class TestBuildSwapRequestBlocks:
def _action_ids(self, blocks):
@ -134,6 +233,88 @@ class TestBuildSwapResolvedBlocks:
]
class TestBuildHolidayAddedBlocks:
def test_header_context_and_section(self):
blocks = build_holiday_added_blocks("2026-07-04", "Independence Day", 2, 1.5)
assert blocks[0]["type"] == "header"
assert "Independence Day" in blocks[0]["text"]["text"]
assert blocks[1]["type"] == "context"
section = blocks[-1]["text"]["text"]
assert "2 slots" in section
assert "1.5x" in section
assert "Holiday" in section
def test_single_slot_is_singular(self):
section = build_holiday_added_blocks("2026-07-04", "Indep", 1, 2)[-1]["text"][
"text"
]
assert "1 slot" in section and "1 slots" not in section
assert "2x" in section # whole-number multiplier drops the .0
def test_date_label_override(self):
blocks = build_holiday_added_blocks(
"2026-07-04", "Indep", 1, 1.5, date_label="July 4th"
)
assert blocks[1]["elements"][0]["text"] == "July 4th"
class TestBuildPickupRequestBlocks:
def _action_ids(self, blocks):
return [
e["action_id"]
for b in blocks
if b["type"] == "actions"
for e in b["elements"]
]
def test_weekday_request_encodes_date_and_ext(self):
blocks = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-06-03", "night", "114"
)
text = blocks[0]["text"]["text"]
assert "<@U_REQ>" in text and "Ext 114" in text
assert "already started" in text
ids = self._action_ids(blocks)
assert ids == ["pickup_approve_2026-06-03_114", "pickup_deny_2026-06-03_114"]
def test_weekend_day_request_uses_day_suffix(self):
blocks = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-06-06", "day", "114"
)
assert "Day (8am" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == [
"pickup_approve_2026-06-06_day_114",
"pickup_deny_2026-06-06_day_114",
]
def test_holiday_request_shows_badge_and_day_suffix(self):
blocks = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-07-04", "day", "114", is_holiday=True
)
assert "Holiday" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == [
"pickup_approve_2026-07-04_day_114",
"pickup_deny_2026-07-04_day_114",
]
def test_button_styles(self):
elements = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-06-03", "night", "114"
)[1]["elements"]
assert elements[0]["style"] == "primary" # Approve
assert elements[1]["style"] == "danger" # Deny
class TestBuildPickupResolvedBlocks:
def test_renders_text_no_buttons(self):
blocks = build_pickup_resolved_blocks("Approved.")
assert blocks == [
{"type": "section", "text": {"type": "mrkdwn", "text": "Approved."}}
]
class TestBuildHelpBlocks:
def test_non_admin_excludes_admin_section(self):
text = build_help_blocks(is_admin=False)[0]["text"]["text"]
@ -143,6 +324,16 @@ class TestBuildHelpBlocks:
text = build_help_blocks(is_admin=True)[0]["text"]["text"]
assert "Admin Commands" in text
def test_admin_includes_holiday_commands(self):
text = build_help_blocks(is_admin=True)[0]["text"]["text"]
assert "holiday add" in text
assert "holiday remove" in text
assert "holiday list" in text
def test_non_admin_excludes_holiday_commands(self):
text = build_help_blocks(is_admin=False)[0]["text"]["text"]
assert "holiday add" not in text
class TestBuildRosterBlocks:
def test_empty_roster(self):

View file

@ -1,11 +1,17 @@
"""Tests for shared.schedule — the DynamoDB single-table data model."""
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
import pytest
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule, determine_shift_type
from shared.schedule import (
DEFAULT_HOLIDAY_MULTIPLIER,
FALLBACK_EXTENSION,
ShiftSchedule,
determine_shift_type,
)
ET = ZoneInfo("America/New_York")
@ -13,6 +19,7 @@ ET = ZoneInfo("America/New_York")
SAT = "2026-06-06"
SUN = "2026-06-07"
WED = "2026-06-03"
HOL = "2026-07-04" # holiday date (a Saturday)
class TestDetermineShiftType:
@ -258,3 +265,266 @@ class TestSwaps:
schedule.create_pending_swap(WED, "night", self.REQ, self.TGT, 1)
schedule.clear_swap(WED)
assert schedule.get_swap(WED) is None
class TestHolidayConfig:
def test_holiday_multiplier_default(self, schedule):
assert schedule.get_holiday_multiplier() == DEFAULT_HOLIDAY_MULTIPLIER
def test_holiday_multiplier_override(self, schedule, seed):
seed.config(holiday_multiplier=Decimal("2"))
assert schedule.get_holiday_multiplier() == Decimal("2")
def test_holiday_queue_default_and_override(self, schedule, seed):
assert schedule.get_holiday_queue() == "802"
seed.config(holiday_queue="900")
assert schedule.get_holiday_queue() == "900"
def test_ivr_number_default_and_override(self, schedule, seed):
assert schedule.get_ivr_number() == "800"
seed.config(ivr_number="700")
assert schedule.get_ivr_number() == "700"
def test_captured_ivr_routes_roundtrip_and_clear(self, schedule, seed):
seed.config()
assert schedule.get_captured_ivr_routes() is None
routes = {
"key0": {"To": "Extension", "Number": "101", "External": ""},
"timeout": {"To": "Extension", "Number": "102", "External": ""},
}
schedule.set_captured_ivr_routes(routes)
assert schedule.get_captured_ivr_routes() == routes
schedule.set_captured_ivr_routes(None)
assert schedule.get_captured_ivr_routes() is None
class TestHolidays:
def test_create_and_get(self, schedule):
assert schedule.create_holiday(HOL, 2, "Independence Day", "U_ADMIN") is True
h = schedule.get_holiday(HOL)
assert h["slots"] == 2
assert h["label"] == "Independence Day"
assert h["created_by"] == "U_ADMIN"
assert h["assignees"] == {}
assert h["activated"] is False
assert h["multiplier"] == DEFAULT_HOLIDAY_MULTIPLIER
def test_create_uses_config_multiplier(self, schedule, seed):
seed.config(holiday_multiplier=Decimal("3"))
schedule.create_holiday(HOL, 1, "Holiday")
assert schedule.get_holiday(HOL)["multiplier"] == Decimal("3")
def test_create_explicit_multiplier_overrides(self, schedule):
schedule.create_holiday(HOL, 1, "Holiday", multiplier="2.5")
assert schedule.get_holiday(HOL)["multiplier"] == Decimal("2.5")
def test_create_duplicate_fails(self, schedule):
assert schedule.create_holiday(HOL, 2, "First") is True
assert schedule.create_holiday(HOL, 5, "Second") is False
# Original record untouched.
assert schedule.get_holiday(HOL)["label"] == "First"
def test_get_missing(self, schedule):
assert schedule.get_holiday(HOL) is None
def test_claim_slot_succeeds(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
assert schedule.claim_holiday_slot(HOL, "114", "Alice") is True
h = schedule.get_holiday(HOL)
assert h["assignees"]["114"]["name"] == "Alice"
assert h["assignees"]["114"]["claimed_at"]
def test_claim_fills_multiple_slots(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
assert schedule.claim_holiday_slot(HOL, "114", "Alice") is True
assert schedule.claim_holiday_slot(HOL, "115", "Bob") is True
assert set(schedule.get_holiday(HOL)["assignees"]) == {"114", "115"}
def test_claim_full_slot_rejected(self, schedule):
schedule.create_holiday(HOL, 1, "Holiday")
assert schedule.claim_holiday_slot(HOL, "114", "Alice") is True
assert schedule.claim_holiday_slot(HOL, "115", "Bob") is False
assert set(schedule.get_holiday(HOL)["assignees"]) == {"114"}
def test_claim_duplicate_rejected(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
assert schedule.claim_holiday_slot(HOL, "114", "Alice") is True
assert schedule.claim_holiday_slot(HOL, "114", "Alice Dup") is False
# The original claim is preserved unchanged.
assert schedule.get_holiday(HOL)["assignees"]["114"]["name"] == "Alice"
def test_claim_missing_holiday_fails(self, schedule):
assert schedule.claim_holiday_slot(HOL, "114", "Alice") is False
def test_release_slot_succeeds(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
schedule.claim_holiday_slot(HOL, "114", "Alice")
assert schedule.release_holiday_slot(HOL, "114") is True
assert schedule.get_holiday(HOL)["assignees"] == {}
def test_release_not_assigned_fails(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
assert schedule.release_holiday_slot(HOL, "114") is False
def test_release_frees_slot_for_reclaim(self, schedule):
schedule.create_holiday(HOL, 1, "Holiday")
schedule.claim_holiday_slot(HOL, "114", "Alice")
assert schedule.claim_holiday_slot(HOL, "115", "Bob") is False # full
schedule.release_holiday_slot(HOL, "114")
assert schedule.claim_holiday_slot(HOL, "115", "Bob") is True
def test_swap_assignee_succeeds(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
schedule.claim_holiday_slot(HOL, "114", "Alice")
assert schedule.swap_holiday_assignee(HOL, "114", "115", "Bob") is True
assignees = schedule.get_holiday(HOL)["assignees"]
assert "114" not in assignees
assert assignees["115"]["name"] == "Bob"
def test_swap_from_not_assigned_fails(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
assert schedule.swap_holiday_assignee(HOL, "114", "115", "Bob") is False
def test_swap_to_already_assigned_fails(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
schedule.claim_holiday_slot(HOL, "114", "Alice")
schedule.claim_holiday_slot(HOL, "115", "Bob")
assert schedule.swap_holiday_assignee(HOL, "114", "115", "Bob") is False
# Both still present.
assert set(schedule.get_holiday(HOL)["assignees"]) == {"114", "115"}
def test_set_activated(self, schedule):
schedule.create_holiday(HOL, 1, "Holiday")
schedule.set_holiday_activated(HOL, True)
assert schedule.get_holiday(HOL)["activated"] is True
def test_remove_holiday(self, schedule):
schedule.create_holiday(HOL, 1, "Holiday")
schedule.remove_holiday(HOL)
assert schedule.get_holiday(HOL) is None
class TestShiftContext:
def test_available_when_nothing_assigned(self, schedule):
ctx = schedule.get_shift_context(WED, "Wednesday")
assert ctx["kind"] == "available"
assert ctx["assignees"] == []
assert ctx["slots"] == 1 and ctx["open_slots"] == 1
assert ctx["multiplier"] == Decimal("1")
assert ctx["label"] == ""
def test_weekly_context(self, schedule, seed):
seed.weekly("Wednesday", "114", "Alice")
ctx = schedule.get_shift_context(WED, "Wednesday")
assert ctx["kind"] == "weekly"
assert ctx["assignees"] == [{"extension": "114", "name": "Alice"}]
assert ctx["open_slots"] == 0
def test_override_context(self, schedule, seed):
seed.weekly("Wednesday", "114", "Alice")
seed.override(WED, "115", "Bob")
ctx = schedule.get_shift_context(WED, "Wednesday")
assert ctx["kind"] == "override"
assert ctx["assignees"] == [{"extension": "115", "name": "Bob"}]
def test_open_override_is_available(self, schedule, seed):
seed.weekly("Wednesday", "114", "Alice")
seed.open_shift(WED)
ctx = schedule.get_shift_context(WED, "Wednesday")
assert ctx["kind"] == "available" and ctx["assignees"] == []
def test_holiday_priority_over_override_and_weekly(self, schedule, seed):
# Day-shift holiday beats both override and weekly.
seed.weekly("Saturday", "200", "DayPerson", shift_type="day")
seed.override(HOL, "201", "OverridePerson", shift_type="day")
schedule.create_holiday(HOL, 2, "Independence Day")
schedule.claim_holiday_slot(HOL, "114", "Alice")
schedule.claim_holiday_slot(HOL, "115", "Bob")
ctx = schedule.get_shift_context(HOL, "Saturday", "day")
assert ctx["kind"] == "holiday"
assert ctx["label"] == "Independence Day"
assert ctx["slots"] == 2 and ctx["open_slots"] == 0
assert ctx["multiplier"] == DEFAULT_HOLIDAY_MULTIPLIER
assert {a["extension"] for a in ctx["assignees"]} == {"114", "115"}
def test_holiday_open_slots_reported(self, schedule):
schedule.create_holiday(HOL, 3, "Holiday")
schedule.claim_holiday_slot(HOL, "114", "Alice")
ctx = schedule.get_shift_context(HOL, "Saturday", "day")
assert ctx["slots"] == 3 and ctx["open_slots"] == 2
def test_holiday_only_applies_to_day_shift(self, schedule):
# Night shift on a holiday date ignores the holiday record.
schedule.create_holiday(HOL, 2, "Holiday")
ctx = schedule.get_shift_context(HOL, "Saturday", "night")
assert ctx["kind"] == "available"
def test_resolve_shift_wrapper_holiday(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
schedule.claim_holiday_slot(HOL, "114", "Alice")
assert schedule.resolve_shift(HOL, "Saturday", "day") == (
"114",
"Alice",
"holiday",
)
def test_resolve_shift_wrapper_empty_holiday_available(self, schedule):
schedule.create_holiday(HOL, 2, "Holiday")
assert schedule.resolve_shift(HOL, "Saturday", "day") == (
FALLBACK_EXTENSION,
"Available",
"available",
)
class TestPickupRequests:
REQ = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
def test_create_and_get(self, schedule):
schedule.create_pickup_request(WED, "night", self.REQ, 1_900_000_000)
req = schedule.get_pickup_request(WED, "night", "114")
assert req["status"] == "pending"
assert req["requester_ext"] == "114"
assert req["requester_name"] == "Alice"
assert req["requester_slack"] == "U_ALICE"
assert req["shift_type"] == "night"
assert req["is_holiday"] is False
assert req["expires_at"] == 1_900_000_000
assert req["created_at"]
def test_create_holiday_pickup(self, schedule):
schedule.create_pickup_request(
HOL, "day", self.REQ, 1_900_000_000, is_holiday=True
)
req = schedule.get_pickup_request(HOL, "day", "114")
assert req["is_holiday"] is True and req["shift_type"] == "day"
def test_get_missing(self, schedule):
assert schedule.get_pickup_request(WED, "night", "114") is None
def test_requests_keyed_per_requester(self, schedule):
other = {"extension": "115", "name": "Bob", "slack_user_id": "U_BOB"}
schedule.create_pickup_request(WED, "night", self.REQ, 1)
schedule.create_pickup_request(WED, "night", other, 2)
assert schedule.get_pickup_request(WED, "night", "114")["expires_at"] == 1
assert schedule.get_pickup_request(WED, "night", "115")["expires_at"] == 2
def test_day_and_night_independent(self, schedule):
schedule.create_pickup_request(SAT, "day", self.REQ, 1)
assert schedule.get_pickup_request(SAT, "day", "114") is not None
assert schedule.get_pickup_request(SAT, "night", "114") is None
def test_clear(self, schedule):
schedule.create_pickup_request(WED, "night", self.REQ, 1)
schedule.clear_pickup_request(WED, "night", "114")
assert schedule.get_pickup_request(WED, "night", "114") is None
def test_approve_is_atomic_first_wins(self, schedule):
schedule.create_pickup_request(WED, "night", self.REQ, 1)
# First approval wins the pending->approved transition; a second
# concurrent approval of the same request loses (no double-claim).
assert schedule.approve_pickup_request(WED, "night", "114") is True
assert schedule.approve_pickup_request(WED, "night", "114") is False
def test_approve_missing_request(self, schedule):
assert schedule.approve_pickup_request(WED, "night", "114") is False

View file

@ -111,3 +111,91 @@ def test_get_queue_and_update_queue_forwarding():
body = json.loads(patched.calls[0].request.body)
assert body["OutOfOfficeRoute"]["Route"]["Number"] == "114"
assert body["HolidaysRoute"]["Route"]["Number"] == "114"
@responses.activate
def test_set_queue_agents_replaces_membership():
_stub_oauth()
patched = responses.add(responses.PATCH, f"{BASE}/xapi/v1/Queues(7)", status=200)
client = ThreeCXClient(
domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s"
)
status = client.set_queue_agents(7, ["114", "115"])
assert status == 200
import json
body = json.loads(patched.calls[0].request.body)
assert body["Agents"] == [{"Number": "114"}, {"Number": "115"}]
@responses.activate
def test_set_queue_agents_empty_clears_membership():
_stub_oauth()
patched = responses.add(responses.PATCH, f"{BASE}/xapi/v1/Queues(7)", status=200)
client = ThreeCXClient(
domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s"
)
client.set_queue_agents(7, [])
import json
body = json.loads(patched.calls[0].request.body)
assert body["Agents"] == []
@responses.activate
def test_get_ivr_and_set_ivr_routes():
_stub_oauth()
responses.add(
responses.GET,
f"{BASE}/xapi/v1/IVRs/Pbx.GetByNumber(number='800')",
json={
"Id": 3,
"Number": "800",
"Options": [{"Digit": "0", "Route": {"To": "Extension", "Number": "101"}}],
"TimeoutForward": {"To": "Extension", "Number": "102"},
},
status=200,
)
patched = responses.add(responses.PATCH, f"{BASE}/xapi/v1/IVRs(3)", status=200)
client = ThreeCXClient(
domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s"
)
ivr = client.get_ivr("800")
assert ivr["Id"] == 3
key0 = {"To": "Queue", "Number": "802"}
status = client.set_ivr_routes(3, key0_route=key0, timeout_route=key0)
assert status == 200
import json
body = json.loads(patched.calls[0].request.body)
assert body["Options"][0]["Digit"] == "0"
assert body["Options"][0]["Route"]["Number"] == "802"
assert body["TimeoutForward"]["Number"] == "802"
def test_extract_ivr_routes_pulls_key0_and_timeout():
ivr = {
"Options": [
{"Digit": "1", "Route": {"Number": "201"}},
{"Digit": "0", "Route": {"Number": "101"}},
],
"TimeoutForward": {"Number": "102"},
}
routes = ThreeCXClient.extract_ivr_routes(ivr)
assert routes["key0"] == {"Number": "101"}
assert routes["timeout"] == {"Number": "102"}
def test_extract_ivr_routes_missing_key0_is_none():
routes = ThreeCXClient.extract_ivr_routes({"Options": [], "TimeoutForward": None})
assert routes == {"key0": None, "timeout": None}
def test_route_to_extension():
assert ThreeCXClient.route_to_extension({"Number": "802"}) == "802"
assert ThreeCXClient.route_to_extension(None) is None
assert ThreeCXClient.route_to_extension({}) is None

View file

@ -0,0 +1,311 @@
"""Tests for slack-bot admin holiday subcommands (add / remove / list) and the
one-off EventBridge Scheduler lifecycle (Phase 7)."""
import boto3
import pytest
from freezegun import freeze_time
# Wednesday 2026-07-01 12:00 ET — well before the 2026-07-04 holiday.
WED = "2026-07-01 16:00:00"
# Saturday 2026-07-04 12:00 ET — inside the holiday window (08:00–17:00).
SAT_MIDDAY = "2026-07-04 16:00:00"
ROLE_ARN = "arn:aws:iam::328440206208:role/holiday-sched"
ROUTER_ARN = "arn:aws:lambda:us-east-1:328440206208:function:afterhours-holiday-router"
@pytest.fixture
def holiday_env(monkeypatch):
"""Wire the scheduler/router ARNs so schedule creation is exercised."""
monkeypatch.setenv("HOLIDAY_ROUTER_ARN", ROUTER_ARN)
monkeypatch.setenv("HOLIDAY_SCHEDULER_ROLE_ARN", ROLE_ARN)
monkeypatch.setenv("HOLIDAY_SCHEDULE_GROUP", "default")
@pytest.fixture
def scheduler():
return boto3.client("scheduler", region_name="us-east-1")
def _admin(slackbot_app, schedule, text, respond, client, channel="C_TEST"):
slackbot_app._handle_admin(
respond, schedule, "U_ADMIN", text, True, client, channel
)
def _schedule_names(scheduler):
return {s["Name"] for s in scheduler.list_schedules()["Schedules"]}
class TestAdd:
@freeze_time(WED)
def test_creates_record_schedules_and_announces(
self,
slackbot_app,
schedule,
dynamodb_table,
holiday_env,
scheduler,
respond,
client,
text_of,
monkeypatch,
):
inline = _spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 2 Independence Day",
respond,
client,
)
holiday = schedule.get_holiday("2026-07-04")
assert holiday is not None
assert holiday["slots"] == 2
assert holiday["label"] == "Independence Day"
assert float(holiday["multiplier"]) == 1.5 # CONFIG default
assert set(holiday["schedule_names"]) == {
"holiday-activate-20260704",
"holiday-deactivate-20260704",
}
# Both one-off schedules exist in EventBridge Scheduler.
assert _schedule_names(scheduler) >= set(holiday["schedule_names"])
# Future holiday → not inline-activated.
inline.assert_not_called()
# Channel announcement + confirmation.
assert client.chat_postMessage.call_args.kwargs["channel"] == "C_TEST"
assert "Scheduled" in text_of(respond)
@freeze_time(WED)
def test_custom_multiplier(
self,
slackbot_app,
schedule,
dynamodb_table,
holiday_env,
scheduler,
respond,
client,
monkeypatch,
):
_spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 3 x2 Independence Day",
respond,
client,
)
holiday = schedule.get_holiday("2026-07-04")
assert float(holiday["multiplier"]) == 2.0
assert holiday["slots"] == 3
assert holiday["label"] == "Independence Day"
@freeze_time(SAT_MIDDAY)
def test_inline_activation_when_window_open(
self,
slackbot_app,
schedule,
dynamodb_table,
holiday_env,
scheduler,
respond,
client,
monkeypatch,
):
inline = _spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 2 Independence Day",
respond,
client,
)
# Added mid-window → activate now (the 08:00 schedule already passed).
inline.assert_called_once()
assert inline.call_args.args[1] == "2026-07-04"
@freeze_time(WED)
def test_duplicate_rolls_back_schedules(
self,
slackbot_app,
schedule,
seed,
dynamodb_table,
holiday_env,
scheduler,
respond,
client,
text_of,
monkeypatch,
):
_spy_inline(slackbot_app, monkeypatch)
seed.holiday("2026-07-04", slots=1, label="Existing")
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 2 Independence Day",
respond,
client,
)
assert "already exists" in text_of(respond).lower()
# The schedules created for the duplicate attempt were deleted again.
assert _schedule_names(scheduler) == set()
@freeze_time(WED)
def test_past_date_rejected(
self,
slackbot_app,
schedule,
holiday_env,
respond,
client,
text_of,
monkeypatch,
):
_spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-06-01 2 Old Day",
respond,
client,
)
assert "past" in text_of(respond).lower()
@freeze_time(WED)
def test_bad_slots(
self,
slackbot_app,
schedule,
holiday_env,
respond,
client,
text_of,
monkeypatch,
):
_spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 lots Independence Day",
respond,
client,
)
assert "whole number" in text_of(respond).lower()
@freeze_time(WED)
def test_missing_label(
self,
slackbot_app,
schedule,
holiday_env,
respond,
client,
text_of,
monkeypatch,
):
_spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 2 x2",
respond,
client,
)
assert "label is required" in text_of(respond).lower()
def test_usage(self, slackbot_app, schedule, respond, client, text_of):
_admin(slackbot_app, schedule, "admin holiday add 2026-07-04", respond, client)
assert "Usage" in text_of(respond)
class TestRemove:
@freeze_time(WED)
def test_removes_record_and_outstanding_schedules(
self,
slackbot_app,
schedule,
dynamodb_table,
holiday_env,
scheduler,
respond,
client,
text_of,
monkeypatch,
):
_spy_inline(slackbot_app, monkeypatch)
_admin(
slackbot_app,
schedule,
"admin holiday add 2026-07-04 2 Independence Day",
respond,
client,
)
assert _schedule_names(scheduler) != set()
_admin(
slackbot_app, schedule, "admin holiday remove 2026-07-04", respond, client
)
assert schedule.get_holiday("2026-07-04") is None
assert _schedule_names(scheduler) == set() # outstanding schedules deleted
assert "Removed" in text_of(respond)
@freeze_time(WED)
def test_no_holiday(self, slackbot_app, schedule, respond, client, text_of):
_admin(
slackbot_app, schedule, "admin holiday remove 2026-07-04", respond, client
)
assert "No holiday" in text_of(respond)
class TestList:
@freeze_time(WED)
def test_lists_upcoming_only(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.holiday("2026-07-04", slots=2, label="Independence Day")
seed.holiday("2026-12-25", slots=1, label="Christmas")
seed.holiday("2026-01-01", slots=1, label="New Year") # past
_admin(slackbot_app, schedule, "admin holiday list", respond, client)
out = text_of(respond)
assert "Independence Day" in out
assert "Christmas" in out
assert "New Year" not in out # past holiday filtered out
@freeze_time(WED)
def test_shows_filled_counts(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
_admin(slackbot_app, schedule, "admin holiday list", respond, client)
assert "1/2 filled" in text_of(respond)
@freeze_time(WED)
def test_empty(self, slackbot_app, schedule, respond, client, text_of):
_admin(slackbot_app, schedule, "admin holiday list", respond, client)
assert "No upcoming holidays" in text_of(respond)
class TestDispatch:
def test_unknown_holiday_command(
self, slackbot_app, schedule, respond, client, text_of
):
_admin(slackbot_app, schedule, "admin holiday frobnicate", respond, client)
assert "Unknown holiday command" in text_of(respond)
def _spy_inline(slackbot_app, monkeypatch):
"""Replace inline activation (a real Lambda invoke) with a spy."""
from unittest.mock import MagicMock
spy = MagicMock(name="_activate_holiday_inline")
monkeypatch.setattr(slackbot_app, "_activate_holiday_inline", spy)
return spy

View file

@ -0,0 +1,229 @@
"""Tests for holiday-aware pick / drop / swap paths via get_shift_context (Phase 8).
These cover the *before-window* (immediate) behaviour; late (in-window) pickups
are covered in test_late_pickup.py.
"""
from freezegun import freeze_time
# Wednesday 2026-07-01 12:00 ET — well before the 2026-07-04 holiday window.
WED = "2026-07-01 16:00:00"
def _resolved_text(respond):
return respond.call_args.kwargs["blocks"][0]["text"]["text"]
def _channels(client):
return [c.kwargs.get("channel") for c in client.chat_postMessage.call_args_list]
# ── pick ─────────────────────────────────────────────────────────────────
class TestHolidayPick:
@freeze_time(WED)
def test_claims_slot(
self, slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "114" in schedule.get_holiday("2026-07-04")["assignees"]
assert "picked up a slot" in text_of(respond).lower()
# Holiday claims never repoint the per-extension routing.
routing_spy.assert_not_called()
client.chat_postMessage.assert_called_once()
@freeze_time(WED)
def test_two_pickers_both_get_slots(
self, slackbot_app, schedule, seed, respond, client
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
slackbot_app._handle_pick(
respond, schedule, "U_BOB", "pick 2026-07-04", "C1", client, "C_TEST"
)
assignees = schedule.get_holiday("2026-07-04")["assignees"]
assert set(assignees) == {"114", "115"}
@freeze_time(WED)
def test_full_holiday_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("116", "Carol", slack_user_id="U_CAROL")
seed.holiday(
"2026-07-04",
slots=1,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_pick(
respond, schedule, "U_CAROL", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "full" in text_of(respond).lower()
assert "116" not in schedule.get_holiday("2026-07-04")["assignees"]
@freeze_time(WED)
def test_already_on_holiday(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "already on" in text_of(respond).lower()
# ── drop ─────────────────────────────────────────────────────────────────
class TestHolidayDrop:
@freeze_time(WED)
def test_releases_slot(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_drop(
respond, schedule, "U_ALICE", "drop 2026-07-04", "C1", client, "C_TEST"
)
assert "114" not in schedule.get_holiday("2026-07-04")["assignees"]
assert "dropped your slot" in text_of(respond).lower()
@freeze_time(WED)
def test_not_on_holiday_falls_through(
self, slackbot_app, schedule, seed, respond, client, text_of
):
# Alice holds no holiday slot → drop falls through to regular resolution,
# which reports it's not her (night) shift.
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"115": {"name": "Bob", "claimed_at": "x"}},
)
slackbot_app._handle_drop(
respond, schedule, "U_ALICE", "drop 2026-07-04", "C1", client, "C_TEST"
)
assert "115" in schedule.get_holiday("2026-07-04")["assignees"] # untouched
assert "not your shift" in text_of(respond).lower()
@freeze_time("2026-07-03 16:00:00") # Friday — Saturday 08:00 holiday <24h away
def test_drop_lock_within_24h(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_drop(
respond, schedule, "U_ALICE", "drop 2026-07-04", "C1", client, "C_TEST"
)
assert "under 24 hours" in text_of(respond).lower()
assert "114" in schedule.get_holiday("2026-07-04")["assignees"] # not dropped
# ── swap ─────────────────────────────────────────────────────────────────
class TestHolidaySwap:
@freeze_time(WED)
def test_request_creates_pending_day_swap(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_swap(
respond,
schedule,
"U_ALICE",
"swap 2026-07-04 <@U_BOB>",
"C1",
client,
"C_TEST",
)
swap = schedule.get_swap("2026-07-04", "day")
assert swap is not None and swap["status"] == "pending"
assert swap["target_ext"] == "115" and swap["requester_ext"] == "114"
# DM'd the target.
assert "U_BOB" in _channels(client)
assert "Swap request sent" in text_of(respond)
@freeze_time(WED)
def test_accept_moves_slot(self, slackbot_app, schedule, seed, respond, client):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
req = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
tgt = {"extension": "115", "name": "Bob", "slack_user_id": "U_BOB"}
schedule.create_pending_swap("2026-07-04", "day", req, tgt, 1_900_000_000)
body = {
"actions": [{"action_id": "swap_accept_2026-07-04_day"}],
"user": {"id": "U_BOB"},
"channel": {"id": "D1"},
}
slackbot_app.handle_swap_accept(body, respond, client, schedule, "C_TEST")
assignees = schedule.get_holiday("2026-07-04")["assignees"]
assert "115" in assignees and "114" not in assignees
assert schedule.get_swap("2026-07-04", "day")["status"] == "verified"
# No plain override was written for the holiday day.
assert schedule.get_override("2026-07-04", "day") is None
@freeze_time(WED)
def test_not_your_holiday_swap_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("116", "Carol", slack_user_id="U_CAROL")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_swap(
respond,
schedule,
"U_CAROL",
"swap 2026-07-04 <@U_BOB>",
"C1",
client,
"C_TEST",
)
assert "not your shift" in text_of(respond).lower()
assert schedule.get_swap("2026-07-04", "day") is None

View file

@ -0,0 +1,374 @@
"""Tests for the late-pickup approval flow (Phase 8) — picking up a shift that
has already started requires admin approval, for BOTH regular and holiday shifts.
"""
import pytest
from freezegun import freeze_time
# Monday 2026-06-01. Night shift starts 17:00, ends next day 08:00.
MON_BEFORE = "2026-06-01 12:00:00" # 08:00 ET — before the night shift starts
MON_AFTER = "2026-06-01 22:00:00" # 18:00 ET — night shift already started
MON_NEXT_DAY = "2026-06-02 13:00:00" # 09:00 ET — Monday night shift has ended
# Saturday 2026-07-04 holiday day shift: 08:00–17:00 ET.
SAT_BEFORE = "2026-07-04 11:00:00" # 07:00 ET — before the day shift starts
SAT_DURING = "2026-07-04 16:00:00" # 12:00 ET — day shift in progress
SAT_AFTER = "2026-07-04 22:00:00" # 18:00 ET — day shift has ended
def _admins(schedule_seed):
schedule_seed.config(admin_users=["U_ADMIN1", "U_ADMIN2"])
def _dm_channels(client):
return [c.kwargs.get("channel") for c in client.chat_postMessage.call_args_list]
# ── _handle_pick: regular shift, after start → approval ──────────────────
class TestRegularLatePick:
@freeze_time(MON_AFTER)
def test_creates_request_and_dms_admins(
self, slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick today", "C1", client, "C_TEST"
)
req = schedule.get_pickup_request("2026-06-01", "night", "114")
assert req is not None and req["status"] == "pending"
assert req["is_holiday"] is False
# Not claimed yet — no override, no 3CX.
assert schedule.get_override("2026-06-01") is None
routing_spy.assert_not_called()
# DM'd both admins.
assert set(_dm_channels(client)) == {"U_ADMIN1", "U_ADMIN2"}
assert "admin approval" in text_of(respond).lower()
@freeze_time(MON_NEXT_DAY)
def test_after_end_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
# A night shift ends the next morning at 08:00, by which point the date
# is in the past — the past-date guard rejects it (no approval request).
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-06-01", "C1", client, "C_TEST"
)
assert "past" in text_of(respond).lower()
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
@freeze_time(MON_BEFORE)
def test_before_start_immediate(
self, slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick today", "C1", client, "C_TEST"
)
# Immediate claim, no approval request.
assert schedule.get_override("2026-06-01")["extension"] == "114"
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
assert "picked up" in text_of(respond).lower()
@freeze_time(MON_AFTER)
def test_no_admins_rolls_back(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
# No admin_users configured.
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick today", "C1", client, "C_TEST"
)
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
assert "couldn't reach any admin" in text_of(respond).lower()
# ── _handle_pick: holiday shift, after start → approval ──────────────────
class TestHolidayLatePick:
@freeze_time(SAT_DURING)
def test_creates_holiday_request(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
req = schedule.get_pickup_request("2026-07-04", "day", "114")
assert req is not None and req["is_holiday"] is True
# Slot not claimed yet.
assert schedule.get_holiday("2026-07-04")["assignees"] == {}
@freeze_time(SAT_BEFORE)
def test_before_window_immediate_claim(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "114" in schedule.get_holiday("2026-07-04")["assignees"]
assert "picked up a slot" in text_of(respond).lower()
@freeze_time(SAT_AFTER)
def test_after_window_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "already ended" in text_of(respond).lower()
assert schedule.get_pickup_request("2026-07-04", "day", "114") is None
# ── handle_pickup button: late pickup gating ─────────────────────────────
class TestButtonLatePick:
@freeze_time(MON_AFTER)
def test_button_after_start_requests_approval(
self, slackbot_app, schedule, seed, respond, client
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
body = {
"actions": [{"action_id": "pickup_2026-06-01"}],
"user": {"id": "U_ALICE"},
"channel": {"id": "C1"},
}
slackbot_app.handle_pickup(body, respond, client, schedule, "C_TEST")
assert schedule.get_pickup_request("2026-06-01", "night", "114") is not None
# The clicker is told via an ephemeral message.
assert client.chat_postEphemeral.called
@freeze_time(SAT_DURING)
def test_button_holiday_late_request(
self, slackbot_app, schedule, seed, respond, client
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
body = {
"actions": [{"action_id": "pickup_2026-07-04_day"}],
"user": {"id": "U_ALICE"},
"channel": {"id": "C1"},
}
slackbot_app.handle_pickup(body, respond, client, schedule, "C_TEST")
req = schedule.get_pickup_request("2026-07-04", "day", "114")
assert req is not None and req["is_holiday"] is True
# ── approve / deny handlers ──────────────────────────────────────────────
def _approve_body(date_str, ext, suffix="", user_id="U_ADMIN1"):
return {
"actions": [{"action_id": f"pickup_approve_{date_str}{suffix}_{ext}"}],
"user": {"id": user_id},
"channel": {"id": "D1"},
}
def _deny_body(date_str, ext, suffix="", user_id="U_ADMIN1"):
return {
"actions": [{"action_id": f"pickup_deny_{date_str}{suffix}_{ext}"}],
"user": {"id": user_id},
"channel": {"id": "D1"},
}
def _resolved_text(respond):
return respond.call_args.kwargs["blocks"][0]["text"]["text"]
class TestApproveRegular:
@pytest.fixture(autouse=True)
def _seed_admins(self, seed):
_admins(seed)
@freeze_time(MON_AFTER)
def test_approve_claims_and_repoints(
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert schedule.get_override("2026-06-01")["extension"] == "114"
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
# Today + active night → 3CX repointed.
routing_spy.assert_called_once_with("114")
assert "U_ALICE" in _dm_channels(client) # requester notified
assert "approved" in _resolved_text(respond).lower()
@freeze_time(MON_AFTER)
def test_non_admin_cannot_approve(
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
# U_ALICE (the requester) is NOT in admin_users — must be rejected with
# no assignment, no 3CX repoint, and the request left pending.
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114", user_id="U_ALICE"),
respond,
client,
schedule,
"C_TEST",
)
assert schedule.get_override("2026-06-01") is None
assert schedule.get_pickup_request("2026-06-01", "night", "114") is not None
routing_spy.assert_not_called()
@freeze_time(MON_AFTER)
def test_second_approve_no_longer_pending(
self, slackbot_app, schedule, seed, respond, client
):
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
# A second admin clicks the (now-resolved) request.
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114", user_id="U_ADMIN2"),
respond,
client,
schedule,
"C_TEST",
)
assert "no longer pending" in _resolved_text(respond).lower()
@freeze_time(MON_NEXT_DAY)
def test_approve_after_end_expires(
self, slackbot_app, schedule, respond, client, routing_spy
):
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert "expired" in _resolved_text(respond).lower()
assert schedule.get_override("2026-06-01") is None
routing_spy.assert_not_called()
class TestApproveHoliday:
@pytest.fixture(autouse=True)
def _seed_admins(self, seed):
_admins(seed)
@freeze_time(SAT_DURING)
def test_approve_claims_slot_and_sets_agents(
self, slackbot_app, schedule, seed, respond, client, monkeypatch
):
agents = _spy_queue_agents(slackbot_app, monkeypatch)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-07-04", "day", emp, 1_900_000_000, is_holiday=True
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-07-04", "114", suffix="_day"),
respond,
client,
schedule,
"C_TEST",
)
assert "114" in schedule.get_holiday("2026-07-04")["assignees"]
# Window open → queue membership refreshed.
agents.assert_called_once()
assert agents.call_args.args[1] == "2026-07-04"
@freeze_time(SAT_DURING)
def test_approve_full_holiday_fails_gracefully(
self, slackbot_app, schedule, seed, respond, client, monkeypatch
):
_spy_queue_agents(slackbot_app, monkeypatch)
seed.holiday(
"2026-07-04",
slots=1,
label="Independence Day",
assignees={"115": {"name": "Bob", "claimed_at": "x"}},
)
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-07-04", "day", emp, 1_900_000_000, is_holiday=True
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-07-04", "114", suffix="_day"),
respond,
client,
schedule,
"C_TEST",
)
assert "full or already taken" in _resolved_text(respond).lower()
assert "114" not in schedule.get_holiday("2026-07-04")["assignees"]
class TestDeny:
@pytest.fixture(autouse=True)
def _seed_admins(self, seed):
_admins(seed)
@freeze_time(MON_AFTER)
def test_deny_clears_and_notifies(self, slackbot_app, schedule, respond, client):
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_deny(
_deny_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
assert schedule.get_override("2026-06-01") is None # never claimed
assert "denied" in _resolved_text(respond).lower()
assert "U_ALICE" in _dm_channels(client)
@freeze_time(MON_AFTER)
def test_deny_no_pending(self, slackbot_app, schedule, respond, client):
slackbot_app.handle_pickup_deny(
_deny_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert "no longer pending" in _resolved_text(respond).lower()
def test_parse_pickup_action(slackbot_app):
assert slackbot_app._parse_pickup_action(
"pickup_approve_2026-07-04_day_114", "pickup_approve_"
) == ("2026-07-04", "day", "114")
assert slackbot_app._parse_pickup_action(
"pickup_deny_2026-06-01_115", "pickup_deny_"
) == ("2026-06-01", "night", "115")
def _spy_queue_agents(slackbot_app, monkeypatch):
from unittest.mock import MagicMock
spy = MagicMock(name="_set_holiday_queue_agents")
monkeypatch.setattr(slackbot_app, "_set_holiday_queue_agents", spy)
return spy

View file

@ -70,6 +70,145 @@ class TestCalculateWeeklyPay:
assert record["breakdown"] == [] and record["totals"] == {}
class TestHolidayPay:
def test_single_holiday_assignee(self, weeklypost_app, schedule, seed):
# Wed 2026-06-03 holiday; one assignee at base $50 * 1.5 = $75.
seed.config(shift_rate="50")
seed.holiday(
"2026-06-03",
slots=2,
label="Founders Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
breakdown, totals = [], {}
weeklypost_app._add_shift_to_pay(
schedule, breakdown, totals, datetime(2026, 6, 3), "Wednesday", "day"
)
assert len(breakdown) == 1
line = breakdown[0]
assert line["name"] == "Alice"
assert line["is_holiday"] is True
assert line["holiday_label"] == "Founders Day"
assert line["multiplier"] == Decimal("1.5")
assert line["base_rate"] == Decimal("50")
# rate is the effective (multiplied) pay for Slack-block compatibility.
assert line["rate"] == Decimal("75.0")
assert line["amount"] == Decimal("75.0")
assert isinstance(line["multiplier"], Decimal)
assert isinstance(line["amount"], Decimal)
assert totals["Alice"]["shifts"] == 1
assert totals["Alice"]["holiday_shifts"] == 1
assert totals["Alice"]["total"] == Decimal("75.0")
def test_two_assignees_same_holiday(self, weeklypost_app, schedule, seed):
# Two slots filled on the same holiday; each paid base * 1.5.
seed.config(shift_rate="40")
seed.roster("114", "Alice", shift_rate="40")
seed.roster("200", "Bob", shift_rate="60")
seed.holiday(
"2026-06-03",
slots=2,
label="Founders Day",
assignees={
"114": {"name": "Alice", "claimed_at": "x"},
"200": {"name": "Bob", "claimed_at": "y"},
},
)
breakdown, totals = [], {}
weeklypost_app._add_shift_to_pay(
schedule, breakdown, totals, datetime(2026, 6, 3), "Wednesday", "day"
)
assert len(breakdown) == 2
assert all(line["is_holiday"] for line in breakdown)
by_name = {line["name"]: line for line in breakdown}
assert by_name["Alice"]["amount"] == Decimal("60.0") # 40 * 1.5
assert by_name["Bob"]["amount"] == Decimal("90.0") # 60 * 1.5
assert totals["Alice"]["total"] == Decimal("60.0")
assert totals["Bob"]["total"] == Decimal("90.0")
assert totals["Alice"]["holiday_shifts"] == 1
assert totals["Bob"]["holiday_shifts"] == 1
def test_custom_multiplier(self, weeklypost_app, schedule, seed):
seed.config(shift_rate="50")
seed.holiday(
"2026-06-03",
slots=1,
label="Double Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
multiplier=Decimal("2"),
)
breakdown, totals = [], {}
weeklypost_app._add_shift_to_pay(
schedule, breakdown, totals, datetime(2026, 6, 3), "Wednesday", "day"
)
assert breakdown[0]["multiplier"] == Decimal("2")
assert breakdown[0]["amount"] == Decimal("100")
assert totals["Alice"]["total"] == Decimal("100")
def test_empty_holiday_adds_nothing(self, weeklypost_app, schedule, seed):
seed.config(shift_rate="50")
seed.holiday("2026-06-03", slots=2, label="Empty", assignees={})
breakdown, totals = [], {}
weeklypost_app._add_shift_to_pay(
schedule, breakdown, totals, datetime(2026, 6, 3), "Wednesday", "day"
)
assert breakdown == [] and totals == {}
def test_mixed_week(self, weeklypost_app, schedule, seed):
# A week with a weekday night shift, a weekend day+night, and a holiday.
seed.config(shift_rate="50")
# Regular Monday night for Alice.
seed.weekly("Monday", "114", "Alice")
# Holiday on Wednesday (day shift) with Alice + Bob.
seed.roster("200", "Bob", shift_rate="50")
seed.holiday(
"2026-06-03",
slots=2,
label="Founders Day",
assignees={
"114": {"name": "Alice", "claimed_at": "x"},
"200": {"name": "Bob", "claimed_at": "y"},
},
)
# Saturday day + night for Bob (regular weekend).
seed.weekly("Saturday", "200", "Bob", shift_type="day")
seed.weekly("Saturday", "200", "Bob", shift_type="night")
record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START)
# Alice: Mon night $50 + Wed holiday $75 = $125 over 2 shifts.
assert record["totals"]["Alice"]["shifts"] == 2
assert record["totals"]["Alice"]["total"] == Decimal("125.0")
assert record["totals"]["Alice"]["holiday_shifts"] == 1
# Bob: Wed holiday $75 + Sat day $50 + Sat night $50 = $175 over 3 shifts.
assert record["totals"]["Bob"]["shifts"] == 3
assert record["totals"]["Bob"]["total"] == Decimal("175.0")
assert record["totals"]["Bob"]["holiday_shifts"] == 1
# Exactly two holiday breakdown lines (Alice + Bob on Wed).
holiday_lines = [b for b in record["breakdown"] if b.get("is_holiday")]
assert len(holiday_lines) == 2
assert {line["name"] for line in holiday_lines} == {"Alice", "Bob"}
def test_night_on_holiday_date_is_regular(self, weeklypost_app, schedule, seed):
# A holiday only applies to the day shift; night falls through to weekly.
seed.config(shift_rate="50")
seed.weekly("Wednesday", "114", "Alice")
seed.holiday(
"2026-06-03",
slots=1,
label="Founders Day",
assignees={"200": {"name": "Bob", "claimed_at": "y"}},
)
breakdown, totals = [], {}
weeklypost_app._add_shift_to_pay(
schedule, breakdown, totals, datetime(2026, 6, 3), "Wednesday", "night"
)
assert len(breakdown) == 1
assert breakdown[0]["name"] == "Alice"
assert breakdown[0].get("is_holiday") is None
assert breakdown[0]["amount"] == Decimal("50")
class TestBuildPayEmailHtml:
def test_renders_totals_rows(self, weeklypost_app):
pay_record = {
@ -81,3 +220,51 @@ class TestBuildPayEmailHtml:
assert "Jun 1 to Jun 7" in html
assert "Alice" in html
assert "$100.00" in html
def test_holiday_rows_rendered_distinctly(self, weeklypost_app):
pay_record = {
"totals": {
"Alice": {
"total": Decimal("75"),
"rate": Decimal("50"),
"shifts": 1,
"holiday_shifts": 1,
},
"Bob": {
"total": Decimal("50"),
"rate": Decimal("50"),
"shifts": 1,
"holiday_shifts": 0,
},
},
"breakdown": [
{
"date_label": "Jun 3",
"day": "Wed (Day)",
"name": "Alice",
"rate": Decimal("75"),
"base_rate": Decimal("50"),
"amount": Decimal("75"),
"is_holiday": True,
"multiplier": Decimal("1.5"),
"holiday_label": "Founders Day",
},
{
"date_label": "Jun 6",
"day": "Sat (Day)",
"name": "Bob",
"rate": Decimal("50"),
"base_rate": Decimal("50"),
"amount": Decimal("50"),
},
],
}
html = weeklypost_app._build_pay_email_html("Jun 1 to Jun 7", pay_record)
# Holiday label, multiplier, and shading appear for the holiday row.
assert "Founders Day" in html
assert "1.50x" in html
assert "background:#fff8e1" in html
# Holiday worker is flagged in the totals table; non-holiday isn't.
assert "holiday" in html
assert "$75.00" in html
assert "$50.00" in html