diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e77d207f7..28811da6f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: # Lint / autoformat: Python code - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: "v0.9.1" + rev: "v0.9.2" hooks: # Run the linter - id: ruff @@ -21,7 +21,7 @@ repos: # Deps: ensure Python uv lockfile is up to date - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.18 + rev: 0.5.21 hooks: - id: uv-lock files: src/backend/pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0a8e64e6..3c99bd8aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## 2025.1.0 (2025-01-24) + +### Feat + +- **backend**: add `integrations` router with API key functionality (external apps) (#2110) +- entity pulse effect on rejected submissions (#2018) +- **mapper**: distance constraint add on frontend (#2084) +- **frontend**: submission table date range filter (#2091) +- **backend**: get api for project's geometry log (#2090) +- capability to draw new polygon and linestring geoms (#2082) +- **backend**: add filters for submission date in submission table and downloads (#2077) +- **backend**: osm-fieldwork --> 0.18.0 (submission filter param + config new feat geom type) +- **geolocation**: comments add +- add submission ids in entities statuses endpoint (#2038) +- consider every additional entities without clipping them with AOI (#2017) +- update the version of osm-fieldwork (#2029) +- **backend**: send org approval message to creator (#2008) +- add submission ids as a dataset property for the entities (#2007) +- update the version of fmtm-splitter 2.0.0 (#1996) +- **mapper**: project details section add to bottom sheet (#1994) +- **mapper**: prompt user to download custom ODK Collect on first load (#1989) + +### Fix + +- **backend**: delete submission photos while deleting project to avoid foreignkey constraint (#2112) +- **backend**: review state for received on submission table (#2101) +- **+page**: remove irrelevant subscribeToEntityStatusUpdates call in onMount +- use task index instead of id in task boundary geojson properties (#2095) +- **mapper**: get entities after page load to speed up first paint (#2051) +- **backend**: get total_tasks count on single project response +- **backend**: various fixes based on sentry error reports (#2053) +- default odk creds when organisation do not have their own during project creation (#2070) +- change geom to geojson in db model +- **generateBasemap**: update tile source option value (#2050) +- **geolocation**: fetch routing api on every 10 seconds +- **dialog-entities-actions**: show alert instead of turning on location on navigation +- **createProjecSlice**: clear additionalFeatureGeojson state after successful project creation (#2041) +- compose file name for backend test stage +- **mapper**: replace task id with index on activities panel (#2002) +- **mapper**: task id with task index on mapper frontend (#1997) +- **backend**: include organisation name in minimal project query results (#1993) +- **frontend**: small fix to reset frontend login if cookie refresh fails +- **mapper**: offline mode button visibility & basemap component TS type error (#1990) +- parse geojson to featcol in generate data extract (#1983) + +### Refactor + +- **mapper**: feature legend & layer-switcher (#2107) +- **mapper**: relocate entity sync button (#2100) +- **frontend**: organization management pages (#2097) +- **frontend**: JS to TS conversion: update useDispatch to useAppDispatch (#2076) +- **frontend**: update TS types on actions (#2054) +- replace incorrect osm libya logo with official osm logo +- **frontend**: terminologies and wording update for users (#1978) +- **mapper**: ts errors on frontend (#2006) +- **mapper**: add mapping guide to sidebar + update links + ## 2024.5.0 (2024-12-11) ### Feat diff --git a/README.md b/README.md index 26ae121761..b5f27c5f38 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | **CI/CD** | | [![Build and Deploy](https://github.com/hotosm/fmtm/actions/workflows/build_and_deploy.yml/badge.svg?branch=main)](https://github.com/hotosm/fmtm/actions/workflows/build_and_deploy.yml?query=branch%3Amain) [![Build CI Img](https://github.com/hotosm/fmtm/actions/workflows/build_ci_img.yml/badge.svg?branch=development)](https://github.com/hotosm/fmtm/actions/workflows/build_ci_img.yml) [![Build ODK Images](https://github.com/hotosm/fmtm/actions/workflows/build_odk_imgs.yml/badge.svg?branch=development)](https://github.com/hotosm/fmtm/actions/workflows/build_odk_imgs.yml)
[![🔧 Build Proxy Images](https://github.com/hotosm/fmtm/actions/workflows/build_proxy_imgs.yml/badge.svg?branch=development)](https://github.com/hotosm/fmtm/actions/workflows/build_proxy_imgs.yml) [![Publish Docs](https://github.com/hotosm/fmtm/actions/workflows/docs.yml/badge.svg?branch=development)](https://github.com/hotosm/fmtm/actions/workflows/docs.yml) [![pre-commit.ci](https://results.pre-commit.ci/badge/github/hotosm/fmtm/development.svg)](https://results.pre-commit.ci/latest/github/hotosm/fmtm/development) | | :--- | :--- | :--- | -| **Tech Stack** | | ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi) ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) | +| **Tech Stack** | | ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi) ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) ![Svelte](https://img.shields.io/badge/Svelte-4A4A55?style=for-the-badge&logo=svelte) ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) | | **Code Style** | | [![Backend Style](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/format.json&labelColor=202235)](https://github.com/astral-sh/ruff) [![Frontend Style](https://img.shields.io/badge/code%20style-prettier-F7B93E?logo=Prettier)](https://github.com/prettier/prettier) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com) | | **Quality** | | [![Coverage](https://docs.fmtm.dev/coverage.svg)](https://docs.fmtm.dev/coverage.html) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9218/badge)](https://www.bestpractices.dev/projects/9218) | | **Community** | | [![Slack](https://img.shields.io/badge/Slack-Join%20the%20community!-d63f3f?style=for-the-badge&logo=slack&logoColor=d63f3f)](https://slack.hotosm.org) [![All Contributors](https://img.shields.io/github/all-contributors/hotosm/fmtm?color=ee8449&style=flat-square)](#contributors-) | @@ -121,13 +121,14 @@ Alternatively see the [docs](https://docs.fmtm.dev) for various deployment guide |✅| 📱 open ODK Collect with feature already selected | |✅| 📱 live updates during mapping (if online) | |✅| 📱 features turn green once mapped | -|⚙️| 📱 better support for mapping **new** points, lines, polygons | -|⚙️| 📱 navigation and capability for routing to map features | -| | 📱 integrate ODK Web Forms (to avoid switching apps) | +|✅| 📱 better support for mapping **new** points, lines, polygons | +|✅| 📱 navigation and capability for routing to map features | +|⚙️| 📱 integrate ODK Web Forms (to avoid switching apps) | +|⚙️| 🖥️ multiple approaches to task splitting algorithm | +|⚙️| 🖥️ user role management per project | | | 📱 fully offline field mapping | | | 🖥️ organization creation and management | | | 🖥️ simplify project creation with basic / advanced workflows | -| | 🖥️ refinements to task splitting algorithm | | | 🖥️ improvements to the validation criteria and workflow | | | 🖥️ export (+merge) the final data to OpenStreetMap | | | 🖥️ better data visualisation and export options | diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 04bdfb10ef..be947c54a3 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -3,7 +3,7 @@ type: application name: fmtm description: Field Mapping Tasking Manager - coordinated field mapping. version: "0.1.0" -appVersion: "2024.5.0" +appVersion: "2025.1.0" maintainers: - email: sam.woodcock@hotosm.org name: Sam Woodcock diff --git a/chart/values.yaml b/chart/values.yaml index e875999b94..156cafdade 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -114,7 +114,7 @@ s3: replicas: 1 image: repository: quay.io/minio/minio - tag: RELEASE.2024-12-18T13-15-44Z + tag: RELEASE.2025-01-20T14-49-07Z environment: MINIO_BROWSER: "off" mountPath: /mnt/data diff --git a/contrib/just/start/Justfile b/contrib/just/start/Justfile index 86b2af1c0a..d7b2204462 100644 --- a/contrib/just/start/Justfile +++ b/contrib/just/start/Justfile @@ -32,7 +32,7 @@ backend: cd {{justfile_directory()}} docker compose up -d api -# Start backend API only +# Start backend API without docker [no-cd] backend-no-docker: #!/usr/bin/env sh @@ -43,11 +43,34 @@ backend-no-docker: OSM_SECRET_KEY="" ENCRYPTION_KEY="" \ uv run uvicorn app.main:api --host 0.0.0.0 --port 8000 -# Start frontend UI only +# Start frontend UI (also starts backend) [no-cd] frontend: docker compose up -d ui +# Start frontend UI without docker, connected to staging +[no-cd] +frontend-dev: + #!/usr/bin/env sh + + cd {{justfile_directory()}}/src/frontend + + pnpm install + VITE_API_URL=https://api.stage.fmtm.hotosm.org \ + pnpm run dev + +# Start mapper frontend UI without docker, connected to staging +[no-cd] +mapper-frontend-dev: + #!/usr/bin/env sh + + cd {{justfile_directory()}}/src/mapper + + pnpm install + VITE_API_URL=https://api.stage.fmtm.hotosm.org \ + VITE_SYNC_URL=https://sync.stage.fmtm.hotosm.org \ + pnpm run dev + # Start FMTM without ODK Central [no-cd] without-central: diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 850af11f72..46dd1274c9 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -178,7 +178,7 @@ services: restart: "on-failure:2" s3: - image: "docker.io/minio/minio:${MINIO_TAG:-RELEASE.2024-12-18T13-15-44Z}" + image: "docker.io/minio/minio:${MINIO_TAG:-RELEASE.2025-01-20T14-49-07Z}" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index f9e949f993..ccccbceec6 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -119,7 +119,7 @@ services: restart: "on-failure:2" s3: - image: "docker.io/minio/minio:${MINIO_TAG:-RELEASE.2024-12-18T13-15-44Z}" + image: "docker.io/minio/minio:${MINIO_TAG:-RELEASE.2025-01-20T14-49-07Z}" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} diff --git a/docker-compose.yml b/docker-compose.yml index 4093f941c5..96833520f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -225,7 +225,7 @@ services: restart: "on-failure:2" s3: - image: "docker.io/minio/minio:${MINIO_TAG:-RELEASE.2024-12-18T13-15-44Z}" + image: "docker.io/minio/minio:${MINIO_TAG:-RELEASE.2025-01-20T14-49-07Z}" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY:-fmtm} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-somelongpassword} diff --git a/docs/dev/Backend.md b/docs/dev/Backend.md index 4c64121957..85ff78e1c5 100644 --- a/docs/dev/Backend.md +++ b/docs/dev/Backend.md @@ -25,7 +25,7 @@ URLs defined in the docker-compose file and your env file. and ensure that it is running on your local machine. 2. From the command line: navigate to the top level directory of the FMTM project. -3. From the command line run: `docker-compose pull`. +3. From the command line run: `docker compose pull`. This will pull the latest container builds from **main** branch. 4. Make sure you have a `.env` file with all required variables, see [here](../INSTALL.md#2-create-an-env-file). diff --git a/docs/dev/Frontend.md b/docs/dev/Frontend.md index 3584c6032e..029cdd49ae 100644 --- a/docs/dev/Frontend.md +++ b/docs/dev/Frontend.md @@ -30,19 +30,17 @@ For details on how to run the API first, please see: ## 2. Start the Frontend locally -### 2A: Navigate to the frontend subdirectory +To run the frontend locally, connected to the staging server as a backend: -`cd src/frontend` - -### 2B: Install dependencies - -`npm install` - -### 2C. Run the project +```bash +just --unstable start frontend-dev +``` -Run the frontend with hot-reloading: `npm run dev` +The mapper frontend can be started with a similar command: -The frontend should now be accessible at: `http://127.0.0.1:` +```bash +just --unstable start mapper-frontend-dev +``` ## Frontend Tips diff --git a/src/Dockerfile.ui.debug b/src/Dockerfile.ui.debug index c01d3698ae..410bcccc98 100755 --- a/src/Dockerfile.ui.debug +++ b/src/Dockerfile.ui.debug @@ -1,4 +1,4 @@ -FROM docker.io/node:20-slim +FROM docker.io/node:22-slim RUN set -ex \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install \ @@ -9,6 +9,6 @@ WORKDIR /app COPY --from=code ./package.json ./pnpm-lock.yaml ./ ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable && corepack use pnpm@9.3.0 +RUN corepack enable && corepack use pnpm@9.15.4 RUN pnpm install ENTRYPOINT ["pnpm", "run", "dev"] diff --git a/src/Dockerfile.ui.prod b/src/Dockerfile.ui.prod index 9eed9fbd08..e3e71d0c82 100644 --- a/src/Dockerfile.ui.prod +++ b/src/Dockerfile.ui.prod @@ -1,11 +1,11 @@ -FROM docker.io/node:20-slim AS base +FROM docker.io/node:22-slim AS base ARG VITE_API_URL ARG VITE_SYNC_URL ENV VITE_API_URL=${VITE_API_URL} \ VITE_SYNC_URL=${VITE_SYNC_URL} \ PNPM_HOME="/pnpm" \ PATH="$PATH:/pnpm" -RUN corepack enable && corepack use pnpm@9.3.0 +RUN corepack enable && corepack use pnpm@9.15.4 WORKDIR /app diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index f9e76a20bd..04da14d68f 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -16,7 +16,7 @@ # ARG PYTHON_IMG_TAG=3.12 ARG UV_IMG_TAG=0.5.2 -ARG MINIO_TAG=${MINIO_TAG:-RELEASE.2024-12-18T13-15-44Z} +ARG MINIO_TAG=${MINIO_TAG:-RELEASE.2025-01-20T14-49-07Z} FROM ghcr.io/astral-sh/uv:${UV_IMG_TAG} AS uv FROM docker.io/minio/minio:${MINIO_TAG} AS minio diff --git a/src/backend/app/__version__.py b/src/backend/app/__version__.py index ff12fec95d..e3fccb90e6 100644 --- a/src/backend/app/__version__.py +++ b/src/backend/app/__version__.py @@ -1 +1 @@ -__version__ = "2024.5.0" +__version__ = "2025.1.0" diff --git a/src/backend/app/auth/auth_deps.py b/src/backend/app/auth/auth_deps.py index 4bea8abdea..9af69a3d26 100644 --- a/src/backend/app/auth/auth_deps.py +++ b/src/backend/app/auth/auth_deps.py @@ -19,7 +19,7 @@ """Auth dependencies, for restricted routes and cookie handling.""" from time import time -from typing import Optional +from typing import Annotated, Optional import jwt from fastapi import Header, HTTPException, Request, Response @@ -262,7 +262,7 @@ async def refresh_cookies( async def login_required( - request: Request, access_token: str = Header(None) + request: Request, access_token: Annotated[Optional[str], Header()] = None ) -> AuthUser: """Dependency for endpoints requiring login.""" if settings.DEBUG: @@ -277,7 +277,7 @@ async def login_required( async def mapper_login_required( - request: Request, access_token: str = Header(None) + request: Request, access_token: Annotated[Optional[str], Header()] = None ) -> AuthUser: """Dependency for mapper frontend login.""" if settings.DEBUG: diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py index eb00493977..cbc7bc409c 100644 --- a/src/backend/app/db/models.py +++ b/src/backend/app/db/models.py @@ -159,6 +159,7 @@ class DbUser(BaseModel): tasks_validated: Optional[int] = None tasks_invalidated: Optional[int] = None projects_mapped: Optional[list[int]] = None + api_key: Optional[str] = None registered_at: Optional[AwareDatetime] = None last_login_at: Optional[AwareDatetime] = None @@ -1358,6 +1359,12 @@ async def update( async def delete(cls, db: Connection, project_id: int) -> bool: """Delete a project and its related data.""" async with db.cursor() as cur: + await cur.execute( + """ + DELETE FROM submission_photos WHERE project_id = %(project_id)s; + """, + {"project_id": project_id}, + ) await cur.execute( """ DELETE FROM background_tasks WHERE project_id = %(project_id)s; @@ -1795,6 +1802,7 @@ def slugify(name: Optional[str]) -> Optional[str]: class DbGeometryLog(BaseModel): """Table geometry log.""" + id: Optional[UUID] = None geojson: dict status: GeomStatus project_id: Optional[int] = None @@ -1834,6 +1842,25 @@ async def create( new_geomlog = await cur.fetchone() return new_geomlog + @classmethod + async def all(cls, db: Connection, project_id: int) -> Optional[list[Self]]: + """Retrieve geometry logs from a project.""" + async with db.cursor(row_factory=class_row(cls)) as cur: + await cur.execute( + """ + SELECT * FROM geometrylog WHERE project_id=%(project_id)s; + """, + {"project_id": project_id}, + ) + if cur.rowcount == 0: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f""" + No geometry log with project_id {project_id} + """, + ) + return await cur.fetchall() + @classmethod async def delete( cls, diff --git a/src/backend/app/integrations/__init__.py b/src/backend/app/integrations/__init__.py new file mode 100644 index 0000000000..75d2e0e971 --- /dev/null +++ b/src/backend/app/integrations/__init__.py @@ -0,0 +1 @@ +"""FMTM integrations API, for linking to external services.""" diff --git a/src/backend/app/integrations/integration_crud.py b/src/backend/app/integrations/integration_crud.py new file mode 100644 index 0000000000..c647a2fca2 --- /dev/null +++ b/src/backend/app/integrations/integration_crud.py @@ -0,0 +1,50 @@ +# Copyright (c) Humanitarian OpenStreetMap Team +# +# This file is part of FMTM. +# +# FMTM is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# FMTM is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with FMTM. If not, see . +# +"""Logic for integration routes.""" + +from secrets import token_urlsafe + +from loguru import logger as log +from psycopg import Connection +from psycopg.rows import class_row + +from app.db.models import DbUser + + +async def generate_api_token( + db: Connection, + user_id: int, +) -> str: + """Generate a new API token for a given user.""" + async with db.cursor(row_factory=class_row(DbUser)) as cur: + await cur.execute( + """ + UPDATE users + SET api_key = %(api_key)s + WHERE id = %(user_id)s + RETURNING *; + """, + {"user_id": user_id, "api_key": token_urlsafe(32)}, + ) + db_user = await cur.fetchone() + if not db_user.api_key: + msg = f"Failed to generate API Key for user ({user_id})" + log.error(msg) + raise ValueError(msg) + + return db_user.api_key diff --git a/src/backend/app/integrations/integration_deps.py b/src/backend/app/integrations/integration_deps.py new file mode 100644 index 0000000000..ccfd758bb9 --- /dev/null +++ b/src/backend/app/integrations/integration_deps.py @@ -0,0 +1,65 @@ +# Copyright (c) Humanitarian OpenStreetMap Team +# +# This file is part of FMTM. +# +# FMTM is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# FMTM is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with FMTM. If not, see . +# + +"""Integration dependencies, for API token validation.""" + +from typing import Annotated + +from fastapi import ( + Depends, + Header, +) +from fastapi.exceptions import HTTPException +from psycopg import Connection +from psycopg.rows import class_row + +from app.db.database import db_conn +from app.db.enums import HTTPStatus +from app.db.models import DbUser + + +async def valid_api_token( + db: Annotated[Connection, Depends(db_conn)], + x_api_key: Annotated[str, Header()], +) -> DbUser: + """Check the API token is present for an active database user. + + A header X-API-Key must be provided in the request. + + TODO currently this only checks for a valid key, but does not + TODO include checking roles. + TODO If roles other than 'mapper' are required, this should be integrated. + """ + async with db.cursor(row_factory=class_row(DbUser)) as cur: + await cur.execute( + """ + SELECT * + FROM users + WHERE api_key = %(api_key)s + AND is_email_verified = TRUE; + """, + {"api_key": x_api_key}, + ) + db_user = await cur.fetchone() + if not db_user: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail=f"API key invalid: ({x_api_key})", + ) + + return db_user diff --git a/src/backend/app/integrations/integration_routes.py b/src/backend/app/integrations/integration_routes.py new file mode 100644 index 0000000000..92a09222ce --- /dev/null +++ b/src/backend/app/integrations/integration_routes.py @@ -0,0 +1,98 @@ +# Copyright (c) Humanitarian OpenStreetMap Team +# +# This file is part of FMTM. +# +# FMTM is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# FMTM is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with FMTM. If not, see . +# +"""Routes to integrate with external apps, via an API key. + +We handle these endpoints separately to minimise the attach surface +possible from misused API keys (so the entire API is not accessible). + +API keys are inherently not as secure as OAuth flow / JWT token combo. +""" + +from typing import Annotated + +from fastapi import ( + APIRouter, + Depends, +) +from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse +from psycopg import Connection + +from app.auth.roles import super_admin +from app.central.central_crud import update_entity_mapping_status +from app.central.central_schemas import EntityMappingStatus, EntityMappingStatusIn +from app.db.database import db_conn +from app.db.enums import HTTPStatus +from app.db.models import DbProject, DbUser +from app.integrations.integration_crud import ( + generate_api_token, +) +from app.integrations.integration_deps import valid_api_token +from app.projects.project_deps import get_project + +router = APIRouter( + prefix="/integrations", + tags=["integrations"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/api-token") +async def get_api_token( + current_user: Annotated[DbUser, Depends(super_admin)], + db: Annotated[Connection, Depends(db_conn)], +): + """Generate and return a new API token. + + This can only be accessed once, and is regenerated on + each call to this endpoint. + + Be sure to store it someplace safe, like a password manager. + + NOTE currently requires super admin permission. + """ + try: + api_key = await generate_api_token(db, current_user.id) + except ValueError as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + return JSONResponse( + status_code=HTTPStatus.OK, + content={"api_key": api_key}, + ) + + +@router.post( + "/webhooks/entity-status", + response_model=EntityMappingStatus, +) +async def update_entity_status( + current_user: Annotated[DbUser, Depends(valid_api_token)], + project: Annotated[DbProject, Depends(get_project)], + entity_details: EntityMappingStatusIn, +): + """Update the status for an Entity.""" + return await update_entity_mapping_status( + project.odk_credentials, + project.odkid, + entity_details.entity_id, + entity_details.label, + entity_details.status, + ) diff --git a/src/backend/app/main.py b/src/backend/app/main.py index c506867bec..a525634f6a 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -39,6 +39,7 @@ from app.db.database import db_conn, get_db_connection_pool from app.db.enums import HTTPStatus from app.helpers import helper_routes +from app.integrations import integration_routes from app.monitoring import ( add_endpoint_profiler, instrument_app_otel, @@ -144,6 +145,7 @@ def get_application() -> FastAPI: _app.include_router(auth_routes.router) _app.include_router(submission_routes.router) _app.include_router(organisation_routes.router) + _app.include_router(integration_routes.router) _app.include_router(helper_routes.router) return _app diff --git a/src/backend/app/organisations/organisation_schemas.py b/src/backend/app/organisations/organisation_schemas.py index 7939c6be17..10ce7c342a 100644 --- a/src/backend/app/organisations/organisation_schemas.py +++ b/src/backend/app/organisations/organisation_schemas.py @@ -66,6 +66,7 @@ def parse_organisation_input( created_by: Optional[int] = Form(None), community_type: CommunityType = Form(None), description: Optional[str] = Form(None), + associated_email: Optional[str] = Form(None), url: Optional[str] = Form(None), type: OrganisationType = Form(None, alias="type"), odk_central_url: Optional[str] = Form(None), @@ -86,6 +87,7 @@ def parse_organisation_input( slug=slug, created_by=created_by, description=description, + associated_email=associated_email, url=url, community_type=community_type, type=type, diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index f00d90c01e..4afe5de9a4 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -41,7 +41,7 @@ from app.central import central_crud, central_schemas from app.config import settings from app.db.enums import BackgroundTaskStatus, HTTPStatus, XLSFormType -from app.db.models import DbBackgroundTask, DbBasemap, DbProject, DbTask, DbUser +from app.db.models import DbBackgroundTask, DbBasemap, DbProject, DbUser from app.db.postgis_utils import ( check_crs, featcol_keep_single_geom_type, @@ -623,18 +623,28 @@ async def get_task_geometry(db: Connection, project_id: int): Returns: str: A geojson of the task boundaries """ - db_tasks = await DbTask.all(db, project_id) - features = [] - for task in db_tasks: - properties = { - "task_id": task.id, - } - feature = { + query = """ + SELECT project_task_index, + ST_AsGeoJSON(tasks.outline)::jsonb AS outline + FROM tasks + WHERE project_id = %(project_id)s + """ + async with db.cursor(row_factory=class_row(dict)) as cur: + await cur.execute(query, {"project_id": project_id}) + db_tasks = await cur.fetchall() + + if not db_tasks: + raise ValueError(f"No tasks found for project ID {project_id}.") + + features = [ + { "type": "Feature", - "geometry": task.outline, - "properties": properties, + "geometry": task["outline"], + "properties": {"task_id": task["project_task_index"]}, } - features.append(feature) + for task in db_tasks + if task["outline"] # Exclude tasks with no geometry + ] feature_collection = {"type": "FeatureCollection", "features": features} return json.dumps(feature_collection) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index c9d3d72345..79540f437e 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -276,7 +276,6 @@ async def get_odk_entity_mapping_status( async def set_odk_entities_mapping_status( project_user: Annotated[ProjectUserDict, Depends(mapper)], entity_details: central_schemas.EntityMappingStatusIn, - db: Annotated[Connection, Depends(db_conn)], ): """Set the ODK entities mapping status, i.e. in progress or complete. @@ -1068,7 +1067,6 @@ async def update_project( @router.post("/{project_id}/upload-task-boundaries") async def upload_project_task_boundaries( - project_id: int, db: Annotated[Connection, Depends(db_conn)], project_user_dict: Annotated[ProjectUserDict, Depends(project_manager)], task_geojson: UploadFile = File(...), @@ -1084,6 +1082,7 @@ async def upload_project_task_boundaries( Returns: JSONResponse: JSON containing success message. """ + project_id = project_user_dict.get("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 @@ -1241,7 +1240,6 @@ async def download_project_boundary( @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)], ): @@ -1256,17 +1254,17 @@ async def download_task_boundaries( 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) + task_geojson = await project_crud.get_task_geometry(db, project_id) headers = { - "Content-Disposition": "attachment; filename=project_outline.geojson", + "Content-Disposition": "attachment; filename=task_boundary.geojson", "Content-Type": "application/media", } - return Response(content=out, headers=headers) + return Response(content=task_geojson, headers=headers) -@router.post("/{project_id}/geometries") +@router.post("/{project_id}/geometry/records") async def create_geom_log( geom_log: project_schemas.GeometryLogIn, current_user: Annotated[ProjectUserDict, Depends(mapper)], @@ -1310,7 +1308,32 @@ async def create_geom_log( return geometries -@router.delete("/{project_id}/geometries") +@router.get( + "/{project_id}/geometry/records", response_model=list[project_schemas.GeometryLogIn] +) +async def read_geom_logs( + db: Annotated[Connection, Depends(db_conn)], + project_user: Annotated[ProjectUserDict, Depends(mapper)], +): + """Retrieve all geometry logs for a specific project. + + This endpoint fetches geometry records. + - Bad submitted feature and + - new feature drawn in a project + + Args: + db: The database connection. + project_user: The currently authenticated project user details. + + Returns: + list[project_schemas.GeometryLogIn]: A list of geometry log entries. + """ + project_id = project_user.get("project").id + geometries = await DbGeometryLog.all(db, project_id) + return geometries + + +@router.delete("/{project_id}/geometry/records/{geom_id}") async def delete_geom_log( geom_id: str, project_user: Annotated[ diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 4e27ba2fb0..2de8745d13 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -46,6 +46,7 @@ class GeometryLogIn(BaseModel): """Geometry log insert.""" + id: Optional[UUID] = None status: GeomStatus geojson: dict project_id: Optional[int] = None @@ -64,7 +65,7 @@ def parse_input_geometry( if value is None: return None featcol = geojson_to_featcol(value) - return featcol.get("features")[0].get("geometry") + return featcol.get("features")[0] class ProjectInBase(DbProject): diff --git a/src/backend/app/submissions/submission_routes.py b/src/backend/app/submissions/submission_routes.py index f9494276e5..a297f560ff 100644 --- a/src/backend/app/submissions/submission_routes.py +++ b/src/backend/app/submissions/submission_routes.py @@ -303,7 +303,7 @@ async def submission_table( "and __system/submissionDate le {}T23:59:59.999+00:00" ).format(start_date, end_date) - if review_state: + if review_state and review_state != "received": review_filter = f"__system/reviewState eq '{review_state}'" filters["$filter"] = ( f"{filters['$filter']} and {review_filter}" @@ -314,6 +314,12 @@ async def submission_table( data = await submission_crud.get_submission_by_project(project, filters) total_count = data.get("@odata.count", 0) submissions = data.get("value", []) + if review_state == "received": + submissions = [ + sub for sub in submissions if sub["__system"].get("reviewState") is None + ] + total_count = len(submissions) + instance_ids = [ sub["__id"] for sub in submissions diff --git a/src/backend/migrations/005-api-key.sql b/src/backend/migrations/005-api-key.sql new file mode 100644 index 0000000000..bf9d11335b --- /dev/null +++ b/src/backend/migrations/005-api-key.sql @@ -0,0 +1,11 @@ +-- ## Migration add an api_key field to the users table + +-- Start a transaction + +BEGIN; + +ALTER TABLE public.users +ADD COLUMN IF NOT EXISTS api_key CHARACTER VARYING; + +-- Commit the transaction +COMMIT; diff --git a/src/backend/migrations/init/fmtm_base_schema.sql b/src/backend/migrations/init/fmtm_base_schema.sql index e44398de07..eea48da46e 100644 --- a/src/backend/migrations/init/fmtm_base_schema.sql +++ b/src/backend/migrations/init/fmtm_base_schema.sql @@ -356,6 +356,7 @@ CREATE TABLE public.users ( tasks_validated integer NOT NULL DEFAULT 0, tasks_invalidated integer NOT NULL DEFAULT 0, projects_mapped integer [], + api_key character varying, registered_at timestamp with time zone DEFAULT now(), last_login_at timestamp with time zone DEFAULT now() ); diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index c477306e0a..7d1fe45c13 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -18,7 +18,7 @@ [project] name = "fmtm" -version = "2024.5.0" +version = "2025.1.0" description = "Field Mapping Tasking Manager - coordinated field mapping." authors = [ {name = "HOTOSM", email = "sysadmin@hotosm.org"}, @@ -45,7 +45,7 @@ dependencies = [ "sozipfile==0.3.2", "cryptography==43.0.3", "pyjwt==2.9.0", - "osm-fieldwork==0.18.0", + "osm-fieldwork==0.18.2", "osm-login-python==2.0.0", "osm-rawdata==0.4.1", "fmtm-splitter==2.0.0", @@ -127,7 +127,7 @@ asyncio_default_fixture_loop_scope="session" [tool.commitizen] name = "cz_conventional_commits" -version = "2024.5.0" +version = "2025.1.0" version_files = [ "pyproject.toml:version", "app/__version__.py", diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 6b8990200f..6bf7aa74b9 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -493,7 +493,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/3e/0d/424de6e5612f1399f [[package]] name = "fmtm" -version = "2024.5.0" +version = "2025.1.0" source = { virtual = "." } dependencies = [ { name = "asgi-lifespan" }, @@ -568,7 +568,7 @@ requires-dist = [ { name = "httptools", specifier = "==0.6.4" }, { name = "loguru", specifier = "==0.7.2" }, { name = "minio", specifier = "==7.2.9" }, - { name = "osm-fieldwork", specifier = "==0.18.0" }, + { name = "osm-fieldwork", specifier = "==0.18.2" }, { name = "osm-login-python", specifier = "==2.0.0" }, { name = "osm-rawdata", specifier = "==0.4.1" }, { name = "psycopg", extras = ["pool"], specifier = ">=3.2.3" }, @@ -1435,7 +1435,7 @@ wheels = [ [[package]] name = "osm-fieldwork" -version = "0.18.0" +version = "0.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1459,9 +1459,9 @@ dependencies = [ { name = "shapely" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/60/a43713da67c39b47460d1d9c598324e71a59c241bd54b69fce6de4168307/osm-fieldwork-0.18.0.tar.gz", hash = "sha256:887fb94b5796bc14a2f8b6ebe0391bb7795bb685f6d9ddee6c9458072d133252", size = 1485366 } +sdist = { url = "https://files.pythonhosted.org/packages/6e/46/8833f246ee8df7e8c6d4cda682a6d85ef4e8c2716b6787031cc6192d84e6/osm-fieldwork-0.18.2.tar.gz", hash = "sha256:7e2bae57f3a2eb88dd3f91a67f89fbc7fa29ae512bd5c212ba060dc3f0ee825e", size = 1485777 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/58/8ed93d23d6f74ec92aa4326faef186f983a368aa9f028ad47d4ee6f4972b/osm_fieldwork-0.18.0-py3-none-any.whl", hash = "sha256:bde9c8c4cca9658d100e5993e986cae84b5060f71134182595a4e1896f8a7437", size = 1511469 }, + { url = "https://files.pythonhosted.org/packages/0b/2d/f9c1bfa56f0ba4a22434c2cdab2ab70e2309368f2921d6218a7ed2a54073/osm_fieldwork-0.18.2-py3-none-any.whl", hash = "sha256:469d210f78b1d391ec5c8ddcbd751365c68beb03424334d5159192464f0744c7", size = 1511940 }, ] [[package]] diff --git a/src/frontend/package.json b/src/frontend/package.json index 48b0284154..d63d878631 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "fmtm", - "version": "2024.5.0", + "version": "2025.1.0", "scripts": { "build": "vite build", "build:dev": "vite build --mode development", @@ -105,5 +105,5 @@ "tailwind-merge": "2.3.0", "uuid": "^10.0.0" }, - "packageManager": "pnpm@9.3.0+sha512.ee7b93e0c2bd11409c6424f92b866f31d3ea1bef5fbe47d3c7500cdc3c9668833d2e55681ad66df5b640c61fa9dc25d546efa54d76d7f8bf54b13614ac293631" + "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" } diff --git a/src/frontend/src/api/OrganisationService.ts b/src/frontend/src/api/OrganisationService.ts index 0a59ff53fb..d3a7ede312 100644 --- a/src/frontend/src/api/OrganisationService.ts +++ b/src/frontend/src/api/OrganisationService.ts @@ -79,12 +79,9 @@ export const MyOrganisationDataService = (url: string) => { export const PostOrganisationDataService = (url: string, payload: any) => { return async (dispatch: AppDispatch) => { - dispatch(OrganisationAction.SetOrganisationFormData({})); dispatch(OrganisationAction.PostOrganisationDataLoading(true)); const postOrganisationData = async (url, payload) => { - dispatch(OrganisationAction.SetOrganisationFormData(payload)); - try { const generateApiFormData = new FormData(); appendObjectToFormData(generateApiFormData, payload); @@ -99,6 +96,8 @@ export const PostOrganisationDataService = (url: string, payload: any) => { dispatch(OrganisationAction.PostOrganisationDataLoading(false)); dispatch(OrganisationAction.postOrganisationData(resp)); + dispatch(OrganisationAction.SetOrganisationFormData({})); + dispatch( CommonActions.SetSnackBar({ open: true, @@ -132,7 +131,16 @@ export const GetIndividualOrganizationService = (url: string) => { const getOrganisationDataResponse = await axios.get(url); const response: GetOrganisationDataModel = getOrganisationDataResponse.data; dispatch(OrganisationAction.SetIndividualOrganization(response)); - } catch (error) {} + } catch (error) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: error.response.data.detail || 'Failed to fetch organization.', + variant: 'error', + duration: 2000, + }), + ); + } }; await getOrganisationData(url); }; @@ -140,12 +148,9 @@ export const GetIndividualOrganizationService = (url: string) => { export const PatchOrganizationDataService = (url: string, payload: any) => { return async (dispatch: AppDispatch) => { - dispatch(OrganisationAction.SetOrganisationFormData({})); dispatch(OrganisationAction.PostOrganisationDataLoading(true)); const patchOrganisationData = async (url, payload) => { - dispatch(OrganisationAction.SetOrganisationFormData(payload)); - try { const generateApiFormData = new FormData(); appendObjectToFormData(generateApiFormData, payload); @@ -159,6 +164,7 @@ export const PatchOrganizationDataService = (url: string, payload: any) => { const resp: GetOrganisationDataModel = patchOrganisationData.data; dispatch(OrganisationAction.PostOrganisationDataLoading(false)); dispatch(OrganisationAction.postOrganisationData(resp)); + dispatch(OrganisationAction.SetOrganisationFormData({})); dispatch( CommonActions.SetSnackBar({ open: true, diff --git a/src/frontend/src/api/Project.ts b/src/frontend/src/api/Project.ts index df825e4f17..da4b8ecc71 100755 --- a/src/frontend/src/api/Project.ts +++ b/src/frontend/src/api/Project.ts @@ -1,10 +1,11 @@ -import { AxiosResponse } from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import { CommonActions } from '@/store/slices/CommonSlice'; import CoreModules from '@/shared/CoreModules'; import { task_state, task_event } from '@/types/enums'; import { EntityOsmMap, + geometryLogResponseType, projectDashboardDetailTypes, projectInfoType, projectTaskBoundriesType, @@ -353,3 +354,64 @@ export const DownloadSubmissionGeojson = (url: string, projectName: string) => { await downloadSubmissionGeojson(url); }; }; + +export const GetGeometryLog = (url: string) => { + return async (dispatch: AppDispatch) => { + const getProjectActivity = async (url: string) => { + try { + dispatch(ProjectActions.SetGeometryLogLoading(true)); + const response: AxiosResponse = await axios.get(url); + dispatch(ProjectActions.SetGeometryLog(response.data)); + } catch (error) { + // error means no geometry log present for the project + dispatch(ProjectActions.SetGeometryLog([])); + } finally { + dispatch(ProjectActions.SetGeometryLogLoading(false)); + } + }; + await getProjectActivity(url); + }; +}; + +export const SyncTaskState = ( + url: string, + params: { project_id: string }, + taskBoundaryFeatures: any, + geojsonStyles: any, +) => { + return async (dispatch: AppDispatch) => { + const syncTaskState = async () => { + try { + dispatch(ProjectActions.SyncTaskStateLoading(true)); + const response: AxiosResponse = await axios.get(url, { params }); + + response.data.map((task) => { + const feature = taskBoundaryFeatures?.find((feature) => feature.getId() === task.id); + const previousProperties = feature.getProperties(); + feature.setProperties({ + ...previousProperties, + task_state: task.task_state, + actioned_by_uid: task.actioned_by_uid, + actioned_by_username: task.actioned_by_username, + }); + + feature.setStyle(geojsonStyles[task.task_state]); + + dispatch( + ProjectActions.UpdateProjectTaskBoundries({ + projectId: params.project_id, + taskId: task.id, + actioned_by_uid: task.actioned_by_uid, + actioned_by_username: task.actioned_by_username, + task_state: task.task_state, + }), + ); + }); + } catch (error) { + } finally { + dispatch(ProjectActions.SyncTaskStateLoading(false)); + } + }; + await syncTaskState(); + }; +}; diff --git a/src/frontend/src/api/SubmissionService.ts b/src/frontend/src/api/SubmissionService.ts index 7c4c56604c..a041290cc8 100644 --- a/src/frontend/src/api/SubmissionService.ts +++ b/src/frontend/src/api/SubmissionService.ts @@ -1,10 +1,11 @@ -import { AxiosResponse } from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { submissionContributorsTypes, submissionFormFieldsTypes, submissionTableDataTypes, updateReviewStateType, validatedMappedType, + geometryLogType, } from '@/models/submission/submissionModel'; import CoreModules from '@/shared/CoreModules'; import { CommonActions } from '@/store/slices/CommonSlice'; @@ -107,3 +108,46 @@ export const MappedVsValidatedTaskService = (url: string) => { await MappedVsValidatedTask(url); }; }; + +// post bad and new geometries +export const PostGeometry = (url: string, payload: geometryLogType) => { + return async (dispatch: AppDispatch) => { + const postGeometry = async () => { + try { + await CoreModules.axios.post(url, payload); + } catch (error) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Failed to post geometry.', + variant: 'error', + duration: 2000, + }), + ); + } + }; + + await postGeometry(); + }; +}; + +export const DeleteGeometry = (url: string) => { + return async (dispatch: AppDispatch) => { + const deleteGeometry = async () => { + try { + await axios.delete(url); + } catch (error) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Failed to delete geometry.', + variant: 'error', + duration: 2000, + }), + ); + } + }; + + await deleteGeometry(); + }; +}; diff --git a/src/frontend/src/api/task.ts b/src/frontend/src/api/task.ts index 5f2a7dabed..c77f0111b5 100644 --- a/src/frontend/src/api/task.ts +++ b/src/frontend/src/api/task.ts @@ -2,11 +2,14 @@ import { AppDispatch } from '@/store/Store'; import CoreModules from '@/shared/CoreModules'; import { TaskActions } from '@/store/slices/TaskSlice'; -export const getDownloadProjectSubmission = (url: string, projectName: string) => { +export const getDownloadProjectSubmission = ( + url: string, + projectName: string, + params: { project_id: string; export_json: boolean; submitted_date_range: string | null }, +) => { return async (dispatch: AppDispatch) => { - const params = new URLSearchParams(url.split('?')[1]); - const isExportJson = params.get('export_json'); - const isJsonOrCsv = isExportJson === 'true' ? 'json' : 'csv'; + const isExportJson = params.export_json; + const isJsonOrCsv = isExportJson ? 'json' : 'csv'; dispatch( TaskActions.GetDownloadProjectSubmissionLoading({ type: isJsonOrCsv, @@ -16,12 +19,10 @@ export const getDownloadProjectSubmission = (url: string, projectName: string) = const getProjectSubmission = async (url: string) => { try { - const response = await CoreModules.axios.get(url, { - responseType: 'blob', - }); + const response = await CoreModules.axios.get(url, { params, responseType: 'blob' }); var a = document.createElement('a'); a.href = window.URL.createObjectURL(response.data); - a.download = isExportJson === 'true' ? `${projectName}.json` : `${projectName}.zip`; + a.download = isExportJson ? `${projectName}.json` : `${projectName}.zip`; a.click(); } catch (error) { } finally { diff --git a/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx b/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx index 79b90db9ac..9cd5fc5c84 100644 --- a/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx +++ b/src/frontend/src/components/ApproveOrganization/ApproveOrganizationHeader.tsx @@ -12,7 +12,7 @@ const ApproveOrganizationHeader = () => {
navigate('/organisation')} + onClick={() => navigate('/organization')} >
diff --git a/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx b/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx index 6752ce4861..0e0e6153ad 100644 --- a/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx +++ b/src/frontend/src/components/ApproveOrganization/OrganizationForm.tsx @@ -52,7 +52,7 @@ const OrganizationForm = () => { if (organizationApprovalSuccess) { dispatch(OrganisationAction.SetOrganisationFormData({})); dispatch(OrganisationAction.SetOrganizationApprovalStatus(false)); - navigate('/organisation'); + navigate('/organization'); } }, [organizationApprovalSuccess]); @@ -82,6 +82,15 @@ const OrganizationForm = () => { fieldType="text" disabled /> + {}} + fieldType="text" + disabled + />