Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import os

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware

Expand All @@ -26,6 +26,17 @@
# local dev has nothing else in front of it, so compress here too.
app.add_middleware(GZipMiddleware, minimum_size=1000)


@app.middleware("http")
async def cache_get_responses(request: Request, call_next):
# Data only changes when the ETL rebuilds the warehouse, so GETs are safe
# to cache briefly in the browser / nginx.
response = await call_next(request)
if request.method == "GET" and response.status_code == 200:
response.headers.setdefault("Cache-Control", "public, max-age=3600")
return response


# Only needed for `next dev`, which bypasses the nginx proxy.
# In the containers everything is same-origin and this does nothing.
if os.environ.get("ALLOW_DEV_CORS"):
Expand Down
24 changes: 20 additions & 4 deletions backend/api/routes/get_routes/get_filters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from functools import lru_cache
from typing import Annotated

from fastapi import APIRouter, Query
Expand All @@ -19,7 +20,7 @@ def get_filter_table_metadata(target_table: str, filter_table: str) -> dict:


@router.get("/filters/schema")
async def get_schema(target_table: str) -> dict:
def get_schema(target_table: str) -> dict:
all_tables = set(schema["default"]) | set(schema.get(target_table, {}))
return {
filter_table: get_filter_table_metadata(target_table, filter_table)
Expand All @@ -28,7 +29,7 @@ async def get_schema(target_table: str) -> dict:


@router.get("/filters/tree")
async def filter_tree_endpoint(
def filter_tree_endpoint(
filter_table: str,
target_table: str = "default",
exclude_cols: Annotated[list[str] | None, Query()] = None,
Expand All @@ -50,6 +51,11 @@ async def filter_tree_endpoint(
dict: a JSON dictionary of format
key1: {values, each key2: values} and so on iteratively through the columns.
"""
return _filter_tree(filter_table, target_table, tuple(exclude_cols or ()))


@lru_cache(maxsize=128)
def _filter_tree(filter_table: str, target_table: str, exclude_cols: tuple):
meta = get_filter_table_metadata(target_table, filter_table)
colmap: dict = meta["columns"]
if exclude_cols:
Expand All @@ -66,11 +72,16 @@ async def filter_tree_endpoint(


@router.get("/filters/options")
async def filter_options_endpoint(
def filter_options_endpoint(
filter_table: str,
target_table: str = "default",
cols: Annotated[list[str] | None, Query()] = None,
):
return _filter_options(filter_table, target_table, tuple(cols or ()))


@lru_cache(maxsize=128)
def _filter_options(filter_table: str, target_table: str, cols: tuple):
meta = get_filter_table_metadata(target_table, filter_table)
colmap: dict = meta["columns"]

Expand All @@ -85,13 +96,18 @@ async def filter_options_endpoint(


@router.get("/filters/ranges")
async def filter_ranges_endpoint(
def filter_ranges_endpoint(
filter_table: str,
target_table: str = "default",
cols: Annotated[list[str] | None, Query()] = None,
):
"""Min/max bounds for one or more numeric columns (e.g. min lot size
sliders). Mirrors /filters/options but for the schema's "range" map."""
return _filter_ranges(filter_table, target_table, tuple(cols or ()))


@lru_cache(maxsize=128)
def _filter_ranges(filter_table: str, target_table: str, cols: tuple):
meta = get_filter_table_metadata(target_table, filter_table)
rangemap: dict = meta.get("range", {})

Expand Down
4 changes: 2 additions & 2 deletions backend/api/routes/get_routes/get_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@


@router.get("/load/mapping/wastewater/septic_soil_legend")
async def wastewater_soil_suit_legend():
def wastewater_soil_suit_legend():
data = get_soil_suit_legend()
return Response(content=data, media_type="application/json")


@router.get("/load/mapping/ambulance/ambulance_legend")
async def ambulance_legend():
def ambulance_legend():
data = get_ambulance_legend()
return Response(content=data, media_type="application/json")
21 changes: 13 additions & 8 deletions backend/api/routes/get_routes/get_wholedata.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import os
from functools import lru_cache
from pathlib import Path

from fastapi import APIRouter, Response
Expand All @@ -14,27 +15,31 @@
BACKEND_DIR = Path(__file__).resolve().parents[3]
DATA_DIR = Path(os.environ.get("DATA_DIR", BACKEND_DIR / "Data"))

# Load the municipalities dataset into memory during initial startup
with open(DATA_DIR / "vermont" / "municipalities.json", "r", encoding="utf-8") as file:
MUNICIPALITIES_DATA = json.load(file)


@router.get("/")
def read_root():
return {"Default Message": "No endpoint specified"}


@lru_cache(maxsize=1)
def _flood_all() -> str:
return get_flood_geojson([])


# Flood Endpoint
# Unfiltered legacy fetch — the filterable, tooltip-carrying query lives in
# query/flood.py (also used by the POST /load/mapping/flood_legal route in
# post_flood.py, which supports the mapping explorer's flood filters).
@router.get("/load/mapping/flood_legal")
async def read_flood_data():
data = get_flood_geojson([])
return Response(content=data, media_type="application/json")
def read_flood_data():
return Response(content=_flood_all(), media_type="application/json")


# VT Municipalities Endpoint
@router.get("/data/vermont/municipalities")
def read_municipalities_data():
with open(
DATA_DIR / "vermont" / "municipalities.json", "r", encoding="utf-8"
) as file:
data = json.load(file)
return data
return MUNICIPALITIES_DATA
59 changes: 26 additions & 33 deletions backend/api/routes/post_routes/post_acs5_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,46 +206,39 @@ def _load_full_table(table: str):

# Demographics
@router.post("/load/acs5-db/tidy/demographics")
async def tidy_demographics(request: FilterRequest):
def tidy_demographics(request: FilterRequest):
rows = get_acs5_tidy(dataset="demographics", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("demographics"))


# Education
@router.post("/load/acs5-db/tidy/education")
async def tidy_education(request: FilterRequest):
def tidy_education(request: FilterRequest):
rows = get_acs5_tidy(dataset="education", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("education"))


# Housing
@router.post("/load/acs5-db/tidy/housing")
async def tidy_housing(request: FilterRequest):
def tidy_housing(request: FilterRequest):
rows = get_acs5_tidy(dataset="housing", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("housing"))


# Economics
@router.post("/load/acs5-db/tidy/economics")
async def tidy_economics(request: FilterRequest):
def tidy_economics(request: FilterRequest):
rows = get_acs5_tidy(dataset="economics", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("labor_force"))


# Labor Force (FIXME: broken)
# Labor Force
@router.post("/load/acs5-db/tidy/labor-force")
async def tidy_labor_force(request: FilterRequest):
def tidy_labor_force(request: FilterRequest):
rows = get_acs5_tidy(dataset="labor_force", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("labor_force"))


# Income (FIXME: broken)
@router.post("/load/acs5-db/tidy/income")
async def tidy_income(request: FilterRequest):
rows = get_acs5_tidy(dataset="income", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("income"))


# -----------------------------
# CENSUS TIMESERIES TABLES
# -----------------------------
Expand All @@ -254,7 +247,7 @@ async def tidy_income(request: FilterRequest):
##### DEMOGRAPHICS #####
# Age Dependency Ratio
@router.post("/load/acs5-db/timeseries/demographics/age-dependency-ratio")
async def get_age_dependency_ratio(request: FilterRequest):
def get_age_dependency_ratio(request: FilterRequest):
rows = get_acs5_timeseries(
category="demographics", dataset="age_dependency_ratio", filters=request.filters
)
Expand All @@ -263,7 +256,7 @@ async def get_age_dependency_ratio(request: FilterRequest):

# Median Age
@router.post("/load/acs5-db/timeseries/demographics/median-age")
async def get_median_age(request: FilterRequest):
def get_median_age(request: FilterRequest):
rows = get_acs5_timeseries(
category="demographics", dataset="median_age", filters=request.filters
)
Expand All @@ -272,7 +265,7 @@ async def get_median_age(request: FilterRequest):

# Historic Population
@router.post("/load/acs5-db/timeseries/demographics/historic-population")
async def get_historic_population(request: FilterRequest):
def get_historic_population(request: FilterRequest):
filters = {key: value for key, value in request.filters.items() if key != "year"}

rows = get_acs5_timeseries(
Expand All @@ -286,7 +279,7 @@ async def get_historic_population(request: FilterRequest):

# Percent Population Change (Historic, Decade-over-Decade)
@router.post("/load/acs5-db/timeseries/demographics/historic-population-change")
async def get_historic_population_change(request: FilterRequest):
def get_historic_population_change(request: FilterRequest):
filters = {key: value for key, value in request.filters.items() if key != "year"}

rows = get_acs5_timeseries(
Expand All @@ -300,17 +293,17 @@ async def get_historic_population_change(request: FilterRequest):

# Percent Population Change (ACS-5, Year-over-Year)
@router.post("/load/acs5-db/timeseries/demographics/population-change")
async def get_population_change(request: FilterRequest):
def get_population_change(request: FilterRequest):
rows = get_acs5_timeseries(
category="demographics", dataset="population_change", filters=request.filters
)
return make_response(data=rows, metadata=get_metadata("demographics"))


##### ECONOMICS #####
# Heath Insurance Coverage
# Health Insurance Coverage
@router.post("/load/acs5-db/timeseries/economics/health-insurance")
async def get_health_insurance(request: FilterRequest):
def get_health_insurance(request: FilterRequest):
rows = get_acs5_timeseries(
category="economics", dataset="health_insurance", filters=request.filters
)
Expand All @@ -320,15 +313,15 @@ async def get_health_insurance(request: FilterRequest):
# Poverty and Uninsured Rates (DP03 profile), trend context for the Community
# Health report. Filtered by `Location` (the ACS NAME), like the tidy routes.
@router.post("/load/acs5-db/timeseries/economics/poverty-uninsured")
async def get_poverty_uninsured(request: FilterRequest):
def get_poverty_uninsured(request: FilterRequest):
names = [str(n) for n in request.filters.get("Location", [])]
rows = get_poverty_uninsured_timeseries(names)
return make_response(data=rows, metadata=get_metadata("income"))


# Median Household Income
@router.post("/load/acs5-db/timeseries/economics/median-hh-income")
async def get_household_income(request: FilterRequest):
def get_household_income(request: FilterRequest):
rows = get_acs5_timeseries(
category="economics", dataset="household_income", filters=request.filters
)
Expand All @@ -337,16 +330,16 @@ async def get_household_income(request: FilterRequest):

# Per Capita Income
@router.post("/load/acs5-db/timeseries/economics/per-capita-income")
async def get_per_capita_income(request: FilterRequest):
def get_per_capita_income(request: FilterRequest):
rows = get_acs5_timeseries(
category="economics", dataset="per_capita_income", filters=request.filters
)
return make_response(data=rows, metadata=get_metadata("income"))


# Median Earnings (FIXME: broken)
# Median Earnings
@router.post("/load/acs5-db/timeseries/economics/median-earnings")
async def get_median_earnings(request: FilterRequest):
def get_median_earnings(request: FilterRequest):
rows = get_acs5_timeseries(
category="economics", dataset="median_earnings", filters=request.filters
)
Expand All @@ -356,7 +349,7 @@ async def get_median_earnings(request: FilterRequest):
##### HOUSING #####
# Total Housing Units
@router.post("/load/acs5-db/timeseries/housing/total-units")
async def get_housing_units(request: FilterRequest):
def get_housing_units(request: FilterRequest):
rows = get_acs5_timeseries(
category="housing", dataset="housing_units", filters=request.filters
)
Expand All @@ -365,7 +358,7 @@ async def get_housing_units(request: FilterRequest):

# Median Home Value
@router.post("/load/acs5-db/timeseries/housing/median-home-value")
async def get_median_home_value(request: FilterRequest):
def get_median_home_value(request: FilterRequest):
rows = get_acs5_timeseries(
category="housing", dataset="median_home_value", filters=request.filters
)
Expand All @@ -374,7 +367,7 @@ async def get_median_home_value(request: FilterRequest):

# Vacancy Rates
@router.post("/load/acs5-db/timeseries/housing/vacancy-rates")
async def get_vacancy_rates(request: FilterRequest):
def get_vacancy_rates(request: FilterRequest):
rows = get_acs5_timeseries(
category="housing", dataset="vacancy_rates", filters=request.filters
)
Expand All @@ -383,7 +376,7 @@ async def get_vacancy_rates(request: FilterRequest):

# Housing Cost Burden
@router.post("/load/acs5-db/timeseries/housing/income-burden")
async def get_income_burden(request: FilterRequest):
def get_income_burden(request: FilterRequest):
rows = get_acs5_timeseries(
category="housing", dataset="income_burden", filters=request.filters
)
Expand All @@ -392,7 +385,7 @@ async def get_income_burden(request: FilterRequest):

# Geography Snapshot Variables
@router.post("/load/acs5-db/tidy/snapshot")
async def tidy_snapshot(request: FilterRequest):
def tidy_snapshot(request: FilterRequest):
rows = get_acs5_tidy(dataset="snapshot", filters=request.filters)
return make_response(data=rows, metadata=get_metadata("demographics"))

Expand All @@ -405,7 +398,7 @@ async def tidy_snapshot(request: FilterRequest):


# @router.get("/load/summary")
# async def get_summary(location: str):
# def get_summary(location: str):
# """Return a summary for the given loocation and variable list."""
# variables = [
# "Population (ACS)",
Expand All @@ -420,7 +413,7 @@ async def tidy_snapshot(request: FilterRequest):

# TODO: Refactor this code to match zoning schema
@router.get("/load/acs5-db/dp-combined/tree")
async def dp_combined_tree():
def dp_combined_tree():
"""Return the global set of distinct cascade options across all DP tables."""
rows = DB.execute(
"""--sql
Expand Down Expand Up @@ -454,7 +447,7 @@ def _dp_identity_columns() -> list[str]:


@router.post("/load/acs5-db/dp-combined/series")
async def dp_combined_series(request: DPSeriesRequest):
def dp_combined_series(request: DPSeriesRequest):
"""Return the annual time-series for a single
(location, table, Category, Subcategory, Variable, Measure) selection.

Expand Down
Loading
Loading