Skip to content

Commit

Permalink
Merge branch 'main' into population-bar-bugfixes
Browse files Browse the repository at this point in the history
  • Loading branch information
nofurtherinformation committed Feb 3, 2025
2 parents fa3a6df + 6f188d7 commit d77deac
Show file tree
Hide file tree
Showing 22 changed files with 592 additions and 79 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/fly-deploy-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,14 @@ jobs:
run: |
flyctl secrets set \
-a ${{ github.event.repository.name }}-${{ github.event.number }}-api \
ENVIRONMENT="qa" \
POSTGRES_SCHEME="postgresql+psycopg" \
POSTGRES_SERVER="${{ github.event.repository.name }}-${{ github.event.number }}-db.flycast" \
POSTGRES_USER="postgres" \
POSTGRES_PASSWORD=${{ secrets.FLY_PR_PG_PASSWORD }} \
POSTGRES_DB="districtr_v2_api" \
BACKEND_CORS_ORIGINS="https://${{ github.event.repository.name }}-${{ github.event.number }}-app.fly.dev,https://districtr-v2-frontend.fly.dev" \
DATABASE_URL="postgresql://postgres:${{ secrets.FLY_PR_PG_PASSWORD }}@${{ steps.fork-db.outputs.name }}.flycast:5432/districtr_v2_api?sslmode=disable&options=-csearch_path%3Dpublic"
DATABASE_URL="postgresql+psycopg://postgres:${{ secrets.FLY_PR_PG_PASSWORD }}@${{ steps.fork-db.outputs.name }}.flycast:5433/districtr_v2_api?sslmode=disable&options=-csearch_path%3Dpublic"
flyctl deploy \
--config fly.toml --app "${{ github.event.repository.name }}-${{ github.event.number }}-api" \
Expand Down Expand Up @@ -154,6 +155,7 @@ jobs:
flyctl secrets set \
-a "${{ github.event.repository.name }}-${{ github.event.number }}-app" \
ENVIRONMENT="qa" \
NEXT_PUBLIC_API_URL="https://${{ github.event.repository.name }}-${{ github.event.number }}-api.fly.dev" \
NEXT_PUBLIC_S3_BUCKET_URL=https://districtr-v2-dev.s3.amazonaws.com
Expand Down
48 changes: 47 additions & 1 deletion app/src/app/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,53 @@ export const Topbar: React.FC = () => {
)}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item disabled>Export Assignments</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger disabled={!mapDocument?.document_id}>
Export Assignments
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>
<Tooltip content="Download a CSV of Census GEOIDs and zone IDs">
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/document/${mapDocument?.document_id}/export?format=CSV&export_type=ZoneAssignments`}
download={`districtr-block-assignments-${mapDocument?.document_id}-${new Date().toDateString()}.csv`}
>
VTD Assignments (CSV)
</a>
</Tooltip>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Tooltip content="Download a GeoJSON of Census GEOIDs and zone IDs">
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/document/${mapDocument?.document_id}/export?format=GeoJSON&export_type=ZoneAssignments`}
download={`districtr-block-assignments-${mapDocument?.document_id}-${new Date().toDateString()}.csv`}
>
VTD Assignments (GeoJSON)
</a>
</Tooltip>
</DropdownMenu.Item>
<DropdownMenu.Item disabled={!mapDocument?.child_layer}>
<Tooltip content="Download a CSV of Census Block GEOIDs and zone IDs">
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/document/${mapDocument?.document_id}/export?format=CSV&export_type=BlockZoneAssignments`}
download={`districtr-block-assignments-${mapDocument?.document_id}-${new Date().toDateString()}.csv`}
>
Block Assignment (CSV)
</a>
</Tooltip>
</DropdownMenu.Item>
<DropdownMenu.Item>
<Tooltip content="Download a GeoJSON of district boundaries">
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/api/document/${mapDocument?.document_id}/export?format=GeoJSON&export_type=Districts`}
download={`districtr-block-assignments-${mapDocument?.document_id}-${new Date().toDateString()}.csv`}
>
District boundaries (GeoJSON)
</a>
</Tooltip>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item onClick={() => setRecentMapsModalOpen(true)}>
View Recent Maps
</DropdownMenu.Item>
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/utils/api/apiHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export const getSummaryStats: (
return await axios
.get<
SummaryStatsResult<P1ZoneSummaryStats[] | P4ZoneSummaryStats[]>
>(`${process.env.NEXT_PUBLIC_API_URL}/api/document/${mapDocument.document_id}/${summaryType}`)
>(`${process.env.NEXT_PUBLIC_API_URL}/api/document/${mapDocument.document_id}/evaluation/${summaryType}`)
.then(res => {
const results = res.data.results.map(row => {
const total = getEntryTotal(row);
Expand Down Expand Up @@ -374,7 +374,7 @@ export const getTotPopSummaryStats: (
return await axios
.get<
SummaryStatsResult<P1TotPopSummaryStats | P4TotPopSummaryStats>
>(`${process.env.NEXT_PUBLIC_API_URL}/api/districtrmap/summary_stats/${summaryType}/${mapDocument.parent_layer}`)
>(`${process.env.NEXT_PUBLIC_API_URL}/api/districtrmap/${mapDocument.parent_layer}/evaluation/${summaryType}`)
.then(res => res.data);
} else {
throw new Error('No document provided');
Expand Down
2 changes: 1 addition & 1 deletion backend/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ BACKEND_CORS_ORIGINS="https://districtr-v2-frontend.fly.dev,https://districtr-v2
SECRET_KEY={fill-me}

# Postgres
DATABASE_URL=postgresql://{fill-me}:{fill-me}@{fill-me}.flycast:5432/{fill-me}?sslmode=disable
DATABASE_URL=postgresql+psycopg://{fill-me}:{fill-me}@{fill-me}.flycast:5432/{fill-me}?sslmode=disable
POSTGRES_SCHEME=postgresql+psycopg
POSTGRES_USER={fill-me}
POSTGRES_PASSWORD={fill-me}
Expand Down
8 changes: 4 additions & 4 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
ARG PYTHON_VERSION=3.12.2-slim-bullseye
ARG PYTHON_VERSION=3.12.6-slim-bullseye
FROM python:${PYTHON_VERSION}

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED True
ENV APP_HOME /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=True
ENV APP_HOME=/app
WORKDIR $APP_HOME

RUN apt-get update && \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""export zone assignments
Revision ID: 552ac8c1defd
Revises: 0f8bbbcdd7be
Create Date: 2025-01-19 19:51:42.117991
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from app.main import settings


# revision identifiers, used by Alembic.
revision: str = "552ac8c1defd"
down_revision: Union[str, None] = "c41fbcfff93e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

sql_files = [
"export_zone_assignments_geo.sql",
"get_block_assignments.sql",
"get_block_assignments_geo.sql",
]


def upgrade() -> None:
for file in sql_files:
with open(settings.SQL_DIR / file) as f:
stmt = f.read()
op.execute(sa.text(stmt))


def downgrade() -> None:
op.execute(sa.text("DROP FUNCTION IF EXISTS get_zone_assignments_geo(UUID)"))
op.execute(sa.text("DROP FUNCTION IF EXISTS get_block_assignments(UUID)"))
op.execute(sa.text("DROP FUNCTION IF EXISTS get_block_assignments_geo(UUID)"))
14 changes: 12 additions & 2 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import warnings
import boto3
from functools import lru_cache
from typing import Annotated, Any, Literal
from typing import Annotated, Any

from pydantic import (
AnyUrl,
Expand All @@ -14,6 +14,8 @@
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Self
from pathlib import Path
from enum import Enum


def parse_cors(v: Any) -> list[str] | str:
Expand All @@ -24,6 +26,13 @@ def parse_cors(v: Any) -> list[str] | str:
raise ValueError(v)


class Environment(str, Enum):
production = "production"
qa = "qa"
local = "local"
test = "test"


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_ignore_empty=True, extra="ignore"
Expand All @@ -33,7 +42,7 @@ class Settings(BaseSettings):
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
DOMAIN: str = "localhost"
ENVIRONMENT: Literal["local", "staging", "production", "test"] = "local"
ENVIRONMENT: Environment = Environment.local

@computed_field # type: ignore[misc]
@property
Expand Down Expand Up @@ -97,6 +106,7 @@ def _enforce_non_default_secrets(self) -> Self:
# Volumes

VOLUME_PATH: str = "/data"
SQL_DIR: Path = Path(__file__).parent.parent / "sql"

# R2

Expand Down
8 changes: 8 additions & 0 deletions backend/app/exports/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from app.exports.main import get_export_sql_method
from app.exports.models import DocumentExportFormat, DocumentExportType

__all__ = [
"get_export_sql_method",
"DocumentExportFormat",
"DocumentExportType",
]
106 changes: 106 additions & 0 deletions backend/app/exports/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from psycopg.sql import SQL, Composed, Identifier, Literal
from typing import Callable, Any
from app.exports.models import (
DocumentExportFormat,
DocumentExportType,
)


def get_export_sql_method(
format: DocumentExportFormat,
) -> Callable[..., tuple[Composed, list[Any]]]:
if format == DocumentExportFormat.geojson:
return get_geojson_export_sql
elif format == DocumentExportFormat.csv:
return get_csv_export_sql

raise NotImplementedError(f"'{format}' export format is not yet supported")


def get_csv_export_sql(
export_type: DocumentExportType, **kwargs
) -> tuple[Composed, list[Any]]:
stmt = None
params = []

if export_type == DocumentExportType.zone_assignments:
stmt = """SELECT
geo_id,
zone::TEXT AS zone
FROM document.assignments
WHERE document_id = %s
ORDER BY geo_id"""
params += [kwargs["document_id"]]

elif export_type == DocumentExportType.block_zone_assignments:
stmt = """SELECT * FROM get_block_assignments(%s)"""
params += [kwargs["document_id"]]

if stmt is None:
raise NotImplementedError("Document export type is not yet supported")

sql = SQL("COPY ( {} ) TO STDOUT WITH (FORMAT CSV, HEADER, DELIMITER ',')").format(
SQL(stmt) # pyright: ignore
)

return sql, params


def get_geojson_export_sql(
export_type: DocumentExportType, **kwargs
) -> tuple[Composed, list[Any]]:
stmt, geom_type, _id = None, None, None
params = []

if export_type == DocumentExportType.zone_assignments:
stmt = "SELECT * FROM get_zone_assignments_geo(%s::UUID)"
params += [kwargs["document_id"]]
geom_type = "Polygon"
_id = "geo_id"

elif export_type == DocumentExportType.block_zone_assignments:
# Sadly, most GeoJSON block exports are too large to go over HTTP
# as a JSON FileResponse. Need to think through a better method.
raise NotImplementedError(
"Block export type is not yet supported for GeoJSON as files are too large"
)

# stmt = """SELECT * FROM get_block_assignments_geo(%s)"""
# params += [kwargs["document_id"]]
# geom_type = "Polygon"
# _id = "geo_id"

elif export_type == DocumentExportType.districts:
stmt = """WITH geos AS ( SELECT * FROM get_zone_assignments_geo(%s::UUID) )
SELECT
zone::TEXT AS zone,
ST_Union(geometry) AS geometry
FROM geos
GROUP BY zone
"""
params += [kwargs["document_id"]]
geom_type = "Polygon"
_id = "zone"

if not all({stmt, geom_type, _id}):
raise NotImplementedError("Survey export type is not yet supported")

sql = SQL("""COPY (
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', jsonb_agg(features.feature) )
FROM (
SELECT jsonb_build_object(
'type', 'Feature',
'id', {id},
'geometry', ST_AsGeoJSON(geometry)::jsonb,
'properties', to_jsonb(inputs) - 'geometry' - {id_name}
) AS feature
FROM ( {select} ) inputs ) features )
TO STDOUT""").format(
id=Identifier("inputs", _id), # pyright: ignore
id_name=Literal(_id),
select=SQL(stmt), # pyright: ignore
)

return sql, params
12 changes: 12 additions & 0 deletions backend/app/exports/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from enum import Enum


class DocumentExportFormat(Enum):
csv = "CSV"
geojson = "GeoJSON"


class DocumentExportType(Enum):
zone_assignments = "ZoneAssignments"
block_zone_assignments = "BlockZoneAssignments"
districts = "Districts"
Loading

0 comments on commit d77deac

Please sign in to comment.