From a6cef2acafc185815226138bad72ba64bb8c3ab2 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 21 Jan 2026 09:49:37 -0800 Subject: [PATCH 01/17] Add missing path to skip X-Workspace check for create/delete --- api/main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/main.py b/api/main.py index 1c4ee21..d7be59f 100644 --- a/api/main.py +++ b/api/main.py @@ -141,8 +141,12 @@ def get_workspace_repository( AUTH_WHITELIST_PATTERNS = [ re.compile(p) for p in [ - r"^/api/0\.6/user/.*$", # used during authentication - r"^/api/0\.6/workspaces/[0-9]+/bbox\.json$", # used to get workspace bbox without workspace header, to be removed + # Creating/deleting workspaces and JOSM path rewriting: + r"^/api/0\.6/workspaces.*$", + # Provisioning users during authentication: + r"^/api/0\.6/user/.*$", + # Used to get workspace bbox without workspace header, to be removed: + r"^/api/0\.6/workspaces/[0-9]+/bbox\.json$", ] ] From 50abeca15d226c412d94b5eb7a7dd33d12666c15 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 23 Jan 2026 11:41:22 -0800 Subject: [PATCH 02/17] Fix route path for workspace creation --- api/src/workspaces/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 99f764c..769024b 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -111,7 +111,7 @@ async def get_workspace_bbox( # Returns 201 on success? -@router.post("/", response_model=Workspace, status_code=status.HTTP_201_CREATED) +@router.post("", response_model=Workspace, status_code=status.HTTP_201_CREATED) async def create_workspace( workspace_data: dict[str, Any], repository_ws: WorkspaceRepository = Depends(get_workspace_repository), From 4ca072d99880c8a9fb323c905aaa2485b936268f Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 27 Jan 2026 15:23:16 -0800 Subject: [PATCH 03/17] Fix workspace creation response object --- api/src/workspaces/routes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 769024b..1c2e161 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -111,13 +111,13 @@ async def get_workspace_bbox( # Returns 201 on success? -@router.post("", response_model=Workspace, status_code=status.HTTP_201_CREATED) +@router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( workspace_data: dict[str, Any], repository_ws: WorkspaceRepository = Depends(get_workspace_repository), repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: +) -> dict[str, int]: try: workspace = await repository_ws.create(current_user, workspace_data) @@ -136,7 +136,7 @@ async def create_workspace( # evict_user_from_cache(current_user.user_uuid) - return workspace + return {"workspaceId": workspace.id} except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") raise From 42e7345c8a64215facd0f7a0817ba8b04d83020d Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 28 Jan 2026 16:01:39 -0800 Subject: [PATCH 04/17] Fix workspace input validation with proper schemas --- api/src/workspaces/repository.py | 65 ++++++++++++++++---------------- api/src/workspaces/routes.py | 15 +++++--- api/src/workspaces/schemas.py | 40 ++++++++++++++++++++ 3 files changed, 82 insertions(+), 38 deletions(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index c56b7eb..5ab4429 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,5 +1,3 @@ -from typing import Any - from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -7,10 +5,14 @@ from api.core.exceptions import AlreadyExistsException, NotFoundException from api.core.security import UserInfo from api.src.workspaces.schemas import ( + ImagerySettingsPatch, QuestDefinitionType, + QuestSettingsPatch, Workspace, + WorkspaceCreate, WorkspaceImagery, WorkspaceLongQuest, + WorkspacePatch, ) @@ -20,20 +22,20 @@ def __init__(self, session: AsyncSession): self.session = session async def create( - self, current_user: UserInfo, workspace_data: dict[str, Any] + self, current_user: UserInfo, workspace_data: WorkspaceCreate ) -> Workspace: workspace = Workspace( - **workspace_data, + **workspace_data.model_dump(), createdBy=current_user.user_uuid, # type: ignore[reportArgumentType] createdByName=current_user.user_name, ) - try: - if workspace.tdeiProjectGroupId not in current_user.getProjectGroupIds(): - raise ValueError( - "User does not have permissions to create a workspace in that project group." - ) + if str(workspace.tdeiProjectGroupId) not in current_user.getProjectGroupIds(): + raise ValueError( + "User does not have permissions to create a workspace in that project group." + ) + try: self.session.add(workspace) await self.session.commit() await self.session.refresh(workspace) @@ -67,7 +69,7 @@ async def update( self, current_user: UserInfo, workspace_id: int, - workspace_data: dict[str, Any], + workspace_data: WorkspacePatch, ) -> Workspace: query = ( update(Workspace) @@ -75,7 +77,7 @@ async def update( (Workspace.id == workspace_id) & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] ) - .values(**workspace_data) + .values(**workspace_data.model_dump(exclude_unset=True)) ) result = await self.session.execute(query) @@ -89,7 +91,7 @@ async def createLongformQuest( self, current_user: UserInfo, workspace_id: int, - longform_quest_data: dict[str, Any], + longform_quest_data: QuestSettingsPatch, ) -> Workspace | None: query = select(Workspace).where( (Workspace.id == workspace_id) @@ -99,7 +101,9 @@ async def createLongformQuest( workspace = result.scalar_one_or_none() if workspace: workspace.longFormQuestDef = WorkspaceLongQuest( - **longform_quest_data, + type=QuestDefinitionType[longform_quest_data.type].value, + definition=longform_quest_data.definition, + url=longform_quest_data.url, modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] modifiedByName=current_user.user_name, workspace_id=workspace_id, @@ -112,20 +116,17 @@ async def updateLongformQuest( self, current_user: UserInfo, workspace_id: int, - longform_quest_data: dict[str, Any], + longform_quest_data: QuestSettingsPatch, ) -> Workspace: - update_data = longform_quest_data - update_data["modifiedBy"] = current_user.user_uuid - update_data["modifiedByName"] = current_user.user_name - - quest_type = longform_quest_data.get("type") - update_data["type"] = QuestDefinitionType[ - quest_type.name if quest_type else "NONE" - ].value - query = ( update(WorkspaceLongQuest) - .values(**update_data) + .values( + type=QuestDefinitionType[longform_quest_data.type].value, + definition=longform_quest_data.definition, + url=longform_quest_data.url, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) .where(WorkspaceLongQuest.workspace_id == workspace_id) # type: ignore[reportArgumentType] ) result = await self.session.execute(query) @@ -140,7 +141,7 @@ async def createImageryDef( self, current_user: UserInfo, workspace_id: int, - imagery_def_data: dict[str, Any], + imagery_def_data: ImagerySettingsPatch, ) -> Workspace | None: query = select(Workspace).where( (Workspace.id == workspace_id) @@ -150,7 +151,7 @@ async def createImageryDef( workspace = result.scalar_one_or_none() if workspace: workspace.imageryListDef = WorkspaceImagery( - **imagery_def_data, + definition=imagery_def_data.definition, modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] modifiedByName=current_user.user_name, workspace_id=workspace_id, @@ -163,15 +164,15 @@ async def updateImageryDef( self, current_user: UserInfo, workspace_id: int, - imagery_def_data: dict[str, Any], + imagery_def_data: ImagerySettingsPatch, ) -> Workspace: - update_data = imagery_def_data - update_data["modifiedBy"] = current_user.user_uuid - update_data["modifiedByName"] = current_user.user_name - query = ( update(WorkspaceImagery) - .values(**update_data) + .values( + definition=imagery_def_data.definition, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) .where(WorkspaceImagery.workspace_id == workspace_id) # type: ignore[reportArgumentType] ) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 1c2e161..3f2ed74 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,4 +1,3 @@ -from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status @@ -11,10 +10,14 @@ from api.src.users.schemas import WorkspaceUserRoleType from api.src.workspaces.repository import OSMRepository, WorkspaceRepository from api.src.workspaces.schemas import ( + ImagerySettingsPatch, QuestDefinitionType, + QuestSettingsPatch, Workspace, + WorkspaceCreate, WorkspaceImagery, WorkspaceLongQuest, + WorkspacePatch, WorkspaceResponse, ) @@ -113,7 +116,7 @@ async def get_workspace_bbox( # Returns 201 on success? @router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( - workspace_data: dict[str, Any], + workspace_data: WorkspaceCreate, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), @@ -146,7 +149,7 @@ async def create_workspace( @router.patch("/{workspace_id}", response_model=Workspace) async def update_workspace( workspace_id: int, - workspace_data: dict[str, Any], + workspace_data: WorkspacePatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> Workspace: @@ -227,7 +230,7 @@ async def get_long_quest_settings( ) async def update_long_quest_settings( workspace_id: int, - long_quest_data: dict[str, Any], + long_quest_data: QuestSettingsPatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: @@ -268,7 +271,7 @@ async def get_imagery_settings( modifiedByName="", ) else: - return WorkspaceImagery(**workspace.imageryListDef.model_dump()) + return workspace.imageryListDef except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise @@ -280,7 +283,7 @@ async def get_imagery_settings( ) async def update_imagery_settings( workspace_id: int, - imagery_data: dict[str, Any], + imagery_data: ImagerySettingsPatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 1f991b9..ffa059d 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -72,6 +72,12 @@ class QuestDefinitionType(IntEnum): URL = 2 +class QuestDefinitionTypeName(StrEnum): + NONE = "NONE" + JSON = "JSON" + URL = "URL" + + class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -113,6 +119,40 @@ class WorkspaceImagery(SQLModel, table=True): modifiedByName: str +class WorkspaceCreate(SQLModel): + """Fields the client may supply when creating a workspace""" + + type: WorkspaceType + title: str + description: Optional[str] = None + tdeiProjectGroupId: UUID + tdeiRecordId: Optional[UUID] = None + tdeiServiceId: Optional[UUID] = None + tdeiMetadata: Optional[Any] = None + + +class WorkspacePatch(SQLModel): + """Fields the client may supply when updating a workspace""" + + title: Optional[str] = None + description: Optional[str] = None + externalAppAccess: Optional[ExternalAppsDefinitionType] = None + + +class QuestSettingsPatch(SQLModel): + """Fields the client may supply when saving long-form quest settings""" + + type: QuestDefinitionTypeName + definition: Optional[str] = None + url: Optional[str] = None + + +class ImagerySettingsPatch(SQLModel): + """Fields the client may supply when saving imagery settings""" + + definition: Optional[list[Any]] = None + + class WorkspaceResponse(SQLModel): """ Workspace serialized for API responses. Includes the effective role for the From 81a4bc6a937dc3a6a3196f23ac3636854fa1681f Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 4 Feb 2026 10:10:01 -0800 Subject: [PATCH 05/17] Remove dead code with incorrect response status code --- api/src/workspaces/routes.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 3f2ed74..ae908a8 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -93,21 +93,9 @@ async def get_workspace_bbox( ): try: # this first query is for permissions checking - workspace = await repository_ws.getById(current_user, workspace_id) - if workspace is None: - raise HTTPException( - status_code=status.HTTP_204_NO_CONTENT, - detail="No Content", - ) - + await repository_ws.getById(current_user, workspace_id) bbox = await repository_osm.getWorkspaceBBox(current_user, workspace_id) - if bbox is None: - raise HTTPException( - status_code=status.HTTP_204_NO_CONTENT, - detail="No Content", - ) - except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise From bb774611770f2029311d1c04dd393b9aa2815be5 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 5 Feb 2026 08:39:17 -0800 Subject: [PATCH 06/17] Fix missing return statement in workspace bbox --- api/src/workspaces/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index ae908a8..48395e9 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -95,7 +95,7 @@ async def get_workspace_bbox( # this first query is for permissions checking await repository_ws.getById(current_user, workspace_id) bbox = await repository_osm.getWorkspaceBBox(current_user, workspace_id) - + return bbox except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise From 67adbff9231ba4ecea1e18ee4f600e9a6eec9f9f Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 6 Feb 2026 10:22:59 -0800 Subject: [PATCH 07/17] Fix ability to create long-form quests and imagery lists --- api/src/workspaces/repository.py | 80 ++++++++++---------------------- api/src/workspaces/routes.py | 4 +- 2 files changed, 26 insertions(+), 58 deletions(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 5ab4429..8b7f820 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -87,32 +87,7 @@ async def update( await self.session.commit() return await self.getById(current_user, workspace_id) - async def createLongformQuest( - self, - current_user: UserInfo, - workspace_id: int, - longform_quest_data: QuestSettingsPatch, - ) -> Workspace | None: - query = select(Workspace).where( - (Workspace.id == workspace_id) - & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] - ) - result = await self.session.execute(query) - workspace = result.scalar_one_or_none() - if workspace: - workspace.longFormQuestDef = WorkspaceLongQuest( - type=QuestDefinitionType[longform_quest_data.type].value, - definition=longform_quest_data.definition, - url=longform_quest_data.url, - modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] - modifiedByName=current_user.user_name, - workspace_id=workspace_id, - ) - await self.session.commit() - await self.session.refresh(workspace) - return workspace - - async def updateLongformQuest( + async def save_longform_quest( self, current_user: UserInfo, workspace_id: int, @@ -127,40 +102,26 @@ async def updateLongformQuest( modifiedBy=current_user.user_uuid, modifiedByName=current_user.user_name, ) - .where(WorkspaceLongQuest.workspace_id == workspace_id) # type: ignore[reportArgumentType] + .where(WorkspaceLongQuest.workspace_id == workspace_id) ) result = await self.session.execute(query) - if result.rowcount == 0: # type: ignore[attr-defined] - raise NotFoundException(f"Workspace with id {workspace_id} not found") + if result.rowcount == 0: + self.session.add( + WorkspaceLongQuest( + workspace_id=workspace_id, + type=QuestDefinitionType[longform_quest_data.type].value, + definition=longform_quest_data.definition, + url=longform_quest_data.url, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) + ) await self.session.commit() return await self.getById(current_user, workspace_id) - async def createImageryDef( - self, - current_user: UserInfo, - workspace_id: int, - imagery_def_data: ImagerySettingsPatch, - ) -> Workspace | None: - query = select(Workspace).where( - (Workspace.id == workspace_id) - & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] - ) - result = await self.session.execute(query) - workspace = result.scalar_one_or_none() - if workspace: - workspace.imageryListDef = WorkspaceImagery( - definition=imagery_def_data.definition, - modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] - modifiedByName=current_user.user_name, - workspace_id=workspace_id, - ) - await self.session.commit() - await self.session.refresh(workspace) - return workspace - - async def updateImageryDef( + async def save_imagery_def( self, current_user: UserInfo, workspace_id: int, @@ -173,13 +134,20 @@ async def updateImageryDef( modifiedBy=current_user.user_uuid, modifiedByName=current_user.user_name, ) - .where(WorkspaceImagery.workspace_id == workspace_id) # type: ignore[reportArgumentType] + .where(WorkspaceImagery.workspace_id == workspace_id) ) result = await self.session.execute(query) - if result.rowcount != 1: # type: ignore[attr-defined] - raise NotFoundException(f"Update failed for workspace id {workspace_id}") + if result.rowcount == 0: # type: ignore[attr-defined] + self.session.add( + WorkspaceImagery( + workspace_id=workspace_id, + definition=imagery_def_data.definition, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) + ) await self.session.commit() return await self.getById(current_user, workspace_id) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 48395e9..0b7b39a 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -229,7 +229,7 @@ async def update_long_quest_settings( ) try: - await repository_ws.updateLongformQuest( + await repository_ws.save_longform_quest( current_user, workspace_id, long_quest_data ) except Exception as e: @@ -282,7 +282,7 @@ async def update_imagery_settings( ) try: - await repository_ws.updateImageryDef(current_user, workspace_id, imagery_data) + await repository_ws.save_imagery_def(current_user, workspace_id, imagery_data) except Exception as e: logger.error(f"Failed to update workspace {workspace_id}: {str(e)}") raise From ea1645c3f18731ced6b07a3d9b4f233b8db4b0b5 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 10 Feb 2026 12:08:25 -0800 Subject: [PATCH 08/17] Remove extraneous queries --- api/src/workspaces/repository.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 8b7f820..d8bff64 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -92,7 +92,7 @@ async def save_longform_quest( current_user: UserInfo, workspace_id: int, longform_quest_data: QuestSettingsPatch, - ) -> Workspace: + ) -> None: query = ( update(WorkspaceLongQuest) .values( @@ -119,14 +119,13 @@ async def save_longform_quest( ) await self.session.commit() - return await self.getById(current_user, workspace_id) async def save_imagery_def( self, current_user: UserInfo, workspace_id: int, imagery_def_data: ImagerySettingsPatch, - ) -> Workspace: + ) -> None: query = ( update(WorkspaceImagery) .values( @@ -150,7 +149,6 @@ async def save_imagery_def( ) await self.session.commit() - return await self.getById(current_user, workspace_id) async def delete(self, current_user: UserInfo, workspace_id: int) -> None: query = delete(Workspace).where( From d8360792b1f5c0da90129d3ed2c92d131c46bd6c Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 11 Feb 2026 16:51:55 -0800 Subject: [PATCH 09/17] Fix response shapes for long form quests --- api/src/workspaces/routes.py | 39 ++++++++++++++++++++++------------- api/src/workspaces/schemas.py | 12 +++++++++++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 0b7b39a..68ec09a 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -11,12 +11,12 @@ from api.src.workspaces.repository import OSMRepository, WorkspaceRepository from api.src.workspaces.schemas import ( ImagerySettingsPatch, - QuestDefinitionType, + QuestDefinitionTypeName, QuestSettingsPatch, + QuestSettingsResponse, Workspace, WorkspaceCreate, WorkspaceImagery, - WorkspaceLongQuest, WorkspacePatch, WorkspaceResponse, ) @@ -182,31 +182,42 @@ async def delete_workspace( raise -### QUESTS +# QUESTS # Returns JSON payload or 204 if not set -@router.get("/{workspace_id}/quests/long/settings", response_model=WorkspaceLongQuest) +@router.get( + "/{workspace_id}/quests/long/settings", response_model=QuestSettingsResponse +) async def get_long_quest_settings( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> WorkspaceLongQuest | None: +) -> QuestSettingsResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) + quest = workspace.longFormQuestDef - if workspace.longFormQuestDef is None: - return WorkspaceLongQuest( + if quest is None: + return QuestSettingsResponse( workspace_id=workspace_id, - type=QuestDefinitionType.NONE, + type=QuestDefinitionTypeName.NONE, definition=None, url=None, - modifiedAt=workspace.createdAt, - modifiedBy=workspace.createdBy, - modifiedByName="", + modified_at=workspace.createdAt, + modified_by=workspace.createdBy, + modified_by_name="", ) - else: - return workspace.longFormQuestDef + + return QuestSettingsResponse( + workspace_id=quest.workspace_id, + type=QuestDefinitionTypeName(quest.type.name), + definition=quest.definition, + url=quest.url, + modified_at=quest.modifiedAt, + modified_by=quest.modifiedBy, + modified_by_name=quest.modifiedByName, + ) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise @@ -237,7 +248,7 @@ async def update_long_quest_settings( raise -### IMAGERY +# IMAGERY # Returns JSON payload or 204 if not set diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index ffa059d..e7057e0 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -147,6 +147,18 @@ class QuestSettingsPatch(SQLModel): url: Optional[str] = None +class QuestSettingsResponse(SQLModel): + """Quest settings serialized for API responses""" + + workspace_id: int + type: QuestDefinitionTypeName + definition: Optional[str] = None + url: Optional[str] = None + modified_at: datetime + modified_by: UUID + modified_by_name: str + + class ImagerySettingsPatch(SQLModel): """Fields the client may supply when saving imagery settings""" From febc60ab49bcaf5bc6203da0bce81de955cbc914 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 13 Feb 2026 15:02:46 -0800 Subject: [PATCH 10/17] Fix missing quest/imagery endpoint/fields for AVIV ScoutRoute --- api/src/workspaces/repository.py | 26 +++++++++++++++++ api/src/workspaces/routes.py | 48 +++++++++++++++++++++++++------- api/src/workspaces/schemas.py | 15 +++++++++- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index d8bff64..849eefd 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,3 +1,4 @@ +import httpx from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -120,6 +121,31 @@ async def save_longform_quest( await self.session.commit() + @staticmethod + async def resolve_quest_def(quest: WorkspaceLongQuest | None) -> str | None: + """ + Resolve a WorkspaceLongQuest to its raw JSON string content. + + - JSON type: returns the stored definition string + - URL type: fetches and returns the remote content + - NONE or missing: returns None + """ + + if quest is None or quest.type == QuestDefinitionType.NONE: + return None + if quest.type == QuestDefinitionType.JSON: + return quest.definition or None + + if quest.url: + try: + async with httpx.AsyncClient() as client: + response = await client.get(quest.url, timeout=10) + return response.text + except Exception: + return None + + return None + async def save_imagery_def( self, current_user: UserInfo, diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 68ec09a..97e1417 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,6 +1,7 @@ +from typing import Any from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -27,7 +28,6 @@ router = APIRouter(prefix="/workspaces", tags=["workspaces"]) - def get_workspace_repository( session: AsyncSession = Depends(get_task_session), ) -> WorkspaceRepository: @@ -71,14 +71,18 @@ async def get_workspace( ) -> WorkspaceResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) - - if workspace is None: - raise HTTPException( - status_code=status.HTTP_204_NO_CONTENT, - detail="No Content", - ) - - return WorkspaceResponse.from_workspace(workspace, current_user) + return WorkspaceResponse.from_workspace( + workspace, + current_user, + imagery_list_def=( + workspace.imageryListDef.definition + if workspace.imageryListDef + else None + ), + long_form_quest_def=await WorkspaceRepository.resolve_quest_def( + workspace.longFormQuestDef + ), + ) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise @@ -185,6 +189,30 @@ async def delete_workspace( # QUESTS +# Return the resolved quest definition content as JSON, or 204 if not set: +@router.get("/{workspace_id}/quests/long") +async def get_long_quest_def( + workspace_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + current_user: UserInfo = Depends(validate_token), +) -> Response: + try: + workspace = await repository_ws.getById(current_user, workspace_id) + definition = await WorkspaceRepository.resolve_quest_def( + workspace.longFormQuestDef + ) + + if definition is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + + return Response(content=definition, media_type="application/json") + except Exception as e: + logger.error( + f"Failed to fetch quest def for workspace {workspace_id}: {str(e)}" + ) + raise + + # Returns JSON payload or 204 if not set @router.get( "/{workspace_id}/quests/long/settings", response_model=QuestSettingsResponse diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index e7057e0..d36d30d 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -185,9 +185,20 @@ class WorkspaceResponse(SQLModel): externalAppAccess: ExternalAppsDefinitionType kartaViewToken: Optional[str] = None role: str + # Included in single-workspace GET for mobile app consumption. TODO: remove + # this when the app fetches these from dedicated endpoints: + longFormQuestDef: Optional[str] = None + imageryListDef: Optional[Any] = None @classmethod - def from_workspace(cls, workspace: "Workspace", user: "UserInfo") -> Self: + def from_workspace( + cls, + workspace: "Workspace", + user: "UserInfo", + *, + imagery_list_def: Any = None, + long_form_quest_def: Any = None, + ) -> Self: return cls( id=workspace.id, type=workspace.type, @@ -203,6 +214,8 @@ def from_workspace(cls, workspace: "Workspace", user: "UserInfo") -> Self: externalAppAccess=workspace.externalAppAccess, kartaViewToken=workspace.kartaViewToken, role=user.effective_role(workspace.id), + imageryListDef=imagery_list_def, + longFormQuestDef=long_form_quest_def, ) From 894aa171e53bbdcd82b125ce57c0721d95fd1a3e Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 16 Feb 2026 11:32:01 -0800 Subject: [PATCH 11/17] Fix response content of workspace update --- api/src/workspaces/routes.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 97e1417..ed83c33 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -137,14 +137,13 @@ async def create_workspace( raise -# Returns the updated workspace on success. -@router.patch("/{workspace_id}", response_model=Workspace) +@router.patch("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_workspace( workspace_id: int, workspace_data: WorkspacePatch, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: +) -> None: if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -152,10 +151,7 @@ async def update_workspace( ) try: - workspace = await repository_ws.update( - current_user, workspace_id, workspace_data - ) - return workspace + await repository_ws.update(current_user, workspace_id, workspace_data) except Exception as e: logger.error(f"Failed to update workspace {workspace_id}: {str(e)}") raise From 84d0f65c7f42efb857267770143bea5c701bd4f9 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 18 Feb 2026 15:14:39 -0800 Subject: [PATCH 12/17] Fix 500 in workspaces project group membership check --- api/src/workspaces/repository.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 849eefd..7036077 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -3,7 +3,11 @@ from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession -from api.core.exceptions import AlreadyExistsException, NotFoundException +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) from api.core.security import UserInfo from api.src.workspaces.schemas import ( ImagerySettingsPatch, @@ -32,8 +36,9 @@ async def create( ) if str(workspace.tdeiProjectGroupId) not in current_user.getProjectGroupIds(): - raise ValueError( - "User does not have permissions to create a workspace in that project group." + raise ForbiddenException( + "User does not have permissions to create a workspace in that " + "project group." ) try: From 279ab673e99293c669d2dd040f7925df2c22988d Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 20 Feb 2026 17:51:03 -0800 Subject: [PATCH 13/17] Remove unused current_user parameter --- api/src/workspaces/repository.py | 1 - api/src/workspaces/routes.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 7036077..14c4b33 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -202,7 +202,6 @@ def __init__(self, session: AsyncSession): async def getWorkspaceBBox( self, - current_user: UserInfo, workspace_id: int, ): # Postgres does not support parameter binding for `SET search_path`, so diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index ed83c33..07caf10 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -98,7 +98,7 @@ async def get_workspace_bbox( try: # this first query is for permissions checking await repository_ws.getById(current_user, workspace_id) - bbox = await repository_osm.getWorkspaceBBox(current_user, workspace_id) + bbox = await repository_osm.getWorkspaceBBox(workspace_id) return bbox except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") From 9769c5686d7253f06215b69c89e83946edeee4ee Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 23 Feb 2026 09:52:16 -0800 Subject: [PATCH 14/17] Fix missing validation of quest/imagery definitions --- api/core/config.py | 5 +- api/core/json_schema.py | 124 +++++++++++++++++++++++++++++++ api/main.py | 3 + api/src/workspaces/repository.py | 7 +- api/src/workspaces/routes.py | 10 +++ api/src/workspaces/schemas.py | 25 +++++++ 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 api/core/json_schema.py diff --git a/api/core/config.py b/api/core/config.py index e23eac7..16cedfd 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -26,9 +26,12 @@ class Settings(BaseSettings): DEBUG: bool = False # used for validation - WS_LONGFORM_SCHEMA_URL: str = ( + LONGFORM_SCHEMA_URL: str = ( "https://raw.githubusercontent.com/TaskarCenterAtUW/asr-quests/refs/heads/main/schema/schema.json" ) + IMAGERY_SCHEMA_URL: str = ( + "https://raw.githubusercontent.com/TaskarCenterAtUW/asr-imagery-list/refs/heads/main/schema/schema.json" + ) # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" diff --git a/api/core/json_schema.py b/api/core/json_schema.py new file mode 100644 index 0000000..2c2317b --- /dev/null +++ b/api/core/json_schema.py @@ -0,0 +1,124 @@ +import asyncio +import json +from typing import Any + +import httpx +import jsonschema +from fastapi import HTTPException, status + +from api.core.config import settings + +# Shared HTTP client. Initialized by main.py lifespan. +_http_client: httpx.AsyncClient | None = None + +_longform_schema: dict | None = None +_longform_schema_lock = asyncio.Lock() + +_imagery_schema: dict | None = None +_imagery_schema_lock = asyncio.Lock() + + +def init_json_schema_client() -> None: + global _http_client + _http_client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=10, read=30, write=30, pool=10), + ) + + +def get_http_client() -> httpx.AsyncClient | None: + return _http_client + + +async def close_json_schema_client() -> None: + global _http_client + if _http_client is not None: + await _http_client.aclose() + _http_client = None + + +async def _fetch_longform_schema() -> dict: + global _longform_schema + if _longform_schema is None: + async with _longform_schema_lock: + if _longform_schema is None: + response = await _http_client.get(settings.LONGFORM_SCHEMA_URL) + response.raise_for_status() + _longform_schema = response.json() + return _longform_schema + + +async def _fetch_imagery_schema() -> dict: + global _imagery_schema + if _imagery_schema is None: + async with _imagery_schema_lock: + if _imagery_schema is None: + response = await _http_client.get(settings.IMAGERY_SCHEMA_URL) + response.raise_for_status() + _imagery_schema = response.json() + return _imagery_schema + + +def _raise_for_fetch_error(e: Exception, label: str) -> None: + if isinstance(e, httpx.TimeoutException): + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail=f"Timed out fetching {label} schema", + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch {label} schema: {e}", + ) + + +async def validate_quest_definition_schema(definition: str) -> None: + """ + Parse, type-check, and validate a quest definition string against the long- + form quest JSON schema. + """ + try: + parsed = json.loads(definition) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"'definition' must be valid JSON: {e}", + ) + if not parsed or not isinstance(parsed, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'definition' must be a JSON object.", + ) + + try: + schema = await _fetch_longform_schema() + except HTTPException: + raise + except Exception as e: + _raise_for_fetch_error(e, "quest") + + try: + jsonschema.validate(instance=parsed, schema=schema) + except jsonschema.ValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{e.message} at {list(e.path)}", + ) + + +async def validate_imagery_definition_schema(definition: list[Any]) -> None: + """ + Validate the provided definition against the imagery list schema. + """ + try: + schema = await _fetch_imagery_schema() + except HTTPException: + raise + except Exception as e: + _raise_for_fetch_error(e, "imagery") + + try: + jsonschema.validate(instance=definition, schema=schema) + except jsonschema.ValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{e.message} at {list(e.path)}", + ) diff --git a/api/main.py b/api/main.py index d7be59f..2311fdf 100644 --- a/api/main.py +++ b/api/main.py @@ -14,6 +14,7 @@ from api.core import config from api.core.config import settings from api.core.database import get_task_session +from api.core.json_schema import close_json_schema_client, init_json_schema_client from api.core.logging import get_logger, setup_logging from api.core.security import ( UserInfo, @@ -59,6 +60,7 @@ async def lifespan(_app: FastAPI): timeout=httpx.Timeout(connect=10, read=7200, write=7200, pool=10), ) init_tdei_client() + init_json_schema_client() yield # App runs @@ -66,6 +68,7 @@ async def lifespan(_app: FastAPI): await _osm_client.aclose() _osm_client = None await close_tdei_client() + await close_json_schema_client() app = FastAPI( diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 14c4b33..8c1c87c 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,4 +1,3 @@ -import httpx from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -8,6 +7,7 @@ ForbiddenException, NotFoundException, ) +from api.core.json_schema import get_http_client from api.core.security import UserInfo from api.src.workspaces.schemas import ( ImagerySettingsPatch, @@ -143,9 +143,8 @@ async def resolve_quest_def(quest: WorkspaceLongQuest | None) -> str | None: if quest.url: try: - async with httpx.AsyncClient() as client: - response = await client.get(quest.url, timeout=10) - return response.text + response = await get_http_client().get(quest.url, timeout=10) + return response.text except Exception: return None diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 07caf10..88f2ff4 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -5,6 +5,10 @@ from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session +from api.core.json_schema import ( + validate_imagery_definition_schema, + validate_quest_definition_schema, +) from api.core.logging import get_logger from api.core.security import UserInfo, evict_user_from_cache, validate_token from api.src.users.repository import UserRepository @@ -263,6 +267,9 @@ async def update_long_quest_settings( detail="User does not have permission to edit this workspace", ) + if long_quest_data.type == QuestDefinitionTypeName.JSON: + await validate_quest_definition_schema(long_quest_data.definition) + try: await repository_ws.save_longform_quest( current_user, workspace_id, long_quest_data @@ -316,6 +323,9 @@ async def update_imagery_settings( detail="User does not have permission to edit this workspace", ) + if imagery_data.definition: + await validate_imagery_definition_schema(imagery_data.definition) + try: await repository_ws.save_imagery_def(current_user, workspace_id, imagery_data) except Exception as e: diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index d36d30d..87d25bc 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -4,6 +4,7 @@ from uuid import UUID from geoalchemy2 import Geometry +from pydantic import model_validator from sqlalchemy import JSON as SAJson from sqlalchemy import Column, SmallInteger, TypeDecorator, Unicode from sqlmodel import Field, Relationship, SQLModel @@ -146,6 +147,30 @@ class QuestSettingsPatch(SQLModel): definition: Optional[str] = None url: Optional[str] = None + @model_validator(mode="after") + def validate_quest_settings(self) -> "QuestSettingsPatch": + if self.type == QuestDefinitionTypeName.NONE: + if self.definition: + raise ValueError("'definition' field not allowed when type is NONE.") + if self.url: + raise ValueError("'url' field not allowed when type is NONE.") + elif self.type == QuestDefinitionTypeName.JSON: + if not self.definition: + raise ValueError("'definition' is required when type is JSON.") + if self.url: + raise ValueError("'url' field not allowed when type is JSON.") + # Inexpensive early check. Full JSON parse and schema validation + # must call validate_quest_definition_schema(): + if not self.definition.strip().startswith("{"): + raise ValueError("'definition' must be a JSON object.") + elif self.type == QuestDefinitionTypeName.URL: + if not self.url: + raise ValueError("'url' is required when type is URL.") + if self.definition: + raise ValueError("'definition' field not allowed when type is URL.") + + return self + class QuestSettingsResponse(SQLModel): """Quest settings serialized for API responses""" From 4919eaf4b3bac2fc05222f3beaab42bc5a598a82 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 28 Feb 2026 14:23:54 -0800 Subject: [PATCH 15/17] Fix missing validation for empty workspace updates --- api/src/workspaces/routes.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 88f2ff4..acdec31 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -154,6 +154,12 @@ async def update_workspace( detail="User does not have permission to update this workspace", ) + if not workspace_data.model_fields_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided to update.", + ) + try: await repository_ws.update(current_user, workspace_id, workspace_data) except Exception as e: From f6927025e9051658c74ac68e261acbbe42d9cdae Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 1 Mar 2026 13:35:19 -0800 Subject: [PATCH 16/17] Fix quest definition JSON for AVIV ScoutRoute --- api/src/workspaces/routes.py | 13 ++++++++++--- api/src/workspaces/schemas.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index acdec31..2ba802f 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,3 +1,4 @@ +import json from typing import Any from uuid import UUID @@ -75,6 +76,14 @@ async def get_workspace( ) -> WorkspaceResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) + quest_def_str = await WorkspaceRepository.resolve_quest_def( + workspace.longFormQuestDef + ) + try: + quest_def = json.loads(quest_def_str) if quest_def_str else None + except json.JSONDecodeError: + quest_def = None + return WorkspaceResponse.from_workspace( workspace, current_user, @@ -83,9 +92,7 @@ async def get_workspace( if workspace.imageryListDef else None ), - long_form_quest_def=await WorkspaceRepository.resolve_quest_def( - workspace.longFormQuestDef - ), + long_form_quest_def=quest_def, ) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 87d25bc..fa86ce6 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -212,7 +212,7 @@ class WorkspaceResponse(SQLModel): role: str # Included in single-workspace GET for mobile app consumption. TODO: remove # this when the app fetches these from dedicated endpoints: - longFormQuestDef: Optional[str] = None + longFormQuestDef: Optional[Any] = None imageryListDef: Optional[Any] = None @classmethod From 443b6976d344a89ed074f1f4b8cef9b4cf8e8004 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 1 Mar 2026 14:26:44 -0800 Subject: [PATCH 17/17] Remove redundant regex pattern --- api/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/main.py b/api/main.py index 2311fdf..d218e44 100644 --- a/api/main.py +++ b/api/main.py @@ -148,8 +148,6 @@ def get_workspace_repository( r"^/api/0\.6/workspaces.*$", # Provisioning users during authentication: r"^/api/0\.6/user/.*$", - # Used to get workspace bbox without workspace header, to be removed: - r"^/api/0\.6/workspaces/[0-9]+/bbox\.json$", ] ]