Compare commits

..

2 Commits

5 changed files with 137 additions and 15 deletions
+117 -3
View File
@@ -33,6 +33,11 @@ DEV_QUOTE_MATERIAL_UUID = "f78b1d23-b9fa-40fe-a806-2425fe09cc0b"
QUOTE_INCLUDE_HEADER_MATERIAL_UUID = "1924893b-917f-474a-adaa-2093bd622d4b"
QUOTE_EXCLUDE_HEADER_MATERIAL_UUID = "4947bfd7-4875-48f7-9caf-2093b9751b9b"
DEV_QUOTE_TAX_RATE_UUID = "84e4dd28-06b3-452b-a796-1f58a20ac49b"
QUOTE_DESCRIPTION_PREFIX = "Thank you for the opportunity to quote to "
QUOTE_DESCRIPTION_SUFFIX = (
"Please find below a detailed breakdown of the proposed costs included in the quotation. "
"If you have any questions or concerns, please do not hesitate to contact us."
)
def utc_now() -> str:
@@ -66,6 +71,69 @@ def build_payload(job_uuid: str, row: dict) -> dict:
}
def clean_text(value: Any) -> str:
if value is None:
return ""
return str(value).replace("\r\n", "\n").replace("\r", "\n").strip()
def first_text(*values: Any) -> str:
for value in values:
text = clean_text(value)
if text:
return text
return ""
def format_job_address(job: Dict[str, Any]) -> str:
direct = first_text(
job.get("job_address"),
job.get("site_address"),
job.get("address"),
job.get("location_address"),
job.get("billing_address"),
)
if direct:
return direct
parts = [
first_text(job.get("street"), job.get("street_address"), job.get("address_1"), job.get("address1")),
first_text(job.get("suburb"), job.get("city")),
first_text(job.get("state")),
first_text(job.get("postcode"), job.get("postal_code"), job.get("zip")),
]
return " ".join(part for part in parts if part)
def build_quote_description_text(description: str, job: Dict[str, Any]) -> str:
description = clean_text(description)
if not description:
return ""
address = format_job_address(job) or "the job address"
return f"{QUOTE_DESCRIPTION_PREFIX} {description} at {address}. {QUOTE_DESCRIPTION_SUFFIX}"
def build_job_update_payload(description: str, job: Dict[str, Any]) -> dict:
quote_description = build_quote_description_text(description, job)
return {"job_description": quote_description} if quote_description else {}
def retrieve_job(session: requests.Session, job_uuid: str) -> Dict[str, Any]:
response = session.get(f"{BASE_URL}/job/{job_uuid}.json", timeout=REQUEST_TIMEOUT)
if not response.ok:
raise RuntimeError(f"Job retrieve failed: HTTP {response.status_code} :: {response.text[:1000]}")
data = response.json()
if not isinstance(data, dict):
raise RuntimeError(f"Job retrieve expected object response, got {type(data).__name__}")
return data
def update_job_description(session: requests.Session, job_uuid: str, payload: dict) -> None:
response = session.post(f"{BASE_URL}/job/{job_uuid}.json", json=payload, timeout=REQUEST_TIMEOUT)
if not response.ok:
raise RuntimeError(f"Job description update failed: HTTP {response.status_code} :: {response.text[:1000]}")
def create_job_material(session: requests.Session, payload: dict) -> str:
response = session.post(f"{BASE_URL}/jobmaterial.json", json=payload, timeout=REQUEST_TIMEOUT)
if not response.ok:
@@ -393,7 +461,40 @@ def main() -> int:
}
try:
api_key = load_api_key()
session = requests.Session()
session.headers.update({"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"})
quote_description_source = clean_text(quote["description"])
job_details = retrieve_job(session, job_uuid) if quote_description_source else {}
job_update_payload = build_job_update_payload(quote_description_source, job_details)
job_update_record_payload = {
"endpoint": f"/job/{job_uuid}.json",
"payload": job_update_payload,
"source_description": quote_description_source,
"job_address": format_job_address(job_details) if job_details else "",
}
job_update_row = {
"kind": "job_description",
"source_question": "Description of Works to be Quoted",
"name": job_update_payload.get("job_description", ""),
}
if not args.apply:
if job_update_payload:
record_apply_row(
conn,
run_id=run_id,
form_response_uuid=form_response_uuid,
job_uuid=job_uuid,
row_index=0,
row=job_update_row,
api_payload=job_update_record_payload,
action="would_update_job_description",
)
result["job_update"] = {"action": "would_update_job_description", **job_update_record_payload}
else:
result["job_update"] = {"action": "skipped", "reason": "Quote description is empty"}
for idx, row in enumerate(desired_rows, start=1):
api_payload = build_payload(job_uuid, row)
record_apply_row(
@@ -416,10 +517,7 @@ def main() -> int:
print(json.dumps(result, indent=2 if args.pretty else None, ensure_ascii=False))
return 0
api_key = load_api_key()
init_state_db(STATE_DB_PATH)
session = requests.Session()
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:
@@ -460,6 +558,22 @@ def main() -> int:
print(json.dumps(result, indent=2 if args.pretty else None, ensure_ascii=False))
return 0
if job_update_payload:
update_job_description(session, job_uuid, job_update_payload)
record_apply_row(
conn,
run_id=run_id,
form_response_uuid=form_response_uuid,
job_uuid=job_uuid,
row_index=0,
row=job_update_row,
api_payload=job_update_record_payload,
action="updated_job_description",
)
result["job_update"] = {"action": "updated_job_description", **job_update_record_payload}
else:
result["job_update"] = {"action": "skipped", "reason": "Quote description is empty"}
created_count = 0
for idx, row in enumerate(desired_rows, start=1):
api_payload = build_payload(job_uuid, row)
+2 -2
View File
@@ -7,8 +7,8 @@ BASE_URL = os.getenv('SERVICEM8_BASE_URL', 'https://api.servicem8.com')
ENDPOINT = '/webhook_subscriptions/event'
ACCESS_TOKEN = os.getenv('SERVICEM8_ACCESS_TOKEN', 'smk-ac525b-99c4b96305a49c7c-fe4dd3e705b647ea')
EVENT_NAME = os.getenv('SERVICEM8_EVENT', 'form.response_created')
CALLBACK_URL = os.getenv('SERVICEM8_CALLBACK_URL', 'https://nps-dev.coast2cloud.net/webhooks/servicem8/form-response')
UNIQUE_ID = os.getenv('SERVICEM8_UNIQUE_ID', 'dev-form-response')
CALLBACK_URL = os.getenv('SERVICEM8_CALLBACK_URL', 'https://webhook.naroomaplumbing.au/webhooks/servicem8/form-response')
UNIQUE_ID = os.getenv('SERVICEM8_UNIQUE_ID', 'au-dev-form-response')
def pretty_print_json(data):
+2 -2
View File
@@ -8,8 +8,8 @@ ENDPOINT = '/webhook_subscriptions'
ACCESS_TOKEN = os.getenv('SERVICEM8_ACCESS_TOKEN', 'smk-ac525b-99c4b96305a49c7c-fe4dd3e705b647ea')
OBJECT_NAME = os.getenv('SERVICEM8_OBJECT', 'job')
FIELDS = os.getenv('SERVICEM8_FIELDS', 'uuid,status,date,queue_uuid,queue_expiry_date,work_order_date,active,edit_date,generated_job_id')
CALLBACK_URL = os.getenv('SERVICEM8_CALLBACK_URL', 'https://nps-dev.coast2cloud.net/webhooks/servicem8-object')
UNIQUE_ID = os.getenv('SERVICEM8_UNIQUE_ID', 'dev-job-object')
CALLBACK_URL = os.getenv('SERVICEM8_CALLBACK_URL', 'https://webhook.naroomaplumbing.au/webhooks/servicem8-object')
UNIQUE_ID = os.getenv('SERVICEM8_UNIQUE_ID', 'au-dev-job-object')
def pretty_print_json(data):
+2 -2
View File
@@ -5,9 +5,9 @@ import requests
BASE_URL = os.getenv('SERVICEM8_BASE_URL', 'https://api.servicem8.com')
ENDPOINT = '/webhook_subscriptions/event'
ACCESS_TOKEN = os.getenv('SERVICEM8_ACCESS_TOKEN', '')
ACCESS_TOKEN = os.getenv('SERVICEM8_ACCESS_TOKEN', 'smk-ac525b-99c4b96305a49c7c-fe4dd3e705b647ea')
EVENT_NAME = os.getenv('SERVICEM8_EVENT', 'job.updated')
CALLBACK_URL = os.getenv('SERVICEM8_CALLBACK_URL', 'https://nps-dev.coast2cloud.net/webhooks/servicem8-job-updated')
CALLBACK_URL = os.getenv('SERVICEM8_CALLBACK_URL', 'https://webhook.naroomaplumbing.au/webhooks/servicem8-job-updated')
UNIQUE_ID = os.getenv('SERVICEM8_UNIQUE_ID', 'dev-job-updated')
+14 -6
View File
@@ -93,6 +93,14 @@ def pretty_json(value):
return str(value)
def html_value(value):
if value is None:
return ""
if isinstance(value, (dict, list)):
return pretty_json(value)
return str(value)
def parse_json_field(value, default=None):
if value is None:
return default
@@ -520,10 +528,10 @@ def form_response_detail(row_id: int):
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"<td>{escape(html_value(item.get('SortOrder')))}</td>"
f"<td>{escape(html_value(item.get('FieldType')))}</td>"
f"<td>{escape(html_value(item.get('Question')))}</td>"
f"<td>{escape(html_value(item.get('Response')))}</td>"
f"</tr>"
)
@@ -851,8 +859,8 @@ def polled_form_response_detail(form_response_uuid: str):
for item in sorted(field_data, key=lambda x: x.get("SortOrder", 0) if isinstance(x, dict) else 0):
if isinstance(item, dict):
field_rows.append(
f"<tr><td>{escape(str(item.get('SortOrder', '')))}</td><td>{escape(item.get('FieldType', ''))}</td>"
f"<td>{escape(item.get('Question', ''))}</td><td>{escape(item.get('Response', ''))}</td></tr>"
f"<tr><td>{escape(html_value(item.get('SortOrder')))}</td><td>{escape(html_value(item.get('FieldType')))}</td>"
f"<td>{escape(html_value(item.get('Question')))}</td><td>{escape(html_value(item.get('Response')))}</td></tr>"
)
quote_link = f"<div><strong>Parsed quote</strong></div><div><a href='/poll/quote-template/{escape(form_response_uuid)}'>Open parsed Quote Template view</a></div>" if quote else ""