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 1c4ee21..d218e44 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( @@ -141,8 +144,10 @@ 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/.*$", ] ] diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index c56b7eb..8c1c87c 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,16 +1,23 @@ -from typing import Any - from sqlalchemy import delete, select, text, update 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.json_schema import get_http_client from api.core.security import UserInfo from api.src.workspaces.schemas import ( + ImagerySettingsPatch, QuestDefinitionType, + QuestSettingsPatch, Workspace, + WorkspaceCreate, WorkspaceImagery, WorkspaceLongQuest, + WorkspacePatch, ) @@ -20,20 +27,21 @@ 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 ForbiddenException( + "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 +75,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 +83,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) @@ -85,103 +93,92 @@ async def update( await self.session.commit() return await self.getById(current_user, workspace_id) - async def createLongformQuest( + async def save_longform_quest( self, current_user: UserInfo, workspace_id: int, - longform_quest_data: dict[str, Any], - ) -> Workspace | None: - query = select(Workspace).where( - (Workspace.id == workspace_id) - & (Workspace.tdeiProjectGroupId.in_(current_user.getProjectGroupIds())) # type: ignore[attr-defined] + longform_quest_data: QuestSettingsPatch, + ) -> None: + query = ( + update(WorkspaceLongQuest) + .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) ) result = await self.session.execute(query) - workspace = result.scalar_one_or_none() - if workspace: - workspace.longFormQuestDef = WorkspaceLongQuest( - **longform_quest_data, - modifiedBy=current_user.user_uuid, # type: ignore[reportArgumentType] - modifiedByName=current_user.user_name, - workspace_id=workspace_id, + + 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() - await self.session.refresh(workspace) - return workspace - async def updateLongformQuest( - self, - current_user: UserInfo, - workspace_id: int, - longform_quest_data: dict[str, Any], - ) -> Workspace: - update_data = longform_quest_data - update_data["modifiedBy"] = current_user.user_uuid - update_data["modifiedByName"] = current_user.user_name + await self.session.commit() - quest_type = longform_quest_data.get("type") - update_data["type"] = QuestDefinitionType[ - quest_type.name if quest_type else "NONE" - ].value + @staticmethod + async def resolve_quest_def(quest: WorkspaceLongQuest | None) -> str | None: + """ + Resolve a WorkspaceLongQuest to its raw JSON string content. - query = ( - update(WorkspaceLongQuest) - .values(**update_data) - .where(WorkspaceLongQuest.workspace_id == workspace_id) # type: ignore[reportArgumentType] - ) - result = await self.session.execute(query) + - JSON type: returns the stored definition string + - URL type: fetches and returns the remote content + - NONE or missing: returns None + """ - if result.rowcount == 0: # type: ignore[attr-defined] - raise NotFoundException(f"Workspace with id {workspace_id} not found") + if quest is None or quest.type == QuestDefinitionType.NONE: + return None + if quest.type == QuestDefinitionType.JSON: + return quest.definition or None - await self.session.commit() - return await self.getById(current_user, workspace_id) + if quest.url: + try: + response = await get_http_client().get(quest.url, timeout=10) + return response.text + except Exception: + return None - async def createImageryDef( - self, - current_user: UserInfo, - workspace_id: int, - imagery_def_data: dict[str, Any], - ) -> 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( - **imagery_def_data, - 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 + return None - async def updateImageryDef( + async def save_imagery_def( self, current_user: UserInfo, workspace_id: int, - imagery_def_data: dict[str, Any], - ) -> Workspace: - update_data = imagery_def_data - update_data["modifiedBy"] = current_user.user_uuid - update_data["modifiedByName"] = current_user.user_name - + imagery_def_data: ImagerySettingsPatch, + ) -> None: query = ( update(WorkspaceImagery) - .values(**update_data) - .where(WorkspaceImagery.workspace_id == workspace_id) # type: ignore[reportArgumentType] + .values( + definition=imagery_def_data.definition, + modifiedBy=current_user.user_uuid, + modifiedByName=current_user.user_name, + ) + .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) async def delete(self, current_user: UserInfo, workspace_id: int) -> None: query = delete(Workspace).where( @@ -204,7 +201,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 99f764c..2ba802f 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,20 +1,29 @@ +import json 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 +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 from api.src.users.schemas import WorkspaceUserRoleType from api.src.workspaces.repository import OSMRepository, WorkspaceRepository from api.src.workspaces.schemas import ( - QuestDefinitionType, + ImagerySettingsPatch, + QuestDefinitionTypeName, + QuestSettingsPatch, + QuestSettingsResponse, Workspace, + WorkspaceCreate, WorkspaceImagery, - WorkspaceLongQuest, + WorkspacePatch, WorkspaceResponse, ) @@ -24,7 +33,6 @@ router = APIRouter(prefix="/workspaces", tags=["workspaces"]) - def get_workspace_repository( session: AsyncSession = Depends(get_task_session), ) -> WorkspaceRepository: @@ -68,14 +76,24 @@ 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) + 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, + imagery_list_def=( + workspace.imageryListDef.definition + if workspace.imageryListDef + else None + ), + long_form_quest_def=quest_def, + ) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise @@ -90,34 +108,22 @@ 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", - ) - - 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", - ) - + await repository_ws.getById(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)}") raise # 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], + workspace_data: WorkspaceCreate, 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,31 +142,33 @@ 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 -# 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: dict[str, Any], + 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, detail="User does not have permission to update this workspace", ) - try: - workspace = await repository_ws.update( - current_user, workspace_id, workspace_data + if not workspace_data.model_fields_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided to update.", ) - return workspace + + try: + 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 @@ -191,31 +199,66 @@ async def delete_workspace( raise -### QUESTS +# 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=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 @@ -227,7 +270,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: @@ -237,8 +280,11 @@ 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.updateLongformQuest( + await repository_ws.save_longform_quest( current_user, workspace_id, long_quest_data ) except Exception as e: @@ -246,7 +292,7 @@ async def update_long_quest_settings( raise -### IMAGERY +# IMAGERY # Returns JSON payload or 204 if not set @@ -268,7 +314,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 +326,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: @@ -290,8 +336,11 @@ 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.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 diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 1f991b9..fa86ce6 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 @@ -72,6 +73,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 +120,76 @@ 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 + + @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""" + + 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""" + + definition: Optional[list[Any]] = None + + class WorkspaceResponse(SQLModel): """ Workspace serialized for API responses. Includes the effective role for the @@ -133,9 +210,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[Any] = 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, @@ -151,6 +239,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, )