Skip to content

Commit

Permalink
feat: add /projects/features endpoint for project FeatureCollection (…
Browse files Browse the repository at this point in the history
…disaster.ninja integration) (#1442)

* refactor: move tasks/features endpoint --> projects/{id}/task-completion

* fix(backend): coerce invalid osm_id integers by removing non-numeric chars

* refactor(frontend): rename project_details page --> project

* feat: add /projects/features/ endpoint to get FeatureCollection

* fix(backend): /projects/features handle empty featcol, hide private projects

* fix(backend): hide projects from home page where visibility!=PUBLIC
  • Loading branch information
spwoodcock authored Apr 12, 2024
1 parent acee91e commit 4ede724
Show file tree
Hide file tree
Showing 15 changed files with 146 additions and 144 deletions.
4 changes: 3 additions & 1 deletion src/backend/app/db/postgis_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ async def geojson_to_flatgeobuf(
(geom, osm_id, tags, version, changeset, timestamp)
SELECT
ST_ForceCollection(ST_GeomFromGeoJSON(feat->>'geometry')) AS geom,
(feat->'properties'->>'osm_id')::integer as osm_id,
regexp_replace(
(feat->'properties'->>'osm_id')::text, '[^0-9]', '', 'g'
)::integer as osm_id,
(feat->'properties'->>'tags')::text as tags,
(feat->'properties'->>'version')::integer as version,
(feat->'properties'->>'changeset')::integer as changeset,
Expand Down
64 changes: 61 additions & 3 deletions src/backend/app/projects/project_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
split_geojson_by_task_areas,
task_geojson_dict_to_entity_values,
)
from app.models.enums import HTTPStatus, ProjectRole
from app.models.enums import HTTPStatus, ProjectRole, ProjectVisibility
from app.projects import project_deps, project_schemas
from app.s3 import add_obj_to_bucket, get_obj_from_bucket
from app.tasks import tasks_crud
Expand All @@ -83,8 +83,9 @@ async def get_projects(
hashtags: Optional[List[str]] = None,
search: Optional[str] = None,
):
"""Get all projects."""
"""Get all projects, or a filtered subset."""
filters = []

if user_id:
filters.append(db_models.DbProject.author_id == user_id)

Expand Down Expand Up @@ -112,13 +113,70 @@ async def get_projects(
else:
db_projects = (
db.query(db_models.DbProject)
.filter(
db_models.DbProject.visibility # type: ignore
== ProjectVisibility.PUBLIC # type: ignore
)
.order_by(db_models.DbProject.id.desc()) # type: ignore
.offset(skip)
.limit(limit)
.all()
)
project_count = db.query(db_models.DbProject).count()
return project_count, await convert_to_app_projects(db_projects)

filtered_projects = await convert_to_app_projects(db_projects)
return project_count, filtered_projects


async def get_projects_featcol(
db: Session,
bbox: Optional[str] = None,
) -> geojson.FeatureCollection:
"""Get all projects, or a filtered subset."""
bbox_condition = (
"""AND ST_Intersects(
p.outline, ST_MakeEnvelope(:minx, :miny, :maxx, :maxy, 4326)
)"""
if bbox
else ""
)

bbox_params = {}
if bbox:
minx, miny, maxx, maxy = map(float, bbox.split(","))
bbox_params = {"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy}

query = text(
f"""
SELECT jsonb_build_object(
'type', 'FeatureCollection',
'features', COALESCE(jsonb_agg(feature), '[]'::jsonb)
) AS featcol
FROM (
SELECT jsonb_build_object(
'type', 'Feature',
'id', p.id,
'geometry', ST_AsGeoJSON(p.outline)::jsonb,
'properties', jsonb_build_object(
'name', pi.name,
'percentMapped', 0,
'percentValidated', 0,
'created', p.created,
'link', concat('https://', :domain, '/project/', p.id)
)
) AS feature
FROM projects p
LEFT JOIN project_info pi ON p.id = pi.project_id
WHERE p.visibility = 'PUBLIC'
{bbox_condition}
) features;
"""
)

result = db.execute(query, {"domain": settings.FMTM_DOMAIN, **bbox_params})
featcol = result.scalar_one()

return featcol


async def get_project_summaries(
Expand Down
122 changes: 68 additions & 54 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from typing import Optional

import geojson
import geojson_pydantic
import requests
from fastapi import (
APIRouter,
Expand Down Expand Up @@ -53,7 +54,7 @@
flatgeobuf_to_geojson,
parse_and_filter_geojson,
)
from app.models.enums import TILES_FORMATS, TILES_SOURCE, HTTPStatus
from app.models.enums import TILES_FORMATS, TILES_SOURCE, HTTPStatus, ProjectVisibility
from app.organisations import organisation_deps
from app.projects import project_crud, project_deps, project_schemas
from app.submissions import submission_crud
Expand All @@ -66,6 +67,15 @@
)


@router.get("/features", response_model=geojson_pydantic.FeatureCollection)
async def read_projects_to_featcol(
db: Session = Depends(database.get_db),
bbox: Optional[str] = None,
):
"""Return all projects as a single FeatureCollection."""
return await project_crud.get_projects_featcol(db, bbox)


@router.get("/", response_model=list[project_schemas.ProjectOut])
async def read_projects(
user_id: int = None,
Expand All @@ -74,60 +84,12 @@ async def read_projects(
db: Session = Depends(database.get_db),
):
"""Return all projects."""
project_count, projects = await project_crud.get_projects(db, user_id, skip, limit)
project_count, projects = await project_crud.get_projects(
db, user_id=user_id, skip=skip, limit=limit
)
return projects


# TODO delete me
# @router.get("/details/{project_id}/")
# async def get_projet_details(
# project_id: int,
# db: Session = Depends(database.get_db),
# current_user: AuthUser = Depends(mapper),
# ):
# """Returns the project details.

# Also includes ODK project details, so takes extra time to return.

# Parameters:
# project_id: int

# Returns:
# Response: Project details.
# """
# project = await project_crud.get_project(db, project_id)
# if not project:
# raise HTTPException(status_code=404, detail={"Project not found"})

# # ODK Credentials
# odk_credentials = project_schemas.ODKCentralDecrypted(
# odk_central_url=project.odk_central_url,
# odk_central_user=project.odk_central_user,
# odk_central_password=project.odk_central_password,
# )

# odk_details = central_crud.get_odk_project_full_details(
# project.odkid, odk_credentials
# )

# # Features count
# query = text(
# "select count(*) from features where "
# f"project_id={project_id} and task_id is not null"
# )
# result = db.execute(query)
# features = result.fetchone()[0]

# return {
# "id": project_id,
# "odkName": odk_details["name"],
# "createdAt": odk_details["createdAt"],
# "tasks": odk_details["forms"],
# "lastSubmission": odk_details["lastSubmission"],
# "total_features": features,
# }


@router.post("/near_me", response_model=list[project_schemas.ProjectSummary])
async def get_tasks_near_me(lat: float, long: float, user_id: int = None):
"""Get projects near me.
Expand All @@ -152,7 +114,14 @@ async def read_project_summaries(
filter(lambda hashtag: hashtag.startswith("#"), hashtags)
) # filter hashtags that do start with #

total_projects = db.query(db_models.DbProject).count()
total_project_count = (
db.query(db_models.DbProject)
.filter(
db_models.DbProject.visibility # type: ignore
== ProjectVisibility.PUBLIC # type: ignore
)
.count()
)
skip = (page - 1) * results_per_page
limit = results_per_page

Expand All @@ -161,8 +130,9 @@ async def read_project_summaries(
)

pagination = await project_crud.get_pagination(
page, project_count, results_per_page, total_projects
page, project_count, results_per_page, total_project_count
)

project_summaries = [
project_schemas.ProjectSummary.from_db_project(project) for project in projects
]
Expand Down Expand Up @@ -214,6 +184,50 @@ async def search_project(
return response


@router.get("/{project_id}/task-completion")
async def task_features_count(
project_id: int,
db: Session = Depends(database.get_db),
):
"""Get all features within a task area."""
# Get the project object.
project = await project_crud.get_project(db, project_id)

# ODK Credentials
odk_credentials = await project_deps.get_odk_credentials(db, project_id)

odk_details = central_crud.list_odk_xforms(project.odkid, odk_credentials, True)

# Assemble the final data list
data = []
feature_count_query = text(
"""
SELECT id, feature_count
FROM tasks
WHERE project_id = :project_id;
"""
)
result = db.execute(feature_count_query, {"project_id": project_id})
feature_counts = result.all()

if not feature_counts:
msg = f"To tasks found for project {project_id}"
log.warning(msg)
raise HTTPException(status_code=404, detail=msg)

data.extend(
{
"task_id": record[0],
"submission_count": odk_details[0]["submissions"],
"last_submission": odk_details[0]["lastSubmission"],
"feature_count": record[1],
}
for record in feature_counts
)

return data


@router.get("/{project_id}", response_model=project_schemas.ReadProject)
async def read_project(project_id: int, db: Session = Depends(database.get_db)):
"""Get a specific project by ID."""
Expand Down
72 changes: 0 additions & 72 deletions src/backend/app/tasks/tasks_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,12 @@
from typing import List

from fastapi import APIRouter, Depends, HTTPException
from loguru import logger as log
from sqlalchemy.orm import Session
from sqlalchemy.sql import text

from app.auth.osm import AuthUser, login_required
from app.auth.roles import get_uid, mapper
from app.central import central_crud
from app.db import database
from app.models.enums import TaskStatus
from app.projects import project_crud, project_deps
from app.tasks import tasks_crud, tasks_schemas

router = APIRouter(
Expand Down Expand Up @@ -138,74 +134,6 @@ async def update_task_status(
return await tasks_crud.update_task_history(task, db)


@router.get("/features/")
async def task_features_count(
project_id: int,
db: Session = Depends(database.get_db),
):
"""Get all features within a task area."""
# Get the project object.
project = await project_crud.get_project(db, project_id)

# ODK Credentials
odk_credentials = await project_deps.get_odk_credentials(db, project_id)

odk_details = central_crud.list_odk_xforms(project.odkid, odk_credentials, True)

# Assemble the final data list
data = []
feature_count_query = text(
"""
SELECT id, feature_count
FROM tasks
WHERE project_id = :project_id;
"""
)
result = db.execute(feature_count_query, {"project_id": project_id})
feature_counts = result.all()

if not feature_counts:
msg = f"To tasks found for project {project_id}"
log.warning(msg)
raise HTTPException(status_code=404, detail=msg)

data.extend(
{
"task_id": record[0],
"submission_count": odk_details[0]["submissions"],
"last_submission": odk_details[0]["lastSubmission"],
"feature_count": record[1],
}
for record in feature_counts
)

return data

# @router.get(
# "/task-comments/", response_model=list[tasks_schemas.TaskCommentResponse]
# )
# async def task_comments(
# project_id: int,
# task_id: int,
# db: Session = Depends(database.get_db),
# ):
# """Retrieve a list of task comments for a specific project and task.

# Args:
# project_id (int): The ID of the project.
# task_id (int): The ID of the task.
# db (Session, optional): The database session.

# Returns:
# A list of task comments.
# """
# task_comment_list = await tasks_crud.get_task_comments(
# db, project_id, task_id
# )

# return task_comment_list


@router.post("/task-comments/", response_model=tasks_schemas.TaskCommentResponse)
async def add_task_comments(
comment: tasks_schemas.TaskCommentRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null })
layerSwitcher.style.alignItems = 'center';
}
if (
location.pathname.includes('project_details') ||
location.pathname.includes('project/') ||
location.pathname.includes('upload-area') ||
location.pathname.includes('select-category') ||
location.pathname.includes('data-extract') ||
Expand All @@ -218,7 +218,7 @@ const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null })
if (olZoom) {
olZoom.style.display = 'none';
}
if (layerSwitcher && location.pathname.includes('project_details')) {
if (layerSwitcher && location.pathname.includes('project/')) {
layerSwitcher.style.right = '14px';
layerSwitcher.style.top = windowSize.width > 640 ? '300px' : '355px';
layerSwitcher.style.zIndex = '1000';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const MapControlComponent = ({ map }) => {
</div>
))}
<div
className={`fmtm-relative ${!pathname.includes('project_details') ? 'fmtm-hidden' : 'sm:fmtm-hidden'}`}
className={`fmtm-relative ${!pathname.includes('project/') ? 'fmtm-hidden' : 'sm:fmtm-hidden'}`}
ref={divRef}
>
<div
Expand Down
Loading

0 comments on commit 4ede724

Please sign in to comment.