diff --git a/.env.example b/.env.example index 359ea6237c..6ca0f86dd2 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ OSM_CLIENT_ID=${OSM_CLIENT_ID} OSM_CLIENT_SECRET=${OSM_CLIENT_SECRET} OSM_URL=${OSM_URL:-"https://www.openstreetmap.org"} OSM_SCOPE=${OSM_SCOPE:-'["read_prefs", "send_messages"]'} -OSM_LOGIN_REDIRECT_URI="http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth/" +OSM_LOGIN_REDIRECT_URI="http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth" OSM_SECRET_KEY=${OSM_SECRET_KEY} ### S3 File Storage ### diff --git a/INSTALL.md b/INSTALL.md index a98b133d02..daafad9544 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -99,8 +99,8 @@ To properly configure your FMTM project, you will need to create keys for OSM. 2. Register your FMTM instance to OAuth 2 applications. - Put your login redirect url as `http://127.0.0.1:7051/osmauth/` if running locally, - or for production replace with https://{YOUR_DOMAIN}/osmauth/ + Put your login redirect url as `http://127.0.0.1:7051/osmauth` if running locally, + or for production replace with https://{YOUR_DOMAIN}/osmauth > Note: `127.0.0.1` is required for debugging instead of `localhost` > due to OSM restrictions. diff --git a/scripts/gen-env.sh b/scripts/gen-env.sh index 1fbc35b874..e059f16c0f 100644 --- a/scripts/gen-env.sh +++ b/scripts/gen-env.sh @@ -305,7 +305,7 @@ set_domains() { set_osm_credentials() { pretty_echo "OSM OAuth2 Credentials" - redirect_uri="http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth/" + redirect_uri="http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth" echo "App credentials are generated from your OSM user profile." echo diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index 19463286cc..122afcc2a6 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -49,7 +49,7 @@ ) -@router.get("/osm-login/") +@router.get("/osm-login") async def login_url(osm_auth=Depends(init_osm_auth)): """Get Login URL for OSM Oauth Application. @@ -69,7 +69,7 @@ async def login_url(osm_auth=Depends(init_osm_auth)): return JSONResponse(content=login_url, status_code=HTTPStatus.OK) -@router.get("/callback/") +@router.get("/callback") async def callback( request: Request, osm_auth: Annotated[AuthUser, Depends(init_osm_auth)] ) -> JSONResponse: @@ -135,7 +135,7 @@ async def callback( ) from e -@router.get("/logout/") +@router.get("/logout") async def logout(): """Reset httpOnly cookie to sign out user.""" response = Response(status_code=HTTPStatus.OK) @@ -178,15 +178,21 @@ async def get_or_create_user( profile_img = EXCLUDED.profile_img RETURNING id, username, profile_img, role ) + SELECT u.id, u.username, u.profile_img, u.role, + + -- Aggregate the organisation IDs managed by the user array_agg( DISTINCT om.organisation_id - ) FILTER (WHERE om.organisation_id IS NOT NULL) as orgs_managed, + ) FILTER (WHERE om.organisation_id IS NOT NULL) AS orgs_managed, + + -- Aggregate project roles for the user, as project:role pairs jsonb_object_agg( ur.project_id, COALESCE(ur.role, 'MAPPER') - ) FILTER (WHERE ur.project_id IS NOT NULL) as project_roles + ) FILTER (WHERE ur.project_id IS NOT NULL) AS project_roles + FROM upserted_user u LEFT JOIN user_roles ur ON u.id = ur.user_id LEFT JOIN organisation_managers om ON u.id = om.user_id @@ -229,7 +235,7 @@ async def get_or_create_user( ) from e -@router.get("/me/", response_model=FMTMUser) +@router.get("/me", response_model=FMTMUser) async def my_data( db: Annotated[Connection, Depends(db_conn)], current_user: Annotated[AuthUser, Depends(login_required)], diff --git a/src/backend/app/auth/auth_schemas.py b/src/backend/app/auth/auth_schemas.py index 6d380fc403..52e1d5752d 100644 --- a/src/backend/app/auth/auth_schemas.py +++ b/src/backend/app/auth/auth_schemas.py @@ -16,10 +16,9 @@ # """Pydantic models for Auth.""" -from typing import Any, Optional, TypedDict +from typing import Optional, TypedDict from pydantic import BaseModel, ConfigDict, PrivateAttr, computed_field -from pydantic.functional_validators import field_validator from app.db.enums import ProjectRole, UserRole from app.db.models import DbOrganisation, DbProject, DbUser @@ -80,30 +79,5 @@ class FMTMUser(BaseModel): username: str profile_img: str role: UserRole - project_roles: Optional[dict[int, ProjectRole]] = {} - orgs_managed: Optional[list[int]] = [] - - @field_validator("role", mode="before") - @classmethod - def convert_user_role_str_to_ints(cls, role: Any) -> Optional[UserRole]: - """User role strings returned from db converted to enum integers.""" - if not role: - return None - if isinstance(role, str): - return UserRole[role] - return role - - @field_validator("project_roles", mode="before") - @classmethod - def convert_project_role_str_to_ints( - cls, roles: dict[int, Any] - ) -> Optional[dict[int, ProjectRole]]: - """User project strings returned from db converted to enum integers.""" - if not roles: - return {} - - first_value = next(iter(roles.values()), None) - if isinstance(first_value, str): - return {id: ProjectRole[role] for id, role in roles.items()} - - return roles + project_roles: Optional[dict[int, ProjectRole]] = None + orgs_managed: Optional[list[int]] = None diff --git a/src/backend/app/auth/roles.py b/src/backend/app/auth/roles.py index bd9d66a57e..74099db1eb 100644 --- a/src/backend/app/auth/roles.py +++ b/src/backend/app/auth/roles.py @@ -86,38 +86,59 @@ async def check_access( user_id = await get_uid(user) sql = """ + WITH role_hierarchy AS ( + SELECT 'MAPPER' AS role, 0 AS level + UNION ALL SELECT 'VALIDATOR', 1 + UNION ALL SELECT 'FIELD_MANAGER', 2 + UNION ALL SELECT 'ASSOCIATE_PROJECT_MANAGER', 3 + UNION ALL SELECT 'PROJECT_MANAGER', 4 + ) + SELECT * FROM users WHERE id = %(user_id)s AND ( CASE - WHEN role = 'ADMIN' THEN true - WHEN role = 'READ_ONLY' THEN false + -- Simple check to see if ADMIN or blocked (READ_ONLY) + WHEN role = 'ADMIN'::public.userrole THEN true + WHEN role = 'READ_ONLY'::public.userrole THEN false ELSE + + -- Check to see if user is org admin EXISTS ( SELECT 1 FROM organisation_managers WHERE organisation_managers.user_id = %(user_id)s - AND - organisation_managers.organisation_id = %(org_id)s + AND organisation_managers.organisation_id = %(org_id)s ) + + -- Check to see if user has equal or greater than project role OR EXISTS ( SELECT 1 FROM user_roles + JOIN role_hierarchy AS user_role_h + ON user_roles.role::public.projectrole + = user_role_h.role::public.projectrole + JOIN role_hierarchy AS required_role_h + ON %(role)s::public.projectrole + = required_role_h.role::public.projectrole WHERE user_roles.user_id = %(user_id)s - AND user_roles.project_id = %(project_id)s - AND user_roles.role >= %(role)s + AND user_roles.project_id = %(project_id)s + AND user_role_h.level >= required_role_h.level ) + + -- Extract organisation id from project, + -- then check to see if user is org admin OR ( %(org_id)s IS NULL AND EXISTS ( SELECT 1 FROM organisation_managers - JOIN projects ON - projects.organisation_id - = organisation_managers.organisation_id + JOIN projects + ON projects.organisation_id = + organisation_managers.organisation_id WHERE organisation_managers.user_id = %(user_id)s - AND projects.id = %(project_id)s + AND projects.id = %(project_id)s ) ) END diff --git a/src/backend/app/config.py b/src/backend/app/config.py index b1df2afdbf..1f9742429b 100644 --- a/src/backend/app/config.py +++ b/src/backend/app/config.py @@ -234,7 +234,7 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any: # https://github.com/openstreetmap/operations/issues/951#issuecomment-1748717154 OSM_URL: HttpUrlStr = "https://www.openstreetmap.org" OSM_SCOPE: list[str] = ["read_prefs", "send_messages"] - OSM_LOGIN_REDIRECT_URI: str = "http://127.0.0.1:7051/osmauth/" + OSM_LOGIN_REDIRECT_URI: str = "http://127.0.0.1:7051/osmauth" S3_ENDPOINT: str = "http://s3:9000" S3_ACCESS_KEY: Optional[str] = "" diff --git a/src/backend/app/db/database.py b/src/backend/app/db/database.py index 603f27a23e..f60573a7ff 100644 --- a/src/backend/app/db/database.py +++ b/src/backend/app/db/database.py @@ -57,7 +57,7 @@ async def db_conn(request: Request) -> Connection: To use the connection in endpoints: ----------------------------------- - @app.get("/something/") + @app.get("/something") async def do_stuff(db = Depends(db_conn)): async with db.cursor() as cursor: await cursor.execute("SELECT * FROM items") diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index fdabbabea9..6f074c116e 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -158,7 +158,7 @@ class DbUser(BaseModel): registered_at: Optional[AwareDatetime] = None # Relationships - project_roles: Optional[list[DbUserRole]] = None + project_roles: Optional[dict[int, ProjectRole]] = None # project:role pairs orgs_managed: Optional[list[int]] = None @classmethod @@ -168,13 +168,18 @@ async def one(cls, db: Connection, user_identifier: int | str) -> Self: sql = """ SELECT u.*, - array_agg( - DISTINCT om.organisation_id - ) FILTER (WHERE om.organisation_id IS NOT NULL) AS orgs_managed, - jsonb_object_agg( - ur.project_id, - COALESCE(ur.role, 'MAPPER') - ) FILTER (WHERE ur.project_id IS NOT NULL) AS project_roles + + -- Aggregate organisation IDs managed by the user + array_agg( + DISTINCT om.organisation_id + ) FILTER (WHERE om.organisation_id IS NOT NULL) AS orgs_managed, + + -- Aggregate project roles for the user, as project:role pairs + jsonb_object_agg( + ur.project_id, + COALESCE(ur.role, 'MAPPER') + ) FILTER (WHERE ur.project_id IS NOT NULL) AS project_roles + FROM users u LEFT JOIN user_roles ur ON u.id = ur.user_id LEFT JOIN organisation_managers om ON u.id = om.user_id diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 0958d992c9..c506867bec 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -121,6 +121,8 @@ def get_application() -> FastAPI: debug=settings.DEBUG, lifespan=lifespan, root_path=settings.API_PREFIX, + # NOTE REST APIs should not have trailing slashes + redirect_slashes=False, ) # Set custom logger diff --git a/src/backend/app/organisations/organisation_routes.py b/src/backend/app/organisations/organisation_routes.py index 748cdbd5dc..76a60f53ea 100644 --- a/src/backend/app/organisations/organisation_routes.py +++ b/src/backend/app/organisations/organisation_routes.py @@ -52,7 +52,7 @@ ) -@router.get("/", response_model=list[OrganisationOut]) +@router.get("", response_model=list[OrganisationOut]) async def get_organisations( db: Annotated[Connection, Depends(db_conn)], current_user: Annotated[AuthUser, Depends(login_required)], @@ -61,34 +61,7 @@ async def get_organisations( return await DbOrganisation.all(db, current_user.id) -@router.get("/my-organisations", response_model=list[OrganisationOut]) -async def get_my_organisations( - db: Annotated[Connection, Depends(db_conn)], - current_user: Annotated[AuthUser, Depends(login_required)], -) -> list[DbOrganisation]: - """Get a list of all organisations.""" - return await organisation_crud.get_my_organisations(db, current_user) - - -@router.get("/unapproved/", response_model=list[OrganisationOut]) -async def list_unapproved_organisations( - db: Annotated[Connection, Depends(db_conn)], - current_user: Annotated[AuthUser, Depends(login_required)], -) -> list[DbOrganisation]: - """Get a list of unapproved organisations.""" - return await DbOrganisation.unapproved(db) - - -@router.get("/{org_id}", response_model=OrganisationOut) -async def get_organisation_detail( - organisation: Annotated[DbOrganisation, Depends(org_exists)], - current_user: Annotated[AuthUser, Depends(login_required)], -): - """Get a specific organisation by id or name.""" - return organisation - - -@router.post("/", response_model=OrganisationOut) +@router.post("", response_model=OrganisationOut) async def create_organisation( db: Annotated[Connection, Depends(db_conn)], current_user: Annotated[AuthUser, Depends(login_required)], @@ -109,33 +82,22 @@ async def create_organisation( return await DbOrganisation.create(db, org_in, current_user.id, logo) -@router.patch("/{org_id}/", response_model=OrganisationOut) -async def update_organisation( +@router.get("/my-organisations", response_model=list[OrganisationOut]) +async def get_my_organisations( db: Annotated[Connection, Depends(db_conn)], - org_user_dict: Annotated[AuthUser, Depends(org_admin)], - new_values: OrganisationUpdate = Depends(parse_organisation_input), - logo: UploadFile = File(None), -): - """Partial update for an existing organisation.""" - org_id = org_user_dict.get("org").id - return await DbOrganisation.update(db, org_id, new_values, logo) + current_user: Annotated[AuthUser, Depends(login_required)], +) -> list[DbOrganisation]: + """Get a list of all organisations.""" + return await organisation_crud.get_my_organisations(db, current_user) -@router.delete("/{org_id}") -async def delete_org( +@router.get("/unapproved", response_model=list[OrganisationOut]) +async def list_unapproved_organisations( db: Annotated[Connection, Depends(db_conn)], - org_user_dict: Annotated[AuthUser, Depends(org_admin)], -): - """Delete an organisation.""" - org = org_user_dict.get("org") - org_deleted = await DbOrganisation.delete(db, org.id) - if not org_deleted: - log.error(f"Failed deleting org ({org.name}).") - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail=f"Failed deleting org ({org.name}).", - ) - return Response(status_code=HTTPStatus.NO_CONTENT) + current_user: Annotated[AuthUser, Depends(login_required)], +) -> list[DbOrganisation]: + """Get a list of unapproved organisations.""" + return await DbOrganisation.unapproved(db) @router.delete("/unapproved/{org_id}") @@ -164,7 +126,7 @@ async def delete_unapproved_org( return Response(status_code=HTTPStatus.NO_CONTENT) -@router.post("/approve/", response_model=OrganisationOut) +@router.post("/approve", response_model=OrganisationOut) async def approve_organisation( org_id: int, db: Annotated[Connection, Depends(db_conn)], @@ -189,7 +151,7 @@ async def approve_organisation( return approved_org -@router.post("/new-admin/") +@router.post("/new-admin") async def add_new_organisation_admin( db: Annotated[Connection, Depends(db_conn)], org_user_dict: Annotated[OrgUserDict, Depends(org_admin)], @@ -204,3 +166,41 @@ async def add_new_organisation_admin( org_user_dict.get("org").id, user_id, ) + + +@router.get("/{org_id}", response_model=OrganisationOut) +async def get_organisation_detail( + organisation: Annotated[DbOrganisation, Depends(org_exists)], + current_user: Annotated[AuthUser, Depends(login_required)], +): + """Get a specific organisation by id or name.""" + return organisation + + +@router.patch("/{org_id}", response_model=OrganisationOut) +async def update_organisation( + db: Annotated[Connection, Depends(db_conn)], + org_user_dict: Annotated[AuthUser, Depends(org_admin)], + new_values: OrganisationUpdate = Depends(parse_organisation_input), + logo: UploadFile = File(None), +): + """Partial update for an existing organisation.""" + org_id = org_user_dict.get("org").id + return await DbOrganisation.update(db, org_id, new_values, logo) + + +@router.delete("/{org_id}") +async def delete_org( + db: Annotated[Connection, Depends(db_conn)], + org_user_dict: Annotated[AuthUser, Depends(org_admin)], +): + """Delete an organisation.""" + org = org_user_dict.get("org") + org_deleted = await DbOrganisation.delete(db, org.id) + if not org_deleted: + log.error(f"Failed deleting org ({org.name}).") + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail=f"Failed deleting org ({org.name}).", + ) + return Response(status_code=HTTPStatus.NO_CONTENT) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index db829f316f..54ae0614bd 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -98,7 +98,7 @@ async def read_projects_to_featcol( return await project_crud.get_projects_featcol(db, bbox) -@router.get("/", response_model=list[project_schemas.ProjectOut]) +@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)], @@ -295,7 +295,7 @@ async def set_odk_entities_mapping_status( @router.get( - "/{project_id}/tiles/", + "/{project_id}/tiles", response_model=list[project_schemas.BasemapOut], ) async def tiles_list( @@ -316,7 +316,7 @@ async def tiles_list( @router.get( - "/{project_id}/tiles/{tile_id}/", + "/{project_id}/tiles/{tile_id}", response_model=project_schemas.BasemapOut, ) async def download_tiles( @@ -353,15 +353,7 @@ 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/") +@router.get("/categories") async def get_categories(current_user: Annotated[AuthUser, Depends(login_required)]): """Get api for fetching all the categories. @@ -378,7 +370,7 @@ async def get_categories(current_user: Annotated[AuthUser, Depends(login_require return categories -@router.get("/download-form/{project_id}/") +@router.get("/download-form/{project_id}") async def download_form( project_user: Annotated[ProjectUserDict, Depends(mapper)], ): @@ -392,50 +384,7 @@ async def download_form( 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/") +@router.get("/features/download") async def download_features( db: Annotated[Connection, Depends(db_conn)], project_user: Annotated[ProjectUserDict, Depends(mapper)], @@ -460,7 +409,7 @@ async def download_features( return Response(content=json.dumps(feature_collection), headers=headers) -@router.get("/convert-fgb-to-geojson/") +@router.get("/convert-fgb-to-geojson") async def convert_fgb_to_geojson( url: str, db: Annotated[Connection, Depends(db_conn)], @@ -537,44 +486,6 @@ async def get_contributors( ################## -@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)], - basemap_in: project_schemas.BasemapGenerate, -): - """Returns basemap tiles for a project.""" - project_id = project_user.get("project").id - - # Create task in db and return uuid - log.debug( - "Creating generate_project_basemap background task " - f"for project ID: {project_id}" - ) - background_task_id = await DbBackgroundTask.create( - db, - project_schemas.BackgroundTaskIn( - project_id=project_id, - name="generate_basemap", - ), - ) - - background_tasks.add_task( - project_crud.generate_project_basemap, - db, - project_id, - background_task_id, - basemap_in.tile_source, - basemap_in.file_format, - basemap_in.tms_url, - ) - - return {"Message": "Tile generation started"} - - @router.post("/task-split") async def task_split( # NOTE we do not set any roles on this endpoint yet @@ -666,7 +577,7 @@ async def validate_form( ) -@router.post("/preview-split-by-square/", response_model=FeatureCollection) +@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 @@ -712,7 +623,7 @@ async def preview_split_by_square( ) -@router.post("/generate-data-extract/") +@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 @@ -745,116 +656,7 @@ async def get_data_extract( 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)], - project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], - xlsform_upload: Annotated[ - Optional[BytesIO], Depends(central_deps.read_optional_xlsform) - ], - additional_entities: Optional[list[str]] = None, -): - """Generate additional content to initialise the project. - - Boundary, ODK Central forms, QR codes, etc. - - Accepts a project ID, category, custom form flag, and an uploaded file as inputs. - The generated files are associated with the project ID and stored in the database. - This api generates odk appuser tokens, forms. This api also creates an app user for - each task and provides the required roles. - Some of the other functionality of this api includes converting a xls file - provided by the user to the xform, generates osm data extracts and uploads - it to the form. - - 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. - additional_entities (list[str]): If additional Entity lists need to be - created (i.e. the project form references multiple geometries). - db (Connection): The database connection. - project_user_dict (ProjectUserDict): Project admin role. - - Returns: - json (JSONResponse): A success message containing the project ID. - """ - project = project_user_dict.get("project") - project_id = project.id - form_category = project.xform_category - - log.debug(f"Generating additional files for project: {project.id}") - - if xlsform_upload: - log.debug("User provided custom XLSForm") - - # Validate uploaded form - await central_crud.validate_and_update_user_xlsform( - xlsform=xlsform_upload, - form_category=form_category, - additional_entities=additional_entities, - ) - xlsform = xlsform_upload - - else: - log.debug(f"Using default XLSForm for category: '{form_category}'") - - form_filename = XLSFormType(form_category).name - xlsform_path = f"{xlsforms_path}/{form_filename}.xls" - with open(xlsform_path, "rb") as f: - xlsform = BytesIO(f.read()) - - xform_id, project_xlsform = await central_crud.append_fields_to_user_xlsform( - xlsform=xlsform, - form_category=form_category, - additional_entities=additional_entities, - ) - # Write XLS form content to db - xlsform_bytes = project_xlsform.getvalue() - if len(xlsform_bytes) == 0 or not xform_id: - raise HTTPException( - status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - detail="There was an error modifying the XLSForm!", - ) - log.debug(f"Setting project XLSForm db data for xFormId: {xform_id}") - sql = """ - UPDATE public.projects - SET - odk_form_id = %(odk_form_id)s, - xlsform_content = %(xlsform_content)s - WHERE id = %(project_id)s; - """ - async with db.cursor() as cur: - await cur.execute( - sql, - { - "project_id": project_id, - "odk_form_id": xform_id, - "xlsform_content": xlsform_bytes, - }, - ) - - success = await project_crud.generate_project_files( - db, - project_id, - ) - - if not success: - return JSONResponse( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - content={ - "message": ( - f"Failed project ({project_id}) creation. " - "Please contact the server admin." - ) - }, - ) - - return JSONResponse( - status_code=HTTPStatus.OK, - content={"message": "success"}, - ) - - -@router.get("/data-extract-url/") +@router.get("/data-extract-url") async def get_or_set_data_extract( db: Annotated[Connection, Depends(db_conn)], project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], @@ -870,7 +672,7 @@ async def get_or_set_data_extract( return JSONResponse(status_code=HTTPStatus.OK, content={"url": fgb_url}) -@router.post("/upload-custom-extract/") +@router.post("/upload-custom-extract") async def upload_custom_extract( db: Annotated[Connection, Depends(db_conn)], project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], @@ -921,46 +723,7 @@ async def upload_custom_extract( return JSONResponse(status_code=HTTPStatus.OK, content={"url": fgb_url}) -@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.post("/add-manager/") +@router.post("/add-manager") async def add_new_project_manager( db: Annotated[Connection, Depends(db_conn)], project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], @@ -978,17 +741,6 @@ async def add_new_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") async def update_project_form( xlsform: Annotated[BytesIO, Depends(central_deps.read_xlsform)], @@ -1046,6 +798,203 @@ async def update_project_form( ) +@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.post("/{project_id}/generate-project-data") +async def generate_files( + db: Annotated[Connection, Depends(db_conn)], + project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], + xlsform_upload: Annotated[ + Optional[BytesIO], Depends(central_deps.read_optional_xlsform) + ], + additional_entities: Optional[list[str]] = None, +): + """Generate additional content to initialise the project. + + Boundary, ODK Central forms, QR codes, etc. + + Accepts a project ID, category, custom form flag, and an uploaded file as inputs. + The generated files are associated with the project ID and stored in the database. + This api generates odk appuser tokens, forms. This api also creates an app user for + each task and provides the required roles. + Some of the other functionality of this api includes converting a xls file + provided by the user to the xform, generates osm data extracts and uploads + it to the form. + + 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. + additional_entities (list[str]): If additional Entity lists need to be + created (i.e. the project form references multiple geometries). + db (Connection): The database connection. + project_user_dict (ProjectUserDict): Project admin role. + + Returns: + json (JSONResponse): A success message containing the project ID. + """ + project = project_user_dict.get("project") + project_id = project.id + form_category = project.xform_category + + log.debug(f"Generating additional files for project: {project.id}") + + if xlsform_upload: + log.debug("User provided custom XLSForm") + + # Validate uploaded form + await central_crud.validate_and_update_user_xlsform( + xlsform=xlsform_upload, + form_category=form_category, + additional_entities=additional_entities, + ) + xlsform = xlsform_upload + + else: + log.debug(f"Using default XLSForm for category: '{form_category}'") + + form_filename = XLSFormType(form_category).name + xlsform_path = f"{xlsforms_path}/{form_filename}.xls" + with open(xlsform_path, "rb") as f: + xlsform = BytesIO(f.read()) + + xform_id, project_xlsform = await central_crud.append_fields_to_user_xlsform( + xlsform=xlsform, + form_category=form_category, + additional_entities=additional_entities, + ) + # Write XLS form content to db + xlsform_bytes = project_xlsform.getvalue() + if len(xlsform_bytes) == 0 or not xform_id: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="There was an error modifying the XLSForm!", + ) + log.debug(f"Setting project XLSForm db data for xFormId: {xform_id}") + sql = """ + UPDATE public.projects + SET + odk_form_id = %(odk_form_id)s, + xlsform_content = %(xlsform_content)s + WHERE id = %(project_id)s; + """ + async with db.cursor() as cur: + await cur.execute( + sql, + { + "project_id": project_id, + "odk_form_id": xform_id, + "xlsform_content": xlsform_bytes, + }, + ) + + success = await project_crud.generate_project_files( + db, + project_id, + ) + + if not success: + return JSONResponse( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + content={ + "message": ( + f"Failed project ({project_id}) creation. " + "Please contact the server admin." + ) + }, + ) + + return JSONResponse( + status_code=HTTPStatus.OK, + content={"message": "success"}, + ) + + +@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)], + basemap_in: project_schemas.BasemapGenerate, +): + """Returns basemap tiles for a project.""" + project_id = project_user.get("project").id + + # Create task in db and return uuid + log.debug( + "Creating generate_project_basemap background task " + f"for project ID: {project_id}" + ) + background_task_id = await DbBackgroundTask.create( + db, + project_schemas.BackgroundTaskIn( + project_id=project_id, + name="generate_basemap", + ), + ) + + background_tasks.add_task( + project_crud.generate_project_basemap, + db, + project_id, + background_task_id, + basemap_in.tile_source, + basemap_in.file_format, + basemap_in.tms_url, + ) + + return {"Message": "Tile generation started"} + + +@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("/{project_id}/upload-task-boundaries") async def upload_project_task_boundaries( project_id: int, @@ -1082,7 +1031,7 @@ async def upload_project_task_boundaries( #################### -@router.post("/", response_model=project_schemas.ProjectOut) +@router.post("", response_model=project_schemas.ProjectOut) async def create_project( project_info: project_schemas.ProjectIn, org_user_dict: Annotated[AuthUser, Depends(org_admin)], @@ -1156,10 +1105,68 @@ async def delete_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}/" + 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) + + +############################### +# ENDPOINTS THAT MUST BE LAST # +############################### +# NOTE this is due to the /{project_id} which will capture any text +# NOTE in place of the project_id integer + + +@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("/{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) diff --git a/src/backend/app/submissions/submission_routes.py b/src/backend/app/submissions/submission_routes.py index de30151a6c..abf2e379c9 100644 --- a/src/backend/app/submissions/submission_routes.py +++ b/src/backend/app/submissions/submission_routes.py @@ -47,7 +47,7 @@ ) -@router.get("/") +@router.get("") async def read_submissions( project_user: Annotated[ProjectUserDict, Depends(mapper)], ) -> list[dict]: @@ -383,7 +383,7 @@ async def download_submission_geojson( return Response(submission_data.getvalue(), headers=headers) -@router.get("/conflate-submission-geojson/") +@router.get("/conflate-submission-geojson") async def conflate_geojson( db_task: Annotated[DbTask, Depends(get_task)], project_user: Annotated[ProjectUserDict, Depends(mapper)], diff --git a/src/backend/app/tasks/task_routes.py b/src/backend/app/tasks/task_routes.py index 7e3f16d225..e2dd110b63 100644 --- a/src/backend/app/tasks/task_routes.py +++ b/src/backend/app/tasks/task_routes.py @@ -37,7 +37,7 @@ ) -@router.get("/", response_model=list[task_schemas.TaskOut]) +@router.get("", response_model=list[task_schemas.TaskOut]) async def read_tasks( project_id: int, db: Annotated[Connection, Depends(db_conn)], @@ -84,7 +84,7 @@ async def add_new_task_event( return await DbTaskEvent.create(db, new_event) -@router.get("/activity/", response_model=list[task_schemas.TaskEventCount]) +@router.get("/activity", response_model=list[task_schemas.TaskEventCount]) async def task_activity( project_id: int, db: Annotated[Connection, Depends(db_conn)], @@ -105,7 +105,7 @@ async def task_activity( return await task_crud.get_project_task_activity(db, project_id, days) -@router.get("/{task_id}/history/", response_model=list[task_schemas.TaskEventOut]) +@router.get("/{task_id}/history", response_model=list[task_schemas.TaskEventOut]) async def get_task_event_history( task_id: int, db: Annotated[Connection, Depends(db_conn)], diff --git a/src/backend/app/users/user_routes.py b/src/backend/app/users/user_routes.py index 051b25bbab..b91e7327ac 100644 --- a/src/backend/app/users/user_routes.py +++ b/src/backend/app/users/user_routes.py @@ -38,7 +38,7 @@ ) -@router.get("/", response_model=List[user_schemas.UserOut]) +@router.get("", response_model=List[user_schemas.UserOut]) async def get_users( db: Annotated[Connection, Depends(db_conn)], current_user: Annotated[DbUser, Depends(super_admin)], @@ -47,6 +47,15 @@ async def get_users( return await DbUser.all(db) +@router.get("/user-role-options") +async def get_user_roles(current_user: Annotated[DbUser, Depends(mapper)]): + """Check for available user role options.""" + user_roles = {} + for role in UserRoleEnum: + user_roles[role.name] = role.value + return user_roles + + @router.get("/{id}", response_model=user_schemas.UserOut) async def get_user_by_identifier( user: Annotated[DbUser, Depends(get_user)], @@ -61,15 +70,6 @@ async def get_user_by_identifier( return user -@router.get("/user-role-options/") -async def get_user_roles(current_user: Annotated[DbUser, Depends(mapper)]): - """Check for available user role options.""" - user_roles = {} - for role in UserRoleEnum: - user_roles[role.name] = role.value - return user_roles - - @router.delete("/{id}") async def delete_user_by_identifier( user: Annotated[DbUser, Depends(get_user)], diff --git a/src/backend/tests/test_organisation_routes.py b/src/backend/tests/test_organisation_routes.py index e8199cb9ca..bb42a4f6da 100644 --- a/src/backend/tests/test_organisation_routes.py +++ b/src/backend/tests/test_organisation_routes.py @@ -22,7 +22,7 @@ async def test_get_organisation(client, organisation): """Test get list of organisations.""" - response = await client.get("/organisation/") + response = await client.get("/organisation") assert response.status_code == 200 data = response.json()[-1] diff --git a/src/backend/tests/test_projects_routes.py b/src/backend/tests/test_projects_routes.py index 3a9fe4d00c..9c836477ce 100644 --- a/src/backend/tests/test_projects_routes.py +++ b/src/backend/tests/test_projects_routes.py @@ -275,14 +275,14 @@ async def test_upload_data_extracts(client, project): ) } response = await client.post( - f"/projects/upload-custom-extract/?project_id={project.id}", + f"/projects/upload-custom-extract?project_id={project.id}", files=fgb_file, ) assert response.status_code == 200 response = await client.get( - f"/projects/data-extract-url/?project_id={project.id}", + f"/projects/data-extract-url?project_id={project.id}", ) assert "url" in response.json() @@ -294,14 +294,14 @@ async def test_upload_data_extracts(client, project): ) } response = await client.post( - f"/projects/upload-custom-extract/?project_id={project.id}", + f"/projects/upload-custom-extract?project_id={project.id}", files=geojson_file, ) assert response.status_code == 200 response = await client.get( - f"/projects/data-extract-url/?project_id={project.id}", + f"/projects/data-extract-url?project_id={project.id}", ) assert "url" in response.json() diff --git a/src/backend/tests/test_users.py b/src/backend/tests/test_users.py index 129bb2f518..c649e85844 100644 --- a/src/backend/tests/test_users.py +++ b/src/backend/tests/test_users.py @@ -44,18 +44,18 @@ async def test_nothing(): # async def test_create_users(client): -# response = await client.post("/users/", json={ +# response = await client.post("/users", json={ # "username": "test3", "password": "test1"}) # assert response.status_code == status.HTTP_200_OK -# response = await client.post("/users/", json={ +# response = await client.post("/users", json={ # "username": "niraj", "password": "niraj"}) # assert response.status_code == status.HTTP_200_OK -# response = await client.post("/users/", json={"username": "niraj"}) +# response = await client.post("/users", json={"username": "niraj"}) # assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY -# response = await client.post("/users/", json={ +# response = await client.post("/users", json={ # "username": "niraj", "password": "niraj"}) # assert response.status_code == status.HTTP_400_BAD_REQUEST # assert response.json() == {"detail": "Username already registered"} diff --git a/src/frontend/src/api/CreateProjectService.ts b/src/frontend/src/api/CreateProjectService.ts index bf26e0ee65..f17c13f98a 100755 --- a/src/frontend/src/api/CreateProjectService.ts +++ b/src/frontend/src/api/CreateProjectService.ts @@ -55,7 +55,7 @@ const CreateProjectService = ( if (isOsmExtract) { // Generated extract from raw-data-api extractResponse = await API.get( - `${import.meta.env.VITE_API_URL}/projects/data-extract-url/?project_id=${projectId}&url=${ + `${import.meta.env.VITE_API_URL}/projects/data-extract-url?project_id=${projectId}&url=${ projectData.data_extract_url }`, ); @@ -64,7 +64,7 @@ const CreateProjectService = ( const dataExtractFormData = new FormData(); dataExtractFormData.append('custom_extract_file', dataExtractFile); extractResponse = await API.post( - `${import.meta.env.VITE_API_URL}/projects/upload-custom-extract/?project_id=${projectId}`, + `${import.meta.env.VITE_API_URL}/projects/upload-custom-extract?project_id=${projectId}`, dataExtractFormData, ); } diff --git a/src/frontend/src/api/Project.ts b/src/frontend/src/api/Project.ts index f3ac9648db..c994ff8fd2 100755 --- a/src/frontend/src/api/Project.ts +++ b/src/frontend/src/api/Project.ts @@ -157,7 +157,7 @@ export const GenerateProjectTiles = (url: string, projectId: string, data: objec const generateProjectTiles = async (url: string, projectId: string) => { try { await CoreModules.axios.post(url, data); - dispatch(GetTilesList(`${import.meta.env.VITE_API_URL}/projects/${projectId}/tiles/`)); + dispatch(GetTilesList(`${import.meta.env.VITE_API_URL}/projects/${projectId}/tiles`)); dispatch(ProjectActions.SetGenerateProjectTilesLoading(false)); } catch (error) { dispatch(ProjectActions.SetGenerateProjectTilesLoading(false)); diff --git a/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx b/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx index 794a97c729..58747db0b1 100644 --- a/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx +++ b/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx @@ -38,7 +38,7 @@ const OrganizationForm = () => { if (organizationId) { dispatch( ApproveOrganizationService( - `${import.meta.env.VITE_API_URL}/organisation/approve/?org_id=${parseInt(organizationId)}`, + `${import.meta.env.VITE_API_URL}/organisation/approve?org_id=${parseInt(organizationId)}`, ), ); } diff --git a/src/frontend/src/components/CreateEditOrganization/CreateEditOrganizationForm.tsx b/src/frontend/src/components/CreateEditOrganization/CreateEditOrganizationForm.tsx index 9cb889eaf1..5ac90baf8b 100644 --- a/src/frontend/src/components/CreateEditOrganization/CreateEditOrganizationForm.tsx +++ b/src/frontend/src/components/CreateEditOrganization/CreateEditOrganizationForm.tsx @@ -42,16 +42,13 @@ const CreateEditOrganizationForm = ({ organizationId }: { organizationId: string const submission = () => { if (!organizationId) { const { fillODKCredentials, ...filteredValues } = values; - dispatch(PostOrganisationDataService(`${import.meta.env.VITE_API_URL}/organisation/`, filteredValues)); + dispatch(PostOrganisationDataService(`${import.meta.env.VITE_API_URL}/organisation`, filteredValues)); } else { const { fillODKCredentials, ...filteredValues } = values; const changedValues = diffObject(organisationFormData, filteredValues); if (Object.keys(changedValues).length > 0) { dispatch( - PatchOrganizationDataService( - `${import.meta.env.VITE_API_URL}/organisation/${organizationId}/`, - changedValues, - ), + PatchOrganizationDataService(`${import.meta.env.VITE_API_URL}/organisation/${organizationId}`, changedValues), ); } } diff --git a/src/frontend/src/components/DialogTaskActions.tsx b/src/frontend/src/components/DialogTaskActions.tsx index 8f356d487a..e44b203134 100755 --- a/src/frontend/src/components/DialogTaskActions.tsx +++ b/src/frontend/src/components/DialogTaskActions.tsx @@ -55,7 +55,7 @@ export default function Dialog({ taskId, feature }: dialogPropType) { if (taskId) { dispatch( GetProjectTaskActivity( - `${import.meta.env.VITE_API_URL}/tasks/${selectedTask?.id}/history/?project_id=${currentProjectId}&comments=false`, + `${import.meta.env.VITE_API_URL}/tasks/${selectedTask?.id}/history?project_id=${currentProjectId}&comments=false`, ), ); } diff --git a/src/frontend/src/components/GenerateBasemap.tsx b/src/frontend/src/components/GenerateBasemap.tsx index fcfd81b02f..dde23eeedc 100644 --- a/src/frontend/src/components/GenerateBasemap.tsx +++ b/src/frontend/src/components/GenerateBasemap.tsx @@ -36,7 +36,7 @@ const GenerateBasemap = ({ projectInfo }: { projectInfo: Partial { - dispatch(GetTilesList(`${import.meta.env.VITE_API_URL}/projects/${id}/tiles/`)); + dispatch(GetTilesList(`${import.meta.env.VITE_API_URL}/projects/${id}/tiles`)); }; useEffect(() => { diff --git a/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx b/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx index ef5c4e4f35..f37c182f1a 100644 --- a/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx +++ b/src/frontend/src/components/ManageProject/EditTab/FormUpdateTab.tsx @@ -79,7 +79,7 @@ const FormUpdateTab = ({ projectId }) => { onClick={() => dispatch( DownloadProjectForm( - `${import.meta.env.VITE_API_URL}/projects/download-form/${projectId}/`, + `${import.meta.env.VITE_API_URL}/projects/download-form/${projectId}`, 'form', projectId, ), diff --git a/src/frontend/src/components/ProjectDetailsV2/Comments.tsx b/src/frontend/src/components/ProjectDetailsV2/Comments.tsx index d3f31ea750..a736ef6742 100644 --- a/src/frontend/src/components/ProjectDetailsV2/Comments.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/Comments.tsx @@ -35,7 +35,7 @@ const Comments = () => { useEffect(() => { dispatch( GetProjectComments( - `${import.meta.env.VITE_API_URL}/tasks/${currentStatus?.id}/history/?project_id=${projectId}&comments=true`, + `${import.meta.env.VITE_API_URL}/tasks/${currentStatus?.id}/history?project_id=${projectId}&comments=true`, ), ); }, [selectedTask, projectId, currentStatus?.id]); @@ -58,7 +58,7 @@ const Comments = () => { return; } dispatch( - PostProjectComments(`${import.meta.env.VITE_API_URL}/tasks/${currentStatus?.id}/event/?project_id=${projectId}`, { + PostProjectComments(`${import.meta.env.VITE_API_URL}/tasks/${currentStatus?.id}/event?project_id=${projectId}`, { task_id: selectedTask.id, comment, }), diff --git a/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx b/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx index 45f00bdcb7..d6709b3654 100644 --- a/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx @@ -28,7 +28,7 @@ const ProjectOptions = ({ projectName }: projectOptionPropTypes) => { if (downloadType === 'form') { dispatch( DownloadProjectForm( - `${import.meta.env.VITE_API_URL}/projects/download-form/${projectId}/`, + `${import.meta.env.VITE_API_URL}/projects/download-form/${projectId}`, downloadType, projectId, ), @@ -44,7 +44,7 @@ const ProjectOptions = ({ projectName }: projectOptionPropTypes) => { } else if (downloadType === 'extract') { dispatch( DownloadDataExtract( - `${import.meta.env.VITE_API_URL}/projects/features/download/?project_id=${projectId}`, + `${import.meta.env.VITE_API_URL}/projects/features/download?project_id=${projectId}`, projectId, ), ); diff --git a/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx b/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx index a94ff99306..e756fcbcdc 100644 --- a/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx +++ b/src/frontend/src/components/ProjectSubmissions/UpdateReviewStatusModal.tsx @@ -61,7 +61,7 @@ const UpdateReviewStatusModal = () => { if (noteComments.trim().length > 0) { dispatch( PostProjectComments( - `${import.meta.env.VITE_API_URL}/tasks/${updateReviewStatusModal?.taskUid}/event/?project_id=${updateReviewStatusModal?.projectId}`, + `${import.meta.env.VITE_API_URL}/tasks/${updateReviewStatusModal?.taskUid}/event?project_id=${updateReviewStatusModal?.projectId}`, { task_id: updateReviewStatusModal?.taskUid, comment: `${updateReviewStatusModal?.instanceId}-SUBMISSION_INST-${noteComments}`, diff --git a/src/frontend/src/components/createnewproject/DataExtract.tsx b/src/frontend/src/components/createnewproject/DataExtract.tsx index f96e61b179..f1273f6257 100644 --- a/src/frontend/src/components/createnewproject/DataExtract.tsx +++ b/src/frontend/src/components/createnewproject/DataExtract.tsx @@ -84,7 +84,7 @@ const DataExtract = ({ try { const response = await axios.post( - `${import.meta.env.VITE_API_URL}/projects/generate-data-extract/`, + `${import.meta.env.VITE_API_URL}/projects/generate-data-extract`, dataExtractRequestFormData, ); diff --git a/src/frontend/src/components/createnewproject/ProjectDetailsForm.tsx b/src/frontend/src/components/createnewproject/ProjectDetailsForm.tsx index 85b489bb70..1cc706df00 100644 --- a/src/frontend/src/components/createnewproject/ProjectDetailsForm.tsx +++ b/src/frontend/src/components/createnewproject/ProjectDetailsForm.tsx @@ -43,7 +43,7 @@ const ProjectDetailsForm = ({ flag }) => { ); const onFocus = () => { - dispatch(OrganisationService(`${import.meta.env.VITE_API_URL}/organisation/`)); + dispatch(OrganisationService(`${import.meta.env.VITE_API_URL}/organisation`)); }; useEffect(() => { diff --git a/src/frontend/src/components/createnewproject/SelectForm.tsx b/src/frontend/src/components/createnewproject/SelectForm.tsx index d579f4cc48..37d5ebb705 100644 --- a/src/frontend/src/components/createnewproject/SelectForm.tsx +++ b/src/frontend/src/components/createnewproject/SelectForm.tsx @@ -175,7 +175,7 @@ const SelectForm = ({ flag, geojsonFile, customFormFile, setCustomFormFile }) =>

