Skip to content

Add PostGIS citation database, contract API, and explorer UI - #757

Merged
gregpawin merged 13 commits into
hackforla:mainfrom
gregpawin:feat/postgis-citation-api
Sep 16, 2026
Merged

gregpawin merged 13 commits into
hackforla:mainfrom
gregpawin:feat/postgis-citation-api

Conversation

@gregpawin

Copy link
Copy Markdown
Member

Description

Adds data-science/postgis_db/, a self-contained PostGIS deployment that turns the raw LA Parking Citations flat file
into a queryable spatial database, plus two applications on top of it:

  • Contract API (FastAPI) — serves the query shapes declared in datacontract.yaml, so request/response schemas are
    generated from the contract rather than hand-maintained alongside it.
  • Explorer UI (FastAPI + Leaflet) — a browser tool for non-engineers to pick a region and get a chart, a map, or a
    spreadsheet, in either single-region or compare mode.

Regions can be resolved five ways: ZIP code, neighborhood, neighborhood council, city council district, and an arbitrary
radius around a point. Boundary layers ship as GeoJSON and shapefiles and load automatically on first container start.

Operationally: docker compose up builds the image, loads boundaries, ingests citations, and starts both apps.
Citations load into a staging table and are promoted with an atomic swap, so a failed or partial load never leaves the
served table in a mixed state. Credentials are required rather than defaulted, the API authenticates with per-consumer
keys, the explorer uses HTTP basic auth, both are rate limited, and docker-compose.prod.yml puts Caddy in front for
automatic TLS. Database errors are logged server-side and returned as fixed generic strings so connection details and
SQL fragments never reach a client.

