From a8953e17b1a41434ba8a3661288a30d6738f53df Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Tue, 11 Jun 2024 18:30:51 +0100 Subject: [PATCH 1/2] Start adding /info endpoint --- arpav_ppcv/webapp/api_v2/routers/base.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/arpav_ppcv/webapp/api_v2/routers/base.py b/arpav_ppcv/webapp/api_v2/routers/base.py index b7fc19fe..79a3e7a2 100644 --- a/arpav_ppcv/webapp/api_v2/routers/base.py +++ b/arpav_ppcv/webapp/api_v2/routers/base.py @@ -1,9 +1,16 @@ import importlib.metadata import logging import os +from typing import Annotated -from fastapi import APIRouter +import sqlmodel +from fastapi import ( + APIRouter, + Depends, +) +from .... import database +from ... import dependencies from ..schemas.base import AppInformation @@ -18,3 +25,16 @@ async def get_app_info(): "version": importlib.metadata.version("arpav_ppcv_backend"), "git_commit": os.getenv("GIT_COMMIT", "unknown"), } + + +@router.get("/info/{longitude}/{latitude") +def get_coordinates_info( + db_session: Annotated[sqlmodel.Session, Depends(dependencies.get_db_session)], + longitude: float, + latitude: float, +): + """Return information about a point location.""" + municipality = database.get_municipality_by_coordinates( + db_session, longitude, latitude + ) + logger.debug(f"{municipality=}") From b16ff2a82bd22e2fa97b8eff0b21dc5550757941 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Wed, 12 Jun 2024 13:14:10 +0100 Subject: [PATCH 2/2] Added municipalities list endpoint with a point filter --- arpav_ppcv/database.py | 53 ++++++++++++++- arpav_ppcv/webapp/api_v2/app.py | 8 +++ arpav_ppcv/webapp/api_v2/routers/base.py | 22 +----- .../webapp/api_v2/routers/municipalities.py | 68 +++++++++++++++++++ .../webapp/api_v2/routers/observations.py | 5 +- .../api_v2/schemas/geojson/municipalities.py | 41 +++++++++++ arpav_ppcv/webapp/responses.py | 5 ++ 7 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 arpav_ppcv/webapp/api_v2/routers/municipalities.py create mode 100644 arpav_ppcv/webapp/api_v2/schemas/geojson/municipalities.py create mode 100644 arpav_ppcv/webapp/responses.py diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index b67065f7..6997e75a 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -9,7 +9,7 @@ Sequence, ) -import shapely.geometry +import shapely import shapely.io import sqlalchemy.exc import sqlmodel @@ -1024,6 +1024,57 @@ def list_allowed_coverage_identifiers( return result +def list_municipalities( + session: sqlmodel.Session, + *, + limit: int = 20, + offset: int = 0, + include_total: bool = False, + polygon_intersection_filter: shapely.Polygon = None, + point_filter: shapely.Point = None, + name_filter: Optional[str] = None, + province_name_filter: Optional[str] = None, + region_name_filter: Optional[str] = None, +) -> Optional[municipalities.Municipality]: + """List existing municipalities. + + Both ``polygon_intersection_filter`` and ``point_filter`` parameters are expected + to be a geometries in the EPSG:4326 CRS. + """ + statement = sqlmodel.select(municipalities.Municipality).order_by( + municipalities.Municipality.name + ) + if name_filter is not None: + statement = statement.where(municipalities.Municipality.name.ilike(name_filter)) + if province_name_filter is not None: + statement = statement.where( + municipalities.Municipality.province_name.ilike(province_name_filter) + ) + if region_name_filter is not None: + statement = statement.where( + municipalities.Municipality.region_name.ilike(region_name_filter) + ) + if polygon_intersection_filter is not None: + statement = statement.where( + func.ST_Intersects( + municipalities.Municipality.geom, + func.ST_GeomFromWKB( + shapely.io.to_wkb(polygon_intersection_filter), 4326 + ), + ) + ) + if point_filter is not None: + statement = statement.where( + func.ST_Intersects( + municipalities.Municipality.geom, + func.ST_GeomFromWKB(shapely.io.to_wkb(point_filter), 4326), + ) + ) + items = session.exec(statement.offset(offset).limit(limit)).all() + num_items = _get_total_num_records(session, statement) if include_total else None + return items, num_items + + def create_many_municipalities( session: sqlmodel.Session, municipalities_to_create: Sequence[municipalities.MunicipalityCreate], diff --git a/arpav_ppcv/webapp/api_v2/app.py b/arpav_ppcv/webapp/api_v2/app.py index 815e5255..fa5746d6 100644 --- a/arpav_ppcv/webapp/api_v2/app.py +++ b/arpav_ppcv/webapp/api_v2/app.py @@ -3,6 +3,7 @@ from ... import config from .routers.coverages import router as coverages_router +from .routers.municipalities import router as municipalities_router from .routers.observations import router as observations_router from .routers.base import router as base_router @@ -50,4 +51,11 @@ def create_app(settings: config.ArpavPpcvSettings) -> fastapi.FastAPI: "observations", ], ) + app.include_router( + municipalities_router, + prefix="/municipalities", + tags=[ + "municipalities", + ], + ) return app diff --git a/arpav_ppcv/webapp/api_v2/routers/base.py b/arpav_ppcv/webapp/api_v2/routers/base.py index 79a3e7a2..b7fc19fe 100644 --- a/arpav_ppcv/webapp/api_v2/routers/base.py +++ b/arpav_ppcv/webapp/api_v2/routers/base.py @@ -1,16 +1,9 @@ import importlib.metadata import logging import os -from typing import Annotated -import sqlmodel -from fastapi import ( - APIRouter, - Depends, -) +from fastapi import APIRouter -from .... import database -from ... import dependencies from ..schemas.base import AppInformation @@ -25,16 +18,3 @@ async def get_app_info(): "version": importlib.metadata.version("arpav_ppcv_backend"), "git_commit": os.getenv("GIT_COMMIT", "unknown"), } - - -@router.get("/info/{longitude}/{latitude") -def get_coordinates_info( - db_session: Annotated[sqlmodel.Session, Depends(dependencies.get_db_session)], - longitude: float, - latitude: float, -): - """Return information about a point location.""" - municipality = database.get_municipality_by_coordinates( - db_session, longitude, latitude - ) - logger.debug(f"{municipality=}") diff --git a/arpav_ppcv/webapp/api_v2/routers/municipalities.py b/arpav_ppcv/webapp/api_v2/routers/municipalities.py new file mode 100644 index 00000000..5e0f9c93 --- /dev/null +++ b/arpav_ppcv/webapp/api_v2/routers/municipalities.py @@ -0,0 +1,68 @@ +import logging +from typing import Annotated + +import shapely.io +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, +) +from sqlmodel import Session + +from .... import database as db +from ... import dependencies +from ...responses import GeoJsonResponse +from ..schemas.geojson import municipalities as municipalities_geojson + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get( + "/", + response_class=GeoJsonResponse, + response_model=municipalities_geojson.MunicipalityFeatureCollection, +) +def list_municipalities( + request: Request, + db_session: Annotated[Session, Depends(dependencies.get_db_session)], + list_params: Annotated[dependencies.CommonListFilterParameters, Depends()], + coords: str | None = None, + name: str | None = None, + province: str | None = None, + region: str | None = None, +): + """List Italian municipalities.""" + geom_filter_kwarg = {} + if coords is not None: + geom = shapely.io.from_wkt(coords) + if geom.geom_type == "Point": + geom_filter_kwarg = {"point_filter": geom} + elif geom.geom_type == "Polygon": + geom_filter_kwarg = {"polygon_intersection_filter": geom} + else: + raise HTTPException( + status_code=400, detail="geometry be either point or polygon" + ) + municipalities, filtered_total = db.list_municipalities( + db_session, + limit=list_params.limit, + offset=list_params.offset, + include_total=True, + name_filter=name, + province_name_filter=province, + region_name_filter=region, + **geom_filter_kwarg, + ) + _, unfiltered_total = db.list_stations( + db_session, limit=1, offset=0, include_total=True + ) + return municipalities_geojson.MunicipalityFeatureCollection.from_items( + municipalities, + request, + limit=list_params.limit, + offset=list_params.offset, + filtered_total=filtered_total, + unfiltered_total=unfiltered_total, + ) diff --git a/arpav_ppcv/webapp/api_v2/routers/observations.py b/arpav_ppcv/webapp/api_v2/routers/observations.py index 173ae7aa..86871461 100644 --- a/arpav_ppcv/webapp/api_v2/routers/observations.py +++ b/arpav_ppcv/webapp/api_v2/routers/observations.py @@ -27,6 +27,7 @@ database as db, operations, ) +from ...responses import GeoJsonResponse from ....schemas import base from ... import dependencies from ..schemas import observations @@ -41,10 +42,6 @@ router = APIRouter() -class GeoJsonResponse(JSONResponse): - media_type = "application/geo+json" - - @router.get( "/stations", response_class=GeoJsonResponse, diff --git a/arpav_ppcv/webapp/api_v2/schemas/geojson/municipalities.py b/arpav_ppcv/webapp/api_v2/schemas/geojson/municipalities.py new file mode 100644 index 00000000..8f300130 --- /dev/null +++ b/arpav_ppcv/webapp/api_v2/schemas/geojson/municipalities.py @@ -0,0 +1,41 @@ +import geojson_pydantic +import pydantic +from fastapi import Request + +from .....schemas import ( + fields, + municipalities, +) +from .base import ArpavFeatureCollection + + +class MunicipalityFeatureCollectionItem(geojson_pydantic.Feature): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + + type: str = "Feature" + id: pydantic.UUID4 + geometry: fields.WkbElement + + @classmethod + def from_db_instance( + cls, + instance: municipalities.Municipality, + request: Request, + ) -> "MunicipalityFeatureCollectionItem": + return cls( + id=instance.id, + geometry=instance.geom, + properties={ + **instance.model_dump( + exclude={ + "id", + "geom", + } + ), + }, + ) + + +class MunicipalityFeatureCollection(ArpavFeatureCollection): + path_operation_name = "list_municipalities" + list_item_type = MunicipalityFeatureCollectionItem diff --git a/arpav_ppcv/webapp/responses.py b/arpav_ppcv/webapp/responses.py new file mode 100644 index 00000000..97d8ef3a --- /dev/null +++ b/arpav_ppcv/webapp/responses.py @@ -0,0 +1,5 @@ +from fastapi.responses import JSONResponse + + +class GeoJsonResponse(JSONResponse): + media_type = "application/geo+json"