diff --git a/src/backend/app/db/postgis_utils.py b/src/backend/app/db/postgis_utils.py index 69109b14c2..8b67dd01f5 100644 --- a/src/backend/app/db/postgis_utils.py +++ b/src/backend/app/db/postgis_utils.py @@ -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, diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index fa67b08feb..a26b004157 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -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 @@ -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) @@ -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( diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 5e7c4f3291..747f11f3d3 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -24,6 +24,7 @@ from typing import Optional import geojson +import geojson_pydantic import requests from fastapi import ( APIRouter, @@ -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 @@ -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, @@ -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. @@ -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 @@ -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 ] @@ -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.""" diff --git a/src/backend/app/tasks/tasks_routes.py b/src/backend/app/tasks/tasks_routes.py index 49cf55f1db..787972b27b 100644 --- a/src/backend/app/tasks/tasks_routes.py +++ b/src/backend/app/tasks/tasks_routes.py @@ -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( @@ -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, diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js index 1c7b1203f2..a374985ded 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js @@ -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') || @@ -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'; diff --git a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx index 1fe4008fc8..980b49bc50 100644 --- a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx @@ -81,7 +81,7 @@ const MapControlComponent = ({ map }) => { ))}
navigate(`/project_details/${projectId}`)}
+ onClick={() => navigate(`/project/${projectId}`)}
>
{projectInfo?.title}{' '}
diff --git a/src/frontend/src/components/createnewproject/SplitTasks.tsx b/src/frontend/src/components/createnewproject/SplitTasks.tsx
index 0c4448a25f..65fd17d97a 100644
--- a/src/frontend/src/components/createnewproject/SplitTasks.tsx
+++ b/src/frontend/src/components/createnewproject/SplitTasks.tsx
@@ -188,7 +188,7 @@ const SplitTasks = ({ flag, geojsonFile, setGeojsonFile, customDataExtractUpload
}),
);
dispatch(CreateProjectActions.SetGenerateProjectQRSuccess(null));
- navigate(`/project_details/${projectId}`);
+ navigate(`/project/${projectId}`);
dispatch(CreateProjectActions.ClearCreateProjectFormData());
dispatch(CreateProjectActions.SetCanSwitchCreateProjectSteps(false));
}
diff --git a/src/frontend/src/components/home/ExploreProjectCard.tsx b/src/frontend/src/components/home/ExploreProjectCard.tsx
index bdb1408fdc..f62f42cfef 100755
--- a/src/frontend/src/components/home/ExploreProjectCard.tsx
+++ b/src/frontend/src/components/home/ExploreProjectCard.tsx
@@ -59,7 +59,7 @@ export default function ExploreProjectCard({ data }: { data: projectType }) {
const project: projectType = data;
// dispatch(ProjectActions.SetProjectTaskBoundries([]))
dispatch(HomeActions.SetSelectedProject(project));
- navigate(`/project_details/${data.id}`);
+ navigate(`/project/${data.id}`);
}}
style={cardInnerStyles.card}
sx={{ boxShadow: 0 }}
diff --git a/src/frontend/src/components/home/ProjectListMap.tsx b/src/frontend/src/components/home/ProjectListMap.tsx
index a2797d9f77..ffd60af5ca 100644
--- a/src/frontend/src/components/home/ProjectListMap.tsx
+++ b/src/frontend/src/components/home/ProjectListMap.tsx
@@ -74,7 +74,7 @@ const ProjectListMap = () => {
const projectClickOnMap = (properties: any) => {
const projectId = properties.id;
- navigate(`/project_details/${projectId}`);
+ navigate(`/project/${projectId}`);
};
return (
diff --git a/src/frontend/src/routes.jsx b/src/frontend/src/routes.jsx
index 338b0e8ec8..091f78a640 100755
--- a/src/frontend/src/routes.jsx
+++ b/src/frontend/src/routes.jsx
@@ -131,7 +131,7 @@ const routes = createBrowserRouter([
),
},
{
- path: '/project_details/:id',
+ path: '/project/:id',
element: (
navigate(`/project_details/${projectId}`)} + onClick={() => navigate(`/project/${projectId}`)} > {projectDashboardDetail?.project_name_prefix}