511 lines
23 KiB
Python
511 lines
23 KiB
Python
import json
|
|
import os
|
|
import sqlite3
|
|
from contextlib import closing
|
|
from datetime import datetime
|
|
from html import escape
|
|
from urllib.parse import urlencode
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
DB_PATH = os.getenv("WEBHOOK_DB_PATH", "./servicem8_webhooks.db")
|
|
STATE_DB_PATH = os.getenv("WEBHOOK_STATE_DB_PATH", "./servicem8_quote_materials_state.db")
|
|
APP_HOST = os.getenv("WEBHOOK_INSPECTOR_HOST", "0.0.0.0")
|
|
APP_PORT = int(os.getenv("WEBHOOK_INSPECTOR_PORT", "18355"))
|
|
PAGE_SIZE = 50
|
|
|
|
app = FastAPI(title="ServiceM8 Inspector", version="0.1.0")
|
|
|
|
|
|
def get_conn():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def get_state_conn():
|
|
conn = sqlite3.connect(STATE_DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def html_page(title: str, body: str) -> HTMLResponse:
|
|
nav = """
|
|
<nav>
|
|
<a href='/'>Dashboard</a>
|
|
<a href='/events'>Events</a>
|
|
<a href='/objects'>Objects</a>
|
|
<a href='/form-responses'>Form responses</a>
|
|
<a href='/generated-materials'>Generated materials</a>
|
|
</nav>
|
|
"""
|
|
css = """
|
|
<style>
|
|
body { font-family: Inter, system-ui, sans-serif; margin: 24px; color: #1f2937; background: #f8fafc; }
|
|
nav { margin-bottom: 20px; display: flex; gap: 14px; flex-wrap: wrap; }
|
|
nav a { text-decoration: none; color: #0f766e; font-weight: 600; }
|
|
h1, h2, h3 { margin-bottom: 0.4rem; }
|
|
.muted { color: #6b7280; }
|
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 16px 0 24px; }
|
|
.card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
|
|
.big { font-size: 1.6rem; font-weight: 700; }
|
|
table { width: 100%; border-collapse: collapse; background: white; }
|
|
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
|
|
th { background: #f1f5f9; position: sticky; top: 0; }
|
|
tr:hover td { background: #f8fafc; }
|
|
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
pre { white-space: pre-wrap; word-break: break-word; background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 10px; overflow-x: auto; }
|
|
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-size: 0.85rem; margin: 2px 4px 2px 0; }
|
|
.section { margin: 24px 0; }
|
|
.toolbar { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 16px; }
|
|
input[type='text'] { padding: 8px; width: 280px; max-width: 100%; }
|
|
button { padding: 8px 12px; }
|
|
.pagination { margin-top: 16px; display: flex; gap: 12px; }
|
|
.summary-grid { display: grid; grid-template-columns: 220px 1fr; gap: 8px 16px; }
|
|
.summary-grid div { padding: 4px 0; }
|
|
details { margin: 12px 0; }
|
|
a { color: #0f766e; }
|
|
</style>
|
|
"""
|
|
return HTMLResponse(f"<!doctype html><html><head><meta charset='utf-8'><title>{escape(title)}</title>{css}</head><body>{nav}<h1>{escape(title)}</h1>{body}</body></html>")
|
|
|
|
|
|
def pretty_json(value):
|
|
try:
|
|
if isinstance(value, str):
|
|
parsed = json.loads(value)
|
|
else:
|
|
parsed = value
|
|
return json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def parse_json_field(value, default=None):
|
|
if value is None:
|
|
return default
|
|
try:
|
|
return json.loads(value)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def event_summary(payload):
|
|
data = payload.get("data", {}) if isinstance(payload, dict) else {}
|
|
related = payload.get("related", {}) if isinstance(payload, dict) else {}
|
|
company = related.get("company", {}) if isinstance(related, dict) else {}
|
|
job_number = data.get("generated_job_id", "")
|
|
purchase_order_number = data.get("purchase_order_number", "")
|
|
return {
|
|
"event_type": payload.get("type", "") if isinstance(payload, dict) else "",
|
|
"uuid": data.get("uuid", ""),
|
|
"generated_job_id": job_number,
|
|
"job_number": job_number,
|
|
"purchase_order_number": purchase_order_number,
|
|
"status": data.get("status", ""),
|
|
"edit_date": data.get("edit_date", ""),
|
|
"company_name": company.get("name", ""),
|
|
}
|
|
|
|
|
|
def format_pills(values):
|
|
if not values:
|
|
return ""
|
|
return " ".join(f"<span class='pill'>{escape(str(v))}</span>" for v in values)
|
|
|
|
|
|
def link_with_params(path, **params):
|
|
filtered = {k: v for k, v in params.items() if v not in (None, "")}
|
|
return f"{path}?{urlencode(filtered)}" if filtered else path
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True, "db_path": DB_PATH, "state_db_path": STATE_DB_PATH}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def dashboard():
|
|
with closing(get_conn()) as conn:
|
|
counts = {
|
|
"webhook_events": conn.execute("select count(*) from webhook_events").fetchone()[0],
|
|
"webhook_objects": conn.execute("select count(*) from webhook_objects").fetchone()[0],
|
|
"webhook_form_responses": conn.execute("select count(*) from webhook_form_responses").fetchone()[0],
|
|
}
|
|
latest_event = conn.execute("select received_at from webhook_events order by id desc limit 1").fetchone()
|
|
latest_object = conn.execute("select received_at from webhook_objects order by id desc limit 1").fetchone()
|
|
latest_form = conn.execute("select received_at from webhook_form_responses order by id desc limit 1").fetchone()
|
|
|
|
state_count = 0
|
|
latest_generated = None
|
|
try:
|
|
with closing(get_state_conn()) as conn:
|
|
state_count = conn.execute("select count(*) from generated_job_materials").fetchone()[0]
|
|
latest_generated = conn.execute("select updated_at from generated_job_materials order by id desc limit 1").fetchone()
|
|
except sqlite3.Error:
|
|
pass
|
|
|
|
counts["generated_job_materials"] = state_count
|
|
|
|
cards = "".join(
|
|
f"<div class='card'><div class='muted'>{escape(name)}</div><div class='big'>{count}</div></div>"
|
|
for name, count in counts.items()
|
|
)
|
|
body = f"""
|
|
<div class='cards'>{cards}</div>
|
|
<div class='card'>
|
|
<div class='summary-grid'>
|
|
<div><strong>DB path</strong></div><div><code>{escape(DB_PATH)}</code></div>
|
|
<div><strong>State DB path</strong></div><div><code>{escape(STATE_DB_PATH)}</code></div>
|
|
<div><strong>Latest event</strong></div><div>{escape(latest_event[0] if latest_event else '—')}</div>
|
|
<div><strong>Latest object</strong></div><div>{escape(latest_object[0] if latest_object else '—')}</div>
|
|
<div><strong>Latest form response</strong></div><div>{escape(latest_form[0] if latest_form else '—')}</div>
|
|
<div><strong>Latest generated material row</strong></div><div>{escape(latest_generated[0] if latest_generated else '—')}</div>
|
|
</div>
|
|
</div>
|
|
<div class='section'>
|
|
<h2>Quick links</h2>
|
|
<ul>
|
|
<li><a href='/events'>Browse webhook events</a></li>
|
|
<li><a href='/objects'>Browse object webhooks</a></li>
|
|
<li><a href='/form-responses'>Browse form responses</a></li>
|
|
<li><a href='/generated-materials'>Browse generated job-material state</a></li>
|
|
</ul>
|
|
</div>
|
|
"""
|
|
return html_page("ServiceM8 Inspector", body)
|
|
|
|
|
|
@app.get("/events", response_class=HTMLResponse)
|
|
def list_events(q: str = Query(""), page: int = Query(1, ge=1)):
|
|
offset = (page - 1) * PAGE_SIZE
|
|
with closing(get_conn()) as conn:
|
|
rows = conn.execute(
|
|
"select id, received_at, payload_json, path from webhook_events order by id desc limit ? offset ?",
|
|
(PAGE_SIZE, offset),
|
|
).fetchall()
|
|
|
|
filtered = []
|
|
q_lower = q.lower().strip()
|
|
q_digits = "".join(ch for ch in q if ch.isdigit())
|
|
for row in rows:
|
|
payload = parse_json_field(row["payload_json"], {}) or {}
|
|
summary = event_summary(payload)
|
|
job_number = str(summary.get("job_number", "") or "")
|
|
purchase_order_number = str(summary.get("purchase_order_number", "") or "")
|
|
haystack = " ".join(str(v) for v in summary.values()).lower() + " " + str(payload).lower()
|
|
job_match = bool(q_digits) and (q_digits in job_number or q_digits in purchase_order_number)
|
|
if q_lower and not job_match and q_lower not in haystack:
|
|
continue
|
|
filtered.append((row, payload, summary))
|
|
|
|
table_rows = []
|
|
for row, payload, summary in filtered:
|
|
table_rows.append(
|
|
f"<tr>"
|
|
f"<td><a href='/events/{row['id']}'>{row['id']}</a></td>"
|
|
f"<td>{escape(row['received_at'])}</td>"
|
|
f"<td>{escape(summary['event_type'])}</td>"
|
|
f"<td>{escape(summary['generated_job_id'])}</td>"
|
|
f"<td>{escape(summary['uuid'])}</td>"
|
|
f"<td>{escape(summary['status'])}</td>"
|
|
f"<td>{escape(summary['company_name'])}</td>"
|
|
f"<td>{escape(row['path'])}</td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
body = f"""
|
|
<div class='toolbar'>
|
|
<form method='get'>
|
|
<label>Search <input type='text' name='q' value='{escape(q)}' placeholder='job number, uuid, status, company, type'></label>
|
|
<input type='hidden' name='page' value='1'>
|
|
<button type='submit'>Filter</button>
|
|
</form>
|
|
<div class='muted'>Search supports job number / generated job ID, UUID, status, company, or event type. Showing up to {PAGE_SIZE} latest rows, then filtering in-app.</div>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Received</th><th>Type</th><th>Job ID</th><th>UUID</th><th>Status</th><th>Company</th><th>Path</th></tr></thead>
|
|
<tbody>{''.join(table_rows) or "<tr><td colspan='8'>No rows matched.</td></tr>"}</tbody>
|
|
</table>
|
|
<div class='pagination'>
|
|
{f"<a href='{link_with_params('/events', q=q, page=page-1)}'>← Prev</a>" if page > 1 else ''}
|
|
<a href='{link_with_params('/events', q=q, page=page+1)}'>Next →</a>
|
|
</div>
|
|
"""
|
|
return html_page("Webhook events", body)
|
|
|
|
|
|
@app.get("/events/{event_id}", response_class=HTMLResponse)
|
|
def event_detail(event_id: int):
|
|
with closing(get_conn()) as conn:
|
|
row = conn.execute("select * from webhook_events where id = ?", (event_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Event not found")
|
|
|
|
payload = parse_json_field(row["payload_json"], {}) or {}
|
|
headers = parse_json_field(row["headers_json"], {}) or {}
|
|
summary = event_summary(payload)
|
|
related = payload.get("related", {}) if isinstance(payload, dict) else {}
|
|
company = related.get("company", {}) if isinstance(related, dict) else {}
|
|
contacts = related.get("jobContacts", []) if isinstance(related, dict) else []
|
|
materials = related.get("jobMaterials", []) if isinstance(related, dict) else []
|
|
|
|
body = f"""
|
|
<div class='card summary-grid'>
|
|
<div><strong>ID</strong></div><div>{row['id']}</div>
|
|
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
|
|
<div><strong>Event type</strong></div><div>{escape(summary['event_type'])}</div>
|
|
<div><strong>UUID</strong></div><div>{escape(summary['uuid'])}</div>
|
|
<div><strong>Generated job ID</strong></div><div>{escape(summary['generated_job_id'])}</div>
|
|
<div><strong>Status</strong></div><div>{escape(summary['status'])}</div>
|
|
<div><strong>Company</strong></div><div>{escape(summary['company_name'])}</div>
|
|
<div><strong>Path</strong></div><div>{escape(row['path'])}</div>
|
|
</div>
|
|
<div class='section'>
|
|
<h2>Related summary</h2>
|
|
<div class='card summary-grid'>
|
|
<div><strong>Company name</strong></div><div>{escape(company.get('name', ''))}</div>
|
|
<div><strong>Contacts</strong></div><div>{len(contacts)}</div>
|
|
<div><strong>Materials</strong></div><div>{len(materials)}</div>
|
|
</div>
|
|
</div>
|
|
<div class='section'>
|
|
<h2>Headers</h2>
|
|
<pre>{escape(pretty_json(headers))}</pre>
|
|
</div>
|
|
<div class='section'>
|
|
<h2>Payload</h2>
|
|
<pre>{escape(pretty_json(payload))}</pre>
|
|
</div>
|
|
"""
|
|
return html_page(f"Event {event_id}", body)
|
|
|
|
|
|
@app.get("/objects", response_class=HTMLResponse)
|
|
def list_objects(page: int = Query(1, ge=1)):
|
|
offset = (page - 1) * PAGE_SIZE
|
|
with closing(get_conn()) as conn:
|
|
rows = conn.execute(
|
|
"select id, received_at, object_type, object_uuid, changed_fields_json, object_time_utc, resource_url from webhook_objects order by id desc limit ? offset ?",
|
|
(PAGE_SIZE, offset),
|
|
).fetchall()
|
|
|
|
table_rows = []
|
|
for row in rows:
|
|
changed = parse_json_field(row["changed_fields_json"], []) or []
|
|
table_rows.append(
|
|
f"<tr>"
|
|
f"<td><a href='/objects/{row['id']}'>{row['id']}</a></td>"
|
|
f"<td>{escape(row['received_at'])}</td>"
|
|
f"<td>{escape(row['object_type'] or '')}</td>"
|
|
f"<td>{escape(row['object_uuid'] or '')}</td>"
|
|
f"<td>{format_pills(changed[:8])}</td>"
|
|
f"<td>{escape(row['object_time_utc'] or '')}</td>"
|
|
f"<td><a href='{escape(row['resource_url'] or '')}'>{escape(row['resource_url'] or '')}</a></td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
body = f"""
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Received</th><th>Object</th><th>UUID</th><th>Changed fields</th><th>Object time</th><th>Resource URL</th></tr></thead>
|
|
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
|
|
</table>
|
|
<div class='pagination'>
|
|
{f"<a href='{link_with_params('/objects', page=page-1)}'>← Prev</a>" if page > 1 else ''}
|
|
<a href='{link_with_params('/objects', page=page+1)}'>Next →</a>
|
|
</div>
|
|
"""
|
|
return html_page("Object webhooks", body)
|
|
|
|
|
|
@app.get("/objects/{object_id}", response_class=HTMLResponse)
|
|
def object_detail(object_id: int):
|
|
with closing(get_conn()) as conn:
|
|
row = conn.execute("select * from webhook_objects where id = ?", (object_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Object row not found")
|
|
|
|
payload = parse_json_field(row["payload_json"], {}) or {}
|
|
headers = parse_json_field(row["headers_json"], {}) or {}
|
|
changed = parse_json_field(row["changed_fields_json"], []) or []
|
|
|
|
body = f"""
|
|
<div class='card summary-grid'>
|
|
<div><strong>ID</strong></div><div>{row['id']}</div>
|
|
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
|
|
<div><strong>Object type</strong></div><div>{escape(row['object_type'] or '')}</div>
|
|
<div><strong>Object UUID</strong></div><div>{escape(row['object_uuid'] or '')}</div>
|
|
<div><strong>Object time UTC</strong></div><div>{escape(row['object_time_utc'] or '')}</div>
|
|
<div><strong>Resource URL</strong></div><div><a href='{escape(row['resource_url'] or '')}'>{escape(row['resource_url'] or '')}</a></div>
|
|
<div><strong>Changed fields</strong></div><div>{format_pills(changed)}</div>
|
|
</div>
|
|
<div class='section'><h2>Headers</h2><pre>{escape(pretty_json(headers))}</pre></div>
|
|
<div class='section'><h2>Payload</h2><pre>{escape(pretty_json(payload))}</pre></div>
|
|
"""
|
|
return html_page(f"Object webhook {object_id}", body)
|
|
|
|
|
|
@app.get("/form-responses", response_class=HTMLResponse)
|
|
def list_form_responses(page: int = Query(1, ge=1)):
|
|
offset = (page - 1) * PAGE_SIZE
|
|
with closing(get_conn()) as conn:
|
|
rows = conn.execute(
|
|
"select id, received_at, payload_json from webhook_form_responses order by id desc limit ? offset ?",
|
|
(PAGE_SIZE, offset),
|
|
).fetchall()
|
|
|
|
table_rows = []
|
|
for row in rows:
|
|
payload = parse_json_field(row["payload_json"], {}) or {}
|
|
data = payload.get("data", {}) if isinstance(payload, dict) else {}
|
|
related = payload.get("related", {}) if isinstance(payload, dict) else {}
|
|
job = related.get("job", {}) if isinstance(related, dict) else {}
|
|
table_rows.append(
|
|
f"<tr>"
|
|
f"<td><a href='/form-responses/{row['id']}'>{row['id']}</a></td>"
|
|
f"<td>{escape(row['received_at'])}</td>"
|
|
f"<td>{escape(data.get('form_uuid', ''))}</td>"
|
|
f"<td>{escape(data.get('regarding_object', ''))}</td>"
|
|
f"<td>{escape(data.get('regarding_object_uuid', ''))}</td>"
|
|
f"<td>{escape(job.get('generated_job_id', ''))}</td>"
|
|
f"<td>{escape(job.get('status', ''))}</td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
body = f"""
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Received</th><th>Form UUID</th><th>Regarding</th><th>Object UUID</th><th>Job ID</th><th>Job status</th></tr></thead>
|
|
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
|
|
</table>
|
|
<div class='pagination'>
|
|
{f"<a href='{link_with_params('/form-responses', page=page-1)}'>← Prev</a>" if page > 1 else ''}
|
|
<a href='{link_with_params('/form-responses', page=page+1)}'>Next →</a>
|
|
</div>
|
|
"""
|
|
return html_page("Form responses", body)
|
|
|
|
|
|
@app.get("/generated-materials", response_class=HTMLResponse)
|
|
def list_generated_materials(page: int = Query(1, ge=1)):
|
|
offset = (page - 1) * PAGE_SIZE
|
|
try:
|
|
with closing(get_state_conn()) as conn:
|
|
rows = conn.execute(
|
|
"select id, job_uuid, form_response_uuid, job_material_uuid, kind, source_question, source_text, updated_at from generated_job_materials order by id desc limit ? offset ?",
|
|
(PAGE_SIZE, offset),
|
|
).fetchall()
|
|
except sqlite3.Error as e:
|
|
return html_page("Generated materials", f"<div class='card'>State DB unavailable: {escape(str(e))}</div>")
|
|
|
|
table_rows = []
|
|
for row in rows:
|
|
table_rows.append(
|
|
f"<tr>"
|
|
f"<td><a href='/generated-materials/{row['id']}'>{row['id']}</a></td>"
|
|
f"<td>{escape(row['job_uuid'] or '')}</td>"
|
|
f"<td>{escape(row['form_response_uuid'] or '')}</td>"
|
|
f"<td>{escape(row['job_material_uuid'] or '')}</td>"
|
|
f"<td>{escape(row['kind'] or '')}</td>"
|
|
f"<td>{escape(row['source_question'] or '')}</td>"
|
|
f"<td>{escape(row['updated_at'] or '')}</td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
body = f"""
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Job UUID</th><th>Form response UUID</th><th>Job material UUID</th><th>Kind</th><th>Source question</th><th>Updated</th></tr></thead>
|
|
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
|
|
</table>
|
|
<div class='pagination'>
|
|
{f"<a href='{link_with_params('/generated-materials', page=page-1)}'>← Prev</a>" if page > 1 else ''}
|
|
<a href='{link_with_params('/generated-materials', page=page+1)}'>Next →</a>
|
|
</div>
|
|
"""
|
|
return html_page("Generated materials", body)
|
|
|
|
|
|
@app.get("/generated-materials/{row_id}", response_class=HTMLResponse)
|
|
def generated_material_detail(row_id: int):
|
|
try:
|
|
with closing(get_state_conn()) as conn:
|
|
row = conn.execute("select * from generated_job_materials where id = ?", (row_id,)).fetchone()
|
|
except sqlite3.Error as e:
|
|
return html_page("Generated material detail", f"<div class='card'>State DB unavailable: {escape(str(e))}</div>")
|
|
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Generated material row not found")
|
|
|
|
body = f"""
|
|
<div class='card summary-grid'>
|
|
<div><strong>ID</strong></div><div>{row['id']}</div>
|
|
<div><strong>Job UUID</strong></div><div>{escape(row['job_uuid'] or '')}</div>
|
|
<div><strong>Form response UUID</strong></div><div>{escape(row['form_response_uuid'] or '')}</div>
|
|
<div><strong>Job material UUID</strong></div><div>{escape(row['job_material_uuid'] or '')}</div>
|
|
<div><strong>Kind</strong></div><div>{escape(row['kind'] or '')}</div>
|
|
<div><strong>Source field UUID</strong></div><div>{escape(row['source_field_uuid'] or '')}</div>
|
|
<div><strong>Source question</strong></div><div>{escape(row['source_question'] or '')}</div>
|
|
<div><strong>Source text</strong></div><div>{escape(row['source_text'] or '')}</div>
|
|
<div><strong>Created</strong></div><div>{escape(row['created_at'] or '')}</div>
|
|
<div><strong>Updated</strong></div><div>{escape(row['updated_at'] or '')}</div>
|
|
</div>
|
|
"""
|
|
return html_page(f"Generated material {row_id}", body)
|
|
|
|
|
|
@app.get("/form-responses/{row_id}", response_class=HTMLResponse)
|
|
def form_response_detail(row_id: int):
|
|
with closing(get_conn()) as conn:
|
|
row = conn.execute("select * from webhook_form_responses where id = ?", (row_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Form response not found")
|
|
|
|
payload = parse_json_field(row["payload_json"], {}) or {}
|
|
headers = parse_json_field(row["headers_json"], {}) or {}
|
|
data = payload.get("data", {}) if isinstance(payload, dict) else {}
|
|
related = payload.get("related", {}) if isinstance(payload, dict) else {}
|
|
job = related.get("job", {}) if isinstance(related, dict) else {}
|
|
|
|
field_data_raw = data.get("field_data", "[]")
|
|
field_data = parse_json_field(field_data_raw, []) or []
|
|
field_rows = []
|
|
for item in sorted(field_data, key=lambda x: x.get("SortOrder", 0)):
|
|
field_rows.append(
|
|
f"<tr>"
|
|
f"<td>{escape(str(item.get('SortOrder', '')))}</td>"
|
|
f"<td>{escape(item.get('FieldType', ''))}</td>"
|
|
f"<td>{escape(item.get('Question', ''))}</td>"
|
|
f"<td>{escape(item.get('Response', ''))}</td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
body = f"""
|
|
<div class='card summary-grid'>
|
|
<div><strong>ID</strong></div><div>{row['id']}</div>
|
|
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
|
|
<div><strong>Form UUID</strong></div><div>{escape(data.get('form_uuid', ''))}</div>
|
|
<div><strong>Regarding object</strong></div><div>{escape(data.get('regarding_object', ''))}</div>
|
|
<div><strong>Regarding UUID</strong></div><div>{escape(data.get('regarding_object_uuid', ''))}</div>
|
|
<div><strong>Timestamp</strong></div><div>{escape(data.get('timestamp', ''))}</div>
|
|
<div><strong>Job ID</strong></div><div>{escape(job.get('generated_job_id', ''))}</div>
|
|
<div><strong>Job status</strong></div><div>{escape(job.get('status', ''))}</div>
|
|
</div>
|
|
<div class='section'>
|
|
<h2>Decoded field data</h2>
|
|
<table>
|
|
<thead><tr><th>Order</th><th>Type</th><th>Question</th><th>Response</th></tr></thead>
|
|
<tbody>{''.join(field_rows) or "<tr><td colspan='4'>No decoded field data.</td></tr>"}</tbody>
|
|
</table>
|
|
</div>
|
|
<div class='section'><h2>Related job</h2><pre>{escape(pretty_json(job))}</pre></div>
|
|
<div class='section'><h2>Headers</h2><pre>{escape(pretty_json(headers))}</pre></div>
|
|
<div class='section'><h2>Payload</h2><pre>{escape(pretty_json(payload))}</pre></div>
|
|
"""
|
|
return html_page(f"Form response {row_id}", body)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run("servicem8_inspector:app", host=APP_HOST, port=APP_PORT, reload=False)
|