Files
magnus919_agent-skills/raleigh/scripts/raleighlib/development.py
T

339 lines
14 KiB
Python

"""Read-only guest-public EnerGov Permit and Development Portal adapter."""
from __future__ import annotations
import json
import os
from typing import Any
from raleighlib import core
BASE_URL = "https://raleighnc-energovpub.tylerhost.net/apps/selfservice"
SEARCH_URL = f"{BASE_URL}/api/energov/search/search"
CRITERIA_URL = f"{BASE_URL}/api/energov/search/criteria"
PERMIT_URL = f"{BASE_URL}/api/energov/permits"
INSPECTION_SEARCH_URL = f"{BASE_URL}/api/energov/entity/inspections/search/search"
class UnsupportedEndpointError(ValueError):
"""Raised for operations that have no verified guest-public endpoint."""
def _ensure_enabled() -> None:
value = os.environ.get("RALEIGH_DISABLE_DEVELOPMENT", "").strip().casefold()
if value in {"1", "true", "yes", "on"}:
raise UnsupportedEndpointError(
"Permit and Development Portal adapter is disabled by RALEIGH_DISABLE_DEVELOPMENT"
)
def _energov_headers(extra: dict[str, str] | None = None) -> dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"tenantId": "1",
"tenantName": "RaleighNCProd",
"Tyler-TenantUrl": "RaleighNCProd",
"Tyler-Tenant-Culture": "en-US",
}
if extra:
headers.update(extra)
return headers
def fetch_criteria() -> dict[str, Any]:
"""Fetch the guest-public search criteria contract."""
_ensure_enabled()
data = core.json_request(CRITERIA_URL, headers=_energov_headers())
if not isinstance(data, dict) or data.get("Success") is False:
raise UnsupportedEndpointError("EnerGov criteria endpoint returned an error")
if not isinstance(data.get("Result"), dict):
raise UnsupportedEndpointError("EnerGov criteria schema is incompatible: Result is not an object")
return data
def _criteria_field(record_type: str) -> str:
mapping = {
"permit": "PermitCriteria",
"plan": "PlanCriteria",
"inspection": "InspectionCriteria",
"code-case": "CodeCaseCriteria",
"request": "RequestCriteria",
"license": "LicenseCriteria",
"project": "ProjectCriteria",
}
return mapping.get(record_type.lower(), "PermitCriteria")
def supported_record_types(criteria: dict[str, Any]) -> set[str]:
"""Discover guest-public record types advertised by the criteria contract."""
result = criteria.get("Result")
if not isinstance(result, dict):
raise UnsupportedEndpointError("EnerGov criteria schema is incompatible: Result is not an object")
candidates = {"permit", "plan", "inspection", "code-case", "request", "license", "project"}
return {kind for kind in candidates if isinstance(result.get(_criteria_field(kind)), dict)}
def _filter_module(record_type: str) -> int:
# EnerGov FilterModule enum values used by the public search controller.
mapping = {
"permit": 2,
"plan": 3,
"inspection": 4,
"code-case": 5,
"request": 6,
"license": 10,
"project": 11,
}
return mapping.get(record_type.lower(), 1)
def _public_scalar(value: Any, *nested_keys: str) -> Any:
"""Return a guest-display scalar, never an upstream container."""
if isinstance(value, dict):
for key in nested_keys:
candidate = value.get(key)
if candidate is None or isinstance(candidate, (dict, list, tuple, set)):
continue
return candidate
return None
if value is None or isinstance(value, (str, int, float, bool)):
return value
return None
def _first_public(record: dict[str, Any], keys: tuple[str, ...], *nested_keys: str) -> Any:
for key in keys:
value = _public_scalar(record.get(key), *nested_keys)
if value not in (None, ""):
return value
return None
def _normalize_search_record(record: dict[str, Any]) -> dict[str, Any]:
"""Return only fields represented by the guest-public search cards."""
address = _first_public(record, ("Address",), "FullAddress", "AddressLine", "DisplayText")
return {
"RecordId": _first_public(record, ("CaseId", "Id", "id"), "Id", "Value"),
"RecordNumber": _first_public(record, ("CaseNumber", "RecordNumber", "PermitNumber"), "Number", "Value"),
"RecordType": _first_public(record, ("CaseType", "RecordType"), "Name", "DisplayText", "Value"),
"WorkClass": _first_public(record, ("CaseWorkclass", "WorkClass"), "Name", "DisplayText", "Value"),
"Status": _first_public(record, ("CaseStatus", "Status"), "Name", "DisplayText", "Value"),
"ProjectName": _first_public(record, ("ProjectName",), "Name", "DisplayText"),
"IssueDate": _public_scalar(record.get("IssueDate")),
"ApplyDate": _public_scalar(record.get("ApplyDate")),
"ExpireDate": _public_scalar(record.get("ExpireDate")),
"FinalDate": _public_scalar(record.get("FinalDate")),
"Address": address or _public_scalar(record.get("AddressDisplay")),
"ParcelNumber": _first_public(record, ("MainParcel",), "ParcelNumber", "Number", "Value"),
"Description": _public_scalar(record.get("Description")),
}
def public_search(
record_type: str,
query: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
"""Search guest-visible records by type using the verified POST contract.
Returns a dict with ``results`` and ``total`` so callers can paginate or
report counts.
"""
core.require_positive_limit(limit)
criteria = fetch_criteria()
normalized_type = record_type.lower()
supported = supported_record_types(criteria)
if normalized_type not in supported:
raise UnsupportedEndpointError(
f"EnerGov does not advertise guest-public {normalized_type} search criteria"
)
result = dict(criteria["Result"])
result["Keyword"] = query or ""
result["ExactMatch"] = bool(query)
result["SearchModule"] = 1 # Global public search
result["FilterModule"] = _filter_module(record_type)
result["SearchMainAddress"] = False
result["PageNumber"] = 1
result["PageSize"] = limit
result["SortBy"] = None
result["SortAscending"] = True
data = core.json_request(
SEARCH_URL,
method="POST",
data=json.dumps(result).encode("utf-8"),
headers=_energov_headers(),
)
data = core.require_object(data, "EnerGov search")
search_result = data.get("Result")
if not isinstance(search_result, dict):
message = (
data.get("ErrorMessage")
or data.get("ValidationErrorMessage")
or "EnerGov search returned no result"
)
raise UnsupportedEndpointError(message)
entity_results = search_result.get("EntityResults")
total_found = search_result.get("TotalFound")
if not isinstance(entity_results, list) or any(not isinstance(item, dict) for item in entity_results):
raise UnsupportedEndpointError("EnerGov search schema is incompatible: EntityResults is not an object list")
if not isinstance(total_found, int) or isinstance(total_found, bool) or total_found < 0:
raise UnsupportedEndpointError("EnerGov search schema is incompatible: TotalFound is invalid")
if len(entity_results) > total_found:
raise UnsupportedEndpointError(
"EnerGov search schema is incompatible: EntityResults exceeds TotalFound"
)
return {
"results": [
_normalize_search_record(record)
for record in entity_results[:limit]
],
"total": total_found,
}
def _is_uuid(value: str) -> bool:
"""Return True if value looks like an EnerGov record UUID."""
import uuid
try:
uuid.UUID(value)
return True
except ValueError:
return False
def _resolve_uuid(record: str) -> str:
"""Resolve a record identifier to a UUID, searching by record number if needed."""
record = record.strip()
if _is_uuid(record):
return record
results = public_search("permit", query=record, limit=20)
record_folded = record.casefold()
matches = [
r for r in results.get("results", [])
if str(r.get("RecordNumber") or r.get("PermitNumber") or r.get("CaseNumber") or "").casefold()
== record_folded
]
if not matches:
raise ValueError(f"No permit found for record number: {record}")
if len(matches) > 1:
raise ValueError(f"Ambiguous record number '{record}'; supply the UUID")
resolved = (
matches[0].get("RecordId")
or matches[0].get("Id")
or matches[0].get("id")
or matches[0].get("CaseId")
)
if not resolved:
raise ValueError("Resolved record has no UUID")
return resolved
def permit_detail(record: str) -> dict[str, Any]:
"""Fetch guest-public permit details by UUID or record number."""
_ensure_enabled()
uuid = _resolve_uuid(record)
url = f"{PERMIT_URL}/{uuid}"
data = core.json_request(url, headers=_energov_headers())
if not isinstance(data, dict):
raise UnsupportedEndpointError("EnerGov permit detail schema is incompatible")
if data.get("Success") is False:
raise ValueError(data.get("ErrorMessage") or "EnerGov permit detail failed")
result = data.get("Result", data)
if not isinstance(result, dict):
raise UnsupportedEndpointError("EnerGov permit detail Result must be an object")
projected = {
"PermitId": _first_public(result, ("PermitId",), "Id", "Value"),
"PermitNumber": _first_public(result, ("PermitNumber",), "Number", "Value"),
"PermitType": _first_public(result, ("PermitType",), "Name", "DisplayText", "Value"),
"PermitStatus": _first_public(result, ("PermitStatus",), "Name", "DisplayText", "Value"),
"IssueDate": _public_scalar(result.get("IssueDate")),
"ExpireDate": _public_scalar(result.get("ExpireDate")),
"FinalizeDate": _public_scalar(result.get("FinalizeDate")),
"ApplyDate": _public_scalar(result.get("ApplyDate")),
"WorkClassName": _first_public(result, ("WorkClassName",), "Name", "DisplayText", "Value"),
"Description": _public_scalar(result.get("Description")),
"IVRNumber": _public_scalar(result.get("IVRNumber")),
"MainAddress": _first_public(result, ("MainAddress",), "FullAddress", "AddressLine", "DisplayText"),
"MainParcelNumber": _first_public(result, ("MainParcelNumber",), "ParcelNumber", "Number", "Value"),
"ProjectName": _first_public(result, ("ProjectName",), "Name", "DisplayText"),
"DistrictName": _first_public(result, ("DistrictName",), "Name", "DisplayText"),
"SquareFeet": _public_scalar(result.get("SquareFeet")),
"Value": _public_scalar(result.get("Value")),
}
if not projected["PermitId"] and not projected["PermitNumber"]:
raise UnsupportedEndpointError(
"EnerGov permit detail Result has no permit identifier"
)
return projected
def inspections_for_record(record: str, limit: int = 10) -> list[dict[str, Any]]:
"""Fetch guest-visible inspections for a permit UUID or record number."""
if limit < 1:
raise ValueError("limit must be at least 1")
_ensure_enabled()
uuid = _resolve_uuid(record)
payload = {
"PageNumber": 1,
"PageSize": limit,
"SortField": "",
"IsSortedInAscendingOrder": True,
"ModuleId": 1,
"EntityId": uuid,
"IsExistingInspection": True,
"IsOptionalInspection": False,
"IsFailed": False,
}
data = core.json_request(
INSPECTION_SEARCH_URL,
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers=_energov_headers(),
)
if not isinstance(data, dict):
raise UnsupportedEndpointError("EnerGov inspection schema is incompatible")
if data.get("Success") is False:
raise ValueError(data.get("ErrorMessage") or "EnerGov inspection search failed")
result = data.get("Result", data)
if isinstance(result, dict):
if "results" in result:
result = result["results"]
elif "Results" in result:
result = result["Results"]
else:
raise UnsupportedEndpointError(
"EnerGov inspection Result has no recognized result list"
)
if not isinstance(result, list):
raise UnsupportedEndpointError("EnerGov inspection Result must be a list")
rows: list[dict[str, Any]] = []
for inspection in result[:limit]:
if not isinstance(inspection, dict):
raise UnsupportedEndpointError(
"EnerGov inspection Result contains a non-object item"
)
rows.append({
"InspectionId": _first_public(inspection, ("InspectionId",), "Id", "Value"),
"InspectionNumber": _first_public(inspection, ("InspectionNumber",), "Number", "Value"),
"InspectionType": _first_public(inspection, ("InspectionType",), "Name", "DisplayText", "Value"),
"InspectionStatus": _first_public(inspection, ("InspectionStatus",), "Name", "DisplayText", "Value"),
"RequestedDate": _public_scalar(inspection.get("RequestedDate")),
"ScheduledStartDate": _public_scalar(inspection.get("ScheduledStartDate")),
"ActualDate": _public_scalar(inspection.get("ActualDate")),
"PrimaryInspector": _first_public(inspection, ("PrimaryInspector",), "Name", "DisplayName"),
"IsReinspectionDisplayText": _public_scalar(inspection.get("IsReinspectionDisplayText")),
})
return rows
def code_cases(query: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
"""Search guest-visible code cases."""
return public_search("code-case", query=query, limit=limit)
def licenses(query: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
"""Search guest-visible licenses."""
return public_search("license", query=query, limit=limit)