From ac195428f13b4bfadc7c51b204959d227b81604b Mon Sep 17 00:00:00 2001 From: Andrea Chirillano Date: Mon, 4 May 2026 21:53:35 +0000 Subject: [PATCH 1/5] Add: url for projects in plan --- .../alembic/versions/006_add_plan_images.py | 41 +++++++ backend/app/api/routes/plans/plans.py | 28 ++++- backend/app/models/plan.py | 11 ++ backend/app/services/chatmap_service.py | 49 ++++++++ backend/app/services/drone_tm_service.py | 11 +- backend/app/services/export_tool_service.py | 4 +- backend/app/services/fair_service.py | 4 +- .../app/services/open_aerial_map_service.py | 13 ++- backend/app/services/plans_service.py | 95 +++++++++++++++- backend/app/services/umap_service.py | 29 ++++- backend/app/services/url_resolver.py | 29 +++++ backend/app/tests/test_url_resolver.py | 106 ++++++++++++++++++ .../src/portal-plans/components/PlanForm.tsx | 13 ++- .../components/PlanProjectCard.tsx | 39 +++---- .../components/ProjectPickerDialog.tsx | 85 +++++++++++++- .../portal-plans/hooks/useAllUserProjects.ts | 1 + frontend/src/portal-plans/hooks/usePlans.ts | 26 ++++- frontend/src/portal-plans/types.ts | 7 ++ frontend/src/utils/appMeta.ts | 2 + 19 files changed, 540 insertions(+), 53 deletions(-) create mode 100644 backend/alembic/versions/006_add_plan_images.py create mode 100644 backend/app/services/chatmap_service.py create mode 100644 backend/app/services/url_resolver.py create mode 100644 backend/app/tests/test_url_resolver.py diff --git a/backend/alembic/versions/006_add_plan_images.py b/backend/alembic/versions/006_add_plan_images.py new file mode 100644 index 0000000..589ba12 --- /dev/null +++ b/backend/alembic/versions/006_add_plan_images.py @@ -0,0 +1,41 @@ +"""add plan_images table + +Revision ID: 006_add_plan_images +Revises: 005_add_plan_is_public +Create Date: 2026-04-24 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "006_add_plan_images" +down_revision = "005_add_plan_is_public" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "plan_images", + sa.Column("id", sa.String(), nullable=False), + sa.Column("plan_id", sa.String(), nullable=False), + sa.Column("s3_key", sa.String(), nullable=False), + sa.Column("url", sa.String(), nullable=False), + sa.Column("display_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["plan_id"], ["plans.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_plan_images_plan_id", "plan_images", ["plan_id"]) + + +def downgrade() -> None: + op.drop_index("ix_plan_images_plan_id", table_name="plan_images") + op.drop_table("plan_images") diff --git a/backend/app/api/routes/plans/plans.py b/backend/app/api/routes/plans/plans.py index 7d18fa8..b42e5e9 100644 --- a/backend/app/api/routes/plans/plans.py +++ b/backend/app/api/routes/plans/plans.py @@ -10,9 +10,12 @@ PlanRead, PlanReadHydrated, PlanUpdate, + UrlResolveRequest, + UrlResolveResponse, ) from app.services import plans_service -from app.services.plans_service import DuplicateProjectError +from app.services.exceptions import UpstreamUnavailable +from app.services.plans_service import DuplicateProjectError, InvalidUrlError, ProjectNotFoundError router = APIRouter(prefix="/plans", tags=["plans"]) @@ -39,6 +42,29 @@ async def create_plan( raise HTTPException(status_code=422, detail=str(e)) +@router.post("/resolve-url", response_model=UrlResolveResponse) +async def resolve_project_url( + payload: UrlResolveRequest, + request: Request, + user: CurrentUser, +) -> UrlResolveResponse: + """Parse a project URL and confirm the project exists upstream. + + Returns app, project_id, and raw upstream data on success. + 422 if the URL format is not recognized, 404 if project not found, + 502 if the upstream service is unreachable. + """ + hanko_cookie = request.cookies.get("hanko") + try: + return await plans_service.resolve_project_url(payload.url, hanko_cookie=hanko_cookie) + except InvalidUrlError: + raise HTTPException(status_code=422, detail="URL does not match any supported app") + except ProjectNotFoundError: + raise HTTPException(status_code=404, detail="project_not_found") + except UpstreamUnavailable: + raise HTTPException(status_code=502, detail="upstream_unavailable") + + @router.get("/shared/{plan_id}", response_model=PlanReadHydrated) async def get_shared_plan( request: Request, diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index aa26aa0..bb84098 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator AppLiteral = Literal[ + "chatmap", "drone-tasking-manager", "export-tool", "fair", @@ -95,3 +96,13 @@ class PlanReadHydrated(BaseModel): class PlanTag(BaseModel): id: str name: str + + +class UrlResolveRequest(BaseModel): + url: str = Field(..., min_length=1, max_length=2048) + + +class UrlResolveResponse(BaseModel): + app: AppLiteral + project_id: str + upstream: dict | None = None diff --git a/backend/app/services/chatmap_service.py b/backend/app/services/chatmap_service.py new file mode 100644 index 0000000..225e9ba --- /dev/null +++ b/backend/app/services/chatmap_service.py @@ -0,0 +1,49 @@ +"""ChatMap service: fetch map metadata by UUID for plan hydration and URL resolution.""" + +import os + +import httpx + +from app.core.cache import DEFAULT_TTL, get_cached, set_cached +from app.core.config import settings +from app.services.exceptions import UpstreamUnavailable + +CHATMAP_API_URL = settings.chatmap_api_url +CHATMAP_VERIFY_SSL = os.getenv("CHATMAP_VERIFY_SSL", "false").lower() == "true" + + +async def fetch_map_by_id( + map_id: str, + *, + base_url: str | None = None, + hanko_cookie: str | None = None, +) -> dict | None: + """Fetch a ChatMap map by UUID. None if not found or private/unauthorized. + + Returns {"name": ..., "id": ...} on success. + Raises UpstreamUnavailable on connection or server errors. + """ + cache_key = f"chatmap_map_{map_id}" + cached = get_cached(cache_key) + if cached is not None: + return cached + + url = f"{base_url or CHATMAP_API_URL}/map/{map_id}" + cookies = {"hanko": hanko_cookie} if hanko_cookie else {} + try: + async with httpx.AsyncClient( + timeout=30.0, verify=CHATMAP_VERIFY_SSL, follow_redirects=True + ) as client: + response = await client.get( + url, headers={"accept": "application/json"}, cookies=cookies + ) + if response.status_code in (401, 403, 404): + return None + response.raise_for_status() + data = response.json() + except (httpx.RequestError, httpx.HTTPStatusError) as e: + raise UpstreamUnavailable(f"chatmap: {e}") from e + + filtered = {"name": data.get("name"), "id": data.get("id")} + set_cached(cache_key, filtered, DEFAULT_TTL) + return filtered diff --git a/backend/app/services/drone_tm_service.py b/backend/app/services/drone_tm_service.py index 9b3acb5..8425095 100644 --- a/backend/app/services/drone_tm_service.py +++ b/backend/app/services/drone_tm_service.py @@ -9,20 +9,21 @@ DRONE_TM_BACKEND_URL = settings.drone_tm_api_base_url or settings.drone_tm_api_url -def verify_ssl() -> bool: - return not DRONE_TM_BACKEND_URL.startswith("https://") or settings.drone_tm_verify_ssl +def verify_ssl(base_url: str | None = None) -> bool: + effective = base_url or DRONE_TM_BACKEND_URL + return not effective.startswith("https://") or bool(settings.drone_tm_verify_ssl) -async def fetch_project_by_id(project_id: str) -> dict | None: +async def fetch_project_by_id(project_id: str, *, base_url: str | None = None) -> dict | None: """Fetch a single DroneTM project by id. None on 404, raises UpstreamUnavailable on failure.""" cache_key = f"dronetm_project_{project_id}" cached = get_cached(cache_key) if cached is not None: return cached - url = f"{DRONE_TM_BACKEND_URL}/projects/{project_id}" + url = f"{base_url or DRONE_TM_BACKEND_URL}/projects/{project_id}" try: - async with httpx.AsyncClient(timeout=30.0, verify=verify_ssl()) as client: + async with httpx.AsyncClient(timeout=30.0, verify=verify_ssl(base_url)) as client: response = await client.get(url, headers={"Accept": "application/json"}) if response.status_code == 404: return None diff --git a/backend/app/services/export_tool_service.py b/backend/app/services/export_tool_service.py index 1f36aa0..b9b271f 100644 --- a/backend/app/services/export_tool_service.py +++ b/backend/app/services/export_tool_service.py @@ -9,14 +9,14 @@ EXPORT_TOOL_API_BASE_URL = settings.export_tool_api_url -async def fetch_job_by_uid(uid: str) -> dict | None: +async def fetch_job_by_uid(uid: str, *, base_url: str | None = None) -> dict | None: """Fetch an export job by uid. None on 404, raises UpstreamUnavailable on failure.""" cache_key = f"export_job_{uid}" cached = get_cached(cache_key) if cached is not None: return cached - url = f"{EXPORT_TOOL_API_BASE_URL}/jobs/{uid}" + url = f"{base_url or EXPORT_TOOL_API_BASE_URL}/jobs/{uid}" try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url) diff --git a/backend/app/services/fair_service.py b/backend/app/services/fair_service.py index 769b90d..da01e31 100644 --- a/backend/app/services/fair_service.py +++ b/backend/app/services/fair_service.py @@ -10,14 +10,14 @@ FAIR_VERIFY_SSL = settings.fair_verify_ssl -async def fetch_model_by_id(mid: str) -> dict | None: +async def fetch_model_by_id(mid: str, *, base_url: str | None = None) -> dict | None: """Fetch a single fAIr model by id. None on 404, raises UpstreamUnavailable on failure.""" cache_key = f"fair_model_{mid}" cached = get_cached(cache_key) if cached is not None: return cached - url = f"{FAIR_API_BASE_URL}/model/{mid}/" + url = f"{base_url or FAIR_API_BASE_URL}/model/{mid}/" try: async with httpx.AsyncClient(timeout=30.0, verify=FAIR_VERIFY_SSL) as client: response = await client.get(url, headers={"accept": "application/json"}) diff --git a/backend/app/services/open_aerial_map_service.py b/backend/app/services/open_aerial_map_service.py index 3d317f4..7b44b24 100644 --- a/backend/app/services/open_aerial_map_service.py +++ b/backend/app/services/open_aerial_map_service.py @@ -9,9 +9,10 @@ OAM_API_BASE_URL = settings.oam_api_url -async def fetch_imagery_by_id(image_id: str) -> dict | None: - """Fetch OAM image metadata by id via the live API. None on 404, raises UpstreamUnavailable on failure. +async def fetch_imagery_by_id(image_id: str, *, base_url: str | None = None) -> dict | None: + """Fetch OAM image metadata by id. + None on 404, raises UpstreamUnavailable on failure. Note: this does not consult the local oam_images DB table — hydration of plans uses the live API directly so orphan detection works even when local sync is stale. """ @@ -20,7 +21,7 @@ async def fetch_imagery_by_id(image_id: str) -> dict | None: if cached is not None: return cached - url = f"{OAM_API_BASE_URL}/meta/{image_id}" + url = f"{base_url or OAM_API_BASE_URL}/meta/{image_id}" try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url) @@ -31,10 +32,10 @@ async def fetch_imagery_by_id(image_id: str) -> dict | None: except (httpx.RequestError, httpx.HTTPStatusError) as e: raise UpstreamUnavailable(f"open-aerial-map: {e}") from e - results = data.get("results") or [] - if not results: + raw = data.get("results") or [] + if not raw: return None - result = results[0] + result = raw if isinstance(raw, dict) else raw[0] filtered = { "title": result.get("title"), "thumbnail": (result.get("properties") or {}).get("thumbnail"), diff --git a/backend/app/services/plans_service.py b/backend/app/services/plans_service.py index a93fd60..ba08814 100644 --- a/backend/app/services/plans_service.py +++ b/backend/app/services/plans_service.py @@ -19,8 +19,11 @@ PlanReadHydrated, PlanTag, PlanUpdate, + UrlResolveResponse, ) +from app.services import url_resolver from app.services import ( + chatmap_service, drone_tm_service, export_tool_service, fair_service, @@ -36,6 +39,14 @@ class DuplicateProjectError(ValueError): """Raised when a plan payload contains duplicate (app, project_id) entries.""" +class InvalidUrlError(ValueError): + """Raised when a URL does not match any supported app pattern.""" + + +class ProjectNotFoundError(ValueError): + """Raised when a URL resolves to an app/project_id that does not exist upstream.""" + + APP_FETCHERS = { "tasking-manager": tasking_manager_service.fetch_project_by_id, "field-tm": field_tm_service.fetch_project_by_id, @@ -43,6 +54,7 @@ class DuplicateProjectError(ValueError): "fair": fair_service.fetch_model_by_id, "open-aerial-map": open_aerial_map_service.fetch_imagery_by_id, "export-tool": export_tool_service.fetch_job_by_uid, + "chatmap": chatmap_service.fetch_map_by_id, } @@ -182,10 +194,42 @@ async def delete_plan(db: AsyncSession, owner_id: str, plan_id: str) -> bool: async def hydrate_one( row: PlanProject, hanko_cookie: str | None = None ) -> HydratedProjectItem: + if row.app == "chatmap": + try: + upstream = await chatmap_service.fetch_map_by_id( + row.project_id, + base_url="https://chatmap.hotosm.org/api/v1", + hanko_cookie=hanko_cookie, + ) + except Exception: + return HydratedProjectItem( + app=row.app, + project_id=row.project_id, + data=row.data, + upstream=None, + error="upstream_unavailable", + ) + if upstream is None: + return HydratedProjectItem( + app=row.app, + project_id=row.project_id, + data=row.data, + upstream=None, + error="not_found", + ) + return HydratedProjectItem( + app=row.app, + project_id=row.project_id, + data=row.data, + upstream=upstream, + error=None, + ) + if row.app == "umap": try: - upstream = await umap_service.fetch_map_metadata_by_id( - row.project_id, hanko_cookie=hanko_cookie + upstream = await umap_service.fetch_map_by_id( + row.project_id, + base_url="https://umap.hotosm.org", ) except Exception: return HydratedProjectItem( @@ -372,3 +416,50 @@ def attach_plan_tags( continue item["plans"] = [t.model_dump() for t in tags.get(str(raw_id), [])] return items + + +# Canonical production base URLs used only when resolving a user-pasted URL. +# Plan hydration goes through APP_FETCHERS or explicit special cases, which respect env config. +_CANONICAL_RESOLVE: dict[str, tuple] = { + "drone-tasking-manager": (drone_tm_service.fetch_project_by_id, "https://api.drone.hotosm.org"), + "fair": (fair_service.fetch_model_by_id, "https://api-prod.fair.hotosm.org/api/v1"), + "export-tool": (export_tool_service.fetch_job_by_uid, "https://export.hotosm.org/api"), + "open-aerial-map": (open_aerial_map_service.fetch_imagery_by_id, "https://api.openaerialmap.org"), + "umap": (umap_service.fetch_map_by_id, "https://umap.hotosm.org"), +} + + +async def resolve_project_url( + url: str, hanko_cookie: str | None = None +) -> UrlResolveResponse: + """Parse a project URL, verify it exists upstream, and return app/project_id/upstream. + + Raises: + InvalidUrlError: URL does not match any supported app pattern. + ProjectNotFoundError: URL parses successfully but project does not exist upstream. + UpstreamUnavailable: Upstream service is unreachable or returned an error. + """ + parsed = url_resolver.parse_project_url(url) + if parsed is None: + raise InvalidUrlError(url) + + app, project_id = parsed + + try: + if app == "chatmap": + upstream = await chatmap_service.fetch_map_by_id( + project_id, + base_url="https://chatmap.hotosm.org/api/v1", + hanko_cookie=hanko_cookie, + ) + elif app in _CANONICAL_RESOLVE: + fetcher, canonical_base = _CANONICAL_RESOLVE[app] + upstream = await fetcher(project_id, base_url=canonical_base) + else: + upstream = await APP_FETCHERS[app](project_id) + except Exception as e: + raise UpstreamUnavailable(app) from e + + if upstream is None: + raise ProjectNotFoundError(f"{app}:{project_id}") + return UrlResolveResponse(app=app, project_id=project_id, upstream=upstream) diff --git a/backend/app/services/umap_service.py b/backend/app/services/umap_service.py index 1d9b972..ae624de 100644 --- a/backend/app/services/umap_service.py +++ b/backend/app/services/umap_service.py @@ -47,7 +47,10 @@ async def fetch_map_by_location(project_id: str) -> dict | None: async def fetch_map_metadata_by_id( - map_id: str, hanko_cookie: str | None = None + map_id: str, + hanko_cookie: str | None = None, + *, + base_url: str | None = None, ) -> dict | None: """Fetch uMap map metadata by numeric map ID. Used for plan hydration. @@ -64,7 +67,7 @@ async def fetch_map_metadata_by_id( if not hanko_cookie: return None - url = f"{UMAP_BASE_URL}/api/v1/maps/" + url = f"{base_url or UMAP_BASE_URL}/api/v1/maps/" try: async with httpx.AsyncClient( timeout=30.0, verify=UMAP_VERIFY_SSL, follow_redirects=True @@ -94,3 +97,25 @@ async def fetch_map_metadata_by_id( } set_cached(cache_key, filtered, DEFAULT_TTL) return filtered + + +async def fetch_map_by_id(map_id: str, *, base_url: str | None = None) -> dict | None: + """Fetch uMap map properties via public geojson endpoint. No auth required. + + Returns the map properties dict (includes 'name') on success, None on 404. + Raises UpstreamUnavailable on connection or HTTP errors. + """ + url = f"{base_url or UMAP_BASE_URL}/map/{map_id}/geojson/" + try: + async with httpx.AsyncClient( + timeout=30.0, verify=UMAP_VERIFY_SSL, follow_redirects=True + ) as client: + response = await client.get(url) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + except (httpx.RequestError, httpx.HTTPStatusError) as e: + raise UpstreamUnavailable(f"umap: {e}") from e + + return data.get("properties") diff --git a/backend/app/services/url_resolver.py b/backend/app/services/url_resolver.py new file mode 100644 index 0000000..2f10013 --- /dev/null +++ b/backend/app/services/url_resolver.py @@ -0,0 +1,29 @@ +"""URL → (app, project_id) parser for the plan resolve-url endpoint.""" + +import re + +from app.models.plan import AppLiteral + +_PATTERNS: list[tuple[re.Pattern[str], AppLiteral]] = [ + (re.compile(r"https?://tasks\.hotosm\.org/projects/(\d+)", re.I), "tasking-manager"), + # Drone TM project IDs can be numeric or slug-based (e.g. "bo-phase-3") + (re.compile(r"https?://drone\.hotosm\.org/projects/([^/\s?#]+)", re.I), "drone-tasking-manager"), + (re.compile(r"https?://field\.hotosm\.org/projects/(\d+)", re.I), "field-tm"), + (re.compile(r"https?://fair\.hotosm\.org/ai-models/(\d+)", re.I), "fair"), + (re.compile(r"https?://export\.hotosm\.org/v3/exports/([0-9a-f\-]{8,})", re.I), "export-tool"), + # OAM: https://map.openaerialmap.org/#/{coords}/latest/{hex-id}[?...] + (re.compile(r"https?://map\.openaerialmap\.org/#/[^/]+/latest/([0-9a-f]+)", re.I), "open-aerial-map"), + # uMap: https://umap.hotosm.org/{locale}/map/{slug}_{id}[#...] + (re.compile(r"https?://umap\.hotosm\.org/[a-z]{2,5}/map/[^#/]+_(\d+)", re.I), "umap"), + # ChatMap: https://chatmap.hotosm.org/#map/{uuid} + (re.compile(r"https?://chatmap\.hotosm\.org/#map/([0-9a-f-]+)", re.I), "chatmap"), +] + + +def parse_project_url(url: str) -> tuple[AppLiteral, str] | None: + """Return (app, project_id) if url matches a known pattern, else None.""" + for pattern, app in _PATTERNS: + m = pattern.match(url) + if m: + return app, m.group(1) + return None diff --git a/backend/app/tests/test_url_resolver.py b/backend/app/tests/test_url_resolver.py new file mode 100644 index 0000000..4cc1bd1 --- /dev/null +++ b/backend/app/tests/test_url_resolver.py @@ -0,0 +1,106 @@ +"""Unit tests for app/services/url_resolver.py — parse_project_url.""" + +import pytest + +from app.services.url_resolver import parse_project_url + + +@pytest.mark.parametrize( + "url, expected_app, expected_id", + [ + # --- tasking-manager --- + ("https://tasks.hotosm.org/projects/555", "tasking-manager", "555"), + ("https://tasks.hotosm.org/projects/1", "tasking-manager", "1"), + ("https://tasks.hotosm.org/projects/12345/", "tasking-manager", "12345"), + ("https://tasks.hotosm.org/projects/42?lang=en", "tasking-manager", "42"), + # --- drone-tasking-manager --- + ("https://drone.hotosm.org/projects/7", "drone-tasking-manager", "7"), + ("https://drone.hotosm.org/projects/200/tasks", "drone-tasking-manager", "200"), + ("https://drone.hotosm.org/projects/bo-phase-3", "drone-tasking-manager", "bo-phase-3"), + ("https://drone.hotosm.org/projects/my-project-2024", "drone-tasking-manager", "my-project-2024"), + # --- field-tm --- + ("https://field.hotosm.org/projects/99", "field-tm", "99"), + ("https://field.hotosm.org/projects/99/", "field-tm", "99"), + # --- fair --- + ("https://fair.hotosm.org/ai-models/7", "fair", "7"), + ("https://fair.hotosm.org/ai-models/123/", "fair", "123"), + # --- export-tool --- + ( + "https://export.hotosm.org/v3/exports/4b7d8c9a-1234-5678-abcd-ef0123456789", + "export-tool", + "4b7d8c9a-1234-5678-abcd-ef0123456789", + ), + ( + "https://export.hotosm.org/v3/exports/abcdef12-0000-0000-0000-000000000000/", + "export-tool", + "abcdef12-0000-0000-0000-000000000000", + ), + # --- open-aerial-map --- + ( + "https://map.openaerialmap.org/#/-18.720703125,18.562947442888312,3/latest/69f7b2056e5c3ae8d432f323", + "open-aerial-map", + "69f7b2056e5c3ae8d432f323", + ), + ( + "https://map.openaerialmap.org/#/0,0,3/latest/abcdef1234567890abcdef12?_k=xyz", + "open-aerial-map", + "abcdef1234567890abcdef12", + ), + # --- umap --- + ( + "https://umap.hotosm.org/es/map/test-portal_2675#16/-34.572928/-58.430572", + "umap", + "2675", + ), + ("https://umap.hotosm.org/en/map/my-map_42", "umap", "42"), + # slug with multiple underscores — should capture the LAST _{digits} + ("https://umap.hotosm.org/es/map/some_map_name_999#10/0/0", "umap", "999"), + # --- chatmap --- + ( + "https://chatmap.hotosm.org/#map/d4ca5204-2352-406b-8a95-f21be9d86a27", + "chatmap", + "d4ca5204-2352-406b-8a95-f21be9d86a27", + ), + ( + "https://chatmap.hotosm.org/#map/aaaabbbb-cccc-dddd-eeee-ffffffffffff", + "chatmap", + "aaaabbbb-cccc-dddd-eeee-ffffffffffff", + ), + ], +) +def test_valid_urls(url: str, expected_app: str, expected_id: str) -> None: + result = parse_project_url(url) + assert result is not None, f"Expected match for {url!r}" + app, project_id = result + assert app == expected_app + assert project_id == expected_id + + +@pytest.mark.parametrize( + "url", + [ + # wrong domain + "https://tasks.hotosm.org/", + "https://tasks.hotosm.org/projects/", + "https://tasks.hotosm.org/projects/abc", + "https://evil.com/projects/123", + "https://tasks.evil.org/projects/123", + # missing ID + "https://drone.hotosm.org/projects/", + "https://fair.hotosm.org/ai-models/", + # OAM missing /latest/ segment + "https://map.openaerialmap.org/#/0,0,3/abc123", + # umap missing _{id} + "https://umap.hotosm.org/es/map/no-number-here", + # chatmap missing uuid + "https://chatmap.hotosm.org/#map/", + # chatmap wrong path + "https://chatmap.hotosm.org/map/d4ca5204-2352-406b-8a95-f21be9d86a27", + # completely unrelated + "https://example.com", + "", + "not a url", + ], +) +def test_invalid_urls(url: str) -> None: + assert parse_project_url(url) is None, f"Expected no match for {url!r}" diff --git a/frontend/src/portal-plans/components/PlanForm.tsx b/frontend/src/portal-plans/components/PlanForm.tsx index 8011d9a..0251a1b 100644 --- a/frontend/src/portal-plans/components/PlanForm.tsx +++ b/frontend/src/portal-plans/components/PlanForm.tsx @@ -39,6 +39,7 @@ function PlanForm({ const [name, setName] = useState(initialName) const [description, setDescription] = useState(initialDescription) const [selected, setSelected] = useState>(initialProjectKeys) + const [extraProjects, setExtraProjects] = useState([]) const [dialogOpen, setDialogOpen] = useState(false) const { sources, projects, isLoading } = useAllUserProjects() @@ -52,13 +53,15 @@ function PlanForm({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - const selectedProjects = projects + const allProjects = [...projects, ...extraProjects] + const selectedProjects = allProjects .filter((p) => selected.has(projectKey(p))) .map(({ app, project_id }) => ({ app, project_id })) await onSubmit({ name, description, selectedProjects }) } - const selectedTags = projects.filter((p) => selected.has(projectKey(p))) + const allProjects = [...projects, ...extraProjects] + const selectedTags = allProjects.filter((p) => selected.has(projectKey(p))) const loadingCount = selected.size - selectedTags.length return ( @@ -123,8 +126,12 @@ function PlanForm({ setSelected(next)} + onConfirm={(next, nextExtra) => { + setSelected(next) + setExtraProjects(nextExtra) + }} onClose={() => setDialogOpen(false)} /> diff --git a/frontend/src/portal-plans/components/PlanProjectCard.tsx b/frontend/src/portal-plans/components/PlanProjectCard.tsx index 5221d9d..f089dd2 100644 --- a/frontend/src/portal-plans/components/PlanProjectCard.tsx +++ b/frontend/src/portal-plans/components/PlanProjectCard.tsx @@ -1,38 +1,26 @@ import placeholder from "../../assets/images/placeholder.png"; import CardProjectTitle from "../../components/shared/CardProjectTitle"; -import { - getDroneTmBaseUrl, - getExportToolJobUrl, - getFairModelUrl, - getUmapBaseUrl, -} from "../../utils/envConfig"; import { APP_META } from "../../utils/appMeta"; import type { HydratedProjectItem, AppName } from "../types"; -function getProjectHref( - app: AppName, - projectId: string, - upstream: Record | null, -): string { +function getProjectHref(app: AppName, projectId: string): string { switch (app) { - case "drone-tasking-manager": - return `${getDroneTmBaseUrl()}/projects/${projectId}`; - case "open-aerial-map": - return `https://openaerialmap.org/#/${projectId}`; case "tasking-manager": return `https://tasks.hotosm.org/projects/${projectId}`; + case "drone-tasking-manager": + return `https://drone.hotosm.org/projects/${projectId}`; + case "field-tm": + return `https://field.hotosm.org/projects/${projectId}`; case "fair": - return getFairModelUrl(Number(projectId)); + return `https://fair.hotosm.org/ai-models/${projectId}`; case "export-tool": - return getExportToolJobUrl(projectId); - case "umap": { - const url = upstream?.url; - return typeof url === "string" - ? `${getUmapBaseUrl()}${url}` - : getUmapBaseUrl(); - } - case "field-tm": - return "#"; + return `https://export.hotosm.org/v3/exports/${projectId}`; + case "open-aerial-map": + return `https://map.openaerialmap.org/#/latest/${projectId}`; + case "umap": + return `https://umap.hotosm.org/m/${projectId}/`; + case "chatmap": + return `https://chatmap.hotosm.org/#map/${projectId}`; } } @@ -66,7 +54,6 @@ function PlanProjectCard({ project }: PlanProjectCardProps) { const href = getProjectHref( project.app, project.project_id, - project.upstream, ); return ( diff --git a/frontend/src/portal-plans/components/ProjectPickerDialog.tsx b/frontend/src/portal-plans/components/ProjectPickerDialog.tsx index 6c5cdd8..9afcdfa 100644 --- a/frontend/src/portal-plans/components/ProjectPickerDialog.tsx +++ b/frontend/src/portal-plans/components/ProjectPickerDialog.tsx @@ -3,7 +3,8 @@ import Button from '../../components/shared/Button' import Checkbox from '../../components/shared/Checkbox' import Dialog from '../../components/shared/Dialog' import { APP_LABELS } from '../hooks' -import type { ProjectSource } from '../hooks' +import type { ProjectOption, ProjectSource } from '../hooks' +import { useResolveProjectUrl } from '../hooks/usePlans' import type { AppName } from '../types' function projectKey(app: AppName, project_id: string) { @@ -22,26 +23,35 @@ function CheckboxSkeleton() { interface ProjectPickerDialogProps { open: boolean selected: Set + extraProjects: ProjectOption[] sources: ProjectSource[] - onConfirm: (selected: Set) => void + onConfirm: (selected: Set, extraProjects: ProjectOption[]) => void onClose: () => void } function ProjectPickerDialog({ open, selected, + extraProjects, sources, onConfirm, onClose, }: ProjectPickerDialogProps) { const [localSelected, setLocalSelected] = useState>(new Set()) + const [localExtraProjects, setLocalExtraProjects] = useState([]) const [activeApp, setActiveApp] = useState('all') + const [urlInput, setUrlInput] = useState('') + const [urlError, setUrlError] = useState(null) + const resolveUrl = useResolveProjectUrl() // biome-ignore lint/correctness/useExhaustiveDependencies: only sync on open-transition, not on every selected change useEffect(() => { if (open) { setLocalSelected(new Set(selected)) + setLocalExtraProjects(extraProjects) setActiveApp('all') + setUrlInput('') + setUrlError(null) } }, [open]) @@ -53,6 +63,42 @@ function ProjectPickerDialog({ }) } + async function handleAddUrl() { + const trimmed = urlInput.trim() + if (!trimmed) return + setUrlError(null) + try { + const result = await resolveUrl.mutateAsync(trimmed) + const key = projectKey(result.app, result.project_id) + if (localSelected.has(key)) { + setUrlError('This project is already in your selection.') + return + } + const upstream = result.upstream ?? {} + const title = + (upstream.name as string | undefined) ?? + (upstream.title as string | undefined) ?? + result.project_id + setLocalExtraProjects((prev) => [ + ...prev, + { app: result.app, project_id: result.project_id, title }, + ]) + setLocalSelected((prev) => new Set(prev).add(key)) + setUrlInput('') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Unknown error' + if (msg.includes('project_not_found')) { + setUrlError('Project not found. Check the URL.') + } else if (msg.includes('upstream_unavailable')) { + setUrlError('Could not reach the service. Try again later.') + } else { + setUrlError( + 'URL not recognized. Supported: Tasking Manager, Drone TM, Field TM, fAIr, OAM, Export Tool, uMap.', + ) + } + } + } + const visibleSources = activeApp === 'all' ? sources : sources.filter((s) => s.app === activeApp) const allLoaded = sources.every((s) => !s.isLoading) @@ -154,6 +200,39 @@ function ProjectPickerDialog({ )} +
+

+ Add by URL +

+
+ { + setUrlInput(e.target.value) + setUrlError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddUrl() + } + }} + placeholder="https://tasks.hotosm.org/projects/123" + className="flex-1 border border-hot-gray-300 rounded-lg px-sm py-xs text-sm outline-none focus:border-hot-red-500" + /> + +
+ {urlError &&

{urlError}

} +
+
{urlError &&

{urlError}

} + {pendingNameKey && ( +
+

+ Map name not found automatically. Enter a name for this project: +

+
+ setPendingNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); applyPendingName() } + if (e.key === 'Escape') { setPendingNameKey(null); setPendingNameValue('') } + }} + placeholder="e.g. My field map" + className="flex-1 border border-hot-gray-300 rounded-lg px-sm py-xs text-sm outline-none focus:border-hot-red-500" + /> + +
+
+ )}
diff --git a/frontend/src/portal-plans/hooks/useAllUserProjects.ts b/frontend/src/portal-plans/hooks/useAllUserProjects.ts index 92b2154..6bab767 100644 --- a/frontend/src/portal-plans/hooks/useAllUserProjects.ts +++ b/frontend/src/portal-plans/hooks/useAllUserProjects.ts @@ -9,6 +9,7 @@ export interface ProjectOption { app: AppName; project_id: string; title: string; + upstream?: Record | null; } export interface ProjectSource { From a70c56a950aa53b07822478c1affd8ee8c8201e5 Mon Sep 17 00:00:00 2001 From: warmijusti Date: Thu, 7 May 2026 09:26:53 -0500 Subject: [PATCH 3/5] fix merge conflicts --- frontend/src/portal-plans/components/PlanForm.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/portal-plans/components/PlanForm.tsx b/frontend/src/portal-plans/components/PlanForm.tsx index 4550fe5..e02f3d6 100644 --- a/frontend/src/portal-plans/components/PlanForm.tsx +++ b/frontend/src/portal-plans/components/PlanForm.tsx @@ -51,6 +51,7 @@ function PlanForm({ const [description, setDescription] = useState(initialDescription); const [selected, setSelected] = useState>(initialProjectKeys); const [dialogOpen, setDialogOpen] = useState(false); + const [extraProjects, setExtraProjects] = useState([]); const { sources, projects, isLoading } = useAllUserProjects(); const { displayImages, @@ -97,6 +98,7 @@ function PlanForm({ name, description, selectedProjects: [...matched, ...orphans], + pendingImages, }); }; From ac2e6a7755c34983b2071acdbe21324652a0ed86 Mon Sep 17 00:00:00 2001 From: warmijusti Date: Thu, 7 May 2026 10:19:56 -0500 Subject: [PATCH 4/5] add aoiBBOX to create thumbnail for TM --- backend/app/services/tasking_manager_service.py | 1 + .../portal-plans/components/PlanProjectCard.tsx | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/app/services/tasking_manager_service.py b/backend/app/services/tasking_manager_service.py index b4209d5..e0999ca 100644 --- a/backend/app/services/tasking_manager_service.py +++ b/backend/app/services/tasking_manager_service.py @@ -38,6 +38,7 @@ async def fetch_project_by_id(project_id: str) -> dict | None: "percentMapped": data.get("percentMapped"), "percentValidated": data.get("percentValidated"), "percentBadImagery": data.get("percentBadImagery"), + "aoiBBOX": data.get("aoiBBOX"), } set_cached(cache_key, filtered, DEFAULT_TTL) return filtered diff --git a/frontend/src/portal-plans/components/PlanProjectCard.tsx b/frontend/src/portal-plans/components/PlanProjectCard.tsx index 76e640c..48ccc7f 100644 --- a/frontend/src/portal-plans/components/PlanProjectCard.tsx +++ b/frontend/src/portal-plans/components/PlanProjectCard.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import placeholder from "../../assets/images/placeholder.png"; import CardProjectTitle from "../../components/shared/CardProjectTitle"; import { APP_META } from "../../utils/appMeta"; +import { osmTileUrl } from "../../utils/osmTiles"; import type { HydratedProjectItem, AppName } from "../types"; function getProjectHref( @@ -48,11 +49,22 @@ function getUpstreamTitle( } function getUpstreamImage( + app: AppName, upstream: Record | null, data: Record | null, ): string { const src = upstream ?? data; if (!src) return placeholder; + + if (app === "tasking-manager") { + const bbox = src.aoiBBOX as [number, number, number, number] | null | undefined; + if (Array.isArray(bbox) && bbox.length === 4) { + const lat = (bbox[1] + bbox[3]) / 2; + const lon = (bbox[0] + bbox[2]) / 2; + return osmTileUrl(lat, lon, 10); + } + } + const img = src.image_url ?? src.thumbnail_url ?? @@ -83,7 +95,7 @@ function PlanProjectCard({ project }: PlanProjectCardProps) { const meta = APP_META[project.app]; const title = chatmapName ?? getUpstreamTitle(project.upstream, project.project_id, project.data); - const image = getUpstreamImage(project.upstream, project.data); + const image = getUpstreamImage(project.app, project.upstream, project.data); const href = getProjectHref( project.app, project.project_id, From ee535d0d5062499a9ad0fed039be2a4869f78d51 Mon Sep 17 00:00:00 2001 From: warmijusti Date: Fri, 8 May 2026 07:33:45 -0500 Subject: [PATCH 5/5] add chatmap thumb when adding via url --- backend/app/services/chatmap_service.py | 19 ++++++++++++++++++- .../components/PlanProjectCard.tsx | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/app/services/chatmap_service.py b/backend/app/services/chatmap_service.py index 225e9ba..e4ceae1 100644 --- a/backend/app/services/chatmap_service.py +++ b/backend/app/services/chatmap_service.py @@ -44,6 +44,23 @@ async def fetch_map_by_id( except (httpx.RequestError, httpx.HTTPStatusError) as e: raise UpstreamUnavailable(f"chatmap: {e}") from e - filtered = {"name": data.get("name"), "id": data.get("id")} + features = data.get("features") or [] + coords = [ + f["geometry"]["coordinates"] + for f in features + if isinstance(f.get("geometry"), dict) + and f["geometry"].get("type") == "Point" + and isinstance(f["geometry"].get("coordinates"), list) + and len(f["geometry"]["coordinates"]) >= 2 + ] + if coords: + centroid: list[float] | None = [ + sum(c[1] for c in coords) / len(coords), # lat + sum(c[0] for c in coords) / len(coords), # lon + ] + else: + centroid = None + + filtered = {"name": data.get("name"), "id": data.get("id"), "centroid": centroid} set_cached(cache_key, filtered, DEFAULT_TTL) return filtered diff --git a/frontend/src/portal-plans/components/PlanProjectCard.tsx b/frontend/src/portal-plans/components/PlanProjectCard.tsx index 48ccc7f..f5dce70 100644 --- a/frontend/src/portal-plans/components/PlanProjectCard.tsx +++ b/frontend/src/portal-plans/components/PlanProjectCard.tsx @@ -56,6 +56,13 @@ function getUpstreamImage( const src = upstream ?? data; if (!src) return placeholder; + if (app === "chatmap") { + const centroid = src.centroid as [number, number] | null | undefined; + if (Array.isArray(centroid) && centroid.length === 2) { + return osmTileUrl(centroid[0], centroid[1], 10); + } + } + if (app === "tasking-manager") { const bbox = src.aoiBBOX as [number, number, number, number] | null | undefined; if (Array.isArray(bbox) && bbox.length === 4) {