mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 21:13:15 +00:00
Some checks are pending
Deploy / deploy (push) Waiting to run
Four modules move into the handbook-mandated lambdas/shared/ location, collapsing duplicated logic that had to be kept in sync by hand across the PO and WO pipelines: - ses_auth.py: the PO and WO copies were verified sha256-identical against the feature/phase-7-ops-recovery baseline before the move (no drift since the last audit). shared/ses_auth.py is the exact bytes of that one copy; both originals are git rm'd (the PO copy via rename, the WO copy as a straight delete). Bundling lands the module flat in /asset-output for both email processors, so the handlers keep `from ses_auth import authenticate_inbound_email` unchanged — zero handler diff for this move, which is what keeps fail-closed auth byte-identical through the change. - web_ui_auth.py: extracts the byte-identical _get_auth_token / _header / is_authenticated block plus the four token-cache globals out of both web_ui handlers. The per-stack INFRA-74 comments stay in each handler as-is (deliberately drifted wording, stack-specific) rather than being unified into the shared module. Fail-closed semantics (unset ARN or Secrets Manager exception -> deny) are unchanged. - email_parsing.py: parse_raw_email ships as the superset version that returns cc unconditionally. WO's output is bit-identical to before; PO simply ignores the cc field rather than being "cleaned up" to consume it. No second variant is kept. - emf.py: a generic emitter parameterized by namespace, dimension sets, and properties. Every call site's emitted EMF envelope is unchanged, including the load-bearing [["ParseMethod"],["ParseMethod","TemplateId"]] dimension-set shape the alarms and metric filters depend on. Emission ordering is untouched: PO still emits ai_fallback before the Bedrock call, WO still emits its mutually-exclusive ai_fallback/ai_fallback_rejected after its gate. The deliberate-double-count comments survive. _emit_derived_agreement_metric was found living inside derived_fields.py, so per the DERIVED-FIELDS exception it is left as a third, unconverted copy (derived_fields.py and the shadow DerivedFieldAgreement telemetry stay untouchable while that bake runs) — a comment there points at shared/emf.py for the eventual follow-up. Bundling: both email-processor cdk bundling commands gain a trailing `cp shared/*.py /asset-output/` (they were already cp-only post-Phase 7, so no pip step or manylinux pin is reintroduced). Both web_ui functions gain the same widened-root staging so web_ui_auth.py ships beside their handler; site_extractor's from_asset is untouched. Tests: PO_EXPECTED_TOP_LEVEL_MODULES gains the shared modules that now ship, the AST sibling-import check resolves imports whose source now lives under shared/, and the new shared cp line has its own revert/mutation detection. _SIBLING_MODULES resolution and _po_parser_support.py now load ses_auth/email_parsing/emf from shared/; the two-copy ses_auth byte-identity fixture-hygiene test is retired as obsolete now that there is one copy, and the ses_auth fixture parameterization over two identical copies is dropped. The sys.modules save/restore dance for template_parser (still duplicated per-pipeline) is left in place.
276 lines
10 KiB
Python
276 lines
10 KiB
Python
"""
|
|
Web UI Lambda.
|
|
|
|
Serves a simple HTML dashboard for viewing work orders and comments.
|
|
Accessed via Lambda Function URL.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from html import escape as esc
|
|
|
|
import boto3
|
|
from web_ui_auth import is_authenticated
|
|
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.INFO)
|
|
|
|
dynamodb = boto3.resource("dynamodb")
|
|
|
|
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
|
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
|
|
|
# Defense-in-depth auth gate. The public Function URL was removed (INFRA-74), but
|
|
# the handler must still refuse unauthenticated requests so any future invocation
|
|
# path does not re-expose the whole WO DB. Callers must present the shared secret
|
|
# in the X-Auth-Token header (or Authorization: Bearer <token>). The secret is
|
|
# fetched at runtime from Secrets Manager to keep it out of CloudFormation
|
|
# templates and Lambda env vars. If the ARN is unset or the secret is missing, the
|
|
# handler fails closed and denies every request.
|
|
|
|
|
|
def get_work_orders(limit=500):
|
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
|
items = []
|
|
response = table.scan()
|
|
items.extend(response.get("Items", []))
|
|
while "LastEvaluatedKey" in response:
|
|
response = table.scan(ExclusiveStartKey=response["LastEvaluatedKey"])
|
|
items.extend(response.get("Items", []))
|
|
items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
|
return items[:limit]
|
|
|
|
|
|
def get_comments(work_order_id):
|
|
table = dynamodb.Table(COMMENTS_TABLE)
|
|
items = []
|
|
kwargs = {
|
|
"KeyConditionExpression": "work_order_id = :woid",
|
|
"ExpressionAttributeValues": {":woid": work_order_id},
|
|
}
|
|
response = table.query(**kwargs)
|
|
items.extend(response.get("Items", []))
|
|
while "LastEvaluatedKey" in response:
|
|
response = table.query(**kwargs, ExclusiveStartKey=response["LastEvaluatedKey"])
|
|
items.extend(response.get("Items", []))
|
|
items.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
return items
|
|
|
|
|
|
def render_badge(value, color_map):
|
|
if not value:
|
|
value = "unknown"
|
|
color = color_map.get(value.lower(), "#9ca3af")
|
|
label = esc(value.replace("_", " ").title())
|
|
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
|
|
|
|
|
|
STATUS_COLORS = {
|
|
"new": "#3b82f6",
|
|
"assigned": "#8b5cf6",
|
|
"in_progress": "#f59e0b",
|
|
"on_hold": "#6b7280",
|
|
"completed": "#10b981",
|
|
"cancelled": "#ef4444",
|
|
"unknown": "#9ca3af",
|
|
}
|
|
|
|
RECORD_TYPE_COLORS = {
|
|
"new_work_order": "#3b82f6",
|
|
"comment": "#8b5cf6",
|
|
"update": "#f59e0b",
|
|
"cancellation": "#ef4444",
|
|
"unknown": "#9ca3af",
|
|
}
|
|
|
|
|
|
def render_work_order_detail(wo, events):
|
|
events_html = ""
|
|
if events:
|
|
for e in events:
|
|
record_type = e.get("record_type", "unknown")
|
|
commenter = esc(e.get("commenter", ""))
|
|
created = esc(e.get("created_at", ""))
|
|
text = esc(e.get("text", ""))
|
|
badge = render_badge(record_type, RECORD_TYPE_COLORS)
|
|
commenter_str = (
|
|
f"<strong>{commenter}</strong> — " if commenter else ""
|
|
)
|
|
|
|
border_colors = {
|
|
"new_work_order": "#3b82f6",
|
|
"comment": "#8b5cf6",
|
|
"update": "#f59e0b",
|
|
"cancellation": "#ef4444",
|
|
}
|
|
border = border_colors.get(record_type, "#94a3b8")
|
|
|
|
events_html += f"""
|
|
<div style="border-left:3px solid {border};padding:8px 12px;margin-bottom:10px;background:#f8fafc;border-radius:0 6px 6px 0;">
|
|
<div style="font-size:12px;color:#64748b;margin-bottom:4px;">
|
|
{badge} {commenter_str}{created}
|
|
</div>
|
|
{"<div style='font-size:14px;color:#1e293b;margin-top:6px;'>" + text + "</div>" if text else ""}
|
|
</div>"""
|
|
else:
|
|
events_html = '<p style="color:#94a3b8;font-style:italic;">No events yet.</p>'
|
|
|
|
wo_id = esc(wo.get("work_order_id", ""))
|
|
fields = [
|
|
("Description", esc(wo.get("description", "")) or None),
|
|
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
|
|
(
|
|
"Record Type",
|
|
render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS),
|
|
),
|
|
("Site Code", esc(wo.get("site_code", "")) or None),
|
|
("Building", esc(wo.get("building", "")) or None),
|
|
("Address", esc(wo.get("address", "")) or None),
|
|
("Severity", esc(wo.get("severity", "")) or None),
|
|
("Priority", esc(wo.get("priority", "")) or None),
|
|
("Due Date", esc(wo.get("due_date", "")) or None),
|
|
("Date Reported", esc(wo.get("date_reported", "")) or None),
|
|
("Scheduled Start", esc(wo.get("scheduled_start", "")) or None),
|
|
("Assigned To", esc(wo.get("assigned_to", "")) or None),
|
|
("Created", esc(wo.get("created_at", "")) or None),
|
|
("Last Updated", esc(wo.get("updated_at", "")) or None),
|
|
]
|
|
|
|
details_html = ""
|
|
for label, value in fields:
|
|
if value:
|
|
details_html += f"""
|
|
<div style="display:flex;padding:8px 0;border-bottom:1px solid #f1f5f9;">
|
|
<div style="width:140px;font-size:13px;color:#64748b;font-weight:500;">{label}</div>
|
|
<div style="flex:1;font-size:14px;color:#1e293b;">{value}</div>
|
|
</div>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WO {wo_id} - Sea Haven</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:800px;margin:0 auto;padding:20px;">
|
|
<div style="margin-bottom:20px;">
|
|
<a href="/" style="color:#3b82f6;text-decoration:none;font-size:14px;">← All Work Orders</a>
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);margin-bottom:20px;">
|
|
<h1 style="font-size:20px;margin-bottom:16px;">Work Order {wo_id}</h1>
|
|
{details_html}
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
|
<h2 style="font-size:16px;margin-bottom:16px;">Events ({len(events)})</h2>
|
|
{events_html}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def render_work_orders_list(work_orders):
|
|
rows = ""
|
|
for wo in work_orders:
|
|
wo_id = esc(wo.get("work_order_id", ""))
|
|
desc = esc(wo.get("description", ""))
|
|
site = esc(wo.get("site_code", ""))
|
|
status = wo.get("wo_status", "unknown")
|
|
record_type = wo.get("record_type", "unknown")
|
|
due = esc(wo.get("due_date", ""))
|
|
updated = esc((wo.get("updated_at") or "")[:16])
|
|
|
|
rows += f"""
|
|
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location={esc(json.dumps(f"/wo?id={wo.get("work_order_id", "")}"), quote=True)}">
|
|
<td style="padding:12px;font-weight:500;color:#3b82f6;">{wo_id}</td>
|
|
<td style="padding:12px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{desc}</td>
|
|
<td style="padding:12px;">{site}</td>
|
|
<td style="padding:12px;">{render_badge(record_type, RECORD_TYPE_COLORS)}</td>
|
|
<td style="padding:12px;">{render_badge(status, STATUS_COLORS)}</td>
|
|
<td style="padding:12px;">{due}</td>
|
|
<td style="padding:12px;color:#64748b;font-size:13px;">{updated}</td>
|
|
</tr>"""
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Work Orders - Sea Haven</title>
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|
tr:hover {{ background: #f8fafc; }}
|
|
th {{ text-align: left; padding: 12px; font-size: 12px; text-transform: uppercase; color: #64748b; border-bottom: 2px solid #e2e8f0; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="max-width:1100px;margin:0 auto;padding:20px;">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
|
<h1 style="font-size:22px;">Work Orders</h1>
|
|
<span style="color:#64748b;font-size:14px;">{len(work_orders)} total</span>
|
|
</div>
|
|
<div style="background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>WO #</th>
|
|
<th>Description</th>
|
|
<th>Site</th>
|
|
<th>Last Action</th>
|
|
<th>Status</th>
|
|
<th>Due Date</th>
|
|
<th>Updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows if rows else '<tr><td colspan="7" style="padding:40px;text-align:center;color:#94a3b8;">No work orders yet.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def handler(event, context):
|
|
if not is_authenticated(event):
|
|
return {
|
|
"statusCode": 401,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": "<h1>401 Unauthorized</h1>",
|
|
}
|
|
|
|
path = event.get("rawPath", "/")
|
|
qs = event.get("queryStringParameters") or {}
|
|
|
|
if path == "/wo" and "id" in qs:
|
|
wo_id = qs["id"]
|
|
# Get work order
|
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
|
result = table.get_item(Key={"work_order_id": wo_id})
|
|
wo = result.get("Item")
|
|
if not wo:
|
|
return {
|
|
"statusCode": 404,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": "<h1>Work order not found</h1>",
|
|
}
|
|
events = get_comments(wo_id)
|
|
html = render_work_order_detail(wo, events)
|
|
else:
|
|
work_orders = get_work_orders()
|
|
html = render_work_orders_list(work_orders)
|
|
|
|
return {
|
|
"statusCode": 200,
|
|
"headers": {"Content-Type": "text/html"},
|
|
"body": html,
|
|
}
|