open-navigator / api /routes /addresses.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
14.7 kB
"""
Property / parcel addresses from ``bronze.bronze_addresses``.
Source: Esri FeatureServer / MapServer attribute harvest (returnGeometry=false)
of county assessor parcel data. Carries situs + owner + parcel attributes, not
geometry — so this API is keyed off identifiers (serial id, source_record_id,
parcel number) rather than lat/lng radius searches.
Used by the property-click drilldown: the frontend maps a parcel polygon (with
its source parcel number or record id) to the full address row here.
See: packages/hosting/scripts/neon/migrations/074_create_bronze_addresses.sql
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import BaseModel, Field
from api.routes.stats_neon import get_db_pool
router = APIRouter(prefix="/addresses", tags=["addresses"])
# Per-request safety net: list endpoints are paged, but a runaway scan would be
# expensive on the bronze table (parcel harvests can run into millions of rows).
_MAX_LIST_LIMIT = 5_000
class AddressDetail(BaseModel):
"""One parcel/property record from ``bronze.bronze_addresses``."""
id: int
source_dataset: str
source_record_id: str
state_code: str
county_fips: Optional[str] = None
county_name: Optional[str] = None
jurisdiction_id: Optional[str] = None
owner_name: Optional[str] = None
# Situs (where the property is) — both pieces and a pre-joined display string.
situs_location: Optional[str] = None
street_number: Optional[str] = None
street_line1: Optional[str] = None
street_line2: Optional[str] = None
city: Optional[str] = None
state_abbr: Optional[str] = None
postal_code: Optional[str] = None
situs_full: Optional[str] = None
parcel_number: Optional[str] = None
parcel_number_formatted: Optional[str] = None
appraised_value: Optional[int] = None
tax_class: Optional[str] = None
data_source: str
esri_endpoint: Optional[str] = None
# `include_raw=true` is required to populate this — the JSONB blob can be
# large (full Esri attribute dump) so we drop it by default.
raw_attributes: Optional[Dict[str, Any]] = None
loaded_at: Optional[str] = None
class AddressListResponse(BaseModel):
state: Optional[str] = None
county_fips: Optional[str] = None
jurisdiction_id: Optional[str] = None
total: int = Field(..., description="Matching rows before LIMIT.")
limit: int
offset: int
addresses: List[AddressDetail]
_SELECT_COLUMNS_NO_RAW = """
id,
source_dataset,
source_record_id,
state_code,
county_fips,
county_name,
jurisdiction_id,
owner_name,
situs_location,
street_number,
street_line1,
street_line2,
city,
state_abbr,
postal_code,
situs_full,
parcel_number,
parcel_number_formatted,
appraised_value,
tax_class,
data_source,
esri_endpoint,
NULL::jsonb AS raw_attributes,
loaded_at
"""
_SELECT_COLUMNS_WITH_RAW = _SELECT_COLUMNS_NO_RAW.replace(
"NULL::jsonb AS raw_attributes", "raw_attributes"
)
def _row_to_address(row) -> AddressDetail:
return AddressDetail(
id=int(row["id"]),
source_dataset=str(row["source_dataset"]),
source_record_id=str(row["source_record_id"]),
state_code=str(row["state_code"]),
county_fips=row["county_fips"],
county_name=row["county_name"],
jurisdiction_id=row["jurisdiction_id"],
owner_name=row["owner_name"],
situs_location=row["situs_location"],
street_number=row["street_number"],
street_line1=row["street_line1"],
street_line2=row["street_line2"],
city=row["city"],
state_abbr=row["state_abbr"],
postal_code=row["postal_code"],
situs_full=row["situs_full"],
parcel_number=row["parcel_number"],
parcel_number_formatted=row["parcel_number_formatted"],
appraised_value=(int(row["appraised_value"]) if row["appraised_value"] is not None else None),
tax_class=row["tax_class"],
data_source=str(row["data_source"]),
esri_endpoint=row["esri_endpoint"],
raw_attributes=row["raw_attributes"],
loaded_at=(row["loaded_at"].isoformat() if row["loaded_at"] is not None else None),
)
async def _table_exists(conn) -> bool:
row = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'bronze' AND table_name = 'bronze_addresses'
)
"""
)
return bool(row)
async def _ensure_table(conn) -> None:
"""Raises 503 with a hint to run the migration if the table is missing."""
if not await _table_exists(conn):
raise HTTPException(
status_code=503,
detail=(
"bronze.bronze_addresses not found. Apply migration: "
"packages/hosting/scripts/neon/migrations/074_create_bronze_addresses.sql"
),
)
@router.get("/{address_id}", response_model=AddressDetail)
async def get_address_by_id(
address_id: int,
include_raw: bool = Query(
False, description="Include the raw Esri attribute JSON blob in the response."
),
):
"""
Look up a single property/parcel by its internal ``bronze_addresses.id``.
This is the primary "property-click → details" endpoint. The frontend
typically gets the id by clicking a parcel polygon whose attributes
include the bronze id (or by resolving via ``/addresses/by-parcel`` when
only a parcel number is available).
"""
select_cols = _SELECT_COLUMNS_WITH_RAW if include_raw else _SELECT_COLUMNS_NO_RAW
pool = await get_db_pool()
async with pool.acquire() as conn:
await _ensure_table(conn)
row = await conn.fetchrow(
f"""
SELECT {select_cols}
FROM bronze.bronze_addresses
WHERE id = $1
""",
address_id,
)
if row is None:
raise HTTPException(status_code=404, detail=f"Address id={address_id} not found.")
return _row_to_address(row)
@router.get("/by-parcel/lookup", response_model=AddressDetail)
async def get_address_by_parcel(
state: str = Query(..., min_length=2, max_length=2, description="USPS state code (e.g. AL)"),
parcel: str = Query(..., min_length=1, description="parcel_number_formatted as it appears in bronze"),
include_raw: bool = Query(False),
):
"""
Resolve a parcel number to its address record.
Uses the ``(state_code, parcel_number_formatted)`` index (see migration 074),
so this is the fast path when the polygon click only has a parcel number,
not the bronze internal id. State code is required because parcel numbers
are not nationally unique.
"""
select_cols = _SELECT_COLUMNS_WITH_RAW if include_raw else _SELECT_COLUMNS_NO_RAW
state_upper = state.strip().upper()
pool = await get_db_pool()
async with pool.acquire() as conn:
await _ensure_table(conn)
row = await conn.fetchrow(
f"""
SELECT {select_cols}
FROM bronze.bronze_addresses
WHERE state_code = $1
AND parcel_number_formatted = $2
LIMIT 1
""",
state_upper,
parcel,
)
if row is None:
raise HTTPException(
status_code=404,
detail=f"No address found for state={state_upper} parcel={parcel}",
)
return _row_to_address(row)
@router.get("/by-source/lookup", response_model=AddressDetail)
async def get_address_by_source(
source_dataset: str = Query(..., description="The harvest source dataset key."),
source_record_id: str = Query(..., description="The id within the source dataset."),
include_raw: bool = Query(False),
):
"""
Resolve a ``(source_dataset, source_record_id)`` pair. This is the
canonical natural-key lookup (UNIQUE constraint on the table) and is
the right path when the polygon click can carry the source attributes
directly.
"""
select_cols = _SELECT_COLUMNS_WITH_RAW if include_raw else _SELECT_COLUMNS_NO_RAW
pool = await get_db_pool()
async with pool.acquire() as conn:
await _ensure_table(conn)
row = await conn.fetchrow(
f"""
SELECT {select_cols}
FROM bronze.bronze_addresses
WHERE source_dataset = $1 AND source_record_id = $2
""",
source_dataset,
source_record_id,
)
if row is None:
raise HTTPException(
status_code=404,
detail=f"No address found for source_dataset={source_dataset} record_id={source_record_id}",
)
return _row_to_address(row)
@router.get("/search", response_model=AddressListResponse)
async def search_addresses(
q: str = Query(..., min_length=3, description="Free-text query (matched against situs_full / street_line1 / city)."),
state: Optional[str] = Query(None, min_length=2, max_length=2, description="USPS state code."),
limit: int = Query(20, ge=1, le=200),
):
"""
Free-text address search — for the property-pin click flow.
The bronze table has no geometry, so we can't resolve a lat/lng pin to
a parcel row directly. Instead we ILIKE-match the situs/street string
from the geocoder. Provide ``state`` whenever possible to constrain the
scan and avoid cross-state false positives.
Returns the same envelope as the listing endpoint so callers can reuse
the rendering. Empty list (200) means "no parcel match" — distinct from
a 503 (table missing) or 4xx (bad query).
"""
state_upper = state.strip().upper() if state else None
# Extract a likely-house-number prefix to tighten ILIKE matches (e.g.
# "5617 Lakeridge..." → "5617"). Helps when the user pastes a full
# display_name like "5617 Lakeridge Court, Tuscaloosa, AL 35406, USA".
head = q.strip().split(",", 1)[0].strip()
where_parts = [
"(situs_full ILIKE $1 OR street_line1 ILIKE $1 OR street_number || ' ' || street_line1 ILIKE $1)",
]
params: list = [f"%{head}%"]
idx = 2
if state_upper:
where_parts.append(f"state_code = ${idx}")
params.append(state_upper)
idx += 1
where_sql = " AND ".join(where_parts)
pool = await get_db_pool()
async with pool.acquire() as conn:
await _ensure_table(conn)
total = await conn.fetchval(
f"SELECT COUNT(*)::bigint FROM bronze.bronze_addresses WHERE {where_sql}",
*params,
)
rows = await conn.fetch(
f"""
SELECT {_SELECT_COLUMNS_NO_RAW}
FROM bronze.bronze_addresses
WHERE {where_sql}
ORDER BY state_code, situs_full NULLS LAST, id
LIMIT ${idx}
""",
*params,
limit,
)
return AddressListResponse(
state=state_upper,
county_fips=None,
jurisdiction_id=None,
total=int(total or 0),
limit=limit,
offset=0,
addresses=[_row_to_address(r) for r in rows],
)
@router.get("", response_model=AddressListResponse)
async def list_addresses(
state: Optional[str] = Query(None, min_length=2, max_length=2),
county_fips: Optional[str] = Query(
None, min_length=3, max_length=5, description="3-digit county FIPS or 5-digit GEOID."
),
jurisdiction_id: Optional[str] = Query(None),
owner_q: Optional[str] = Query(
None, description="Free-text owner_name search (uses the GIN tsvector index)."
),
limit: int = Query(200, ge=1, le=_MAX_LIST_LIMIT),
offset: int = Query(0, ge=0),
):
"""
Paged list of parcels within a jurisdiction filter.
At least one of ``state``, ``county_fips``, ``jurisdiction_id``, or
``owner_q`` must be provided — an unfiltered scan of the bronze table
is rejected (it would table-scan millions of rows).
Example::
GET /api/addresses?state=AL&county_fips=01125&limit=200
GET /api/addresses?state=AL&owner_q=university
"""
if not (state or county_fips or jurisdiction_id or owner_q):
raise HTTPException(
status_code=400,
detail="Provide at least one of: state, county_fips, jurisdiction_id, owner_q.",
)
where_parts: List[str] = []
params: list = []
idx = 1
state_upper = state.strip().upper() if state else None
if state_upper:
where_parts.append(f"state_code = ${idx}")
params.append(state_upper)
idx += 1
if county_fips:
fips = county_fips.strip()
# Accept either '125' (3-digit county part) or '01125' (5-digit GEOID).
# Stored as county_fips VARCHAR(5), values vary by loader — match both.
if len(fips) == 5:
where_parts.append(f"county_fips = ${idx}")
params.append(fips)
idx += 1
else:
where_parts.append(f"(county_fips = ${idx} OR county_fips LIKE '%' || ${idx})")
params.append(fips)
idx += 1
if jurisdiction_id:
where_parts.append(f"jurisdiction_id = ${idx}")
params.append(jurisdiction_id)
idx += 1
if owner_q:
# Uses idx_bronze_addresses_owner GIN index from the migration.
where_parts.append(
f"to_tsvector('english', coalesce(owner_name, '')) @@ plainto_tsquery('english', ${idx})"
)
params.append(owner_q)
idx += 1
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
pool = await get_db_pool()
async with pool.acquire() as conn:
await _ensure_table(conn)
total = await conn.fetchval(
f"SELECT COUNT(*)::bigint FROM bronze.bronze_addresses WHERE {where_sql}",
*params,
)
limit_idx = idx
offset_idx = idx + 1
rows = await conn.fetch(
f"""
SELECT {_SELECT_COLUMNS_NO_RAW}
FROM bronze.bronze_addresses
WHERE {where_sql}
ORDER BY state_code, county_fips NULLS LAST, situs_full NULLS LAST, id
LIMIT ${limit_idx} OFFSET ${offset_idx}
""",
*params,
limit,
offset,
)
return AddressListResponse(
state=state_upper,
county_fips=county_fips,
jurisdiction_id=jurisdiction_id,
total=int(total or 0),
limit=limit,
offset=offset,
addresses=[_row_to_address(r) for r in rows],
)