Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| 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" | |
| ), | |
| ) | |
| 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) | |
| 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) | |
| 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) | |
| 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], | |
| ) | |
| 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], | |
| ) | |