Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions backend/alembic/versions/006_add_plan_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

Revision ID: 006_add_plan_images
Revises: 005_add_plan_is_public
Create Date: 2026-04-29
Create Date: 2026-04-24
"""

import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa


revision = "006_add_plan_images"
Expand All @@ -23,8 +23,14 @@ def upgrade() -> None:
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.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"])
Expand Down
28 changes: 27 additions & 1 deletion backend/app/api/routes/plans/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions backend/app/models/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator

AppLiteral = Literal[
"chatmap",
"drone-tasking-manager",
"export-tool",
"fair",
Expand Down Expand Up @@ -106,3 +107,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
49 changes: 49 additions & 0 deletions backend/app/services/chatmap_service.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions backend/app/services/drone_tm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/export_tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/fair_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/field_tm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
FMTM_API_BASE_URL = "https://api.fmtm.hotosm.org"


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 FMTM project by id. None on 404, raises UpstreamUnavailable on failure."""
cache_key = f"fmtm_project_{project_id}"
cached = get_cached(cache_key)
if cached is not None:
return cached

url = f"{FMTM_API_BASE_URL}/projects/{project_id}"
url = f"{base_url or FMTM_API_BASE_URL}/projects/{project_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
Expand Down
14 changes: 8 additions & 6 deletions backend/app/services/open_aerial_map_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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)
Expand All @@ -31,13 +32,14 @@ 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"),
"bbox": result.get("bbox"),
}
set_cached(cache_key, filtered, DEFAULT_TTL)
return filtered
Loading