Refactored the quote_push process to check for any existing jobMaterials and fail with a log entry and some intelligence so that we don't overwrite anything that has been manually created. Also added another list in the inspector.py code to allow us to view this when it occurs.
This commit is contained in:
+10
-2
@@ -47,9 +47,12 @@ The poller stores all fetched form responses in `form_responses_raw`, then parse
|
|||||||
- dry-run by default
|
- dry-run by default
|
||||||
- `--apply` performs live ServiceM8 `jobmaterial.json` creates
|
- `--apply` performs live ServiceM8 `jobmaterial.json` creates
|
||||||
- refuses duplicate apply when generated material rows already exist for that form response unless `--force` is used
|
- refuses duplicate apply when generated material rows already exist for that form response unless `--force` is used
|
||||||
- Apply tracking tables in `servicem8_formresponse_poll.db`:
|
- before any live create, checks remote ServiceM8 `/jobmaterial.json` for existing rows on the target `job_uuid`
|
||||||
|
- if remote rows already exist, records a remote-existing incident and creates nothing unless `--force-remote-existing` is explicitly used
|
||||||
|
- Apply/incident tracking tables in `servicem8_formresponse_poll.db`:
|
||||||
- `quote_template_apply_runs`
|
- `quote_template_apply_runs`
|
||||||
- `quote_template_apply_run_rows`
|
- `quote_template_apply_run_rows`
|
||||||
|
- `quote_template_remote_existing_incidents`
|
||||||
- Created ServiceM8 job material mappings are recorded in:
|
- Created ServiceM8 job material mappings are recorded in:
|
||||||
- `servicem8_quote_materials_state.db`
|
- `servicem8_quote_materials_state.db`
|
||||||
|
|
||||||
@@ -73,15 +76,20 @@ Current apply payload rules:
|
|||||||
- stores/parses results
|
- stores/parses results
|
||||||
- applies any parsed Quote Template responses that are not already marked/applied
|
- applies any parsed Quote Template responses that are not already marked/applied
|
||||||
- logs each run under `logs/poll-and-apply-YYYYMMDD-HHMMSS.log`
|
- logs each run under `logs/poll-and-apply-YYYYMMDD-HHMMSS.log`
|
||||||
- Safety option now available:
|
- Safety options now available:
|
||||||
- `--dry-run` runs the same poll/selection flow, but previews the ServiceM8 `jobmaterial` payloads only
|
- `--dry-run` runs the same poll/selection flow, but previews the ServiceM8 `jobmaterial` payloads only
|
||||||
- dry-run does **not** write to ServiceM8
|
- dry-run does **not** write to ServiceM8
|
||||||
- dry-run does **not** mark quote responses as applied
|
- dry-run does **not** mark quote responses as applied
|
||||||
|
- live apply blocks if remote ServiceM8 already has jobMaterial rows for the job, logging the captured rows/counts to `quote_template_remote_existing_incidents`
|
||||||
|
- `--recheck-remote-existing` revisits previously blocked rows without overriding the safety gate
|
||||||
|
- `--force-remote-existing` explicitly overrides the remote-existing safety gate and still records a forced incident before creating
|
||||||
- Examples:
|
- Examples:
|
||||||
- `./poll_and_apply_quote_templates.sh`
|
- `./poll_and_apply_quote_templates.sh`
|
||||||
- `./poll_and_apply_quote_templates.sh --hours 48`
|
- `./poll_and_apply_quote_templates.sh --hours 48`
|
||||||
- `./poll_and_apply_quote_templates.sh --since '2026-05-04 08:00:00'`
|
- `./poll_and_apply_quote_templates.sh --since '2026-05-04 08:00:00'`
|
||||||
- `./poll_and_apply_quote_templates.sh --dry-run --hours 48`
|
- `./poll_and_apply_quote_templates.sh --dry-run --hours 48`
|
||||||
|
- `./poll_and_apply_quote_templates.sh --recheck-remote-existing --hours 48`
|
||||||
|
- `./poll_and_apply_quote_templates.sh --force-remote-existing --hours 48`
|
||||||
|
|
||||||
This is the proposed scheduled entry point for soft release, e.g. every 10–30 minutes. For manual confidence checks, run it with `--dry-run` first, inspect the generated payloads/log, then rerun without `--dry-run` only when ready to apply.
|
This is the proposed scheduled entry point for soft release, e.g. every 10–30 minutes. For manual confidence checks, run it with `--dry-run` first, inspect the generated payloads/log, then rerun without `--dry-run` only when ready to apply.
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ def utc_now() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def escape_filter_value(value: str) -> str:
|
||||||
|
return value.replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
def material_uuid_for_row(row: dict) -> str:
|
def material_uuid_for_row(row: dict) -> str:
|
||||||
kind = row.get("kind", "")
|
kind = row.get("kind", "")
|
||||||
if kind == "include_header":
|
if kind == "include_header":
|
||||||
@@ -72,6 +76,23 @@ def create_job_material(session: requests.Session, payload: dict) -> str:
|
|||||||
return record_uuid
|
return record_uuid
|
||||||
|
|
||||||
|
|
||||||
|
def list_remote_job_materials(session: requests.Session, job_uuid: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Return existing remote ServiceM8 jobMaterial rows for a job."""
|
||||||
|
filter_expr = f"job_uuid eq '{escape_filter_value(job_uuid)}'"
|
||||||
|
response = session.get(f"{BASE_URL}/jobmaterial.json", params={"$filter": filter_expr}, timeout=REQUEST_TIMEOUT)
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError(f"Remote jobMaterial preflight failed: HTTP {response.status_code} :: {response.text[:1000]}")
|
||||||
|
data = response.json()
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise RuntimeError(f"Remote jobMaterial preflight expected list response, got {type(data).__name__}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def is_active_remote_job_material(row: Dict[str, Any]) -> bool:
|
||||||
|
value = row.get("active", 1)
|
||||||
|
return value not in (0, "0", False, "false", "False")
|
||||||
|
|
||||||
|
|
||||||
def load_api_key() -> str:
|
def load_api_key() -> str:
|
||||||
for name in ("SERVICEM8_ACCESS_TOKEN", "SERVICEM8_API_KEY"):
|
for name in ("SERVICEM8_ACCESS_TOKEN", "SERVICEM8_API_KEY"):
|
||||||
value = os.getenv(name)
|
value = os.getenv(name)
|
||||||
@@ -92,7 +113,7 @@ def load_api_key() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_conn(db_path: Path = POLL_DB_PATH) -> sqlite3.Connection:
|
def get_conn(db_path: Path = POLL_DB_PATH) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path, timeout=30)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -134,8 +155,28 @@ def init_apply_tables(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS quote_template_remote_existing_incidents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
detected_at TEXT NOT NULL,
|
||||||
|
form_response_uuid TEXT NOT NULL,
|
||||||
|
job_uuid TEXT NOT NULL,
|
||||||
|
apply_run_id INTEGER,
|
||||||
|
desired_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remote_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remote_active_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
remote_rows_json TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(apply_run_id) REFERENCES quote_template_apply_runs(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_apply_runs_form_response ON quote_template_apply_runs(form_response_uuid)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_apply_runs_form_response ON quote_template_apply_runs(form_response_uuid)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_apply_rows_run ON quote_template_apply_run_rows(run_id)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_apply_rows_run ON quote_template_apply_run_rows(run_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_remote_existing_form_response ON quote_template_remote_existing_incidents(form_response_uuid)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_quote_remote_existing_job_uuid ON quote_template_remote_existing_incidents(job_uuid)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +194,7 @@ def existing_created_for_form(form_response_uuid: str) -> int:
|
|||||||
if not STATE_DB_PATH.exists():
|
if not STATE_DB_PATH.exists():
|
||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
with closing(sqlite3.connect(STATE_DB_PATH)) as conn:
|
with closing(sqlite3.connect(STATE_DB_PATH, timeout=30)) as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"select count(*) from generated_job_materials where form_response_uuid = ?",
|
"select count(*) from generated_job_materials where form_response_uuid = ?",
|
||||||
(form_response_uuid,),
|
(form_response_uuid,),
|
||||||
@@ -226,6 +267,42 @@ def record_apply_row(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_remote_existing_incident(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
form_response_uuid: str,
|
||||||
|
job_uuid: str,
|
||||||
|
apply_run_id: int,
|
||||||
|
desired_count: int,
|
||||||
|
remote_rows: List[Dict[str, Any]],
|
||||||
|
action: str,
|
||||||
|
reason: str,
|
||||||
|
) -> int:
|
||||||
|
remote_active_count = sum(1 for row in remote_rows if is_active_remote_job_material(row))
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO quote_template_remote_existing_incidents (
|
||||||
|
detected_at, form_response_uuid, job_uuid, apply_run_id, desired_count,
|
||||||
|
remote_count, remote_active_count, action, reason, remote_rows_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
utc_now(),
|
||||||
|
form_response_uuid,
|
||||||
|
job_uuid,
|
||||||
|
apply_run_id,
|
||||||
|
desired_count,
|
||||||
|
len(remote_rows),
|
||||||
|
remote_active_count,
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
json.dumps(remote_rows, ensure_ascii=False, sort_keys=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
def list_pending(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
def list_pending(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -263,6 +340,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--db", default=str(POLL_DB_PATH), help="Poll DB path")
|
parser.add_argument("--db", default=str(POLL_DB_PATH), help="Poll DB path")
|
||||||
parser.add_argument("--apply", action="store_true", help="Actually create ServiceM8 jobMaterial records. Default is dry-run.")
|
parser.add_argument("--apply", action="store_true", help="Actually create ServiceM8 jobMaterial records. Default is dry-run.")
|
||||||
parser.add_argument("--force", action="store_true", help="Allow apply even if generated_job_materials already contains rows for this form response")
|
parser.add_argument("--force", action="store_true", help="Allow apply even if generated_job_materials already contains rows for this form response")
|
||||||
|
parser.add_argument("--force-remote-existing", action="store_true", help="Allow apply even when ServiceM8 already has jobMaterial rows for the target job; records a forced incident before creating")
|
||||||
parser.add_argument("--list", action="store_true", help="List parsed polled Quote Template responses and exit")
|
parser.add_argument("--list", action="store_true", help="List parsed polled Quote Template responses and exit")
|
||||||
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
|
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -343,6 +421,45 @@ def main() -> int:
|
|||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.headers.update({"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"})
|
session.headers.update({"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"})
|
||||||
|
|
||||||
|
remote_existing_rows = list_remote_job_materials(session, job_uuid)
|
||||||
|
if remote_existing_rows:
|
||||||
|
remote_active_count = sum(1 for remote_row in remote_existing_rows if is_active_remote_job_material(remote_row))
|
||||||
|
action = "forced" if args.force_remote_existing else "blocked"
|
||||||
|
reason = (
|
||||||
|
f"Remote ServiceM8 job already has {len(remote_existing_rows)} jobMaterial row(s) "
|
||||||
|
f"({remote_active_count} active); no creates attempted"
|
||||||
|
if not args.force_remote_existing
|
||||||
|
else f"Remote ServiceM8 job already has {len(remote_existing_rows)} jobMaterial row(s) "
|
||||||
|
f"({remote_active_count} active); create forced by --force-remote-existing"
|
||||||
|
)
|
||||||
|
incident_id = record_remote_existing_incident(
|
||||||
|
conn,
|
||||||
|
form_response_uuid=form_response_uuid,
|
||||||
|
job_uuid=job_uuid,
|
||||||
|
apply_run_id=run_id,
|
||||||
|
desired_count=len(desired_rows),
|
||||||
|
remote_rows=remote_existing_rows,
|
||||||
|
action=action,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
result["remote_existing"] = {
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"action": action,
|
||||||
|
"remote_count": len(remote_existing_rows),
|
||||||
|
"remote_active_count": remote_active_count,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
if not args.force_remote_existing:
|
||||||
|
finish_apply_run(conn, run_id, status="blocked_remote_existing", created_count=0)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE quote_template_form_responses SET processed_at = ?, process_status = ?, process_error = NULL WHERE form_response_uuid = ?",
|
||||||
|
(utc_now(), "blocked_remote_existing", form_response_uuid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
result["status"] = "blocked_remote_existing"
|
||||||
|
print(json.dumps(result, indent=2 if args.pretty else None, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
for idx, row in enumerate(desired_rows, start=1):
|
for idx, row in enumerate(desired_rows, start=1):
|
||||||
api_payload = build_payload(job_uuid, row)
|
api_payload = build_payload(job_uuid, row)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ set -euo pipefail
|
|||||||
# By default this wrapper applies unapplied parsed responses. Use --dry-run to
|
# By default this wrapper applies unapplied parsed responses. Use --dry-run to
|
||||||
# run the poll and preview each pending apply without writing to ServiceM8.
|
# run the poll and preview each pending apply without writing to ServiceM8.
|
||||||
# The apply script still refuses duplicate applies unless --force is explicitly
|
# The apply script still refuses duplicate applies unless --force is explicitly
|
||||||
# passed through.
|
# passed through. It also checks ServiceM8 for existing jobMaterial rows on
|
||||||
|
# the target job and blocks the apply unless --force-remote-existing is passed.
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
POLL_SCRIPT="$SCRIPT_DIR/poll_form_responses_since.py"
|
POLL_SCRIPT="$SCRIPT_DIR/poll_form_responses_since.py"
|
||||||
@@ -24,11 +25,13 @@ QUOTE_TEMPLATE_FORM_UUID="${SERVICEM8_QUOTE_TEMPLATE_FORM_UUID:-3621b6be-1d19-47
|
|||||||
SINCE=""
|
SINCE=""
|
||||||
HOURS="24"
|
HOURS="24"
|
||||||
FORCE="0"
|
FORCE="0"
|
||||||
|
FORCE_REMOTE_EXISTING="0"
|
||||||
|
RECHECK_REMOTE_EXISTING="0"
|
||||||
DRY_RUN="0"
|
DRY_RUN="0"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $0 [--since 'YYYY-MM-DD HH:MM:SS'] [--hours N] [--dry-run] [--force]
|
Usage: $0 [--since 'YYYY-MM-DD HH:MM:SS'] [--hours N] [--dry-run] [--force] [--force-remote-existing] [--recheck-remote-existing]
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$0
|
$0
|
||||||
@@ -43,6 +46,13 @@ This will:
|
|||||||
|
|
||||||
With --dry-run, step 3 previews the ServiceM8 jobMaterial payloads only; it does
|
With --dry-run, step 3 previews the ServiceM8 jobMaterial payloads only; it does
|
||||||
not write to ServiceM8 or mark responses as applied.
|
not write to ServiceM8 or mark responses as applied.
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
--force keeps the existing local duplicate override behaviour.
|
||||||
|
--force-remote-existing allows creating rows even when the remote ServiceM8
|
||||||
|
job already has jobMaterial rows; the incident is still recorded.
|
||||||
|
--recheck-remote-existing revisits rows previously blocked because remote
|
||||||
|
jobMaterial rows existed. If the remote rows are gone, the apply can proceed.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +70,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
FORCE="1"
|
FORCE="1"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--force-remote-existing)
|
||||||
|
FORCE_REMOTE_EXISTING="1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--recheck-remote-existing)
|
||||||
|
RECHECK_REMOTE_EXISTING="1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--dry-run)
|
--dry-run)
|
||||||
DRY_RUN="1"
|
DRY_RUN="1"
|
||||||
shift
|
shift
|
||||||
@@ -90,6 +108,7 @@ echo "== ServiceM8 Quote Template poll/apply run =="
|
|||||||
echo "Started: $(date --iso-8601=seconds)"
|
echo "Started: $(date --iso-8601=seconds)"
|
||||||
echo "Since: $SINCE"
|
echo "Since: $SINCE"
|
||||||
echo "Mode: $([[ "$DRY_RUN" == "1" ]] && echo "dry-run" || echo "apply")"
|
echo "Mode: $([[ "$DRY_RUN" == "1" ]] && echo "dry-run" || echo "apply")"
|
||||||
|
echo "Remote existing: $([[ "$FORCE_REMOTE_EXISTING" == "1" ]] && echo "force" || ([[ "$RECHECK_REMOTE_EXISTING" == "1" ]] && echo "recheck" || echo "block"))"
|
||||||
echo "DB: $DB_PATH"
|
echo "DB: $DB_PATH"
|
||||||
echo "Log: $LOG_FILE"
|
echo "Log: $LOG_FILE"
|
||||||
echo
|
echo
|
||||||
@@ -100,20 +119,25 @@ echo "== Polling form responses =="
|
|||||||
echo
|
echo
|
||||||
echo "== Finding unapplied parsed Quote Template responses =="
|
echo "== Finding unapplied parsed Quote Template responses =="
|
||||||
mapfile -t FORM_RESPONSE_UUIDS < <(
|
mapfile -t FORM_RESPONSE_UUIDS < <(
|
||||||
python3 - "$DB_PATH" "$QUOTE_TEMPLATE_FORM_UUID" <<'PY'
|
python3 - "$DB_PATH" "$QUOTE_TEMPLATE_FORM_UUID" "$RECHECK_REMOTE_EXISTING" "$FORCE_REMOTE_EXISTING" <<'PY'
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
poll_db, quote_form_uuid = sys.argv[1], sys.argv[2]
|
poll_db, quote_form_uuid, recheck_remote_existing, force_remote_existing = sys.argv[1:5]
|
||||||
|
include_remote_blocked = recheck_remote_existing == "1" or force_remote_existing == "1"
|
||||||
|
status_clause = "coalesce(q.process_status, '') != 'applied'"
|
||||||
|
if not include_remote_blocked:
|
||||||
|
status_clause += " and coalesce(q.process_status, '') != 'blocked_remote_existing'"
|
||||||
|
|
||||||
conn = sqlite3.connect(poll_db)
|
conn = sqlite3.connect(poll_db)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"""
|
||||||
select q.form_response_uuid
|
select q.form_response_uuid
|
||||||
from quote_template_form_responses q
|
from quote_template_form_responses q
|
||||||
left join form_responses_raw r on r.uuid = q.form_response_uuid
|
left join form_responses_raw r on r.uuid = q.form_response_uuid
|
||||||
where q.form_uuid = ?
|
where q.form_uuid = ?
|
||||||
and coalesce(q.process_status, '') != 'applied'
|
and {status_clause}
|
||||||
order by coalesce(r.timestamp, q.discovered_at) asc, q.form_response_uuid asc
|
order by coalesce(r.timestamp, q.discovered_at) asc, q.form_response_uuid asc
|
||||||
""",
|
""",
|
||||||
(quote_form_uuid,),
|
(quote_form_uuid,),
|
||||||
@@ -143,6 +167,9 @@ fi
|
|||||||
if [[ "$FORCE" == "1" ]]; then
|
if [[ "$FORCE" == "1" ]]; then
|
||||||
APPLY_ARGS+=(--force)
|
APPLY_ARGS+=(--force)
|
||||||
fi
|
fi
|
||||||
|
if [[ "$FORCE_REMOTE_EXISTING" == "1" ]]; then
|
||||||
|
APPLY_ARGS+=(--force-remote-existing)
|
||||||
|
fi
|
||||||
|
|
||||||
for uuid in "${FORM_RESPONSE_UUIDS[@]}"; do
|
for uuid in "${FORM_RESPONSE_UUIDS[@]}"; do
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ def html_page(title: str, body: str) -> HTMLResponse:
|
|||||||
<a href='/poll/form-responses'>Polled form responses</a>
|
<a href='/poll/form-responses'>Polled form responses</a>
|
||||||
<a href='/poll/quote-template'>Polled quote templates</a>
|
<a href='/poll/quote-template'>Polled quote templates</a>
|
||||||
<a href='/poll/apply-runs'>Apply runs</a>
|
<a href='/poll/apply-runs'>Apply runs</a>
|
||||||
|
<a href='/poll/remote-existing-incidents'>Remote existing incidents</a>
|
||||||
<a href='/generated-materials'>Generated materials</a>
|
<a href='/generated-materials'>Generated materials</a>
|
||||||
</nav>
|
</nav>
|
||||||
"""
|
"""
|
||||||
@@ -160,15 +161,22 @@ def dashboard():
|
|||||||
"poll_runs": 0,
|
"poll_runs": 0,
|
||||||
"polled_form_responses": 0,
|
"polled_form_responses": 0,
|
||||||
"polled_quote_templates": 0,
|
"polled_quote_templates": 0,
|
||||||
|
"remote_existing_incidents": 0,
|
||||||
}
|
}
|
||||||
latest_poll_run = None
|
latest_poll_run = None
|
||||||
latest_polled_form = None
|
latest_polled_form = None
|
||||||
latest_polled_quote = None
|
latest_polled_quote = None
|
||||||
|
latest_remote_existing_incident = None
|
||||||
try:
|
try:
|
||||||
with closing(get_poll_conn()) as conn:
|
with closing(get_poll_conn()) as conn:
|
||||||
poll_counts["poll_runs"] = conn.execute("select count(*) from poll_runs").fetchone()[0]
|
poll_counts["poll_runs"] = conn.execute("select count(*) from poll_runs").fetchone()[0]
|
||||||
poll_counts["polled_form_responses"] = conn.execute("select count(*) from form_responses_raw").fetchone()[0]
|
poll_counts["polled_form_responses"] = conn.execute("select count(*) from form_responses_raw").fetchone()[0]
|
||||||
poll_counts["polled_quote_templates"] = conn.execute("select count(*) from quote_template_form_responses").fetchone()[0]
|
poll_counts["polled_quote_templates"] = conn.execute("select count(*) from quote_template_form_responses").fetchone()[0]
|
||||||
|
try:
|
||||||
|
poll_counts["remote_existing_incidents"] = conn.execute("select count(*) from quote_template_remote_existing_incidents").fetchone()[0]
|
||||||
|
latest_remote_existing_incident = conn.execute("select detected_at from quote_template_remote_existing_incidents order by id desc limit 1").fetchone()
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
latest_poll_run = conn.execute("select finished_at from poll_runs order by id desc limit 1").fetchone()
|
latest_poll_run = conn.execute("select finished_at from poll_runs order by id desc limit 1").fetchone()
|
||||||
latest_polled_form = conn.execute("select timestamp from form_responses_raw order by timestamp desc limit 1").fetchone()
|
latest_polled_form = conn.execute("select timestamp from form_responses_raw order by timestamp desc limit 1").fetchone()
|
||||||
latest_polled_quote = conn.execute("select discovered_at from quote_template_form_responses order by discovered_at desc limit 1").fetchone()
|
latest_polled_quote = conn.execute("select discovered_at from quote_template_form_responses order by discovered_at desc limit 1").fetchone()
|
||||||
@@ -196,6 +204,7 @@ def dashboard():
|
|||||||
<div><strong>Latest poll run</strong></div><div>{escape(latest_poll_run[0] if latest_poll_run else '—')}</div>
|
<div><strong>Latest poll run</strong></div><div>{escape(latest_poll_run[0] if latest_poll_run else '—')}</div>
|
||||||
<div><strong>Latest polled form timestamp</strong></div><div>{escape(latest_polled_form[0] if latest_polled_form else '—')}</div>
|
<div><strong>Latest polled form timestamp</strong></div><div>{escape(latest_polled_form[0] if latest_polled_form else '—')}</div>
|
||||||
<div><strong>Latest polled quote discovered</strong></div><div>{escape(latest_polled_quote[0] if latest_polled_quote else '—')}</div>
|
<div><strong>Latest polled quote discovered</strong></div><div>{escape(latest_polled_quote[0] if latest_polled_quote else '—')}</div>
|
||||||
|
<div><strong>Latest remote-existing incident</strong></div><div>{escape(latest_remote_existing_incident[0] if latest_remote_existing_incident else '—')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='section'>
|
<div class='section'>
|
||||||
@@ -208,6 +217,7 @@ def dashboard():
|
|||||||
<li><a href='/poll/quote-template'>Browse parsed polled Quote Template responses</a></li>
|
<li><a href='/poll/quote-template'>Browse parsed polled Quote Template responses</a></li>
|
||||||
<li><a href='/poll/runs'>Browse poll runs</a></li>
|
<li><a href='/poll/runs'>Browse poll runs</a></li>
|
||||||
<li><a href='/poll/apply-runs'>Browse dry-run/apply runs</a></li>
|
<li><a href='/poll/apply-runs'>Browse dry-run/apply runs</a></li>
|
||||||
|
<li><a href='/poll/remote-existing-incidents'>Browse remote-existing incidents</a></li>
|
||||||
<li><a href='/generated-materials'>Browse generated job-material state</a></li>
|
<li><a href='/generated-materials'>Browse generated job-material state</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -542,6 +552,90 @@ def form_response_detail(row_id: int):
|
|||||||
return html_page(f"Form response {row_id}", body)
|
return html_page(f"Form response {row_id}", body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/poll/remote-existing-incidents", response_class=HTMLResponse)
|
||||||
|
def list_remote_existing_incidents(page: int = Query(1, ge=1)):
|
||||||
|
offset = (page - 1) * PAGE_SIZE
|
||||||
|
try:
|
||||||
|
with closing(get_poll_conn()) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
select id, detected_at, form_response_uuid, job_uuid, apply_run_id,
|
||||||
|
desired_count, remote_count, remote_active_count, action, reason
|
||||||
|
from quote_template_remote_existing_incidents
|
||||||
|
order by id desc
|
||||||
|
limit ? offset ?
|
||||||
|
""",
|
||||||
|
(PAGE_SIZE, offset),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
return html_page("Remote existing incidents", f"<div class='card'>Incident table unavailable: {escape(str(e))}</div>")
|
||||||
|
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
run_link = f"<a href='/poll/apply-runs/{row['apply_run_id']}'>{row['apply_run_id']}</a>" if row['apply_run_id'] else ""
|
||||||
|
table_rows.append(
|
||||||
|
f"<tr><td><a href='/poll/remote-existing-incidents/{row['id']}'>{row['id']}</a></td>"
|
||||||
|
f"<td>{escape(row['detected_at'] or '')}</td><td>{escape(row['action'] or '')}</td>"
|
||||||
|
f"<td><a href='/poll/quote-template/{escape(row['form_response_uuid'])}'>{escape(row['form_response_uuid'])}</a></td>"
|
||||||
|
f"<td>{escape(row['job_uuid'] or '')}</td><td>{run_link}</td>"
|
||||||
|
f"<td>{row['desired_count']}</td><td>{row['remote_count']}</td><td>{row['remote_active_count']}</td>"
|
||||||
|
f"<td>{escape((row['reason'] or '')[:180])}</td></tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>Detected</th><th>Action</th><th>Form response UUID</th><th>Job UUID</th><th>Apply run</th><th>Desired</th><th>Remote rows</th><th>Active</th><th>Reason</th></tr></thead>
|
||||||
|
<tbody>{''.join(table_rows) or "<tr><td colspan='10'>No remote-existing incidents found.</td></tr>"}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class='pagination'>
|
||||||
|
{f"<a href='{link_with_params('/poll/remote-existing-incidents', page=page-1)}'>← Prev</a>" if page > 1 else ''}
|
||||||
|
<a href='{link_with_params('/poll/remote-existing-incidents', page=page+1)}'>Next →</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return html_page("Remote existing incidents", body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/poll/remote-existing-incidents/{incident_id}", response_class=HTMLResponse)
|
||||||
|
def remote_existing_incident_detail(incident_id: int):
|
||||||
|
try:
|
||||||
|
with closing(get_poll_conn()) as conn:
|
||||||
|
row = conn.execute("select * from quote_template_remote_existing_incidents where id = ?", (incident_id,)).fetchone()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
return html_page("Remote existing incident", f"<div class='card'>Incident table unavailable: {escape(str(e))}</div>")
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Remote existing incident not found")
|
||||||
|
|
||||||
|
remote_rows = parse_json_field(row["remote_rows_json"], []) or []
|
||||||
|
material_rows = []
|
||||||
|
if isinstance(remote_rows, list):
|
||||||
|
for item in remote_rows:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
material_rows.append(
|
||||||
|
f"<tr><td>{escape(str(item.get('uuid', '')))}</td><td>{escape(str(item.get('active', '')))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('name', '')))}</td><td>{escape(str(item.get('material_uuid', '')))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('quantity', '')))}</td><td>{escape(str(item.get('price', '')))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('sort_order', '')))}</td></tr>"
|
||||||
|
)
|
||||||
|
run_link = f"<a href='/poll/apply-runs/{row['apply_run_id']}'>{row['apply_run_id']}</a>" if row['apply_run_id'] else ""
|
||||||
|
body = f"""
|
||||||
|
<div class='card summary-grid'>
|
||||||
|
<div><strong>ID</strong></div><div>{row['id']}</div>
|
||||||
|
<div><strong>Detected</strong></div><div>{escape(row['detected_at'] or '')}</div>
|
||||||
|
<div><strong>Action</strong></div><div>{escape(row['action'] or '')}</div>
|
||||||
|
<div><strong>Form response UUID</strong></div><div><a href='/poll/quote-template/{escape(row['form_response_uuid'])}'>{escape(row['form_response_uuid'])}</a></div>
|
||||||
|
<div><strong>Job UUID</strong></div><div>{escape(row['job_uuid'] or '')}</div>
|
||||||
|
<div><strong>Apply run</strong></div><div>{run_link}</div>
|
||||||
|
<div><strong>Desired rows</strong></div><div>{row['desired_count']}</div>
|
||||||
|
<div><strong>Remote rows</strong></div><div>{row['remote_count']}</div>
|
||||||
|
<div><strong>Remote active rows</strong></div><div>{row['remote_active_count']}</div>
|
||||||
|
<div><strong>Reason</strong></div><div>{escape(row['reason'] or '')}</div>
|
||||||
|
</div>
|
||||||
|
<div class='section'><h2>Remote jobMaterial rows</h2><table><thead><tr><th>UUID</th><th>Active</th><th>Name</th><th>Material UUID</th><th>Qty</th><th>Price</th><th>Sort</th></tr></thead><tbody>{''.join(material_rows) or "<tr><td colspan='7'>No remote rows captured.</td></tr>"}</tbody></table></div>
|
||||||
|
<div class='section'><h2>Raw remote rows JSON</h2><pre>{escape(pretty_json(remote_rows))}</pre></div>
|
||||||
|
"""
|
||||||
|
return html_page(f"Remote existing incident {incident_id}", body)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/poll/apply-runs", response_class=HTMLResponse)
|
@app.get("/poll/apply-runs", response_class=HTMLResponse)
|
||||||
def list_apply_runs(page: int = Query(1, ge=1)):
|
def list_apply_runs(page: int = Query(1, ge=1)):
|
||||||
offset = (page - 1) * PAGE_SIZE
|
offset = (page - 1) * PAGE_SIZE
|
||||||
@@ -592,11 +686,23 @@ def apply_run_detail(run_id: int):
|
|||||||
"select * from quote_template_apply_run_rows where run_id = ? order by row_index asc",
|
"select * from quote_template_apply_run_rows where run_id = ? order by row_index asc",
|
||||||
(run_id,),
|
(run_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
incidents = conn.execute(
|
||||||
|
"select id, detected_at, action, remote_count, remote_active_count, reason from quote_template_remote_existing_incidents where apply_run_id = ? order by id desc",
|
||||||
|
(run_id,),
|
||||||
|
).fetchall()
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
return html_page("Apply run", f"<div class='card'>Apply-run table unavailable: {escape(str(e))}</div>")
|
return html_page("Apply run", f"<div class='card'>Apply-run table unavailable: {escape(str(e))}</div>")
|
||||||
if not run:
|
if not run:
|
||||||
raise HTTPException(status_code=404, detail="Apply run not found")
|
raise HTTPException(status_code=404, detail="Apply run not found")
|
||||||
|
|
||||||
|
incident_rows = []
|
||||||
|
for incident in incidents:
|
||||||
|
incident_rows.append(
|
||||||
|
f"<tr><td><a href='/poll/remote-existing-incidents/{incident['id']}'>{incident['id']}</a></td>"
|
||||||
|
f"<td>{escape(incident['detected_at'] or '')}</td><td>{escape(incident['action'] or '')}</td>"
|
||||||
|
f"<td>{incident['remote_count']}</td><td>{incident['remote_active_count']}</td><td>{escape((incident['reason'] or '')[:180])}</td></tr>"
|
||||||
|
)
|
||||||
|
|
||||||
table_rows = []
|
table_rows = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
payload = parse_json_field(row['api_payload_json'], {}) or {}
|
payload = parse_json_field(row['api_payload_json'], {}) or {}
|
||||||
@@ -620,6 +726,7 @@ def apply_run_detail(run_id: int):
|
|||||||
<div><strong>Created</strong></div><div>{run['created_count']}</div>
|
<div><strong>Created</strong></div><div>{run['created_count']}</div>
|
||||||
<div><strong>Error</strong></div><div>{escape(run['error'] or '')}</div>
|
<div><strong>Error</strong></div><div>{escape(run['error'] or '')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class='section'><h2>Remote-existing incidents</h2><table><thead><tr><th>ID</th><th>Detected</th><th>Action</th><th>Remote rows</th><th>Active</th><th>Reason</th></tr></thead><tbody>{''.join(incident_rows) or "<tr><td colspan='6'>No remote-existing incidents for this run.</td></tr>"}</tbody></table></div>
|
||||||
<div class='section'><h2>Rows</h2><table><thead><tr><th>#</th><th>Action</th><th>Kind</th><th>Name</th><th>Created UUID</th><th>Source question</th><th>Error</th></tr></thead><tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows recorded.</td></tr>"}</tbody></table></div>
|
<div class='section'><h2>Rows</h2><table><thead><tr><th>#</th><th>Action</th><th>Kind</th><th>Name</th><th>Created UUID</th><th>Source question</th><th>Error</th></tr></thead><tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows recorded.</td></tr>"}</tbody></table></div>
|
||||||
"""
|
"""
|
||||||
return html_page(f"Apply run {run_id}", body)
|
return html_page(f"Apply run {run_id}", body)
|
||||||
@@ -842,13 +949,23 @@ def polled_quote_template_detail(form_response_uuid: str):
|
|||||||
"select id, mode, status, started_at, finished_at, desired_count, created_count, error from quote_template_apply_runs where form_response_uuid = ? order by id desc limit 8",
|
"select id, mode, status, started_at, finished_at, desired_count, created_count, error from quote_template_apply_runs where form_response_uuid = ? order by id desc limit 8",
|
||||||
(row['form_response_uuid'],),
|
(row['form_response_uuid'],),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
recent_incidents = conn.execute(
|
||||||
|
"select id, detected_at, action, remote_count, remote_active_count, reason from quote_template_remote_existing_incidents where form_response_uuid = ? order by id desc limit 8",
|
||||||
|
(row['form_response_uuid'],),
|
||||||
|
).fetchall()
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
recent_runs = []
|
recent_runs = []
|
||||||
|
recent_incidents = []
|
||||||
recent_run_rows = []
|
recent_run_rows = []
|
||||||
for run in recent_runs:
|
for run in recent_runs:
|
||||||
recent_run_rows.append(
|
recent_run_rows.append(
|
||||||
f"<tr><td><a href='/poll/apply-runs/{run['id']}'>{run['id']}</a></td><td>{escape(run['mode'] or '')}</td><td>{escape(run['status'] or '')}</td><td>{escape(run['started_at'] or '')}</td><td>{escape(run['finished_at'] or '')}</td><td>{run['desired_count']}</td><td>{run['created_count']}</td><td>{escape((run['error'] or '')[:120])}</td></tr>"
|
f"<tr><td><a href='/poll/apply-runs/{run['id']}'>{run['id']}</a></td><td>{escape(run['mode'] or '')}</td><td>{escape(run['status'] or '')}</td><td>{escape(run['started_at'] or '')}</td><td>{escape(run['finished_at'] or '')}</td><td>{run['desired_count']}</td><td>{run['created_count']}</td><td>{escape((run['error'] or '')[:120])}</td></tr>"
|
||||||
)
|
)
|
||||||
|
recent_incident_rows = []
|
||||||
|
for incident in recent_incidents:
|
||||||
|
recent_incident_rows.append(
|
||||||
|
f"<tr><td><a href='/poll/remote-existing-incidents/{incident['id']}'>{incident['id']}</a></td><td>{escape(incident['detected_at'] or '')}</td><td>{escape(incident['action'] or '')}</td><td>{incident['remote_count']}</td><td>{incident['remote_active_count']}</td><td>{escape((incident['reason'] or '')[:120])}</td></tr>"
|
||||||
|
)
|
||||||
|
|
||||||
body = f"""
|
body = f"""
|
||||||
<div class='card summary-grid'>
|
<div class='card summary-grid'>
|
||||||
@@ -877,6 +994,10 @@ def polled_quote_template_detail(form_response_uuid: str):
|
|||||||
<h2>Recent dry-run/apply runs for this response</h2>
|
<h2>Recent dry-run/apply runs for this response</h2>
|
||||||
<table><thead><tr><th>ID</th><th>Mode</th><th>Status</th><th>Started</th><th>Finished</th><th>Desired</th><th>Created</th><th>Error</th></tr></thead><tbody>{''.join(recent_run_rows) or "<tr><td colspan='8'>No dry-run/apply runs yet.</td></tr>"}</tbody></table>
|
<table><thead><tr><th>ID</th><th>Mode</th><th>Status</th><th>Started</th><th>Finished</th><th>Desired</th><th>Created</th><th>Error</th></tr></thead><tbody>{''.join(recent_run_rows) or "<tr><td colspan='8'>No dry-run/apply runs yet.</td></tr>"}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class='section'>
|
||||||
|
<h2>Remote-existing incidents for this response</h2>
|
||||||
|
<table><thead><tr><th>ID</th><th>Detected</th><th>Action</th><th>Remote rows</th><th>Active</th><th>Reason</th></tr></thead><tbody>{''.join(recent_incident_rows) or "<tr><td colspan='6'>No remote-existing incidents yet.</td></tr>"}</tbody></table>
|
||||||
|
</div>
|
||||||
<div class='section'><h2>Desired jobMaterial rows</h2><table><thead><tr><th>Sort</th><th>Kind</th><th>Name</th><th>Material UUID</th><th>Source question</th></tr></thead><tbody>{''.join(material_rows) or "<tr><td colspan='5'>No desired jobMaterial rows.</td></tr>"}</tbody></table></div>
|
<div class='section'><h2>Desired jobMaterial rows</h2><table><thead><tr><th>Sort</th><th>Kind</th><th>Name</th><th>Material UUID</th><th>Source question</th></tr></thead><tbody>{''.join(material_rows) or "<tr><td colspan='5'>No desired jobMaterial rows.</td></tr>"}</tbody></table></div>
|
||||||
<div class='section'><h2>Parsed JSON</h2><pre>{escape(pretty_json(parsed))}</pre></div>
|
<div class='section'><h2>Parsed JSON</h2><pre>{escape(pretty_json(parsed))}</pre></div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def clean_text(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_state_conn(db_path: Path = STATE_DB_PATH):
|
def get_state_conn(db_path: Path = STATE_DB_PATH):
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path, timeout=30)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user