ring-scheduler-3cx/src/handler.py
Adam Moussa 642d24429c Support weekend day shifts (8am-5pm) alongside after-hours
On weekends, the scheduler now runs twice:
- 8am: routes calls to the day shift person (checks DynamoDB -DAY keys)
- 5pm: switches to the night shift person (existing after-hours logic)

Adds two EventBridge rules for weekend 5pm triggers (EST/EDT).
DynamoDB keys: OVERRIDE SK=<date>-DAY, WEEKLY SK=Saturday-Day/Sunday-Day.
2026-04-07 18:30:39 -04:00

197 lines
6.7 KiB
Python

import json
import logging
import os
from datetime import datetime, timezone, timedelta
import boto3
import yaml
from src.three_cx_client import ThreeCXClient
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# US Eastern (handles DST automatically via zoneinfo on Python 3.9+)
try:
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
except ImportError:
EASTERN = timezone(timedelta(hours=-5))
def load_schedule() -> dict:
"""Load schedule from config bundled in the Lambda package."""
config_path = os.path.join(os.path.dirname(__file__), "..", "config", "schedule.yaml")
with open(config_path) as f:
return yaml.safe_load(f)
def get_3cx_credentials() -> dict:
"""Fetch 3CX credentials from SSM Parameter Store."""
ssm = boto3.client("ssm")
prefix = os.environ.get("SSM_PREFIX", "/3cx-scheduler")
params = ssm.get_parameters_by_path(
Path=prefix,
WithDecryption=True,
)
creds = {}
for p in params["Parameters"]:
key = p["Name"].split("/")[-1]
creds[key] = p["Value"]
return creds
WEEKEND_DAYS = {"Saturday", "Sunday"}
def check_dynamodb_override(date_str: str, day_name: str, shift_type: str = "night") -> str | None:
"""Check the afterhours-shifts DynamoDB table for a Slack-driven override.
Args:
date_str: Date in YYYY-MM-DD format
day_name: Day of week (e.g. "Saturday")
shift_type: "night" for after-hours, "day" for weekend daytime (8am-5pm)
Returns the extension if an override exists, None otherwise.
Only active when SHIFT_TABLE env var is set.
"""
table_name = os.environ.get("SHIFT_TABLE")
if not table_name:
return None
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(table_name)
# Check date-specific override first
override_sk = f"{date_str}-DAY" if shift_type == "day" else date_str
resp = table.get_item(Key={"PK": "OVERRIDE", "SK": override_sk})
item = resp.get("Item")
if item:
ext = item["extension"]
if ext == "OPEN":
# Open shift with no pickup — use fallback from DynamoDB config or default 100
config_resp = table.get_item(Key={"PK": "CONFIG", "SK": "CONFIG"})
fallback = config_resp.get("Item", {}).get("fallback_extension", "100")
logger.info("DynamoDB override for %s (%s): OPEN — using fallback Ext %s", override_sk, shift_type, fallback)
return fallback
logger.info("DynamoDB override for %s (%s): Ext %s", override_sk, shift_type, ext)
return ext
# Check DynamoDB weekly schedule (takes precedence over YAML when SHIFT_TABLE is set)
weekly_sk = f"{day_name}-Day" if shift_type == "day" else day_name
resp = table.get_item(Key={"PK": "WEEKLY", "SK": weekly_sk})
item = resp.get("Item")
if item:
ext = item["extension"]
logger.info("DynamoDB weekly schedule for %s (%s, %s): Ext %s", date_str, weekly_sk, shift_type, ext)
return ext
return None
def determine_shift_type(now: datetime) -> str:
"""Determine the shift type based on day of week and time.
On weekends at 8am: returns 'day' (daytime 8am-5pm shift)
On weekends at 5pm: returns 'night' (after-hours shift)
On weekdays: always returns 'night' (after-hours shift)
"""
day_name = now.strftime("%A")
if day_name in WEEKEND_DAYS and now.hour < 17:
return "day"
return "night"
def resolve_extension(schedule: dict, now: datetime, shift_type: str = "night") -> str:
"""Determine which extension should handle calls for this shift.
Args:
schedule: YAML schedule config
now: Current datetime in Eastern
shift_type: "night" for after-hours, "day" for weekend daytime
Resolution order:
1. DynamoDB date override (Slack-driven pickup/drop)
2. DynamoDB weekly schedule (if SHIFT_TABLE is set)
3. YAML exception overrides (night shifts only)
4. YAML weekly schedule (night shifts only)
"""
date_str = now.strftime("%Y-%m-%d")
day_name = now.strftime("%A")
# Check DynamoDB first (Slack shift manager)
dynamo_ext = check_dynamodb_override(date_str, day_name, shift_type)
if dynamo_ext is not None:
return dynamo_ext
# YAML config only covers night shifts (legacy)
if shift_type == "day":
logger.info("No DynamoDB entry for %s day shift — using fallback Ext 100", date_str)
return "100"
# Fall back to YAML config
exceptions = schedule.get("exceptions") or {}
if date_str in exceptions:
ext = str(exceptions[date_str])
logger.info("Using YAML exception override for %s: Ext %s", date_str, ext)
return ext
weekly = schedule["weekly_schedule"]
if day_name not in weekly:
raise ValueError(f"No schedule entry for {day_name}")
ext = str(weekly[day_name])
logger.info("Using YAML weekly schedule for %s (%s): Ext %s", date_str, day_name, ext)
return ext
def handler(event, context):
"""Lambda entry point. Triggered by EventBridge rules:
- Daily at 8am ET (EST + EDT rules) — sets after-hours route (weekdays)
or daytime route (weekends)
- Weekends at 5pm ET (EST + EDT rules) — switches to night shift person
Only one rule per time slot fires correctly depending on DST."""
now = datetime.now(EASTERN)
current_hour = now.hour
day_name = now.strftime("%A")
logger.info("Running 3CX ring group scheduler at %s", now.isoformat())
force = event.get("force", False)
valid_hours = {8, 17} if day_name in WEEKEND_DAYS else {8}
if not force and current_hour not in valid_hours:
logger.info("Current ET hour is %d on %s — skipping (wrong DST rule fired)", current_hour, day_name)
return {"skipped": True, "reason": f"ET hour is {current_hour}, not in {valid_hours}"}
schedule = load_schedule()
shift_type = determine_shift_type(now)
if event.get("override_extension"):
extension = event["override_extension"]
else:
extension = resolve_extension(schedule, now, shift_type)
creds = get_3cx_credentials()
client = ThreeCXClient(
domain=creds["domain"],
auth_mode="oauth",
client_id=creds["client_id"],
client_secret=creds["client_secret"],
)
ring_group = client.get_ring_group(schedule["ring_group_number"])
ring_group_id = ring_group["Id"]
client.update_ring_group_forwarding(
ring_group_id=ring_group_id,
closed_destination=extension,
holiday_destination=extension,
)
result = {
"date": now.strftime("%Y-%m-%d"),
"day": day_name,
"shift_type": shift_type,
"extension": extension,
"ring_group": schedule["ring_group_number"],
}
logger.info("Result: %s", json.dumps(result))
return result