diff --git a/.github/workflows/git.yml b/.github/workflows/git.yml new file mode 100644 index 0000000..52c6062 --- /dev/null +++ b/.github/workflows/git.yml @@ -0,0 +1,11 @@ +name: Lint commits + +on: [pull_request] + +jobs: + lint: + name: Commit Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main + - uses: toggle-corp/commit-lint@main diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 672267f..30521e2 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -2,6 +2,9 @@ name: CI on: pull_request: + push: + branches: + - "main" jobs: pre_commit_checks: diff --git a/.lycheeignore b/.lycheeignore index eba9e9d..50f2ca8 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -1,2 +1,8 @@ https://slack.com/shortcuts/Ft09CHNNHZNX/8d0d6f7d69a1350a91b2ef33fc6d5704 +https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword https://www.mdpi.com/2072-4292/8/10/859 + +# 404 +https:\/\/backend-(\d+)\.mapswipe\.dev\.togglecorp\.com +https://backend-stage.mapswipe.org +https://backend.mapswipe.org diff --git a/docs/overview.md b/docs/overview.md index a1adcc6..c6512f0 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -14,7 +14,7 @@ ### Old Architecture
Prev arch
Previous MapSwipe Architectural Flow
@@ -31,7 +31,7 @@ Furthermore, A background worker periodically synchronized (partially) the 2 dat ### New Architecture
New architecture
New MapSwipe Architectural Flow
diff --git a/examples/mapswipe-backend-api/.gitignore b/examples/mapswipe-backend-api/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/examples/mapswipe-backend-api/.gitignore @@ -0,0 +1 @@ +.env diff --git a/examples/mapswipe-backend-api/README.md b/examples/mapswipe-backend-api/README.md new file mode 100644 index 0000000..69a3d1b --- /dev/null +++ b/examples/mapswipe-backend-api/README.md @@ -0,0 +1,87 @@ +## Background + +This script is provided as an example for interacting with the MapSwipe backend. + +> [!CAUTION] +> Ongoing updates to the backend might render this script **out-of-date**. + +## Getting started + +> [!NOTE] +> You will need Manager account credentials to run this script. + +- Create `.env` file. +- Define the following variables: + - `MANAGER_URL`: URL for Manager Dashboard + - `BACKEND_URL`: URL for Backend + - `CSRFTOKEN_KEY`: CSRF Token Key + - `ENABLE_AUTHENTICATION` + - `FB_AUTH_URL`: Authentication URL + - `FB_USERNAME`: Manager account username + - `FB_PASSWORD`: Manager account password +- For `FB_AUTH_URL`, + - Sign-in on the Manager Dashboard and open Dev Console. + - Look for the URL which starts with https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword + image + +### Alpha Instance + +> [!CAUTION] +> The alpha instance is running inside Togglecorp's domain for internal testing. + +Your final `.env` for alpha instance should look like this: + +```dotenv +MANAGER_URL=https://manager-2.mapswipe.dev.togglecorp.com +BACKEND_URL=https://backend-2.mapswipe.dev.togglecorp.com +CSRFTOKEN_KEY=MAPSWIPE-ALPHA-2-CSRFTOKEN + +ENABLE_AUTHENTICATION=true +FB_AUTH_URL=https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# Your web-app login credential +FB_USERNAME=me@example.com +FB_PASSWORD=my-very-good-password +``` + +### Staging Instance + +Your final `.env` for staging instance should look like this: + +```dotenv +MANAGER_URL=https://managers-stage.mapswipe.org +BACKEND_URL=https://backend-stage.mapswipe.org +CSRFTOKEN_KEY=MAPSWIPE-STAGE-CSRFTOKEN + +ENABLE_AUTHENTICATION=true +FB_AUTH_URL=https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# Your web-app login credential +FB_USERNAME=me@example.com +FB_PASSWORD=my-very-good-password +``` + +### Production Instance + +Your final `.env` for production instance should look like this: + +```dotenv +MANAGER_URL=https://managers.mapswipe.org +BACKEND_URL=https://backend.mapswipe.org +CSRFTOKEN_KEY=MAPSWIPE-PROD-CSRFTOKEN + +ENABLE_AUTHENTICATION=false +FB_AUTH_URL=https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# Your web-app login credential +FB_USERNAME=me@example.com +FB_PASSWORD=my-very-good-password +``` + +## Running the script + +Run the example script using uv + +```bash +uv run run.py +``` + +> [!NOTE] +> To install uv, visit https://docs.astral.sh/uv/getting-started/installation/ diff --git a/examples/mapswipe-backend-api/run.py b/examples/mapswipe-backend-api/run.py new file mode 100644 index 0000000..f760a6a --- /dev/null +++ b/examples/mapswipe-backend-api/run.py @@ -0,0 +1,524 @@ +# /// script +# dependencies = [ +# "httpx", +# "python-dotenv", +# "python-ulid>=3.0.0", +# "typing-extensions", +# "colorlog", +# ] +# /// + +# NOTE: Please read ./README.md + +import json +import typing +import httpx +import logging +import colorlog +from dotenv import dotenv_values +from ulid import ULID + +config = dotenv_values(".env") + + +def logging_init(): + handler = colorlog.StreamHandler() + handler.setFormatter( + colorlog.ColoredFormatter( + "%(log_color)s[%(levelname)s]%(reset)s %(message)s", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + ) + ) + + logger = colorlog.getLogger() + logger.addHandler(handler) + logger.setLevel(logging.INFO) + return logger + + +logger = logging_init() + + +# Define the GraphQL query +class Query: + ME_OP_NAME = "Me" + ME = """ + query Me { + me { + id + displayName + } + } + """ + + PUBLIC_PROJECTS_OP_NAME = "PublicProjectsList" + PUBLIC_PROJECTS = """ + query PublicProjectsList($filters: ProjectFilter = {}) { + publicProjects(filters: $filters) { + totalCount + results { + id + firebaseId + name + + exportAggregatedResults { + id + file { + url + } + } + exportUsers { + id + file { + url + } + } + exportTasks { + id + file { + url + } + } + exportResults { + id + file { + url + } + } + exportModerateToHighAgreementYesMaybeGeometries { + id + file { + url + } + } + exportHotTaskingManagerGeometries { + id + file { + url + } + } + exportHistory { + id + file { + url + } + } + exportGroups { + id + file { + url + } + } + exportAreaOfInterest { + id + file { + url + } + } + exportAggregatedResultsWithGeometry { + id + file { + url + } + } + + } + } + } + """ + + PROJECTS_OP_NAME = "ProjectsList" + PROJECTS = """ + query ProjectsList { + projects { + totalCount + results { + id + firebaseId + name + + exportAggregatedResults { + id + file { + url + } + } + exportUsers { + id + file { + url + } + } + exportTasks { + id + file { + url + } + } + exportResults { + id + file { + url + } + } + exportModerateToHighAgreementYesMaybeGeometries { + id + file { + url + } + } + exportHotTaskingManagerGeometries { + id + file { + url + } + } + exportHistory { + id + file { + url + } + } + exportGroups { + id + file { + url + } + } + exportAreaOfInterest { + id + file { + url + } + } + exportAggregatedResultsWithGeometry { + id + file { + url + } + } + + } + } + } + """ + + ORGANIZATIONS_OP_NAME = "Organizations" + ORGANIZATIONS = """ + query Organizations { + organizations { + totalCount + results { + id + name + } + } + } + """ + + CREATE_DRAFT_PROJECTS_OP_NAME = "NewDraftProject" + CREATE_DRAFT_PROJECTS = """ + mutation NewDraftProject($data: ProjectCreateInput!) { + createProject(data: $data) { + ... on OperationInfo { + __typename + messages { + code + field + kind + message + } + } + ... on ProjectTypeMutationResponseType { + errors + ok + result { + id + firebaseId + } + } + } + } + """ + + CREATE_PROJECT_ASSET_OP_NAME = "CreateProjectAsset" + CREATE_PROJECT_ASSET = """ + mutation CreateProjectAsset($data: ProjectAssetCreateInput!) { + createProjectAsset(data: $data) { + ... on ProjectAssetTypeMutationResponseType { + errors + ok + result { + id + file { + name + url + } + } + } + } + } + """ + + +class MapSwipeApiClient: + # Set the base URL + BASE_URL = config["BACKEND_URL"] + CSRFTOKEN_KEY = config["CSRFTOKEN_KEY"] + MANAGER_URL = config["MANAGER_URL"] + + ENABLE_AUTHENTICATION = ( + config.get("ENABLE_AUTHENTICATION", "false").lower() == "true" + ) + FB_AUTH_URL = config.get("FB_AUTH_URL") + + # Your web-app login credential + FB_USERNAME = config.get("FB_USERNAME") + FB_PASSWORD = config.get("FB_PASSWORD") + + def __enter__(self): + self.client = httpx.Client(base_url=self.BASE_URL, timeout=10.0) + + # For CSRF + health_resp = self.client.get("/health-check/") + health_resp.raise_for_status() + + if self.ENABLE_AUTHENTICATION: + self.login_with_firebaes() + + csrf_token = self.client.cookies.get(self.CSRFTOKEN_KEY) + self.headers = { + # Required for CSRF verification + "x-csrftoken": csrf_token, + "origin": self.MANAGER_URL, + } + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.client.close() + return False # If True, suppresses exceptions + + def login_with_firebaes(self): + logger.info("Logging using firebase auth") + resp = httpx.post( + self.FB_AUTH_URL, + headers={ + "origin": self.MANAGER_URL, + }, + json={ + "returnSecureToken": True, + "email": self.FB_USERNAME, + "password": self.FB_PASSWORD, + "clientType": "CLIENT_TYPE_WEB", + }, + ) + resp.raise_for_status() + + idToken = resp.json()["idToken"] + + resp = self.client.post( + "/firebase-auth/", + json={ + "token": idToken, + }, + ) + resp.raise_for_status() + + def graphql_request_with_files( + self, + operation_name: str, + query: str, + *, + files: dict[typing.Any, typing.Any], + map: dict[typing.Any, typing.Any], + variables: dict[typing.Any, typing.Any] | None = None, + ): + # Request type: form data + graphql_resp = self.client.post( + "/graphql/", + headers=self.headers, + files=files, + data={ + "operations": json.dumps( + { + "query": query, + "variables": variables, + }, + ), + "map": json.dumps(map), + }, + ) + + if not (200 <= graphql_resp.status_code < 300): + logger.error("Error: %s", graphql_resp.text) + graphql_resp.raise_for_status() + + return graphql_resp.json() + + def graphql_request( + self, + operation_name: str, + query: str, + variables: dict[typing.Any, typing.Any] | None = None, + ): + payload = { + "operationName": operation_name, + "query": query, + "variables": variables, + } + + graphql_resp = self.client.post( + "/graphql/", + headers=self.headers, + json=payload, + ) + + if not (200 <= graphql_resp.status_code < 300): + logger.error("Error: %s", graphql_resp.text) + graphql_resp.raise_for_status() + + return graphql_resp.json() + + def create_draft_project(self, params): + resp = self.graphql_request( + Query.CREATE_DRAFT_PROJECTS_OP_NAME, + Query.CREATE_DRAFT_PROJECTS, + {"data": params}, + ) + + if errors := resp.get("errors"): + logger.error("Failed to create new project: %s", errors) + return None + + if errors := resp["data"]["createProject"].get("messages"): + logger.error("Failed to create new project: %s", errors) + return None + + if errors := resp["data"]["createProject"].get("errors"): + logger.error("Failed to create new project: %s", errors) + return None + + return resp["data"]["createProject"]["result"]["id"] + + def create_project_asset( + self, + *, + project_file, + params, + ): + resp = self.graphql_request_with_files( + Query.CREATE_PROJECT_ASSET_OP_NAME, + Query.CREATE_PROJECT_ASSET, + files={ + "projectFile": project_file, + }, + map={ + "projectFile": ["variables.data.file"], + }, + variables={"data": params}, + ) + + if errors := resp.get("errors"): + logger.error("Failed to create project asset: %s", errors) + return None + + if errors := resp["data"]["createProjectAsset"].get("messages"): + logger.error("Failed to create project asset: %s", errors) + return None + + if errors := resp["data"]["createProjectAsset"].get("errors"): + logger.error("Failed to create project asset: %s", errors) + return None + + return resp["data"]["createProjectAsset"]["result"]["id"] + + +def run(): + with MapSwipeApiClient() as api_client: + logger.info("Public endpoints") + + logger.info( + "%s: %s", + Query.PUBLIC_PROJECTS_OP_NAME, + api_client.graphql_request( + Query.PUBLIC_PROJECTS_OP_NAME, + Query.PUBLIC_PROJECTS, + variables={ + "filters": { + "status": { + "exact": "FINISHED", + } + } + }, + ), + ) + + logger.info("Private endpoints") + me_info = api_client.graphql_request(Query.ME_OP_NAME, Query.ME)["data"]["me"] + if not me_info: + raise Exception("Not logged in.... :(") + logger.info("%s: %s", Query.ME_OP_NAME, me_info) + + organization_id = api_client.graphql_request( + Query.ORGANIZATIONS_OP_NAME, + Query.ORGANIZATIONS, + )["data"]["organizations"]["results"][0]["id"] + + logger.info( + "%s: %s", + Query.PUBLIC_PROJECTS_OP_NAME, + api_client.graphql_request(Query.PROJECTS_OP_NAME, Query.PROJECTS), + ) + + new_project_client_id = str(ULID()) + new_project_topic_name = "Test - Building Validation - 8" + + logger.warning( + "You are about to create projects in %s. This may modify the environment.", + api_client.BASE_URL, + ) + confirmation = input('Proceed? Type "yes" to continue: ').strip().lower() + if confirmation != "yes": + logger.warning("Operation cancelled. No changes were made.") + return None + + new_project_id = api_client.create_draft_project( + { + "clientId": new_project_client_id, + "projectType": "VALIDATE", + "region": "Nepal", + "topic": new_project_topic_name, + "description": "Validate building footprints", + "projectInstruction": "Validate building footprints", + "lookFor": "buildings", + "projectNumber": 1000, + "requestingOrganization": organization_id, + "additionalInfoUrl": "fair-dev.hotosm.org", + "team": None, + } + ) + assert new_project_id is not None + + logger.info("%s: %s", "Create Draft Project", new_project_id) + + with open("./sample_image.png", "rb") as image_file: + new_project_asset_client_id = str(ULID()) + new_project_asset = api_client.create_project_asset( + project_file=image_file, + params={ + "inputType": "COVER_IMAGE", + "clientId": new_project_asset_client_id, + "project": new_project_id, + }, + ) + + logger.info("%s: %s", "Create Project Asset", new_project_asset) + + +run() diff --git a/examples/mapswipe-backend-api/sample_image.png b/examples/mapswipe-backend-api/sample_image.png new file mode 100644 index 0000000..b80767c Binary files /dev/null and b/examples/mapswipe-backend-api/sample_image.png differ