mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 06:43:12 +00:00
Compare commits
7 commits
7decc63e21
...
e14eeaa17f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e14eeaa17f | ||
|
|
889a1b307c | ||
|
|
98631a3b2f | ||
|
|
745f8cd31e | ||
|
|
69220afb9a | ||
|
|
eb8f397b9e | ||
|
|
dbaf99df49 |
8 changed files with 200 additions and 73 deletions
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
|
|
@ -1,7 +1,12 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
directories:
|
||||
- "/src/slack-bot"
|
||||
- "/src/weekly-post"
|
||||
- "/src/roster-sync"
|
||||
- "/src/ring-scheduler"
|
||||
- "/src/shared"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
assignees:
|
||||
|
|
|
|||
|
|
@ -11,21 +11,18 @@ from datetime import datetime
|
|||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.ring_scheduler import update_queue_routing
|
||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||
from shared.schedule import (
|
||||
FALLBACK_EXTENSION,
|
||||
WEEKEND_DAYS,
|
||||
ShiftSchedule,
|
||||
determine_shift_type,
|
||||
)
|
||||
from shared.secrets import get_secret
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
||||
|
||||
|
||||
def determine_shift_type(now: datetime) -> str:
|
||||
day_name = now.strftime("%A")
|
||||
if day_name in WEEKEND_DAYS and now.hour < 17:
|
||||
return "day"
|
||||
return "night"
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
|
|
@ -55,13 +52,18 @@ def handler(event, context):
|
|||
extension = ext if source != "available" else FALLBACK_EXTENSION
|
||||
|
||||
secret_prefix = os.environ["TCX_SECRET_PREFIX"]
|
||||
result = update_queue_routing(
|
||||
extension=extension,
|
||||
queue_number=os.environ["QUEUE_NUMBER"],
|
||||
domain=get_secret(f"{secret_prefix}domain"),
|
||||
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||
)
|
||||
try:
|
||||
result = update_queue_routing(
|
||||
extension=extension,
|
||||
queue_number=os.environ["QUEUE_NUMBER"],
|
||||
domain=get_secret(f"{secret_prefix}domain"),
|
||||
client_id=get_secret(f"{secret_prefix}client-id"),
|
||||
client_secret=get_secret(f"{secret_prefix}client-secret"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to update 3CX queue routing")
|
||||
return {"error": True, "date": now.strftime("%Y-%m-%d"), "day": day_name}
|
||||
|
||||
result.update(
|
||||
{
|
||||
"date": now.strftime("%Y-%m-%d"),
|
||||
|
|
|
|||
|
|
@ -130,7 +130,11 @@ def build_shift_change_message(
|
|||
"""Build a channel notification for a shift change."""
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
day_label = dt.strftime("%A, %b %-d")
|
||||
type_label = f" ({SHIFT_LABELS.get(shift_type, shift_type)})" if shift_type == "day" else ""
|
||||
type_label = (
|
||||
f" ({SHIFT_LABELS.get(shift_type, shift_type)})"
|
||||
if dt.strftime("%A") in WEEKEND_DAYS
|
||||
else ""
|
||||
)
|
||||
|
||||
if action == "picked_up":
|
||||
text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,5 @@ def update_queue_routing(
|
|||
closed_destination=extension,
|
||||
holiday_destination=extension,
|
||||
)
|
||||
logger.info(
|
||||
"Updated queue %s to forward to Ext %s", queue_number, extension
|
||||
)
|
||||
logger.info("Updated queue %s to forward to Ext %s", queue_number, extension)
|
||||
return {"extension": extension, "queue": queue_number}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,30 @@ Single-table design:
|
|||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Key
|
||||
|
||||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
||||
FALLBACK_EXTENSION = "100"
|
||||
|
||||
|
||||
def determine_shift_type(now: datetime | None = None) -> str:
|
||||
"""Return the currently active shift type: 'day' or 'night'.
|
||||
|
||||
Weekend 8am-5pm is the day shift; all other times are night.
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(EASTERN)
|
||||
day_name = now.strftime("%A")
|
||||
if day_name in WEEKEND_DAYS and 8 <= now.hour < 17:
|
||||
return "day"
|
||||
return "night"
|
||||
|
||||
|
||||
class ShiftSchedule:
|
||||
def __init__(self, table_name: str | None = None):
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
|
|
@ -145,9 +161,7 @@ class ShiftSchedule:
|
|||
# ── Schedule post tracking ───────────────────────────────────────────
|
||||
|
||||
def get_schedule_post(self, channel_id: str) -> dict | None:
|
||||
resp = self.table.get_item(
|
||||
Key={"PK": "SCHEDULE_POST", "SK": channel_id}
|
||||
)
|
||||
resp = self.table.get_item(Key={"PK": "SCHEDULE_POST", "SK": channel_id})
|
||||
return resp.get("Item")
|
||||
|
||||
def save_schedule_post(
|
||||
|
|
@ -183,16 +197,22 @@ class ShiftSchedule:
|
|||
config = self.get_config()
|
||||
return config.get("admin_users", [])
|
||||
|
||||
def add_roster_entry(self, extension: str, name: str) -> None:
|
||||
self.table.put_item(
|
||||
Item={
|
||||
"PK": "ROSTER",
|
||||
"SK": extension,
|
||||
"name": name,
|
||||
"extension": extension,
|
||||
"slack_user_id": "",
|
||||
}
|
||||
)
|
||||
def add_roster_entry(self, extension: str, name: str) -> bool:
|
||||
"""Add a new roster entry. Returns False if extension already exists."""
|
||||
try:
|
||||
self.table.put_item(
|
||||
Item={
|
||||
"PK": "ROSTER",
|
||||
"SK": extension,
|
||||
"name": name,
|
||||
"extension": extension,
|
||||
"slack_user_id": "",
|
||||
},
|
||||
ConditionExpression="attribute_not_exists(PK)",
|
||||
)
|
||||
return True
|
||||
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
||||
return False
|
||||
|
||||
def remove_roster_entry(self, extension: str) -> None:
|
||||
self.table.delete_item(Key={"PK": "ROSTER", "SK": extension})
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ from shared.blocks import (
|
|||
build_week_schedule,
|
||||
)
|
||||
from shared.ring_scheduler import update_queue_routing
|
||||
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
||||
from shared.schedule import (
|
||||
FALLBACK_EXTENSION,
|
||||
WEEKEND_DAYS,
|
||||
ShiftSchedule,
|
||||
determine_shift_type,
|
||||
)
|
||||
from shared.secrets import get_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -88,6 +93,28 @@ def is_today(date_str: str) -> bool:
|
|||
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _is_active_shift_type(shift_type: str) -> bool:
|
||||
return determine_shift_type() == shift_type
|
||||
|
||||
|
||||
def _find_employee_shift(schedule, date_str, day_name, employee_ext):
|
||||
"""Find which shift type an employee is assigned to on a given date.
|
||||
|
||||
On weekends, checks both day and night shifts. Returns (ext, name, source, shift_type)
|
||||
or None if not found on any shift.
|
||||
"""
|
||||
if day_name in WEEKEND_DAYS:
|
||||
for st in ("day", "night"):
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name, st)
|
||||
if ext == employee_ext:
|
||||
return ext, name, source, st
|
||||
return None
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name, "night")
|
||||
if ext == employee_ext:
|
||||
return ext, name, source, "night"
|
||||
return None
|
||||
|
||||
|
||||
def create_app(
|
||||
bot_token: str, signing_secret: str, schedule_channel: str | None = None
|
||||
) -> App:
|
||||
|
|
@ -186,11 +213,15 @@ def create_app(
|
|||
)
|
||||
return
|
||||
|
||||
if is_today(date_str) and shift_type == "night":
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(employee["extension"])
|
||||
|
||||
blocks = build_shift_change_message(
|
||||
user_id, date_str, "picked_up", employee["extension"], employee["name"],
|
||||
user_id,
|
||||
date_str,
|
||||
"picked_up",
|
||||
employee["extension"],
|
||||
employee["name"],
|
||||
shift_type=shift_type,
|
||||
)
|
||||
respond(
|
||||
|
|
@ -218,7 +249,7 @@ def create_app(
|
|||
channel=channel,
|
||||
ts=post["message_ts"],
|
||||
blocks=blocks,
|
||||
text=f"After-Hours Schedule — {now.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
|
||||
text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Could not update schedule post", exc_info=True)
|
||||
|
|
@ -364,7 +395,19 @@ def create_app(
|
|||
return
|
||||
|
||||
day_name = date.strftime("%A")
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||
|
||||
# On weekends, find the first available shift (day then night)
|
||||
shift_type = "night"
|
||||
if day_name in WEEKEND_DAYS:
|
||||
for st in ("day", "night"):
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name, st)
|
||||
if source == "available":
|
||||
shift_type = st
|
||||
break
|
||||
else:
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||
else:
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||
|
||||
# Already assigned to someone else (not open)
|
||||
if source in ("weekly", "override") and ext != FALLBACK_EXTENSION:
|
||||
|
|
@ -373,15 +416,22 @@ def create_app(
|
|||
)
|
||||
return
|
||||
|
||||
schedule.set_override(date_str, employee["extension"], employee["name"])
|
||||
schedule.set_override(
|
||||
date_str, employee["extension"], employee["name"], shift_type
|
||||
)
|
||||
|
||||
if is_today(date_str):
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(employee["extension"])
|
||||
|
||||
respond(text=f"You picked up the shift for *{date.strftime('%A, %b %-d')}*.")
|
||||
|
||||
blocks = build_shift_change_message(
|
||||
user_id, date_str, "picked_up", employee["extension"], employee["name"]
|
||||
user_id,
|
||||
date_str,
|
||||
"picked_up",
|
||||
employee["extension"],
|
||||
employee["name"],
|
||||
shift_type=shift_type,
|
||||
)
|
||||
try:
|
||||
client.chat_postMessage(
|
||||
|
|
@ -419,22 +469,28 @@ def create_app(
|
|||
return
|
||||
|
||||
day_name = date.strftime("%A")
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||
found = _find_employee_shift(
|
||||
schedule, date_str, day_name, employee["extension"]
|
||||
)
|
||||
|
||||
if ext != employee["extension"]:
|
||||
if not found:
|
||||
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
||||
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).")
|
||||
return
|
||||
|
||||
schedule.mark_open(date_str)
|
||||
ext, name, source, shift_type = found
|
||||
schedule.mark_open(date_str, shift_type)
|
||||
|
||||
if is_today(date_str):
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(FALLBACK_EXTENSION)
|
||||
|
||||
respond(
|
||||
text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup."
|
||||
)
|
||||
|
||||
blocks = build_shift_change_message(user_id, date_str, "dropped", ext, name)
|
||||
blocks = build_shift_change_message(
|
||||
user_id, date_str, "dropped", ext, name, shift_type=shift_type
|
||||
)
|
||||
try:
|
||||
client.chat_postMessage(
|
||||
channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}"
|
||||
|
|
@ -465,15 +521,24 @@ def create_app(
|
|||
return
|
||||
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
day_name = date.strftime("%A")
|
||||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||
if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"):
|
||||
respond(text="You can't swap a shift in the past.")
|
||||
return
|
||||
|
||||
if ext != employee["extension"]:
|
||||
day_name = date.strftime("%A")
|
||||
found = _find_employee_shift(
|
||||
schedule, date_str, day_name, employee["extension"]
|
||||
)
|
||||
|
||||
if not found:
|
||||
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
||||
respond(
|
||||
text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts."
|
||||
)
|
||||
return
|
||||
|
||||
_ext, _name, _source, shift_type = found
|
||||
|
||||
# Resolve target user — could be <@U12345> or an extension number
|
||||
target_text = parts[2].strip()
|
||||
slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text)
|
||||
|
|
@ -491,9 +556,9 @@ def create_app(
|
|||
respond(text=f"Extension `{target_text}` not found in the roster.")
|
||||
return
|
||||
|
||||
schedule.set_override(date_str, target["extension"], target["name"])
|
||||
schedule.set_override(date_str, target["extension"], target["name"], shift_type)
|
||||
|
||||
if is_today(date_str):
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(target["extension"])
|
||||
|
||||
blocks = build_shift_change_message(
|
||||
|
|
@ -502,6 +567,7 @@ def create_app(
|
|||
"swapped",
|
||||
target["extension"],
|
||||
target["name"],
|
||||
shift_type=shift_type,
|
||||
)
|
||||
respond(
|
||||
text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']})."
|
||||
|
|
@ -525,9 +591,9 @@ def create_app(
|
|||
respond(
|
||||
text=(
|
||||
"*Admin Commands:*\n"
|
||||
"`admin override <date> <ext>` — Assign shift\n"
|
||||
"`admin open <date>` — Mark open\n"
|
||||
"`admin clear <date>` — Remove override\n"
|
||||
"`admin override <date> <ext> [day|night]` — Assign shift\n"
|
||||
"`admin open <date> [day|night]` — Mark open\n"
|
||||
"`admin clear <date> [day|night]` — Remove override\n"
|
||||
"`admin roster add <ext> <name>` — Add employee\n"
|
||||
"`admin roster remove <ext>` — Remove employee\n"
|
||||
"`admin roster rename <ext> <name>` — Rename"
|
||||
|
|
@ -539,49 +605,75 @@ def create_app(
|
|||
|
||||
if subcmd == "override":
|
||||
if len(parts) < 4:
|
||||
respond(text="Usage: `/oncall admin override <date> <extension>`")
|
||||
respond(
|
||||
text="Usage: `/oncall admin override <date> <extension> [day|night]`"
|
||||
)
|
||||
return
|
||||
date = parse_date(parts[2])
|
||||
if not date:
|
||||
respond(text=f"Couldn't parse date: `{parts[2]}`")
|
||||
return
|
||||
ext = parts[3]
|
||||
shift_type = (
|
||||
parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night"
|
||||
)
|
||||
employee = schedule.get_employee_by_extension(ext)
|
||||
if not employee:
|
||||
respond(text=f"Extension `{ext}` not found in the roster.")
|
||||
return
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
schedule.set_override(date_str, employee["extension"], employee["name"])
|
||||
schedule.set_override(
|
||||
date_str, employee["extension"], employee["name"], shift_type
|
||||
)
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(employee["extension"])
|
||||
label = "Day" if shift_type == "day" else "Night"
|
||||
respond(
|
||||
text=f"Override set: *{date.strftime('%A, %b %-d')}* → {employee['name']} (Ext {ext})"
|
||||
text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})"
|
||||
)
|
||||
_refresh_schedule_post(client)
|
||||
|
||||
elif subcmd == "open":
|
||||
if len(parts) < 3:
|
||||
respond(text="Usage: `/oncall admin open <date>`")
|
||||
respond(text="Usage: `/oncall admin open <date> [day|night]`")
|
||||
return
|
||||
date = parse_date(parts[2])
|
||||
if not date:
|
||||
respond(text=f"Couldn't parse date: `{parts[2]}`")
|
||||
return
|
||||
shift_type = (
|
||||
parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night"
|
||||
)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
schedule.mark_open(date_str)
|
||||
respond(text=f"*{date.strftime('%A, %b %-d')}* marked as open.")
|
||||
schedule.mark_open(date_str, shift_type)
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
_update_3cx_routing(FALLBACK_EXTENSION)
|
||||
label = "Day" if shift_type == "day" else "Night"
|
||||
respond(text=f"*{date.strftime('%A, %b %-d')}* ({label}) marked as open.")
|
||||
_refresh_schedule_post(client)
|
||||
|
||||
elif subcmd == "clear":
|
||||
if len(parts) < 3:
|
||||
respond(text="Usage: `/oncall admin clear <date>`")
|
||||
respond(text="Usage: `/oncall admin clear <date> [day|night]`")
|
||||
return
|
||||
date = parse_date(parts[2])
|
||||
if not date:
|
||||
respond(text=f"Couldn't parse date: `{parts[2]}`")
|
||||
return
|
||||
shift_type = (
|
||||
parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night"
|
||||
)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
schedule.remove_override(date_str)
|
||||
schedule.remove_override(date_str, shift_type)
|
||||
if is_today(date_str) and _is_active_shift_type(shift_type):
|
||||
day_name = date.strftime("%A")
|
||||
ext, _name, _source = schedule.resolve_shift(
|
||||
date_str, day_name, shift_type
|
||||
)
|
||||
_update_3cx_routing(ext)
|
||||
label = "Day" if shift_type == "day" else "Night"
|
||||
respond(
|
||||
text=f"Override cleared for *{date.strftime('%A, %b %-d')}* — reverted to weekly schedule."
|
||||
text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule."
|
||||
)
|
||||
_refresh_schedule_post(client)
|
||||
|
||||
|
|
@ -597,7 +689,12 @@ def create_app(
|
|||
return
|
||||
ext = parts[3]
|
||||
name = " ".join(parts[4:])
|
||||
schedule.add_roster_entry(ext, name)
|
||||
added = schedule.add_roster_entry(ext, name)
|
||||
if not added:
|
||||
respond(
|
||||
text=f"Extension `{ext}` already exists. Use `roster rename` to change the name."
|
||||
)
|
||||
return
|
||||
respond(text=f"Added *{name}* (Ext {ext}) to the roster.")
|
||||
|
||||
elif roster_cmd == "remove":
|
||||
|
|
@ -630,7 +727,9 @@ def create_app(
|
|||
)
|
||||
|
||||
else:
|
||||
respond(text="Unknown roster command. Use `add`, `remove`, or `rename`.")
|
||||
respond(
|
||||
text="Unknown roster command. Use `add`, `remove`, or `rename`."
|
||||
)
|
||||
|
||||
else:
|
||||
respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.")
|
||||
|
|
|
|||
|
|
@ -212,7 +212,9 @@ def handler(event, context):
|
|||
schedule.save_schedule_post(
|
||||
channel_id, result["ts"], this_monday.strftime("%Y-%m-%d")
|
||||
)
|
||||
logger.info("Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"])
|
||||
logger.info(
|
||||
"Posted weekly schedule to channel %s (ts=%s)", channel_id, result["ts"]
|
||||
)
|
||||
return {
|
||||
"posted": True,
|
||||
"channel": channel_id,
|
||||
|
|
|
|||
|
|
@ -195,12 +195,9 @@ Resources:
|
|||
QUEUE_NUMBER: !Ref QueueNumber
|
||||
TZ: !Ref Timezone
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref ShiftTable
|
||||
- Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- dynamodb:GetItem
|
||||
Resource:
|
||||
- !GetAtt ShiftTable.Arn
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue