mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 10:13:11 +00:00
Add CI workflow and apply ruff formatting (#57)
This commit is contained in:
parent
7aefcb9d68
commit
c741924ee7
8 changed files with 225 additions and 69 deletions
10
.github/workflows/ci.yaml
vendored
Normal file
10
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
|
||||
with:
|
||||
source-dirs: "src"
|
||||
129
src/app.py
129
src/app.py
|
|
@ -22,7 +22,15 @@ from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
|
|||
logger = logging.getLogger(__name__)
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
||||
DAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||
DAY_NAMES = [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
]
|
||||
|
||||
|
||||
def parse_date(text: str) -> datetime | None:
|
||||
|
|
@ -78,7 +86,9 @@ def is_today(date_str: str) -> bool:
|
|||
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None = None) -> App:
|
||||
def create_app(
|
||||
bot_token: str, signing_secret: str, schedule_channel: str | None = None
|
||||
) -> App:
|
||||
app = App(
|
||||
token=bot_token,
|
||||
signing_secret=signing_secret,
|
||||
|
|
@ -110,11 +120,17 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
elif text.startswith("register"):
|
||||
_handle_register(respond, schedule, user_id, text)
|
||||
elif text.startswith("pick"):
|
||||
_handle_pick(respond, schedule, user_id, text, schedule_channel or channel_id, client)
|
||||
_handle_pick(
|
||||
respond, schedule, user_id, text, schedule_channel or channel_id, client
|
||||
)
|
||||
elif text.startswith("drop"):
|
||||
_handle_drop(respond, schedule, user_id, text, schedule_channel or channel_id, client)
|
||||
_handle_drop(
|
||||
respond, schedule, user_id, text, schedule_channel or channel_id, client
|
||||
)
|
||||
elif text.startswith("swap"):
|
||||
_handle_swap(respond, schedule, user_id, text, schedule_channel or channel_id, client)
|
||||
_handle_swap(
|
||||
respond, schedule, user_id, text, schedule_channel or channel_id, client
|
||||
)
|
||||
else:
|
||||
respond(text="Unknown command. Try `/oncall help`")
|
||||
|
||||
|
|
@ -144,7 +160,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
)
|
||||
return
|
||||
|
||||
claimed = schedule.claim_open_shift(date_str, employee["extension"], employee["name"], shift_type)
|
||||
claimed = schedule.claim_open_shift(
|
||||
date_str, employee["extension"], employee["name"], shift_type
|
||||
)
|
||||
if not claimed:
|
||||
client.chat_postEphemeral(
|
||||
channel=channel_id,
|
||||
|
|
@ -159,7 +177,12 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
blocks = build_shift_change_message(
|
||||
user_id, date_str, "picked_up", employee["extension"], employee["name"]
|
||||
)
|
||||
respond(response_type="in_channel", replace_original=False, blocks=blocks, text=f"Shift picked up for {date_str}")
|
||||
respond(
|
||||
response_type="in_channel",
|
||||
replace_original=False,
|
||||
blocks=blocks,
|
||||
text=f"Shift picked up for {date_str}",
|
||||
)
|
||||
|
||||
# ── Subcommand handlers ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -185,13 +208,17 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
pay_record = schedule.get_pay_record(week_key)
|
||||
if pay_record and pay_record.get("breakdown"):
|
||||
prev_sunday = prev_monday + timedelta(days=6)
|
||||
week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
||||
week_label = (
|
||||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
||||
)
|
||||
blocks = build_pay_summary_blocks(
|
||||
week_label, pay_record["breakdown"], pay_record["totals"]
|
||||
)
|
||||
respond(blocks=blocks)
|
||||
else:
|
||||
respond(text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}.")
|
||||
respond(
|
||||
text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}."
|
||||
)
|
||||
|
||||
def _handle_rate(respond, schedule, text):
|
||||
parts = text.split()
|
||||
|
|
@ -200,7 +227,11 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
default_rate = schedule.get_shift_rate()
|
||||
roster = schedule.get_roster()
|
||||
lines = [f"*Default rate:* ${default_rate:.2f}/shift\n"]
|
||||
custom = [(e["SK"], e.get("name", "Unknown"), float(e["shift_rate"])) for e in roster if e.get("shift_rate")]
|
||||
custom = [
|
||||
(e["SK"], e.get("name", "Unknown"), float(e["shift_rate"]))
|
||||
for e in roster
|
||||
if e.get("shift_rate")
|
||||
]
|
||||
if custom:
|
||||
lines.append("*Per-person rates:*")
|
||||
for ext, name, rate in sorted(custom, key=lambda x: x[0]):
|
||||
|
|
@ -213,7 +244,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
# /oncall rate default <amount>
|
||||
if parts[1] == "default":
|
||||
if len(parts) < 3:
|
||||
respond(text="Usage: `/oncall rate default <amount>` (e.g. `/oncall rate default 50`)")
|
||||
respond(
|
||||
text="Usage: `/oncall rate default <amount>` (e.g. `/oncall rate default 50`)"
|
||||
)
|
||||
return
|
||||
try:
|
||||
amount = float(parts[2].replace("$", ""))
|
||||
|
|
@ -226,7 +259,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
|
||||
# /oncall rate <extension> <amount>
|
||||
if len(parts) < 3:
|
||||
respond(text="Usage: `/oncall rate <extension> <amount>` (e.g. `/oncall rate 114 75`)")
|
||||
respond(
|
||||
text="Usage: `/oncall rate <extension> <amount>` (e.g. `/oncall rate 114 75`)"
|
||||
)
|
||||
return
|
||||
|
||||
ext = parts[1]
|
||||
|
|
@ -242,18 +277,24 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
return
|
||||
|
||||
schedule.set_employee_shift_rate(ext, amount)
|
||||
respond(text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*.")
|
||||
respond(
|
||||
text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*."
|
||||
)
|
||||
|
||||
def _handle_register(respond, schedule, user_id, text):
|
||||
parts = text.split()
|
||||
if len(parts) < 2:
|
||||
respond(text="Usage: `/oncall register <extension>` (e.g. `/oncall register 114`)")
|
||||
respond(
|
||||
text="Usage: `/oncall register <extension>` (e.g. `/oncall register 114`)"
|
||||
)
|
||||
return
|
||||
|
||||
ext = parts[1].strip()
|
||||
employee = schedule.register_user(user_id, ext)
|
||||
if not employee:
|
||||
respond(text=f"Extension {ext} not found in the roster. Check `/oncall roster`.")
|
||||
respond(
|
||||
text=f"Extension {ext} not found in the roster. Check `/oncall roster`."
|
||||
)
|
||||
return
|
||||
|
||||
respond(text=f"Linked your account to *{employee['name']}* (Ext {ext}).")
|
||||
|
|
@ -266,12 +307,16 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
|
||||
employee = schedule.get_employee_by_slack_id(user_id)
|
||||
if not employee:
|
||||
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||
respond(
|
||||
text="You're not registered. Use `/oncall register <extension>` first."
|
||||
)
|
||||
return
|
||||
|
||||
date = parse_date(parts[1])
|
||||
if not date:
|
||||
respond(text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05")
|
||||
respond(
|
||||
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
|
||||
)
|
||||
return
|
||||
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
|
@ -280,7 +325,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
|
||||
# Already assigned to someone else (not open)
|
||||
if source in ("weekly", "override") and ext != FALLBACK_EXTENSION:
|
||||
respond(text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first.")
|
||||
respond(
|
||||
text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first."
|
||||
)
|
||||
return
|
||||
|
||||
schedule.set_override(date_str, employee["extension"], employee["name"])
|
||||
|
|
@ -294,7 +341,11 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
user_id, date_str, "picked_up", employee["extension"], employee["name"]
|
||||
)
|
||||
try:
|
||||
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift picked up for {date_str}")
|
||||
client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
blocks=blocks,
|
||||
text=f"Shift picked up for {date_str}",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to post pickup notification to channel")
|
||||
|
||||
|
|
@ -306,12 +357,16 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
|
||||
employee = schedule.get_employee_by_slack_id(user_id)
|
||||
if not employee:
|
||||
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||
respond(
|
||||
text="You're not registered. Use `/oncall register <extension>` first."
|
||||
)
|
||||
return
|
||||
|
||||
date = parse_date(parts[1])
|
||||
if not date:
|
||||
respond(text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05")
|
||||
respond(
|
||||
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
|
||||
)
|
||||
return
|
||||
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
|
@ -327,11 +382,15 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
if is_today(date_str):
|
||||
invoke_3cx_scheduler(FALLBACK_EXTENSION)
|
||||
|
||||
respond(text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup.")
|
||||
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)
|
||||
try:
|
||||
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}")
|
||||
client.chat_postMessage(
|
||||
channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to post drop notification to channel")
|
||||
|
||||
|
|
@ -339,12 +398,16 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
# Expected format: swap <date> @user OR swap <date> <extension>
|
||||
parts = text.split(maxsplit=2)
|
||||
if len(parts) < 3:
|
||||
respond(text="Usage: `/oncall swap <date> @person` (e.g. `/oncall swap friday @sarah`)")
|
||||
respond(
|
||||
text="Usage: `/oncall swap <date> @person` (e.g. `/oncall swap friday @sarah`)"
|
||||
)
|
||||
return
|
||||
|
||||
employee = schedule.get_employee_by_slack_id(user_id)
|
||||
if not employee:
|
||||
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||
respond(
|
||||
text="You're not registered. Use `/oncall register <extension>` first."
|
||||
)
|
||||
return
|
||||
|
||||
date = parse_date(parts[1])
|
||||
|
|
@ -357,7 +420,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||
|
||||
if ext != employee["extension"]:
|
||||
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts.")
|
||||
respond(
|
||||
text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts."
|
||||
)
|
||||
return
|
||||
|
||||
# Resolve target user — could be <@U12345> or an extension number
|
||||
|
|
@ -367,7 +432,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
target_slack_id = slack_id_match.group(1)
|
||||
target = schedule.get_employee_by_slack_id(target_slack_id)
|
||||
if not target:
|
||||
respond(text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register <extension>`.")
|
||||
respond(
|
||||
text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register <extension>`."
|
||||
)
|
||||
return
|
||||
else:
|
||||
target = schedule.get_employee_by_extension(target_text)
|
||||
|
|
@ -387,10 +454,14 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
|
|||
target["extension"],
|
||||
target["name"],
|
||||
)
|
||||
respond(text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']}).")
|
||||
respond(
|
||||
text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']})."
|
||||
)
|
||||
|
||||
try:
|
||||
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}")
|
||||
client.chat_postMessage(
|
||||
channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to post swap notification to channel")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ EASTERN = ZoneInfo("America/New_York")
|
|||
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
||||
|
||||
|
||||
def _format_shift_line(date: datetime, ext: str, name: str, source: str, is_today: bool, shift_type: str = "night") -> str:
|
||||
def _format_shift_line(
|
||||
date: datetime,
|
||||
ext: str,
|
||||
name: str,
|
||||
source: str,
|
||||
is_today: bool,
|
||||
shift_type: str = "night",
|
||||
) -> str:
|
||||
day_label = date.strftime("%a %b %-d")
|
||||
if shift_type == "day":
|
||||
day_label += " (Day 8a–5p)"
|
||||
|
|
@ -65,7 +72,10 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
|
|||
blocks = [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {"type": "plain_text", "text": f"After-Hours Schedule — {start_label} to {end_label}"},
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"After-Hours Schedule — {start_label} to {end_label}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
|
|
@ -84,14 +94,19 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
|
|||
elements.append(
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}"},
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}",
|
||||
},
|
||||
"action_id": f"pickup_{date_str}{action_suffix}",
|
||||
"style": "primary",
|
||||
}
|
||||
)
|
||||
# Slack allows max 5 buttons per actions block — use multiple blocks
|
||||
for chunk_start in range(0, len(elements), 5):
|
||||
blocks.append({"type": "actions", "elements": elements[chunk_start:chunk_start + 5]})
|
||||
blocks.append(
|
||||
{"type": "actions", "elements": elements[chunk_start : chunk_start + 5]}
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
|
|
@ -136,7 +151,9 @@ def build_help_blocks() -> list[dict]:
|
|||
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
|
||||
|
||||
|
||||
def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]) -> list[dict]:
|
||||
def build_pay_summary_blocks(
|
||||
week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]
|
||||
) -> list[dict]:
|
||||
"""Build Block Kit blocks for the weekly pay summary.
|
||||
|
||||
Args:
|
||||
|
|
@ -146,12 +163,16 @@ def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_b
|
|||
"""
|
||||
lines = []
|
||||
for entry in pay_breakdown:
|
||||
lines.append(f"{entry['day']} {entry['date_label']} — {entry['name']} (Ext {entry['extension']}) — ${entry['rate']:.2f}")
|
||||
lines.append(
|
||||
f"{entry['day']} {entry['date_label']} — {entry['name']} (Ext {entry['extension']}) — ${entry['rate']:.2f}"
|
||||
)
|
||||
|
||||
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 ""
|
||||
lines.append(f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''}{rate_note} — *${info['total']:.2f}*")
|
||||
lines.append(
|
||||
f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''}{rate_note} — *${info['total']:.2f}*"
|
||||
)
|
||||
|
||||
blocks = [
|
||||
{
|
||||
|
|
@ -169,11 +190,18 @@ def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_b
|
|||
def build_roster_blocks(roster: list[dict]) -> list[dict]:
|
||||
"""Build roster display blocks."""
|
||||
if not roster:
|
||||
return [{"type": "section", "text": {"type": "mrkdwn", "text": "No employees in the roster."}}]
|
||||
return [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "No employees in the roster."},
|
||||
}
|
||||
]
|
||||
|
||||
lines = ["*On-Call Roster*\n"]
|
||||
for emp in sorted(roster, key=lambda x: x["SK"]):
|
||||
slack = f"<@{emp['slack_user_id']}>" if emp.get("slack_user_id") else "_not linked_"
|
||||
slack = (
|
||||
f"<@{emp['slack_user_id']}>" if emp.get("slack_user_id") else "_not linked_"
|
||||
)
|
||||
lines.append(f"Ext {emp['SK']} — {emp.get('name', 'Unknown')} ({slack})")
|
||||
|
||||
return [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ logger = logging.getLogger()
|
|||
logger.setLevel(logging.INFO)
|
||||
|
||||
SlackRequestHandler.clear_all_log_handlers()
|
||||
logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO)
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO
|
||||
)
|
||||
|
||||
# Lazy-initialized app singleton
|
||||
_slack_handler = None
|
||||
|
|
|
|||
|
|
@ -41,12 +41,16 @@ def handler(event, context):
|
|||
# DST guard — two EventBridge rules fire, only one is at 6am ET
|
||||
force = event.get("force", False)
|
||||
if not force and now.hour != 6:
|
||||
logger.info("ET hour is %d, not 6am — skipping (wrong DST rule fired)", now.hour)
|
||||
logger.info(
|
||||
"ET hour is %d, not 6am — skipping (wrong DST rule fired)", now.hour
|
||||
)
|
||||
return {"skipped": True}
|
||||
|
||||
group_name = os.environ.get("SYNC_GROUP", "DEFAULT")
|
||||
|
||||
logger.info("Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat())
|
||||
logger.info(
|
||||
"Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat()
|
||||
)
|
||||
|
||||
creds = get_3cx_credentials()
|
||||
client = ThreeCXClient(
|
||||
|
|
@ -59,10 +63,16 @@ def handler(event, context):
|
|||
members = client.get_group_members(group_name)
|
||||
# Only sync actual phone extensions, not ring groups, IVRs, etc.
|
||||
extensions = [
|
||||
m for m in members
|
||||
m
|
||||
for m in members
|
||||
if m.get("Type") == "Extension" and m.get("MemberName") not in EXCLUDE_NAMES
|
||||
]
|
||||
logger.info("Found %d extensions in 3CX group '%s' (filtered from %d members)", len(extensions), group_name, len(members))
|
||||
logger.info(
|
||||
"Found %d extensions in 3CX group '%s' (filtered from %d members)",
|
||||
len(extensions),
|
||||
group_name,
|
||||
len(members),
|
||||
)
|
||||
|
||||
schedule = ShiftSchedule()
|
||||
current_roster = {item["SK"]: item for item in schedule.get_roster()}
|
||||
|
|
@ -111,7 +121,9 @@ def handler(event, context):
|
|||
schedule.table.delete_item(Key={"PK": "ROSTER", "SK": ext_number})
|
||||
removed.append(f"Ext {ext_number}: {item.get('name', 'Unknown')}")
|
||||
elif current_roster:
|
||||
logger.warning("No 3CX extensions found but roster is non-empty — skipping removal to prevent data loss")
|
||||
logger.warning(
|
||||
"No 3CX extensions found but roster is non-empty — skipping removal to prevent data loss"
|
||||
)
|
||||
|
||||
result = {
|
||||
"group": group_name,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ class ShiftSchedule:
|
|||
resp = self.table.get_item(Key={"PK": "OVERRIDE", "SK": sk})
|
||||
return resp.get("Item")
|
||||
|
||||
def set_override(self, date_str: str, extension: str, name: str, shift_type: str = "night") -> None:
|
||||
def set_override(
|
||||
self, date_str: str, extension: str, name: str, shift_type: str = "night"
|
||||
) -> None:
|
||||
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
|
||||
self.table.put_item(
|
||||
Item={
|
||||
|
|
@ -73,7 +75,9 @@ class ShiftSchedule:
|
|||
}
|
||||
)
|
||||
|
||||
def claim_open_shift(self, date_str: str, extension: str, name: str, shift_type: str = "night") -> bool:
|
||||
def claim_open_shift(
|
||||
self, date_str: str, extension: str, name: str, shift_type: str = "night"
|
||||
) -> bool:
|
||||
"""Atomically claim a shift only if it is currently open.
|
||||
|
||||
Succeeds when no override exists (weekly fallback) or the override
|
||||
|
|
@ -112,7 +116,9 @@ class ShiftSchedule:
|
|||
|
||||
# ── Resolution ──────────────────────────────────────────────────────
|
||||
|
||||
def resolve_shift(self, date_str: str, day_name: str, shift_type: str = "night") -> tuple[str, str, str]:
|
||||
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.
|
||||
|
||||
shift_type: 'night' (after-hours) or 'day' (weekend 8am-5pm)
|
||||
|
|
@ -145,9 +151,7 @@ class ShiftSchedule:
|
|||
|
||||
def save_pay_record(self, week_key: str, record: dict) -> None:
|
||||
"""Save a weekly pay summary. week_key is the Monday date string."""
|
||||
self.table.put_item(
|
||||
Item={"PK": "PAY", "SK": week_key, **record}
|
||||
)
|
||||
self.table.put_item(Item={"PK": "PAY", "SK": week_key, **record})
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,17 @@ class ThreeCXClient:
|
|||
"""
|
||||
self.base_url = f"https://{domain}"
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"OData-Version": "4.0",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
self.session.headers.update(
|
||||
{
|
||||
"OData-Version": "4.0",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
if auth_mode == "oauth":
|
||||
self._authenticate_oauth(auth_kwargs["client_id"], auth_kwargs["client_secret"])
|
||||
self._authenticate_oauth(
|
||||
auth_kwargs["client_id"], auth_kwargs["client_secret"]
|
||||
)
|
||||
else:
|
||||
self._authenticate_user(auth_kwargs["username"], auth_kwargs["password"])
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,15 @@ logger.setLevel(logging.INFO)
|
|||
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
||||
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
DAY_ORDER = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
]
|
||||
|
||||
|
||||
WEEKEND_DAYS = {"Saturday", "Sunday"}
|
||||
|
|
@ -35,17 +43,24 @@ def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="n
|
|||
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)),
|
||||
})
|
||||
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)),
|
||||
}
|
||||
)
|
||||
|
||||
if name not in totals:
|
||||
totals[name] = {"shifts": 0, "total": Decimal("0"), "extension": ext, "rate": Decimal(str(rate))}
|
||||
totals[name] = {
|
||||
"shifts": 0,
|
||||
"total": Decimal("0"),
|
||||
"extension": ext,
|
||||
"rate": Decimal(str(rate)),
|
||||
}
|
||||
totals[name]["shifts"] += 1
|
||||
totals[name]["total"] += Decimal(str(rate))
|
||||
|
||||
|
|
@ -127,7 +142,9 @@ def handler(event, context):
|
|||
# DST guard — same pattern as the 3CX scheduler
|
||||
force = event.get("force", False)
|
||||
if not force and now.hour != 7:
|
||||
logger.info("ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour)
|
||||
logger.info(
|
||||
"ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour
|
||||
)
|
||||
return {"skipped": True}
|
||||
|
||||
ssm = boto3.client("ssm")
|
||||
|
|
@ -153,7 +170,9 @@ def handler(event, context):
|
|||
schedule.save_pay_record(week_key, pay_record)
|
||||
|
||||
prev_sunday = prev_monday + timedelta(days=6)
|
||||
week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
||||
week_label = (
|
||||
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
|
||||
)
|
||||
pay_blocks = build_pay_summary_blocks(
|
||||
week_label, pay_record["breakdown"], pay_record["totals"]
|
||||
)
|
||||
|
|
@ -165,7 +184,9 @@ def handler(event, context):
|
|||
blocks=pay_blocks,
|
||||
text=f"After-Hours Pay — {week_label}",
|
||||
)
|
||||
logger.info("Sent pay summary DM to %s for week of %s", pay_dm_user, week_key)
|
||||
logger.info(
|
||||
"Sent pay summary DM to %s for week of %s", pay_dm_user, week_key
|
||||
)
|
||||
else:
|
||||
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
|
||||
|
||||
|
|
@ -184,4 +205,8 @@ def handler(event, context):
|
|||
)
|
||||
|
||||
logger.info("Posted weekly schedule to channel %s", channel_id)
|
||||
return {"posted": True, "channel": channel_id, "pay_calculated": bool(pay_record["breakdown"])}
|
||||
return {
|
||||
"posted": True,
|
||||
"channel": channel_id,
|
||||
"pay_calculated": bool(pay_record["breakdown"]),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue