Skip to content
Open
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
74 changes: 74 additions & 0 deletions backend/api/projects/osm_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import json

from databases import Database
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse

from backend.db import get_db
from backend.exceptions import BadRequest, NotFound
from backend.services.overpass_service import OverpassService, OverpassServiceError

router = APIRouter(
prefix="/projects",
tags=["projects"],
responses={404: {"description": "Not found"}},
)


@router.get("/{project_id}/tasks/{task_id}/osm-features/")
async def get_osm_features_for_task(
project_id: int,
task_id: int,
db: Database = Depends(get_db),

Check warning on line 22 in backend/api/projects/osm_features.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ3dikIcmYQXfsvmTpgT&open=AZ3dikIcmYQXfsvmTpgT&pullRequest=7234
):
"""
Fetch existing OSM features from the real OpenStreetMap within a task's boundary.
This endpoint is only available for sandbox projects and is intended for
data conflation in the sandbox environment.

Returns a GeoJSON FeatureCollection of all tagged OSM features (nodes, ways)
within the task boundary.
"""
project = await db.fetch_one(
"SELECT id, sandbox FROM projects WHERE id = :project_id",
values={"project_id": project_id},
)
if not project:
raise NotFound(
sub_code="PROJECT_NOT_FOUND",
message=f"Project {project_id} not found.",
)
if not project["sandbox"]:
raise BadRequest(
sub_code="NOT_SANDBOX_PROJECT",
message="This endpoint is only available for sandbox projects.",
)

task = await db.fetch_one(
"""
SELECT id, project_id, ST_AsGeoJSON(geometry) AS geojson
FROM tasks
WHERE id = :task_id
AND project_id = :project_id
""",
values={"task_id": task_id, "project_id": project_id},
)
if not task:
raise NotFound(
sub_code="TASK_NOT_FOUND",
message=f"Task {task_id} not found in project {project_id}.",
)

geometry_geojson = json.loads(task["geojson"])

try:
feature_collection = await OverpassService.fetch_osm_features_for_boundary(
geometry_geojson
)
except OverpassServiceError as e:
raise BadRequest(
sub_code="OVERPASS_API_ERROR",
message=str(e.message),
)

return JSONResponse(content=feature_collection)
2 changes: 2 additions & 0 deletions backend/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from backend.api.projects import campaigns as project_campaigns
from backend.api.projects import contributions as project_contributions
from backend.api.projects import favorites as project_favorites
from backend.api.projects import osm_features as project_osm_features
from backend.api.projects import partnerships as project_partnerships
from backend.api.projects import resources as project_resources
from backend.api.projects import statistics as project_statistics
Expand Down Expand Up @@ -55,6 +56,7 @@ def add_api_end_points(api):
v2.include_router(project_actions.router)
v2.include_router(project_favorites.router)
v2.include_router(project_partnerships.router)
v2.include_router(project_osm_features.router)

# Comments REST endpoint
v2.include_router(comment_resources.router)
Expand Down
167 changes: 167 additions & 0 deletions backend/services/overpass_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import geojson
import httpx
from loguru import logger
from shapely.geometry import shape

from backend.config import settings

OVERPASS_API_URL = "https://overpass-api.de/api/interpreter"


class OverpassServiceError(Exception):
def __init__(self, message):
self.message = message
logger.error(f"OverpassServiceError: {message}")
super().__init__(message)


class OverpassService:
@staticmethod
def _build_query(geometry_geojson: dict, timeout: int = 60) -> str:
"""
Build an Overpass QL query that fetches all OSM features (nodes, ways,
relations) within the given polygon boundary.
"""
geom = shape(geometry_geojson)

if geom.geom_type == "MultiPolygon":
polygon = max(geom.geoms, key=lambda g: g.area)
else:
polygon = geom

simplified = polygon.simplify(0.0001, preserve_topology=True)
coords = list(simplified.exterior.coords)

poly_str = " ".join(f"{lat} {lon}" for lon, lat in coords)

query = f"""
[out:json][timeout:{timeout}];
(
nwr(poly:"{poly_str}");
);
out body;
>;
out skel qt;
"""
return query

@staticmethod
def _overpass_elements_to_geojson(data: dict) -> geojson.FeatureCollection:

Check failure on line 49 in backend/services/overpass_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 31 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ3dikLWmYQXfsvmTpgU&open=AZ3dikLWmYQXfsvmTpgU&pullRequest=7234
"""
Convert Overpass JSON response to a GeoJSON FeatureCollection.
Handles nodes, ways, and basic relations.
"""
elements = data.get("elements", [])

nodes = {}
ways = {}
features = []

for elem in elements:
if elem["type"] == "node":
nodes[elem["id"]] = elem

for elem in elements:
if elem["type"] == "way":
ways[elem["id"]] = elem

for elem in elements:
feature = None
if elem["type"] == "node" and "tags" in elem:
feature = geojson.Feature(
geometry=geojson.Point([elem["lon"], elem["lat"]]),
properties={
"osm_id": elem["id"],
"osm_type": "node",
**(elem.get("tags", {})),
},
)
elif elem["type"] == "way" and "tags" in elem:
coords = []
for nd_ref in elem.get("nodes", []):
nd = nodes.get(nd_ref)
if nd:
coords.append([nd["lon"], nd["lat"]])

if len(coords) < 2:
continue

if len(coords) >= 4 and coords[0] == coords[-1]:
feature = geojson.Feature(
geometry=geojson.Polygon([coords]),
properties={
"osm_id": elem["id"],
"osm_type": "way",
**(elem.get("tags", {})),
},
)
else:
feature = geojson.Feature(
geometry=geojson.LineString(coords),
properties={
"osm_id": elem["id"],
"osm_type": "way",
**(elem.get("tags", {})),
},
)
elif elem["type"] == "relation" and "tags" in elem:
feature = geojson.Feature(
geometry=None,
properties={
"osm_id": elem["id"],
"osm_type": "relation",
**(elem.get("tags", {})),
},
)

if feature:
features.append(feature)

return geojson.FeatureCollection(features)

@staticmethod
async def fetch_osm_features_for_boundary(
geometry_geojson: dict,
timeout: int = 60,

Check warning on line 125 in backend/services/overpass_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "timeout" parameter and use a timeout context manager instead.

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ3dikLWmYQXfsvmTpgV&open=AZ3dikLWmYQXfsvmTpgV&pullRequest=7234
) -> geojson.FeatureCollection:
"""
Fetch all OSM features within the given boundary polygon from the
Overpass API and return as a GeoJSON FeatureCollection.

:param geometry_geojson: A GeoJSON geometry (Polygon or MultiPolygon)
:param timeout: Overpass query timeout in seconds
:returns: GeoJSON FeatureCollection of OSM features
:raises OverpassServiceError: If the Overpass API request fails
"""
query = OverpassService._build_query(geometry_geojson, timeout=timeout)

async with httpx.AsyncClient(timeout=timeout + 10) as client:
response = await client.post(
OVERPASS_API_URL,
data={"data": query},
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": settings.OSM_USER_AGENT,
},
)

if response.status_code == 429:
raise OverpassServiceError(
"Overpass API rate limit exceeded. Please try again later."
)
if response.status_code == 504:
raise OverpassServiceError(
"Overpass API query timed out. The task boundary may be too large."
)
if response.status_code != 200:
raise OverpassServiceError(
f"Overpass API returned status {response.status_code}: {response.text[:200]}"
)

try:
data = response.json()
except Exception:
raise OverpassServiceError("Failed to parse Overpass API response as JSON.")

return OverpassService._overpass_elements_to_geojson(data)
17 changes: 17 additions & 0 deletions frontend/src/api/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,23 @@ export const useAllPartnersQuery = (token, userId) => {
});
};

export const useOsmFeaturesQuery = (projectId, taskId, enabled = true) => {
const token = useSelector((state) => state.auth.token);

const fetchOsmFeatures = ({ signal }) => {
return api(token).get(`projects/${projectId}/tasks/${taskId}/osm-features/`, {
signal,
});
};

return useQuery({
queryKey: ['osm-features', projectId, taskId],
queryFn: fetchOsmFeatures,
select: (data) => data.data,
enabled: !!(enabled && projectId && taskId),
});
};

const backendToQueryConversion = {
difficulty: 'difficulty',
campaign: 'campaign',
Expand Down
Loading
Loading