data-science/.gitignore gains a rule for postgis_db/dumps/*, which holds multi-GB pg_dump files staged for restore.

Related Issues

Refs #695

This does not fully close #695 — it builds the destination and serving layer, not the scheduled ingestion framework
the issue describes. Against that issue's acceptance criteria:

  • ETL/ELT pipelines automated and scheduled — partial. Loading is fully automated and repeatable via container
    init, but nothing schedules it. There is no cron, Airflow, or Prefect component.
  • Incremental load support and retry mechanisms — not addressed. The loader does a full refresh via staging table
    plus atomic swap. There is no watermark, no upsert, and no retry logic.
  • Pipeline monitoring dashboard — not addressed. The explorer UI queries citation data; it does not report on
    pipeline health or load runs.

Happy to split those remaining criteria into their own issues, or to retarget this PR if you would rather track the
warehouse/serving layer separately from ingestion.

Testing

  • Unit/integration: 39 tests via pytest, covering the contract models, region resolution, API handlers, and the
    auth/rate-limit/error-sanitization paths. Database-error tests use real psycopg exceptions rather than mocks, after
    finding that an unreachable database bypassed the error handler and returned a plain-text 500 instead of a sanitized
    503.

    $ .venv/bin/python -m pytest tests/ -q --ignore=tests/test_service_integration.py
    39 passed in 0.26s
    
  • End-to-end: scripts/smoke_test.sh (also .ps1/.cmd) checks container health, the PostGIS extension, row counts
    for every boundary table, a real spatial chart query, and both web apps, printing PASS/FAIL per check and exiting
    non-zero on failure.

  • Config: both docker compose config and the prod overlay validate.

  • Clean-room: verified from an empty volume on macOS. Set CITATIONS_LOAD_LIMIT to load a sample so a full run
    completes in minutes instead of hours. Windows was specifically targeted, since Git for Windows defaults to
    core.autocrlf=true and the image copies shell scripts into a Linux container where a trailing \r fails; a scoped
    .gitattributes pins those files to LF and the Dockerfile strips CR as a second line of defence.

Checklist

  • I have followed all conventions outlined in our documentation.
  • I have fully tested my changes and confirmed that all new and existing tests pass.
  • I have written meaningful commit messages for all changes.
  • I have linted and formatted my changes to follow the code style of this repository.
  • I have updated our documentation, accordingly.
  • I have checked currently opened pull requests to ensure that there are no pending pull request for the same
    changes or issues.
  • I have confirmed that this pull request fully meets the acceptance criteria for all related issues listed above.

Two boxes left unchecked deliberately:

  • Lint/format — this is Python, and the repo's ESLint/Prettier setup only covers apps/* and packages/*, so
    pnpm verify does not reach these files. Tell me the preferred Python formatter and I will apply it; the code is
    currently Black-compatible.
  • Acceptance criteria — see the breakdown under Related Issues. Three of Set up ingestion framework and ETL #695's criteria are partial or unaddressed
    and I did not want to claim otherwise.

Two notes for reviewers: docs/CONTRIBUTING.md says to branch from and target dev, but that branch does not exist, so
this targets main. And the 119 files include boundary shapefiles and vendored Leaflet assets, so the reviewable
surface is much smaller than the diff stat suggests.

gregpawin and others added 12 commits September 14, 2026 16:46
- Enhanced `parking_clean.py` by adding `drop_incomplete` function to filter out rows with missing `issue_datetime` or `loc_lat`.
- Updated `rebuild_clean` to invoke `drop_incomplete` post datetime creation.
- Modified `parking_pipeline.py` to integrate the dropping of incomplete rows into the overall pipeline.
- Adjusted `parking_postgis.py` to ensure `drop_incomplete` is executed after the cleaned table is rebuilt.
…lace points

- Updated `datacontract.yaml` to include new valid values: "Neighborhood" and "City Council District".
- Modified `Dockerfile` to load additional GeoJSON files for council districts, neighborhoods, and places.
- Refactored `02_load_boundaries.sh` to streamline loading of boundary polygons and place points, adding functions for better code organization and including new data sources.
- Updated `docker-compose.yml` to include `SKIP_CITATIONS_LOAD` environment variable for controlling CSV loading on first boot.
- Modified `Dockerfile` to install additional Python dependencies and include new scripts for loading contract citations.
- Added `03_load_citations.sh` script to handle loading of parking citations from CSV files.
- Introduced FastAPI application in `api/main.py` for querying parking citation data.
- Created `query_contract.py` CLI for executing data-contract queries against the PostGIS database.
- Implemented error handling and validation in the new API and CLI.
- Added tests for FastAPI routes to ensure functionality and reliability.
…API features

- Added a portable boundary loader script (`load_boundaries.sh`) to streamline the loading of GeoJSON files into the database.
- Updated `02_load_boundaries.sh` to utilize the new loader, ensuring all boundary tables are populated on first container start.
- Enhanced `README.md` with detailed instructions on the new loading process and added scripts for boundary checks.
- Updated `requirements.txt` to include additional dependencies for improved functionality.
- Introduced new methods in the API for region suggestions and fetching citations based on geographic regions.
- Introduced Leaflet CSS and JS files for enhanced map functionality.
- Added marker and layer images to support map features.
- Integrated Leaflet assets into the project to facilitate interactive map rendering.
…etup

- Added a new "Quick start" section detailing the steps to build and load the PostGIS database using Docker.
- Included commands for preflight checks, starting the PostGIS service, and setting up Python tooling for local exploration.
- Provided default database connection details for user convenience.
…ionality

- Updated the citation explorer UI to support a new "Compare mode" allowing users to select and compare two regions side by side.
- Modified the backend API to handle requests for both single-region and compare mode, including validation for input regions.
- Enhanced the JavaScript to toggle between single and compare modes, updating the UI accordingly.
- Updated the CSS to style the new mode switch buttons and layout for comparison results.
- Revised the README.md to reflect the new features and usage instructions for the citation explorer.
- Introduced `docker-compose.prod.yml` for production deployment of PostGIS, API, and explorer UI.
- Created `.env.example` to provide a template for environment variables required in production.
- Updated `Dockerfile` and `Dockerfile.api` to support production builds with necessary dependencies.
- Enhanced `README.md` with detailed instructions for building and deploying the application on a VPS.
- Added deployment documentation for IONOS and general VPS setups, including steps for database initialization and restoration.
- Implemented health checks for services to ensure proper startup and readiness.
- Removed obsolete `postgisdb.zip` file to streamline the project structure.
- Enhanced the "Quick start" section with a direct link to the Parking Citations dataset on the LA Open Data Portal.
- Revised instructions for downloading the citations CSV to specify the required format and source for clarity.
Neither app could be shared with others: no authentication, no TLS, and
no abuse limits. Add credentials, put Caddy in front, and stop leaking
database internals in error responses.

Access control:
- API requires X-API-Key against the comma-separated API_KEYS, compared
  in constant time. One key per consumer, so revoking one does not
  disturb the rest.
- Explorer requires HTTP basic auth (WEB_USER / WEB_PASSWORD).
- Both fail closed: no credentials configured means 503, not open
  access. ALLOW_UNAUTHENTICATED=1 opts out for localhost only.
- /docs and /openapi.json are off unless API_DOCS_PUBLIC=1.

TLS:
- Add a caddy service that terminates TLS with automatic Let's Encrypt
  certificates; it is the only service publishing to the internet.
- Bind api/web to 127.0.0.1 in prod so they are reachable for local
  curl checks but not from outside.

Abuse and query limits:
- Per-key sliding-window rate limiter (60/min API, 30/min explorer),
  falling back to client IP when unauthenticated.
- Connections carry a 5s connect timeout and a 15s statement_timeout.
  Without the latter one wide spatial query can hold a connection until
  max_connections is exhausted.

Error handling:
- Classify exceptions in one place and return fixed strings. psycopg
  errors are not QueryError subclasses, so an unreachable database
  previously escaped the handler and returned Starlette's plain-text
  500; matched explicitly now, with tests using real driver exceptions.
- Normalize FastAPI's own 422 body to the documented {"error": ...}
  shape so clients handle one error format instead of two.
- Full detail goes to the container log, never to the client.

Credentials:
- Remove the "changeme" default from the Dockerfile, the service DSN,
  and both compose files; POSTGRES_PASSWORD is now required.
- scripts/ensure_env.sh writes a .env with a generated password so
  local setup stays one command, and gen_secrets.sh prints production
  credentials.
- Redact the password from the citation loader's progress output.

Also: run the API image as non-root, pin dependencies via
constraints.txt for reproducible builds, gitignore dumps/*.dump, and
set pythonpath in pytest.ini.

Co-authored-by: Cursor <cursoragent@cursor.com>
Repeated runs on one machine hid first-run and cross-platform bugs: a
populated volume, a warm image cache, and an existing .env all mask
steps that would fail from scratch.

Line endings (would have broken Windows outright):
- Add a .gitattributes scoped to postgis_db pinning *.sh, *.sql, and
  *.yml to LF. Git for Windows defaults to core.autocrlf=true, and the
  image COPYs init/*.sh plus load_boundaries.sh into a Linux container,
  where a trailing \r fails as "$'\r': command not found".
- Normalize 16 files that already had CRLF committed, including
  scripts/prod_restore.sh, which would have failed on the VPS on any
  platform.
- Strip CR in the Dockerfile before chmod as a second line of defence.

PowerShell 5.1 (the version that ships with Windows):
- ensure_env.ps1 and gen_secrets.ps1 used RandomNumberGenerator::Fill,
  which is .NET Core 2.1+ only, so they would have crashed under the
  Windows PowerShell 5.1 that the .cmd shims invoke. Use
  RandomNumberGenerator::Create().GetBytes() instead, which works on
  both 5.1 and 7.x.
- Write .env without a BOM; 5.1's Set-Content -Encoding utf8 adds one
  and Compose does not strip it.
- Keep .ps1 files ASCII-only, since 5.1 reads BOM-less UTF-8 as ANSI.

Verifying an install:
- Add scripts/smoke_test.sh/.ps1/.cmd: one command checks containers,
  every boundary table, a real spatial query, and both web apps,
  printing PASS/FAIL per check and exiting non-zero on failure. The
  chart window is derived from the data because the contract caps a
  range at 3660 days and CSV years differ between dumps.
- Add CITATIONS_LOAD_LIMIT so first boot can load a sample and finish
  in minutes. The multi-hour full load is the reason a clean
  end-to-end run was never practical.

Also: read POSTGRES_USER/DB from .env in the db-status scripts instead
of hardcoding them, and exclude .venv (~260 MB), .git, tests, and dumps
from the Docker build context.

README documents both this work and the preceding security changes:
a clean-room checklist, Windows-specific hazards, the empty-volume
reset trap, and the POSTGRES_PASSWORD upgrade note.

Co-authored-by: Cursor <cursoragent@cursor.com>
A fresh build of the postgis target fails:

  E: Failed to fetch .../libpython3.9-stdlib_3.9.2-1+deb11u7_amd64.deb
     404  Not Found
  E: Failed to fetch .../python3-pip_20.3.4-4+deb11u2_all.deb  404  Not Found
  E: Unable to fetch some archives
  exit code: 100

postgis/postgis:16-3.5 is built on Debian 11 (bullseye), which reached
end-of-LTS on 2026-08-31. Its .deb files have since been withdrawn from
deb.debian.org, but the index served by apt-get update still advertises
them, so update succeeds and the install then 404s on every Python
package. gdal-bin comes from bullseye/main and still resolves, which is
why the failure looks Python-specific.

This was not visible locally because a warm layer cache skips the RUN
entirely. Only a --no-cache build, or a machine that never built the
image, hits it - so it broke for a new contributor while continuing to
work here.

Take packages from archive.debian.org, which still serves bullseye:
- Drop bullseye-security. archive.debian.org has no such suite, and
  leaving it in makes apt-get update exit non-zero, which now fails the
  build since the step runs under `set -eux`.
- Archived Release files are past Valid-Until, so disable that check.
- apt.postgresql.org is a separate live repo and is left untouched.

Staying on bullseye is deliberate. postgis/postgis publishes no
bookworm/trixie build for PostGIS 3.5 on PG16; 17-3.5 is bullseye too,
and the only current option, 18-3.6, is Debian 13 but would force a
PostgreSQL major upgrade that existing data directories cannot be read
across. Moving there needs a pg_upgrade or dump/restore and should be
its own change.

Verified with `docker compose build --no-cache postgis`, then a fresh
container on a throwaway volume: healthy in 30s, PostGIS 3.5, and all
five boundary layers loaded via ogr2ogr (99 councils, 313 zipcodes, 15
districts, 114 neighborhoods, 257 places). polars 1.36.1 and psycopg
3.2.13 install and import.

Also document the failure in the README, including that a warm cache
hides it and that --no-cache is how to check what a new contributor gets.

Co-authored-by: Cursor <cursoragent@cursor.com>
@glenflorendo
glenflorendo self-requested a review September 15, 2026 18:28
@gregpawin
gregpawin merged commit 2967926 into hackforla:main Sep 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Set up ingestion framework and ETL

2 participants