From 07a1d13243d543232f9e8c5fb250836f8eec3a95 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 15:16:49 -0400 Subject: [PATCH 1/7] Removing "broken" api route tags + unused routes --- backend/api/routes/post_routes/post_acs5_db.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index 0eeb3251..4e3030df 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -232,20 +232,13 @@ async def tidy_economics(request: FilterRequest): 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): 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 # ----------------------------- @@ -308,7 +301,7 @@ async def get_population_change(request: FilterRequest): ##### ECONOMICS ##### -# Heath Insurance Coverage +# Health Insurance Coverage @router.post("/load/acs5-db/timeseries/economics/health-insurance") async def get_health_insurance(request: FilterRequest): rows = get_acs5_timeseries( @@ -344,7 +337,7 @@ async def get_per_capita_income(request: FilterRequest): 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): rows = get_acs5_timeseries( From ec49dbaecd426b7b90024c2eaeeafa4a2968da67 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 15:17:15 -0400 Subject: [PATCH 2/7] Removing more FIXME tags + unused functions --- backend/api/routes/post_routes/post_cdc.py | 1 - backend/query/acs5.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/backend/api/routes/post_routes/post_cdc.py b/backend/api/routes/post_routes/post_cdc.py index f1ea8904..10146f5e 100644 --- a/backend/api/routes/post_routes/post_cdc.py +++ b/backend/api/routes/post_routes/post_cdc.py @@ -88,7 +88,6 @@ async def cdc_comparison_tract(specs: list[FilterSpec]) -> APIResponse: return make_response(data=geojson, metadata={"legend": legend}) -# (FIXME) @router.post("/load/mapping/cdc/places/pca_summary") async def cdc_pca(specs: list[FilterSpec]) -> APIResponse: return make_response(data=get_cdc_county_pca(), metadata={}) diff --git a/backend/query/acs5.py b/backend/query/acs5.py index d599357f..bd700406 100644 --- a/backend/query/acs5.py +++ b/backend/query/acs5.py @@ -13,7 +13,6 @@ import pandas as pd from api.models import FilterSource, RangeFilter -from query.core_functions import filter_tree from query.production_db import get_db from query.sql_render import sql_filter_block @@ -275,8 +274,3 @@ def get_poverty_uninsured_timeseries( if not names: return pd.DataFrame(columns=["year", "Location", "Variable", "Percent"]) return DB.execute(sql, [*case_params, *names]).df() - - -# FIXME: Link to new database table name (broken for now) -def get_acs5_filters(): - return filter_tree(ACS5_FILTER_COLS, ACS5_TREE_LABELS, "acs5_info") From 3a8f44bf6796c6e9f45a290cb3d49cba6833898c Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 15:20:10 -0400 Subject: [PATCH 3/7] removing old zoning standard api route --- backend/api/routes/post_routes/post_zoning.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/backend/api/routes/post_routes/post_zoning.py b/backend/api/routes/post_routes/post_zoning.py index 59a92148..69d28286 100644 --- a/backend/api/routes/post_routes/post_zoning.py +++ b/backend/api/routes/post_routes/post_zoning.py @@ -64,14 +64,6 @@ async def zoning_unzoned(): return Response(content=data, media_type="application/json") -# Unused by the frontend (it calls standard_new); disabled pending removal. -# @router.post("/load/mapping/zoning/standard") -# async def zoning_geojson_info(request: FilterRequest): -# source = request_to_source(request, "VersoZoning_info", "default") -# data = get_zoning_geojson([source]) -# return Response(content=data, media_type="application/json") - - @router.post("/load/data/zoning/aggregated") async def acreage_response(request: FilterRequest): source = request_to_source(request, "VersoZoning_info", "default") From 2e5c61a5c9d5e361c635f9b53ff2355725d144ed Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 16:15:09 -0400 Subject: [PATCH 4/7] Removing 'async' tag to read-only API routes (faster) --- backend/api/routes/get_routes/get_filters.py | 8 ++-- backend/api/routes/get_routes/get_legend.py | 4 +- .../api/routes/get_routes/get_wholedata.py | 2 +- .../api/routes/post_routes/post_acs5_db.py | 46 +++++++++---------- .../api/routes/post_routes/post_ambulance.py | 6 +-- backend/api/routes/post_routes/post_cdc.py | 12 ++--- backend/api/routes/post_routes/post_census.py | 2 +- .../api/routes/post_routes/post_comparison.py | 6 +-- backend/api/routes/post_routes/post_export.py | 6 +-- backend/api/routes/post_routes/post_flood.py | 2 +- .../api/routes/post_routes/post_parcels.py | 2 +- backend/api/routes/post_routes/post_qcew.py | 2 +- .../api/routes/post_routes/post_wastewater.py | 10 ++-- backend/api/routes/post_routes/post_zoning.py | 10 ++-- docs/archive/zoning-four-table-migration.md | 6 +-- 15 files changed, 62 insertions(+), 62 deletions(-) diff --git a/backend/api/routes/get_routes/get_filters.py b/backend/api/routes/get_routes/get_filters.py index 7c5c0253..df57af3d 100644 --- a/backend/api/routes/get_routes/get_filters.py +++ b/backend/api/routes/get_routes/get_filters.py @@ -19,7 +19,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) @@ -28,7 +28,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, @@ -66,7 +66,7 @@ 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, @@ -85,7 +85,7 @@ 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, diff --git a/backend/api/routes/get_routes/get_legend.py b/backend/api/routes/get_routes/get_legend.py index b566fa17..96d36dc1 100644 --- a/backend/api/routes/get_routes/get_legend.py +++ b/backend/api/routes/get_routes/get_legend.py @@ -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") diff --git a/backend/api/routes/get_routes/get_wholedata.py b/backend/api/routes/get_routes/get_wholedata.py index d6621a74..a2b0726e 100644 --- a/backend/api/routes/get_routes/get_wholedata.py +++ b/backend/api/routes/get_routes/get_wholedata.py @@ -25,7 +25,7 @@ def read_root(): # 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(): +def read_flood_data(): data = get_flood_geojson([]) return Response(content=data, media_type="application/json") diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index 4e3030df..056823eb 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -206,35 +206,35 @@ 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 @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")) @@ -247,7 +247,7 @@ async def tidy_labor_force(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 ) @@ -256,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 ) @@ -265,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( @@ -279,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( @@ -293,7 +293,7 @@ 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 ) @@ -303,7 +303,7 @@ async def get_population_change(request: FilterRequest): ##### ECONOMICS ##### # 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 ) @@ -313,7 +313,7 @@ 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")) @@ -321,7 +321,7 @@ async def get_poverty_uninsured(request: FilterRequest): # 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 ) @@ -330,7 +330,7 @@ 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 ) @@ -339,7 +339,7 @@ async def get_per_capita_income(request: FilterRequest): # 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 ) @@ -349,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 ) @@ -358,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 ) @@ -367,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 ) @@ -376,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 ) @@ -385,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")) @@ -398,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)", @@ -413,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 @@ -447,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. diff --git a/backend/api/routes/post_routes/post_ambulance.py b/backend/api/routes/post_routes/post_ambulance.py index d2467d05..b8a600ba 100644 --- a/backend/api/routes/post_routes/post_ambulance.py +++ b/backend/api/routes/post_routes/post_ambulance.py @@ -34,20 +34,20 @@ @router.post("/load/mapping/ambulance/service_area") -async def ambulance_info_geojson(request: FilterRequest): +def ambulance_info_geojson(request: FilterRequest): source = request_to_source(request, "VCGI_ambulanceService_info", "default") data = get_ambulance_geojson([source]) return Response(content=data, media_type="application/json") @router.post("/load/mapping/ambulance/service_area_new") -async def ambulance_info_geojson_new(specs: list[FilterSpec]): +def ambulance_info_geojson_new(specs: list[FilterSpec]): sources = [spec_to_source(spec, "default") for spec in specs] data = get_ambulance_geojson(sources) 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") diff --git a/backend/api/routes/post_routes/post_cdc.py b/backend/api/routes/post_routes/post_cdc.py index 10146f5e..afbc046b 100644 --- a/backend/api/routes/post_routes/post_cdc.py +++ b/backend/api/routes/post_routes/post_cdc.py @@ -43,21 +43,21 @@ @router.post("/load/mapping/cdc/places/single") -async def cdc_single_geojson(request: FilterRequest): +def cdc_single_geojson(request: FilterRequest): source = request_to_source(request, "cdc_places_county", "default") data = single_var_geojson([source]) return data @router.post("/load/data/cdc/places") -async def cdc_places_tidy(request: FilterRequest): +def cdc_places_tidy(request: FilterRequest): source = request_to_source(request, "cdc_places_county", "default") rows = get_cdc_places_tidy([source]) return make_response(data=rows, metadata={}) @router.post("/load/data/cdc/places/by-county") -async def cdc_places_by_county(request: FilterRequest): +def cdc_places_by_county(request: FilterRequest): """Every county's value for the filtered measures, for rankings.""" source = request_to_source(request, "cdc_places_county", "default") rows = get_cdc_places_by_county([source]) @@ -65,7 +65,7 @@ async def cdc_places_by_county(request: FilterRequest): @router.post("/load/mapping/cdc/places/county_comparison") -async def cdc_comparison(specs: list[FilterSpec]) -> APIResponse: +def cdc_comparison(specs: list[FilterSpec]) -> APIResponse: """Bivariate comparison map: geojson in `data`, legend in `metadata`. One response so the legend grid is guaranteed to match the map colors @@ -77,7 +77,7 @@ async def cdc_comparison(specs: list[FilterSpec]) -> APIResponse: @router.post("/load/mapping/cdc/places/tract_comparison") -async def cdc_comparison_tract(specs: list[FilterSpec]) -> APIResponse: +def cdc_comparison_tract(specs: list[FilterSpec]) -> APIResponse: """Bivariate comparison map: geojson in `data`, legend in `metadata`. One response so the legend grid is guaranteed to match the map colors @@ -89,5 +89,5 @@ async def cdc_comparison_tract(specs: list[FilterSpec]) -> APIResponse: @router.post("/load/mapping/cdc/places/pca_summary") -async def cdc_pca(specs: list[FilterSpec]) -> APIResponse: +def cdc_pca(specs: list[FilterSpec]) -> APIResponse: return make_response(data=get_cdc_county_pca(), metadata={}) diff --git a/backend/api/routes/post_routes/post_census.py b/backend/api/routes/post_routes/post_census.py index 6fd740b6..ff9e4d21 100644 --- a/backend/api/routes/post_routes/post_census.py +++ b/backend/api/routes/post_routes/post_census.py @@ -39,7 +39,7 @@ # Load the Census Dataset by `category`(housing, economic, etc.) # and `subcategory`(special time series tables) @router.post("/load/census/{category}/{subcategory}") -async def read_census_data_subcat( +def read_census_data_subcat( category: str, request: FilterRequest, subcategory: str = "main" ): if category not in CENSUS_DATASETS: diff --git a/backend/api/routes/post_routes/post_comparison.py b/backend/api/routes/post_routes/post_comparison.py index 974a5c94..52858109 100644 --- a/backend/api/routes/post_routes/post_comparison.py +++ b/backend/api/routes/post_routes/post_comparison.py @@ -17,7 +17,7 @@ @router.get("/load/mapping/compare/datasets") -async def compare_datasets() -> dict: +def compare_datasets() -> dict: """Registry of every dataset available to the generalized variable explorer: display label, the filter_table its Section/Variable picker should query, and which geography levels it supports.""" @@ -25,7 +25,7 @@ async def compare_datasets() -> dict: @router.post("/load/mapping/compare/{level}") -async def compare(level: str, specs: list[FilterSpec]) -> APIResponse: +def compare(level: str, specs: list[FilterSpec]) -> APIResponse: """Bivariate comparison map: geojson in `data`, legend in `metadata`. `specs` carries two Cascade filter picks (Variable 1, Variable 2), each @@ -101,7 +101,7 @@ async def compare(level: str, specs: list[FilterSpec]) -> APIResponse: @router.post("/load/mapping/compare/{dataset}/{level}/composite_index") -async def compare_composite_index(dataset: str, level: str) -> APIResponse: +def compare_composite_index(dataset: str, level: str) -> APIResponse: """A single-component PCA summary across every variable in the dataset, standardized relative to this level's own Vermont-wide average (not a national baseline). Independent of which two variables are selected.""" diff --git a/backend/api/routes/post_routes/post_export.py b/backend/api/routes/post_routes/post_export.py index b4d6d137..02ad63e4 100644 --- a/backend/api/routes/post_routes/post_export.py +++ b/backend/api/routes/post_routes/post_export.py @@ -108,7 +108,7 @@ class ExportRequest(BaseModel): @router.get("/sources") -async def list_export_sources(): +def list_export_sources(): """Return available export sources with metadata (no internal loader).""" return { key: {k: v for k, v in meta.items() if k in _SOURCE_META_KEYS} @@ -117,7 +117,7 @@ async def list_export_sources(): @router.get("/locations") -async def list_locations(): +def list_locations(): """ Return sorted lists of Vermont counties and towns, derived from the ACS5 housing export source (representative of every town-level dataset). @@ -137,7 +137,7 @@ async def list_locations(): @router.post("/csv") -async def export_csv(body: ExportRequest, request: Request): +def export_csv(body: ExportRequest, request: Request): """ Return a filtered dataset as a CSV file. diff --git a/backend/api/routes/post_routes/post_flood.py b/backend/api/routes/post_routes/post_flood.py index a9a0d1ac..bc2077f9 100644 --- a/backend/api/routes/post_routes/post_flood.py +++ b/backend/api/routes/post_routes/post_flood.py @@ -8,7 +8,7 @@ @router.post("/load/mapping/flood_legal") -async def flood_geojson(request: FilterRequest): +def flood_geojson(request: FilterRequest): source = request_to_source(request, "FEMA_floodHazard_geom", "default") data = get_flood_geojson([source]) return Response(content=data, media_type="application/json") diff --git a/backend/api/routes/post_routes/post_parcels.py b/backend/api/routes/post_routes/post_parcels.py index fb75327d..b4890353 100644 --- a/backend/api/routes/post_routes/post_parcels.py +++ b/backend/api/routes/post_routes/post_parcels.py @@ -8,7 +8,7 @@ @router.post("/load/mapping/parcels/standard") -async def parcels_geojson(specs: list[FilterSpec]): +def parcels_geojson(specs: list[FilterSpec]): sources = [spec_to_source(spec, "default") for spec in specs] data = get_parcels_geojson(sources) return Response(content=data, media_type="application/json") diff --git a/backend/api/routes/post_routes/post_qcew.py b/backend/api/routes/post_routes/post_qcew.py index 59c67daf..1919b83b 100644 --- a/backend/api/routes/post_routes/post_qcew.py +++ b/backend/api/routes/post_routes/post_qcew.py @@ -23,7 +23,7 @@ @router.post("/load/qcew/employment") -async def employment_by_sector(request: FilterRequest): +def employment_by_sector(request: FilterRequest): filters = request.filters or {} def _first(label: str): diff --git a/backend/api/routes/post_routes/post_wastewater.py b/backend/api/routes/post_routes/post_wastewater.py index c799779c..0f454fdd 100644 --- a/backend/api/routes/post_routes/post_wastewater.py +++ b/backend/api/routes/post_routes/post_wastewater.py @@ -82,14 +82,14 @@ @router.post("/load/mapping/wastewater/service_area") -async def wastewater_service_geojson(request: FilterRequest): +def wastewater_service_geojson(request: FilterRequest): source = request_to_source(request, "VersoWastewater_serviceAreas_info", "default") data = get_waste_service_areas_geojson([source]) return Response(content=data, media_type="application/json") @router.post("/load/mapping/wastewater/treatment_facility") -async def wastewater_facility_geojson(request: FilterRequest): +def wastewater_facility_geojson(request: FilterRequest): source = request_to_source( request, "VersoWastewater_treatmentFacilities_info", "default" ) @@ -98,7 +98,7 @@ async def wastewater_facility_geojson(request: FilterRequest): @router.post("/load/mapping/wastewater/treatment_facility/permits") -async def wastewater_facility_permits(request: FilterRequest): +def wastewater_facility_permits(request: FilterRequest): # Filter against the facilities table (has Town/County/Jurisdiction/RPC # columns and the Facility_ID join key); the permits table itself has # neither and can't be filtered directly. @@ -110,7 +110,7 @@ async def wastewater_facility_permits(request: FilterRequest): @router.post("/load/mapping/wastewater/septic_soil_suitability") -async def wastewater_soil_suit_geojson(request: FilterRequest): +def wastewater_soil_suit_geojson(request: FilterRequest): source = request_to_source( request, "VersoWastewater_soilSuitability_info", "default" ) @@ -119,6 +119,6 @@ async def wastewater_soil_suit_geojson(request: FilterRequest): @router.post("/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") diff --git a/backend/api/routes/post_routes/post_zoning.py b/backend/api/routes/post_routes/post_zoning.py index 69d28286..3b67f3b9 100644 --- a/backend/api/routes/post_routes/post_zoning.py +++ b/backend/api/routes/post_routes/post_zoning.py @@ -51,28 +51,28 @@ @router.post("/load/mapping/zoning/standard_new") -async def zoning_geo_new(specs: list[FilterSpec]): +def zoning_geo_new(specs: list[FilterSpec]): sources = [spec_to_source(spec, "default") for spec in specs] data = get_zoning_geojson(sources) return Response(content=data, media_type="application/json") @router.get("/load/mapping/zoning/unzoned") -async def zoning_unzoned(): +def zoning_unzoned(): """Grey "no zoning information" backdrop. GET: it takes no filters.""" data = get_unzoned_geojson() return Response(content=data, media_type="application/json") @router.post("/load/data/zoning/aggregated") -async def acreage_response(request: FilterRequest): +def acreage_response(request: FilterRequest): source = request_to_source(request, "VersoZoning_info", "default") agg, table = get_zoning_aggregated_acres([source]) return make_response(data=agg, metadata=get_metadata("zoning"), tableData=table) @router.post("/load/data/zoning/allowances") -async def zoning_allowances(request: FilterRequest): +def zoning_allowances(request: FilterRequest): source = request_to_source(request, "VersoZoning_info", "default") agg, table = get_zoning_allowances([source]) return make_response( @@ -83,7 +83,7 @@ async def zoning_allowances(request: FilterRequest): @router.post("/load/data/zoning/building-footprints") -async def building_footprints(request: FilterRequest): +def building_footprints(request: FilterRequest): source = request_to_source(request, "VCGI_buildingFootprints_geom", "default") agg, table = get_building_footprints([source]) return make_response( diff --git a/docs/archive/zoning-four-table-migration.md b/docs/archive/zoning-four-table-migration.md index 73bfebf6..9dd7d013 100644 --- a/docs/archive/zoning-four-table-migration.md +++ b/docs/archive/zoning-four-table-migration.md @@ -285,7 +285,7 @@ router = APIRouter() @router.post("/load/mapping/zoning") -async def read_zoning_data(request: FilterRequest) -> APIResponse: +def read_zoning_data(request: FilterRequest) -> APIResponse: metadata = get_metadata("zoning") if request.format == "aggregated_acres": @@ -309,7 +309,7 @@ router = APIRouter() @router.get("/load/mapping/zoning/filters") -async def read_zoning_filters(): +def read_zoning_filters(): return zoning_db.filter_tree() ``` @@ -320,7 +320,7 @@ Leave the flood / WWTF / service-area GETs on `masterload` for now. Replace just ```python # Zoning GET Endpoint @router.get("/load/mapping/zoning") -async def read_zoning_data(): +def read_zoning_data(): from app_utils import zoning_db return zoning_db.geojson() From a167b2ac92e0956fe9240641c73015d4402f0f32 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 16:30:55 -0400 Subject: [PATCH 5/7] Only load municipalities dataset during startup --- backend/api/routes/get_routes/get_wholedata.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/api/routes/get_routes/get_wholedata.py b/backend/api/routes/get_routes/get_wholedata.py index a2b0726e..5f6632b7 100644 --- a/backend/api/routes/get_routes/get_wholedata.py +++ b/backend/api/routes/get_routes/get_wholedata.py @@ -14,6 +14,10 @@ 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(): @@ -33,8 +37,4 @@ def read_flood_data(): # 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 From 6fb7c7d2b00502c048113283165b104f131122ad Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 16:41:07 -0400 Subject: [PATCH 6/7] Added cacheing to reduce api redundancy --- backend/api/main.py | 13 ++++++++++++- backend/api/routes/get_routes/get_filters.py | 16 ++++++++++++++++ backend/api/routes/get_routes/get_wholedata.py | 9 +++++++-- backend/api/routes/post_routes/post_export.py | 2 ++ backend/query/ambulance.py | 2 ++ backend/query/wastewater.py | 2 ++ backend/query/zoning.py | 2 ++ 7 files changed, 43 insertions(+), 3 deletions(-) diff --git a/backend/api/main.py b/backend/api/main.py index ac1da2d5..87235faa 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -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 @@ -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"): diff --git a/backend/api/routes/get_routes/get_filters.py b/backend/api/routes/get_routes/get_filters.py index df57af3d..0ecba053 100644 --- a/backend/api/routes/get_routes/get_filters.py +++ b/backend/api/routes/get_routes/get_filters.py @@ -1,4 +1,5 @@ import logging +from functools import lru_cache from typing import Annotated from fastapi import APIRouter, Query @@ -50,6 +51,11 @@ 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: @@ -71,6 +77,11 @@ def filter_options_endpoint( 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"] @@ -92,6 +103,11 @@ def filter_ranges_endpoint( ): """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", {}) diff --git a/backend/api/routes/get_routes/get_wholedata.py b/backend/api/routes/get_routes/get_wholedata.py index 5f6632b7..0dc11e77 100644 --- a/backend/api/routes/get_routes/get_wholedata.py +++ b/backend/api/routes/get_routes/get_wholedata.py @@ -1,6 +1,7 @@ import json import logging import os +from functools import lru_cache from pathlib import Path from fastapi import APIRouter, Response @@ -24,14 +25,18 @@ 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") def read_flood_data(): - data = get_flood_geojson([]) - return Response(content=data, media_type="application/json") + return Response(content=_flood_all(), media_type="application/json") # VT Municipalities Endpoint diff --git a/backend/api/routes/post_routes/post_export.py b/backend/api/routes/post_routes/post_export.py index 02ad63e4..f50818bb 100644 --- a/backend/api/routes/post_routes/post_export.py +++ b/backend/api/routes/post_routes/post_export.py @@ -16,6 +16,7 @@ import threading import time from collections import defaultdict +from functools import lru_cache from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse @@ -117,6 +118,7 @@ def list_export_sources(): @router.get("/locations") +@lru_cache(maxsize=1) def list_locations(): """ Return sorted lists of Vermont counties and towns, derived from the ACS5 diff --git a/backend/query/ambulance.py b/backend/query/ambulance.py index 16a43afb..7d4b27dd 100644 --- a/backend/query/ambulance.py +++ b/backend/query/ambulance.py @@ -8,6 +8,7 @@ """ import logging +from functools import lru_cache from pathlib import Path import pandas as pd @@ -31,6 +32,7 @@ def get_ambulance_geojson(sources: list[FilterSource]): return result[0] +@lru_cache(maxsize=1) def get_ambulance_legend(): result = DB.execute( "SELECT json_group_array(to_json(VCGI_ambulanceService_colors)) FROM VCGI_ambulanceService_colors;" diff --git a/backend/query/wastewater.py b/backend/query/wastewater.py index 32ee8a2e..a9bea595 100644 --- a/backend/query/wastewater.py +++ b/backend/query/wastewater.py @@ -8,6 +8,7 @@ """ import logging +from functools import lru_cache from pathlib import Path import pandas as pd @@ -64,6 +65,7 @@ def get_soil_suit_geojson(sources: list[FilterSource]): return result[0] +@lru_cache(maxsize=1) def get_soil_suit_legend(): result = DB.execute( "SELECT json_group_array(to_json(VersoWastewater_soilSuitability_colors)) FROM VersoWastewater_soilSuitability_colors;" diff --git a/backend/query/zoning.py b/backend/query/zoning.py index 61b845a0..46928031 100644 --- a/backend/query/zoning.py +++ b/backend/query/zoning.py @@ -10,6 +10,7 @@ """ import logging +from functools import lru_cache from pathlib import Path import pandas as pd @@ -34,6 +35,7 @@ def get_zoning_geojson(sources: list[FilterSource]): return result[0] +@lru_cache(maxsize=1) def get_unzoned_geojson() -> str: """GeoJSON (as a string) for the areas we have no zoning information about. From 8b9119f179e07e7b2edafd9ff85f639da7ecf45a Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 5 Oct 2026 16:52:10 -0400 Subject: [PATCH 7/7] Allow database to handle concurrent API calls --- backend/query/production_db.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/backend/query/production_db.py b/backend/query/production_db.py index 9f6d9470..56be417b 100644 --- a/backend/query/production_db.py +++ b/backend/query/production_db.py @@ -27,11 +27,29 @@ def _load_spatial(con: duckdb.DuckDBPyConnection) -> None: """Load the spatial extension.""" try: con.execute("LOAD spatial") - except duckdb.Error as exc: - raise RuntimeError("The DuckDB spatial extension could not be loaded.") from exc + except duckdb.Error as e: + raise RuntimeError("The DuckDB spatial extension could not be loaded.") from e -def get_db() -> duckdb.DuckDBPyConnection: +class _ThreadSafeDB: + """Wraps one connection; each execute() gets its own cursor. + + A single DuckDB connection is not safe across FastAPI's threadpool: + concurrent execute()/.df() calls steal each other's results (.df() + returns None). Cursors share the database but not result state. + """ + + def __init__(self, con: duckdb.DuckDBPyConnection): + self._con = con + + def execute(self, *args, **kwargs): + return self._con.cursor().execute(*args, **kwargs) + + def __getattr__(self, name): + return getattr(self._con, name) + + +def get_db() -> _ThreadSafeDB: """ Open a read-only connection to the production warehouse. @@ -47,10 +65,10 @@ def get_db() -> duckdb.DuckDBPyConnection: try: con = duckdb.connect(WAREHOUSE_PATH, read_only=True) _load_spatial(con) - return con + return _ThreadSafeDB(con) - except Exception as exc: + except Exception as e: raise RuntimeError( f"Could not open the production database at {WAREHOUSE_PATH}.\n" "Try running `just run-etl` to rebuild the production database." - ) from exc + ) from e