diff --git a/alembic_osm/env.py b/alembic_osm/env.py index bb18cbb..dea7d02 100644 --- a/alembic_osm/env.py +++ b/alembic_osm/env.py @@ -35,8 +35,10 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) +config.file_config.interpolation = None +escaped_url = settings.OSM_DATABASE_URL.replace("%", "%%") # Set sqlalchemy.url from settings -config.set_main_option("sqlalchemy.url", settings.OSM_DATABASE_URL) +config.set_main_option("sqlalchemy.url", escaped_url) # Add your model's MetaData object here for 'autogenerate' support target_metadata = Base.metadata diff --git a/alembic_task/env.py b/alembic_task/env.py index ba3c5da..ca0283c 100644 --- a/alembic_task/env.py +++ b/alembic_task/env.py @@ -35,8 +35,10 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) +# config.file_config.interpolation = None +escaped_url = settings.TASK_DATABASE_URL.replace("%", "%%") # Set sqlalchemy.url from settings -config.set_main_option("sqlalchemy.url", settings.TASK_DATABASE_URL) +config.set_main_option("sqlalchemy.url", escaped_url) # Add your model's MetaData object here for 'autogenerate' support target_metadata = Base.metadata diff --git a/api/core/security.py b/api/core/security.py index 020abf8..1f0c7c7 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -162,6 +162,18 @@ def evict_user_from_cache(auth_uid: UUID) -> None: _user_info_cache.pop(auth_uid, None) + +def evict_user_from_cache(auth_uid: str) -> None: + """ + Evict a user's cached UserInfo object so that their next request re-fetches + permissions. + + Call this after modifying a user's roles in the OSM DB to ensure the change + takes effect on their next request rather than after the cache TTL expires. + """ + _user_info_cache.pop(auth_uid, None) + + security = HTTPBearer() @@ -244,11 +256,14 @@ def isWorkspaceValidator(self, workspaceId: int) -> bool: return True return False - # user has has any association with a project group that owns the workspace + # user has has any association with a project group that owns the workspace, + # OR has any explicit role granted in the OSM DB for that workspace def isWorkspaceContributor(self, workspaceId: int) -> bool: for pgid, wsids in self.accessibleWorkspaceIds.items(): if workspaceId in wsids: return True + if workspaceId in self.osmWorkspaceRoles: + return True return False def effective_role(self, workspaceId: int) -> WorkspaceUserRoleType: diff --git a/api/main.py b/api/main.py index c3708b3..7bda970 100644 --- a/api/main.py +++ b/api/main.py @@ -104,6 +104,15 @@ async def lifespan(_app: FastAPI): max_age=100, ) +# Set up CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, # Adjust this to your needs + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Include routers app.include_router(osm_router, prefix="/api/v1") app.include_router(teams_router, prefix="/api/v1") @@ -115,6 +124,7 @@ async def lifespan(_app: FastAPI): app.include_router(tasking_audit_router, prefix="/api/v1") + @app.get("/health") async def health_check(): """Health check endpoint. Used for Docker.""" diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 90ac74f..6f3d2fb 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -12,7 +12,6 @@ from api.src.workspaces.schemas import ( ImagerySettingsPatch, QuestDefinitionType, - QuestSettingsPatch, Workspace, WorkspaceCreate, WorkspaceImagery, @@ -201,3 +200,31 @@ async def delete(self, current_user: UserInfo, workspace_id: int) -> None: raise NotFoundException(f"Workspace delete failed for id {workspace_id}") await self.session.commit() + + +class OSMRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def getWorkspaceBBox( + self, + current_user: UserInfo, + workspace_id: int, + ): + await self.session.execute( + text(f"SET search_path TO 'workspace-{workspace_id}', public") + ) + + sql_query = text( + "select MAX(latitude) AS max_lat, MAX(longitude) AS max_lon, \ + MIN(latitude) AS min_lat, MIN(longitude) AS min_lon from nodes" + ) + + result = await self.session.execute(sql_query) + retVal = result.mappings().first() + + if retVal is None: + raise NotFoundException(f"Workspace with id {workspace_id} not found") + + return retVal diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 430509c..bcda4e0 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,5 +1,6 @@ import json from uuid import UUID +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlmodel.ext.asyncio.session import AsyncSession @@ -16,6 +17,9 @@ from api.src.users.repository import UserRepository from api.src.users.schemas import WorkspaceUserRoleType from api.src.workspaces.repository import WorkspaceRepository +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 ( ImagerySettingsPatch, QuestDefinitionTypeName, @@ -24,15 +28,37 @@ WorkspaceCreate, WorkspaceImagery, WorkspacePatch, + WorkspaceLongQuest, WorkspaceResponse, ) + # Set up logger for this module logger = get_logger(__name__) router = APIRouter(prefix="/workspaces", tags=["workspaces"]) +def _to_response(workspace: Workspace, user: "UserInfo") -> WorkspaceResponse: + """Convert a Workspace ORM object to a response model with the user's effective role.""" + return WorkspaceResponse( + id=workspace.id, + type=workspace.type, + title=workspace.title, + description=workspace.description, + tdeiProjectGroupId=workspace.tdeiProjectGroupId, + tdeiRecordId=workspace.tdeiRecordId, + tdeiServiceId=workspace.tdeiServiceId, + tdeiMetadata=workspace.tdeiMetadata, + createdAt=workspace.createdAt, + createdBy=workspace.createdBy, + createdByName=workspace.createdByName, + externalAppAccess=workspace.externalAppAccess, + kartaViewToken=workspace.kartaViewToken, + role=user.effectiveRole(workspace.id), + ) + + def get_workspace_repository( session: AsyncSession = Depends(get_task_session), ) -> WorkspaceRepository: @@ -55,6 +81,12 @@ def get_user_repository( # @test: Test that this method's results match the values of the fetch-by-workspace-id method below; all workspaces in this list are retrievable via that method +def get_user_repository( + session: AsyncSession = Depends(get_osm_session), +) -> UserRepository: + return UserRepository(session) + + # Returns list of workspaces user has access to as JSON payload on success--returns empty JSON list if none @router.get("/mine", response_model=list[WorkspaceResponse]) async def get_my_workspaces( diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 9614338..3cda58e 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -1,6 +1,7 @@ from datetime import datetime from enum import IntEnum, StrEnum from typing import TYPE_CHECKING, Any, Optional, Self +from typing import Any, Optional from uuid import UUID from geoalchemy2 import Geometry