{ - const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me/`, { + const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, { credentials: 'include', }); @@ -16,7 +16,7 @@ export const getUserDetailsFromApi = async () => { export const osmLoginRedirect = async () => { try { - const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/osm-login/`); + const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/osm-login`); const data = await resp.json(); window.location = data.login_url; } catch (error) { @@ -26,7 +26,7 @@ export const osmLoginRedirect = async () => { export const revokeCookie = async () => { try { - const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/logout/`, { credentials: 'include' }); + const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/logout`, { credentials: 'include' }); if (!response.ok) { console.error('/auth/logout endpoint did not return 200 response'); } diff --git a/src/frontend/src/views/DataConflation.tsx b/src/frontend/src/views/DataConflation.tsx index c586c3f81d..fa69121dd2 100644 --- a/src/frontend/src/views/DataConflation.tsx +++ b/src/frontend/src/views/DataConflation.tsx @@ -17,7 +17,7 @@ const DataConflation = () => { SubmissionConflationGeojsonService( `${ import.meta.env.VITE_API_URL - }/submission/conflate-submission-geojson/?project_id=${projectId}&task_id=${taskId}`, + }/submission/conflate-submission-geojson?project_id=${projectId}&task_id=${taskId}`, ), ); }, []); diff --git a/src/frontend/src/views/Organisation.tsx b/src/frontend/src/views/Organisation.tsx index 943305e667..15c5df4238 100644 --- a/src/frontend/src/views/Organisation.tsx +++ b/src/frontend/src/views/Organisation.tsx @@ -46,9 +46,9 @@ const Organisation = () => { useEffect(() => { if (verifiedTab) { - dispatch(OrganisationDataService(`${import.meta.env.VITE_API_URL}/organisation/`)); + dispatch(OrganisationDataService(`${import.meta.env.VITE_API_URL}/organisation`)); } else { - dispatch(OrganisationDataService(`${import.meta.env.VITE_API_URL}/organisation/unapproved/`)); + dispatch(OrganisationDataService(`${import.meta.env.VITE_API_URL}/organisation/unapproved`)); } }, [verifiedTab]); diff --git a/src/frontend/src/views/OsmAuth.tsx b/src/frontend/src/views/OsmAuth.tsx index ef8ee4215a..a6a7837638 100644 --- a/src/frontend/src/views/OsmAuth.tsx +++ b/src/frontend/src/views/OsmAuth.tsx @@ -27,7 +27,7 @@ function OsmAuth() { const loginRedirect = async () => { // authCode is passed from OpenStreetMap redirect, so get cookie, then redirect if (authCode) { - const callbackUrl = `${import.meta.env.VITE_API_URL}/auth/callback/?code=${authCode}&state=${state}`; + const callbackUrl = `${import.meta.env.VITE_API_URL}/auth/callback?code=${authCode}&state=${state}`; const completeLogin = async () => { // NOTE this encapsulates async methods to call sequentially diff --git a/src/frontend/src/views/ProjectSubmissions.tsx b/src/frontend/src/views/ProjectSubmissions.tsx index 172bb94890..7782e183df 100644 --- a/src/frontend/src/views/ProjectSubmissions.tsx +++ b/src/frontend/src/views/ProjectSubmissions.tsx @@ -53,7 +53,7 @@ const ProjectSubmissions = () => { useEffect(() => { dispatch( - MappedVsValidatedTaskService(`${import.meta.env.VITE_API_URL}/tasks/activity/?project_id=${projectId}&days=30`), + MappedVsValidatedTaskService(`${import.meta.env.VITE_API_URL}/tasks/activity?project_id=${projectId}&days=30`), ); }, []); diff --git a/src/frontend/src/views/SubmissionDetails.tsx b/src/frontend/src/views/SubmissionDetails.tsx index 0faf5afe38..9582301348 100644 --- a/src/frontend/src/views/SubmissionDetails.tsx +++ b/src/frontend/src/views/SubmissionDetails.tsx @@ -117,7 +117,7 @@ const SubmissionDetails = () => { if (taskUid == 'undefined') return; dispatch( GetProjectComments( - `${import.meta.env.VITE_API_URL}/tasks/${parseInt(taskUid)}/history/?project_id=${projectId}&comments=true`, + `${import.meta.env.VITE_API_URL}/tasks/${parseInt(taskUid)}/history?project_id=${projectId}&comments=true`, ), ); }, [taskUid]); diff --git a/src/mapper/src/lib/db/events.ts b/src/mapper/src/lib/db/events.ts index 5b8493de37..83e1ff8db3 100644 --- a/src/mapper/src/lib/db/events.ts +++ b/src/mapper/src/lib/db/events.ts @@ -20,7 +20,7 @@ async function add_event( task_id: taskId, comment: comment, }; - const resp = await fetch(`${API_URL}/tasks/${taskId}/event/?project_id=${projectId}`, { + const resp = await fetch(`${API_URL}/tasks/${taskId}/event?project_id=${projectId}`, { method: 'POST', credentials: 'include', headers: {