mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 03:23:12 +00:00
Add holiday shifts with 3CX routing and late-pickup approval (#121)
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:
parent
9353b9222b
commit
88e782c205
22 changed files with 4132 additions and 111 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
192
src/holiday-router/app.py
Normal 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
|
||||
2
src/holiday-router/requirements.txt
Normal file
2
src/holiday-router/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
boto3>=1.43.27
|
||||
requests>=2.34.2
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1005
src/slack-bot/app.py
1005
src/slack-bot/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -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;">★ 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} — ${base:.2f} × {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 — {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>"""
|
||||
|
|
|
|||
125
template.yaml
125
template.yaml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
22
tests/holiday_router/conftest.py
Normal file
22
tests/holiday_router/conftest.py
Normal 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")
|
||||
273
tests/holiday_router/test_handler.py
Normal file
273
tests/holiday_router/test_handler.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
311
tests/slack_bot/test_handle_holiday.py
Normal file
311
tests/slack_bot/test_handle_holiday.py
Normal 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
|
||||
229
tests/slack_bot/test_handle_holiday_actions.py
Normal file
229
tests/slack_bot/test_handle_holiday_actions.py
Normal 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
|
||||
374
tests/slack_bot/test_late_pickup.py
Normal file
374
tests/slack_bot/test_late_pickup.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue