diff --git a/src/sla_monitor/app.py b/src/sla_monitor/app.py index d93d5f7..d4f0657 100644 --- a/src/sla_monitor/app.py +++ b/src/sla_monitor/app.py @@ -53,6 +53,7 @@ def _get_slack_token(): # Front API # --------------------------------------------------------------------------- + def _front_get(url_or_path, params=None): if url_or_path.startswith("http"): url = url_or_path @@ -61,10 +62,13 @@ def _front_get(url_or_path, params=None): if params: url += "?" + parse.urlencode(params, doseq=True) - req = request.Request(url, headers={ - "Authorization": f"Bearer {_get_front_token()}", - "Accept": "application/json", - }) + req = request.Request( + url, + headers={ + "Authorization": f"Bearer {_get_front_token()}", + "Accept": "application/json", + }, + ) time.sleep(RATE_LIMIT_DELAY) @@ -97,12 +101,17 @@ def _front_paginate(path, params=None): # Slack API # --------------------------------------------------------------------------- + def _slack_post(method, payload): data = json.dumps(payload).encode() - req = request.Request(f"{SLACK_BASE}/{method}", data=data, headers={ - "Content-Type": "application/json; charset=utf-8", - "Authorization": f"Bearer {_get_slack_token()}", - }) + req = request.Request( + f"{SLACK_BASE}/{method}", + data=data, + headers={ + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {_get_slack_token()}", + }, + ) try: with request.urlopen(req) as resp: @@ -117,9 +126,12 @@ def _slack_post(method, payload): def _slack_get(method, params): url = f"{SLACK_BASE}/{method}?" + parse.urlencode(params) - req = request.Request(url, headers={ - "Authorization": f"Bearer {_get_slack_token()}", - }) + req = request.Request( + url, + headers={ + "Authorization": f"Bearer {_get_slack_token()}", + }, + ) try: with request.urlopen(req) as resp: @@ -146,17 +158,21 @@ def _resolve_slack_user(email): def _send_slack(channel, blocks, text): - _slack_post("chat.postMessage", { - "channel": channel, - "blocks": blocks, - "text": text, - }) + _slack_post( + "chat.postMessage", + { + "channel": channel, + "blocks": blocks, + "text": text, + }, + ) # --------------------------------------------------------------------------- # Business time calculation (weekdays only, Eastern time) # --------------------------------------------------------------------------- + def _business_minutes_elapsed(since_utc, now_utc): since = since_utc.astimezone(EASTERN) now = now_utc.astimezone(EASTERN) @@ -186,6 +202,7 @@ def _business_minutes_elapsed(since_utc, now_utc): # DynamoDB dedup # --------------------------------------------------------------------------- + def _already_alerted(conv_id, tier): resp = _get_table().get_item(Key={"conversationId": conv_id}) item = resp.get("Item") @@ -229,6 +246,7 @@ def _record_run(today_str): # Slack message blocks # --------------------------------------------------------------------------- + def _tier1_blocks(conv, assignee_email=None): subject = conv.get("subject", "No subject") conv_id = conv.get("id", "") @@ -238,13 +256,25 @@ def _tier1_blocks(conv, assignee_email=None): status = f"Assigned to {assignee_email}" if assignee_email else "Unassigned" return [ - {"type": "header", "text": {"type": "plain_text", "text": ":warning: SLA Breach: 1-Hour Acknowledgment"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": ( - f"*<{link}|{subject}>*\n" - f"Last inbound: {ts}\n" - f"Status: {status}\n" - f"_No reply for over 1 business hour._" - )}}, + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":warning: SLA Breach: 1-Hour Acknowledgment", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"*<{link}|{subject}>*\n" + f"Last inbound: {ts}\n" + f"Status: {status}\n" + f"_No reply for over 1 business hour._" + ), + }, + }, ] @@ -258,13 +288,25 @@ def _tier2_blocks(conv): status = f"Assigned to {assignee['email']}" if assignee else "Unassigned" return [ - {"type": "header", "text": {"type": "plain_text", "text": ":rotating_light: SLA Breach: 1-Day Action Required"}}, - {"type": "section", "text": {"type": "mrkdwn", "text": ( - f"*<{link}|{subject}>*\n" - f"Last inbound: {ts}\n" - f"Status: {status}\n" - f"_No reply for over 1 business day. Immediate attention required._" - )}}, + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":rotating_light: SLA Breach: 1-Day Action Required", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"*<{link}|{subject}>*\n" + f"Last inbound: {ts}\n" + f"Status: {status}\n" + f"_No reply for over 1 business day. Immediate attention required._" + ), + }, + }, ] @@ -291,7 +333,13 @@ def _summary_blocks(tier1_breaches, tier2_breaches): total = len(tier1_breaches) + len(tier2_breaches) return [ - {"type": "header", "text": {"type": "plain_text", "text": f":sunrise: Morning SLA Summary — {total} breach{'es' if total != 1 else ''}"}}, + { + "type": "header", + "text": { + "type": "plain_text", + "text": f":sunrise: Morning SLA Summary — {total} breach{'es' if total != 1 else ''}", + }, + }, {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}, ] @@ -300,6 +348,7 @@ def _summary_blocks(tier1_breaches, tier2_breaches): # Handler # --------------------------------------------------------------------------- + def handler(event, context): ack_threshold = int(os.environ["ACK_SLA_MINUTES"]) action_threshold = int(os.environ["ACTION_SLA_MINUTES"]) @@ -317,7 +366,9 @@ def handler(event, context): return {"skipped": True, "reason": "before_start_date"} if now_et.hour < BH_START or now_et.hour >= BH_END: - logger.info("Outside business hours (%s ET), skipping", now_et.strftime("%H:%M")) + logger.info( + "Outside business hours (%s ET), skipping", now_et.strftime("%H:%M") + ) return {"skipped": True, "reason": "outside_business_hours"} today_str = now_et.strftime("%Y-%m-%d") @@ -336,8 +387,11 @@ def handler(event, context): if monitor_set: shared = [i for i in shared if i.get("name", "").lower() in monitor_set] - logger.info("Monitoring %d inboxes: %s", len(shared), - ", ".join(i.get("name", "?") for i in shared)) + logger.info( + "Monitoring %d inboxes: %s", + len(shared), + ", ".join(i.get("name", "?") for i in shared), + ) tier1_breaches = [] tier2_breaches = [] @@ -356,7 +410,9 @@ def handler(event, context): logger.error("Failed to fetch conversations for %s: %s", inbox_name, e) continue - logger.info("Inbox '%s': %d recent open conversations", inbox_name, len(conversations)) + logger.info( + "Inbox '%s': %d recent open conversations", inbox_name, len(conversations) + ) for conv in conversations: if conv.get("id") in seen: @@ -377,12 +433,18 @@ def handler(event, context): logger.info("Tier 2 breach: %s", conv_id) elif elapsed >= ack_threshold and not _already_alerted(conv_id, 1): - email = assignee["email"] if assignee and assignee.get("email") else None + email = ( + assignee["email"] + if assignee and assignee.get("email") + else None + ) tier1_breaches.append((conv, email)) logger.info("Tier 1 breach: %s", conv_id) except Exception: - logger.exception("Error processing conversation %s", conv.get("id", "?")) + logger.exception( + "Error processing conversation %s", conv.get("id", "?") + ) total = len(tier1_breaches) + len(tier2_breaches) @@ -398,18 +460,27 @@ def handler(event, context): for conv in tier2_breaches: adam_uid = _resolve_slack_user(adam_email) target = adam_uid or alert_channel - _send_slack(target, _tier2_blocks(conv), - f"SLA Breach: {conv.get('subject', '')} - 1 day without reply") + _send_slack( + target, + _tier2_blocks(conv), + f"SLA Breach: {conv.get('subject', '')} - 1 day without reply", + ) for conv, email in tier1_breaches: if email: uid = _resolve_slack_user(email) target = uid or alert_channel - _send_slack(target, _tier1_blocks(conv, email), - f"SLA Breach: {conv.get('subject', '')} - 1 hour without reply") + _send_slack( + target, + _tier1_blocks(conv, email), + f"SLA Breach: {conv.get('subject', '')} - 1 hour without reply", + ) else: - _send_slack(alert_channel, _tier1_blocks(conv), - f"SLA Breach: {conv.get('subject', '')} - unassigned, 1 hour without reply") + _send_slack( + alert_channel, + _tier1_blocks(conv), + f"SLA Breach: {conv.get('subject', '')} - unassigned, 1 hour without reply", + ) for conv in tier2_breaches: _record_alert(conv["id"], 2) diff --git a/src/user_sync/app.py b/src/user_sync/app.py index 3ecabbb..a3e08bf 100644 --- a/src/user_sync/app.py +++ b/src/user_sync/app.py @@ -52,14 +52,18 @@ def _list_google_users(org_units): page_token = None while True: - result = service.users().list( - customer="my_customer", - maxResults=500, - projection="full", - orderBy="email", - query=f"orgUnitPath='{ou}'", - pageToken=page_token, - ).execute() + result = ( + service.users() + .list( + customer="my_customer", + maxResults=500, + projection="full", + orderBy="email", + query=f"orgUnitPath='{ou}'", + pageToken=page_token, + ) + .execute() + ) for user in result.get("users", []): email = user.get("primaryEmail", "") @@ -71,7 +75,11 @@ def _list_google_users(org_units): if not page_token: break - logger.info(" Found %d users in %s", len([u for u in all_users if u.get("primaryEmail") in seen]), ou) + logger.info( + " Found %d users in %s", + len([u for u in all_users if u.get("primaryEmail") in seen]), + ou, + ) return all_users @@ -88,8 +96,12 @@ def _extract_user_fields(google_user): phone = "" phones = google_user.get("phones", []) if phones: - work_phone = next((p for p in phones if p.get("type") == "work" and p.get("value")), None) - primary_phone = next((p for p in phones if p.get("primary") and p.get("value")), None) + work_phone = next( + (p for p in phones if p.get("type") == "work" and p.get("value")), None + ) + primary_phone = next( + (p for p in phones if p.get("primary") and p.get("value")), None + ) fallback = next((p for p in phones if p.get("value")), None) phone = (work_phone or primary_phone or fallback or {}).get("value", "") @@ -147,7 +159,13 @@ def handler(event, context): google_users = _list_google_users(org_units) logger.info("Found %d total users across %d OUs", len(google_users), len(org_units)) - summary = {"updated": 0, "not_in_front": 0, "no_data": 0, "errors": 0, "error_details": []} + summary = { + "updated": 0, + "not_in_front": 0, + "no_data": 0, + "errors": 0, + "error_details": [], + } for user in google_users: email, job_title, phone = _extract_user_fields(user)