Skip to content

Commit

Permalink
fix: project creation workflow fixes (task splitting) (#1194)
Browse files Browse the repository at this point in the history
* fix: remove Field(Form()) from schemas

* refactor: move geometry generation to project_schemas

* fix(frontend): send project outline geojson during create

* fix: only send custom data extracts to splitter

* fix: optional project_task_name in schema

* refactor(frontend): remove refs to project_task_name

* refactor(backend): remove extract_type from data-extract-url

* fix(frontend): setting data extract url if osm generated extract

* test: fix or remove tests during project creation
  • Loading branch information
spwoodcock authored Feb 13, 2024
1 parent 058ca2b commit bd6c743
Show file tree
Hide file tree
Showing 16 changed files with 370 additions and 323 deletions.
134 changes: 109 additions & 25 deletions src/backend/app/db/postgis_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@
"""PostGIS and geometry handling helper funcs."""

import datetime
import json
import logging
from json import dumps as json_dumps
from typing import Optional, Union

import geojson
import requests
from fastapi import HTTPException
from geoalchemy2 import WKBElement
from geoalchemy2.shape import to_shape
from geojson import FeatureCollection
from geojson import loads as geojson_loads
from geojson_pydantic import Feature
from shapely.geometry import mapping
from geoalchemy2.shape import from_shape, to_shape
from geojson_pydantic import Feature, Polygon
from geojson_pydantic import FeatureCollection as FeatCol
from shapely.geometry import mapping, shape
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -78,14 +79,46 @@ def get_centroid(
return {}


def geojson_to_geometry(
geojson: Union[FeatCol, Feature, Polygon],
) -> Optional[WKBElement]:
"""Convert GeoJSON to SQLAlchemy geometry."""
parsed_geojson = parse_and_filter_geojson(geojson.model_dump_json(), filter=False)

if not parsed_geojson:
return None

features = parsed_geojson.get("features", [])

if len(features) > 1:
# TODO code to merge all geoms into multipolygon
# TODO do not use convex hull
pass

geometry = features[0].get("geometry")

shapely_geom = shape(geometry)
return from_shape(shapely_geom)


def read_wkb(wkb: WKBElement):
"""Load a WKBElement and return a shapely geometry."""
return to_shape(wkb)


def write_wkb(shape):
"""Load shapely geometry and output WKBElement."""
return from_shape(shape)


async def geojson_to_flatgeobuf(
db: Session, geojson: FeatureCollection
db: Session, geojson: geojson.FeatureCollection
) -> Optional[bytes]:
"""From a given FeatureCollection, return a memory flatgeobuf obj.
Args:
db (Session): SQLAlchemy db session.
geojson (FeatureCollection): a geojson.FeatureCollection object.
geojson (geojson.FeatureCollection): a FeatureCollection object.
Returns:
flatgeobuf (bytes): a Python bytes representation of a flatgeobuf file.
Expand Down Expand Up @@ -142,7 +175,7 @@ async def geojson_to_flatgeobuf(
FROM (SELECT * FROM public.temp_features as geoms) AS fgb_data;
"""
# Run the SQL
result = db.execute(text(sql), {"geojson": json_dumps(geojson)})
result = db.execute(text(sql), {"geojson": json.dumps(geojson)})
# Get a memoryview object, then extract to Bytes
flatgeobuf = result.first()

Expand All @@ -158,15 +191,15 @@ async def geojson_to_flatgeobuf(

async def flatgeobuf_to_geojson(
db: Session, flatgeobuf: bytes
) -> Optional[FeatureCollection]:
) -> Optional[geojson.FeatureCollection]:
"""Converts FlatGeobuf data to GeoJSON.
Args:
db (Session): SQLAlchemy db session.
flatgeobuf (bytes): FlatGeobuf data in bytes format.
Returns:
FeatureCollection: A GeoJSON FeatureCollection object.
geojson.FeatureCollection: A FeatureCollection object.
"""
sql = text(
"""
Expand Down Expand Up @@ -204,41 +237,49 @@ async def flatgeobuf_to_geojson(
return None

if feature_collection:
return geojson_loads(json_dumps(feature_collection[0]))
return geojson.loads(json.dumps(feature_collection[0]))

return None


async def parse_and_filter_geojson(geojson_str: str) -> Optional[FeatureCollection]:
def parse_and_filter_geojson(
geojson_str: str, filter: bool = True
) -> Optional[geojson.FeatureCollection]:
"""Parse geojson string and filter out incomaptible geometries."""
log.debug("Parsing geojson file")
geojson_parsed = geojson_loads(geojson_str)
if isinstance(geojson_parsed, FeatureCollection):
log.debug("Parsing geojson string")
geojson_parsed = geojson.loads(geojson_str)
if isinstance(geojson_parsed, geojson.FeatureCollection):
log.debug("Already in FeatureCollection format, skipping reparse")
featcol = geojson_parsed
elif isinstance(geojson_parsed, Feature):
elif isinstance(geojson_parsed, geojson.Feature):
log.debug("Converting Feature to FeatureCollection")
featcol = FeatureCollection(geojson_parsed)
featcol = geojson.FeatureCollection(features=[geojson_parsed])
else:
log.debug("Converting geometry to FeatureCollection")
featcol = FeatureCollection[Feature(geometry=geojson_parsed)]
featcol = geojson.FeatureCollection(
features=[geojson.Feature(geometry=geojson_parsed)]
)

# Validating Coordinate Reference System
check_crs(featcol)
# Exit early if no geoms
if not featcol.get("features", []):
return None

geom_type = await get_featcol_main_geom_type(featcol)
# Return unfiltered featcol
if not filter:
return featcol

# Filter out geoms not matching main type
geom_type = get_featcol_main_geom_type(featcol)
features_filtered = [
feature
for feature in featcol.get("features", [])
if feature.get("geometry", {}).get("type", "") == geom_type
]

return FeatureCollection(features_filtered)
return geojson.FeatureCollection(features_filtered)


async def get_featcol_main_geom_type(featcol: FeatureCollection) -> str:
def get_featcol_main_geom_type(featcol: geojson.FeatureCollection) -> str:
"""Get the predominant geometry type in a FeatureCollection."""
geometry_counts = {"Polygon": 0, "Point": 0, "Polyline": 0}

Expand All @@ -250,7 +291,7 @@ async def get_featcol_main_geom_type(featcol: FeatureCollection) -> str:
return max(geometry_counts, key=geometry_counts.get)


async def check_crs(input_geojson: Union[dict, FeatureCollection]):
async def check_crs(input_geojson: Union[dict, geojson.FeatureCollection]):
"""Validate CRS is valid for a geojson."""
log.debug("validating coordinate reference system")

Expand Down Expand Up @@ -307,3 +348,46 @@ def is_valid_coordinate(coord):
if not is_valid_coordinate(first_coordinate):
log.error(error_message)
raise HTTPException(status_code=400, detail=error_message)


def get_address_from_lat_lon(latitude, longitude):
"""Get address using Nominatim, using lat,lon."""
base_url = "https://nominatim.openstreetmap.org/reverse"

params = {
"format": "json",
"lat": latitude,
"lon": longitude,
"zoom": 18,
}
headers = {"Accept-Language": "en"} # Set the language to English

log.debug("Getting Nominatim address from project centroid")
response = requests.get(base_url, params=params, headers=headers)
if (status_code := response.status_code) != 200:
log.error(f"Getting address string failed: {status_code}")
return None

data = response.json()
log.debug(f"Nominatim response: {data}")

address = data.get("address", None)
if not address:
log.error(f"Getting address string failed: {status_code}")
return None

country = address.get("country", "")
city = address.get("city", "")

address_str = f"{city},{country}"

if not address_str or address_str == ",":
log.error("Getting address string failed")
return None

return address_str


async def get_address_from_lat_lon_async(latitude, longitude):
"""Async wrapper for get_address_from_lat_lon."""
return get_address_from_lat_lon(latitude, longitude)
15 changes: 5 additions & 10 deletions src/backend/app/organisations/organisation_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
from re import sub
from typing import Optional

from fastapi import Form
from pydantic import BaseModel, Field, computed_field
from pydantic import BaseModel, computed_field

from app.config import HttpUrlStr
from app.models.enums import OrganisationType
Expand All @@ -34,13 +33,9 @@
class OrganisationIn(ODKCentralIn):
"""Organisation to create from user input."""

name: str = Field(Form(..., description="Organisation name"))
description: Optional[str] = Field(
Form(None, description="Organisation description")
)
url: Optional[HttpUrlStr] = Field(
Form(None, description="Organisation website URL")
)
name: str
description: Optional[str] = None
url: Optional[HttpUrlStr] = None

@computed_field
@property
Expand All @@ -59,7 +54,7 @@ class OrganisationEdit(OrganisationIn):
"""Organisation to edit via user input."""

# Override to make name optional
name: Optional[str] = Field(Form(None, description="Organisation name"))
name: Optional[str] = None


class OrganisationOut(BaseModel):
Expand Down
Loading

0 comments on commit bd6c743

Please sign in to comment.