diff --git a/src/backend/app/config.py b/src/backend/app/config.py index 82abb942c..b1df2afdb 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -172,9 +172,10 @@ def assemble_cors_origins( default_origins = ["https://xlsforms.fmtm.dev"] # Handle localhost/testing scenario - domain = info.data.get("FMTM_DOMAIN", "localhost") + domain = info.data.get("FMTM_DOMAIN", "fmtm.localhost") dev_port = info.data.get("FMTM_DEV_PORT", "") - if "localhost" in domain: + # NOTE fmtm.dev.test is used as the Playwright test domain + if "localhost" in domain or "fmtm.dev.test" in domain: local_server_port = ( f":{dev_port}" if dev_port and dev_port.lower() not in ("0", "no", "false") diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 72b691205..db829f316 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -86,15 +86,21 @@ @router.get("/features", response_model=FeatureCollection) async def read_projects_to_featcol( + # NOTE here we add not permissions to make this externally accessible + # to scrapers / other projects db: Annotated[Connection, Depends(db_conn)], bbox: Optional[str] = None, ): - """Return all projects as a single FeatureCollection.""" + """Return all projects as a single FeatureCollection. + + This endpoint is used by disaster.ninja. + """ return await project_crud.get_projects_featcol(db, bbox) @router.get("/", response_model=list[project_schemas.ProjectOut]) async def read_projects( + current_user: Annotated[AuthUser, Depends(login_required)], db: Annotated[Connection, Depends(db_conn)], user_id: int = None, skip: int = 0, @@ -106,7 +112,9 @@ async def read_projects( @router.post("/me", response_model=list[DbProject]) -async def get_projects_for_user(user_id: int): +async def get_projects_for_user( + current_user: Annotated[AuthUser, Depends(login_required)], +): """Get all projects the user is author of. TODO to be implemented in future. @@ -115,7 +123,11 @@ async def get_projects_for_user(user_id: int): @router.post("/near_me", response_model=list[project_schemas.ProjectSummary]) -async def get_tasks_near_me(lat: float, long: float, user_id: int = None): +async def get_tasks_near_me( + lat: float, + long: float, + current_user: Annotated[AuthUser, Depends(login_required)], +): """Get projects near me. TODO to be implemented in future. @@ -125,6 +137,7 @@ async def get_tasks_near_me(lat: float, long: float, user_id: int = None): @router.get("/summaries", response_model=project_schemas.PaginatedProjectSummaries) async def read_project_summaries( + current_user: Annotated[AuthUser, Depends(login_required)], db: Annotated[Connection, Depends(db_conn)], page: int = Query(1, ge=1), # Default to page 1, must be greater than or equal to 1 results_per_page: int = Query(13, le=100), @@ -142,6 +155,7 @@ async def read_project_summaries( response_model=project_schemas.PaginatedProjectSummaries, ) async def search_project( + current_user: Annotated[AuthUser, Depends(login_required)], db: Annotated[Connection, Depends(db_conn)], search: str, page: int = Query(1, ge=1), # Default to page 1, must be greater than or equal to 1 @@ -159,7 +173,7 @@ async def search_project( "/{project_id}/entities", response_model=central_schemas.EntityFeatureCollection ) async def get_odk_entities_geojson( - project: Annotated[DbProject, Depends(project_deps.get_project)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], minimal: bool = False, ): """Get the ODK entities for a project in GeoJSON format. @@ -168,6 +182,7 @@ async def get_odk_entities_geojson( Rendering multiple GeoJSONs if inefficient. This is done by the flatgeobuf by filtering the task area bbox. """ + project = project_user.get("project") return await central_crud.get_entities_geojson( project.odk_credentials, project.odkid, @@ -180,11 +195,11 @@ async def get_odk_entities_geojson( response_model=list[central_schemas.EntityMappingStatus], ) async def get_odk_entities_mapping_statuses( - project_id: int, - project: Annotated[DbProject, Depends(project_deps.get_project)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], db: Annotated[Connection, Depends(db_conn)], ): """Get the ODK entities mapping statuses, i.e. in progress or complete.""" + project = project_user.get("project") entities = await central_crud.get_entities_data( project.odk_credentials, project.odkid, @@ -192,7 +207,7 @@ async def get_odk_entities_mapping_statuses( # First update the Entity statuses in the db # FIXME this is a hack and in the long run should be replaced # https://github.com/hotosm/fmtm/issues/1841 - await DbOdkEntities.upsert(db, project_id, entities) + await DbOdkEntities.upsert(db, project.id, entities) return entities @@ -201,7 +216,7 @@ async def get_odk_entities_mapping_statuses( response_model=list[central_schemas.EntityOsmID], ) async def get_odk_entities_osm_ids( - project: Annotated[DbProject, Depends(project_deps.get_project)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], ): """Get the ODK entities linked OSM IDs. @@ -209,6 +224,7 @@ async def get_odk_entities_osm_ids( when generated via raw-data-api. We need to link Entity UUIDs to OSM/Feature IDs. """ + project = project_user.get("project") return await central_crud.get_entities_data( project.odk_credentials, project.odkid, @@ -221,9 +237,10 @@ async def get_odk_entities_osm_ids( response_model=list[central_schemas.EntityTaskID], ) async def get_odk_entities_task_ids( - project: Annotated[DbProject, Depends(project_deps.get_project)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], ): """Get the ODK entities linked FMTM Task IDs.""" + project = project_user.get("project") return await central_crud.get_entities_data( project.odk_credentials, project.odkid, @@ -237,10 +254,11 @@ async def get_odk_entities_task_ids( ) async def get_odk_entity_mapping_status( entity_id: str, - project: Annotated[DbProject, Depends(project_deps.get_project)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], db: Annotated[Connection, Depends(db_conn)], ): """Get the ODK entity mapping status, i.e. in progress or complete.""" + project = project_user.get("project") return await central_crud.get_entity_mapping_status( project.odk_credentials, project.odkid, @@ -253,8 +271,8 @@ async def get_odk_entity_mapping_status( response_model=central_schemas.EntityMappingStatus, ) async def set_odk_entities_mapping_status( + project_user: Annotated[ProjectUserDict, Depends(mapper)], entity_details: central_schemas.EntityMappingStatusIn, - project: Annotated[DbProject, Depends(project_deps.get_project)], db: Annotated[Connection, Depends(db_conn)], ): """Set the ODK entities mapping status, i.e. in progress or complete. @@ -266,6 +284,7 @@ async def set_odk_entities_mapping_status( "status": 0 } """ + project = project_user.get("project") return await central_crud.update_entity_mapping_status( project.odk_credentials, project.odkid, @@ -334,11 +353,197 @@ async def download_tiles( ) +@router.get("/{project_id}", response_model=project_schemas.ProjectOut) +async def read_project( + project_user: Annotated[ProjectUserDict, Depends(mapper)], +): + """Get a specific project by ID.""" + return project_user.get("project") + + +@router.get("/categories/") +async def get_categories(current_user: Annotated[AuthUser, Depends(login_required)]): + """Get api for fetching all the categories. + + This endpoint fetches all the categories from osm_fieldwork. + + ## Response + - Returns a JSON object containing a list of categories and their respoective forms. + + """ + # FIXME update to use osm-rawdata + categories = ( + getChoices() + ) # categories are fetched from osm_fieldwork.make_data_extracts.getChoices() + return categories + + +@router.get("/download-form/{project_id}/") +async def download_form( + project_user: Annotated[ProjectUserDict, Depends(mapper)], +): + """Download the XLSForm for a project.""" + project = project_user.get("project") + + headers = { + "Content-Disposition": f"attachment; filename={project.id}_xlsform.xlsx", + "Content-Type": "application/media", + } + return Response(content=project.xlsform_content, headers=headers) + + +@router.get("/{project_id}/download") +async def download_project_boundary( + project_user: Annotated[ProjectUserDict, Depends(mapper)], +) -> StreamingResponse: + """Downloads the boundary of a project as a GeoJSON file.""" + project = project_user.get("project") + geojson = json.dumps(project.outline).encode("utf-8") + return StreamingResponse( + BytesIO(geojson), + headers={ + "Content-Disposition": (f"attachment; filename={project.slug}.geojson"), + "Content-Type": "application/media", + }, + ) + + +@router.get("/{project_id}/download_tasks") +async def download_task_boundaries( + project_id: int, + db: Annotated[Connection, Depends(db_conn)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], +): + """Downloads the boundary of the tasks for a project as a GeoJSON file. + + Args: + project_id (int): Project ID path param. + db (Connection): The database connection. + project_user (ProjectUserDict): Check if user has MAPPER permission. + + Returns: + Response: The HTTP response object containing the downloaded file. + """ + project_id = project_user.get("project").id + out = await project_crud.get_task_geometry(db, project_id) + + headers = { + "Content-Disposition": "attachment; filename=project_outline.geojson", + "Content-Type": "application/media", + } + + return Response(content=out, headers=headers) + + +@router.get("/features/download/") +async def download_features( + db: Annotated[Connection, Depends(db_conn)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], + task_id: Optional[int] = None, +): + """Downloads the features of a project as a GeoJSON file. + + Can generate a geojson for the entire project, or specific task areas. + """ + project = project_user.get("project") + feature_collection = await project_crud.get_project_features_geojson( + db, project, task_id + ) + + headers = { + "Content-Disposition": ( + f"attachment; filename=fmtm_project_{project.id}_features.geojson" + ), + "Content-Type": "application/media", + } + + return Response(content=json.dumps(feature_collection), headers=headers) + + +@router.get("/convert-fgb-to-geojson/") +async def convert_fgb_to_geojson( + url: str, + db: Annotated[Connection, Depends(db_conn)], + current_user: Annotated[AuthUser, Depends(login_required)], +): + """Convert flatgeobuf to GeoJSON format, extracting GeometryCollection. + + Helper endpoint to test data extracts during project creation. + Required as the flatgeobuf files wrapped in GeometryCollection + cannot be read in QGIS or other tools. + + Args: + url (str): URL to the flatgeobuf file. + db (Connection): The database connection. + current_user (AuthUser): Check if user is logged in. + + Returns: + Response: The HTTP response object containing the downloaded file. + """ + with requests.get(url) as response: + if not response.ok: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Download failed for data extract", + ) + data_extract_geojson = await flatgeobuf_to_featcol(db, response.content) + + if not data_extract_geojson: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=("Failed to convert flatgeobuf --> geojson"), + ) + + headers = { + "Content-Disposition": ("attachment; filename=fmtm_data_extract.geojson"), + "Content-Type": "application/media", + } + + return Response(content=json.dumps(data_extract_geojson), headers=headers) + + +@router.get( + "/task-status/{bg_task_id}", + response_model=project_schemas.BackgroundTaskStatus, +) +async def get_task_status( + bg_task_id: str, + db: Annotated[Connection, Depends(db_conn)], + current_user: Annotated[AuthUser, Depends(login_required)], +): + """Get the background task status by passing the task ID.""" + try: + return await DbBackgroundTask.one(db, bg_task_id) + except KeyError as e: + log.warning(str(e)) + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) from e + + +@router.get("/contributors/{project_id}") +async def get_contributors( + db: Annotated[Connection, Depends(db_conn)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], +): + """Get contributors of a project. + + TODO use a pydantic model for return type + """ + project = project_user.get("project") + return await project_crud.get_project_users_plus_contributions(db, project.id) + + +################## +# MANAGER ROUTES # +################## + + @router.post("/{project_id}/tiles-generate") async def generate_project_basemap( + # NOTE we do not set the correct role on this endpoint yet + # FIXME once sub project creation implemented, this should be manager only + project_user: Annotated[ProjectUserDict, Depends(mapper)], background_tasks: BackgroundTasks, db: Annotated[Connection, Depends(db_conn)], - project_user: Annotated[ProjectUserDict, Depends(mapper)], basemap_in: project_schemas.BasemapGenerate, ): """Returns basemap tiles for a project.""" @@ -370,86 +575,27 @@ async def generate_project_basemap( return {"Message": "Tile generation started"} -@router.get("/{project_id}", response_model=project_schemas.ProjectOut) -async def read_project( - project_user: Annotated[ProjectUserDict, Depends(mapper)], -): - """Get a specific project by ID.""" - return project_user.get("project") - - -@router.delete("/{project_id}") -async def delete_project( - db: Annotated[Connection, Depends(db_conn)], - project: Annotated[DbProject, Depends(project_deps.get_project)], - org_user_dict: Annotated[OrgUserDict, Depends(org_admin)], +@router.post("/task-split") +async def task_split( + # NOTE we do not set any roles on this endpoint yet + # FIXME once sub project creation implemented, this should be manager only + current_user: Annotated[AuthUser, Depends(login_required)], + project_geojson: UploadFile = File(...), + extract_geojson: Optional[UploadFile] = File(None), + no_of_buildings: int = Form(50), ): - """Delete a project from both ODK Central and the local database.""" - log.info( - f"User {org_user_dict.get('user').username} attempting " - f"deletion of project {project.id}" - ) - # Delete ODK Central project - await central_crud.delete_odk_project(project.odkid, project.odk_credentials) - # Delete S3 resources - await delete_all_objs_under_prefix( - settings.S3_BUCKET_NAME, f"/{project.organisation_id}/{project.id}/" - ) - # Delete FMTM project - await DbProject.delete(db, project.id) + """Split a task into subtasks. - log.info(f"Deletion of project {project.id} successful") - return Response(status_code=HTTPStatus.NO_CONTENT) + NOTE we pass a connection - -@router.post("/{project_id}/upload-task-boundaries") -async def upload_project_task_boundaries( - project_id: int, - db: Annotated[Connection, Depends(db_conn)], - org_user_dict: Annotated[OrgUserDict, Depends(org_admin)], - task_geojson: UploadFile = File(...), -): - """Set project task boundaries using split GeoJSON from frontend. - - Each polygon in the uploaded geojson are made into single task. - - Required Parameters: - project_id (id): ID for associated project. - task_geojson (UploadFile): Multi-polygon GeoJSON file. - - Returns: - JSONResponse: JSON containing success message. - """ - tasks_featcol = parse_geojson_file_to_featcol(await task_geojson.read()) - await check_crs(tasks_featcol) - # We only want to allow polygon geometries - featcol_single_geom_type = featcol_keep_single_geom_type( - tasks_featcol, - geom_type="Polygon", - ) - success = await DbTask.create(db, project_id, featcol_single_geom_type) - if not success: - return JSONResponse(content={"message": "failure"}) - return JSONResponse(content={"message": "success"}) - - -@router.post("/task-split") -async def task_split( - project_geojson: UploadFile = File(...), - extract_geojson: Optional[UploadFile] = File(None), - no_of_buildings: int = Form(50), -): - """Split a task into subtasks. - - NOTE we pass a connection - - Args: - project_geojson (UploadFile): The geojson (AOI) to split. - extract_geojson (UploadFile, optional): Custom data extract geojson - containing osm features (should be a FeatureCollection). - If not included, an extract is generated automatically. - no_of_buildings (int, optional): The number of buildings per subtask. - Defaults to 50. + Args: + current_user (AuthUser): the currently logged in user. + project_geojson (UploadFile): The geojson (AOI) to split. + extract_geojson (UploadFile, optional): Custom data extract geojson + containing osm features (should be a FeatureCollection). + If not included, an extract is generated automatically. + no_of_buildings (int, optional): The number of buildings per subtask. + Defaults to 50. Returns: The result of splitting the task into subtasks. @@ -485,6 +631,9 @@ async def task_split( @router.post("/validate-form") async def validate_form( + # NOTE we do not set any roles on this endpoint yet + # FIXME once sub project creation implemented, this should be manager only + current_user: Annotated[AuthUser, Depends(login_required)], xlsform: Annotated[BytesIO, Depends(central_deps.read_xlsform)], debug: bool = False, ): @@ -517,6 +666,85 @@ async def validate_form( ) +@router.post("/preview-split-by-square/", response_model=FeatureCollection) +async def preview_split_by_square( + # NOTE we do not set any roles on this endpoint yet + # FIXME once sub project creation implemented, this should be manager only + current_user: Annotated[AuthUser, Depends(login_required)], + project_geojson: UploadFile = File(...), + extract_geojson: Optional[UploadFile] = File(None), + dimension_meters: int = Form(100), +): + """Preview splitting by square. + + TODO update to use a response_model + """ + # Validating for .geojson File. + file_name = os.path.splitext(project_geojson.filename) + file_ext = file_name[1] + allowed_extensions = [".geojson", ".json"] + if file_ext not in allowed_extensions: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + detail="Provide a valid .geojson file", + ) + + # read entire file + boundary_featcol = parse_geojson_file_to_featcol(await project_geojson.read()) + + # Validatiing Coordinate Reference System + await check_crs(boundary_featcol) + parsed_extract = None + if extract_geojson: + parsed_extract = parse_geojson_file_to_featcol(await extract_geojson.read()) + if parsed_extract: + await check_crs(parsed_extract) + else: + log.warning("Parsed geojson file contained no geometries") + + if len(boundary_featcol["features"]) == 0: + boundary_featcol = merge_polygons(boundary_featcol) + + return split_by_square( + boundary_featcol, + osm_extract=parsed_extract, + meters=dimension_meters, + ) + + +@router.post("/generate-data-extract/") +async def get_data_extract( + # config_file: Optional[str] = Form(None), + # NOTE we do not set any roles on this endpoint yet + # FIXME once sub project creation implemented, this should be manager only + current_user: Annotated[AuthUser, Depends(login_required)], + geojson_file: UploadFile = File(...), + form_category: Optional[XLSFormType] = Form(None), +): + """Get a new data extract for a given project AOI. + + TODO allow config file (YAML/JSON) upload for data extract generation + TODO alternatively, direct to raw-data-api to generate first, then upload + """ + boundary_geojson = json.loads(await geojson_file.read()) + + # Get extract config file from existing data_models + if form_category: + config_filename = XLSFormType(form_category).name + data_model = f"{data_models_path}/{config_filename}.yaml" + with open(data_model, "rb") as data_model_yaml: + extract_config = BytesIO(data_model_yaml.read()) + else: + extract_config = None + + fgb_url = await project_crud.generate_data_extract( + boundary_geojson, + extract_config, + ) + + return JSONResponse(status_code=HTTPStatus.OK, content={"url": fgb_url}) + + @router.post("/{project_id}/generate-project-data") async def generate_files( db: Annotated[Connection, Depends(db_conn)], @@ -538,11 +766,6 @@ async def generate_files( provided by the user to the xform, generates osm data extracts and uploads it to the form. - TODO this requires org_admin permission. - We should refactor to create a project as a stub. - Then move most logic to another endpoint to edit an existing project. - The edit project endpoint can have project manager permissions. - Args: xlsform_upload (UploadFile, optional): A custom XLSForm to use in the project. A file should be provided if user wants to upload a custom xls form. @@ -631,136 +854,6 @@ async def generate_files( ) -@router.post("/{project_id}/additional-entity") -async def add_additional_entity_list( - db: Annotated[Connection, Depends(db_conn)], - project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], - geojson: UploadFile = File(...), -): - """Add an additional Entity list for the project in ODK. - - Note that the Entity list will be named from the filename - of the GeoJSON uploaded. - """ - project = project_user_dict.get("project") - project_id = project.id - project_odk_id = project.odkid - project_odk_creds = project.odk_credentials - # NOTE the Entity name is extracted from the filename (without extension) - entity_name = Path(geojson.filename).stem - - # Parse geojson + divide by task - # (not technically required, but also appends properties in correct format) - featcol = parse_geojson_file_to_featcol(await geojson.read()) - properties = list(featcol.get("features")[0].get("properties").keys()) - feature_split_by_task = await split_geojson_by_task_areas(db, featcol, project_id) - entities_list = await central_crud.task_geojson_dict_to_entity_values( - feature_split_by_task - ) - dataset_name = entity_name.replace(" ", "_") - - await central_crud.create_entity_list( - project_odk_creds, - project_odk_id, - properties=properties, - dataset_name=dataset_name, - entities_list=entities_list, - ) - - return Response(status_code=HTTPStatus.OK) - - -@router.get("/categories/") -async def get_categories(current_user: Annotated[AuthUser, Depends(login_required)]): - """Get api for fetching all the categories. - - This endpoint fetches all the categories from osm_fieldwork. - - ## Response - - Returns a JSON object containing a list of categories and their respoective forms. - - """ - # FIXME update to use osm-rawdata - categories = ( - getChoices() - ) # categories are fetched from osm_fieldwork.make_data_extracts.getChoices() - return categories - - -@router.post("/preview-split-by-square/", response_model=FeatureCollection) -async def preview_split_by_square( - project_geojson: UploadFile = File(...), - extract_geojson: Optional[UploadFile] = File(None), - dimension_meters: int = Form(100), -): - """Preview splitting by square. - - TODO update to use a response_model - """ - # Validating for .geojson File. - file_name = os.path.splitext(project_geojson.filename) - file_ext = file_name[1] - allowed_extensions = [".geojson", ".json"] - if file_ext not in allowed_extensions: - raise HTTPException( - HTTPStatus.BAD_REQUEST, - detail="Provide a valid .geojson file", - ) - - # read entire file - boundary_featcol = parse_geojson_file_to_featcol(await project_geojson.read()) - - # Validatiing Coordinate Reference System - await check_crs(boundary_featcol) - parsed_extract = None - if extract_geojson: - parsed_extract = parse_geojson_file_to_featcol(await extract_geojson.read()) - if parsed_extract: - await check_crs(parsed_extract) - else: - log.warning("Parsed geojson file contained no geometries") - - if len(boundary_featcol["features"]) == 0: - boundary_featcol = merge_polygons(boundary_featcol) - - return split_by_square( - boundary_featcol, - osm_extract=parsed_extract, - meters=dimension_meters, - ) - - -@router.post("/generate-data-extract/") -async def get_data_extract( - # config_file: Optional[str] = Form(None), - current_user: Annotated[AuthUser, Depends(login_required)], - geojson_file: UploadFile = File(...), - form_category: Optional[XLSFormType] = Form(None), -): - """Get a new data extract for a given project AOI. - - TODO allow config file (YAML/JSON) upload for data extract generation - TODO alternatively, direct to raw-data-api to generate first, then upload - """ - boundary_geojson = json.loads(await geojson_file.read()) - - # Get extract config file from existing data_models - if form_category: - config_filename = XLSFormType(form_category).name - data_model = f"{data_models_path}/{config_filename}.yaml" - with open(data_model, "rb") as data_model_yaml: - extract_config = BytesIO(data_model_yaml.read()) - else: - extract_config = None - - fgb_url = await project_crud.generate_data_extract( - boundary_geojson, - extract_config, - ) - - return JSONResponse(status_code=HTTPStatus.OK, content={"url": fgb_url}) - - @router.get("/data-extract-url/") async def get_or_set_data_extract( db: Annotated[Connection, Depends(db_conn)], @@ -828,18 +921,72 @@ async def upload_custom_extract( return JSONResponse(status_code=HTTPStatus.OK, content={"url": fgb_url}) -@router.get("/download-form/{project_id}/") -async def download_form( - project_user: Annotated[ProjectUserDict, Depends(mapper)], +@router.post("/{project_id}/additional-entity") +async def add_additional_entity_list( + db: Annotated[Connection, Depends(db_conn)], + project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], + geojson: UploadFile = File(...), ): - """Download the XLSForm for a project.""" - project = project_user.get("project") + """Add an additional Entity list for the project in ODK. - headers = { - "Content-Disposition": f"attachment; filename={project.id}_xlsform.xlsx", - "Content-Type": "application/media", - } - return Response(content=project.xlsform_content, headers=headers) + Note that the Entity list will be named from the filename + of the GeoJSON uploaded. + """ + project = project_user_dict.get("project") + project_id = project.id + project_odk_id = project.odkid + project_odk_creds = project.odk_credentials + # NOTE the Entity name is extracted from the filename (without extension) + entity_name = Path(geojson.filename).stem + + # Parse geojson + divide by task + # (not technically required, but also appends properties in correct format) + featcol = parse_geojson_file_to_featcol(await geojson.read()) + properties = list(featcol.get("features")[0].get("properties").keys()) + feature_split_by_task = await split_geojson_by_task_areas(db, featcol, project_id) + entities_list = await central_crud.task_geojson_dict_to_entity_values( + feature_split_by_task + ) + dataset_name = entity_name.replace(" ", "_") + + await central_crud.create_entity_list( + project_odk_creds, + project_odk_id, + properties=properties, + dataset_name=dataset_name, + entities_list=entities_list, + ) + + return Response(status_code=HTTPStatus.OK) + + +@router.post("/add-manager/") +async def add_new_project_manager( + db: Annotated[Connection, Depends(db_conn)], + project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], +): + """Add a new project manager. + + The logged in user must be either the admin of the organisation or a super admin. + """ + await DbUserRole.create( + db, + project_user_dict["project"].id, + project_user_dict["user"].id, + ProjectRole.PROJECT_MANAGER, + ) + return Response(status_code=HTTPStatus.OK) + + +@router.patch("/{project_id}", response_model=project_schemas.ProjectOut) +async def update_project( + new_data: project_schemas.ProjectUpdate, + project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], + db: Annotated[Connection, Depends(db_conn)], +): + """Partial update an existing project.""" + # NOTE this does not including updating the ODK project name + return await DbProject.update(db, project_user_dict.get("project").id, new_data) @router.post("/update-form") @@ -899,161 +1046,40 @@ async def update_project_form( ) -@router.get("/{project_id}/download") -async def download_project_boundary( - project_user: Annotated[ProjectUserDict, Depends(mapper)], -) -> StreamingResponse: - """Downloads the boundary of a project as a GeoJSON file.""" - project = project_user.get("project") - geojson = json.dumps(project.outline).encode("utf-8") - return StreamingResponse( - BytesIO(geojson), - headers={ - "Content-Disposition": (f"attachment; filename={project.slug}.geojson"), - "Content-Type": "application/media", - }, - ) - - -@router.get("/{project_id}/download_tasks") -async def download_task_boundaries( +@router.post("/{project_id}/upload-task-boundaries") +async def upload_project_task_boundaries( project_id: int, db: Annotated[Connection, Depends(db_conn)], - project_user: Annotated[ProjectUserDict, Depends(mapper)], -): - """Downloads the boundary of the tasks for a project as a GeoJSON file. - - Args: - project_id (int): Project ID path param. - db (Connection): The database connection. - project_user (ProjectUserDict): Check if user has MAPPER permission. - - Returns: - Response: The HTTP response object containing the downloaded file. - """ - project_id = project_user.get("project").id - out = await project_crud.get_task_geometry(db, project_id) - - headers = { - "Content-Disposition": "attachment; filename=project_outline.geojson", - "Content-Type": "application/media", - } - - return Response(content=out, headers=headers) - - -@router.get("/features/download/") -async def download_features( - db: Annotated[Connection, Depends(db_conn)], - project_user: Annotated[ProjectUserDict, Depends(mapper)], - task_id: Optional[int] = None, -): - """Downloads the features of a project as a GeoJSON file. - - Can generate a geojson for the entire project, or specific task areas. - """ - project = project_user.get("project") - feature_collection = await project_crud.get_project_features_geojson( - db, project, task_id - ) - - headers = { - "Content-Disposition": ( - f"attachment; filename=fmtm_project_{project.id}_features.geojson" - ), - "Content-Type": "application/media", - } - - return Response(content=json.dumps(feature_collection), headers=headers) - - -@router.get("/convert-fgb-to-geojson/") -async def convert_fgb_to_geojson( - url: str, - db: Annotated[Connection, Depends(db_conn)], - current_user: Annotated[AuthUser, Depends(login_required)], + project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], + task_geojson: UploadFile = File(...), ): - """Convert flatgeobuf to GeoJSON format, extracting GeometryCollection. + """Set project task boundaries using split GeoJSON from frontend. - Helper endpoint to test data extracts during project creation. - Required as the flatgeobuf files wrapped in GeometryCollection - cannot be read in QGIS or other tools. + Each polygon in the uploaded geojson are made into single task. - Args: - url (str): URL to the flatgeobuf file. - db (Connection): The database connection. - current_user (AuthUser): Check if user is logged in. + Required Parameters: + project_id (id): ID for associated project. + task_geojson (UploadFile): Multi-polygon GeoJSON file. Returns: - Response: The HTTP response object containing the downloaded file. - """ - with requests.get(url) as response: - if not response.ok: - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail="Download failed for data extract", - ) - data_extract_geojson = await flatgeobuf_to_featcol(db, response.content) - - if not data_extract_geojson: - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail=("Failed to convert flatgeobuf --> geojson"), - ) - - headers = { - "Content-Disposition": ("attachment; filename=fmtm_data_extract.geojson"), - "Content-Type": "application/media", - } - - return Response(content=json.dumps(data_extract_geojson), headers=headers) - - -@router.get( - "/task-status/{bg_task_id}", - response_model=project_schemas.BackgroundTaskStatus, -) -async def get_task_status( - bg_task_id: str, - db: Annotated[Connection, Depends(db_conn)], -): - """Get the background task status by passing the task ID.""" - try: - return await DbBackgroundTask.one(db, bg_task_id) - except KeyError as e: - log.warning(str(e)) - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) from e - - -@router.get("/contributors/{project_id}") -async def get_contributors( - db: Annotated[Connection, Depends(db_conn)], - project_user: Annotated[ProjectUserDict, Depends(mapper)], -): - """Get contributors of a project. - - TODO use a pydantic model for return type + JSONResponse: JSON containing success message. """ - project = project_user.get("project") - return await project_crud.get_project_users_plus_contributions(db, project.id) + tasks_featcol = parse_geojson_file_to_featcol(await task_geojson.read()) + await check_crs(tasks_featcol) + # We only want to allow polygon geometries + featcol_single_geom_type = featcol_keep_single_geom_type( + tasks_featcol, + geom_type="Polygon", + ) + success = await DbTask.create(db, project_id, featcol_single_geom_type) + if not success: + return JSONResponse(content={"message": "failure"}) + return JSONResponse(content={"message": "success"}) -@router.post("/add-manager/") -async def add_new_project_manager( - db: Annotated[Connection, Depends(db_conn)], - project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], -): - """Add a new project manager. - - The logged in user must be either the admin of the organisation or a super admin. - """ - await DbUserRole.create( - db, - project_user_dict["project"].id, - project_user_dict["user"].id, - ProjectRole.PROJECT_MANAGER, - ) - return Response(status_code=HTTPStatus.OK) +#################### +# ORG ADMIN ROUTES # +#################### @router.post("/", response_model=project_schemas.ProjectOut) @@ -1115,12 +1141,25 @@ async def create_project( return project -@router.patch("/{project_id}", response_model=project_schemas.ProjectOut) -async def update_project( - new_data: project_schemas.ProjectUpdate, - org_user_dict: Annotated[AuthUser, Depends(org_admin)], +@router.delete("/{project_id}") +async def delete_project( db: Annotated[Connection, Depends(db_conn)], + project: Annotated[DbProject, Depends(project_deps.get_project)], + org_user_dict: Annotated[OrgUserDict, Depends(org_admin)], ): - """Partial update an existing project.""" - # NOTE this does not including updating the ODK project name - return await DbProject.update(db, org_user_dict.get("project").id, new_data) + """Delete a project from both ODK Central and the local database.""" + log.info( + f"User {org_user_dict.get('user').username} attempting " + f"deletion of project {project.id}" + ) + # Delete ODK Central project + await central_crud.delete_odk_project(project.odkid, project.odk_credentials) + # Delete S3 resources + await delete_all_objs_under_prefix( + settings.S3_BUCKET_NAME, f"/{project.organisation_id}/{project.id}/" + ) + # Delete FMTM project + await DbProject.delete(db, project.id) + + log.info(f"Deletion of project {project.id} successful") + return Response(status_code=HTTPStatus.NO_CONTENT)