From 7d8b758dcfa3a3d7e88f71e2cc74dfe30a6f9790 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 3 Jan 2026 19:31:25 -0800 Subject: [PATCH 001/159] Finish implementation of stubbed roles APIs --- api/core/exceptions.py | 7 ++ api/core/security.py | 9 ++- api/main.py | 2 + api/src/teams/repository.py | 2 +- api/src/teams/routes.py | 47 +++++++++++--- api/src/users/repository.py | 108 +++++++++++++++++++++++++++++++ api/src/users/routes.py | 87 +++++++++++++++++++++++++ api/src/users/schemas.py | 83 ++++++++++++++++++++++++ api/src/workspaces/repository.py | 64 +----------------- api/src/workspaces/routes.py | 94 ++++++++++----------------- api/src/workspaces/schemas.py | 79 +++++++++++----------- 11 files changed, 409 insertions(+), 173 deletions(-) create mode 100644 api/src/users/repository.py create mode 100644 api/src/users/routes.py create mode 100644 api/src/users/schemas.py diff --git a/api/core/exceptions.py b/api/core/exceptions.py index 9bbbdba..fd1a7d4 100644 --- a/api/core/exceptions.py +++ b/api/core/exceptions.py @@ -15,6 +15,13 @@ def __init__(self, detail: str = "Resource already exists"): super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) +class ConflictException(HTTPException): + """Base exception for conflict errors.""" + + def __init__(self, detail: str = "Conflict"): + super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) + + class UnauthorizedException(HTTPException): """Base exception for unauthorized access errors.""" diff --git a/api/core/security.py b/api/core/security.py index 3e60de1..c036621 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -12,7 +12,7 @@ from api.core.database import get_osm_session, get_task_session from api.core.jwt import validate_and_decode_token from api.core.logging import get_logger -from api.src.workspaces.schemas import WorkspaceUserRoleType +from api.src.users.schemas import WorkspaceUserRoleType # Set up logger for this module logger = get_logger(__name__) @@ -125,6 +125,13 @@ def isWorkspaceContributor(self, workspaceId: int) -> bool: return True return False + def effective_role(self, workspaceId: int) -> WorkspaceUserRoleType: + if self.isWorkspaceLead(workspaceId): + return WorkspaceUserRoleType.LEAD + if self.isWorkspaceValidator(workspaceId): + return WorkspaceUserRoleType.VALIDATOR + return WorkspaceUserRoleType.CONTRIBUTOR + # can't use the ORM here since the ORM uses us! (circular dependency) def get_osm_db_session( diff --git a/api/main.py b/api/main.py index d8c4978..1c4ee21 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,7 @@ validate_token, ) from api.src.teams.routes import router as teams_router +from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository from api.src.workspaces.routes import router as workspaces_router from api.utils.migrations import run_migrations @@ -85,6 +86,7 @@ async def lifespan(_app: FastAPI): # Include routers app.include_router(teams_router, prefix="/api/v1") +app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 93be2a3..2b6c09e 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -9,7 +9,7 @@ WorkspaceTeamItem, WorkspaceTeamUpdate, ) -from api.src.workspaces.schemas import User +from api.src.users.schemas import User class WorkspaceTeamRepository: diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index c031fae..5163259 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -9,8 +9,9 @@ WorkspaceTeamItem, WorkspaceTeamUpdate, ) -from api.src.workspaces.repository import OSMRepository, WorkspaceRepository -from api.src.workspaces.schemas import User +from api.src.users.repository import UserRepository +from api.src.users.schemas import User +from api.src.workspaces.repository import WorkspaceRepository router = APIRouter(prefix="/workspaces/{workspace_id}/teams", tags=["teams"]) @@ -22,10 +23,10 @@ def get_workspace_repo( return repo -def get_osm_repo( +def get_user_repo( session: AsyncSession = Depends(get_osm_session), -) -> OSMRepository: - repository = OSMRepository(session) +) -> UserRepository: + repository = UserRepository(session) return repository @@ -56,6 +57,12 @@ async def create_team_for_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ) -> int: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can create teams", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) return await team_repo.create(workspace_id, team) @@ -84,6 +91,12 @@ async def update_team_for_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can update teams", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) @@ -98,6 +111,12 @@ async def delete_team_from_workspace( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can delete teams", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) @@ -123,14 +142,14 @@ async def join_workspace_team( workspace_id: int, team_id: int, workspace_repo=Depends(get_workspace_repo), - osm_repo=Depends(get_osm_repo), + user_repo=Depends(get_user_repo), team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ) -> User: # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) - user = await osm_repo.get_current_user(current_user) + user = await user_repo.get_current_user(current_user) await team_repo.add_member(team_id, user.id) return user @@ -144,6 +163,12 @@ async def add_member_to_workspace_team( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can add team members", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) @@ -159,6 +184,12 @@ async def delete_member_from_workspace_team( team_repo=Depends(get_team_repo), current_user: UserInfo = Depends(validate_token), ): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads can remove team members", + ) + # Repo guards if workspace doesn't exist or user cannot access: await workspace_repo.getById(current_user, workspace_id) await team_repo.assert_team_in_workspace(team_id, workspace_id) diff --git a/api/src/users/repository.py b/api/src/users/repository.py new file mode 100644 index 0000000..f7769e4 --- /dev/null +++ b/api/src/users/repository.py @@ -0,0 +1,108 @@ +from uuid import UUID + +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.core.security import UserInfo +from api.src.users.schemas import ( + User, + WorkspaceUserRole, + WorkspaceUserRoleItem, + WorkspaceUserRoleType, +) + + +class UserRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def get_privileged_workspace_members( + self, + workspace_id: int, + ) -> list[WorkspaceUserRoleItem]: + # The table only stores "lead" and "validator" role assignments. Project + # group members implicitly have the base "contributor" role. + # + query = ( + select(User, WorkspaceUserRole.role) + .join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid) + .where(WorkspaceUserRole.workspace_id == workspace_id) + ) + result = await self.session.execute(query) + + return [ + WorkspaceUserRoleItem( + id=user.id, + auth_uid=user.auth_uid, + display_name=user.display_name, + role=role, + ) + for user, role in result.all() + ] + + async def get_current_user(self, current_user: UserInfo) -> User: + result = await self.session.exec( + select(User).where(User.auth_uid == str(current_user.user_uuid)) + ) + + # Current user should exist--throw if it doesn't: + return result.scalar_one() + + async def assign_member_role( + self, + workspace_id: int, + user_id: UUID, + role: WorkspaceUserRoleType, + ) -> None: + # Ensure the user has a local user record (signed in at least once): + user_exists = await self.session.scalar( + select(User.id).where(User.auth_uid == str(user_id)) + ) + if not user_exists: + raise NotFoundException( + f"User {user_id} has not signed in to Workspaces yet" + ) + + await self.session.execute( + pg_insert(WorkspaceUserRole) + .values( + user_auth_uid=str(user_id), + workspace_id=workspace_id, + role=role, + ) + .on_conflict_do_update( + index_elements=["user_auth_uid", "workspace_id"], + set_={"role": role}, + ) + ) + await self.session.commit() + + async def remove_member_role( + self, + workspace_id: int, + user_id: UUID, + ) -> None: + query = delete(WorkspaceUserRole).where( + (WorkspaceUserRole.workspace_id == workspace_id) + & (WorkspaceUserRole.user_auth_uid == str(user_id)) + ) + + result = await self.session.execute(query) + + if result.rowcount != 1: + raise NotFoundException( + f"No role assigned for workspace {workspace_id}, user {user_id}" + ) + + await self.session.commit() + + async def remove_all_member_roles(self, workspace_id: int) -> None: + await self.session.execute( + delete(WorkspaceUserRole).where( + WorkspaceUserRole.workspace_id == workspace_id + ) + ) + await self.session.commit() diff --git a/api/src/users/routes.py b/api/src/users/routes.py new file mode 100644 index 0000000..e9914bb --- /dev/null +++ b/api/src/users/routes.py @@ -0,0 +1,87 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.users.repository import UserRepository +from api.src.users.schemas import SetRoleRequest, WorkspaceUserRoleItem +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter(prefix="/workspaces/{workspace_id}/users", tags=["users"]) + + +def get_user_repo( + session: AsyncSession = Depends(get_osm_session), +) -> UserRepository: + repository = UserRepository(session) + return repository + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +@router.get("", response_model=list[WorkspaceUserRoleItem]) +async def get_privileged_workspace_members( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), +): + if not current_user.isWorkspaceContributor(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Project group membership required to view members", + ) + + return await user_repo.get_privileged_workspace_members(workspace_id) + + +@router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) +async def assign_member_role( + workspace_id: int, + user_id: UUID, + body: SetRoleRequest, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Must be a workspace owner to assign roles", + ) + + # Ensure that the workspace exists in the tasks DB before we write to the + # OSM DB. TODO: remove the check when we merge the DBs with the proper FK + # constraints that enforce referential integrity internally. + # + await workspace_repo.getById(current_user, workspace_id) + + await user_repo.assign_member_role(workspace_id, user_id, body.role) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_member_role( + workspace_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + user_repo: UserRepository = Depends(get_user_repo), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), +): + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Must be a workspace owner to remove roles", + ) + + # Ensure that the workspace exists in the tasks DB before we write to the + # OSM DB. TODO: remove the check when we merge the DBs with the proper FK + # constraints that enforce referential integrity internally. + # + await workspace_repo.getById(current_user, workspace_id) + + await user_repo.remove_member_role(workspace_id, user_id) diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py new file mode 100644 index 0000000..7b4731b --- /dev/null +++ b/api/src/users/schemas.py @@ -0,0 +1,83 @@ +from enum import StrEnum +from typing import TYPE_CHECKING + +from pydantic import field_validator +from sqlalchemy import Column, Enum +from sqlmodel import Field, Relationship, SQLModel + +from api.src.teams.schemas import WorkspaceTeamUser + +if TYPE_CHECKING: + from api.src.teams.schemas import WorkspaceTeam + + +class WorkspaceUserRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +class WorkspaceUserRole(SQLModel, table=True): + """Associates users with workspaces and their roles""" + + __tablename__ = "user_workspace_roles" # type: ignore[assignment] + + # this is the TDEI auth user UUID, from the token + user_auth_uid: str = Field(foreign_key="users.auth_uid", primary_key=True) + # workspace_id lives in a different DB (task DB), so no FK constraint here + workspace_id: int = Field(primary_key=True) + + role: WorkspaceUserRoleType = Field( + sa_column=Column( + Enum( + WorkspaceUserRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda e: [m.value for m in e], + ), + nullable=False, + ) + ) + + +class SetRoleRequest(SQLModel): + role: WorkspaceUserRoleType + + @field_validator("role") + @classmethod + def privileged_roles_only(cls, v: WorkspaceUserRoleType) -> WorkspaceUserRoleType: + if v == WorkspaceUserRoleType.CONTRIBUTOR: + raise ValueError("cannot assign implicit role 'contributor' directly") + return v + + +class WorkspaceUserRoleItem(SQLModel): + """ + User with their workspace role. DTO for use in the context of a particular + workspace + """ + + id: int + auth_uid: str + display_name: str + role: WorkspaceUserRoleType + + +class User(SQLModel, table=True): + """Users in the OSM DB""" + + __tablename__ = "users" # type: ignore[assignment] + + # User ID referred to by parts of the code based on the OSM DB schema: + id: int = Field(default=None, primary_key=True) + + # Principal ID from the TDEI OIDC gateway ("subject" in an access token). + # It differs from the TDEI user ID: + auth_uid: str = Field(unique=True, index=True) + + email: str = Field(unique=True, index=True) + display_name: str = Field(nullable=False) + + teams: list["WorkspaceTeam"] = Relationship( + back_populates="users", link_model=WorkspaceTeamUser + ) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 62a8e85..c56b7eb 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,5 +1,4 @@ -from typing import Any, cast -from uuid import UUID +from typing import Any from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError @@ -9,12 +8,9 @@ from api.core.security import UserInfo from api.src.workspaces.schemas import ( QuestDefinitionType, - User, Workspace, WorkspaceImagery, WorkspaceLongQuest, - WorkspaceUserRole, - WorkspaceUserRoleType, ) @@ -232,61 +228,3 @@ async def getWorkspaceBBox( raise NotFoundException(f"Workspace with id {workspace_id} not found") return retVal - - async def getAllUsers( - self, - ): - query = select(User) - result = await self.session.execute(query) - return list(result.scalars().all()) - - async def get_current_user(self, current_user: UserInfo) -> User: - result = await self.session.exec( - select(User).where(User.auth_uid == str(current_user.user_uuid)) - ) - - # Current user should exist--throw if it doesn't: - return result.scalar_one() - - async def addUserToWorkspaceWithRole( - self, - current_user: UserInfo, - workspace_id: int, - user_id: UUID, - role: WorkspaceUserRoleType, - ) -> None: - - userRole = WorkspaceUserRole( - auth_user_uid=cast(UUID, user_id), - workspace_id=workspace_id, - role=role, - ) - - try: - self.session.add(userRole) - await self.session.commit() - except IntegrityError: - await self.session.rollback() - raise AlreadyExistsException( - "User association with that workspace already exists" - ) - - async def removeUserFromWorkspace( - self, - current_user: UserInfo, - workspace_id: int, - user_id: UUID, - ) -> None: - query = delete(WorkspaceUserRole).where( - (WorkspaceUserRole.workspace_id == workspace_id) # type: ignore[reportArgumentType] - & (WorkspaceUserRole.auth_user_uid == user_id) - ) - - result = await self.session.execute(query) - - if result.rowcount != 1: # type: ignore[attr-defined] - raise NotFoundException( - f"User association removal failed for workspace {workspace_id} and user {user_id}" - ) - - await self.session.commit() diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 86d890d..1739ca8 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,5 +1,4 @@ from typing import Any -from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession @@ -7,13 +6,15 @@ from api.core.database import get_osm_session, get_task_session from api.core.logging import get_logger from api.core.security import UserInfo, 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, Workspace, WorkspaceImagery, WorkspaceLongQuest, - WorkspaceUserRoleType, + WorkspaceResponse, ) # Set up logger for this module @@ -22,6 +23,7 @@ router = APIRouter(prefix="/workspaces", tags=["workspaces"]) + def get_workspace_repository( session: AsyncSession = Depends(get_task_session), ) -> WorkspaceRepository: @@ -36,27 +38,33 @@ def get_osm_repository( return repository +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[Workspace]) +@router.get("/mine", response_model=list[WorkspaceResponse]) async def get_my_workspaces( repository: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> list[Workspace]: +) -> list[WorkspaceResponse]: try: workspaces = await repository.getAll(current_user) - return workspaces + return [WorkspaceResponse.from_workspace(ws, current_user) for ws in workspaces] except Exception as e: logger.error(f"Failed to fetch workspaces: {str(e)}") raise # Returns JSON payload or 204 if not found -@router.get("/{workspace_id}", response_model=Workspace) +@router.get("/{workspace_id}", response_model=WorkspaceResponse) async def get_workspace( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), -) -> Workspace: +) -> WorkspaceResponse: try: workspace = await repository_ws.getById(current_user, workspace_id) @@ -66,7 +74,7 @@ async def get_workspace( detail="No Content", ) - return workspace + return WorkspaceResponse.from_workspace(workspace, current_user) except Exception as e: logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise @@ -106,10 +114,21 @@ async def get_workspace_bbox( 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: try: workspace = await repository_ws.create(current_user, workspace_data) + + # Assign the creator as lead so that non-POC members can manage their + # own workspace: + # + await repository_users.assign_member_role( + workspace.id, + current_user.user_uuid, + WorkspaceUserRoleType.LEAD, + ) + return workspace except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") @@ -124,7 +143,7 @@ async def update_workspace( repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> Workspace: - if current_user.isWorkspaceLead(workspace_id) is False: + 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", @@ -145,9 +164,10 @@ async def update_workspace( async def delete_workspace( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), + repository_users: UserRepository = Depends(get_user_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to delete this workspace", @@ -155,6 +175,7 @@ async def delete_workspace( try: await repository_ws.delete(current_user, workspace_id) + await repository_users.remove_all_member_roles(workspace_id) except Exception as e: logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}") raise @@ -200,7 +221,7 @@ async def update_long_quest_settings( repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to edit this workspace", @@ -253,7 +274,7 @@ async def update_imagery_settings( repository_ws: WorkspaceRepository = Depends(get_workspace_repository), current_user: UserInfo = Depends(validate_token), ) -> None: - if current_user.isWorkspaceLead(workspace_id) is False: + if not current_user.isWorkspaceLead(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User does not have permission to edit this workspace", @@ -264,52 +285,3 @@ async def update_imagery_settings( except Exception as e: logger.error(f"Failed to update workspace {workspace_id}: {str(e)}") raise - - -### USERS - - -@router.get("/{workspace_id}/users") -async def get_users( - workspace_id: int, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - return await repository_osm.getAllUsers() - - -@router.post("/{workspace_id}/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def add_user_with_role( - workspace_id: int, - user_id: UUID, - role: WorkspaceUserRoleType, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - if current_user.isWorkspaceLead(workspace_id) is False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User does not have permission to edit this workspace", - ) - - return await repository_osm.addUserToWorkspaceWithRole( - current_user, workspace_id, user_id, role - ) - - -@router.delete("/{workspace_id}/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_user_with_role( - workspace_id: int, - user_id: UUID, - current_user: UserInfo = Depends(validate_token), - repository_osm: OSMRepository = Depends(get_osm_repository), -): - if current_user.isWorkspaceLead(workspace_id) is False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User does not have permission to edit this workspace", - ) - - return await repository_osm.removeUserFromWorkspace( - current_user, workspace_id, user_id - ) diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index af8c859..1f991b9 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import IntEnum, StrEnum -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Self from uuid import UUID from geoalchemy2 import Geometry @@ -8,10 +8,8 @@ from sqlalchemy import Column, SmallInteger, TypeDecorator, Unicode from sqlmodel import Field, Relationship, SQLModel -from api.src.teams.schemas import WorkspaceTeamUser - if TYPE_CHECKING: - from api.src.teams.schemas import WorkspaceTeam + from api.core.security import UserInfo class IntEnumType(TypeDecorator): @@ -74,12 +72,6 @@ class QuestDefinitionType(IntEnum): URL = 2 -class WorkspaceUserRoleType(StrEnum): - LEAD = "lead" - VALIDATOR = "validator" - CONTRIBUTOR = "contributor" - - class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -121,36 +113,45 @@ class WorkspaceImagery(SQLModel, table=True): modifiedByName: str -class WorkspaceUserRole(SQLModel, table=True): - """Associates users with workspaces and their roles""" - - __tablename__ = "user_workspace_roles" # type: ignore[assignment] - - # this is the TDEI auth user UUID, from the token - auth_user_uid: str = Field(foreign_key="users.auth_uid", primary_key=True) - workspace_id: int = Field(foreign_key="workspaces.id", primary_key=True) - - role: WorkspaceUserRoleType = Field( - sa_column=Column(StrEnumType(WorkspaceUserRoleType), nullable=False) - ) - - -class User(SQLModel, table=True): - """Users""" - - __tablename__ = "users" # type: ignore[assignment] - - id: int = Field(default=None, primary_key=True) +class WorkspaceResponse(SQLModel): + """ + Workspace serialized for API responses. Includes the effective role for the + user making the request. + """ - # this is the user ID from the TDEI authentication system - auth_uid: str = Field(unique=True, index=True) - - email: str = Field(unique=True, index=True) - display_name: str = Field(nullable=False) - - teams: list["WorkspaceTeam"] = Relationship( - back_populates="users", link_model=WorkspaceTeamUser - ) + id: int + type: WorkspaceType + title: str + description: Optional[str] = None + tdeiProjectGroupId: UUID + tdeiRecordId: Optional[UUID] = None + tdeiServiceId: Optional[UUID] = None + tdeiMetadata: Optional[Any] = None + createdAt: datetime + createdBy: UUID + createdByName: str + externalAppAccess: ExternalAppsDefinitionType + kartaViewToken: Optional[str] = None + role: str + + @classmethod + def from_workspace(cls, workspace: "Workspace", user: "UserInfo") -> Self: + return cls( + 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.effective_role(workspace.id), + ) class Workspace(SQLModel, table=True): From b1d35465d87dfc14baa76c18099904a9e45eda11 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 11 Jan 2026 13:00:41 -0800 Subject: [PATCH 002/159] Evict user info cache entries when roles change --- api/core/security.py | 68 +++++++++++++++++++++++++----------- api/src/users/routes.py | 4 ++- api/src/workspaces/routes.py | 12 ++++++- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index c036621..c283af4 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -17,8 +17,10 @@ # Set up logger for this module logger = get_logger(__name__) -# TTL cache for token validation (1 hour TTL, max 1000 entries) -_token_cache: cachetools.TTLCache[str, "UserInfo"] = cachetools.TTLCache( +# TTL cache keyed by a user's OIDC subject. Evict entries when roles change. We +# still validate the JWT signature and expiry on every request before reading a +# cached record. +_user_info_cache: cachetools.TTLCache[UUID, "UserInfo"] = cachetools.TTLCache( maxsize=1000, ttl=60 * 60 ) @@ -41,6 +43,17 @@ async def close_tdei_client() -> None: _tdei_client = None +def evict_user_from_cache(auth_uid: UUID) -> 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() @@ -72,6 +85,7 @@ class UserInfo: credentials: str user_uuid: UUID user_name: str + token_jti: str # JWT ID used to detect token rotation on cache hits # workspaceId, role from OSM DB osmWorkspaceRoles: dict[int, list[WorkspaceUserRoleType]] @@ -151,9 +165,13 @@ async def validate_token( osm_db_session: AsyncSession = Depends(get_osm_db_session), task_db_session: AsyncSession = Depends(get_task_db_session), ) -> UserInfo: - """Dependency to get current authenticated user from TDEI/KeyCloak token and APIs. + """ + Dependency that gets the current authenticated user from the TDEI/KeyCloak + access token and fetches permissions from TDEI APIs. - Results are cached by token for 1 hour to avoid repeated validation calls. + We validate the JWT's signature and expiry on every request. The expensive + TDEI API and DB lookups are cached for 1 hour and should be evicted when a + user's role changes via evict_user_from_cache(). """ token = credentials.credentials @@ -168,27 +186,39 @@ async def validate_token( except Exception: raise credentials_exception - user_id: str | None = payload.get("sub") - if user_id is None: + user_id_str: str | None = payload.get("sub") + if user_id_str is None: raise credentials_exception - # Check cache first - if token in _token_cache: - logger.info("Token validation cache hit") - return _token_cache[token] + try: + user_uuid = UUID(user_id_str) + except ValueError: + raise credentials_exception from None - # Cache miss - perform full validation + # Cache keyed by user UUID. If the token rotated (new "jti") since we + # created the cache entry, evict it so we fetch fresh claims: + # + if user_uuid in _user_info_cache: + cached = _user_info_cache[user_uuid] + current_jti = payload.get("jti", "") + if cached.token_jti == current_jti: + logger.info("Token validation cache hit") + return cached + logger.info("Token validation cache miss: token rotated") + del _user_info_cache[user_uuid] + + # Cache miss: fetch TDEI roles and DB data: user_info = await _validate_token_uncached( - token, user_id, payload, osm_db_session, task_db_session + token, user_uuid, payload, osm_db_session, task_db_session ) - _token_cache[token] = user_info + _user_info_cache[user_uuid] = user_info return user_info async def _validate_token_uncached( token: str, - user_id: str, + user_uuid: UUID, payload: dict, osm_db_session: AsyncSession, task_db_session: AsyncSession, @@ -207,13 +237,9 @@ async def _validate_token_uncached( } r = UserInfo() - - try: - r.user_uuid = UUID(user_id) - except ValueError: - raise credentials_exception from None - + r.user_uuid = user_uuid r.credentials = token + r.token_jti = payload.get("jti", "") r.user_name = payload.get("preferred_username", "unknown") # get user's project groups and roles from TDEI @@ -221,7 +247,7 @@ async def _validate_token_uncached( try: response = await _tdei_client.get( - f"project-group-roles/{user_id}", + f"project-group-roles/{user_uuid}", headers=headers, params={"page_no": 1, "page_size": 1000}, ) diff --git a/api/src/users/routes.py b/api/src/users/routes.py index e9914bb..54d3527 100644 --- a/api/src/users/routes.py +++ b/api/src/users/routes.py @@ -4,7 +4,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session -from api.core.security import UserInfo, validate_token +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 SetRoleRequest, WorkspaceUserRoleItem from api.src.workspaces.repository import WorkspaceRepository @@ -62,6 +62,7 @@ async def assign_member_role( await workspace_repo.getById(current_user, workspace_id) await user_repo.assign_member_role(workspace_id, user_id, body.role) + evict_user_from_cache(user_id) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -85,3 +86,4 @@ async def remove_member_role( await workspace_repo.getById(current_user, workspace_id) await user_repo.remove_member_role(workspace_id, user_id) + evict_user_from_cache(user_id) diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 1739ca8..99f764c 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,11 +1,12 @@ from typing import Any +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session from api.core.logging import get_logger -from api.core.security import UserInfo, validate_token +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 @@ -129,6 +130,12 @@ async def create_workspace( WorkspaceUserRoleType.LEAD, ) + # Evict the creator's cache so their next request reflects the new + # workspace and lead role rather than serving stale data for up to + # an hour: + # + evict_user_from_cache(current_user.user_uuid) + return workspace except Exception as e: logger.error(f"Failed to create workspace: {str(e)}") @@ -174,8 +181,11 @@ async def delete_workspace( ) try: + members = await repository_users.get_privileged_workspace_members(workspace_id) await repository_ws.delete(current_user, workspace_id) await repository_users.remove_all_member_roles(workspace_id) + for member in members: + evict_user_from_cache(UUID(member.auth_uid)) except Exception as e: logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}") raise From a6cef2acafc185815226138bad72ba64bb8c3ab2 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 21 Jan 2026 09:49:37 -0800 Subject: [PATCH 003/159] 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 004/159] 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 005/159] 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 006/159] 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 007/159] 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 008/159] 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 009/159] 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 010/159] 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 011/159] 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 012/159] 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 013/159] 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 014/159] 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 015/159] 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 016/159] 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 ea3a35982f1802ae2f8a53f99fdccd041011153d Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 30 Dec 2025 12:22:49 -0800 Subject: [PATCH 017/159] Switch JWKS client to singleton for cert/key caching --- api/core/jwt.py | 33 ++++++++++++++++++++++++++++++++ api/core/security.py | 45 ++++++++++++++++++++++---------------------- 2 files changed, 56 insertions(+), 22 deletions(-) create mode 100644 api/core/jwt.py diff --git a/api/core/jwt.py b/api/core/jwt.py new file mode 100644 index 0000000..1f2c268 --- /dev/null +++ b/api/core/jwt.py @@ -0,0 +1,33 @@ +import jwt + +from api.core.config import settings + +# Singleton JWKS client reused to take advantage of internal cert/key caching: +_jwks_client: jwt.PyJWKClient | None = None + + +def _get_jwks_client() -> jwt.PyJWKClient: + global _jwks_client + + if _jwks_client is None: + _jwks_client = jwt.PyJWKClient( + f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}" + f"/protocol/openid-connect/certs" + ) + + return _jwks_client + + +def validate_and_decode_token(token: str) -> dict: + # TODO: use an async client like pyjwt-key-fetcher + signing_key = _get_jwks_client().get_signing_key_from_jwt(token) + + decoded = jwt.decode_complete( + token, + key=signing_key.key, + algorithms=["RS256"], + # OIDC server does not currently differentiate tokens by audience + options={"verify_aud": False}, + ) + + return decoded.get("payload", {}) diff --git a/api/core/security.py b/api/core/security.py index 7531677..02e3e9e 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -3,7 +3,6 @@ from uuid import UUID import cachetools -import jwt import requests from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -12,6 +11,7 @@ from api.core.config import settings from api.core.database import get_osm_session, get_task_session +from api.core.jwt import validate_and_decode_token from api.core.logging import get_logger from api.src.workspaces.schemas import WorkspaceUserRoleType @@ -129,19 +129,39 @@ async def validate_token( """ token = credentials.credentials + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = validate_and_decode_token(token) + except Exception: + raise credentials_exception + + user_id: str | None = payload.get("sub") + if user_id is None: + raise credentials_exception + # Check cache first if token in _token_cache: logger.info("Token validation cache hit") return _token_cache[token] # Cache miss - perform full validation - user_info = await _validate_token_uncached(token, osm_db_session, task_db_session) + user_info = await _validate_token_uncached( + token, user_id, payload, osm_db_session, task_db_session + ) _token_cache[token] = user_info + return user_info async def _validate_token_uncached( token: str, + user_id: str, + payload: dict, osm_db_session: AsyncSession, task_db_session: AsyncSession, ) -> UserInfo: @@ -153,25 +173,6 @@ async def _validate_token_uncached( headers={"WWW-Authenticate": "Bearer"}, ) - jwks_client = jwt.PyJWKClient( - f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs" - ) - - signing_key = jwks_client.get_signing_key_from_jwt(token) - - jwtDecoded = jwt.decode_complete( - token, - key=signing_key.key, - algorithms=["RS256"], - # OIDC server does not currently differentiate tokens by audience - options={"verify_aud": False} - ) - payload = jwtDecoded.get("payload", {}) - - user_id: str | None = payload.get("sub") - if user_id is None: - raise credentials_exception - headers = { "Authorization": "Bearer " + token, "Content-Type": "application/json", @@ -200,7 +201,7 @@ async def _validate_token_uncached( r = UserInfo() r.credentials = token - r.user_uuid = UUID(payload.get("sub", "unknown")) + r.user_uuid = UUID(user_id) r.user_name = payload.get("preferred_username", "unknown") # project groups and roles from TDEI KeyCloak From 6e4f03e09c35fd3d3e3e203c7ebd13a273706321 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 30 Dec 2025 14:31:19 -0800 Subject: [PATCH 018/159] Fix ValueError raised in project group verification --- api/core/security.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/core/security.py b/api/core/security.py index 02e3e9e..10cef9e 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -84,7 +84,9 @@ def isWorkspaceLead(self, workspaceId: int) -> bool: for pg in self.projectGroups: if TdeiProjectGroupRole.POINT_OF_CONTACT in pg.tdeiRoles: - if workspaceId in self.accessibleWorkspaceIds[pg.project_group_id]: + if workspaceId in self.accessibleWorkspaceIds.get( + pg.project_group_id, [] + ): return True return False From 3a8cb57ff75176cd57ab9158a5f6ea070085d611 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 31 Dec 2025 18:03:11 -0800 Subject: [PATCH 019/159] Fix the blocking requests for a user's project groups --- api/core/security.py | 58 +++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index 10cef9e..4c36ce6 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,9 +1,8 @@ -import json from enum import StrEnum from uuid import UUID import cachetools -import requests +import httpx from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text @@ -180,42 +179,39 @@ async def _validate_token_uncached( "Content-Type": "application/json", } - # get user's project groups and roles from TDEI - # TODO: fix if user has > 50 PGs - authorizationUrl = ( - settings.TDEI_BACKEND_URL - + "/project-group-roles/" - + user_id - + "?page_no=1&page_size=50" - ) - - response = requests.get(authorizationUrl, headers=headers) - - # token is not valid or server unavailable - if response.status_code != 200: - raise credentials_exception - - try: - content = response.text - j = json.loads(content) - except json.JSONDecodeError: - raise credentials_exception - r = UserInfo() r.credentials = token r.user_uuid = UUID(user_id) r.user_name = payload.get("preferred_username", "unknown") - # project groups and roles from TDEI KeyCloak + # get user's project groups and roles from TDEI + pg_base_url = f"{settings.TDEI_BACKEND_URL}/project-group-roles/{user_id}" pgs = [] - for i in j: - pgs.append( - UserInfoPGMembership( - project_group_id=i["tdei_project_group_id"], - project_group_name=i["project_group_name"], - tdeiRoles=i["roles"], - ) + async with httpx.AsyncClient() as http_client: + response = await http_client.get( + pg_base_url, + headers=headers, + params={"page_no": 1, "page_size": 1000}, ) + + # token is not valid or server unavailable + if response.status_code != 200: + raise credentials_exception + + try: + pg_data = response.json() + except Exception: + raise credentials_exception + + for i in pg_data: + pgs.append( + UserInfoPGMembership( + project_group_id=i["tdei_project_group_id"], + project_group_name=i["project_group_name"], + tdeiRoles=i["roles"], + ) + ) + r.projectGroups = pgs # workspaces within our set of PGs from tasking manager DB From 06c3bbe6e3227bcd109c929ae32d4b7112a0b873 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 15 Dec 2025 11:19:33 -0800 Subject: [PATCH 020/159] Plug potential SQL injection issue --- api/src/workspaces/repository.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 080f103..ada15ba 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -211,8 +211,13 @@ async def getWorkspaceBBox( current_user: UserInfo, workspace_id: int, ): + # Postgres does not support parameter binding for `SET search_path`, so + # workspace_id is interpolated directly. The explicit int() cast guards + # against SQL injection if this method is ever called from outside of a + # FastAPI path handler (where the type annotation acts as a safeguard). + # await self.session.execute( - text(f"SET search_path TO 'workspace-{workspace_id}', public") + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") ) sql_query = text( From 2001befc4e2b9797a634822885925a84fff46663 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 15 Dec 2025 11:22:41 -0800 Subject: [PATCH 021/159] Fix OSM proxy routing for CGImap --- api/core/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index 94edba6..a861236 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -18,8 +18,8 @@ class Settings(BaseSettings): "https://raw.githubusercontent.com/TaskarCenterAtUW/asr-quests/refs/heads/main/schema/schema.json" ) - # proxy destination--"osm-rails" is a virtual docker network endpoint - WS_OSM_HOST: str = "http://osm-rails:3000" + # proxy destination--"osm-web" is a virtual docker network endpoint + WS_OSM_HOST: str = "http://osm-web" #WS_OSM_HOST: str = "https://osm.workspaces-dev.sidewalks.washington.edu" SENTRY_DSN: str = "" From da0548e50434512aa4f3d1b2251f27799cdb50b7 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 17 Dec 2025 13:12:51 -0800 Subject: [PATCH 022/159] Fix missing X-Workspace header in OSM proxy --- api/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/main.py b/api/main.py index af66673..d6f112f 100644 --- a/api/main.py +++ b/api/main.py @@ -104,7 +104,8 @@ async def catch_all( detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) - return + + authorizedWorkspace = workspace_id else: if not any( re.search(pattern, request.url.path) for pattern in AUTH_WHITELIST_PATHS From 6790c451a21d2af63be2efbd18db95f62d6a5f6a Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 18 Dec 2025 10:16:30 -0800 Subject: [PATCH 023/159] Fix improper use of HTTP 401 in OSM proxy --- api/main.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/api/main.py b/api/main.py index d6f112f..49608f5 100644 --- a/api/main.py +++ b/api/main.py @@ -100,9 +100,8 @@ async def catch_all( if not current_user.isWorkspaceContributor(workspace_id): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this workspace", ) authorizedWorkspace = workspace_id @@ -111,10 +110,9 @@ async def catch_all( re.search(pattern, request.url.path) for pattern in AUTH_WHITELIST_PATHS ): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="You must set your workspace in the X-Workspace header to access OSM methods.", + status_code=status.HTTP_400_BAD_REQUEST, + detail="No X-Workspace header supplied", ) - return url = httpx.URL( path=request.url.path.strip(), query=request.url.query.encode("utf-8") From bc3e19a3cc3b7738add7336e16035f7452888efd Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 19 Dec 2025 14:46:08 -0800 Subject: [PATCH 024/159] Fix bogus regex for OSM proxy auth path whitelist --- api/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/main.py b/api/main.py index 49608f5..35b7831 100644 --- a/api/main.py +++ b/api/main.py @@ -70,8 +70,8 @@ def get_workspace_repository( # Define paths that do not require X-Workspace header AUTH_WHITELIST_PATHS = [ - "/api/0.6/user/*", # used during authentication - "/api/0.6/workspaces/[0-9]*/bbox.json", # used to get workspace bbox without workspace header, to be removed + 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 ] @@ -107,7 +107,7 @@ async def catch_all( authorizedWorkspace = workspace_id else: if not any( - re.search(pattern, request.url.path) for pattern in AUTH_WHITELIST_PATHS + re.fullmatch(pattern, request.url.path) for pattern in AUTH_WHITELIST_PATHS ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, From 205770e1a4ea1a22d2388abebbc912e456f6d917 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 30 Dec 2025 20:15:44 -0800 Subject: [PATCH 025/159] Fix resource leak/exhaustion in OSM proxy http client --- api/main.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/api/main.py b/api/main.py index 35b7831..56127df 100644 --- a/api/main.py +++ b/api/main.py @@ -1,5 +1,6 @@ import os import re +from contextlib import asynccontextmanager import httpx import sentry_sdk @@ -35,10 +36,28 @@ # Set up logger for this module logger = get_logger(__name__) +# Shared HTTP client for OSM proxy. Reuses connection pool across requests: +_osm_client: httpx.AsyncClient | None = None + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + # Run before app bootstrap: + global _osm_client + _osm_client = httpx.AsyncClient(base_url=settings.WS_OSM_HOST) + + yield # App runs + + # Run after app cleanup: + await _osm_client.aclose() + _osm_client = None + + app = FastAPI( title=settings.PROJECT_NAME, debug=settings.DEBUG, swagger_ui_parameters={"syntaxHighlight": False}, + lifespan=lifespan, ) # Include routers @@ -117,8 +136,8 @@ async def catch_all( url = httpx.URL( path=request.url.path.strip(), query=request.url.query.encode("utf-8") ) - client = httpx.AsyncClient(base_url=settings.WS_OSM_HOST) + client = _osm_client new_headers = list() new_headers.append( (bytes("Authorization", "utf-8"), request.headers.get("Authorization")) @@ -126,24 +145,27 @@ async def catch_all( if authorizedWorkspace is not None: new_headers.append( - (bytes("X-Workspace", "utf-8"), bytes(str(authorizedWorkspace.id), "utf-8")) + ( + bytes("X-Workspace", "utf-8"), + bytes(str(authorizedWorkspace), "utf-8"), + ) ) - new_headers.append((bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8"))) + new_headers.append( + (bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8")) + ) rp_req = client.build_request( request.method, url, headers=new_headers, content=await request.body() ) - rp_resp = await client.send(rp_req, stream=True) if rp_resp.status_code >= 400 and rp_resp.status_code < 600: - sentry_sdk.capture_message( - f"Upstream request to {rp_req.url} returned status code {rp_resp.status_code}" - ) - - logger.warning( - f"Upstream request to {rp_req.url} returned status code {rp_resp.status_code}" + msg = ( + f"Upstream request to {rp_req.url} returned " + f"status code {rp_resp.status_code}" ) + sentry_sdk.capture_message(msg) + logger.warning(msg) return StreamingResponse( rp_resp.aiter_raw(), From d44a810f652ce24b6a02c401fc1ea6973c349c72 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 10 Jan 2026 16:55:20 -0800 Subject: [PATCH 026/159] Avoid buffering entire request body in OSM proxy --- api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/main.py b/api/main.py index 56127df..c07d22a 100644 --- a/api/main.py +++ b/api/main.py @@ -155,7 +155,7 @@ async def catch_all( (bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8")) ) rp_req = client.build_request( - request.method, url, headers=new_headers, content=await request.body() + request.method, url, headers=new_headers, content=request.stream() ) rp_resp = await client.send(rp_req, stream=True) From 07afd9ade8e582f3790beccaa2f7ca4212155ad9 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 12 Jan 2026 12:10:31 -0800 Subject: [PATCH 027/159] Fix broken chunked encoding in OSM proxy by omitting HBHHs --- api/main.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/api/main.py b/api/main.py index c07d22a..1b3dd18 100644 --- a/api/main.py +++ b/api/main.py @@ -87,6 +87,20 @@ def get_workspace_repository( # h/t: https://stackoverflow.com/questions/70610266/proxy-an-external-website-using-python-fast-api-not-supporting-query-params # +# According to HTTP/1.1, a proxy must not forward these "hop-by-hop" headers: +HOP_BY_HOP_HEADERS = frozenset( + [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + ] +) + # Define paths that do not require X-Workspace header AUTH_WHITELIST_PATHS = [ r"^/api/0\.6/user/.*$", # used during authentication @@ -151,9 +165,7 @@ async def catch_all( ) ) - new_headers.append( - (bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8")) - ) + new_headers.append((bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8"))) rp_req = client.build_request( request.method, url, headers=new_headers, content=request.stream() ) @@ -167,9 +179,13 @@ async def catch_all( sentry_sdk.capture_message(msg) logger.warning(msg) + forwarded_headers = { + k: v for k, v in rp_resp.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS + } + return StreamingResponse( rp_resp.aiter_raw(), status_code=rp_resp.status_code, - headers=rp_resp.headers, + headers=forwarded_headers, background=BackgroundTask(rp_resp.aclose), ) From e9b705ed78b3f79ca1053af58c25f4118dee8519 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 16 Jan 2026 11:15:01 -0800 Subject: [PATCH 028/159] Increase OSM proxy timeout for large imports --- api/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/main.py b/api/main.py index 1b3dd18..cd3be2a 100644 --- a/api/main.py +++ b/api/main.py @@ -44,7 +44,11 @@ async def lifespan(_app: FastAPI): # Run before app bootstrap: global _osm_client - _osm_client = httpx.AsyncClient(base_url=settings.WS_OSM_HOST) + _osm_client = httpx.AsyncClient( + base_url=settings.WS_OSM_HOST, + # 2 hour timeout for long-running OSM imports: + timeout=httpx.Timeout(connect=10, read=7200, write=7200, pool=10), + ) yield # App runs From 20fa5216d79926cb37ee2d16e1b91e8b11d35647 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 25 Jan 2026 14:49:52 -0800 Subject: [PATCH 029/159] Refuse TRACE proxy requests to avoid cross-site tracing attacks --- api/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/main.py b/api/main.py index cd3be2a..c5b196b 100644 --- a/api/main.py +++ b/api/main.py @@ -114,7 +114,7 @@ def get_workspace_repository( @app.api_route( "/{full_path:path}", - methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"], + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], ) async def catch_all( request: Request, From a38e82e99b3ffd385606d8dcd83edb14d57a1fa8 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 25 Jan 2026 15:11:23 -0800 Subject: [PATCH 030/159] Avoid compiling auth regex for every request --- api/main.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/api/main.py b/api/main.py index c5b196b..69ea5ce 100644 --- a/api/main.py +++ b/api/main.py @@ -106,9 +106,12 @@ def get_workspace_repository( ) # Define paths that do not require X-Workspace header -AUTH_WHITELIST_PATHS = [ - 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 +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 + ] ] @@ -143,9 +146,7 @@ async def catch_all( authorizedWorkspace = workspace_id else: - if not any( - re.fullmatch(pattern, request.url.path) for pattern in AUTH_WHITELIST_PATHS - ): + if not any(p.fullmatch(request.url.path) for p in AUTH_WHITELIST_PATTERNS): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No X-Workspace header supplied", From bc8c182521b97027475d832af36515c2a8ed3fb5 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Mon, 2 Feb 2026 16:08:53 -0800 Subject: [PATCH 031/159] Surface OSM proxy gateway issues instead of generic 500 --- api/main.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/api/main.py b/api/main.py index 69ea5ce..5632f84 100644 --- a/api/main.py +++ b/api/main.py @@ -174,7 +174,18 @@ async def catch_all( rp_req = client.build_request( request.method, url, headers=new_headers, content=request.stream() ) - rp_resp = await client.send(rp_req, stream=True) + try: + rp_resp = await client.send(rp_req, stream=True) + except httpx.TimeoutException: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Upstream OSM service timed out", + ) + except httpx.ConnectError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not connect to upstream OSM service", + ) if rp_resp.status_code >= 400 and rp_resp.status_code < 600: msg = ( From 508bd3ea61b33b5aad2f282321e2f12ea2cdae49 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 17 Feb 2026 11:10:59 -0800 Subject: [PATCH 032/159] Fix OSM proxy for no-auth on capabilities.json --- api/main.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/api/main.py b/api/main.py index 5632f84..c169ad8 100644 --- a/api/main.py +++ b/api/main.py @@ -115,6 +115,38 @@ def get_workspace_repository( ] +@app.get("/api/capabilities.json") +async def capabilities(): + """Proxy OSM capabilities manifest without requiring authentication.""" + + url = httpx.URL(path="/api/capabilities.json") + rp_req = _osm_client.build_request("GET", url) + + try: + rp_resp = await _osm_client.send(rp_req, stream=True) + except httpx.TimeoutException: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Upstream OSM service timed out", + ) + except httpx.ConnectError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not connect to upstream OSM service", + ) + + forwarded_headers = { + k: v for k, v in rp_resp.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS + } + + return StreamingResponse( + rp_resp.aiter_raw(), + status_code=rp_resp.status_code, + headers=forwarded_headers, + background=BackgroundTask(rp_resp.aclose), + ) + + @app.api_route( "/{full_path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"], From b92d706e37c68f3d6662f37489d843bb72d9098e Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 18 Feb 2026 09:49:17 -0800 Subject: [PATCH 033/159] Fix OSM proxy response format by forwarding all headers --- api/main.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/api/main.py b/api/main.py index c169ad8..6567d70 100644 --- a/api/main.py +++ b/api/main.py @@ -189,20 +189,14 @@ async def catch_all( ) client = _osm_client - new_headers = list() - new_headers.append( - (bytes("Authorization", "utf-8"), request.headers.get("Authorization")) - ) - if authorizedWorkspace is not None: - new_headers.append( - ( - bytes("X-Workspace", "utf-8"), - bytes(str(authorizedWorkspace), "utf-8"), - ) - ) + # Forward all request headers except the hop-by-hops: + new_headers = [ + (k.encode(), v.encode()) + for k, v in request.headers.items() + if k.lower() not in HOP_BY_HOP_HEADERS + ] - new_headers.append((bytes("Host", "utf-8"), bytes(client.base_url.host, "utf-8"))) rp_req = client.build_request( request.method, url, headers=new_headers, content=request.stream() ) From 57716c692405e6de22495d84d067720c0cc237f1 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sun, 8 Feb 2026 15:28:36 -0800 Subject: [PATCH 034/159] Add CORS middleware for OSM proxy --- api/core/config.py | 8 ++++++++ api/main.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/api/core/config.py b/api/core/config.py index a861236..2163857 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,9 +1,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict + + class Settings(BaseSettings): """Application settings.""" PROJECT_NAME: str = "Workspaces API" + # JSON array of allowed CORS origins. For example: + # + # ["https://workspaces.example.com", "https://leaderboard.example.com"] + # + CORS_ORIGINS: list[str] = [] + TASK_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" OSM_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" diff --git a/api/main.py b/api/main.py index 6567d70..cfae2d4 100644 --- a/api/main.py +++ b/api/main.py @@ -5,6 +5,7 @@ import httpx import sentry_sdk from fastapi import Depends, FastAPI, HTTPException, Request, status +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, StreamingResponse from sqlmodel.ext.asyncio.session import AsyncSession from starlette.background import BackgroundTask @@ -64,6 +65,15 @@ async def lifespan(_app: FastAPI): lifespan=lifespan, ) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + max_age=100, +) + # Include routers app.include_router(teams_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") From 4919eaf4b3bac2fc05222f3beaab42bc5a598a82 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 28 Feb 2026 14:23:54 -0800 Subject: [PATCH 035/159] 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 036/159] 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 037/159] 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$", ] ] From 6227337b18922bb888211776f2226d0e7235d752 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 5 Mar 2026 11:02:16 -0800 Subject: [PATCH 038/159] Fix potential resource exhaustion in TDEI HTTP client --- api/core/security.py | 64 ++++++++++++++++++++++++++++---------------- api/main.py | 10 ++++++- 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index 4c36ce6..3fe3bb8 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -22,6 +22,25 @@ maxsize=1000, ttl=60 * 60 ) +# Shared HTTP client for TDEI backend calls. Initialized by main.py lifespan. +_tdei_client: httpx.AsyncClient | None = None + + +def init_tdei_client() -> None: + global _tdei_client + _tdei_client = httpx.AsyncClient( + base_url=settings.TDEI_BACKEND_URL, + timeout=httpx.Timeout(connect=10, read=30, write=30, pool=10), + ) + + +async def close_tdei_client() -> None: + global _tdei_client + if _tdei_client is not None: + await _tdei_client.aclose() + _tdei_client = None + + security = HTTPBearer() @@ -119,6 +138,7 @@ def get_task_db_session( ) -> AsyncSession: return session + async def validate_token( credentials: HTTPAuthorizationCredentials = Depends(security), osm_db_session: AsyncSession = Depends(get_osm_db_session), @@ -185,32 +205,30 @@ async def _validate_token_uncached( r.user_name = payload.get("preferred_username", "unknown") # get user's project groups and roles from TDEI - pg_base_url = f"{settings.TDEI_BACKEND_URL}/project-group-roles/{user_id}" pgs = [] - async with httpx.AsyncClient() as http_client: - response = await http_client.get( - pg_base_url, - headers=headers, - params={"page_no": 1, "page_size": 1000}, - ) + response = await _tdei_client.get( + f"project-group-roles/{user_id}", + headers=headers, + params={"page_no": 1, "page_size": 1000}, + ) + + # token is not valid or server unavailable + if response.status_code != 200: + raise credentials_exception + + try: + pg_data = response.json() + except Exception: + raise credentials_exception - # token is not valid or server unavailable - if response.status_code != 200: - raise credentials_exception - - try: - pg_data = response.json() - except Exception: - raise credentials_exception - - for i in pg_data: - pgs.append( - UserInfoPGMembership( - project_group_id=i["tdei_project_group_id"], - project_group_name=i["project_group_name"], - tdeiRoles=i["roles"], - ) + for i in pg_data: + pgs.append( + UserInfoPGMembership( + project_group_id=i["tdei_project_group_id"], + project_group_name=i["project_group_name"], + tdeiRoles=i["roles"], ) + ) r.projectGroups = pgs diff --git a/api/main.py b/api/main.py index cfae2d4..ef859f5 100644 --- a/api/main.py +++ b/api/main.py @@ -14,7 +14,12 @@ from api.core.config import settings from api.core.database import get_task_session from api.core.logging import get_logger, setup_logging -from api.core.security import UserInfo, validate_token +from api.core.security import ( + UserInfo, + close_tdei_client, + init_tdei_client, + validate_token, +) from api.src.teams.routes import router as teams_router from api.src.workspaces.repository import WorkspaceRepository from api.src.workspaces.routes import router as workspaces_router @@ -50,12 +55,14 @@ async def lifespan(_app: FastAPI): # 2 hour timeout for long-running OSM imports: timeout=httpx.Timeout(connect=10, read=7200, write=7200, pool=10), ) + init_tdei_client() yield # App runs # Run after app cleanup: await _osm_client.aclose() _osm_client = None + await close_tdei_client() app = FastAPI( @@ -78,6 +85,7 @@ async def lifespan(_app: FastAPI): app.include_router(teams_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") + @app.get("/health") async def health_check(): """Health check endpoint. Used for Docker.""" From 34c0423fbd57beda97def936b21e539affac1b83 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 5 Mar 2026 11:36:50 -0800 Subject: [PATCH 039/159] Harden JWKS URL construction to avoid malformed endpoints --- api/core/jwt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/core/jwt.py b/api/core/jwt.py index 1f2c268..46e04c8 100644 --- a/api/core/jwt.py +++ b/api/core/jwt.py @@ -11,8 +11,8 @@ def _get_jwks_client() -> jwt.PyJWKClient: if _jwks_client is None: _jwks_client = jwt.PyJWKClient( - f"{settings.TDEI_OIDC_URL}realms/{settings.TDEI_OIDC_REALM}" - f"/protocol/openid-connect/certs" + f"{settings.TDEI_OIDC_URL.rstrip("/")}/realms/" + f"{settings.TDEI_OIDC_REALM}/protocol/openid-connect/certs" ) return _jwks_client From 15c7436d1fa0ae456059732645ac909047e7740e Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 5 Mar 2026 12:12:10 -0800 Subject: [PATCH 040/159] Fix OSM proxy upstream logging with forwarding headers --- api/main.py | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/api/main.py b/api/main.py index ef859f5..ba2e33c 100644 --- a/api/main.py +++ b/api/main.py @@ -123,6 +123,16 @@ def get_workspace_repository( ] ) +# Do not forward spoofed reverse-proxy informational headers: +STRIP_REQUEST_HEADERS = HOP_BY_HOP_HEADERS | { + "host", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "forwarded", +} + # Define paths that do not require X-Workspace header AUTH_WHITELIST_PATTERNS = [ re.compile(p) @@ -134,11 +144,24 @@ def get_workspace_repository( @app.get("/api/capabilities.json") -async def capabilities(): +async def capabilities(request: Request): """Proxy OSM capabilities manifest without requiring authentication.""" + client_host = request.client.host if request.client else "unknown" + req_headers = [ + (k.encode(), v.encode()) + for k, v in request.headers.items() + if k.lower() not in STRIP_REQUEST_HEADERS + ] + [ + (b"Host", _osm_client.base_url.host.encode()), + (b"X-Real-IP", client_host.encode()), + (b"X-Forwarded-For", client_host.encode()), + (b"X-Forwarded-Host", (request.url.hostname or "").encode()), + (b"X-Forwarded-Proto", request.url.scheme.encode()), + ] + url = httpx.URL(path="/api/capabilities.json") - rp_req = _osm_client.build_request("GET", url) + rp_req = _osm_client.build_request("GET", url, headers=req_headers) try: rp_resp = await _osm_client.send(rp_req, stream=True) @@ -207,16 +230,21 @@ async def catch_all( ) client = _osm_client - - # Forward all request headers except the hop-by-hops: - new_headers = [ + client_host = request.client.host if request.client else "unknown" + req_headers = [ (k.encode(), v.encode()) for k, v in request.headers.items() - if k.lower() not in HOP_BY_HOP_HEADERS + if k.lower() not in STRIP_REQUEST_HEADERS + ] + [ + (b"Host", client.base_url.host.encode()), + (b"X-Real-IP", client_host.encode()), + (b"X-Forwarded-For", client_host.encode()), + (b"X-Forwarded-Host", (request.url.hostname or "").encode()), + (b"X-Forwarded-Proto", request.url.scheme.encode()), ] rp_req = client.build_request( - request.method, url, headers=new_headers, content=request.stream() + request.method, url, headers=req_headers, content=request.stream() ) try: rp_resp = await client.send(rp_req, stream=True) From 7011b63e2ca3cc85cd95d8a8e5ff31e86661528e Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 6 Mar 2026 09:59:39 -0800 Subject: [PATCH 041/159] Remove now unused authorizedWorkspace variable --- api/main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/main.py b/api/main.py index ba2e33c..396b530 100644 --- a/api/main.py +++ b/api/main.py @@ -200,7 +200,6 @@ async def catch_all( """ Catch-all route to proxy requests to the OSM service. """ - authorizedWorkspace = None if request.headers.get("X-Workspace") is not None: try: @@ -216,8 +215,6 @@ async def catch_all( status_code=status.HTTP_403_FORBIDDEN, detail="You do not have access to this workspace", ) - - authorizedWorkspace = workspace_id else: if not any(p.fullmatch(request.url.path) for p in AUTH_WHITELIST_PATTERNS): raise HTTPException( From ca6fe12e167fd55e4c0d4942c993e45231a7b6f0 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 7 Mar 2026 00:02:07 -0800 Subject: [PATCH 042/159] Handle TDEI transport failures explicitly --- api/core/security.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index 3fe3bb8..58c1288 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -206,11 +206,18 @@ async def _validate_token_uncached( # get user's project groups and roles from TDEI pgs = [] - response = await _tdei_client.get( - f"project-group-roles/{user_id}", - headers=headers, - params={"page_no": 1, "page_size": 1000}, - ) + + try: + response = await _tdei_client.get( + f"project-group-roles/{user_id}", + headers=headers, + params={"page_no": 1, "page_size": 1000}, + ) + except httpx.RequestError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not reach TDEI backend", + ) from None # token is not valid or server unavailable if response.status_code != 200: From 3d0c0568d4434e3e562f24ab2f37d8adbc8fd08d Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Sat, 7 Mar 2026 13:55:36 -0800 Subject: [PATCH 043/159] Guard UUID parsing to avoid 500s on malformed tokens --- api/core/security.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/core/security.py b/api/core/security.py index 58c1288..3e60de1 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -200,8 +200,13 @@ async def _validate_token_uncached( } r = UserInfo() + + try: + r.user_uuid = UUID(user_id) + except ValueError: + raise credentials_exception from None + r.credentials = token - r.user_uuid = UUID(user_id) r.user_name = payload.get("preferred_username", "unknown") # get user's project groups and roles from TDEI From 934ce06c3c33a1dff460aa3402c90ebea2fec65c Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 24 Feb 2026 20:04:48 -0500 Subject: [PATCH 044/159] Small security flags and linter feedback --- LICENSE => LICENSE.template | 0 alembic_osm/env.py | 2 +- .../9221408912dd_add_user_role_table.py | 48 ++++++++++++------- alembic_task/env.py | 2 +- alembic_task/versions/add6266277c7_.py | 1 - api/core/config.py | 10 ++-- api/src/teams/repository.py | 9 ++-- api/src/teams/routes.py | 2 +- api/src/teams/schemas.py | 2 +- api/src/workspaces/repository.py | 12 ++--- api/src/workspaces/schemas.py | 2 +- 11 files changed, 54 insertions(+), 36 deletions(-) rename LICENSE => LICENSE.template (100%) diff --git a/LICENSE b/LICENSE.template similarity index 100% rename from LICENSE rename to LICENSE.template diff --git a/alembic_osm/env.py b/alembic_osm/env.py index 9ec3f3e..bb18cbb 100644 --- a/alembic_osm/env.py +++ b/alembic_osm/env.py @@ -8,11 +8,11 @@ # Add the project root directory to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context from api.core.config import settings from api.core.database import Base diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index aa938a9..3963de9 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -5,15 +5,15 @@ Create Date: 2026-01-29 14:54:10.669000 """ + from typing import Sequence, Union +import sqlalchemy as sa from alembic import op from sqlalchemy import inspect, text -import sqlalchemy as sa - # revision identifiers, used by Alembic. -revision: str = '9221408912dd' +revision: str = "9221408912dd" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,6 +21,7 @@ def upgrade() -> None: bind = op.get_bind() + assert bind is not None insp = inspect(bind) # Add unique constraint on users.auth_uid (if not already present) @@ -28,45 +29,60 @@ def upgrade() -> None: text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") ).scalar() if not constraint_exists: - op.create_unique_constraint('auth_uid_unique', 'users', ['auth_uid']) + op.create_unique_constraint("auth_uid_unique", "users", ["auth_uid"]) # Create the workspace_role enum type (if not already present) result = bind.execute( text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") ) if not result.scalar(): - workspace_role = sa.Enum('lead', 'validator', 'contributor', name='workspace_role') + workspace_role = sa.Enum( + "lead", "validator", "contributor", name="workspace_role" + ) workspace_role.create(bind) # Create the user_workspace_roles table (if not already present) - if not insp.has_table('user_workspace_roles'): + if not insp.has_table("user_workspace_roles"): op.create_table( - 'user_workspace_roles', - sa.Column('user_auth_uid', sa.Uuid(), nullable=False), - sa.Column('workspace_id', sa.BigInteger(), nullable=False), - sa.Column('role', sa.Enum('lead', 'validator', 'contributor', name='workspace_role', create_type=False), nullable=False), - sa.ForeignKeyConstraint(['user_auth_uid'], ['users.auth_uid']), - sa.PrimaryKeyConstraint('user_auth_uid', 'workspace_id') + "user_workspace_roles", + sa.Column("user_auth_uid", sa.Uuid(), nullable=False), + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column( + "role", + sa.Enum( + "lead", + "validator", + "contributor", + name="workspace_role", + create_type=False, + ), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("user_auth_uid", "workspace_id"), ) def downgrade() -> None: bind = op.get_bind() + assert bind is not None insp = inspect(bind) - if insp.has_table('user_workspace_roles'): - op.drop_table('user_workspace_roles') + if insp.has_table("user_workspace_roles"): + op.drop_table("user_workspace_roles") # Drop the enum type result = bind.execute( text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") ) if result.scalar(): - workspace_role = sa.Enum('lead', 'validator', 'contributor', name='workspace_role') + workspace_role = sa.Enum( + "lead", "validator", "contributor", name="workspace_role" + ) workspace_role.drop(bind) constraint_exists = bind.execute( text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") ).scalar() if constraint_exists: - op.drop_constraint('auth_uid_unique', 'users', type_='unique') + op.drop_constraint("auth_uid_unique", "users", type_="unique") diff --git a/alembic_task/env.py b/alembic_task/env.py index 3d1a2b3..ba3c5da 100644 --- a/alembic_task/env.py +++ b/alembic_task/env.py @@ -8,11 +8,11 @@ # Add the project root directory to the Python path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context from api.core.config import settings from api.core.database import Base diff --git a/alembic_task/versions/add6266277c7_.py b/alembic_task/versions/add6266277c7_.py index 517ee34..ed80db5 100644 --- a/alembic_task/versions/add6266277c7_.py +++ b/alembic_task/versions/add6266277c7_.py @@ -7,7 +7,6 @@ """ import sqlalchemy as sa - from alembic import op # revision identifiers, used by Alembic. diff --git a/api/core/config.py b/api/core/config.py index 2163857..e23eac7 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -12,8 +12,12 @@ class Settings(BaseSettings): # CORS_ORIGINS: list[str] = [] - TASK_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" - OSM_DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + TASK_DATABASE_URL: str = ( + "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + ) + OSM_DATABASE_URL: str = ( + "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" + ) TDEI_BACKEND_URL: str = "https://portal-api-dev.tdei.us/api/v1/" TDEI_OIDC_URL: str = "https://account-dev.tdei.us/" @@ -28,7 +32,6 @@ class Settings(BaseSettings): # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" - #WS_OSM_HOST: str = "https://osm.workspaces-dev.sidewalks.washington.edu" SENTRY_DSN: str = "" @@ -37,4 +40,5 @@ class Settings(BaseSettings): env_file_encoding="utf-8", ) + settings = Settings() diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 9604282..93be2a3 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -24,7 +24,7 @@ async def get_all(self, workspace_id: int) -> list[WorkspaceTeamItem]: .where(WorkspaceTeam.workspace_id == workspace_id) ) - return [WorkspaceTeamItem.from_team(x) for x in result.scalars()] + return [WorkspaceTeamItem.from_team(x) for x in result.scalars().all()] async def get(self, id: int, load_members: bool = False) -> WorkspaceTeam: query = select(WorkspaceTeam).where(WorkspaceTeam.id == id) @@ -59,14 +59,13 @@ async def assert_team_in_workspace(self, id: int, workspace_id: int): raise NotFoundException(f"Team {id} not in workspace {workspace_id}") async def create(self, workspace_id: int, data: WorkspaceTeamCreate) -> int: - team = WorkspaceTeam() - team.workspace_id = workspace_id - team.name = data.name + team = WorkspaceTeam(name=data.name, workspace_id=workspace_id) self.session.add(team) await self.session.commit() await self.session.refresh(team) + assert team.id is not None return team.id async def update(self, id: int, data: WorkspaceTeamUpdate): @@ -77,7 +76,7 @@ async def update(self, id: int, data: WorkspaceTeamUpdate): await self.session.commit() async def delete(self, id: int) -> None: - await self.session.exec(delete(WorkspaceTeam).where(WorkspaceTeam.id == id)) + await self.session.execute(delete(WorkspaceTeam).where(WorkspaceTeam.id == id)) # type: ignore[arg-type] await self.session.commit() async def get_members(self, id: int) -> list[User]: diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index 5437ab1..c031fae 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index c0def20..7d623d4 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -1,4 +1,4 @@ -from typing import Self, TYPE_CHECKING +from typing import TYPE_CHECKING, Self from sqlmodel import Field, Relationship, SQLModel diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index ada15ba..62a8e85 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -83,7 +83,7 @@ async def update( ) result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # type: ignore[attr-defined] raise NotFoundException(f"Update failed for workspace id {workspace_id}") await self.session.commit() @@ -134,7 +134,7 @@ async def updateLongformQuest( ) result = await self.session.execute(query) - if result.rowcount == 0: + if result.rowcount == 0: # type: ignore[attr-defined] raise NotFoundException(f"Workspace with id {workspace_id} not found") await self.session.commit() @@ -181,7 +181,7 @@ async def updateImageryDef( result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # type: ignore[attr-defined] raise NotFoundException(f"Update failed for workspace id {workspace_id}") await self.session.commit() @@ -195,7 +195,7 @@ async def delete(self, current_user: UserInfo, workspace_id: int) -> None: result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # type: ignore[attr-defined] raise NotFoundException(f"Workspace delete failed for id {workspace_id}") await self.session.commit() @@ -257,7 +257,7 @@ async def addUserToWorkspaceWithRole( ) -> None: userRole = WorkspaceUserRole( - auth_user_uid=cast(UUID, current_user.user_uuid), + auth_user_uid=cast(UUID, user_id), workspace_id=workspace_id, role=role, ) @@ -284,7 +284,7 @@ async def removeUserFromWorkspace( result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # type: ignore[attr-defined] raise NotFoundException( f"User association removal failed for workspace {workspace_id} and user {user_id}" ) diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 2c4e423..af8c859 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import IntEnum, StrEnum -from typing import Any, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional from uuid import UUID from geoalchemy2 import Geometry From 4961e4b402f6aa466c2d4971913dc869ab857cd3 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 24 Feb 2026 20:27:22 -0500 Subject: [PATCH 045/159] Skip migrations when running tests --- api/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/main.py b/api/main.py index 396b530..d8c4978 100644 --- a/api/main.py +++ b/api/main.py @@ -1,5 +1,6 @@ import os import re +import sys from contextlib import asynccontextmanager import httpx @@ -36,9 +37,6 @@ # Set up logging configuration setup_logging() -# Optional: Run migrations on startup -run_migrations() - # Set up logger for this module logger = get_logger(__name__) @@ -48,6 +46,10 @@ @asynccontextmanager async def lifespan(_app: FastAPI): + # only run migrations when not under test + if "pytest" not in sys.modules: + run_migrations() + # Run before app bootstrap: global _osm_client _osm_client = httpx.AsyncClient( From 8671bc356e5626236b9194b2c6ffddd73e9125f0 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Tue, 19 May 2026 16:59:16 -0700 Subject: [PATCH 046/159] Tighten tenant bypass for tenantless endpoints --- api/main.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/api/main.py b/api/main.py index d218e44..dd82fcc 100644 --- a/api/main.py +++ b/api/main.py @@ -140,15 +140,13 @@ def get_workspace_repository( "forwarded", } -# Define paths that do not require X-Workspace header -AUTH_WHITELIST_PATTERNS = [ - re.compile(p) - for p in [ - # Creating/deleting workspaces and JOSM path rewriting: - r"^/api/0\.6/workspaces.*$", - # Provisioning users during authentication: - r"^/api/0\.6/user/.*$", - ] +# Paths that do not require X-Workspace header, scoped by HTTP method. Each +# entry is a tuple of: (compiled regex, set of allowed methods). +TENANT_BYPASSES: list[tuple[re.Pattern[str], set[str]]] = [ + # Creating/deleting a workspace (no tenant context applies): + (re.compile(r"^/api/0\.6/workspaces/\d+$"), {"PUT", "DELETE"}), + # Provisioning users during authentication: + (re.compile(r"^/api/0\.6/user/[^/]+$"), {"PUT"}), ] @@ -225,7 +223,10 @@ async def catch_all( detail="You do not have access to this workspace", ) else: - if not any(p.fullmatch(request.url.path) for p in AUTH_WHITELIST_PATTERNS): + if not any( + p.fullmatch(request.url.path) and request.method in methods + for p, methods in TENANT_BYPASSES + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No X-Workspace header supplied", From 877bfec347624523518608241a8dd75d002fed02 Mon Sep 17 00:00:00 2001 From: MashB Date: Thu, 14 May 2026 23:45:59 +0530 Subject: [PATCH 047/159] open spec --- docs/tasking-mvp/tasking-mvp.openapi.yaml | 1825 +++++++++++++++++++++ 1 file changed, 1825 insertions(+) create mode 100644 docs/tasking-mvp/tasking-mvp.openapi.yaml diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml new file mode 100644 index 0000000..5f6b0e9 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -0,0 +1,1825 @@ +openapi: 3.0.3 +info: + title: Workspaces Tasking Manager API + version: 1.0.0 + description: | + Tasking Manager API for the Workspaces Tasking Manager MVP. + +servers: + - url: / + description: workspaces-backend root. + +tags: + - name: projects + description: Tasking project CRUD and lifecycle. + - name: aoi + description: Project area-of-interest upload, retrieval, deletion. + - name: tasks + description: Task validation, persistence and read access. + - name: locks + description: Lock lifecycle on a task. + - name: submit + description: Done? submit flow — changeset + state transition. + - name: roles + description: Workspace and project role management. + - name: audit + description: Audit trail. + - name: stats + description: Project and user statistics. + +security: + - bearerAuth: [] + +paths: + # ========================================================================= + # Projects + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [projects] + summary: List tasking projects in a workspace. + description: Paginated list of non-deleted projects. Any workspace contributor. + operationId: listProjects + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/ProjectStatus" } + - in: query + name: textSearch + schema: { type: string, maxLength: 255 } + description: Case-insensitive substring match on `name`. + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 20 } + - in: query + name: orderBy + schema: + type: string + enum: [createdAt, updatedAt, name] + default: createdAt + - in: query + name: orderByType + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Paginated list. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + post: + tags: [projects] + summary: Create a tasking project (optionally with AOI and role assignments). + description: | + LEAD only. Creates a project in `draft` status. + + Optional inline AOI may be provided (same validation as + `POST .../aoi`). + + Optional `roleAssignments` may be provided to seed the + project-level role overrides table (`tasking_project_roles`) in + the same transaction. The creator is auto-added with + `role = lead`; other users may be added with any of `lead`, + `validator`, `contributor`. + + Activation later requires at least one user assigned with + `contributor` or `validator` so the project has at least one + worker before it goes live — see `POST .../activate`. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectCreateRequest" } + responses: + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "409": + description: A non-deleted project with this name already exists. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [projects] + summary: Get a tasking project. + operationId: getProject + responses: + "200": + description: Project detail. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + patch: + tags: [projects] + summary: Update editable settings. + description: | + LEAD only. Per-status mutability: + + | Field | draft | open | done | + |--------------------|-------|------|------| + | name | yes | yes | no | + | instructions | yes | yes | no | + | lockTimeoutHours | yes | yes | no | + | reviewRequired | yes | NO | no | + + AOI is updated through the dedicated AOI endpoints, not PATCH. + Immutable-field writes return 422. + operationId: updateProject + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectUpdateRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [projects] + summary: Soft-delete a tasking project. + description: | + LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains + audit rows (flagged with `project_deleted=true`). Returns 409 + if any task currently has an active lock — force-release first. + operationId: deleteProject + responses: + "204": + description: Soft-deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: Project has active task locks. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/activate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Activate a draft project (`draft` → `open`). + description: | + LEAD only. Pre-conditions (all must hold; 422 with a clear + `detail` otherwise): + - Project has a `name`. + - Project has an AOI set. + - Project has at least one saved task. + - At least one user is allocated to the project with role + `contributor` or `validator` in `tasking_project_roles` + (the creator's auto-`lead` row does not satisfy this — a + worker must be assigned). + operationId: activateProject + responses: + "200": + description: Activated. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/close: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Close an open project (`open` → `done`). + description: | + LEAD only. Requires every task to be `completed` and no active + locks. + operationId: closeProject + responses: + "200": + description: Closed. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: One or more tasks are still locked or not completed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [projects] + summary: Reset all tasks in a project back to `to_map`. + description: | + LEAD only. Restarts work on the whole project so contributors + can re-map and re-validate. + + Effect (single transaction): + - Releases every active lock with `release_reason = 'reset'`, + emitting one `task_unlocked` audit event per released lock. + - Sets every task's `status` to `to_map`, clears + `last_mapper_id`, emits a `task_state_changed` audit event + for every task whose status actually changed. + - If the project was `done`, transitions it back to `open`. + - Emits one `project_reset` audit event for the project. + + Tasks themselves (geometry, history of changesets and remap + feedback) are NOT deleted — only their lifecycle state is + rewound. Stats and audit history remain intact. + operationId: resetProject + responses: + "200": + description: Reset complete. + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (nothing to reset). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # AOI + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/aoi: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [aoi] + summary: Get the AOI of a project. + description: | + Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326). + `404` if no AOI is set. + operationId: getProjectAoi + responses: + "200": + description: AOI. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [aoi] + summary: Upload or replace the AOI. + description: | + LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection` + (single feature), or bare geometry. Geometry may be either + `Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs + are upcast to MultiPolygon on insert; the storage column is + `GEOMETRY(MultiPolygon, 4326)`. + + Project must be in `draft`. Replacing an AOI hard-deletes any + saved tasks. + operationId: uploadProjectAoi + requestBody: + required: true + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + application/json: + schema: { $ref: "#/components/schemas/AoiUploadRequest" } + responses: + "200": + description: AOI stored. + content: + application/geo+json: + schema: { $ref: "#/components/schemas/AoiFeature" } + application/json: + schema: { $ref: "#/components/schemas/AoiFeature" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + delete: + tags: [aoi] + summary: Delete the AOI. + description: | + LEAD only. Project must be in `draft`. Clears the AOI, hard- + deletes any saved tasks (releasing their locks in the same + transaction), clears `taskBoundaryType`. Returns 404 if no AOI + is set. + operationId: deleteProjectAoi + responses: + "204": + description: AOI deleted. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: Project not found, or project has no AOI. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Project is not in `draft`. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Tasks (validate + save + read) + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + post: + tags: [tasks] + summary: Validate a GeoJSON FeatureCollection of candidate task boundaries. + description: | + LEAD only. Does NOT persist anything — the client must POST each + validated Feature to `/tasks/save` to commit. + + Project preconditions: status `draft`, AOI uploaded. + + Hard validation (422 on failure): + - Top-level type must be `FeatureCollection`. + - Every feature geometry must be `Polygon` (no MultiPolygon). + - Coordinates valid lon/lat (EPSG:4326). + - Every polygon lies within the project AOI (centroid-inside + is not sufficient). + - At least one feature. + + Non-blocking warning: + - `polygon_exceeds_grid_size` — area exceeds + `TM_TASKING_GRID_SIZE_METERS`² (km²). + operationId: validateTasks + requestBody: + required: true + content: + application/geo+json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + application/json: + schema: + { $ref: "#/components/schemas/TaskBoundariesFeatureCollection" } + responses: + "200": + description: Validation result. + content: + application/json: + schema: { $ref: "#/components/schemas/ValidatePreviewResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/IdempotencyKeyHeader" + post: + tags: [tasks] + summary: Persist a single previewed feature as a new task. + description: | + LEAD only. Commits one feature per call. The client iterates + the validated FeatureCollection and calls this endpoint once + per feature with a fresh `Idempotency-Key`. + + On each successful call the server: + 1. Re-validates the feature against the project AOI. + 2. Allocates the next sequential `taskNumber` (starts at 1). + 3. Inserts the `tasking_tasks` row. + 4. On the first feature, sets + `tasking_projects.task_boundary_type` to `source`. + Subsequent calls must use the same `source` — 409 + otherwise. + 5. Emits a `task_created` audit event. + + Project preconditions: status `draft`, AOI set. + + See the top-level idempotency contract for retry semantics. + operationId: saveTask + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTaskRequest" } + responses: + "201": + description: Task created. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTaskResponse" } + "200": + description: | + Idempotent replay (same key + same body); `replayed: true`. + content: + application/json: + schema: { $ref: "#/components/schemas/SaveTaskResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Task boundary source differs from a previous save". + - "Idempotency key reused with a different request". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [tasks] + summary: List tasks (geometry always included; serves list + map views). + description: | + Paginated. Any workspace contributor. + operationId: listTasks + parameters: + - in: query + name: status + schema: { $ref: "#/components/schemas/TaskStatus" } + - in: query + name: lockedByUserId + schema: { type: string, format: uuid } + - in: query + name: lastMapperId + schema: { type: string, format: uuid } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 1000, default: 200 } + responses: + "200": + description: Tasks. + content: + application/json: + schema: { $ref: "#/components/schemas/TaskListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [tasks] + summary: Get task detail (geometry, status, lock, last mapper). + operationId: getTask + responses: + "200": + description: Task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace, project, or task not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Locking + Submit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Acquire a lock on a task. + description: | + Eligibility: + + | Task status | Allowed roles | + |-------------|---------------------------------------------------| + | `to_map` | CONTRIBUTOR, LEAD | + | `to_remap` | CONTRIBUTOR, LEAD | + | `to_review` | VALIDATOR, LEAD — and NOT this task's last_mapper | + | `completed` | (none) | + + Other rules: + - No other active lock on this task. + - Caller holds no other active lock in this project (one-lock- + per-project rule). 409 response includes `existingLock`. + - Project status is `open`. + + Side effects: + - INSERT `tasking_locks` with + `expires_at = NOW() + project.lock_timeout_hours`. + - Emit `task_locked` audit event. + operationId: lockTask + responses: + "200": + description: Lock acquired. Body is the updated task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Role does not permit locking in this status, or self-validation guard hit. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: | + One of: + - "Task is already locked". + - "User already holds a lock in this project" — body includes `existingLock`. + - "Project is not open". + content: + application/json: + schema: { $ref: "#/components/schemas/LockConflictError" } + delete: + tags: [locks] + summary: Release a lock on a task. + description: | + Default: caller must be the active lock holder + (`release_reason = manual`). + + With `?force=true`: caller must be LEAD + (`release_reason = lead_release`). + + Task `status` is unchanged in both modes. Force-release does + not relock — LEAD must POST `/lock` separately to take over. + operationId: unlockTask + parameters: + - in: query + name: force + schema: { type: boolean, default: false } + responses: + "204": + description: Released. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Not the lock holder (default mode) or not LEAD (force mode). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Extend the expiry of your own lock. + description: | + Caller must hold the active lock. Adds the project's + `lock_timeout_hours` to the current `expires_at` (slides from + the existing expiry, not from `NOW()`). Emits + `task_lock_extended`. + operationId: extendTaskLock + responses: + "200": + description: Lock extended. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "409": + description: Task has no active lock to extend. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [locks] + summary: Reset a single task back to `to_map`. + description: | + LEAD only. Restarts work on a task — useful when a contributor + or validator pushed a task into a wrong terminal state and + someone needs to redo it. + + Effect (single transaction): + - If an active lock exists, it is released with + `release_reason = 'reset'` and a `task_unlocked` audit + event is emitted. + - Task `status` is set to `to_map`, `last_mapper_id` is + cleared, and a `task_state_changed` audit event is emitted + (only when the status actually changed). + - A `task_reset` audit event is emitted. + + Existing changeset rows and remap-feedback rows are NOT + deleted — only the live task state is rewound. + + Pre-conditions: project status is `open` or `done`. + operationId: resetTask + responses: + "200": + description: Task reset. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": + description: Project is in `draft` (no tasks to reset yet). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + post: + tags: [submit] + summary: Submit an OSM changeset and optionally transition state. + description: | + The "Done?" flow. Caller must hold the active lock. + + Effective actor role is inferred from current task status: + - `to_map` / `to_remap` → mapper context + - `to_review` → validator context + (LEAD may act in either context.) + + State transition (when `done:true`): + + | Current | Effective role | Feedback? | New status | + |-------------|--------------------|-----------|---------------------------------------------------------| + | `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` | + | `to_remap` | contributor / lead | n/a | `to_review` | + | `to_review` | validator / lead | no | `completed` | + | `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_remap_feedback`) | + + With `done:false` the state is unchanged but the lock's + `expires_at` slides forward to `submitted_at + lock_timeout_hours` + (emits `task_lock_renewed`). + + OSM validation: a `404` from `OSM_CHANGESET_API_URL` returns + `422` with detail "Invalid OSM changeset id"; nothing is + written. Network failure to OSM → `502`. + + `last_mapper_id` is updated only when the effective role is + mapper. + + Feedback rules: + - `feedback` is meaningful only in validator context on + `to_review`. Otherwise its presence returns 422. + - For `to_review` → `to_remap`, `feedback.reasonCategory` is + required (422 if missing). + operationId: submitTask + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SubmitRequest" } + responses: + "200": + description: Submission accepted. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": + description: Caller does not hold the active lock. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + "422": { $ref: "#/components/responses/UnprocessableEntity" } + "502": + description: Could not reach the OSM Changeset API. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + # ========================================================================= + # Roles + # ========================================================================= + + /workspaces/{workspace_id}/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List all users associated with the workspace and their role. + description: | + Returns every user with a `user_workspace_roles` row OR a TDEI + role in the workspace's owning project group, each with the + role the server resolves for them. Any workspace contributor. + operationId: listWorkspaceRoles + responses: + "200": + description: Roles. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + + /workspaces/{workspace_id}/roles/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [roles] + summary: Get a user's workspace-level role. + operationId: getWorkspaceRole + responses: + "200": + description: Role. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace not found, or user has no association. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + tags: [roles] + summary: Set (upsert) the workspace-level role for a user. + description: | + LEAD only. Idempotent upsert into `user_workspace_roles`. + + Last-LEAD guard: the workspace must always retain at least one + LEAD (counting both `user_workspace_roles` rows and TDEI-derived + LEAD via `poc` / `osw_data_generator`). 422 if violated. + operationId: putWorkspaceRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + responses: + "200": + description: Upserted. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "422": + description: | + One of: + - "Cannot demote the last lead in this workspace". + - "User is not a member of the workspace's project group". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Remove the workspace-level role row for a user. + description: | + LEAD only. Deletes the row from `user_workspace_roles`; the + user's role falls back to their TDEI-derived role. + operationId: deleteWorkspaceRole + responses: + "204": + description: Removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: No row exists for this user/workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would leave the workspace without a LEAD. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [roles] + summary: List project-level role overrides on a project. + description: | + Returns rows in `tasking_project_roles`. Sparse — only users + with an explicit override appear. Any workspace contributor. + operationId: listProjectRoles + responses: + "200": + description: Overrides. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [roles] + summary: Get a user's project-level role. + description: Project override if set, otherwise the workspace-level role. + operationId: getProjectRole + responses: + "200": + description: Role. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleEntry" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + put: + tags: [roles] + summary: Set (upsert) a project-level role override. + description: | + LEAD only. Inserts/updates a row in `tasking_project_roles`. + Project must not be `done` (422 otherwise). User must be a + member of the workspace's project group (422 otherwise). + operationId: putProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + responses: + "200": + description: Upserted. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleEntry" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "422": + description: | + One of: + - "User is not a member of the workspace's project group". + - "Project is closed". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + delete: + tags: [roles] + summary: Clear a project-level role override. + description: LEAD only. Falls back to the workspace-level role. + operationId: deleteProjectRole + responses: + "204": + description: Removed. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: No override row for this user/project. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /me/workspaces/{workspace_id}/tasking/projects/roles: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + get: + tags: [roles] + summary: List the caller's role for every project in a workspace. + description: | + Single round-trip for the project-list page: returns one entry + per project with the caller's role on that project. + operationId: listSelfProjectRoles + responses: + "200": + description: Project roles for the caller. + content: + application/json: + schema: { $ref: "#/components/schemas/SelfProjectRolesResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/WorkspaceNotFound" } + + # ========================================================================= + # Audit + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [audit] + summary: List project-level audit events. + description: | + Paginated, newest first. Any workspace contributor. Soft-deleted + projects are visible only with `includeDeleted=true`. + operationId: listProjectAudit + parameters: + - in: query + name: eventType + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: taskNumber + schema: { type: integer, minimum: 1 } + - in: query + name: actorUserId + schema: { type: string, format: uuid } + - in: query + name: occurredFrom + schema: { type: string, format: date-time } + - in: query + name: occurredTo + schema: { type: string, format: date-time } + - in: query + name: includeDeleted + schema: { type: boolean, default: false } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - in: query + name: orderByType + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + - $ref: "#/components/parameters/TaskNumberPath" + get: + tags: [audit] + summary: List task-level audit events. + description: Newest first. Any workspace contributor. + operationId: listTaskAudit + parameters: + - in: query + name: eventType + schema: { $ref: "#/components/schemas/AuditEventType" } + - in: query + name: actorUserId + schema: { type: string, format: uuid } + - in: query + name: occurredFrom + schema: { type: string, format: date-time } + - in: query + name: occurredTo + schema: { type: string, format: date-time } + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: pageSize + schema: { type: integer, minimum: 1, maximum: 200, default: 25 } + - in: query + name: orderByType + schema: { type: string, enum: [ASC, DESC], default: DESC } + responses: + "200": + description: Events for the task. + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventListResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } + + # ========================================================================= + # Stats + # ========================================================================= + + /workspaces/{workspace_id}/tasking/projects/{project_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/ProjectIdPath" + get: + tags: [stats] + summary: Project statistics summary. + description: | + Live-computed. Any workspace contributor. + + Field semantics: + - `tasksByStatus` always zero-fills all four states. + - `percentMapped` — share of tasks past `to_map` at least + once. Rounded to nearest integer. + - `percentCompleted` — share whose current status is + `completed`. + - `avgTaskDurationMinutes` — mean across completed tasks of + the sum of `(released_at - locked_at)` over that task's + locks. `null` when no task is completed yet. + - `uniqueMappers` — distinct submitters with at least one + changeset while task was in `to_map` or `to_remap`. + - `uniqueValidators` — distinct submitters with at least one + changeset while task was in `to_review`. + operationId: getProjectStats + responses: + "200": + description: Stats. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + + /workspaces/{workspace_id}/tasking/users/{user_id}/stats: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" + get: + tags: [stats] + summary: Cross-project stats for a user within a workspace. + description: | + `totals` sums across the workspace's non-deleted projects. + `byProject` lists only projects with non-zero contribution. + + Counting rules: + - `tasksMapped` — distinct tasks where the user submitted at + least one `done:true` changeset while the task was in + `to_map` or `to_remap`. + - `tasksValidated` — distinct tasks where the user submitted + at least one `done:true` changeset while in `to_review`. + - `totalMappingMinutes` / `totalValidationMinutes` — sum of + `(released_at - locked_at)` over the user's lock sessions + in mapping/validation context. + - `totalAreaSqkm` — sum of `tasking_tasks.area_sqkm` over + tasks the user mapped (each task counted once). + operationId: getUserStats + responses: + "200": + description: User stats. + content: + application/json: + schema: { $ref: "#/components/schemas/UserStats" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Workspace not found, user has no activity, or outside tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + WorkspaceIdPath: + name: workspace_id + in: path + required: true + schema: { type: integer, format: int64 } + ProjectIdPath: + name: project_id + in: path + required: true + schema: { type: integer, format: int64 } + TaskNumberPath: + name: task_number + in: path + required: true + description: Sequential, human-readable id scoped to the project. Starts at 1. + schema: { type: integer, minimum: 1 } + UserIdPath: + name: user_id + in: path + required: true + description: TDEI auth user uuid. + schema: { type: string, format: uuid } + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + description: Client-generated unique value (UUID recommended). Scoped per project. + schema: + type: string + minLength: 8 + maxLength: 128 + + schemas: + # ---------- Enums ---------- + + ProjectStatus: + type: string + enum: [draft, open, done] + + TaskStatus: + type: string + enum: [to_map, to_review, to_remap, completed] + + TaskBoundaryType: + type: string + enum: [grid, import] + nullable: true + + GridSource: + type: string + enum: [grid, import] + + WorkspaceUserRoleType: + type: string + enum: [lead, validator, contributor] + + LockReleaseReason: + type: string + enum: [auto_unlock, manual, lead_release, stale_timeout, reset] + description: | + `auto_unlock` — released by a successful `/submit` with `done:true`. + `manual` — caller released their own lock via `DELETE /lock`. + `lead_release` — LEAD force-released via `DELETE /lock?force=true`. + `stale_timeout` — released by the background job (`expires_at < NOW()`). + `reset` — released by `POST /reset` on the task or its project. + + RemapFeedbackReason: + type: string + enum: [incomplete_mapping, data_quality_issue, wrong_area, other] + + AuditEventType: + type: string + enum: + - project_created + - project_activated + - project_closed + - project_edited + - project_deleted + - aoi_uploaded + - aoi_deleted + - task_created + - task_state_changed + - task_locked + - task_lock_extended + - task_lock_renewed + - task_unlocked + - task_reset + - project_reset + - changeset_submitted + - remap_feedback + + # ---------- GeoJSON ---------- + + GeoJsonPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [Polygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + GeoJsonMultiPolygon: + type: object + required: [type, coordinates] + properties: + type: { type: string, enum: [MultiPolygon] } + coordinates: + type: array + minItems: 1 + items: + type: array + minItems: 1 + items: + type: array + minItems: 4 + items: + type: array + minItems: 2 + maxItems: 3 + items: { type: number } + + AoiGeometry: + description: AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert). + oneOf: + - $ref: "#/components/schemas/GeoJsonPolygon" + - $ref: "#/components/schemas/GeoJsonMultiPolygon" + + AoiFeature: + type: object + required: [type, geometry, properties] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/AoiGeometry" } + properties: + type: object + additionalProperties: true + + AoiFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + maxItems: 1 + items: { $ref: "#/components/schemas/AoiFeature" } + + AoiUploadRequest: + oneOf: + - $ref: "#/components/schemas/AoiFeature" + - $ref: "#/components/schemas/AoiFeatureCollection" + - $ref: "#/components/schemas/AoiGeometry" + + TaskBoundaryFeature: + type: object + required: [type, geometry] + properties: + type: { type: string, enum: [Feature] } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + properties: + type: object + additionalProperties: true + + TaskBoundariesFeatureCollection: + type: object + required: [type, features] + properties: + type: { type: string, enum: [FeatureCollection] } + features: + type: array + minItems: 1 + items: { $ref: "#/components/schemas/TaskBoundaryFeature" } + + # ---------- Projects ---------- + + Project: + type: object + required: + - id + - workspaceId + - name + - status + - reviewRequired + - lockTimeoutHours + - createdBy + - createdAt + - updatedAt + - hasAoi + - taskCount + properties: + id: { type: integer, format: int64 } + workspaceId: { type: integer, format: int64 } + name: { type: string, maxLength: 255 } + instructions: { type: string, nullable: true } + status: { $ref: "#/components/schemas/ProjectStatus" } + reviewRequired: { type: boolean, default: true } + lockTimeoutHours: + { type: integer, minimum: 1, maximum: 720, default: 8 } + taskBoundaryType: { $ref: "#/components/schemas/TaskBoundaryType" } + hasAoi: { type: boolean } + taskCount: { type: integer, minimum: 0 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListItem: + type: object + required: [id, name, status, taskCount, percentCompleted, createdAt] + properties: + id: { type: integer, format: int64 } + name: { type: string } + status: { $ref: "#/components/schemas/ProjectStatus" } + taskCount: { type: integer, minimum: 0 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + createdBy: { type: string, format: uuid } + createdByName: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + ProjectListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectListItem" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ProjectCreateRequest: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: Unique within the workspace (case-insensitive) among non-deleted projects. + instructions: + type: string + maxLength: 10000 + nullable: true + reviewRequired: + type: boolean + default: true + description: If false, tasks transition straight to `completed` on mapper submit. Immutable after activation. + lockTimeoutHours: + type: integer + minimum: 1 + maximum: 720 + default: 8 + aoi: + allOf: + - $ref: "#/components/schemas/AoiUploadRequest" + nullable: true + description: Optional inline AOI (same validation as `POST .../aoi`). + roleAssignments: + type: array + description: | + Optional list of project-level role assignments to seed at + creation. Each entry is upserted into `tasking_project_roles` + in the same transaction as the project insert. + + The creator is auto-added as `lead` on `tasking_project_roles` + and does not need to appear here. + + Each `userId` must be a member of the workspace's TDEI + project group; entries failing this check are rejected with + 422 and no rows are written. + items: { $ref: "#/components/schemas/ProjectRoleAssignment" } + + ProjectRoleAssignment: + type: object + required: [userId, role] + properties: + userId: { type: string, format: uuid } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectUpdateRequest: + type: object + description: All fields optional. Server enforces per-status mutability. + properties: + name: { type: string, minLength: 1, maxLength: 255 } + instructions: { type: string, maxLength: 10000, nullable: true } + lockTimeoutHours: { type: integer, minimum: 1, maximum: 720 } + reviewRequired: { type: boolean } + + # ---------- Tasks ---------- + + TaskLockSummary: + type: object + required: [userId, lockedAt, expiresAt] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LastMapper: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + userName: { type: string, nullable: true } + + Task: + type: object + required: + [id, taskNumber, status, geometry, areaSqkm, createdAt, updatedAt] + properties: + id: { type: integer, format: int64 } + taskNumber: { type: integer, minimum: 1 } + status: { $ref: "#/components/schemas/TaskStatus" } + geometry: { $ref: "#/components/schemas/GeoJsonPolygon" } + areaSqkm: { type: number } + lock: + nullable: true + allOf: + - $ref: "#/components/schemas/TaskLockSummary" + lastMapper: + nullable: true + allOf: + - $ref: "#/components/schemas/LastMapper" + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + TaskListResponse: + type: object + required: [tasks, pagination] + properties: + tasks: + type: array + items: { $ref: "#/components/schemas/Task" } + pagination: { $ref: "#/components/schemas/Pagination" } + + ValidateWarning: + type: object + required: [taskIndex, issue] + properties: + taskIndex: + type: integer + description: 0-based index into the submitted `features` array. + issue: + type: string + enum: [polygon_exceeds_grid_size] + areaSqkm: + type: number + + ValidatePreviewResponse: + type: object + required: [valid, warnings, source, featureCollection] + properties: + valid: + type: boolean + description: True when no hard failures. Warnings do not affect this. + warnings: + type: array + items: { $ref: "#/components/schemas/ValidateWarning" } + source: + $ref: "#/components/schemas/GridSource" + description: Always `import` for this response. Pass through unchanged to `/save`. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" + + SaveTaskRequest: + type: object + required: [source, feature] + properties: + source: + $ref: "#/components/schemas/GridSource" + description: First save sets `tasking_projects.task_boundary_type`; subsequent saves must use the same source. + feature: + $ref: "#/components/schemas/TaskBoundaryFeature" + + SaveTaskResponse: + type: object + required: [projectId, taskBoundaryType, task] + properties: + projectId: { type: integer, format: int64 } + taskBoundaryType: { $ref: "#/components/schemas/GridSource" } + task: { $ref: "#/components/schemas/Task" } + idempotencyKey: + type: string + nullable: true + replayed: + type: boolean + default: false + description: True on idempotent replay (HTTP 200 case). + + # ---------- Submit / Lock ---------- + + RemapFeedback: + type: object + required: [reasonCategory] + properties: + reasonCategory: { $ref: "#/components/schemas/RemapFeedbackReason" } + notes: + type: string + maxLength: 4000 + nullable: true + + SubmitRequest: + type: object + required: [osmChangesetId, done] + properties: + osmChangesetId: + type: integer + format: int64 + minimum: 1 + done: + type: boolean + description: | + `true` — auto-unlocks and transitions state per the table. + `false` — records the changeset, state unchanged, lock + `expires_at` slides forward to `submitted_at + + lock_timeout_hours`. + feedback: + allOf: + - $ref: "#/components/schemas/RemapFeedback" + description: Meaningful only in validator context on `to_review`. + + ExistingLockSummary: + type: object + required: [taskNumber, taskStatus, lockedAt, expiresAt] + properties: + taskNumber: { type: integer, minimum: 1 } + taskStatus: { $ref: "#/components/schemas/TaskStatus" } + lockedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + + LockConflictError: + allOf: + - $ref: "#/components/schemas/Error" + - type: object + properties: + existingLock: + $ref: "#/components/schemas/ExistingLockSummary" + + # ---------- Roles ---------- + + UserSummary: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + displayName: { type: string, nullable: true } + email: { type: string, nullable: true } + + RoleAssignmentBody: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + WorkspaceRoleEntry: + type: object + required: [user, role] + properties: + user: { $ref: "#/components/schemas/UserSummary" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + WorkspaceRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + + ProjectRoleEntry: + type: object + required: [user, role] + properties: + user: { $ref: "#/components/schemas/UserSummary" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleListResponse: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/ProjectRoleEntry" } + + SelfProjectRolesItem: + type: object + required: [projectId, projectName, role] + properties: + projectId: { type: integer, format: int64 } + projectName: { type: string } + projectStatus: { $ref: "#/components/schemas/ProjectStatus" } + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRolesResponse: + type: object + required: [workspaceRole, projects] + properties: + workspaceRole: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + projects: + type: array + items: { $ref: "#/components/schemas/SelfProjectRolesItem" } + + # ---------- Audit ---------- + + ActorRef: + type: object + required: [userId] + properties: + userId: { type: string, format: uuid } + displayName: { type: string, nullable: true } + + AuditEvent: + type: object + required: [id, eventType, projectId, actor, occurredAt, details] + properties: + id: { type: integer, format: int64 } + eventType: { $ref: "#/components/schemas/AuditEventType" } + projectId: { type: integer, format: int64 } + taskId: + type: integer + format: int64 + nullable: true + taskNumber: + type: integer + nullable: true + description: Convenience copy from `details` (so list renderers don't peek inside JSONB). + actor: { $ref: "#/components/schemas/ActorRef" } + occurredAt: { type: string, format: date-time } + details: + type: object + additionalProperties: true + projectDeleted: + type: boolean + default: false + + AuditEventListResponse: + type: object + required: [results, pagination] + properties: + results: + type: array + items: { $ref: "#/components/schemas/AuditEvent" } + pagination: { $ref: "#/components/schemas/Pagination" } + + # ---------- Stats ---------- + + TaskStatusCounts: + type: object + required: [to_map, to_review, to_remap, completed] + properties: + to_map: { type: integer, minimum: 0 } + to_review: { type: integer, minimum: 0 } + to_remap: { type: integer, minimum: 0 } + completed: { type: integer, minimum: 0 } + + ProjectStats: + type: object + required: + - projectId + - totalTasks + - tasksByStatus + - percentMapped + - percentCompleted + - uniqueMappers + - uniqueValidators + properties: + projectId: { type: integer, format: int64 } + totalTasks: { type: integer, minimum: 0 } + tasksByStatus: { $ref: "#/components/schemas/TaskStatusCounts" } + percentMapped: { type: integer, minimum: 0, maximum: 100 } + percentCompleted: { type: integer, minimum: 0, maximum: 100 } + avgTaskDurationMinutes: + type: number + nullable: true + uniqueMappers: { type: integer, minimum: 0 } + uniqueValidators: { type: integer, minimum: 0 } + + UserStatsTotals: + type: object + required: + - tasksMapped + - tasksValidated + - totalMappingMinutes + - totalValidationMinutes + - totalChangesets + - totalAreaSqkm + properties: + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalMappingMinutes: { type: integer, minimum: 0 } + totalValidationMinutes: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStatsByProject: + type: object + required: [projectId, projectName, tasksMapped, tasksValidated] + properties: + projectId: { type: integer, format: int64 } + projectName: { type: string } + tasksMapped: { type: integer, minimum: 0 } + tasksValidated: { type: integer, minimum: 0 } + totalChangesets: { type: integer, minimum: 0 } + totalAreaSqkm: { type: number, minimum: 0 } + + UserStats: + type: object + required: [workspaceId, userId, totals, byProject] + properties: + workspaceId: { type: integer, format: int64 } + userId: { type: string, format: uuid } + totals: { $ref: "#/components/schemas/UserStatsTotals" } + byProject: + type: array + items: { $ref: "#/components/schemas/UserStatsByProject" } + + # ---------- Shared ---------- + + Pagination: + type: object + required: [page, pageSize, total] + properties: + page: { type: integer, minimum: 1 } + pageSize: { type: integer, minimum: 1 } + total: { type: integer, minimum: 0 } + + Error: + type: object + required: [detail] + properties: + detail: + oneOf: + - type: string + - type: array + items: { type: object, additionalProperties: true } + description: FastAPI default error shape — string for raised `HTTPException`, structured list for pydantic validation errors. + + responses: + BadRequest: + description: Malformed JSON or schema-level failure. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Missing or invalid bearer token. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ForbiddenLeadRequired: + description: Caller is a workspace contributor but does not hold LEAD. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + WorkspaceNotFound: + description: Workspace does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ProjectOrWorkspaceNotFound: + description: Workspace or project does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TaskOrProjectOrWorkspaceNotFound: + description: Workspace, project, or task does not exist, or is outside the caller's tenancy. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + UnprocessableEntity: + description: Well-formed request that violates a business rule. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } From 806d7e67d7704357863f27ff08028cbf94f1c797 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 18 May 2026 20:31:10 +0530 Subject: [PATCH 048/159] DB schema --- docs/db-schema/db-schema.png | Bin 0 -> 547277 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/db-schema/db-schema.png diff --git a/docs/db-schema/db-schema.png b/docs/db-schema/db-schema.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd1187f793cfe85b5dd1f2bb08a00c1a5db11d6 GIT binary patch literal 547277 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�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%0ARNxIF!a7pyp>6%wX@)iS>;8u)uJ+^B3>ZE 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
WHKFjvJQ`!xu0q^tt-iRI{$%l z=xP!~o~#?=Ko8YFlshc)h9Xx|u3^LXpAu!y&6JtH)qfxTDZ>#qmwG{iG83lP&1d3j zYW4@NNcaV~Hp-_t{MWNV0>qSOT+)VFD!zQTykYwI=}FcpyI+vJ>4lTqw$s4lsShM= z?r)xv?G|Gx{W@D>f`THHh>f>Ku#Vu~9;pSP!1`@{=0AY{w^%rRLe9PoZYV$_e4e zbDtJUB3JCXoR+@?uQeHzwYFLpusE1&@GvMBzb*jdwt3Um9!-U=enmEQGDuBTlyV#2 zO65Pq<#9#6uS7GxH7zI6xq5%-{Mq2%y?F8BBVd8rOQk=Ek7Ryp-)<7nNCF(*@Dsbg z8S^@uCrGP=50JE&60x5jc&=<590@S+G6Y2mVP-j8c}Fath#JvIX|A@frLtqg+0R$D zKUs&4jnq)2Ow)zhVAue#i6nTnWZ6qAuM&9xFBQb1^Qg!FdQ+UzVvv%<)6+Mn7Eu{R z<7gZ^V*nb&{D?txG{$I=#teHnvMU}(1X2otDMS)E?Dwjn*NKKzgaJ&Igw9Ln7el?# z?6*9AlF--hV<3SgH~@#4oSqKpi&28CBtWZpu)b>1y2e){s`liZNi$!)3N=^vLsz-W zAmD8iM0(n=2XB*wc6LJx=3J^f2)Dhk~lJ|1QhIsefBBsoIfV_P@;K{h{;JmOIS{X9$I9?z+~JuSE&abb{-F0|$!H zzg0iFd3*S;cfcRhDWDMMwyT)Hi1E@UwsQPL7A~Z)Lo(CVAql)@tMcVpGUA9*(lAMx zCv07$@Y4A}4HGexI=g^{I|ZK+$vk^O5R@(P9({M+qF|!SGzn!OW^41=nE9iR5uh(+ zlDx%bsl6 z1o~9s%F=wa7u47UfBy*=-@-9xO$UjJ0ueWv1L;7d$z<4fUrI|how1{TZbMZ1kF)wfJ)!WnA$DsNM4sqx|)pzhOf zKa{-~;KowB&XMd*UUNL2J~FlIz~?s>3FmbxfXbn(O!z_ro1UN|7IwaqpG{KLS65Hf z(+HdLvD>dgxO;-k>F-MR@_a$Glm=}Y!E_cj@mN3YFvalbRpuL z2yR8eUw^w5{fa35OXz?IX4YxP_`6cuf3>T!<*%gczl08%U`CDhpF!yV`gNfe<$mpo zei5MV6qdC0+(a1X)qf@@2oNUFt@QwL_=tJGA-qOnPrzhp>PAPbU_eN?4r=H85kNlV zzm~|l>)m4uPz*A;X%XDnU5?nXMyoKaZtOgbCC%$jOGY2p2?W3LoY?Fv#G5*xZSsId zo#P`VAPg`y>w^!`Y_Do9YO1SfM)u#LrUBd3(1&aYAAUXCfV8A zQp5okeYT5F0_8==4?g8Yte!rb*Ne@W4|g7I8-`$kbnWL~iEf8xy?EEFd%EOi?z5nT z?9sMknkFhLx_FP=+OsFQC0IS8yQ(L*UhrJ-_~px&XFUBIr1r3vu|#kiu^k<)sN&Yn zl$VlOHpYp#GV zRKg`pg~imy>) zmH$RRvonMYR!6&SWeFh;yHy>+!KG!w5(ZmDo)Ryt_t-+isTIm_MUf3rlF%tU2>%b+ zeuCbrI^gAUeQ8L)f<|98BY`w$MIO&ENCN>Pw zEz6R<0~fX*n)~_}n`4{yeg`E;!2aPn8WWK|+0{;Yj~xWnud8Dj?auEHpB{}4p5&bX z0)9yZZ}^rq&+lKH7d6NS8en3O_40qJliy#=CaUf9 zQ`mN!{mVfVJ|Cf-H-N5SBa# z@ov2&FICbY1ycpd6)1|xa#!-J%1SGff|A?b|5ewa5(cu+7Z7%3>UVP?sq$+^27W%$ zDevtxRo(j4*a~?ly2_^Zv;S2B6f)!GjbbKl5`Vq?-_hCs3{?jY^e2p(F}V=i^-9!4 z>v0sVbBy?9Dp&VC(9>0&@m+aEK{8a&wM*U?izBinz#2@gyofutlJ5&lwpL?SEPS&R zRt~a!x&q*Jk{>g|mn7$PGHsep`_&_}?3HK&MkO4|vn$d%}3jB8*96yXa|M^22q@p;aVb+00ID9G}Qmrxo?*&vC(s@lC2=$+=oDNT_ z8mIxpaWzbMK$XqPGZN$WwXC3=L*Xv?L|ibPpKCap&hmV}7A zS7T*{eAP&4c>A*dI{bIV0WCBHPkPdu`F%;&GJ_Jm@&UG`a`ghcqZf-#)>NF(uY~#u zHw(MvQ8NAePxNEDW!t+hME%_c{COoAU&q2yC)r-7TLFLekIe-5wX#)g>ezR=S*WgK zsR(f(5^NxB~H{`uno)zTn48iFO+?Ps7T^4MS=yzz*Iw^j;E zEpEQ)dn;eut$4|Z6MTBZ2gM|~*j7x7S-7^q(dE8vBd?fod)-NS(e~>S zqdP)kcH8rhZ2t0echizP@}ccb9}ncdz4fZW-6!qt(D7WAeRo?y?l}?aa(4N9L2LOw zaI<+uWw3+{Or|A|N8LIKSHpB#6d{{n9lgZ^Hg-y1l%4iQR2FXI<;h;@yKD20AEWL> zzjuz%*I%2TjN-`%Opq0svRJ_4VUv6C=MVG^4SI(wU-`K|zEaz@T#18Yx=Y(D3_W=T zzk%E$DKFn(GY1k@-w)+eK4Kp*&6iV?M0vYU|73EqViob9!1U{1o%fm8_`~lnxOD!Y z)B}x^M?NW7J<3!_(PU!nNS~0OJnGdg&&;VaVdAfU6&{>utMtXaI_1yz`1jrDD~V(H zb9$M5D_KQ2k50(v6XF>Ev$nH8gjhBZMzdhSKiJA3kDtMF{yq$roAb6Vr^UBqv2ef2 zcJ523#$y%;EDoOh)%U#hfpsaUWtPVfF^l%rVPe)~hoVq~gxG`K)y5bqzx#|C7cG@i zn3~Boqx#;^_3clj=V^wPfIFcGl#;uO(y+~JJq>73i(hl9Qd+ird1EHJ z9VEzyeD8>fP zil3=Q2P2(CqWPFAY`K@G?Em8|`RnN6bZE}_pU;N3Ki+Fbn?!b)f6_G0@WGE9jy{av zJt)@6kfMB)qJLKZ7Jl88{f9e2)e@2ZQh^z$_$lz{};;GnHm> zoawonz8Q0fZ9^o3_5CsOa?f*g+D}GkJ4}R-*5jsDkI*U$)s>m$d>loE0^v83G;qwU zW!GJphedOFKE!jNomahHyifDlxWYXvO}^+Y*AjT_172Mgc)TB>Ua|T>Gb1waJzg3K ze54+_Wp^&oqnAN3smoYQvv19hHR8?iY+bSgQ%&CC`&_eTNdQN7F0QowbgbyoH+d9n zOV#euKm}1d3$#BaKX)wZZAnW2;j`^>oU((_5}W0QZ!U3(9Ti$Jm+|DoT;-*TUTR-E zXvGzNcvaXX@jtGr|DvgQ0#%!iJ~>7ce3}D}q4@B;$>}8CnStjt`;itLfZBbeKAyFdgO!_bIi%$`><-WmVF3 zrgH|Ru@>D|_)!d8Ej#jqqe0-_y)8BR!UyaJ*#(IhQ@QceLXN@?4a85U`;ic6TFPfb znGMinHqqaQ?xEx)2ZSEL7sDH{E9T0MryDRHaxC2zT%n{|m~raZ@kQ=+pkhjbSTlSY z0hdhth#;47pNFH$Syk`SsV|RC-JgCUJeS1Mdj=?n!$%>eT4;YmuVsYITbn*jFny;z zI(w?hMYW9kY1o^#;4sAL?5!s;Rc_aI^{<3JjUoqyb97$nNvB4i-0*UuYI?FK@BEF< zn~#Hn$UaA{Ay7pi@&18Urpi@s!i3nO!1m`%8~8x zxx16L{poQ;iibX5LC#Q6%`DFfyL(EA(?VWKHnb3z!REf!7p05gmL_gDn_9X4cn)QA zCWlpeH|4%bU*uf88x20&;@4jzDrrdbX&(`CzNhd^3OLIgR{3G$U$@s5SJKeUb0GAu z`~T-}DLcT*8Gd7is;TaT0B;@<-kFzDH!{vhnek-q#k^iW>KtCL%i9&PPjK&#xr+IG z)vJ~*TSiBpx4ST%3-UhHqOrtn-(U2syL^gp#2}xRRi@lxRhFGESwW>o#J0qVFI%#LmeJ!xJ%JQLE`t`HJi=Md)HBQdl z8TR&;-M)d$BDMVF!XsYVztbZl0A}bctG4xMJ6eBdL+clJPwsFVDO9oVsu9uWtC`8GTrbK$%wL)iCGJ3-(8z)hRo} zJU2giimBO7ki}#0%F?8; zBQbyXvFgH_oon=~aQj_W%zQrpr;>tsM!PaWnTmx4dc|?hy+4~{_24l{z{=t3 z$=nd1`gj?;)`t@YfKb^Z8E53TAnO^R)|G@5EyhIE`Pr{hg|7OM}lvD9)?*eaBB$k8ku{p}+qG8S|_#=Us&eL`|b_ zl(rQg{OL!2mRP*lm-``i%kp+Ia~kXn`1HEjV7jf6VaW^=MoCttg{xlpvSfkW`&qb+ zpG&yI2b*4=un_$t&g(QVu?eD6?>v7hIa2p>_uOzut0E3v1pUqY!`vP8%>2GneCvn$+y|86%-WI_zs(sFB_O( zMQs0<#d!EGr;fvG(g>LPiq;g-it-7PoE$&+$|JYbZIx&a?Orlc#k3H1ECqI|10-@uS>&FEnEKeJb^n{) zd*3~Fxb)f?MR4Ums$1fg9`u`5hOI3Bpx$zP$->Os=1JZ<@HT2*rXKkM-|8n~=nh{l zvC|ywzq}I~I)(!rOx%XCO+ZT;MPCdkT^8W6^Y)5ga(#}Z%8s2+w6mw$xwsFtT)sy{ z)0$AZ*5`1P1CK#5_F~%k!4$L(?Vq2cAU~eobQS+$ernm|6iuIkr^~x+of&J_f7qOtjCnJS^G`Y=!uk0l!b z@WSQ?MA8Yr1)lVS4An?WlZxcSX$#;^!LR0KnN@SRL>nytU4D5ue+v2ZaFJgnFg+=A z|CLb>_ERhe{n-Bg96_Z@Th+9TiK$`BgX9E!@4q6ODoF{63cu}O`KRbDQswo62fMX( z#VN1*K1=!r>ONtqN0zsf#;-1+DJmjiKB#XzcOGJji=Ay7!*K^@ z?xx?}dwDj>iNU~C_H2K&4BpAqBRpA<9sWO+A)Uu@EquM6TP=jMunw4adxCB-s+7X> zI?JkTS>9WUXI{UQuzpfn#GVoBz^L#amyC6Ie6HJD>~*lUVHJt?r{0A#$@s5w_{mg> zmGXVxV;|X$sr)bY-a8!Y{f{3{dxvxoW_w&!W9Nq5we!s?ZJf4r|<9UH@Unz-NJr0m@#q%)})1nR`Uc>X-w(ig2=HAYcbUITjP>sudTOmI}SdAXBZq(BY%9NI_kJnFUxX}&DZZ$HmK+({rfZVFaRJ_d*Fyr~x9zmld*Ky&kIe)se& zL_xO!mN6JbsFaR=%3^DMc(1m-tlpFN;}f?$Kd&om>fFg;!{ktL%G5b)@RCiAsS;CX zr*LAMX7DWBL~cWuqxYvk+QlaG+ z5zTdxfM6gp#vtD!PKh>KU5z_Us|c)9qAE1e$_Z(XcE$vKxil>`q>~M3$|GXNt7Bh8 zD=&h|cNSshituB=+!5c8H&Fq~n*9pGcOc+OeZ(0p0ZK&Db#K_GyWeosWCYxA>4D zWVynl8+ZT6zc!q<($XiFbh)lS@@h5dJI~$6T3@0?^qWa>PXkk)X|K21)FTfM#T-vs z-5S)uy=}tsb|Rcxi+V4KeDWkIiLRqgj;+^1`KP}`35{8N+?6T}Er)w;0n0Wh5&wcx zdfF(Hk?f`$huIorhdM4)Vaj9v4Q zojI7M3lhR|Mk^FgzV+8qJ4v(JKiT(rDV@-oA2{>pXOW#8z-eOMd@J5HVhPiapVDS0 zua-}T1@d01BzSVlR7-kh#ICJsw>R4ojM=qM3DCX^*GYDjCT>W&>}c($G| z3IIHJkrY=j$Bz>e2$L%aKN%}|p0tX<7ur{303zlGlwq4G`^BJ#p^~dWU^K9#&tzxI zKZDHA9i5~CMu|;1!)4X_LZQfjR9aFH0!h6$_p=fmT-Y8f_fM)xB|(BwWxIx-BB`6 zjdTVFMZmmWdIGRcASe0{Hvcl?)rU~MAyd=lwHxp7d_RaAAnmqDn{Lv5iQvTrd1>c2 zxMEW3@SPbNh>@q}P|K%t_|uYa8OlaJI82+(*)=&1r6Q7A{D?sr*Xl7$xT=<6+;>po zN4|Fg31Nr|R1rcRz+zH%kV}!?^J44z;{(SCZ%A=Fs}YiD%E>p6?Bk7#6ix}VoS}7# z?xNgA+;1&{-I6lQ;LT z>2niV;cbB(Lj-RI(?<>Z8=Rya0M)n3!4H*LZf;eH7?-LVDVu>#LNc#tC2||PB3xk7 zg1jDsVuxMOC*Fu|T+Fk_XbJu!!O$e_A8DfQ6$I#FdBJDevYexah=G{z|N#<3I|LCrr+NBC_S))qQSMHjiZcvqOVmhwc9kaT}vQ*+AWpZvowz8 zTeezSVY%ueQ#PP&(-G#b5nyB}u0*Y^w^x3i%_!uXeb2RwGdi_oh^PgFJ7;|KTF?^z zn<-L}gn*QWsx4XrmdMFvIqRIvvjGfqFaodg`>dxK=QcWIOVDfmRn#-eLN~64fyfLd zcPL2b%8r5fvu8NZjp#@S=nHe!VV?CU1kd)cav|@jzs~20?Ie=)Ha`?J*nv*Zj z;C^R^1+2E%8b&|q%FO0DdE#LXm8f)~%qu1YP$EqWvKLX? z41&dcTVX3R%A-Gw9IpYL0TU7=>v(0Ae>0SmZ7xWv)+Lch=c2^TfBDoe@ML}5eco?p z`LBQb`cpg->F(O|z-}wXN>AV}1u^kyIJ}ijyfUzaS&r&SwV^EtR*h9rOpJPl3Va~kuUvg&-z$W#l zE|r{RIJSZ0Xvth^v;Mqe_%@7+*~_WI3s?K+5-D{s8dm^-AnJ3v(s`Q7sFo77P)ris zdJ|SWeFINVZ@tT7z31ixmgy*8Cp_s9)AwR6Dl7!%O!+?G(Fb@Gu7CrV$Jq5dk{A z^P^qIN9}h8Bcw0Djdcq=?2Frfc>T7Ci{G^gpC_Br%D;?GoIn?cmOr+clmyFj?wp>^ zM+siNuX;Mre`En_>A_Z;COs0pmm-O~X8-K4PMOM%LQz5(K_piT@6UHq<$HEufB0Ti zUL}6sXD#)kc__r5U7_gn6d->6Xl1~>G)5w+lBlv3RUQJ?&JhcJEvUxNE5`M;Ds|#8 zx#)f_)bq0)5{Zqg(Jw#!mmQcgm4<`<{*C+;_ZSK%CHU3G?9oYr;2&t`?@|h2 zZB6e)73CnhyYBymS1O?%+Bo9$x3kvW3qPxZu#`;Fftvr?J-2oTA*6Ym6akXRQ@BMU zMopp+GLtO^HqV|7&RmM-pbW?SHVbveo$O1ijvF~2gYc2S=x~21_ytwzZUgUU(Y&^R za@MiCmo_*CpqS7SiSCh$0GYESm~pgLmP~sg>egg(%uPtI5iL0aGqt7wAuL3yVK$1# zS|1AnOttN0*U{!kxAP=r035gyMP&@E;b_%K3ng$La4VgzgD9Jy6*f|GL?jpe!0NIz zJTd8pA-M;V?lrL1p+o`=bKXrnfm;^#IpB67_**ui`2PjJ%~a{PXN`5X9sGQdlyFIz z>2q^wr?lWLEk9X9FksIDqTGszzSuU$i$N?9iDzX=S<>xcSSZu(>U;Z7A*rqsMlc+D z;Mt1t$m5X5wJ5Ef1f)C1^-5|QX)4(yFpHL_lHt;zjS!CHi(7lvI=gnV4%vnmA_nD! z$9&Ly1(j~h8?)NjgGhd zxU#$3cceCvaer$y>J8Nd(7t3g{pDY$Q9j#~yzTQtiMuDA;>lNQ3SH?$GId?-tPG=>+qIBgdZHOiQJ&QZtVN0)?TRmZ>>q40+oth> zA89*tM-Mv-H|>nCk{L|+k^VAAlPS3jTWA@(t^HpbpM$`BY1Epu>xgr7dw6eMb-q&Z zlGAslDM(+YTo*x$)JjqCw~a$onmAhF^5QD(Jhr;pHNL;)bwae_Zjmd|7C?S(dmOSc zL|km1{}zmnmTl0q=8swrVUT$-kcO>{Snh&8n(@q8vV#qa2cer}W`SmzAjXwjv?Q8w zvF-fW+n7MK%Y$BbI3FUntU)?)}jH!j?n>~sX${0Ls!$lG54`F(A=c(r7z)XUhK7+ z2&GO0&U4txuT-nqI_$ZT+fg<%gP(&g5Q%s_LsbsXB+D(&89^$Hi%VZNTA>o=LE@xi zyR;c6xgHBa1XRW1UagY?s_$&@Y|blFYnXgVaTbZ#%P3BkWwfhY3m2S&NZoIQz~QW4 zzm#|+WW!Gpt97K@pb8_AO-S)l+JifPG{KKlAYq3H&z_NAci%5}QLkSk zC2~9}h|#|k_0VVuSW{u;-d)StzA+f;Hn3c!T}@zqOJoytjwyI5DT`EhLv`Yr7Xvp{ zFEj6e$m6=#x-weLSFf_lXvSZKT+-ORUgh*A1bX}iWK@nLCQ5cl_$Q(@v+Cq3#1{z_ z$e3+9oYH6dKqF!e=8jxpTfB>3DZU@9)XG`cKukCk#I_(=VyYOXc=YL++*9hjNyqVa zvF#`V`8wck3Z^y-5jH0cLQ_eF@P}ifuX5)1pP6YdawvKyTmIMw$F#!%hgVbim1IOy zqD^V_J<9^k0oMFUW8kb4NMLq3dbLwrWa?8GqEZ5_YY*p|eRphJVU=Y3Fh6M4lk&y7Y*2u^6~k*LO2}(*SguaTK)h#@?Og6 znW`e}gj~d@8FzVLa8R3HiJMWO+ur;y2S+gg1KgwDriKl5Y4##BUzO;2bkc z0vt&giJ*HVkSUJjUFU=hYw84@%-KM0u@sgj!dd8V=@ZvzA}p5#o}8&?3|}T+C0_B@k)#SkPbB%+nLJOCh=$qSIeqDjlg`CE8FDP{c7{XX#XNZl z-E?U+cq>nH=9k~&K@1XZpL{N79n=0iAjfl4W3)eX?K=1?_Z;K|!C{rv-<^ov2y{y) z116L6C|H>8~G)BPULDS!0my5L0Esuk=I!^WlPs3_I|f7@O7?K>^-;ltc*e9M)3a{sGfB zJakztxm>mpYeVP^H++q9l=+qVcQ)VpG(n`im{^|*-Owyjb*Johs`nQ+6NK6H?n}U_ z6lscfzM20)G7iP_xGk0w+4TvbdB5vt@Nyk4-CE*#UnT9qqM`LOOqHj&^T3V-D|A&; zCR>EFyWhQNyv;b&ulT7jXwQr5s^Kx#@0;L>E=?V3emvCtWX9(q#UbCF$9J9DGr6oJ zPbF!={DU9eCmWsb4v#$RjC2{wuA3SweN%Ve(GuQrlXPmK?WYsLPRizK>;M!(;5=$= zEl`>kh_VD;LVzg}H+_D!YrFOd0*;)=7{G0SjhFIKqL;J6F_z6fl5I*H`b_Rb^v!5JoX;nYsRz5#0UzVH?uFz)Og)N*qCHN z|9tS+G)PN@@6(nR3_885Yb4B~Ps>gk{q_&9ZYowp4~HT^ zA_h>p=lSLmEk7dzmND9nEyyh{%emDAaG(wep{%NfG%dfG8am zK}rsl^%HZ_EBSzlZQiWPQqzi9r=^?2(pV)HiWDzEpmNS+tDOaW*{jc4rPLk}U_S_R zH;Dd1=0M#%-;R{^$iky?geO)zQ;>j<@N8O;XnS`&^2Sw3|GAlf9x2wC4U>dUKJS@R zL~CFj*E?^&Z&|P8+^LVcF_HX=c^4(SoaM-?eha6}JQh)d*iKU_3#lSAQ+IE!-FO}x zzQL6lS*Lq@WNv5nJ7a?AHOMUoZ3~yMj2dmfD#y`su_q&BCKF^n9i1cHnqPxyn7dZg zLi@%JF2Wgw>`h?>hi!Rh9zmHLtixC)`KQ@DVcGtd-dokZ(tdVE)?fOZfc43@p7Y2V z+c^hw8O=qlU5GuMT@v=PUSvV8?W0XwGM zW+{4pOv%Ow(~l^>(Yo38Gsz@>;rC z$I;qk_aJo6oH;=li}PZt#L!jE{O6p0C4=B7<;=r6PK0N}SK^ecdroD34Qz(ux}i*E zE;#i5H5hVL2cxRCA_I`fwbZi-t{85eW#2n3E#T^>CHnCZ%Rg<-AXvFWF}jVS(l3`B z#aNHzL!&1;5zMtq_~*8Gx`_rvm!4PB?AcsMkos;ZOYiS1V=k=RvQ*(TUjJHvQ4Mjn zfl$-$+5D1B7XzbTGHa2|D2RsM2VyE{i;kMU%V+SVbzT5XkTgfFl#tMZk{1t(LEasf zOQ^yLjFw;bp-(*jLoMf}?yLM~1v^f$uO*BmU*RLh+dXGedR=xSgls^BN#T3dpEtyw z3a>!1ru7#i6$nOmh&Gc_?FQ4HQsJI3u%os8c5(}2A_Ak!cBf`b`=|^IOQVeWO7t|l zgIK5F#Bn_#2SkwPHOi*Pb~eXWRP?*DoVJ|r!g1RgJ?$A8%AdQtNmGGWrg%kv)R1hM zaL?fxqZhc4i1pQ|Z5Cmn(Nqkv!!mYm7gFpAfA{0)LEFiPi#|ghL>8;wNYk^j`Yg|a z!uQQDMTM9rBKwW?QiX9fUfpuPvwWL-#@#$~E-~0o&N}G~Nz^~@Q4!1J@NH6B3O{a; zT?NBa{k$vhTg7Qq3IPn@K%E$c{O|ZA zX&tkBit-H+N$R^=j!ssc7^PDypt6sF@Cp`mOL<6&sA!;sFz@%2^|i0r(_7%v zB3#exl{0<>FRQ|}Xmp<$%6R-wqc~2K5n7Hh7gIhnikdTak#;PWqkw+hid5wWZtY*3 z4`9$Ey|2=gbgm}8hKQUrrmOckyA%tv1_eRQ^^Gk5ZSFXPcWKf?EzzHg#cL%xQE6EI z23CU5#R-7nJUZ~H{xe4Os37rBjFywi9QnMJwy%K5J%MUh(%qYaCYnl57h6zJ2qE_U zAvP8j+9K(;nzEa_@(p~EV?_$vJ|8FmqwwA3B#-NA-?#wz$byAX?ao@ejp=3gI|(wm zAX!tYXWAPUg%^Wuv(B+?uDG^sC>_PDH?n?rn_Fg`7roxV%yQS{^}Va4o@Lznv_lRJ&SA$JZg{MUhk_l*gF%r`-&&uDSpyPz6I zU^@($d3>Am84@a|Y&U#)wX?Tpw24r^!RezMCTbZON6W0IO`WRe^m~uY4$^pxN?c#& z4$n-(7yY+}(q=OBHlJKBZXr=S4e@8uV1!O>R_n0Df+d+~o3qQTcj-kP_3%zvwt(Rl zXQPbkV|rKLZ_PLgZM_wWi}Wu_%;Gwu0`to_=VH}&cze~H>hxuTt4?ihtaUqDRek?J z38(h@Yxa*E*K+}aV|VY;eb`{uqBy)&{DpLwj#XpP2N_?Zm6I>UyFOypU46qiC!6)j zA2Ma?iW>F_EoneWzGFir816>E5D?8Nn4Km-RB)?gyBrT=7Z)tZHhxX2jD2*wWwu2N z5-JE8nZT+fH*(W*zCRW1<>l;r{-;}J>Dq#^shy%A_xhG)2-+TW6%^~&5GhbUR>>E2 zE78fG=%VtfY%s5{J&(zkwx}GDwwwyYFEo8Uj5!+s7(cW<;z!I+vTV3L3=98_D7rAh ztG_GC63r1-b~WGhjDj5fZPQn?1BZ^RQ;@rNTTDviTsIQPEkGYg+xUI0!h8Fl@BqUn zeXQCsO3Hdh6RLoqcq)nLeSU2ecp+=%zMdzkY1dIisLVKw{#N|$ea9YTa$Kp07+###Z*dZzpjo-d=z(Ry*Ql-8L2{BUyZ>ur-1$hgkp)SBaNyvU8<(8ld1p&SZ*)|KC6Hq5&K1r%$|K1iD&_0 za>Xadr54S^;0|CQZ|!~`oP50GsdHQ0cF8Pv+GKIM)~ZHK(Wk*%a>hq6^JP~Y8&1}u z*Hx|*xtS|8>nbzH8>jP=(&H0Vh+Zu(-QBlh@`3&{z?k?D1t9|Q8(y^Wvz&LNX!+VD zBcBT%OO59$e>|+Pl$mDg@%!(uJ*9h}grRM-kE;}g314ps+375M_?1d0s@%UJRW!m@ zni}`SKrO7qWunUBbU5aav6m`S+<5cq*^2XNcMC+hs~&qBIW zlT?C;)2MA@6+IU1d@XDHC9%6MDVj$7uuS)ph_P(CBFrYEY$WSs2A63bN#;##+)mD{ zO9#4wDEnv<#mIFU5ZE`-f0|Z6#^->p88PH1p@e&HZh^i~Pw9bor}{nvDlXEA)O}k@ zG#Ln88fZAld0t!B7aUX2g{(89iCmAtQ1v+cPT%|%sou@yX%FoKK#nbdGcb1pU}M7Z zbgU&h@Up4b8t@K|9XB+&6AJ@M+ZGCZcjL2fF*3bf@5SFQ%Lqww6s%}{G1l+;$70XS z_<{(TM>8n4cXS_?)VNhI^RP_!q@9SKInMfoAxIlwRfZc)<%-hs-i%wBSpgCvJzAh4 z68@S|%!_^qDonP zWjs~Z@qWYEmDV$#g}u)jmo1)zQ6rpS+=V(8^@z35&)^=q=^uBIpx9{ZD?dv+(#QaC zVr^lC7HPrKNVGH>!>`M|O%V2{b1qu*sSc#)ys7}^e$R?^7bajJA^)1mOg zHbx+|p^qpq$3Z9~(|{Xf`-BqJNDpN2gt|?Y=`&ae2uw(ab%I;@?lv6LSk)jfT9)dU zT?Y8$4wshjXfO&XqrN7_McboYS(ciWt+C36-ely1NDfxZ<@vTfG8uW)E$3EqktwSg z?ML{PPI;4(3wBsZQec4?sGYT%NM|~JoX&Wk80%k6s9Inp1Y}jO%#1JWoxHJA=rl?7 zbWN`1&utFRRl-G~lzWf37j6u#T4(GOl|wyyM$+JDhRI7_-(m%EpQS>AP%HJXaJEXw zS;~we38A+_Oyr<=76z(g27m?`j6LB4jUB-iiQ?EDU_p!ujA`gVa8~|CM&DHBbqgnW z5?!jKGHX5@o)CuMk+duv*c672`nTPQ3Ft)Mn1wihJuD15ac0+}*4E+1Qf%!{W|a#m`b9#0xXl5`6nf4>SpI-jwqc(Tv@(Z788owWS7 zbZ%!<^{XJzeBNpA_wimukA@ncjb|W8rb_1H;?fd1p917WF&L%u0zhxhCAAgR-dbua zIIZVRyjdX7((YPH%`#s?6DDKj!G1l6Iv3-oqi@TeQn6+$!Hn7Y(G4o3BemnBku!AB z@x**ErI8FPBbm1BaQlXh<-#KT(eeJsmJoa&6nezsyG-=Rxe3S{q#wXO-rX0`w!Soc z7rLLN#~csR)evUH#{Q7++8Dk|TmSTK3^*!X(trN%KTga$IvjE7eZgT-rE`t>4?$%I zW8xVI%L?NIUe^iNh@tXY6>y)OJhJ=mNI|+IKM)&j{<+~9iP&%Mj6RJVG4G@>0;o_F zrV5C3Kb~Z#g(_-=L_dYJ>!UA3CB_IX6-1Ae(so<-VVqu?jVc`0N-RW*@$X8fgocKY z+}6#1g(gBWHsl|n-g=8_iLb%lMvWd`u837}-r1vYz^NRfB|m2ZeM^y~w*uun+_5xq zGRip_!lLimN$UM+pZJ&4i4&|`>Czv&C(c|os0l@Gx{KkG4I@E3Lwu+~obuHWq|2x% z=@Y+9`*?FE6+~`^#tJkB7^6P`tg2w*3{i~`KwA;%yCMS5*C5Y*cFmqO&4W1aJ-?qh zL*EKqPJe^6v>t{i2tlrGy-mvw((qp;>#0`s5L-CVH$YKjrBD z86BnkfT#4gQ!M|AbYE*PKfe3o^x|XQnWpkfTJ`VW+L}+QV-BIX?VXj|_fGJf&aA^p zX3Pt@z2d})HvFjfuukRQR!ly77Z%syLy7RwpT0DbkraEUS=n~cnAN9^epa=L)F91f z^g9XSJ?%@%l7M92`>hu@y*%fByd%#YAFI{plm1TOwLfK*{JB|pxcUEkxc}|kKaSaV=kNbN$MOU> zrF-9pSr}@_^vOz{5dLRZ`(_t+=~|5rEqHdBFXKS*<@W~L8nwQAz8^pRA_m+vuI0HR zKgBAK6Xv7ou-p5`GYCURy7$#C!pf@HR<|1cV5<1Xt!k_jaQ(absSHNKL+lZxLc|jy zf4*oL&(`Zbs1C8GBtY}+_KMqkTf@92N{WqM&saDbLyyt!PPVz$6^!NtCozApP1e`M9a z`8Q<$>&5klogJIYMbeIhauWNX~=F^Y=-g(F;oq!Z=3|0HT0^=!Hx_ zK`|@oKvCGIxVBsoLrwM&;D%d3>?WBJKm=+x3;6ThyEV(f%Mc**&CK4W?Li#KO}fNv z(E!?+S#ArFOQI_GH zHhU1fo|2Sj6GOuwf{Mzec8=0!K^8Nhau4g*A1*Pgus)a>Cw=ixhTApE>1H-HQhM4I zgd~o>vgGq9CP3$BR0(Dk^60u zefNp32x<(K8KSSO(bv&k*0tbCA_)-6U4?at0dtRmi3%3^SPlNe7$`2MV<$p^AD-TC zXST}~N53X3{|^1P-~1U2e7ynL^QIpcphO3Sk&YOAxyI@Yh*xTL5%n_4F`6-%M0OCJ z4(pHBNTWZ}3&A)lsq>zHBUq81q|4Hd8pxj}B;5f9Rml*_GNdZcUb({gC^G<^VwJy% z5QGaX&)23b3TR#p^W%^+ASBKBVzBTC3RuyC2A+(jg5&y*BBGQ;B>hA{NYT({OiE8b z)W`zW*)z=%&Lt(Iz@mv%d0*+!P{y4$A`d5&Iq(~)pZE^6%$_qR;Y7e`VE_Tvn!ltAo;ZQ!TVdwSy4_Xht9&1>7 zbnlJO?Hl~zIVbNM!S&7*O$w)G`6_9!)+0SAY1T^SPC>2MO|I4+1ys}GIk;G=V0I+%2L&3w&=fCNA|a$3oO>N zlJPr17q}FES`Vu|(V$1#&w#0~eQ_MhO#9xm`**e^v4L|Y^j19oc?Mw2MF&6)LW88D z=4^>Pb>elNDXse=4fT-3t>slu8tA*`-~`Gh6!ZTy#rt-EkL$sO2FgtVUGOLVI(q{- ztKBqZ4g$i2UjnmeqRSRT9DpAi#;EH;(t=FBkR9!Lx!Oor{h|nie*tg~C8z=zAgOhuy+YEa<&1RSYoq)7{+JqCVGBKdqK^ns z*OJ!vVdsnjp}TlgHbAQ=+!-LuZ;+|Et#(o5*>BSGOGyMG>71)J;a<;fBe;+0$w172 zyyZH0cHxPQ+$ZGEB5PCqEU@oqZtyR2Du0r^^cdCRufXE(Kgn0mI4(ok0C&?0HVzRL zMFX7cQrJwzMt|`dT=fYCOD18k6!-&n_9V(mpkecp-cOXw6^I;17V4L~z!;cdsgNpZ z`vBZU0a~KkhfsMw2P&@$kjk90!L-i@6qfp0+Uf(eU$USO%>X*L_TF7{Bm}IIOKpHb z8=#nWUvNP?8J97`1taRgx+&AsHE^OdE!O&eSC#(Z;aWj@4BY>29KRh+BwW*`W3B#d ziv2B0_-soa+S_2g*ATR*wI<^cTRHfFgp5uU;c!2fcBHawBDfG4AOs4jX}$+AW|SvC z`s6C4KZW-tdL7Z>ytx2f*oki1bF1H{A4F9`z$_76DXU`{%F;lP zfOZ;J_{P%Z=tTEp7XFRsLIcD@k3&I|(`#Y)X0s<@f)l>^?AO+!GPD-iem;^NEF7n9 zv_1rwK<(K*!8s=`t$_a~!Ih5N$L|{ZerV2kP9w|B+0n%Q!+)JHqB%RWD~yD{Z(!X5 zUUd`G+b?x{9l&Z_pdg~$xspr(ozEewhvQKv-HFyz$|)kfLW*N6R-?Be7^E|)K*CJ+ zI1FrU75Gqb88uMUl05eA1PY;&(d$V_1h={cC8EZ}QrX4C%o#E0^XG-ww)R#%FyV$` zRI&q+y6iYlsJ(|H9F>Ti7y>gJM}4P#0Q?kI1<8m9A~ERO;r^Dx^dI~#PDR! z*kAW@@=tSw@Y47GslPxsEGb>RT7FYO+tCJToQKf)rufV>B7y>6%fwr-_0fL0a0;q8 zD2i?_%`-HnS$*RIgxiy;YWLBJL3+WVzpIY%HRcsBX2Y_Z@}DL;8e~)j#>nm=u7Dv7 ze8lR$PiS-i3voz=V1J}J0gaQ+9Ym;<%)uZtF7s2@Bi!^uxD^*?3Ta|8)UunFYVv1a zs&A*US+hZT5^Syslzo^mQXLgjULw~=+6T#CKq$B|fyMS*LLC*h6&G6CSYp4LLseLh zrnz+aDc*3&;%{bOpP<^kD~GfFXzol}1UeH%4`|`?Id7D74=9hELlo^1!6butwbuP3 zT;`DO?n5>Rz-)gK3b zo-cUm5TsSGyQw%B8Vm&pviZBB{x~bBpQeIlvi3H}awadjV224Th>AYu%!ijCbMH`s zSCq>}&ChAh{Q2jI;y!7KkdRR-)yA;r(L~fkG3_PW;N>FkBlGiwZP=rg11Y1&Zo8@^ zB2pCdgc(+<%zoX}R?y9k<1XC%dLqWx^~}@j=?4WZ1a)tHGRlCovyccE?5^<4xI1l# zO7ATaL;Fmm)o`CVpI*v6k)t)$zikZIgiG_b_m0uuLr&|XAHftm4;fU_F@5-xl07p^ z)T_wFg#85OfUPH=;>A;AMbW})y)PWNpU6xnp_gy)k#oB@hYS0Mx#sx>mH}&Aq!{;e z!sNGGk@655-e1FU=;wlEC+0Y8=D!XK4(e!0#`ZQ}>tiVo>dAB=fYi^<@da{L<@4Ceb`VU@2Jn67Ekh3yR`qyY9t5 zx-Xa{=iGNkdZg|im0x_&bF?d7u-={j!THz%9Wh8v zf?m1UurQ#a?KoKZ_a50jAK@sZ$x-k}d2b5r3&df+GeZQJ*VK<0YGwF~cI+XU@;NKfZ-D?4+-^ zKG|>1PmS(C)CqBXVar;r4O44~#LOkT;G~H~<2gt6&R~JV`n=Y_+DwBwR0%O5+C!GK z^&^6@so84e38npW-}=hpO|=x2Aw8)ujSK9kGZ0+gg4<7WP5WYNxUDFLXIME^2;;T0 zF&E2!&FO> zcBteCbQWE7FKNTiS*Cn(VQjL;U-u1>PC?MZ@n}AT`VL>dzYWyp2gW=g+z4nu)`OJ0 zh(r{8{-OSt&8x3xlo>(p6hv8VkQ2zjjf6ALp=eAb1jRT;4Q)0@iiVp)h@J>}TCykG zb?D5QJNJI`?#RV#<730|M1rW#49&6NfgmLpt%9KBZpgUs5`kqXZPQLll4gG7c8X+- zJOXPxBN1rr<8K@3alVRvM$55^myK)3pMtQMBc!}n^-@#T;QF5%^l!F*dO9h;*>|kE z{nJWamj;p>gR}(xd31}2r`U8UclVd*U5)dqk??yfT~e{CA1Y91TNbNTMEXQdAiAEn zjC5HTARc)r|BUpS;7lY{h&o+`N~*gtlOk|Paf=6MPM`1;$+u>Vzsx@rk)5wHpd@qg*!4c zn1qf93`}4n#ow@zd<0&)1iAvX^Z}`S3asnSp0?=q<&P^V(T5;c@zC*zu|nsy{#REw zEnkqBBi8J7iREPiad7fmLGX%!=AYd~B_*`Z8C#bIuL|t&ELn*h%&r=Z&intkj8ldl zKqR>oIrOp^rBJU|fW~vpLmkSi#-EmD#4DNR1+c9p-9<&L8|{kvGy{o(=3B$&hJu>B zd?(W7_9%9yGn!)r1EoKE9PKs|$Y+_D(ICR-KN9ojWQZ_Cu z=ZIFm3&(LN93*qFw9%TAA&$~?_38y83A#cU{N9AxwogY0v%PAboSfVnba%Sck{R`g z9u~*Cd7>kDF~_Q(+Lb@;E;|>D3U{)%>QBw_h}_10w!3?5SGf+%Q(3xGWHk%bo-u)w zN=LVq$uDmvOOyh`aw#Pvy5(Db6M4Fwer?|Q*HN1k!&e`<$EmeLq0M(fu3hfGoY?Q$mnS-2bXa{Uy|4Dm?I(H{4}6@FL(LqPSvPI! z^x5<`2B(@*`J2$o6#xaA97w-}injn>3qal_GD}M1*h+nnu(cN!w?BBf?Nh$om@o@! z`bGkL)j=zkZ_T4>9OKRirSkI82gk{cyk8`YDJm)L_Y0$t%XyqS6UDNz{odwkt3m+} zXTH7NzJ?3I{h_j$;y64GOl= z$Jjbav$Ju~1eDl@Ne~jiMwmK5~gGCiN!4<1cEShhx|7KlCVygs_cIc)xmvFmMM z;0B`lo$J#7b}8`A4 zoNxNMKCbYJ2cfO3f{whA&L2}*owda%yZY?kuaX;7y=+|~NrRnwyaO%vnR5HGPe^=B zssB9M)8FF79WYStc(cW9x1AyXA;nken@>!kBiq6$|Dzw=D_3$-c0KAVG@J9oSL|D` z1iQRhyypI#SDbVSVJr`pUH^@|k?7ui>VCN~^o8iaN_-W;YF#88w1Nfws{ZQYZ$)P5 z2&OiiP3bP#XoNUJNSZJW^SC@&@9*Y61aN7vA9PJh1lc1h=5y$+X+P`AFLeELRqdD< z9wdNu`WuIO|9Q14`@^!vdgDL!XN}8{ezAJC#k^`>=q}iQGr=Nruqg#C7@=z^8!*9V z0G|+r1Y#%5!{abqwr4v-fQDurJ8GLt)i|^2Zfd(u%CY@@s(%DC1@K*}05E5i&t~s8 z+FvUdmpSxd#KE>$L#r8AyO=8yPOaG<)`lHh4cXv(53Q?R72UoFW*EU!~Vo(W5fHggDuGNR~yO&C-Qp0?i z!EufLxiQA-Qf>z|eP}UZJAL>^2TR=yZjR@9=iXt84E^yoeA~JmYp3T>kK~G7;Q8^M z-f5i*C}(KSJ=dwV8ORpwvt}Kd*mqIGeYW}3>%|wBWz}5WdW}AVcVy)@(<(l}>V(hD zYlcYY6cs}|0ztoCKg*yX4pjej;GM^-&_b9>RVqr|#=2RHw8SXZ=#9!7A9foTQq~1< z!VF7aKu4J9k{~A;3>cL}i9=XHiI zHGndRvTGK+>{fwK8T~cBL>&QYNDOdMjw21L6ZNpHdi~^6&vVbpE0ZPrEZ#U-dc`^FHkZ@%&M{pRS54A$y-hbLc2~K635>S-F#LRW{j{Px%hFCTQBa@?X z&1qmnwZH>IMpn-4u%nT1<7kHJ`PR<-vzAwv48})j75Ut7^!(MWq??+wRi6L4T}S8} z#vk6>Y?d{gl*H2(=?53z*n}+?HQu1+LEiDm9qjtJnfly&mtBWiEk}DR@?n6NBI~eN zcs?EUU>XQXkHttH?%XrF2=1SQcg}Vz3cIjTq=C?nfmp}&7D)5 zd2(gMu->d1s`~?0ZWe6ySmK?Vj(|xr1&T*cS^z(pJ?cnKsBvn`1vjxVAF6QK+p4l9zN(udZLSB%^u11LiSk`D(y5|T zdOJRo#U%4l`~9uN#!32}E3ulJHqsL-=f8QOB+tjp;g7~3xQ;=e(tot{zBj`USLfT( zUg3dX+G3?E;PdS_-|o>DTew9Xi~P#&mDB83vaoHH4-X$wBd^F^s`WM@Y(|1aTxWUW z3y%(~s(TW5&0|xJ?XjqhQqL4 zELct5B&sl4g8h2lBA~Inxvuy=Gtw2GPy;7Eyo?E8$=Z?h6$xQ<(`GgtdkP!ILA>I7 zU4(c32(v2&wA5^i&x?)_0}J-3uK*ZGd>qK%3$C#nMsR3jNU1?f4>}(J;5SMNZsc z#SfjzpSH<;2@|}>CW-i;?2|inu2(M8t}3nRv(K$URI4}PgWPY;*usT5uot2@mYdz% z1On>ydo4^yB|qgX&RXK@2s2fWOf>)+*&F2^A-$16g@D^b0VS?8MnuxzFFHVK4a#@n z^(zi)YVWImJWt3E5L1KwEuE zo@&c{HVC9ne@|&RyR{CK+@gM{EnGOo&v)e|+VCHz-oW;? zJH@5c`J})|hqB12*8xrpz!wz#2y-4*6?(e{noMHD&qrn;x-hzAp8V{67{oQ22euB8@PG{&u}jPq^|_a0 z29)Q9LAiPr#;P$mcV^h57A~%=DOPD5t@1utZhWwGaJX=>Zz`;=5jvb`fIK8%Dy&yG zp-0CLdMpa4T&`FaqTg7Y8udi4Ch;~mWtVIBu)N>4`o253M zcjRj=1!c4_;c`Pz6zPG!-B%NLOG|AodoMgqsp-hTr@{1&`-Os*@KJjPYXi9hBvzoh z)Tr|5icNB19MehFVJX~ZiS~O>=JpppRwlvrh%o}RKwx}L8_us#G1*2H-t*b{do%gZ z*zp!UC=d$&{QbmlfYhuLcuuU0}c82hZfQnfB0AIW|VPzvY9w*hhYWmLT!Kw-SwXxq_Tsx0n)3wEa(@uK7iaYzzqgtI=t?V(iwakw@^$(plKY{20Ca07Ts zq}uKrKl)5djb)>UD6toY>I|U>&>Nirn)x8#f6!HoB7feDrF!(88T|&V=%W~{eHoHz zNmE?fm_XszqQ}HeyO^iSJ_V4|z0);@P$#$|xQ>^P6H*tCSo2fSL5_$txIDN?0&L;* zWMb7}Z)U9@YrY(BG2~hM-z#tjkj_2d*KglzOoZZ2}jfmG%;z(Cv0U)?c zmy&9Z-5k&FWHd}`obU(kjf}+4nj{{|)7E`(+h*IUtg(hI`P71$D(=1jiKN6}4XyI^ z);k6Lwu0}#**ZbQ5PLnWFQef%W;_WQ{R>+l3fxU4;g_;B-z32mgkg|LME(8x>pO~2 zLtue_v%EImUx-U;-EipH88wz}cnx19ewq>^Gnbp4iRMBW-Wan^b?$rqWd+)O(?W_J z4@T(6J<<8&Ntx)@IFr$k4qGX?);04Q&n?hKWJv4Pv&>y$iO{fV$95ERtYdgR%r)*c zJxX~|dlg`KWB8}p*My@SSV*$x&Br(YY>68g!X!<% z;qCm4Gq=4Q8F!E{A1X4gzOf0jgVwoSw|p|Quv-!WCh}J_+?lRj6z^;sDXMlHUw%W! zzC$bCaf8*D&zGMuHPdOrKT#Bd^s1niOEKbj9)&;9dD9*)Wzv;_|yM?_roh4_tMyHf!c}9 zc~Zp}6?|QXXwOSgpErJ9EU>6TNa+*^-NwXXLSip^IN&{YV|8($o1`;&0v+t-mdnFG|g z+*N16x8HmpAD&Ce2DE4NL37;aLSj=xh?vEzb+ERJTQhy}!rL1AXi;AASr<59zI}Mr zS@MS3L<5IR%XC9F4^SrFakZqor~t(p$xl84iDeMG5KPT@aHVo>7kY4ViC&$3*08?W zy+39q*>kOx=)RDMcxqKpuumkeY;kx!Zvnm`^z=SyXL=6*jl!@Z#v!7l?eKF zS?M0x^y^O?IFEQ?^QM3EO2momTQd7jiB{3T_S(Qm|I|dC#E~2QgFEFu>~7Nr?{bF@ zR`6O<31J0X)^7iCn)kPTzOe&~$vlF^x}gRLaPY?_`P&D|x6Z>UA7j_$`u3Z@ zz1`$LmuJ8u`AzKXL9~-(trQS;(yXy(<5~rtFxG3_Yj=Q6?HZ|zK2@}H~;(fkXETpqAGj|oqlhSn zZ|Z$6Ecb3KHyzhEpE!Bp{`|cE*O&WRf!?WaT!0@Pxc@EvKaS0RdD8c(d*x#CDQCHk z>D?8^EVxcf7?55wh<1LnCO^KkSM1>KUd4^B*ZuXyn|K#@mNh#*B!m&v8uEqofH{4U zLq0tH@uUfZRwAx%dHd)?r(eEN`Fz}WQKQJaXaBq$KmTE$=)7}~L`Cf3jpAb7-G?OZ#ayVd@0ul*5c{w9$90+D!1)!d3>y}J%y${(<@GWc3XXrfOj7OPS~z-|O~Nn=0w`d@UXMl=a>V?g^5-ZQq;x%=*L^fT{qvTQx}{lONP2RR@v%S>$Tc&RDuM>m zwF>(PYLi}q+3d=5|3D(XP;xqWk#|}kba~mTfX^cVvi-#p05ZBGUtWf;8a-eke=r;Q zm)zug9JFD&`%zqG38sHprvH9T&oR9H+Z~vZ3ZNFrr|U?*+laDR`lr0XH%#iOt=BI_ z!8&|`_(o%xS{f<6+1EH8j_K?KrZ#ARl7|s$;=*WXer=XmOhpme5@Vp751(htx-T$- zfu(h^a-$~f-I%cD0|-8s1jq!{s8z2u#UGC4?9tR;qG{-23%;K8Ldk|R$K&}aFF|`% z;kpQ3R~|?ZRxO9ZZOi$7H^FEGb`*Ks$UX2p_Tng}!g`}wy76f-igEMmnchELb~5`R zA{*AZzqDAh|7%O^pWD+y;}!%^eX{EtipuXi4rYNjx6ZY`471{Gq;UGdS4+5LuHM1L~~^;gq?x z7f@tqPiu=yc(QO~1E+c#%1>VlZ4bKIh1>=UnKv9#Q;U$6M3NF(FK%M6_4J_^PwcBL zl}DPyv%Co`h&d{HjhAH*tm63G5!C*gR{iW4$rr=3+>~_Q&-=4iOtzv)T9CnX6FUkE zBpGIf%c%^u`MixflptvD?CbgXc4GvJ5iL-SguaXz!N;!Ije+hFNa`qY7)`%sJJgT4 ztIBFRwLI<))w5D1HQrAcDyj^yJ#?r9%fb#cZ=8+XuS`RVFKC8(5L8a*%I+~HsWPUa_4q3v|-e>6a7=tng!x z=X?UYzG=1^u=ZPQzLNER8|uh?fwfk`UC9!CNNVY*E@$Z+_fgMChM9fU$0p+T>qEX< z0Ksok(5fFt34aUZck`j0T~49QKUXbm9hawp27MC{;#)85-^_xHBFii`n;i@PAQB@9 zK%j+spMJZw)iy8H6ENt4$yFC3Sfs?CfC_s)N)(ZuhF_tRycEm&DD(+=9NtyLt7xvV zn=r41j}LS)3sV&c{!W8lYGVW@eBH5tb0`yN|2GKbI|9P59rhG=M*7tuT53MgF)9U+ z6=DP3M5w*Uu{nGx>UT#ct`W4r31n!V{Dv3%GEokBpZXz>O6(0}zXD*{8&tM}m^HW7 z0h5cOP<8>#nPIg(kC3D6J8Og?O~4ED>}P3c&yVr|zGpZ&^v{Cbd~|7RipXx(>Xj;nP{owaXI14mcDFJZv~qh%#p|H$NY9 zH-?+!>)XFwf{qbu$ZTG#ej-NA@nV)e?$C}>Az@Jn^{U|l)`8Svnc08aaC8kO#RW+; zp4&wQoEkui%HW>1OqODy-!BK=-Fr^WWy7yfIYs(R%dRoFxZ=C^b?0O&T#!Baq) zFFsid@ifE6dI5uU2@sxwRk$voJL>M)&e)^dF05af*`Lv=i1Pm4XW}luXBVKPbLY;L z{$GEu9o+==7-PTiA3+WR^sAG!)C4%i6{k)=4}W9H^J~lWKhWVH=l;LVKK_UC{LMN1 z599gUf=r+NKS=Fwp%BpA|Emb*734QJ_Mf_;8E?U$Bm!7``gzJk1$DG|#5l90GGWZA zxa&L6VRG>O#jVTFfq_2v?z#@+KWxMgUi@jsdB5DE;h{@)+q1p!1_Orfz2)YVHc`V% zv;O7p&s_mJJICVI%KyZ>G8y9865sci`(9UPS>iTTiAR2)|58-5NO7#^YXSH@ODEkw zR{VRY_1bE$SCStfSR^?`dqtMc+jNm=7gkOMi5-{#vQM;Xop9CHAcR4+1cEQ7uv;%Jj~) zYzdRgjoB`DM`NJIA|uMzSS@55JLe7`QcFp>U9RxUTi|{TU+psLGrta!CN9pGqLiSc z`EkAO57w404r771UF5vvrL+5Ye42;MNqXD6BG7lr=QN=*JuA?hZH zBZq2!eV4xeMVEkk*)m^AhoVU8CjK}PN-(d@=def7kPOT7pbBqW?JkN6;vxTPJ73=K zule_nwr{~=pxaNxs;zzN2B%DD|PZxh4 z7JaWnOJcJANZ`w)a-G{@n^|tpb;oERl2a4hHAFU*(8+)vl8lZ99j`qfbjnikEv{9| zD*bwQSdcI8;(nML^B-)_^dpzZmrcfu2@K&eBAlbnuyH^riaAG@c(P? z%Hyd{`*z!uYAQ`qWNB}NByEb&rrktoLAG|Ov9(C#X-u+CS+iECNlCPzLN$&(Y9f@S z2oWLKvc1=>lvCq$oYV8X@AH1{Kc3I0$1%q_-`{nA*X#P;zPk2pspvP3Qh5K!xe!gk zY45WOGLNFQ>IA|}95{$;J?}@A`i(&+VUJuxu8GZI@iF$BPUB0}YWWT=Vm%r>a}cOz zT${~SEF3ua@|%sRaViRby65jQobR?uqT~zOe#n#emuerDJ1`{DeLPBY$}eB?Os}|8 zn?~Akagq_as;RmLxf`L`+D$g~5VCso^awXc5XAwVi$=%n>UbM!msCM|t$iM&OQ8XFLkRR4fE(;Xy2J zYAH3s_J;&?orJNto;0Jtd|H?F&>&zVZxIox`Qwb#3XM7CZ~E>R0A{iGmir`5>>^z$A$TY z=b0ut2O5#4dL%f@0g#b~WoSwu>V2O1#9zLYW>?Ch|(3;6AZ4pa9n-A zJZ?5|vY&SAXJ_yF7xxB;62e~G&1ZYZO!4`3AyEX3+*=Go`?aQF59m(pEwP+-r0Uel zX(zPy=S-@{ZkPMFcMVZl(6rSDjmRc%EG2O!?d}s({6>SLn!4i!ks9c?SL{%*$7n*r zId-eWd0cx}1A4oxh6RQi&hJX0L-3d%TP#1S_{d_3^TDwU%PA}oqF6~N9Ic0U{_yol z|N1Ah&<*~~so8KV#qck6NGm~?eK~sRAV<=1t^rv!jxedjW}Ye(0$&jAgDXOn+3;8< ze@XjG$ZLtNr$c5xF9#~|&I51dNs;M>DPyXZF=Yg&#RA|a}5#R|z79v8%0uUfAAY8~s zGJ!bScW7dm4{#uvu;$dFrLC(oK%g81z* zVNS}Hl*7}KHT*ZOT5sjjx`uVsdP?{Z%?%F+vG!0OK(`z0%{V>P-zF_lAT40*{h}avL-Mw;qB$1;3Aip8=$tKRID ze$TeLI84123;-O~CJy7lcXk+O`1GX34jMgF8o&TWntz9nQM_s}Aw-*2WQXx$i`G6X z@4qIAxrRfoGs>@(NFoRc*F-J=`AdNvV-=!{@?6zdTdSYnvuRMxooFMwMz+|MOh03^ z*Up^qgCQ0mkHXbui#sBU_js{gmQdC>VLnh?bLqBwu#<`%Bh^P%CY0RcW0RnEW)fpp zTVsMkiRY?qCe!vglrL3EE}Z&N-8X6hQC~2yM_(_e8PivL;(Ey7lHsngS0Hni%I`#I z;uI8!X+Tk+_!1peMREwmbH%0jUGC3-+ep&&3_r8RJl3kcO?JUM1j%i&Zdxjv9Evfx zwZHx(T%55*)pHw$ktYU@n7l4B1eE&|XbaC2f##(pRmJkH3wBMZVI}U2s#2)xeM`O0 z%})x$tpZI^(18gVCytX{thV3Hx8`k|rKD_5xu5-8en^lq3$}Mhxcyj{oT(=>pEmjH zeb^`ntOdtjy`?Yxc>4nf?W%`Cf4kLtReO9?v?TjqdNo1ptuYt`(?%x#e0V=7pU^yv zXGmdWcqgM2fil_?H--+4+5^EI9wCwCpVSiuN#eoUQoHZkaa4%1hZN5>ax^U!rZ(-4o<~II7CC6BOLEldjiA z4@1YKTH|ru@2#G6SsO+ZE)6Xh!zL-YW2Z}r)p?x&qIs57< zZ42|>*18Xm`%XOOuO*QW@J)U7XOr1Re%8Cg?;H5e5xP09k)3I!2N73F{2n;#YeF;U zL2yurOgE%DdwqkZYXe3uVI9FgWb4p$1)+&y?<^spAU0vk zZ2re&DAPX&F(4(+22%Zm?cRpFsxd$ ztU5@lK&<1-JdCvVc%75(AdnMQHiiUl1uDJW`Dzi&Ru_V~lVt6Qx}cK@fO)QX7?}31 zw{H;UgHtnMOoIZT9Y@jxxmdm|S=h0NWTZZ3Pc}(IPqnPZCw!&E`FU1qa;%w|VBcz> zb~GE@ynwa2JL1`0!EprlgY}Fw3qp1Tx!=y6E%8ZHa5@%&$_SSg#t)q0!;5WFZK?$j zI6g+zcMxKb3DJ+ClpB;FZhtcu1?3}v33qiY6vDxl<#G9!5L6osJ?!4|pD=NL<#n}O zJerX)F>(eM2X$x_uy>%OS2FJ7(L5-}SV9!Tf}-}DL~6H5s`5?=sR8@PjufY=4pYec zh*(tLn+v&E5vmG%XcNP#FOtlYBwOb&oys|aA)|5Pg>dm7j;mgM1*$;Gqq7BV^%2t8 zxMorH_ev?p0!;dipoU>!tghYYK!U$o3+vzyof{!I{vIyPPVir`h4_AG`W;XxGf?BS zE*s|>{u<%e77_y~Rf!_Z+!G^^F1?M!=PrZ{#U7!M&(9a+1uXrUbrS+Xi+wp8Qz3IO znRyjLMdphg{)AY`)YN8x!hu_$Lt@TEMY=T@T;BdmS-l$$a` zE!~bOZOiIl5EO~FkM2}iH0=jNLys_q=D&#ysMee}tV~lv)}Ne{0k*1liZQ`V*FEc| zm5x*Nw&^LjA>mdzZfRQcG?2s?j{(ZURbBjoBpO}s8X|b@v0Q9F;kNy)fP$((trcSm(WL^{Iu^Q{wgh9xXQcl%6m= zf(jvuD0nQKNTkELDA;VOlSqm73RRF_3g{nxeJ6=ctc@SD@1^1c_&x=tyQT7%MP-s-@sDDw*7H?3SSu!fNaYZRVDStayUmB14^~y)yd{4_Tj3(uMjdpvTw(P-hxUx8`e%!><9KzsUo6D$MG(R!o%Hg> z-wwj3L@g-!%fBVH15f|#VWEv>D;lDz-ha}&N`zzEj8I-=<;`b$kOUI>u+Xt6RPZKz z6B#d~mICtTrr1BHK*~8ef758N5{<~n92;2mu@_W!w9C11JABR5+qT>yr`;M$ zKE$xsP?Q`IOR$M<8l_4nysY_KL2Y_qMexqFV#l=~b#%43?RaXMd_(R*G%iezZbKSv z$G2P-oj^UfcPR7tlCV;ePPHfX^r@`yZj^GVte|IUIi`_8WUepj7(8E>WgSbC^{7wyZS7!0}FKDN7!rV)9itmR!e^Ksvp>f5a zvtZ^0-yf$INN$G9u?)HKWU8D;{^|x?E+c@2N17wmwv>+%>vNq@gwU>WKeh-oShK)c z>q{?Rd7IQyHQ;g7GV4I}p)QAyQ-7%m4#EseQP!ekMMvltE{197>wVqv`1&U|mJ@x7 zE^sfFbWgwSI_k3xwN&Qorlq+qEh8W8%%is#8AYQi{ z)W4Nz5W8O;M7o^g&Z`U`dvziHx_Dg4H>M{UQ;}M4zM(aFCCetphmVNKjoom2S)&9e zw`uJ!r4J;O8O~=M6~3LZZgTB-MV(oMzp_-*pXJ2isNxVpdmJ}LSMY13eEpLf8f32H z*Y>V;u3oBdW}o-}NvV<6?5Cz9FUOG?P6!FyW~Hg#tih2)Wvgr|TzWI(XK9lQDFw z{LO{z6zJo5um_=c4lm4&#U4dRsk!@6bARQ>fBqLm_ZO$>zt@@kGAN8(hswa#`v4Td zkFZ<#Ah@N4WMqQo*<$36=hk0RNAaQ(hrJvlaa-UCtL6wRd-QtZ(UFL7t;P<%S{b4Q z5Rlm(=QRJ#XwrT}?^-=_1j-OQh$wF47T`+OQ6LpdwbWy4N%0{$R9a2>YvxzFmS&w% zI(A=%jHvwnA{!4#Lm%sZ28W@=osGfFIc$6+>$HO6&B5N)L$9uejNZUz{pq-n@iI~) zEtxC(``D}HP*|w@21&7|1f4k#Z=aFjD=qcKGyJFMZoPRDF%X9H@mM}Ig7ko~@3tf{ zg-CwLRxYv-BMklhcvMsTWKmH}J4(cObq&LjWK2YQ2~}B8ad!@uN)uNNiX^S6jwYfi z?|Gx5_~JLOAv-9<;9yI61lb)&Xs|+FQ5`ZKs1EGW(U&8m4#N4y#Sg_hQ_%wUEq#Jw zz9o0$2F>AUoEcun%41cY?Z<`pCyw*P2C^k&p6aT$M$Dm(Y&|=vEONTtXQ3?FT!to6 zJi5{Db{QGLeRqW$U=K>FU{odl>VGmpXXv^shzNY`cf-hHw&uek)~eW1h6=kzw7o_& z4B7sU%O7Mr#*M|0=%h%WHXQ;%*&94QvK;~~iTcC_vYCR+R7@@xqdZA#5CIc>F6skP zeVzc~)D8M#N!q2xG8HSY1dCT^kCR<%P0{}CEu|eJCCSPfIGF@BOoLP!&A;Z>i`asrS_p&;%O7iQXZj+Qwh#_$_uu~Mng1A+u zPaPA(RfEQuv&LQx+BW<&t}7sW?DTQT$)lTw&$6*-B>+|IO2gJq&HW9BCXzzANK)(x z8Q72VQ|awRyc9M8^1pKAknG6)yb>+0Z=ufij9{*@_?Q)7PQ zh%f7`xWhI&B=0X1CVqt|89?;ch=bdy4e<@deT16OVYvC(k|}}WDsn?!-%3RJY%2OSQ%^si^T2>G9J!!Vqi8VP=j{U60|l0fc^rnXVCBnVtrMCps}6g zwSSjt=ka{Br9~m4J%OH)n6id@gBNOhA=8ISkD$9b2$hCDW+z+9M~F={$ylq*_|*Q6 ztt)#x#Sd(SgLwVia+k^Y5(J7w>PUcy8K2Ri3l)d@%pyBsmKq*UVx6Yy+yG85^Q((~ zSmD7Mg&Q9uh?tj3Dty`To!cLKot?1JcL^r%De{73=>`<53DC3ZVU=eX8dH|9qu`Ag zm_!H=y&@KdO~#&U7yr}fWpogAno7JAuyvz0TKr1nc%l&-P|^W+X57Ey1OZp*F6A6^gD|9ClKFCKpT;+h?G?GP1{nhd6KbYh zI=yOXQE1-816O~YyTl~DrNko2LV{|BlkXB^jc^&L~ z3)hUmO9s1pc)Z4tst);yd87E!T{}Cu0M)vZ5)yMQ`wT~IHr!xFH%lx#QPt*(@aSJy z>&#Ad#$&;c`LmFHS-Tv*F0bjKy7KjiF}rvj$_8w`q8X*CrlzJXcN+ShEJT>t#sjTZ z?PxLwrgQE^N>36twWzf(uf0J1f~+S`cAJ@* z@!Iby+_7z2&^+I?=jqce-|Kn@1qFHe`-iGun&qHu-rw2~{jMDKy;2icI>funaK4t_ ze-p&|vwrZQ^RXCl`vt>{2Hr$)Fq590KEe7CRFf7&!PJs($b&w9Knv{bh~LCJcKU93 z^0oW!HnV>7hj(!FYUA@+e!Y)Q9?CF1=@D1A`Y+Q-MN1;yLMi?mX5ocden0wZI6G#+ zujb+p1psLRb^Y&rSXS4G)~|Ye50Jae+*dJ#op&CA-hxc;mGvk3#cRU!siU^av5xIO z!iL*~wiUV^Gq2})5Hb;!D){xlV*4#QPB0{FOcEyN+yrbs;j7HCF4}J9Lw@zv}4S{e$=Ht>0bt;2AF0+0a^)z82dkt+ ztwNmfKhdF;p+`)7Po@@r%Om#4|0wkd^(e@EAHTiQ|C!pgYa!+J3x=MPwgjjiy53!7 zaO+mI_HLCOH~CA4&8!nl|3+eDEsX)az_CfAtyvhp^Cg{9o;`+!naQ#FS>;dTp9egZ z33>nK_C?=X>zLnqS%(eJe)@i$cV51ZUsSa3`xg&myu9AFdxvfMLy0xuA0CDG?fa+5 z@^65&<5}n&3DBrgxV#}E>yqWed&2;m-z6UoTU&Q*U31C2SWk!^J3nwYZy5gz zJll^RdJ|bYKz2a!>33Q{zH#tost?$JG>Z?aNUU=Almk2Kuex{y7Vp>#+pFfV`>Sl2 z29OE!JUU6>B+I|a#v64pl%6#1cw-}izygxGz*)FE>38L30$-ZN5%NF5@Z$-) zWad7lqYjZe#2#CKmdxDu3TO|J-M%9Wc+wu?=Vs!S-*ozQasgPFjO9zTpU6ob^7$DM z<5K5Hog>Rb)@}7b2Q17diIWn64p_MNV06I3y$7Qc=kJi-QHMw!Vvj9A2Q1wt7;L8{ zbil$%?(v1)>BO1)_9UG+vwdc}jknYxesM5+98xF+hkFYGp41^yhsaS}r4wiF&mWyQ za|Au+P5_-abML|E#F_h)jygo@5PNI^I&to{L}K>xtm*?gab{&5_Bm@&^YZVxAYiV2823Knvt@tm=fn7M#ZTXJ4MdvWo}TAjpFh!;$m`pChxD;ii@&F~ z{o3g7scok)bU>X3Xs!`X12p#tr!b^2ruu>gXztw?g&~DuPn(or6o#Bwfc6bJu^Mx>M*D_byDthu3d5dm%lQAZDPBbXfpv*={ zV#XQmA#z=W(;gxVr^Mz<6owRroLGSN5IM0LQ^ik96gGVnlhv4`D23sF1cr(gxzat53yU* zuni{K%I*Rvwynlg#Zeei7;<6(+C${TYRqSpwz9c)UlfKEhP0K_ohu#Yu@}f$oK6bE|AR0*J9AcKCl{c1A6hjt zIZhT}?)D`bpu05<+a83%kixL1zLExLwvShL^*IgDJq6v@$CSd5!jKaS&>kWuR%1GU zw1>#O`=T(UFr;i7HY`9(6ei!xNzF$qWlpTd?4KwMDGYlW^=XO1e1^I!hgqCXS}FGw zbYCA+3PTD*PAou66i%$hbpB|i%)R@fFr+YKqfTT>F8=of0oG5W^aZgQ>NnjsEtP~* z^TyhYVe<&kFwHg2|68)f!!~HMP`Q&$LfaV(2|1ANU2IZbopSva`RMGs&XK?zD|Gff+dc*E5KyyQy8?qrC zVh)qk8*=IdKiQFb!>-|#snhkzM&J4eg&~C@Cl;W@2TrWUbVe!vhI{u#VaPQMdBy$| Xjl93-_85#OdM#hFX7QDUI{*3~HFXqu literal 0 HcmV?d00001 From 7fa7619706c2749b22556166417e44c39684f0c9 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 18 May 2026 20:51:56 +0530 Subject: [PATCH 049/159] updated feedback --- docs/tasking-mvp/tasking-mvp.openapi.yaml | 301 +++++++++++++--------- 1 file changed, 184 insertions(+), 117 deletions(-) diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index 5f6b0e9..bdb4e05 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -6,8 +6,8 @@ info: Tasking Manager API for the Workspaces Tasking Manager MVP. servers: - - url: / - description: workspaces-backend root. + - url: /api/v1 + description: workspaces-backend FastAPI app — all routers are mounted under `/api/v1` (see `api/main.py`). tags: - name: projects @@ -417,43 +417,53 @@ paths: - $ref: "#/components/parameters/IdempotencyKeyHeader" post: tags: [tasks] - summary: Persist a single previewed feature as a new task. + summary: Persist a validated FeatureCollection as the project's tasks (bulk). description: | - LEAD only. Commits one feature per call. The client iterates - the validated FeatureCollection and calls this endpoint once - per feature with a fresh `Idempotency-Key`. - - On each successful call the server: - 1. Re-validates the feature against the project AOI. - 2. Allocates the next sequential `taskNumber` (starts at 1). - 3. Inserts the `tasking_tasks` row. - 4. On the first feature, sets - `tasking_projects.task_boundary_type` to `source`. - Subsequent calls must use the same `source` — 409 - otherwise. - 5. Emits a `task_created` audit event. - - Project preconditions: status `draft`, AOI set. - - See the top-level idempotency contract for retry semantics. - operationId: saveTask + LEAD only. Commits the **entire** FeatureCollection as + `tasking_tasks` rows in a single transaction. + + Effect (single transaction; all-or-nothing): + 1. Re-validates every feature against the project AOI (same + rules as `POST /tasks/validate`). Any hard failure aborts + the transaction — no rows are written. + 2. Allocates sequential `taskNumber`s starting at 1, in the + order features appear in the request. + 3. Bulk-inserts the `tasking_tasks` rows. + 4. Sets `tasking_projects.task_boundary_type` to `source`. + 5. Emits one `task_created` audit event per task. + + Project preconditions: + - Project must be in `draft`. + - Project must have an AOI set. + - Project must currently have **no tasks** (a second save is + rejected with 409 `"Tasks already saved"`). To replace + tasks, re-upload the AOI in `draft` — that wipes existing + tasks and re-enables save. + + Idempotency: `Idempotency-Key` is honoured at the batch level. + Same key + same body within the configured TTL replays the + original response (HTTP 200, `replayed: true`). Same key with + different body returns 409 + `"Idempotency key reused with a different request"`. See the + top-level idempotency contract. + operationId: saveTasks requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/SaveTaskRequest" } + schema: { $ref: "#/components/schemas/SaveTasksRequest" } responses: "201": - description: Task created. + description: Tasks created. content: application/json: - schema: { $ref: "#/components/schemas/SaveTaskResponse" } + schema: { $ref: "#/components/schemas/SaveTasksResponse" } "200": description: | Idempotent replay (same key + same body); `replayed: true`. content: application/json: - schema: { $ref: "#/components/schemas/SaveTaskResponse" } + schema: { $ref: "#/components/schemas/SaveTasksResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } @@ -461,7 +471,7 @@ paths: "409": description: | One of: - - "Task boundary source differs from a previous save". + - "Tasks already saved" — project already has tasks; re-upload AOI to start over. - "Idempotency key reused with a different request". content: application/json: @@ -715,15 +725,18 @@ paths: | `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` | | `to_remap` | contributor / lead | n/a | `to_review` | | `to_review` | validator / lead | no | `completed` | - | `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_remap_feedback`) | + | `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) | With `done:false` the state is unchanged but the lock's `expires_at` slides forward to `submitted_at + lock_timeout_hours` (emits `task_lock_renewed`). - OSM validation: a `404` from `OSM_CHANGESET_API_URL` returns - `422` with detail "Invalid OSM changeset id"; nothing is - written. Network failure to OSM → `502`. + `osmChangesetId` is supplied by the client. In practice it is + auto-populated by the editor (Rapid in the iframe) on save — + the UI does not ask the user to type it. The id may optionally + be verified by looking it up in the internal `osm-web` service + (same network); a verification miss returns `422` with detail + "Invalid OSM changeset id" and nothing is written. `last_mapper_id` is updated only when the effective role is mapper. @@ -755,108 +768,125 @@ paths: "404": { $ref: "#/components/responses/TaskOrProjectOrWorkspaceNotFound" } "422": { $ref: "#/components/responses/UnprocessableEntity" } - "502": - description: Could not reach the OSM Changeset API. - content: - application/json: - schema: { $ref: "#/components/schemas/Error" } # ========================================================================= # Roles # ========================================================================= - /workspaces/{workspace_id}/roles: + # NOTE: the workspace-level role surface below mirrors the shipped + # endpoints on the `roles` branch (api/src/users/routes.py). It uses + # `/workspaces/{workspace_id}/users/...`, not `/.../roles/...`. Only + # privileged role rows (LEAD, VALIDATOR) are stored; CONTRIBUTOR is + # implicit for any project-group member and cannot be assigned via PUT. + + /workspaces/{workspace_id}/users: parameters: - $ref: "#/components/parameters/WorkspaceIdPath" get: tags: [roles] - summary: List all users associated with the workspace and their role. + summary: List privileged workspace members (lead + validator role rows). description: | - Returns every user with a `user_workspace_roles` row OR a TDEI - role in the workspace's owning project group, each with the - role the server resolves for them. Any workspace contributor. - operationId: listWorkspaceRoles + Returns the rows in `user_workspace_roles` for this workspace, + joined with their `users` record. Visible to any workspace + contributor (`isWorkspaceContributor`). + + Does NOT include implicit contributors — only users with an + explicit LEAD or VALIDATOR row. The UI uses this for the team + management table; broader user listings come from the TDEI + proxy (see `/assignable-users`). + operationId: listWorkspaceMembers responses: "200": - description: Roles. + description: Privileged members. content: application/json: - schema: { $ref: "#/components/schemas/WorkspaceRoleListResponse" } + schema: + type: array + items: { $ref: "#/components/schemas/WorkspaceUserRoleItem" } "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "403": + description: Caller is not a member of the workspace's project group. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } - /workspaces/{workspace_id}/roles/{user_id}: + /workspaces/{workspace_id}/users/{user_id}/role: parameters: - $ref: "#/components/parameters/WorkspaceIdPath" - $ref: "#/components/parameters/UserIdPath" - get: - tags: [roles] - summary: Get a user's workspace-level role. - operationId: getWorkspaceRole - responses: - "200": - description: Role. - content: - application/json: - schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } - "401": { $ref: "#/components/responses/Unauthorized" } - "404": - description: Workspace not found, or user has no association. - content: - application/json: - schema: { $ref: "#/components/schemas/Error" } put: tags: [roles] - summary: Set (upsert) the workspace-level role for a user. + summary: Assign (upsert) a privileged role for a user on a workspace. description: | LEAD only. Idempotent upsert into `user_workspace_roles`. - Last-LEAD guard: the workspace must always retain at least one - LEAD (counting both `user_workspace_roles` rows and TDEI-derived - LEAD via `poc` / `osw_data_generator`). 422 if violated. - operationId: putWorkspaceRole + The body's `role` must be `lead` or `validator` — assigning + `contributor` is rejected with 422 ("cannot assign implicit + role 'contributor' directly"). Contributor is the default for + any project-group member; to remove an explicit role and fall + back to contributor, call `DELETE /users/{user_id}` below. + + The named user must have signed in to Workspaces at least once + (i.e. exist in `users.auth_uid`). If they have not, returns + 404 with detail "User {uuid} has not signed in to Workspaces + yet". + + Side effect: the target user's `UserInfo` cache entry is + evicted (`evict_user_from_cache`) so their next request + reflects the new role immediately. + operationId: assignWorkspaceMemberRole requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + schema: { $ref: "#/components/schemas/SetRoleRequest" } responses: - "200": - description: Upserted. - content: - application/json: - schema: { $ref: "#/components/schemas/WorkspaceRoleEntry" } + "204": + description: Role assigned (or updated). "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } - "404": { $ref: "#/components/responses/WorkspaceNotFound" } + "403": + description: Caller is not LEAD on this workspace. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "404": + description: Workspace not found, or the named user has not signed in to Workspaces yet. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } "422": - description: | - One of: - - "Cannot demote the last lead in this workspace". - - "User is not a member of the workspace's project group". + description: '"cannot assign implicit role ''contributor'' directly".' content: application/json: schema: { $ref: "#/components/schemas/Error" } + + /workspaces/{workspace_id}/users/{user_id}: + parameters: + - $ref: "#/components/parameters/WorkspaceIdPath" + - $ref: "#/components/parameters/UserIdPath" delete: tags: [roles] - summary: Remove the workspace-level role row for a user. + summary: Remove a user's privileged role on a workspace. description: | LEAD only. Deletes the row from `user_workspace_roles`; the - user's role falls back to their TDEI-derived role. - operationId: deleteWorkspaceRole + user's role falls back to implicit `contributor` (or to + whatever TDEI grants — `poc` still maps to LEAD). + + Side effect: the target user's `UserInfo` cache entry is + evicted. + operationId: removeWorkspaceMemberRole responses: "204": - description: Removed. + description: Role row removed. "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } - "404": - description: No row exists for this user/workspace. + "403": + description: Caller is not LEAD on this workspace. content: application/json: schema: { $ref: "#/components/schemas/Error" } - "422": - description: Would leave the workspace without a LEAD. + "404": + description: No role row exists for this user/workspace. content: application/json: schema: { $ref: "#/components/schemas/Error" } @@ -911,7 +941,7 @@ paths: required: true content: application/json: - schema: { $ref: "#/components/schemas/RoleAssignmentBody" } + schema: { $ref: "#/components/schemas/SetRoleRequest" } responses: "200": description: Upserted. @@ -1206,9 +1236,14 @@ components: `stale_timeout` — released by the background job (`expires_at < NOW()`). `reset` — released by `POST /reset` on the task or its project. - RemapFeedbackReason: + FeedbackReason: type: string enum: [incomplete_mapping, data_quality_issue, wrong_area, other] + description: | + Reason categories persisted to `tasking_feedback.reason_category`. + Required when feedback is used to drive a `to_review → to_remap` + transition (see `POST /submit`); optional for generic free-form + notes on a task. AuditEventType: type: string @@ -1229,7 +1264,7 @@ components: - task_reset - project_reset - changeset_submitted - - remap_feedback + - feedback_submitted # ---------- GeoJSON ---------- @@ -1514,23 +1549,30 @@ components: featureCollection: $ref: "#/components/schemas/TaskBoundariesFeatureCollection" - SaveTaskRequest: + SaveTasksRequest: type: object - required: [source, feature] + required: [source, featureCollection] properties: source: $ref: "#/components/schemas/GridSource" - description: First save sets `tasking_projects.task_boundary_type`; subsequent saves must use the same source. - feature: - $ref: "#/components/schemas/TaskBoundaryFeature" + description: Sets `tasking_projects.task_boundary_type` for the project. + featureCollection: + $ref: "#/components/schemas/TaskBoundariesFeatureCollection" - SaveTaskResponse: + SaveTasksResponse: type: object - required: [projectId, taskBoundaryType, task] + required: [projectId, taskBoundaryType, taskCount, tasks] properties: projectId: { type: integer, format: int64 } taskBoundaryType: { $ref: "#/components/schemas/GridSource" } - task: { $ref: "#/components/schemas/Task" } + taskCount: + type: integer + minimum: 1 + description: Number of tasks created (== `featureCollection.features.length`). + tasks: + type: array + description: All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1. + items: { $ref: "#/components/schemas/Task" } idempotencyKey: type: string nullable: true @@ -1541,15 +1583,24 @@ components: # ---------- Submit / Lock ---------- - RemapFeedback: + Feedback: type: object - required: [reasonCategory] + description: | + Feedback payload — persisted to `tasking_feedback`. Used by + `POST /submit` to attach a validator's remap-rejection note, + and reusable by any future endpoint that records free-form + task feedback. + required: [notes] properties: - reasonCategory: { $ref: "#/components/schemas/RemapFeedbackReason" } + reasonCategory: + allOf: + - $ref: "#/components/schemas/FeedbackReason" + nullable: true + description: Required when the feedback drives `to_review → to_remap`; optional otherwise. notes: type: string + minLength: 1 maxLength: 4000 - nullable: true SubmitRequest: type: object @@ -1568,8 +1619,12 @@ components: lock_timeout_hours`. feedback: allOf: - - $ref: "#/components/schemas/RemapFeedback" - description: Meaningful only in validator context on `to_review`. + - $ref: "#/components/schemas/Feedback" + description: | + Meaningful only in validator context on `to_review`. When + provided here, `feedback.reasonCategory` is required (drives + the `to_remap` transition) and the row is persisted to + `tasking_feedback`. ExistingLockSummary: type: object @@ -1598,27 +1653,39 @@ components: displayName: { type: string, nullable: true } email: { type: string, nullable: true } - RoleAssignmentBody: + AssignableRoleType: + type: string + enum: [lead, validator] + description: | + Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`. + CONTRIBUTOR is implicit (project-group membership grants it + automatically) and cannot be set by this endpoint. + + SetRoleRequest: type: object required: [role] properties: - role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + role: { $ref: "#/components/schemas/AssignableRoleType" } - WorkspaceRoleEntry: + WorkspaceUserRoleItem: type: object - required: [user, role] + description: | + Privileged-member DTO returned by `GET /workspaces/{wid}/users`. + Mirrors the shipped `WorkspaceUserRoleItem` from + `api/src/users/schemas.py` on the `roles` branch. + required: [id, auth_uid, email, display_name, role] properties: - user: { $ref: "#/components/schemas/UserSummary" } + id: + type: integer + format: int64 + description: Internal OSM-DB `users.id`. + auth_uid: + type: string + description: TDEI auth user uuid as string (`users.auth_uid`). + email: { type: string } + display_name: { type: string } role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } - WorkspaceRoleListResponse: - type: object - required: [results] - properties: - results: - type: array - items: { $ref: "#/components/schemas/WorkspaceRoleEntry" } - ProjectRoleEntry: type: object required: [user, role] From cab0cf0e1df6265e5dceec3e5479e025bca13fa0 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 18 May 2026 21:03:23 +0530 Subject: [PATCH 050/159] spec in json format --- docs/tasking-mvp/tasking-mvp.openapi.json | 3157 +++++++++++++++++++++ 1 file changed, 3157 insertions(+) create mode 100644 docs/tasking-mvp/tasking-mvp.openapi.json diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json new file mode 100644 index 0000000..275acc8 --- /dev/null +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -0,0 +1,3157 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Workspaces Tasking Manager API", + "version": "1.0.0", + "description": "Tasking Manager API for the Workspaces Tasking Manager MVP.\n" + }, + "servers": [ + { + "url": "/api/v1", + "description": "workspaces-backend FastAPI app \u2014 all routers are mounted under `/api/v1` (see `api/main.py`)." + } + ], + "tags": [ + { + "name": "projects", + "description": "Tasking project CRUD and lifecycle." + }, + { + "name": "aoi", + "description": "Project area-of-interest upload, retrieval, deletion." + }, + { + "name": "tasks", + "description": "Task validation, persistence and read access." + }, + { + "name": "locks", + "description": "Lock lifecycle on a task." + }, + { + "name": "submit", + "description": "Done? submit flow \u2014 changeset + state transition." + }, + { + "name": "roles", + "description": "Workspace and project role management." + }, + { + "name": "audit", + "description": "Audit trail." + }, + { + "name": "stats", + "description": "Project and user statistics." + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/workspaces/{workspace_id}/tasking/projects": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "List tasking projects in a workspace.", + "description": "Paginated list of non-deleted projects. Any workspace contributor.", + "operationId": "listProjects", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/ProjectStatus" + } + }, + { + "in": "query", + "name": "textSearch", + "schema": { + "type": "string", + "maxLength": 255 + }, + "description": "Case-insensitive substring match on `name`." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "in": "query", + "name": "orderBy", + "schema": { + "type": "string", + "enum": [ + "createdAt", + "updatedAt", + "name" + ], + "default": "createdAt" + } + }, + { + "in": "query", + "name": "orderByType", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Paginated list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "projects" + ], + "summary": "Create a tasking project (optionally with AOI and role assignments).", + "description": "LEAD only. Creates a project in `draft` status.\n\nOptional inline AOI may be provided (same validation as\n`POST .../aoi`).\n\nOptional `roleAssignments` may be provided to seed the\nproject-level role overrides table (`tasking_project_roles`) in\nthe same transaction. The creator is auto-added with\n`role = lead`; other users may be added with any of `lead`,\n`validator`, `contributor`.\n\nActivation later requires at least one user assigned with\n`contributor` or `validator` so the project has at least one\nworker before it goes live \u2014 see `POST .../activate`.\n", + "operationId": "createProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + }, + "409": { + "description": "A non-deleted project with this name already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "projects" + ], + "summary": "Get a tasking project.", + "operationId": "getProject", + "responses": { + "200": { + "description": "Project detail.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "patch": { + "tags": [ + "projects" + ], + "summary": "Update editable settings.", + "description": "LEAD only. Per-status mutability:\n\n| Field | draft | open | done |\n|--------------------|-------|------|------|\n| name | yes | yes | no |\n| instructions | yes | yes | no |\n| lockTimeoutHours | yes | yes | no |\n| reviewRequired | yes | NO | no |\n\nAOI is updated through the dedicated AOI endpoints, not PATCH.\nImmutable-field writes return 422.\n", + "operationId": "updateProject", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "projects" + ], + "summary": "Soft-delete a tasking project.", + "description": "LEAD only. Sets `deletedAt`, hard-deletes child tasks, retains\naudit rows (flagged with `project_deleted=true`). Returns 409\nif any task currently has an active lock \u2014 force-release first.\n", + "operationId": "deleteProject", + "responses": { + "204": { + "description": "Soft-deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Project has active task locks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/activate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Activate a draft project (`draft` \u2192 `open`).", + "description": "LEAD only. Pre-conditions (all must hold; 422 with a clear\n`detail` otherwise):\n - Project has a `name`.\n - Project has an AOI set.\n - Project has at least one saved task.\n - At least one user is allocated to the project with role\n `contributor` or `validator` in `tasking_project_roles`\n (the creator's auto-`lead` row does not satisfy this \u2014 a\n worker must be assigned).\n", + "operationId": "activateProject", + "responses": { + "200": { + "description": "Activated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/close": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Close an open project (`open` \u2192 `done`).", + "description": "LEAD only. Requires every task to be `completed` and no active\nlocks.\n", + "operationId": "closeProject", + "responses": { + "200": { + "description": "Closed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One or more tasks are still locked or not completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "projects" + ], + "summary": "Reset all tasks in a project back to `to_map`.", + "description": "LEAD only. Restarts work on the whole project so contributors\ncan re-map and re-validate.\n\nEffect (single transaction):\n - Releases every active lock with `release_reason = 'reset'`,\n emitting one `task_unlocked` audit event per released lock.\n - Sets every task's `status` to `to_map`, clears\n `last_mapper_id`, emits a `task_state_changed` audit event\n for every task whose status actually changed.\n - If the project was `done`, transitions it back to `open`.\n - Emits one `project_reset` audit event for the project.\n\nTasks themselves (geometry, history of changesets and remap\nfeedback) are NOT deleted \u2014 only their lifecycle state is\nrewound. Stats and audit history remain intact.\n", + "operationId": "resetProject", + "responses": { + "200": { + "description": "Reset complete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (nothing to reset).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/aoi": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "aoi" + ], + "summary": "Get the AOI of a project.", + "description": "Returns the AOI as a GeoJSON Feature (Polygon, EPSG:4326).\n`404` if no AOI is set.\n", + "operationId": "getProjectAoi", + "responses": { + "200": { + "description": "AOI.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "post": { + "tags": [ + "aoi" + ], + "summary": "Upload or replace the AOI.", + "description": "LEAD only. Accepts a GeoJSON `Feature`, `FeatureCollection`\n(single feature), or bare geometry. Geometry may be either\n`Polygon` or `MultiPolygon` (EPSG:4326). Single-Polygon inputs\nare upcast to MultiPolygon on insert; the storage column is\n`GEOMETRY(MultiPolygon, 4326)`.\n\nProject must be in `draft`. Replacing an AOI hard-deletes any\nsaved tasks.\n", + "operationId": "uploadProjectAoi", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiUploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "AOI stored.", + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + }, + "delete": { + "tags": [ + "aoi" + ], + "summary": "Delete the AOI.", + "description": "LEAD only. Project must be in `draft`. Clears the AOI, hard-\ndeletes any saved tasks (releasing their locks in the same\ntransaction), clears `taskBoundaryType`. Returns 404 if no AOI\nis set.\n", + "operationId": "deleteProjectAoi", + "responses": { + "204": { + "description": "AOI deleted." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "Project not found, or project has no AOI.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Project is not in `draft`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/validate": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Validate a GeoJSON FeatureCollection of candidate task boundaries.", + "description": "LEAD only. Does NOT persist anything \u2014 the client must POST each\nvalidated Feature to `/tasks/save` to commit.\n\nProject preconditions: status `draft`, AOI uploaded.\n\nHard validation (422 on failure):\n - Top-level type must be `FeatureCollection`.\n - Every feature geometry must be `Polygon` (no MultiPolygon).\n - Coordinates valid lon/lat (EPSG:4326).\n - Every polygon lies within the project AOI (centroid-inside\n is not sufficient).\n - At least one feature.\n\nNon-blocking warning:\n - `polygon_exceeds_grid_size` \u2014 area exceeds\n `TM_TASKING_GRID_SIZE_METERS`\u00b2 (km\u00b2).\n", + "operationId": "validateTasks", + "requestBody": { + "required": true, + "content": { + "application/geo+json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + } + }, + "responses": { + "200": { + "description": "Validation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatePreviewResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/save": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/IdempotencyKeyHeader" + } + ], + "post": { + "tags": [ + "tasks" + ], + "summary": "Persist a validated FeatureCollection as the project's tasks (bulk).", + "description": "LEAD only. Commits the **entire** FeatureCollection as\n`tasking_tasks` rows in a single transaction.\n\nEffect (single transaction; all-or-nothing):\n 1. Re-validates every feature against the project AOI (same\n rules as `POST /tasks/validate`). Any hard failure aborts\n the transaction \u2014 no rows are written.\n 2. Allocates sequential `taskNumber`s starting at 1, in the\n order features appear in the request.\n 3. Bulk-inserts the `tasking_tasks` rows.\n 4. Sets `tasking_projects.task_boundary_type` to `source`.\n 5. Emits one `task_created` audit event per task.\n\nProject preconditions:\n - Project must be in `draft`.\n - Project must have an AOI set.\n - Project must currently have **no tasks** (a second save is\n rejected with 409 `\"Tasks already saved\"`). To replace\n tasks, re-upload the AOI in `draft` \u2014 that wipes existing\n tasks and re-enables save.\n\nIdempotency: `Idempotency-Key` is honoured at the batch level.\nSame key + same body within the configured TTL replays the\noriginal response (HTTP 200, `replayed: true`). Same key with\ndifferent body returns 409\n`\"Idempotency key reused with a different request\"`. See the\ntop-level idempotency contract.\n", + "operationId": "saveTasks", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Tasks created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "200": { + "description": "Idempotent replay (same key + same body); `replayed: true`.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveTasksResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Tasks already saved\" \u2014 project already has tasks; re-upload AOI to start over.\n - \"Idempotency key reused with a different request\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "List tasks (geometry always included; serves list + map views).", + "description": "Paginated. Any workspace contributor.\n", + "operationId": "listTasks", + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "$ref": "#/components/schemas/TaskStatus" + } + }, + { + "in": "query", + "name": "lockedByUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "lastMapperId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + } + ], + "responses": { + "200": { + "description": "Tasks.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "tasks" + ], + "summary": "Get task detail (geometry, status, lock, last mapper).", + "operationId": "getTask", + "responses": { + "200": { + "description": "Task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace, project, or task not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/lock": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Acquire a lock on a task.", + "description": "Eligibility:\n\n| Task status | Allowed roles |\n|-------------|---------------------------------------------------|\n| `to_map` | CONTRIBUTOR, LEAD |\n| `to_remap` | CONTRIBUTOR, LEAD |\n| `to_review` | VALIDATOR, LEAD \u2014 and NOT this task's last_mapper |\n| `completed` | (none) |\n\nOther rules:\n - No other active lock on this task.\n - Caller holds no other active lock in this project (one-lock-\n per-project rule). 409 response includes `existingLock`.\n - Project status is `open`.\n\nSide effects:\n - INSERT `tasking_locks` with\n `expires_at = NOW() + project.lock_timeout_hours`.\n - Emit `task_locked` audit event.\n", + "operationId": "lockTask", + "responses": { + "200": { + "description": "Lock acquired. Body is the updated task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Role does not permit locking in this status, or self-validation guard hit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "One of:\n - \"Task is already locked\".\n - \"User already holds a lock in this project\" \u2014 body includes `existingLock`.\n - \"Project is not open\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LockConflictError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "locks" + ], + "summary": "Release a lock on a task.", + "description": "Default: caller must be the active lock holder\n(`release_reason = manual`).\n\nWith `?force=true`: caller must be LEAD\n(`release_reason = lead_release`).\n\nTask `status` is unchanged in both modes. Force-release does\nnot relock \u2014 LEAD must POST `/lock` separately to take over.\n", + "operationId": "unlockTask", + "parameters": [ + { + "in": "query", + "name": "force", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "204": { + "description": "Released." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Not the lock holder (default mode) or not LEAD (force mode).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/extend": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Extend the expiry of your own lock.", + "description": "Caller must hold the active lock. Adds the project's\n`lock_timeout_hours` to the current `expires_at` (slides from\nthe existing expiry, not from `NOW()`). Emits\n`task_lock_extended`.\n", + "operationId": "extendTaskLock", + "responses": { + "200": { + "description": "Lock extended.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "409": { + "description": "Task has no active lock to extend.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/reset": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "locks" + ], + "summary": "Reset a single task back to `to_map`.", + "description": "LEAD only. Restarts work on a task \u2014 useful when a contributor\nor validator pushed a task into a wrong terminal state and\nsomeone needs to redo it.\n\nEffect (single transaction):\n - If an active lock exists, it is released with\n `release_reason = 'reset'` and a `task_unlocked` audit\n event is emitted.\n - Task `status` is set to `to_map`, `last_mapper_id` is\n cleared, and a `task_state_changed` audit event is emitted\n (only when the status actually changed).\n - A `task_reset` audit event is emitted.\n\nExisting changeset rows and remap-feedback rows are NOT\ndeleted \u2014 only the live task state is rewound.\n\nPre-conditions: project status is `open` or `done`.\n", + "operationId": "resetTask", + "responses": { + "200": { + "description": "Task reset.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "description": "Project is in `draft` (no tasks to reset yet).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/submit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "post": { + "tags": [ + "submit" + ], + "summary": "Submit an OSM changeset and optionally transition state.", + "description": "The \"Done?\" flow. Caller must hold the active lock.\n\nEffective actor role is inferred from current task status:\n - `to_map` / `to_remap` \u2192 mapper context\n - `to_review` \u2192 validator context\n (LEAD may act in either context.)\n\nState transition (when `done:true`):\n\n| Current | Effective role | Feedback? | New status |\n|-------------|--------------------|-----------|---------------------------------------------------------|\n| `to_map` | contributor / lead | n/a | `to_review` if `review_required` else `completed` |\n| `to_remap` | contributor / lead | n/a | `to_review` |\n| `to_review` | validator / lead | no | `completed` |\n| `to_review` | validator / lead | yes | `to_remap` (and INSERT `tasking_feedback` with `reasonCategory` set) |\n\nWith `done:false` the state is unchanged but the lock's\n`expires_at` slides forward to `submitted_at + lock_timeout_hours`\n(emits `task_lock_renewed`).\n\n`osmChangesetId` is supplied by the client. In practice it is\nauto-populated by the editor (Rapid in the iframe) on save \u2014\nthe UI does not ask the user to type it. The id may optionally\nbe verified by looking it up in the internal `osm-web` service\n(same network); a verification miss returns `422` with detail\n\"Invalid OSM changeset id\" and nothing is written.\n\n`last_mapper_id` is updated only when the effective role is\nmapper.\n\nFeedback rules:\n - `feedback` is meaningful only in validator context on\n `to_review`. Otherwise its presence returns 422.\n - For `to_review` \u2192 `to_remap`, `feedback.reasonCategory` is\n required (422 if missing).\n", + "operationId": "submitTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Submission accepted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller does not hold the active lock.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + } + } + } + }, + "/workspaces/{workspace_id}/users": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List privileged workspace members (lead + validator role rows).", + "description": "Returns the rows in `user_workspace_roles` for this workspace,\njoined with their `users` record. Visible to any workspace\ncontributor (`isWorkspaceContributor`).\n\nDoes NOT include implicit contributors \u2014 only users with an\nexplicit LEAD or VALIDATOR row. The UI uses this for the team\nmanagement table; broader user listings come from the TDEI\nproxy (see `/assignable-users`).\n", + "operationId": "listWorkspaceMembers", + "responses": { + "200": { + "description": "Privileged members.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceUserRoleItem" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not a member of the workspace's project group.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}/role": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "put": { + "tags": [ + "roles" + ], + "summary": "Assign (upsert) a privileged role for a user on a workspace.", + "description": "LEAD only. Idempotent upsert into `user_workspace_roles`.\n\nThe body's `role` must be `lead` or `validator` \u2014 assigning\n`contributor` is rejected with 422 (\"cannot assign implicit\nrole 'contributor' directly\"). Contributor is the default for\nany project-group member; to remove an explicit role and fall\nback to contributor, call `DELETE /users/{user_id}` below.\n\nThe named user must have signed in to Workspaces at least once\n(i.e. exist in `users.auth_uid`). If they have not, returns\n404 with detail \"User {uuid} has not signed in to Workspaces\nyet\".\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted (`evict_user_from_cache`) so their next request\nreflects the new role immediately.\n", + "operationId": "assignWorkspaceMemberRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetRoleRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Role assigned (or updated)." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Workspace not found, or the named user has not signed in to Workspaces yet.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "\"cannot assign implicit role 'contributor' directly\".", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/users/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "delete": { + "tags": [ + "roles" + ], + "summary": "Remove a user's privileged role on a workspace.", + "description": "LEAD only. Deletes the row from `user_workspace_roles`; the\nuser's role falls back to implicit `contributor` (or to\nwhatever TDEI grants \u2014 `poc` still maps to LEAD).\n\nSide effect: the target user's `UserInfo` cache entry is\nevicted.\n", + "operationId": "removeWorkspaceMemberRole", + "responses": { + "204": { + "description": "Role row removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Caller is not LEAD on this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No role row exists for this user/workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List project-level role overrides on a project.", + "description": "Returns rows in `tasking_project_roles`. Sparse \u2014 only users\nwith an explicit override appear. Any workspace contributor.\n", + "operationId": "listProjectRoles", + "responses": { + "200": { + "description": "Overrides.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "Get a user's project-level role.", + "description": "Project override if set, otherwise the workspace-level role.", + "operationId": "getProjectRole", + "responses": { + "200": { + "description": "Role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleEntry" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + }, + "put": { + "tags": [ + "roles" + ], + "summary": "Set (upsert) a project-level role override.", + "description": "LEAD only. Inserts/updates a row in `tasking_project_roles`.\nProject must not be `done` (422 otherwise). User must be a\nmember of the workspace's project group (422 otherwise).\n", + "operationId": "putProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetRoleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Upserted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleEntry" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "422": { + "description": "One of:\n - \"User is not a member of the workspace's project group\".\n - \"Project is closed\".\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "roles" + ], + "summary": "Clear a project-level role override.", + "description": "LEAD only. Falls back to the workspace-level role.", + "operationId": "deleteProjectRole", + "responses": { + "204": { + "description": "Removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "No override row for this user/project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/me/workspaces/{workspace_id}/tasking/projects/roles": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + } + ], + "get": { + "tags": [ + "roles" + ], + "summary": "List the caller's role for every project in a workspace.", + "description": "Single round-trip for the project-list page: returns one entry\nper project with the caller's role on that project.\n", + "operationId": "listSelfProjectRoles", + "responses": { + "200": { + "description": "Project roles for the caller.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelfProjectRolesResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/WorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List project-level audit events.", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `includeDeleted=true`.\n", + "operationId": "listProjectAudit", + "parameters": [ + { + "in": "query", + "name": "eventType", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "taskNumber", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "actorUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurredFrom", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurredTo", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "includeDeleted", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "in": "query", + "name": "orderByType", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/tasks/{task_number}/audit": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + }, + { + "$ref": "#/components/parameters/TaskNumberPath" + } + ], + "get": { + "tags": [ + "audit" + ], + "summary": "List task-level audit events.", + "description": "Newest first. Any workspace contributor.", + "operationId": "listTaskAudit", + "parameters": [ + { + "in": "query", + "name": "eventType", + "schema": { + "$ref": "#/components/schemas/AuditEventType" + } + }, + { + "in": "query", + "name": "actorUserId", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "in": "query", + "name": "occurredFrom", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "occurredTo", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 25 + } + }, + { + "in": "query", + "name": "orderByType", + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + } + } + ], + "responses": { + "200": { + "description": "Events for the task.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditEventListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/TaskOrProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/projects/{project_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/ProjectIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Project statistics summary.", + "description": "Live-computed. Any workspace contributor.\n\nField semantics:\n - `tasksByStatus` always zero-fills all four states.\n - `percentMapped` \u2014 share of tasks past `to_map` at least\n once. Rounded to nearest integer.\n - `percentCompleted` \u2014 share whose current status is\n `completed`.\n - `avgTaskDurationMinutes` \u2014 mean across completed tasks of\n the sum of `(released_at - locked_at)` over that task's\n locks. `null` when no task is completed yet.\n - `uniqueMappers` \u2014 distinct submitters with at least one\n changeset while task was in `to_map` or `to_remap`.\n - `uniqueValidators` \u2014 distinct submitters with at least one\n changeset while task was in `to_review`.\n", + "operationId": "getProjectStats", + "responses": { + "200": { + "description": "Stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + } + } + } + }, + "/workspaces/{workspace_id}/tasking/users/{user_id}/stats": { + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdPath" + }, + { + "$ref": "#/components/parameters/UserIdPath" + } + ], + "get": { + "tags": [ + "stats" + ], + "summary": "Cross-project stats for a user within a workspace.", + "description": "`totals` sums across the workspace's non-deleted projects.\n`byProject` lists only projects with non-zero contribution.\n\nCounting rules:\n - `tasksMapped` \u2014 distinct tasks where the user submitted at\n least one `done:true` changeset while the task was in\n `to_map` or `to_remap`.\n - `tasksValidated` \u2014 distinct tasks where the user submitted\n at least one `done:true` changeset while in `to_review`.\n - `totalMappingMinutes` / `totalValidationMinutes` \u2014 sum of\n `(released_at - locked_at)` over the user's lock sessions\n in mapping/validation context.\n - `totalAreaSqkm` \u2014 sum of `tasking_tasks.area_sqkm` over\n tasks the user mapped (each task counted once).\n", + "operationId": "getUserStats", + "responses": { + "200": { + "description": "User stats.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Workspace not found, user has no activity, or outside tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "WorkspaceIdPath": { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "ProjectIdPath": { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + "TaskNumberPath": { + "name": "task_number", + "in": "path", + "required": true, + "description": "Sequential, human-readable id scoped to the project. Starts at 1.", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + "UserIdPath": { + "name": "user_id", + "in": "path", + "required": true, + "description": "TDEI auth user uuid.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "IdempotencyKeyHeader": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Client-generated unique value (UUID recommended). Scoped per project.", + "schema": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + }, + "schemas": { + "ProjectStatus": { + "type": "string", + "enum": [ + "draft", + "open", + "done" + ] + }, + "TaskStatus": { + "type": "string", + "enum": [ + "to_map", + "to_review", + "to_remap", + "completed" + ] + }, + "TaskBoundaryType": { + "type": "string", + "enum": [ + "grid", + "import" + ], + "nullable": true + }, + "GridSource": { + "type": "string", + "enum": [ + "grid", + "import" + ] + }, + "WorkspaceUserRoleType": { + "type": "string", + "enum": [ + "lead", + "validator", + "contributor" + ] + }, + "LockReleaseReason": { + "type": "string", + "enum": [ + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset" + ], + "description": "`auto_unlock` \u2014 released by a successful `/submit` with `done:true`.\n`manual` \u2014 caller released their own lock via `DELETE /lock`.\n`lead_release` \u2014 LEAD force-released via `DELETE /lock?force=true`.\n`stale_timeout` \u2014 released by the background job (`expires_at < NOW()`).\n`reset` \u2014 released by `POST /reset` on the task or its project.\n" + }, + "FeedbackReason": { + "type": "string", + "enum": [ + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other" + ], + "description": "Reason categories persisted to `tasking_feedback.reason_category`.\nRequired when feedback is used to drive a `to_review \u2192 to_remap`\ntransition (see `POST /submit`); optional for generic free-form\nnotes on a task.\n" + }, + "AuditEventType": { + "type": "string", + "enum": [ + "project_created", + "project_activated", + "project_closed", + "project_edited", + "project_deleted", + "aoi_uploaded", + "aoi_deleted", + "task_created", + "task_state_changed", + "task_locked", + "task_lock_extended", + "task_lock_renewed", + "task_unlocked", + "task_reset", + "project_reset", + "changeset_submitted", + "feedback_submitted" + ] + }, + "GeoJsonPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Polygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + }, + "GeoJsonMultiPolygon": { + "type": "object", + "required": [ + "type", + "coordinates" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "array", + "minItems": 4, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "number" + } + } + } + } + } + } + }, + "AoiGeometry": { + "description": "AOI accepts either a single Polygon or a MultiPolygon (EPSG:4326). Servers store both as MultiPolygon (single-Polygon inputs are upcast on insert).", + "oneOf": [ + { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + { + "$ref": "#/components/schemas/GeoJsonMultiPolygon" + } + ] + }, + "AoiFeature": { + "type": "object", + "required": [ + "type", + "geometry", + "properties" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/AoiGeometry" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "AoiFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/components/schemas/AoiFeature" + } + } + } + }, + "AoiUploadRequest": { + "oneOf": [ + { + "$ref": "#/components/schemas/AoiFeature" + }, + { + "$ref": "#/components/schemas/AoiFeatureCollection" + }, + { + "$ref": "#/components/schemas/AoiGeometry" + } + ] + }, + "TaskBoundaryFeature": { + "type": "object", + "required": [ + "type", + "geometry" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "properties": { + "type": "object", + "additionalProperties": true + } + } + }, + "TaskBoundariesFeatureCollection": { + "type": "object", + "required": [ + "type", + "features" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "features": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/TaskBoundaryFeature" + } + } + } + }, + "Project": { + "type": "object", + "required": [ + "id", + "workspaceId", + "name", + "status", + "reviewRequired", + "lockTimeoutHours", + "createdBy", + "createdAt", + "updatedAt", + "hasAoi", + "taskCount" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "instructions": { + "type": "string", + "nullable": true + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "reviewRequired": { + "type": "boolean", + "default": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/TaskBoundaryType" + }, + "hasAoi": { + "type": "boolean" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListItem": { + "type": "object", + "required": [ + "id", + "name", + "status", + "taskCount", + "percentCompleted", + "createdAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "taskCount": { + "type": "integer", + "minimum": 0 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProjectListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectListItem" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ProjectCreateRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Unique within the workspace (case-insensitive) among non-deleted projects." + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "reviewRequired": { + "type": "boolean", + "default": true, + "description": "If false, tasks transition straight to `completed` on mapper submit. Immutable after activation." + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720, + "default": 8 + }, + "aoi": { + "allOf": [ + { + "$ref": "#/components/schemas/AoiUploadRequest" + } + ], + "nullable": true, + "description": "Optional inline AOI (same validation as `POST .../aoi`)." + }, + "roleAssignments": { + "type": "array", + "description": "Optional list of project-level role assignments to seed at\ncreation. Each entry is upserted into `tasking_project_roles`\nin the same transaction as the project insert.\n\nThe creator is auto-added as `lead` on `tasking_project_roles`\nand does not need to appear here.\n\nEach `userId` must be a member of the workspace's TDEI\nproject group; entries failing this check are rejected with\n422 and no rows are written.\n", + "items": { + "$ref": "#/components/schemas/ProjectRoleAssignment" + } + } + } + }, + "ProjectRoleAssignment": { + "type": "object", + "required": [ + "userId", + "role" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectUpdateRequest": { + "type": "object", + "description": "All fields optional. Server enforces per-status mutability.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "instructions": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "lockTimeoutHours": { + "type": "integer", + "minimum": 1, + "maximum": 720 + }, + "reviewRequired": { + "type": "boolean" + } + } + }, + "TaskLockSummary": { + "type": "object", + "required": [ + "userId", + "lockedAt", + "expiresAt" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LastMapper": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "userName": { + "type": "string", + "nullable": true + } + } + }, + "Task": { + "type": "object", + "required": [ + "id", + "taskNumber", + "status", + "geometry", + "areaSqkm", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "#/components/schemas/TaskStatus" + }, + "geometry": { + "$ref": "#/components/schemas/GeoJsonPolygon" + }, + "areaSqkm": { + "type": "number" + }, + "lock": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/TaskLockSummary" + } + ] + }, + "lastMapper": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/LastMapper" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "TaskListResponse": { + "type": "object", + "required": [ + "tasks", + "pagination" + ], + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "ValidateWarning": { + "type": "object", + "required": [ + "taskIndex", + "issue" + ], + "properties": { + "taskIndex": { + "type": "integer", + "description": "0-based index into the submitted `features` array." + }, + "issue": { + "type": "string", + "enum": [ + "polygon_exceeds_grid_size" + ] + }, + "areaSqkm": { + "type": "number" + } + } + }, + "ValidatePreviewResponse": { + "type": "object", + "required": [ + "valid", + "warnings", + "source", + "featureCollection" + ], + "properties": { + "valid": { + "type": "boolean", + "description": "True when no hard failures. Warnings do not affect this." + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidateWarning" + } + }, + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Always `import` for this response. Pass through unchanged to `/save`." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksRequest": { + "type": "object", + "required": [ + "source", + "featureCollection" + ], + "properties": { + "source": { + "$ref": "#/components/schemas/GridSource", + "description": "Sets `tasking_projects.task_boundary_type` for the project." + }, + "featureCollection": { + "$ref": "#/components/schemas/TaskBoundariesFeatureCollection" + } + } + }, + "SaveTasksResponse": { + "type": "object", + "required": [ + "projectId", + "taskBoundaryType", + "taskCount", + "tasks" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "taskBoundaryType": { + "$ref": "#/components/schemas/GridSource" + }, + "taskCount": { + "type": "integer", + "minimum": 1, + "description": "Number of tasks created (== `featureCollection.features.length`)." + }, + "tasks": { + "type": "array", + "description": "All created tasks in the same order as the request's `features[]`. `taskNumber` is sequential starting at 1.", + "items": { + "$ref": "#/components/schemas/Task" + } + }, + "idempotencyKey": { + "type": "string", + "nullable": true + }, + "replayed": { + "type": "boolean", + "default": false, + "description": "True on idempotent replay (HTTP 200 case)." + } + } + }, + "Feedback": { + "type": "object", + "description": "Feedback payload \u2014 persisted to `tasking_feedback`. Used by\n`POST /submit` to attach a validator's remap-rejection note,\nand reusable by any future endpoint that records free-form\ntask feedback.\n", + "required": [ + "notes" + ], + "properties": { + "reasonCategory": { + "allOf": [ + { + "$ref": "#/components/schemas/FeedbackReason" + } + ], + "nullable": true, + "description": "Required when the feedback drives `to_review \u2192 to_remap`; optional otherwise." + }, + "notes": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + } + }, + "SubmitRequest": { + "type": "object", + "required": [ + "osmChangesetId", + "done" + ], + "properties": { + "osmChangesetId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "done": { + "type": "boolean", + "description": "`true` \u2014 auto-unlocks and transitions state per the table.\n`false` \u2014 records the changeset, state unchanged, lock\n`expires_at` slides forward to `submitted_at +\nlock_timeout_hours`.\n" + }, + "feedback": { + "allOf": [ + { + "$ref": "#/components/schemas/Feedback" + } + ], + "description": "Meaningful only in validator context on `to_review`. When\nprovided here, `feedback.reasonCategory` is required (drives\nthe `to_remap` transition) and the row is persisted to\n`tasking_feedback`.\n" + } + } + }, + "ExistingLockSummary": { + "type": "object", + "required": [ + "taskNumber", + "taskStatus", + "lockedAt", + "expiresAt" + ], + "properties": { + "taskNumber": { + "type": "integer", + "minimum": 1 + }, + "taskStatus": { + "$ref": "#/components/schemas/TaskStatus" + }, + "lockedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "LockConflictError": { + "allOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "object", + "properties": { + "existingLock": { + "$ref": "#/components/schemas/ExistingLockSummary" + } + } + } + ] + }, + "UserSummary": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "AssignableRoleType": { + "type": "string", + "enum": [ + "lead", + "validator" + ], + "description": "Roles assignable via `PUT /workspaces/{wid}/users/{uid}/role`.\nCONTRIBUTOR is implicit (project-group membership grants it\nautomatically) and cannot be set by this endpoint.\n" + }, + "SetRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "$ref": "#/components/schemas/AssignableRoleType" + } + } + }, + "WorkspaceUserRoleItem": { + "type": "object", + "description": "Privileged-member DTO returned by `GET /workspaces/{wid}/users`.\nMirrors the shipped `WorkspaceUserRoleItem` from\n`api/src/users/schemas.py` on the `roles` branch.\n", + "required": [ + "id", + "auth_uid", + "email", + "display_name", + "role" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "Internal OSM-DB `users.id`." + }, + "auth_uid": { + "type": "string", + "description": "TDEI auth user uuid as string (`users.auth_uid`)." + }, + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleEntry": { + "type": "object", + "required": [ + "user", + "role" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/UserSummary" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleListResponse": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectRoleEntry" + } + } + } + }, + "SelfProjectRolesItem": { + "type": "object", + "required": [ + "projectId", + "projectName", + "role" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "projectName": { + "type": "string" + }, + "projectStatus": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRolesResponse": { + "type": "object", + "required": [ + "workspaceRole", + "projects" + ], + "properties": { + "workspaceRole": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SelfProjectRolesItem" + } + } + } + }, + "ActorRef": { + "type": "object", + "required": [ + "userId" + ], + "properties": { + "userId": { + "type": "string", + "format": "uuid" + }, + "displayName": { + "type": "string", + "nullable": true + } + } + }, + "AuditEvent": { + "type": "object", + "required": [ + "id", + "eventType", + "projectId", + "actor", + "occurredAt", + "details" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "eventType": { + "$ref": "#/components/schemas/AuditEventType" + }, + "projectId": { + "type": "integer", + "format": "int64" + }, + "taskId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "taskNumber": { + "type": "integer", + "nullable": true, + "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." + }, + "actor": { + "$ref": "#/components/schemas/ActorRef" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "projectDeleted": { + "type": "boolean", + "default": false + } + } + }, + "AuditEventListResponse": { + "type": "object", + "required": [ + "results", + "pagination" + ], + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEvent" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "TaskStatusCounts": { + "type": "object", + "required": [ + "to_map", + "to_review", + "to_remap", + "completed" + ], + "properties": { + "to_map": { + "type": "integer", + "minimum": 0 + }, + "to_review": { + "type": "integer", + "minimum": 0 + }, + "to_remap": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } + } + }, + "ProjectStats": { + "type": "object", + "required": [ + "projectId", + "totalTasks", + "tasksByStatus", + "percentMapped", + "percentCompleted", + "uniqueMappers", + "uniqueValidators" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "totalTasks": { + "type": "integer", + "minimum": 0 + }, + "tasksByStatus": { + "$ref": "#/components/schemas/TaskStatusCounts" + }, + "percentMapped": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "percentCompleted": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "avgTaskDurationMinutes": { + "type": "number", + "nullable": true + }, + "uniqueMappers": { + "type": "integer", + "minimum": 0 + }, + "uniqueValidators": { + "type": "integer", + "minimum": 0 + } + } + }, + "UserStatsTotals": { + "type": "object", + "required": [ + "tasksMapped", + "tasksValidated", + "totalMappingMinutes", + "totalValidationMinutes", + "totalChangesets", + "totalAreaSqkm" + ], + "properties": { + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalMappingMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalValidationMinutes": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStatsByProject": { + "type": "object", + "required": [ + "projectId", + "projectName", + "tasksMapped", + "tasksValidated" + ], + "properties": { + "projectId": { + "type": "integer", + "format": "int64" + }, + "projectName": { + "type": "string" + }, + "tasksMapped": { + "type": "integer", + "minimum": 0 + }, + "tasksValidated": { + "type": "integer", + "minimum": 0 + }, + "totalChangesets": { + "type": "integer", + "minimum": 0 + }, + "totalAreaSqkm": { + "type": "number", + "minimum": 0 + } + } + }, + "UserStats": { + "type": "object", + "required": [ + "workspaceId", + "userId", + "totals", + "byProject" + ], + "properties": { + "workspaceId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "string", + "format": "uuid" + }, + "totals": { + "$ref": "#/components/schemas/UserStatsTotals" + }, + "byProject": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserStatsByProject" + } + } + } + }, + "Pagination": { + "type": "object", + "required": [ + "page", + "pageSize", + "total" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1 + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, + "Error": { + "type": "object", + "required": [ + "detail" + ], + "properties": { + "detail": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + ], + "description": "FastAPI default error shape \u2014 string for raised `HTTPException`, structured list for pydantic validation errors." + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Malformed JSON or schema-level failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid bearer token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ForbiddenLeadRequired": { + "description": "Caller is a workspace contributor but does not hold LEAD.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "WorkspaceNotFound": { + "description": "Workspace does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "ProjectOrWorkspaceNotFound": { + "description": "Workspace or project does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "TaskOrProjectOrWorkspaceNotFound": { + "description": "Workspace, project, or task does not exist, or is outside the caller's tenancy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "UnprocessableEntity": { + "description": "Well-formed request that violates a business rule.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} \ No newline at end of file From 07b3284c00ec95b774c2c6960d083163cb410bd3 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Wed, 20 May 2026 12:50:20 -0700 Subject: [PATCH 051/159] Fix user_auth_uid type in user_role migration The type of the `user_auth_uid` column was modified in 80156fc to a UUID which breaks the foreign key reference. The original VARCHAR type was correct. --- alembic_osm/versions/9221408912dd_add_user_role_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 3963de9..d34f97e 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -45,7 +45,7 @@ def upgrade() -> None: if not insp.has_table("user_workspace_roles"): op.create_table( "user_workspace_roles", - sa.Column("user_auth_uid", sa.Uuid(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), sa.Column("workspace_id", sa.BigInteger(), nullable=False), sa.Column( "role", From 6b4f7fd0dd637056974765708bc1c805f715c233 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 22 May 2026 22:28:47 -0700 Subject: [PATCH 052/159] Move OSM repo out of workspaces directory --- api/src/osm/__init__.py | 0 api/src/osm/repository.py | 36 ++++++++++++++++++++++++++++++++ api/src/workspaces/repository.py | 34 +----------------------------- api/src/workspaces/routes.py | 13 ++++-------- api/src/workspaces/schemas.py | 1 + 5 files changed, 42 insertions(+), 42 deletions(-) create mode 100644 api/src/osm/__init__.py create mode 100644 api/src/osm/repository.py diff --git a/api/src/osm/__init__.py b/api/src/osm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py new file mode 100644 index 0000000..e888531 --- /dev/null +++ b/api/src/osm/repository.py @@ -0,0 +1,36 @@ +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException + + +class OSMRepository: + + def __init__(self, session: AsyncSession): + self.session = session + + async def getWorkspaceBBox( + self, + workspace_id: int, + ): + # Postgres does not support parameter binding for `SET search_path`, so + # workspace_id is interpolated directly. The explicit int() cast guards + # against SQL injection if this method is ever called from outside of a + # FastAPI path handler (where the type annotation acts as a safeguard). + # + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(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/repository.py b/api/src/workspaces/repository.py index 8c1c87c..d2c2c4e 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,4 +1,4 @@ -from sqlalchemy import delete, select, text, update +from sqlalchemy import delete, select, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -192,35 +192,3 @@ 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, - workspace_id: int, - ): - # Postgres does not support parameter binding for `SET search_path`, so - # workspace_id is interpolated directly. The explicit int() cast guards - # against SQL injection if this method is ever called from outside of a - # FastAPI path handler (where the type annotation acts as a safeguard). - # - await self.session.execute( - text(f"SET search_path TO 'workspace-{int(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 2ba802f..405313d 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -12,9 +12,11 @@ ) from api.core.logging import get_logger from api.core.security import UserInfo, evict_user_from_cache, validate_token +from api.src.osm.repository import OSMRepository +from api.src.osm.routes import get_osm_repo 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.repository import WorkspaceRepository from api.src.workspaces.schemas import ( ImagerySettingsPatch, QuestDefinitionTypeName, @@ -40,13 +42,6 @@ def get_workspace_repository( return repository -def get_osm_repository( - session: AsyncSession = Depends(get_osm_session), -) -> OSMRepository: - repository = OSMRepository(session) - return repository - - def get_user_repository( session: AsyncSession = Depends(get_osm_session), ) -> UserRepository: @@ -103,7 +98,7 @@ async def get_workspace( async def get_workspace_bbox( workspace_id: int, repository_ws: WorkspaceRepository = Depends(get_workspace_repository), - repository_osm: OSMRepository = Depends(get_osm_repository), + repository_osm: OSMRepository = Depends(get_osm_repo), current_user: UserInfo = Depends(validate_token), ): try: diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index fa86ce6..766f032 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -296,3 +296,4 @@ class Workspace(SQLModel, table=True): "cascade": "all, delete-orphan", } ) + From e36d542393352389c1c91439db64288bb93a01c4 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Fri, 22 May 2026 23:37:59 -0700 Subject: [PATCH 053/159] Add SQL-based augmented diff generator --- alembic_osm/sql/osm_augmented_diff.sql | 650 ++++++++++++++++++ ...61d7b3a_add_osm_augmented_diff_function.py | 29 + api/main.py | 2 + api/src/osm/repository.py | 15 + api/src/osm/routes.py | 49 ++ api/src/osm/schemas.py | 74 ++ 6 files changed, 819 insertions(+) create mode 100644 alembic_osm/sql/osm_augmented_diff.sql create mode 100644 alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py create mode 100644 api/src/osm/routes.py create mode 100644 api/src/osm/schemas.py diff --git a/alembic_osm/sql/osm_augmented_diff.sql b/alembic_osm/sql/osm_augmented_diff.sql new file mode 100644 index 0000000..51733f0 --- /dev/null +++ b/alembic_osm/sql/osm_augmented_diff.sql @@ -0,0 +1,650 @@ +-- osm_augmented_diff(p_changeset_id bigint) -> table of adiff rows +-- +-- Computes a complete augmented diff for a changeset like those produced by +-- the Overpass API: +-- +-- https://wiki.openstreetmap.org/wiki/Overpass_API/Augmented_Diffs +-- +-- Each returned row represents one action. Callers convert rows to whatever +-- output format they need (JSON, XML, etc.). +-- + +CREATE OR REPLACE FUNCTION osm_augmented_diff(p_changeset_id bigint) +RETURNS TABLE ( + action_type text, -- 'create' | 'modify' | 'delete' + element_type text, -- 'node' | 'way' | 'relation' + -- new element (always populated) + new_id bigint, + new_version bigint, + new_changeset_id bigint, + new_timestamp timestamp without time zone, + new_visible boolean, + new_user text, + new_uid bigint, + new_lat double precision, -- nodes only + new_lon double precision, -- nodes only + new_tags jsonb, + new_nodes jsonb, -- ways only: [{ref,lat,lon}] + new_members jsonb, -- relations only: [{type,ref,role}] + -- old element (all NULL for create actions) + old_id bigint, + old_version bigint, + old_changeset_id bigint, + old_timestamp timestamp without time zone, + old_visible boolean, + old_user text, + old_uid bigint, + old_lat double precision, + old_lon double precision, + old_tags jsonb, + old_nodes jsonb, + old_members jsonb +) +LANGUAGE sql +STABLE +AS $$ +WITH + +-- Nodes in this changeset +cs_node_base AS ( + SELECT n.node_id AS id, + n.version, + n.changeset_id, + n.timestamp, + n.visible, + n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon, + CASE + WHEN n.version = 1 THEN 'create' + WHEN NOT n.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM nodes n + WHERE n.changeset_id = p_changeset_id + AND n.redaction_id IS NULL +), + +cs_nodes AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM node_tags t + WHERE t.node_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + usr.uid, + usr.username + FROM cs_node_base b + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous node versions (for modify/delete) +prev_nodes AS ( + SELECT n.node_id AS id, + n.version, + n.changeset_id, + n.timestamp, + n.visible, + n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM node_tags t + WHERE t.node_id = n.node_id + AND t.version = n.version + ), + '{}'::jsonb + ) AS tags, + usr.uid, + usr.username + FROM cs_nodes csn + JOIN nodes n + ON n.node_id = csn.id + AND n.version = csn.version - 1 + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = n.changeset_id + LIMIT 1 + ) usr ON true + WHERE csn.version > 1 + AND n.redaction_id IS NULL +), + +-- Ways in this changeset +cs_way_base AS ( + SELECT w.way_id AS id, + w.version, + w.changeset_id, + w.timestamp, + w.visible, + CASE + WHEN w.version = 1 THEN 'create' + WHEN NOT w.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM ways w + WHERE w.changeset_id = p_changeset_id + AND w.redaction_id IS NULL +), + +-- Resolve each node ref to its coords as of this changeset (latest version ≤ $1) +cs_way_nodes AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN cs_way_base csw + ON csw.id = wn.way_id + AND csw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id <= p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +cs_ways AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(wn.nodes, '[]'::jsonb) AS nodes, + usr.uid, + usr.username + FROM cs_way_base b + LEFT JOIN cs_way_nodes wn + ON wn.way_id = b.id + AND wn.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous way versions (for modify/delete) +prev_way_base AS ( + SELECT w.way_id AS id, + w.version, + w.changeset_id, + w.timestamp, + w.visible + FROM cs_ways csw + JOIN ways w + ON w.way_id = csw.id + AND w.version = csw.version - 1 + WHERE csw.version > 1 + AND w.redaction_id IS NULL +), + +-- Use changeset_id < p_changeset_id (strictly before) so coords reflect the +-- state before any node moves in this changeset +prev_way_nodes AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN prev_way_base pw + ON pw.id = wn.way_id + AND pw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id < p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +prev_ways AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(wn.nodes, '[]'::jsonb) AS nodes, + usr.uid, + usr.username + FROM prev_way_base b + LEFT JOIN prev_way_nodes wn + ON wn.way_id = b.id + AND wn.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Relations in this changeset +cs_rel_base AS ( + SELECT r.relation_id AS id, + r.version, + r.changeset_id, + r.timestamp, + r.visible, + CASE + WHEN r.version = 1 THEN 'create' + WHEN NOT r.visible THEN 'delete' + ELSE 'modify' + END AS action + FROM relations r + WHERE r.changeset_id = p_changeset_id + AND r.redaction_id IS NULL +), + +cs_rel_members AS ( + SELECT rm.relation_id AS id, + rm.version, + jsonb_agg( + jsonb_build_object( + 'type', lower(rm.member_type::text), + 'ref', rm.member_id, + 'role', rm.member_role + ) + ORDER BY rm.sequence_id + ) AS members + FROM relation_members rm + JOIN cs_rel_base cr + ON cr.id = rm.relation_id + AND cr.version = rm.version + GROUP BY rm.relation_id, rm.version +), + +cs_relations AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM relation_tags t + WHERE t.relation_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(m.members, '[]'::jsonb) AS members, + usr.uid, + usr.username + FROM cs_rel_base b + LEFT JOIN cs_rel_members m + ON m.id = b.id + AND m.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Previous relation versions (for modify/delete) +prev_rel_base AS ( + SELECT r.relation_id AS id, + r.version, + r.changeset_id, + r.timestamp, + r.visible + FROM cs_relations csr + JOIN relations r + ON r.relation_id = csr.id + AND r.version = csr.version - 1 + WHERE csr.version > 1 + AND r.redaction_id IS NULL +), + +prev_rel_members AS ( + SELECT rm.relation_id AS id, + rm.version, + jsonb_agg( + jsonb_build_object( + 'type', lower(rm.member_type::text), + 'ref', rm.member_id, + 'role', rm.member_role + ) + ORDER BY rm.sequence_id + ) AS members + FROM relation_members rm + JOIN prev_rel_base pr + ON pr.id = rm.relation_id + AND pr.version = rm.version + GROUP BY rm.relation_id, rm.version +), + +prev_relations AS ( + SELECT b.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM relation_tags t + WHERE t.relation_id = b.id + AND t.version = b.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(m.members, '[]'::jsonb) AS members, + usr.uid, + usr.username + FROM prev_rel_base b + LEFT JOIN prev_rel_members m + ON m.id = b.id + AND m.version = b.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = b.changeset_id + LIMIT 1 + ) usr ON true +), + +-- Ways affected by node geometry changes: +-- +-- When a node moves, every way containing it has an implicit geometry change +-- even if the way record was untouched. We emit a 'modify' row for each such +-- way so the diff viewer can render the shape change. +-- Ways already explicitly in this changeset are excluded. +affected_way_ids AS ( + SELECT DISTINCT cwn.way_id AS id + FROM current_way_nodes cwn + WHERE cwn.node_id IN ( + SELECT id + FROM cs_nodes + WHERE action IN ('modify', 'delete') + ) + AND cwn.way_id NOT IN ( + SELECT id + FROM cs_way_base + ) +), + +-- Resolve each affected way to the version it was at when this changeset ran +affected_way_versions AS ( + SELECT awi.id, + w.version, + w.changeset_id, + w.timestamp, + w.visible + FROM affected_way_ids awi + JOIN LATERAL ( + SELECT w2.version, + w2.changeset_id, + w2.timestamp, + w2.visible + FROM ways w2 + WHERE w2.way_id = awi.id + AND w2.changeset_id <= p_changeset_id + AND w2.redaction_id IS NULL + ORDER BY w2.version DESC + LIMIT 1 + ) w ON true +), + +-- "new" node coords: after this changeset's node edits (<=) +affected_nodes_new AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN affected_way_versions aw + ON aw.id = wn.way_id + AND aw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id <= p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +-- "old" node coords: before this changeset's node edits (<) +affected_nodes_old AS ( + SELECT wn.way_id, + wn.version, + jsonb_agg( + jsonb_build_object('ref', wn.node_id, 'lat', nc.lat, 'lon', nc.lon) + ORDER BY wn.sequence_id + ) AS nodes + FROM way_nodes wn + JOIN affected_way_versions aw + ON aw.id = wn.way_id + AND aw.version = wn.version + LEFT JOIN LATERAL ( + SELECT n.latitude / 10000000.0 AS lat, + n.longitude / 10000000.0 AS lon + FROM nodes n + WHERE n.node_id = wn.node_id + AND n.changeset_id < p_changeset_id + AND n.redaction_id IS NULL + ORDER BY n.version DESC + LIMIT 1 + ) nc ON true + GROUP BY wn.way_id, wn.version +), + +affected_ways AS ( + SELECT aw.*, + COALESCE( + ( + SELECT jsonb_object_agg(k, v) + FROM way_tags t + WHERE t.way_id = aw.id + AND t.version = aw.version + ), + '{}'::jsonb + ) AS tags, + COALESCE(anew.nodes, '[]'::jsonb) AS nodes_new, + COALESCE(aold.nodes, '[]'::jsonb) AS nodes_old, + usr.uid, + usr.username + FROM affected_way_versions aw + LEFT JOIN affected_nodes_new anew + ON anew.way_id = aw.id + AND anew.version = aw.version + LEFT JOIN affected_nodes_old aold + ON aold.way_id = aw.id + AND aold.version = aw.version + LEFT JOIN LATERAL ( + SELECT u.id AS uid, + u.display_name AS username + FROM changesets c + JOIN users u + ON u.id = c.user_id + WHERE c.id = aw.changeset_id + LIMIT 1 + ) usr ON true +) + +-- Emit one row per action +SELECT csn.action, + 'node', + csn.id, + csn.version, + csn.changeset_id, + csn.timestamp, + csn.visible, + csn.username, + csn.uid, + csn.lat, + csn.lon, + csn.tags, + NULL::jsonb, + NULL::jsonb, + pn.id, + pn.version, + pn.changeset_id, + pn.timestamp, + pn.visible, + pn.username, + pn.uid, + pn.lat, + pn.lon, + pn.tags, + NULL::jsonb, + NULL::jsonb + FROM cs_nodes csn + LEFT JOIN prev_nodes pn + ON pn.id = csn.id + AND pn.version = csn.version - 1 + +UNION ALL + +SELECT csw.action, + 'way', + csw.id, + csw.version, + csw.changeset_id, + csw.timestamp, + csw.visible, + csw.username, + csw.uid, + NULL::double precision, + NULL::double precision, + csw.tags, + csw.nodes, + NULL::jsonb, + pw.id, + pw.version, + pw.changeset_id, + pw.timestamp, + pw.visible, + pw.username, + pw.uid, + NULL::double precision, + NULL::double precision, + pw.tags, + pw.nodes, + NULL::jsonb + FROM cs_ways csw + LEFT JOIN prev_ways pw + ON pw.id = csw.id + AND pw.version = csw.version - 1 + +UNION ALL + +SELECT csr.action, + 'relation', + csr.id, + csr.version, + csr.changeset_id, + csr.timestamp, + csr.visible, + csr.username, + csr.uid, + NULL::double precision, + NULL::double precision, + csr.tags, + NULL::jsonb, + csr.members, + pr.id, + pr.version, + pr.changeset_id, + pr.timestamp, + pr.visible, + pr.username, + pr.uid, + NULL::double precision, + NULL::double precision, + pr.tags, + NULL::jsonb, + pr.members + FROM cs_relations csr + LEFT JOIN prev_relations pr + ON pr.id = csr.id + AND pr.version = csr.version - 1 + +UNION ALL + +-- Implicit way modifications from node moves: new and old share way metadata; +-- only the node coordinate arrays differ. +SELECT 'modify', + 'way', + aw.id, + aw.version, + aw.changeset_id, + aw.timestamp, + aw.visible, + aw.username, + aw.uid, + NULL::double precision, + NULL::double precision, + aw.tags, + aw.nodes_new, + NULL::jsonb, + aw.id, + aw.version, + aw.changeset_id, + aw.timestamp, + aw.visible, + aw.username, + aw.uid, + NULL::double precision, + NULL::double precision, + aw.tags, + aw.nodes_old, + NULL::jsonb + FROM affected_ways aw; +$$; diff --git a/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py new file mode 100644 index 0000000..c55a7a0 --- /dev/null +++ b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py @@ -0,0 +1,29 @@ +"""add osm_augmented_diff function + +Revision ID: 5303f61d7b3a +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-17 00:00:00.000000 + +""" + +import pathlib +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "5303f61d7b3a" +down_revision: Union[str, None] = "9221408912dd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_SQL_DIR = pathlib.Path(__file__).parent.parent / "sql" + + +def upgrade() -> None: + op.execute(text((_SQL_DIR / "osm_augmented_diff.sql").read_text())) + + +def downgrade() -> None: + op.execute(text("DROP FUNCTION IF EXISTS osm_augmented_diff(bigint)")) diff --git a/api/main.py b/api/main.py index dd82fcc..381076a 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,7 @@ init_tdei_client, validate_token, ) +from api.src.osm.routes import router as osm_router from api.src.teams.routes import router as teams_router from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository @@ -88,6 +89,7 @@ async def lifespan(_app: FastAPI): ) # Include routers +app.include_router(osm_router, prefix="/api/v1") app.include_router(teams_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index e888531..f57fcdc 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -34,3 +34,18 @@ async def getWorkspaceBBox( raise NotFoundException(f"Workspace with id {workspace_id} not found") return retVal + + async def getChangesetAdiff( + self, + workspace_id: int, + changeset_id: int + ) -> list: + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + result = await self.session.execute( + text("SELECT * FROM osm_augmented_diff(:changeset_id)"), + {"changeset_id": changeset_id}, + ) + + return result.mappings().all() diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py new file mode 100644 index 0000000..ca0ff2f --- /dev/null +++ b/api/src/osm/routes.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.logging import get_logger +from api.core.security import UserInfo, validate_token +from api.src.osm.repository import OSMRepository +from api.src.osm.schemas import AugmentedDiffResponse +from api.src.workspaces.repository import WorkspaceRepository + +logger = get_logger(__name__) + +router = APIRouter(prefix="/workspaces", tags=["osm"]) + + +def get_osm_repo( + session: AsyncSession = Depends(get_osm_session), +) -> OSMRepository: + return OSMRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +@router.get( + "/{workspace_id}/changesets/{changeset_id}/adiff", + response_model=AugmentedDiffResponse, +) +async def get_changeset_adiff( + workspace_id: int, + changeset_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repo), + repository_osm: OSMRepository = Depends(get_osm_repo), + current_user: UserInfo = Depends(validate_token), +) -> AugmentedDiffResponse: + try: + await repository_ws.getById(current_user, workspace_id) + rows = await repository_osm.getChangesetAdiff(workspace_id, changeset_id) + + return AugmentedDiffResponse.from_rows(rows) + except Exception as e: + logger.error( + f"Failed to fetch adiff for changeset {changeset_id} " + f"in workspace {workspace_id}: {str(e)}" + ) + raise diff --git a/api/src/osm/schemas.py b/api/src/osm/schemas.py new file mode 100644 index 0000000..04e46c7 --- /dev/null +++ b/api/src/osm/schemas.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel + + +class AdiffElement(BaseModel): + """A particular OSM element version in an augmented diff action.""" + + type: str # 'node' | 'way' | 'relation' + id: int + version: int + changeset: int + timestamp: datetime + user: Optional[str] = None + uid: Optional[int] = None + visible: bool + tags: dict[str, str] + # node-only: + lat: Optional[float] = None + lon: Optional[float] = None + # way-only: [{ref, lat, lon}] + nodes: Optional[list[dict[str, Any]]] = None + # relation-only: [{type, ref, role}] + members: Optional[list[dict[str, Any]]] = None + + +class AdiffAction(BaseModel): + type: str # 'create' | 'modify' | 'delete' + new: AdiffElement + old: Optional[AdiffElement] = None + + +class AugmentedDiffResponse(BaseModel): + actions: list[AdiffAction] + + @classmethod + def from_rows(cls, rows: list) -> "AugmentedDiffResponse": + def make_element(row: Any, prefix: str) -> Optional[AdiffElement]: + if row[f"{prefix}_id"] is None: + return None + + return AdiffElement( + type=row["element_type"], + id=row[f"{prefix}_id"], + version=row[f"{prefix}_version"], + changeset=row[f"{prefix}_changeset_id"], + timestamp=row[f"{prefix}_timestamp"], + user=row[f"{prefix}_user"], + uid=row[f"{prefix}_uid"], + visible=row[f"{prefix}_visible"], + tags=row[f"{prefix}_tags"] or {}, + lat=row[f"{prefix}_lat"], + lon=row[f"{prefix}_lon"], + nodes=row[f"{prefix}_nodes"], + members=row[f"{prefix}_members"], + ) + + def require_new(row: Any) -> AdiffElement: + element = make_element(row, "new") + if element is None: + raise ValueError(f"adiff row missing new_id: {dict(row)}") + return element + + actions = [ + AdiffAction( + type=row["action_type"], + new=require_new(row), + old=make_element(row, "old"), + ) + for row in rows + ] + + return cls(actions=actions) From 1625f36adfa7c937f57f236af628f1f87f73eccc Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 25 May 2026 00:20:33 +0530 Subject: [PATCH 054/159] project, aoi, tasks, lock apis, unit & integration tests --- .gitignore | 7 +- .vscode/extensions.json | 6 + .../a1b2c3d4e5f6_tasking_mvp_schema.py | 564 +++++++++ .../c5121cbba124_initial_task_schema.py | 112 ++ api/src/tasking/__init__.py | 0 api/src/tasking/projects/__init__.py | 0 api/src/tasking/projects/dtos.py | 144 +++ api/src/tasking/projects/repository.py | 764 ++++++++++++ api/src/tasking/projects/routes.py | 234 ++++ api/src/tasking/projects/schemas.py | 142 +++ api/src/tasking/tasks/__init__.py | 0 api/src/tasking/tasks/dtos.py | 145 +++ api/src/tasking/tasks/repository.py | 1101 +++++++++++++++++ api/src/tasking/tasks/routes.py | 259 ++++ api/src/tasking/tasks/schemas.py | 197 +++ tests/conftest.py | 242 ++++ tests/integration/__init__.py | 0 tests/integration/conftest.py | 510 ++++++++ tests/integration/test_projects_flow.py | 322 +++++ tests/integration/test_tasks_flow.py | 937 ++++++++++++++ tests/unit/__init__.py | 0 tests/unit/conftest.py | 286 +++++ tests/unit/test_aoi_normalisation.py | 74 ++ tests/unit/test_dtos_validation.py | 99 ++ tests/unit/test_project_routes.py | 244 ++++ tests/unit/test_user_info_gates.py | 85 ++ 26 files changed, 6473 insertions(+), 1 deletion(-) create mode 100644 .vscode/extensions.json create mode 100644 alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py create mode 100644 alembic_task/versions/c5121cbba124_initial_task_schema.py create mode 100644 api/src/tasking/__init__.py create mode 100644 api/src/tasking/projects/__init__.py create mode 100644 api/src/tasking/projects/dtos.py create mode 100644 api/src/tasking/projects/repository.py create mode 100644 api/src/tasking/projects/routes.py create mode 100644 api/src/tasking/projects/schemas.py create mode 100644 api/src/tasking/tasks/__init__.py create mode 100644 api/src/tasking/tasks/dtos.py create mode 100644 api/src/tasking/tasks/repository.py create mode 100644 api/src/tasking/tasks/routes.py create mode 100644 api/src/tasking/tasks/schemas.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_projects_flow.py create mode 100644 tests/integration/test_tasks_flow.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_aoi_normalisation.py create mode 100644 tests/unit/test_dtos_validation.py create mode 100644 tests/unit/test_project_routes.py create mode 100644 tests/unit/test_user_info_gates.py diff --git a/.gitignore b/.gitignore index 42694c4..cf7489e 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,9 @@ alembic/versions/.DS_Store /workspaces-openstreetmap-website pg_user_cache.sqlite -.env** \ No newline at end of file +.env** +integration-report.html +docs/tasking-mvp/tasking-mvp.postman_collection.json +docs/tasking-mvp/tasking-mvp.postman_environment.json +docs/tasking-mvp/_enrich_postman.py +docs/tasking-mvp/feature-coverage.md diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..778fd59 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] +} diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py new file mode 100644 index 0000000..8606ce9 --- /dev/null +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -0,0 +1,564 @@ + + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "9221408912dd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Enum names + values, declared once and reused on up/down. +TASKING_PROJECT_STATUS = ("draft", "open", "done") +TASKING_TASK_STATUS = ("to_map", "to_review", "to_remap", "completed") +TASKING_TASK_BOUNDARY_TYPE = ("grid", "import") +TASKING_LOCK_RELEASE_REASON = ( + "auto_unlock", + "manual", + "lead_release", + "stale_timeout", + "reset", +) +TASKING_FEEDBACK_REASON = ( + "incomplete_mapping", + "data_quality_issue", + "wrong_area", + "other", +) + + +def _create_enum_if_absent(bind, name: str, values: tuple[str, ...]) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if not exists: + sa.Enum(*values, name=name).create(bind) + + +def _drop_enum_if_present(bind, name: str) -> None: + exists = bind.execute( + text("SELECT 1 FROM pg_type WHERE typname = :n"), {"n": name} + ).scalar() + if exists: + bind.execute(text(f'DROP TYPE IF EXISTS "{name}"')) + + +def _postgis_available(bind) -> bool: + return bool( + bind.execute( + text("SELECT 1 FROM pg_available_extensions WHERE name = 'postgis'") + ).scalar() + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + use_postgis = _postgis_available(bind) + if use_postgis: + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + + # ---- teams / team_user ------------------------------------------- + # + # Created here so the OSM tree owns every table that references + # `users.id`. The `has_table` guards keep this idempotent in both + # production (shared TASK/OSM database) and fresh test installs. + + if not insp.has_table("teams"): + op.create_table( + "teams", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_teams_workspace_id", "teams", ["workspace_id"]) + + if not insp.has_table("team_user"): + op.create_table( + "team_user", + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["team_id"], ["teams.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("team_id", "user_id"), + ) + + # ---- Enums -------------------------------------------------------- + + _create_enum_if_absent(bind, "tasking_project_status", TASKING_PROJECT_STATUS) + _create_enum_if_absent(bind, "tasking_task_status", TASKING_TASK_STATUS) + _create_enum_if_absent( + bind, "tasking_task_boundary_type", TASKING_TASK_BOUNDARY_TYPE + ) + _create_enum_if_absent( + bind, "tasking_lock_release_reason", TASKING_LOCK_RELEASE_REASON + ) + _create_enum_if_absent(bind, "tasking_feedback_reason", TASKING_FEEDBACK_REASON) + + # ---- tasking_projects -------------------------------------------- + + if not insp.has_table("tasking_projects"): + op.create_table( + "tasking_projects", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + # Cross-DB ref to workspaces.id — no FK by design (matches + # user_workspace_roles convention). + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("instructions", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_PROJECT_STATUS, + name="tasking_project_status", + create_type=False, + ), + nullable=False, + server_default="draft", + ), + sa.Column( + "review_required", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ), + sa.Column( + "lock_timeout_hours", + sa.Integer(), + nullable=False, + server_default="8", + ), + sa.Column( + "task_boundary_type", + postgresql.ENUM( + *TASKING_TASK_BOUNDARY_TYPE, + name="tasking_task_boundary_type", + create_type=False, + ), + nullable=True, + ), + # AOI is MultiPolygon in EPSG:4326. Polygon inputs are + # upcast to single-member MultiPolygons in the app layer. + sa.Column( + "aoi", + sa.dialects.postgresql.BYTEA(), # placeholder; replaced below + nullable=True, + ), + sa.Column("created_by", sa.Uuid(), nullable=False), + sa.Column("created_by_name", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + if use_postgis: + op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") + op.execute( + "ALTER TABLE tasking_projects " + "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" + ) + + # Unique project name per workspace among non-deleted rows. + op.execute( + "CREATE UNIQUE INDEX tasking_projects_workspace_name_unique " + "ON tasking_projects (workspace_id, lower(name)) " + "WHERE deleted_at IS NULL" + ) + + op.create_index( + "tasking_projects_workspace_idx", + "tasking_projects", + ["workspace_id"], + ) + op.create_index( + "tasking_projects_status_idx", + "tasking_projects", + ["status"], + ) + + # ---- tasking_project_roles --------------------------------------- + + if not insp.has_table("tasking_project_roles"): + op.create_table( + "tasking_project_roles", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "role", + postgresql.ENUM( + "lead", + "validator", + "contributor", + name="workspace_role", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("project_id", "user_auth_uid"), + ) + + # ---- tasking_tasks ------------------------------------------------ + + if not insp.has_table("tasking_tasks"): + op.create_table( + "tasking_tasks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_number", sa.Integer(), nullable=False), + sa.Column( + "area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False + ), + sa.Column( + "status", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + server_default="to_map", + ), + sa.Column("last_mapper_id", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.UniqueConstraint( + "project_id", "task_number", name="tasking_tasks_pn_unique" + ), + ) + if use_postgis: + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry GEOMETRY(Polygon, 4326) NOT NULL" + ) + op.execute( + "CREATE INDEX tasking_tasks_geometry_idx " + "ON tasking_tasks USING GIST (geometry)" + ) + else: + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry BYTEA" + ) + op.create_index( + "tasking_tasks_project_idx", "tasking_tasks", ["project_id"] + ) + + # ---- tasking_locks ------------------------------------------------ + + if not insp.has_table("tasking_locks"): + op.create_table( + "tasking_locks", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column( + "task_status_at_lock", + postgresql.ENUM( + *TASKING_TASK_STATUS, + name="tasking_task_status", + create_type=False, + ), + nullable=False, + ), + sa.Column( + "locked_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("released_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "release_reason", + postgresql.ENUM( + *TASKING_LOCK_RELEASE_REASON, + name="tasking_lock_release_reason", + create_type=False, + ), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + # One active lock per task; one active lock per (project, user). + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_task " + "ON tasking_locks (task_id) WHERE released_at IS NULL" + ) + op.execute( + "CREATE UNIQUE INDEX tasking_locks_one_active_per_user_project " + "ON tasking_locks (project_id, user_auth_uid) " + "WHERE released_at IS NULL" + ) + op.execute( + "CREATE INDEX tasking_locks_expiry_idx " + "ON tasking_locks (expires_at) WHERE released_at IS NULL" + ) + + # ---- tasking_changesets ------------------------------------------ + + if not insp.has_table("tasking_changesets"): + op.create_table( + "tasking_changesets", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("lock_id", sa.BigInteger(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("osm_changeset_id", sa.BigInteger(), nullable=False), + sa.Column( + "submitted_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["lock_id"], ["tasking_locks.id"]), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + ) + op.create_index( + "tasking_changesets_task_idx", "tasking_changesets", ["task_id"] + ) + + # ---- tasking_feedback -------------------------------------------- + # + # Generic per-task feedback table. Covers validator remap rejections + # (originally the only use case) plus any other free-form notes a + # contributor / validator / lead may attach to a task — approval + # comments, follow-up reminders, etc. + # + # `reason_category` is nullable: required only when the feedback is + # used to drive a `to_review → to_remap` transition; left NULL for + # generic notes. `notes` is required so a row always has at least + # one of (category, free text) — usually both. + + if not insp.has_table("tasking_feedback"): + op.create_table( + "tasking_feedback", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("task_id", sa.BigInteger(), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("author_user_auth_uid", sa.String(), nullable=False), + sa.Column( + "reason_category", + postgresql.ENUM( + *TASKING_FEEDBACK_REASON, + name="tasking_feedback_reason", + create_type=False, + ), + nullable=True, + ), + sa.Column("notes", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["task_id"], ["tasking_tasks.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["author_user_auth_uid"], ["users.auth_uid"] + ), + ) + op.create_index( + "tasking_feedback_task_idx", "tasking_feedback", ["task_id"] + ) + op.create_index( + "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] + ) + + # ---- tasking_audit_events ---------------------------------------- + + if not insp.has_table("tasking_audit_events"): + op.create_table( + "tasking_audit_events", + sa.Column( + "id", + sa.BigInteger(), + primary_key=True, + autoincrement=True, + ), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("task_id", sa.BigInteger(), nullable=True), + sa.Column("actor_user_auth_uid", sa.Uuid(), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "details", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "project_deleted", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + # No FK to tasking_projects so audit survives the + # project's hard-delete of children + soft-delete itself. + ) + op.create_index( + "tasking_audit_project_idx", "tasking_audit_events", ["project_id"] + ) + op.create_index( + "tasking_audit_project_task_idx", + "tasking_audit_events", + ["project_id", "task_id"], + ) + op.create_index( + "tasking_audit_occurred_idx", + "tasking_audit_events", + ["occurred_at"], + ) + + # ---- tasking_task_save_idempotency ------------------------------- + + if not insp.has_table("tasking_task_save_idempotency"): + op.create_table( + "tasking_task_save_idempotency", + sa.Column("project_id", sa.BigInteger(), nullable=False), + sa.Column("key", sa.String(length=128), nullable=False), + sa.Column("body_hash", sa.String(length=128), nullable=False), + sa.Column( + "response_json", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("project_id", "key"), + ) + op.create_index( + "tasking_task_save_idempotency_created_idx", + "tasking_task_save_idempotency", + ["created_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + # Drop tables in reverse FK order. + for table in ( + "tasking_task_save_idempotency", + "tasking_audit_events", + "tasking_feedback", + "tasking_changesets", + "tasking_locks", + "tasking_tasks", + "tasking_project_roles", + "tasking_projects", + "team_user", + "teams", + ): + if insp.has_table(table): + op.drop_table(table) + + # Drop tasking-specific enums (workspace_role is owned by an earlier + # revision and stays). + for enum_name in ( + "tasking_feedback_reason", + "tasking_lock_release_reason", + "tasking_task_boundary_type", + "tasking_task_status", + "tasking_project_status", + ): + _drop_enum_if_present(bind, enum_name) diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py new file mode 100644 index 0000000..ef9befa --- /dev/null +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -0,0 +1,112 @@ + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from geoalchemy2 import Geometry +from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + +revision: str = "c5121cbba124" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _postgis_available(bind) -> bool: + return bool( + bind.execute( + text("SELECT 1 FROM pg_available_extensions WHERE name = 'postgis'") + ).scalar() + ) + + +def upgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + use_postgis = _postgis_available(bind) + if use_postgis: + op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + + geometry_column = ( + sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, + ) + if use_postgis + else sa.Column("geometry", sa.Text(), nullable=True) + ) + + # The TASK tree owns `workspaces` and `workspaces_*` only. + # `users`, `teams`, `team_user`, `user_workspace_roles`, and the + # `tasking_*` tables are owned by the OSM tree. + + if not insp.has_table("workspaces"): + op.create_table( + "workspaces", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("tdeiProjectGroupId", sa.Uuid(), nullable=False), + sa.Column("tdeiRecordId", sa.Uuid(), nullable=True), + sa.Column("tdeiServiceId", sa.Uuid(), nullable=True), + sa.Column("tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("createdAt", sa.DateTime(), nullable=False), + sa.Column("createdBy", sa.Uuid(), nullable=False), + sa.Column("createdByName", sa.String(), nullable=False), + geometry_column, + sa.Column( + "externalAppAccess", + sa.SmallInteger(), + nullable=False, + server_default="0", + ), + sa.Column("kartaViewToken", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + if not insp.has_table("workspaces_long_quests"): + op.create_table( + "workspaces_long_quests", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column("definition", sa.String(), nullable=True), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + if not insp.has_table("workspaces_imagery"): + op.create_table( + "workspaces_imagery", + sa.Column("workspace_id", sa.Integer(), nullable=False), + sa.Column( + "definition", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("modifiedAt", sa.DateTime(), nullable=False), + sa.Column("modifiedBy", sa.Uuid(), nullable=False), + sa.Column("modifiedByName", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"]), + sa.PrimaryKeyConstraint("workspace_id"), + ) + + + +def downgrade() -> None: + bind = op.get_bind() + assert bind is not None + insp = inspect(bind) + + if insp.has_table("workspaces_imagery"): + op.drop_table("workspaces_imagery") + if insp.has_table("workspaces_long_quests"): + op.drop_table("workspaces_long_quests") + if insp.has_table("workspaces"): + op.drop_table("workspaces") diff --git a/api/src/tasking/__init__.py b/api/src/tasking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/projects/__init__.py b/api/src/tasking/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py new file mode 100644 index 0000000..c2c266f --- /dev/null +++ b/api/src/tasking/projects/dtos.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field as PydField, field_validator + +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectStatus, + TaskBoundaryType, + _MultiPolygon, +) + + +# --------------------------------------------------------------------------- +# Shared DTO base. +# --------------------------------------------------------------------------- + + +class WireModel(BaseModel): + """Common base for request and response DTOs. + + `extra="forbid"` rejects unknown keys on input bodies with a 422 so + callers get explicit feedback when they misspell a field or send a + property the endpoint does not accept (e.g. `aoi` on PATCH project, + which has its own sub-resource at `/aoi`). Responses are unaffected + because Pydantic only enforces `extra` during input validation. + """ + + model_config = ConfigDict(extra="forbid") + + +# --------------------------------------------------------------------------- +# Project DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleAssignment(WireModel): + """Seed entry for `tasking_project_roles` at create time.""" + + user_id: UUID + role: Literal["lead", "validator", "contributor"] + + +class ProjectCreateRequest(WireModel): + """Body for `POST /workspaces/{wid}/tasking/projects`.""" + + name: str = PydField(min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + review_required: bool = True + lock_timeout_hours: int = PydField(default=8, ge=1, le=720) + aoi: Optional[AoiInput] = None + role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + + @field_validator("name") + @classmethod + def _name_not_blank(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("name cannot be blank") + return v + + +class ProjectUpdateRequest(WireModel): + """Body for `PATCH /workspaces/{wid}/tasking/projects/{pid}`. + + All fields optional; per-status mutability is enforced in the + repository layer. + """ + + name: Optional[str] = PydField(default=None, min_length=1, max_length=255) + instructions: Optional[str] = PydField(default=None, max_length=10_000) + lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) + review_required: Optional[bool] = None + + +class ProjectResponse(WireModel): + """Project detail returned by GET/POST/PATCH/activate/close/reset.""" + + id: int + workspace_id: int + name: str + instructions: Optional[str] = None + status: ProjectStatus + review_required: bool + lock_timeout_hours: int + task_boundary_type: Optional[TaskBoundaryType] = None + has_aoi: bool + task_count: int + created_by: UUID + created_by_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class ProjectListItem(WireModel): + """Row for `GET /workspaces/{wid}/tasking/projects`.""" + + id: int + name: str + status: ProjectStatus + task_count: int + percent_completed: int + created_by: UUID + created_by_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class Pagination(WireModel): + page: int + page_size: int + total: int + + +class ProjectListResponse(WireModel): + results: list[ProjectListItem] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# AOI response shape (canonical Feature wrapping a MultiPolygon) +# --------------------------------------------------------------------------- + + +class AoiFeature(WireModel): + type: Literal["Feature"] = "Feature" + geometry: _MultiPolygon + properties: dict[str, Any] = PydField(default_factory=dict) + + +__all__ = [ + "AoiFeature", + "Pagination", + "ProjectCreateRequest", + "ProjectListItem", + "ProjectListResponse", + "ProjectResponse", + "ProjectRoleAssignment", + "ProjectUpdateRequest", + "WireModel", +] diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py new file mode 100644 index 0000000..1a1c6bd --- /dev/null +++ b/api/src/tasking/projects/repository.py @@ -0,0 +1,764 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.projects.dtos import ( + AoiFeature, + Pagination, + ProjectCreateRequest, + ProjectListItem, + ProjectListResponse, + ProjectResponse, + ProjectUpdateRequest, +) +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectStatus, + TaskingProject, + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + + +# --------------------------------------------------------------------------- +# AOI helpers +# --------------------------------------------------------------------------- + + +def _aoi_to_shapely(aoi: AoiInput) -> ShapelyMultiPolygon: + """Normalise any of the accepted GeoJSON shapes to a Shapely + MultiPolygon. Bare Polygons are upcast to a single-member + MultiPolygon — storage column is always MULTIPOLYGON(4326). + """ + if isinstance(aoi, _FeatureCollection): + geom_dict = aoi.features[0].geometry.model_dump() + elif isinstance(aoi, _Feature): + geom_dict = aoi.geometry.model_dump() + elif isinstance(aoi, (_Polygon, _MultiPolygon)): + geom_dict = aoi.model_dump() + else: # pragma: no cover — Pydantic guards against this + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unsupported AOI shape", + ) + + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid AOI geometry: {e}", + ) from None + + if isinstance(geom, ShapelyPolygon): + geom = ShapelyMultiPolygon([geom]) + elif not isinstance(geom, ShapelyMultiPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI must be a Polygon or MultiPolygon", + ) + + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", + ) + + return geom + + +def _shapely_to_aoi_feature(geom: ShapelyMultiPolygon) -> AoiFeature: + """Build the GeoJSON Feature wrapper returned by the AOI GET endpoint.""" + raw = geom.__geo_interface__ + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=raw["coordinates"], + ), + properties={}, + ) + + +# --------------------------------------------------------------------------- +# Constraint translation — map Postgres `IntegrityError` to a precise +# HTTPException keyed by constraint name. Avoids the generic +# "everything is 409: name already exists" message. +# --------------------------------------------------------------------------- + + +def _constraint_name(e: IntegrityError) -> str | None: + """Return the PG constraint name from an `IntegrityError`, or None.""" + orig = getattr(e, "orig", None) + name = getattr(orig, "constraint_name", None) + if name: + return name + inner = getattr(orig, "__cause__", None) + return getattr(inner, "constraint_name", None) + + +def _translate_integrity_error(e: IntegrityError) -> HTTPException: + """Convert a Postgres constraint violation into an HTTPException.""" + name = _constraint_name(e) or "" + + if name == "tasking_projects_workspace_name_unique": + return AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + + if name == "tasking_project_roles_user_auth_uid_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "One or more `role_assignments[].user_id` values refer " + "to a user that has not signed in to Workspaces yet." + ), + ) + + if name == "tasking_tasks_project_id_fkey": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot insert tasks: parent project does not exist.", + ) + + # NOT NULL violations surface with no constraint_name on asyncpg. + orig_class = type(getattr(e, "orig", None)).__name__ + if orig_class == "NotNullViolationError": + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Required field is missing.", + ) + + if name: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Database constraint violated: {name}", + ) + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Database constraint violated.", + ) + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingProjectRepository: + """CRUD and lifecycle for tasking projects. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- internal helpers -------------------------------------------- + + async def _get_active( + self, workspace_id: int, project_id: int + ) -> TaskingProject: + """Fetch a non-deleted project scoped to a workspace; raise 404 otherwise.""" + result = await self.session.execute( + select(TaskingProject).where( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.deleted_at.is_(None)) + ) + ) + project = result.scalar_one_or_none() + if project is None: + raise NotFoundException(f"Project {project_id} not found") + return project + + @staticmethod + def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectResponse: + return ProjectResponse( + id=project.id, # type: ignore[arg-type] + workspace_id=project.workspace_id, + name=project.name, + instructions=project.instructions, + status=project.status, + review_required=project.review_required, + lock_timeout_hours=project.lock_timeout_hours, + task_boundary_type=project.task_boundary_type, + has_aoi=project.aoi is not None, + task_count=task_count, + created_by=project.created_by, + created_by_name=project.created_by_name, + created_at=project.created_at, + updated_at=project.updated_at, + ) + + async def _missing_user_auth_uids( + self, uuids: list[UUID] + ) -> list[str]: + """Return the subset of `uuids` without a matching `users` row. + + Preflight for the `tasking_project_roles.user_auth_uid` FK so + downstream inserts produce a clean 422 with the offending ids + instead of a 23503 foreign-key-violation. + """ + if not uuids: + return [] + + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)" + ), + {"uids": [str(u) for u in uuids]}, + ) + existing = {row[0] for row in rows.all()} + return [str(u) for u in uuids if str(u) not in existing] + + async def _task_count(self, project_id: int) -> int: + """Read-only task count for a project; raw SQL to keep this + module independent of the tasks sub-module's ORM.""" + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid" + ), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + # ---- create / list / get / patch / delete ------------------------ + + async def list_projects( + self, + workspace_id: int, + *, + status_filter: ProjectStatus | None = None, + text_search: str | None = None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ) -> ProjectListResponse: + valid_order = { + "created_at": TaskingProject.created_at, + "updated_at": TaskingProject.updated_at, + "name": TaskingProject.name, + } + col = valid_order.get(order_by, TaskingProject.created_at) + col = col.desc() if order_dir.upper() == "DESC" else col.asc() + + where = (TaskingProject.workspace_id == workspace_id) & ( + TaskingProject.deleted_at.is_(None) + ) + if status_filter is not None: + where = where & (TaskingProject.status == status_filter) + if text_search: + where = where & ( + func.lower(TaskingProject.name).contains(text_search.lower()) + ) + + total_q = await self.session.execute( + select(func.count()).select_from(TaskingProject).where(where) + ) + total = int(total_q.scalar() or 0) + + page = max(page, 1) + page_size = max(min(page_size, 200), 1) + offset = (page - 1) * page_size + + rows = await self.session.execute( + select(TaskingProject) + .where(where) + .order_by(col) + .limit(page_size) + .offset(offset) + ) + projects = list(rows.scalars().all()) + + # task counts in one round trip + counts: dict[int, int] = {} + if projects: + from sqlalchemy import text + + ids = [p.id for p in projects] + cnt_rows = await self.session.execute( + text( + "SELECT project_id, COUNT(*) FROM tasking_tasks " + "WHERE project_id = ANY(:ids) GROUP BY project_id" + ), + {"ids": ids}, + ) + counts = {pid: int(c) for pid, c in cnt_rows.all()} + + items: list[ProjectListItem] = [] + for p in projects: + tc = counts.get(p.id, 0) # type: ignore[arg-type] + completed = 0 + if tc > 0: + from sqlalchemy import text + + done_q = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_tasks " + "WHERE project_id = :pid AND status = 'completed'" + ), + {"pid": p.id}, + ) + completed = int(done_q.scalar() or 0) + pct = int(round((completed / tc) * 100)) if tc > 0 else 0 + items.append( + ProjectListItem( + id=p.id, # type: ignore[arg-type] + name=p.name, + status=p.status, + task_count=tc, + percent_completed=pct, + created_by=p.created_by, + created_by_name=p.created_by_name, + created_at=p.created_at, + updated_at=p.updated_at, + ) + ) + + return ProjectListResponse( + results=items, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def create( + self, + workspace_id: int, + current_user: UserInfo, + body: ProjectCreateRequest, + ) -> ProjectResponse: + # Preflight every user_auth_uid that will be inserted into + # `tasking_project_roles` — the creator's auto-LEAD seed plus + # any explicit role_assignments. Returns a 422 listing the + # missing ids instead of a generic FK violation. + candidate_uuids: list[UUID] = [current_user.user_uuid] + candidate_uuids.extend(ra.user_id for ra in body.role_assignments or []) + missing = await self._missing_user_auth_uids(candidate_uuids) + if missing: + creator_uid = str(current_user.user_uuid) + if creator_uid in missing: + # Signed-in caller is not yet provisioned in `users`; + # distinct from a bad role_assignments entry. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Your user record has not been provisioned yet. " + "Sign in to Workspaces once to create your `users` " + "row, then retry." + ), + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "One or more `role_assignments[].user_id` values " + "refer to a user that has not signed in to " + "Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + project = TaskingProject( + workspace_id=workspace_id, + name=body.name, + instructions=body.instructions, + review_required=body.review_required, + lock_timeout_hours=body.lock_timeout_hours, + created_by=current_user.user_uuid, + created_by_name=current_user.user_name, + ) + if body.aoi is not None: + geom = _aoi_to_shapely(body.aoi) + project.aoi = from_shape(geom, srid=4326) + + try: + self.session.add(project) + await self.session.flush() # need project.id for role rows + + # Seed project-level role overrides. + if body.role_assignments: + from sqlalchemy import text + + for ra in body.role_assignments: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, :role) " + "ON CONFLICT (project_id, user_auth_uid) " + "DO UPDATE SET role = EXCLUDED.role, " + " updated_at = NOW()" + ), + { + "pid": project.id, + "uid": str(ra.user_id), + "role": ra.role, + }, + ) + + # Creator is auto-assigned the LEAD role on the project, + # mirroring the workspace-creator auto-LEAD convention. + from sqlalchemy import text + + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role) " + "VALUES (:pid, :uid, 'lead') " + "ON CONFLICT DO NOTHING" + ), + { + "pid": project.id, + "uid": str(current_user.user_uuid), + }, + ) + + await self.session.commit() + await self.session.refresh(project) + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + return self._to_response(project) + + async def get(self, workspace_id: int, project_id: int) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def patch( + self, + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + + if project.status == ProjectStatus.DONE: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A closed project cannot be edited", + ) + + # `review_required` immutable after activation + if ( + body.review_required is not None + and project.status != ProjectStatus.DRAFT + and body.review_required != project.review_required + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="review_required is immutable after activation", + ) + + updates: dict[str, Any] = {} + if body.name is not None: + updates["name"] = body.name.strip() + if body.instructions is not None: + updates["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + updates["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + updates["review_required"] = body.review_required + + if updates: + updates["updated_at"] = datetime.now() + try: + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(**updates) + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + await self.session.refresh(project) + + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def soft_delete(self, workspace_id: int, project_id: int) -> None: + project = await self._get_active(workspace_id, project_id) + + # Refuse if any active task locks remain. + from sqlalchemy import text + + active = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks; force-release first", + ) + + # Soft-delete the project, hard-delete its tasks, flag audit rows. + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(deleted_at=datetime.now()) + ) + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + text( + "UPDATE tasking_audit_events SET project_deleted = TRUE " + "WHERE project_id = :pid" + ), + {"pid": project.id}, + ) + await self.session.commit() + + # ---- lifecycle transitions --------------------------------------- + + async def activate( + self, workspace_id: int, project_id: int + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only draft projects can be activated", + ) + if not project.name.strip(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project name is required", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required", + ) + tc = await self._task_count(project.id) # type: ignore[arg-type] + if tc == 0: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + # Activation requires at least one explicit contributor or + # validator allocation (creator's auto-LEAD does not count). + from sqlalchemy import text + + worker_q = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND role IN ('contributor', 'validator') " + "LIMIT 1" + ), + {"pid": project.id}, + ) + if worker_q.scalar() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="At least one contributor or validator must be allocated to the project", + ) + + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + await self.session.commit() + await self.session.refresh(project) + return self._to_response(project, task_count=tc) + + async def close( + self, workspace_id: int, project_id: int + ) -> ProjectResponse: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + from sqlalchemy import text + + not_done = await self.session.execute( + text( + "SELECT 1 FROM tasking_tasks " + "WHERE project_id = :pid AND status <> 'completed' LIMIT 1" + ), + {"pid": project.id}, + ) + if not_done.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has tasks that are not yet completed", + ) + active_lock = await self.session.execute( + text( + "SELECT 1 FROM tasking_locks " + "WHERE project_id = :pid AND released_at IS NULL LIMIT 1" + ), + {"pid": project.id}, + ) + if active_lock.scalar() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project has active task locks", + ) + + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.DONE, updated_at=datetime.now()) + ) + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + async def reset( + self, workspace_id: int, project_id: int + ) -> ProjectResponse: + """LEAD reset — see spec §projects.""" + project = await self._get_active(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a draft project (nothing to reset)", + ) + + from sqlalchemy import text + + # Release every active lock with release_reason='reset'. + await self.session.execute( + text( + "UPDATE tasking_locks " + "SET released_at = NOW(), release_reason = 'reset' " + "WHERE project_id = :pid AND released_at IS NULL" + ), + {"pid": project.id}, + ) + # Wind tasks back to to_map; clear last_mapper_id. + await self.session.execute( + text( + "UPDATE tasking_tasks " + "SET status = 'to_map', last_mapper_id = NULL, updated_at = NOW() " + "WHERE project_id = :pid AND status <> 'to_map'" + ), + {"pid": project.id}, + ) + # Project reopens if it was done. + if project.status == ProjectStatus.DONE: + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) + ) + + await self.session.commit() + await self.session.refresh(project) + tc = await self._task_count(project.id) # type: ignore[arg-type] + return self._to_response(project, task_count=tc) + + # ---- AOI --------------------------------------------------------- + + async def get_aoi( + self, workspace_id: int, project_id: int + ) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + geom = to_shape(project.aoi) + if isinstance(geom, ShapelyPolygon): # defensive + geom = ShapelyMultiPolygon([geom]) + return _shapely_to_aoi_feature(geom) + + async def upload_aoi( + self, workspace_id: int, project_id: int, aoi: AoiInput + ) -> AoiFeature: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be set or replaced while the project is in draft", + ) + + geom = _aoi_to_shapely(aoi) + from sqlalchemy import text + + # Replacing AOI hard-deletes any saved tasks and clears the + # boundary type (per spec). + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + aoi=from_shape(geom, srid=4326), + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self.session.commit() + return _shapely_to_aoi_feature(geom) + + async def delete_aoi(self, workspace_id: int, project_id: int) -> None: + project = await self._get_active(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="AOI can only be deleted while the project is in draft", + ) + if project.aoi is None: + raise NotFoundException("AOI is not set on this project") + + from sqlalchemy import text + + await self.session.execute( + text("DELETE FROM tasking_tasks WHERE project_id = :pid"), + {"pid": project.id}, + ) + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + aoi=None, + task_boundary_type=None, + updated_at=datetime.now(), + ) + ) + await self.session.commit() + + +__all__ = ["TaskingProjectRepository"] diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py new file mode 100644 index 0000000..cd699ce --- /dev/null +++ b/api/src/tasking/projects/routes.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.dtos import ( + AoiFeature, + ProjectCreateRequest, + ProjectListResponse, + ProjectResponse, + ProjectUpdateRequest, +) +from api.src.tasking.projects.schemas import ( + AoiInput, + ProjectStatus, +) +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_project_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingProjectRepository: + return TaskingProjectRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Projects — CRUD + lifecycle +# --------------------------------------------------------------------------- + + +@router.get("", response_model=ProjectListResponse) +async def list_projects( + workspace_id: int, + status_filter: Annotated[ + ProjectStatus | None, Query(alias="status") + ] = None, + text_search: str | None = Query(default=None, max_length=255), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), + order_by: str = Query("created_at"), + order_by_type: str = Query("DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_projects( + workspace_id, + status_filter=status_filter, + text_search=text_search, + page=page, + page_size=page_size, + order_by=order_by, + order_dir=order_by_type, + ) + + +@router.post( + "", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_project( + workspace_id: int, + body: ProjectCreateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.create(workspace_id, current_user, body) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get(workspace_id, project_id) + + +@router.patch("/{project_id}", response_model=ProjectResponse) +async def update_project( + workspace_id: int, + project_id: int, + body: ProjectUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.patch(workspace_id, project_id, body) + + +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.soft_delete(workspace_id, project_id) + + +@router.post("/{project_id}/activate", response_model=ProjectResponse) +async def activate_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.activate(workspace_id, project_id) + + +@router.post("/{project_id}/close", response_model=ProjectResponse) +async def close_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.close(workspace_id, project_id) + + +@router.post("/{project_id}/reset", response_model=ProjectResponse) +async def reset_project( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.reset(workspace_id, project_id) + + +# --------------------------------------------------------------------------- +# AOI +# --------------------------------------------------------------------------- + + +@router.get("/{project_id}/aoi", response_model=AoiFeature) +async def get_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_aoi(workspace_id, project_id) + + +@router.post("/{project_id}/aoi", response_model=AoiFeature) +async def upload_project_aoi( + workspace_id: int, + project_id: int, + body: AoiInput = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await project_repo.upload_aoi(workspace_id, project_id, body) + + +@router.delete("/{project_id}/aoi", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project_aoi( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + await project_repo.delete_aoi(workspace_id, project_id) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py new file mode 100644 index 0000000..18cde11 --- /dev/null +++ b/api/src/tasking/projects/schemas.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal, Optional +from uuid import UUID + +from geoalchemy2 import Geometry +from pydantic import BaseModel, Field as PydField +from sqlalchemy import Column, Enum as SAEnum +from sqlmodel import Field, SQLModel + + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class ProjectStatus(StrEnum): + DRAFT = "draft" + OPEN = "open" + DONE = "done" + + +class TaskBoundaryType(StrEnum): + GRID = "grid" + IMPORT = "import" + + +# --------------------------------------------------------------------------- +# Table model +# --------------------------------------------------------------------------- + + +class TaskingProject(SQLModel, table=True): + """Tasking project — lifecycle, AOI, settings.""" + + __tablename__ = "tasking_projects" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + + # Cross-DB reference to workspaces.id; no FK by design, matching + # the existing `user_workspace_roles` convention. + workspace_id: int = Field(nullable=False, index=True) + + name: str = Field(max_length=255, nullable=False) + instructions: Optional[str] = None + + # Bind to the Postgres enum from the migration. `name=` and + # `values_callable` are required so SQLAlchemy uses the existing + # `tasking_project_status` type (lowercase values) instead of + # auto-generating a new one keyed by member names. + status: ProjectStatus = Field( + default=ProjectStatus.DRAFT, + sa_column=Column( + SAEnum( + ProjectStatus, + name="tasking_project_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + + review_required: bool = Field(default=True, nullable=False) + lock_timeout_hours: int = Field(default=8, nullable=False) + + task_boundary_type: Optional[TaskBoundaryType] = Field( + default=None, + sa_column=Column( + SAEnum( + TaskBoundaryType, + name="tasking_task_boundary_type", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ), + ) + + # PostGIS MultiPolygon in EPSG:4326. Stored as WKB and converted + # to / from GeoJSON in the repository layer. + aoi: Optional[Any] = Field( + default=None, + sa_column=Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326)), + ) + + created_by: UUID = Field(nullable=False) + created_by_name: Optional[str] = None + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + deleted_at: Optional[datetime] = None + + +# --------------------------------------------------------------------------- +# GeoJSON input shapes — accepted by the AOI endpoints. Polygon inputs +# are upcast to single-member MultiPolygon at the repository layer. +# --------------------------------------------------------------------------- + + +class _Polygon(BaseModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class _MultiPolygon(BaseModel): + type: Literal["MultiPolygon"] + coordinates: list[list[list[list[float]]]] + + +class _Feature(BaseModel): + type: Literal["Feature"] + geometry: _Polygon | _MultiPolygon + properties: Optional[dict[str, Any]] = None + + +class _FeatureCollection(BaseModel): + type: Literal["FeatureCollection"] + features: list[_Feature] = PydField(min_length=1, max_length=1) + + +AoiInput = _Polygon | _MultiPolygon | _Feature | _FeatureCollection + + +__all__ = [ + "AoiInput", + "ProjectStatus", + "TaskBoundaryType", + "TaskingProject", + "_Feature", + "_FeatureCollection", + "_MultiPolygon", + "_Polygon", +] diff --git a/api/src/tasking/tasks/__init__.py b/api/src/tasking/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py new file mode 100644 index 0000000..edfec34 --- /dev/null +++ b/api/src/tasking/tasks/dtos.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional +from uuid import UUID + +from pydantic import Field as PydField + +from api.src.tasking.projects.dtos import Pagination, WireModel +from api.src.tasking.tasks.schemas import ( + FeedbackReason, + TaskStatus, +) + + +# --------------------------------------------------------------------------- +# Task boundary GeoJSON (input for /tasks/validate and /tasks/save) +# --------------------------------------------------------------------------- + + +class TaskBoundaryPolygon(WireModel): + type: Literal["Polygon"] + coordinates: list[list[list[float]]] + + +class TaskBoundaryFeature(WireModel): + type: Literal["Feature"] + geometry: TaskBoundaryPolygon + properties: Optional[dict[str, Any]] = None + + +class TaskBoundariesFeatureCollection(WireModel): + type: Literal["FeatureCollection"] + features: list[TaskBoundaryFeature] = PydField(min_length=1) + + +GridSource = Literal["grid", "import"] + + +# --------------------------------------------------------------------------- +# Task detail / list +# --------------------------------------------------------------------------- + + +class TaskLockSummary(WireModel): + user_id: UUID + user_name: Optional[str] = None + locked_at: datetime + expires_at: datetime + + +class LastMapper(WireModel): + user_id: UUID + user_name: Optional[str] = None + + +class TaskResponse(WireModel): + id: int + task_number: int + status: TaskStatus + geometry: TaskBoundaryPolygon + area_sqkm: float + lock: Optional[TaskLockSummary] = None + last_mapper: Optional[LastMapper] = None + created_at: datetime + updated_at: datetime + + +class TaskListResponse(WireModel): + tasks: list[TaskResponse] + pagination: Pagination + + +# --------------------------------------------------------------------------- +# Validate / Save +# --------------------------------------------------------------------------- + + +class ValidateWarning(WireModel): + task_index: int + issue: Literal["polygon_exceeds_grid_size"] + area_sqkm: Optional[float] = None + + +class ValidatePreviewResponse(WireModel): + valid: bool + warnings: list[ValidateWarning] = PydField(default_factory=list) + source: GridSource = "import" + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksRequest(WireModel): + source: GridSource + feature_collection: TaskBoundariesFeatureCollection + + +class SaveTasksResponse(WireModel): + project_id: int + task_boundary_type: GridSource + task_count: int + tasks: list[TaskResponse] + idempotency_key: Optional[str] = None + replayed: bool = False + + +# --------------------------------------------------------------------------- +# Submit / lock +# --------------------------------------------------------------------------- + + +class FeedbackInput(WireModel): + reason_category: Optional[FeedbackReason] = None + notes: str = PydField(min_length=1, max_length=4000) + + +class SubmitRequest(WireModel): + osm_changeset_id: int = PydField(ge=1) + done: bool + feedback: Optional[FeedbackInput] = None + + +class ExistingLockSummary(WireModel): + task_number: int + task_status: TaskStatus + locked_at: datetime + expires_at: datetime + + +__all__ = [ + "ExistingLockSummary", + "FeedbackInput", + "GridSource", + "LastMapper", + "SaveTasksRequest", + "SaveTasksResponse", + "SubmitRequest", + "TaskBoundariesFeatureCollection", + "TaskBoundaryFeature", + "TaskBoundaryPolygon", + "TaskListResponse", + "TaskLockSummary", + "TaskResponse", + "ValidatePreviewResponse", + "ValidateWarning", +] diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py new file mode 100644 index 0000000..ab4ee13 --- /dev/null +++ b/api/src/tasking/tasks/repository.py @@ -0,0 +1,1101 @@ +from __future__ import annotations + +import hashlib +import json +import math +from datetime import datetime, timedelta +from typing import Any, Optional +from uuid import UUID + +from fastapi import HTTPException, status +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon +from shapely.geometry import Polygon as ShapelyPolygon +from shapely.geometry import shape as shapely_shape +from sqlalchemy import func, select, text, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ( + AlreadyExistsException, + ForbiddenException, + NotFoundException, +) +from api.core.security import UserInfo +from api.src.tasking.projects.dtos import Pagination +from api.src.tasking.projects.schemas import ( + ProjectStatus, + TaskingProject, +) +from api.src.tasking.tasks.dtos import ( + ExistingLockSummary, + FeedbackInput, + LastMapper, + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + TaskBoundariesFeatureCollection, + TaskBoundaryFeature, + TaskBoundaryPolygon, + TaskListResponse, + TaskLockSummary, + TaskResponse, + ValidatePreviewResponse, + ValidateWarning, +) +from api.src.tasking.tasks.schemas import ( + LockReleaseReason, + TaskingChangeset, + TaskingFeedback, + TaskingLock, + TaskingTask, + TaskStatus, +) + + +# Equirectangular approximation for area calculations on small +# EPSG:4326 polygons: 1 degree latitude ≈ 111.32 km. Sufficient for +# the grid-size warning threshold; precise areas need a metric +# reprojection. +_DEG2_TO_KM2 = 111.32 * 111.32 + +# Threshold for the `polygon_exceeds_grid_size` warning. Default is +# 5000 m per side (matching the conventional Tasking Manager grid). +# Overridable via the `TM_TASKING_GRID_SIZE_METERS` environment +# variable; read once at import time. +import os as _os # noqa: E402 + +try: + _GRID_SIZE_M = float(_os.getenv("TM_TASKING_GRID_SIZE_METERS", "5000")) +except ValueError: + _GRID_SIZE_M = 5000.0 +_GRID_MAX_KM2 = (_GRID_SIZE_M / 1000.0) ** 2 + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +def _polygon_to_shapely(geom_dict: dict) -> ShapelyPolygon: + try: + geom = shapely_shape(geom_dict) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid task geometry: {e}", + ) from None + if not isinstance(geom, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Each task feature must be a Polygon", + ) + if not geom.is_valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task polygon is not valid (self-intersection or invalid ring)", + ) + return geom + + +def _shapely_polygon_to_geojson(geom: ShapelyPolygon) -> TaskBoundaryPolygon: + raw = geom.__geo_interface__ + return TaskBoundaryPolygon(type="Polygon", coordinates=raw["coordinates"]) + + +def _polygon_area_km2(geom: ShapelyPolygon) -> float: + return float(geom.area) * _DEG2_TO_KM2 + + +def _generate_grid_over_aoi( + aoi: ShapelyMultiPolygon, + cell_size_m: float, +) -> list[ShapelyPolygon]: + """Build a regular grid of square cells over the AOI bounding box, + clip each cell to the AOI, and return the resulting polygons. + + Sizes are converted from meters using an equirectangular + approximation: + - 1° latitude ≈ 111 320 m everywhere. + - 1° longitude ≈ 111 320 · cos(centre latitude) m. + + Adequate for small project AOIs; a proper metric reprojection + (pyproj) is required for global-scale precision. + """ + minx, miny, maxx, maxy = aoi.bounds + center_lat = (miny + maxy) / 2.0 + lat_step = cell_size_m / 111_320.0 + lon_step = cell_size_m / ( + 111_320.0 * max(math.cos(math.radians(center_lat)), 0.01) + ) + + cells: list[ShapelyPolygon] = [] + # Safety cap for accidental large-AOI + small-cell combinations. + cell_cap = 50_000 + + y = miny + while y < maxy: + x = minx + while x < maxx: + cell = ShapelyPolygon( + [ + (x, y), + (x + lon_step, y), + (x + lon_step, y + lat_step), + (x, y + lat_step), + (x, y), + ] + ) + if cell.intersects(aoi): + clipped = cell.intersection(aoi) + if not clipped.is_empty and clipped.area > 0: + # `intersection` can return a Polygon, MultiPolygon, + # or GeometryCollection; retain polygon pieces only. + geoms = ( + list(clipped.geoms) + if hasattr(clipped, "geoms") + else [clipped] + ) + for piece in geoms: + if ( + isinstance(piece, ShapelyPolygon) + and piece.area > 0 + ): + cells.append(piece) + if len(cells) >= cell_cap: + return cells + x += lon_step + y += lat_step + + return cells + + +# --------------------------------------------------------------------------- +# Repository +# --------------------------------------------------------------------------- + + +class TaskingTaskRepository: + """Tasks, locks, changesets, and submit-flow operations. + + Methods assume the caller has already passed the workspace tenancy + gate (`WorkspaceRepository.getById`); that check is performed at + the route layer. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- common helpers --------------------------------------------------- + + async def _get_project( + self, workspace_id: int, project_id: int + ) -> TaskingProject: + rs = await self.session.execute( + select(TaskingProject).where( + (TaskingProject.id == project_id) + & (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.deleted_at.is_(None)) + ) + ) + project = rs.scalar_one_or_none() + if project is None: + raise NotFoundException(f"Project {project_id} not found") + return project + + async def _get_task( + self, project_id: int, task_number: int + ) -> TaskingTask: + rs = await self.session.execute( + select(TaskingTask).where( + (TaskingTask.project_id == project_id) + & (TaskingTask.task_number == task_number) + ) + ) + task = rs.scalar_one_or_none() + if task is None: + raise NotFoundException( + f"Task {task_number} not found in project {project_id}" + ) + return task + + async def _get_active_lock( + self, task_id: int + ) -> Optional[TaskingLock]: + rs = await self.session.execute( + select(TaskingLock).where( + (TaskingLock.task_id == task_id) + & (TaskingLock.released_at.is_(None)) + ) + ) + return rs.scalar_one_or_none() + + async def _get_active_lock_for_user_in_project( + self, project_id: int, user_auth_uid: str + ) -> Optional[TaskingLock]: + rs = await self.session.execute( + select(TaskingLock).where( + (TaskingLock.project_id == project_id) + & (TaskingLock.user_auth_uid == user_auth_uid) + & (TaskingLock.released_at.is_(None)) + ) + ) + return rs.scalar_one_or_none() + + async def _project_role( + self, project_id: int, user: UserInfo, workspace_id: int + ) -> Optional[str]: + """Effective project role: explicit row overrides workspace-level. + + Returns one of `lead`, `validator`, `contributor`, or `None` + (outsider). Workspace LEAD beats an explicit non-lead row so + a workspace lead is never accidentally demoted by a stale role + assignment. + """ + rs = await self.session.execute( + text( + "SELECT role FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user.user_uuid)}, + ) + explicit = rs.scalar_one_or_none() + + # Workspace LEAD always wins. + if user.isWorkspaceLead(workspace_id): + return "lead" + if explicit: + return str(explicit) + if user.isWorkspaceValidator(workspace_id): + return "validator" + if user.isWorkspaceContributor(workspace_id): + return "contributor" + return None + + async def _audit( + self, + *, + event_type: str, + project_id: int, + task_id: Optional[int], + actor_uuid: UUID, + details: Optional[dict[str, Any]] = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, :tid, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "tid": task_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + + async def _lookup_user_display( + self, user_auth_uid: Optional[str] + ) -> Optional[str]: + if not user_auth_uid: + return None + rs = await self.session.execute( + text( + "SELECT display_name FROM users WHERE auth_uid = :uid" + ), + {"uid": user_auth_uid}, + ) + return rs.scalar_one_or_none() + + async def _to_task_response(self, task: TaskingTask) -> TaskResponse: + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + + lock_row = await self._get_active_lock(task.id) # type: ignore[arg-type] + lock_summary: Optional[TaskLockSummary] = None + if lock_row is not None: + display = await self._lookup_user_display(lock_row.user_auth_uid) + lock_summary = TaskLockSummary( + user_id=UUID(lock_row.user_auth_uid), + user_name=display, + locked_at=lock_row.locked_at, + expires_at=lock_row.expires_at, + ) + + last_mapper: Optional[LastMapper] = None + if task.last_mapper_id: + display = await self._lookup_user_display(task.last_mapper_id) + last_mapper = LastMapper( + user_id=UUID(task.last_mapper_id), user_name=display + ) + + return TaskResponse( + id=task.id, # type: ignore[arg-type] + task_number=task.task_number, + status=task.status, + geometry=_shapely_polygon_to_geojson(geom_shape), + area_sqkm=float(task.area_sqkm), + lock=lock_summary, + last_mapper=last_mapper, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + # ---- grid generation ------------------------------------------------- + + async def generate_grid( + self, + workspace_id: int, + project_id: int, + cell_size_m: int, + ) -> TaskBoundariesFeatureCollection: + """Preview-only grid generation: returns a FeatureCollection of + clipped grid cells over the project AOI without persisting. + + Caller previews the returned shapes and then commits the same + FeatureCollection via `POST /tasks/save` (with + ``source: "grid"``). LEAD-only; project must be in `draft` + with an AOI set. + """ + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be generated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before generating a grid", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + cells = _generate_grid_over_aoi(aoi_geom, float(cell_size_m)) + if not cells: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Grid produced no cells — AOI may be too small for " + "the chosen cellSizeMeters." + ), + ) + + features = [ + TaskBoundaryFeature( + type="Feature", + geometry=_shapely_polygon_to_geojson(cell), + properties={"cellIndex": idx}, + ) + for idx, cell in enumerate(cells) + ] + return TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=features, + ) + + # ---- validate -------------------------------------------------------- + + async def validate( + self, + workspace_id: int, + project_id: int, + fc: TaskBoundariesFeatureCollection, + ) -> ValidatePreviewResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be validated while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before validating tasks", + ) + + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + warnings: list[ValidateWarning] = [] + for idx, feat in enumerate(fc.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not aoi_geom.covers(poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + area_km2 = _polygon_area_km2(poly) + if area_km2 > _GRID_MAX_KM2: + warnings.append( + ValidateWarning( + task_index=idx, + issue="polygon_exceeds_grid_size", + area_sqkm=round(area_km2, 4), + ) + ) + + return ValidatePreviewResponse( + valid=True, + warnings=warnings, + source="import", + feature_collection=fc, + ) + + # ---- save ------------------------------------------------------------ + + async def save( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + body: SaveTasksRequest, + idempotency_key: Optional[str], + ) -> tuple[SaveTasksResponse, bool]: + """Bulk-insert tasks. Returns (response, replayed).""" + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tasks can only be saved while the project is in draft", + ) + if project.aoi is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project AOI is required before saving tasks", + ) + + body_bytes = json.dumps( + body.model_dump(mode="json"), sort_keys=True + ).encode() + body_hash = hashlib.sha256(body_bytes).hexdigest() + + # Idempotent replay path. + if idempotency_key: + rs = await self.session.execute( + text( + "SELECT body_hash, response_json " + "FROM tasking_task_save_idempotency " + "WHERE project_id = :pid AND key = :k" + ), + {"pid": project.id, "k": idempotency_key}, + ) + row = rs.first() + if row is not None: + if row.body_hash != body_hash: + raise AlreadyExistsException( + "Idempotency key reused with a different request" + ) + stored = row.response_json + if isinstance(stored, str): + stored = json.loads(stored) + # Stored payload was serialised with `replayed=False` + # at first-write time; set it to True on replay so the + # caller can distinguish a replay from a fresh save. + stored["replayed"] = True + return SaveTasksResponse(**stored), True + + # Refuse if tasks already exist (re-upload AOI to wipe). + existing = await self.session.execute( + text( + "SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1" + ), + {"pid": project.id}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException("Tasks already saved") + + # Re-validate every feature against AOI (atomic guarantee). + aoi_geom = to_shape(project.aoi) + if isinstance(aoi_geom, ShapelyPolygon): + aoi_geom = ShapelyMultiPolygon([aoi_geom]) + + created: list[TaskingTask] = [] + for idx, feat in enumerate(body.feature_collection.features): + poly = _polygon_to_shapely(feat.geometry.model_dump()) + if not aoi_geom.covers(poly): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Task feature {idx} is not fully inside the project AOI", + ) + task = TaskingTask( + project_id=project.id, # type: ignore[arg-type] + task_number=idx + 1, + area_sqkm=round(_polygon_area_km2(poly), 4), + status=TaskStatus.TO_MAP, + geometry=from_shape(poly, srid=4326), + ) + self.session.add(task) + created.append(task) + + await self.session.flush() # populate ids + + # Set boundary type + bump project updated_at. + await self.session.execute( + update(TaskingProject) + .where(TaskingProject.id == project.id) + .values( + task_boundary_type=body.source, + updated_at=datetime.now(), + ) + ) + + # Audit one row per task. + for t in created: + await self._audit( + event_type="task_created", + project_id=project.id, # type: ignore[arg-type] + task_id=t.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": t.task_number}, + ) + + task_responses = [await self._to_task_response(t) for t in created] + response = SaveTasksResponse( + project_id=project.id, # type: ignore[arg-type] + task_boundary_type=body.source, + task_count=len(created), + tasks=task_responses, + idempotency_key=idempotency_key, + replayed=False, + ) + + # Persist idempotency record (committed in the same txn). + if idempotency_key: + payload = response.model_dump(mode="json") + await self.session.execute( + text( + "INSERT INTO tasking_task_save_idempotency " + "(project_id, key, body_hash, response_json) " + "VALUES (:pid, :k, :bh, CAST(:rj AS jsonb))" + ), + { + "pid": project.id, + "k": idempotency_key, + "bh": body_hash, + "rj": json.dumps(payload), + }, + ) + + await self.session.commit() + return response, False + + # ---- list / get ------------------------------------------------------ + + async def list_tasks( + self, + workspace_id: int, + project_id: int, + *, + status_filter: Optional[TaskStatus] = None, + locked_by_user_id: Optional[UUID] = None, + last_mapper_id: Optional[UUID] = None, + page: int = 1, + page_size: int = 200, + ) -> TaskListResponse: + await self._get_project(workspace_id, project_id) + + where = TaskingTask.project_id == project_id + if status_filter is not None: + where = where & (TaskingTask.status == status_filter) + if last_mapper_id is not None: + where = where & (TaskingTask.last_mapper_id == str(last_mapper_id)) + + total_q = await self.session.execute( + select(func.count()).select_from(TaskingTask).where(where) + ) + total = int(total_q.scalar() or 0) + + page = max(page, 1) + page_size = max(min(page_size, 1000), 1) + offset = (page - 1) * page_size + + rows = await self.session.execute( + select(TaskingTask) + .where(where) + .order_by(TaskingTask.task_number.asc()) + .limit(page_size) + .offset(offset) + ) + tasks = list(rows.scalars().all()) + + responses: list[TaskResponse] = [] + for t in tasks: + tr = await self._to_task_response(t) + if locked_by_user_id is not None and ( + tr.lock is None or tr.lock.user_id != locked_by_user_id + ): + continue + responses.append(tr) + + return TaskListResponse( + tasks=responses, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get_task( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskResponse: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + return await self._to_task_response(task) + + # ---- lock / unlock / extend / reset ---------------------------------- + + async def lock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status != ProjectStatus.OPEN: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Project is not open", + ) + + task = await self._get_task(project_id, task_number) + + # Eligibility table. + role = await self._project_role( + project_id, current_user, workspace_id + ) + if role is None: + raise ForbiddenException("User has no access to this project") + + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + if role not in ("contributor", "validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for mapping" + ) + elif task.status == TaskStatus.TO_REVIEW: + if role not in ("validator", "lead"): + raise ForbiddenException( + "Role does not permit locking this task for validation" + ) + if ( + task.last_mapper_id + and task.last_mapper_id == str(current_user.user_uuid) + ): + raise ForbiddenException( + "Cannot validate a task you last mapped" + ) + else: + raise ForbiddenException("Task is in a terminal state") + + # One active lock per task. + if await self._get_active_lock(task.id) is not None: # type: ignore[arg-type] + raise AlreadyExistsException("Task is already locked") + + # One active lock per (project, user). + other = await self._get_active_lock_for_user_in_project( + project_id, str(current_user.user_uuid) + ) + if other is not None: + other_task_rs = await self.session.execute( + select(TaskingTask).where(TaskingTask.id == other.task_id) + ) + other_task = other_task_rs.scalar_one() + summary = ExistingLockSummary( + task_number=other_task.task_number, + task_status=other_task.status, + locked_at=other.locked_at, + expires_at=other.expires_at, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "User already holds a lock in this project", + "existing_lock": summary.model_dump(mode="json"), + }, + ) + + now = datetime.now() + expires_at = now + timedelta(hours=project.lock_timeout_hours) + lock = TaskingLock( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + user_auth_uid=str(current_user.user_uuid), + task_status_at_lock=task.status, + locked_at=now, + expires_at=expires_at, + ) + + try: + self.session.add(lock) + await self.session.flush() + await self._audit( + event_type="task_locked", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + await self.session.commit() + except IntegrityError: + await self.session.rollback() + raise AlreadyExistsException( + "Task is already locked or user already holds a lock in this project" + ) + + return await self._to_task_response(task) + + async def unlock_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + force: bool = False, + ) -> None: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock", + ) + + if force: + if not current_user.isWorkspaceLead(workspace_id): + raise ForbiddenException("Only LEAD may force-release a lock") + release_reason = LockReleaseReason.LEAD_RELEASE + else: + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may release it") + release_reason = LockReleaseReason.MANUAL + + now = datetime.now() + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(released_at=now, release_reason=release_reason) + ) + await self._audit( + event_type="task_unlocked", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": release_reason.value, + }, + ) + await self.session.commit() + + async def extend_lock( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Task has no active lock to extend", + ) + if lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Only the lock holder may extend the lock") + + # `expires_at` comes back tz-aware from Postgres (TIMESTAMPTZ); + # the SQLModel column is typed as naive `datetime`. Strip tzinfo + # before re-binding to avoid asyncpg's offset-aware/naive + # mismatch error. + prev = lock.expires_at + if prev.tzinfo is not None: + prev = prev.replace(tzinfo=None) + new_expiry = prev + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(expires_at=new_expiry) + ) + await self._audit( + event_type="task_lock_extended", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + return await self._to_task_response(task) + + async def reset_task( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + if project.status == ProjectStatus.DRAFT: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot reset a task while the project is in draft", + ) + + task = await self._get_task(project_id, task_number) + + now = datetime.now() + # Release any active lock. + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is not None: + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values( + released_at=now, + release_reason=LockReleaseReason.RESET, + ) + ) + await self._audit( + event_type="task_unlocked", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.RESET.value, + }, + ) + + previous_status = task.status + if previous_status != TaskStatus.TO_MAP: + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values( + status=TaskStatus.TO_MAP, + last_mapper_id=None, + updated_at=now, + ) + ) + await self._audit( + event_type="task_state_changed", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": TaskStatus.TO_MAP.value, + }, + ) + else: + # Clear last_mapper_id even if state was already to_map. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values(last_mapper_id=None, updated_at=now) + ) + + await self._audit( + event_type="task_reset", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={"taskNumber": task.task_number}, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # ---- submit ---------------------------------------------------------- + + async def submit( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + body: SubmitRequest, + ) -> TaskResponse: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + + # Effective role for the *current* task status — drives the + # state transition table. + if task.status in (TaskStatus.TO_MAP, TaskStatus.TO_REMAP): + actor_role = "mapper" + elif task.status == TaskStatus.TO_REVIEW: + actor_role = "validator" + else: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task is in a terminal state", + ) + + # Feedback is only meaningful in validator context. + # if body.feedback is not None and actor_role != "validator": + # raise HTTPException( + # status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + # detail="feedback is only accepted in validator context", + # ) + + now = datetime.now() + + # Record the changeset row. + cs = TaskingChangeset( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + lock_id=lock.id, # type: ignore[arg-type] + user_auth_uid=str(current_user.user_uuid), + osm_changeset_id=body.osm_changeset_id, + submitted_at=now, + ) + self.session.add(cs) + await self.session.flush() + + await self._audit( + event_type="changeset_submitted", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "osmChangesetId": body.osm_changeset_id, + "done": body.done, + }, + ) + + if not body.done: + # Slide lock expiry from submitted_at + lock_timeout_hours. + new_expiry = now + timedelta(hours=project.lock_timeout_hours) + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values(expires_at=new_expiry) + ) + await self._audit( + event_type="task_lock_renewed", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "expiresAt": new_expiry.isoformat(), + }, + ) + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + # done = True → resolve next status per transition table. + previous_status = task.status + new_last_mapper = task.last_mapper_id + + if actor_role == "mapper": + new_last_mapper = str(current_user.user_uuid) + if previous_status == TaskStatus.TO_MAP: + new_status = ( + TaskStatus.TO_REVIEW + if project.review_required + else TaskStatus.COMPLETED + ) + else: # to_remap + new_status = TaskStatus.TO_REVIEW + else: # validator + if body.feedback is not None: + if body.feedback.reason_category is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="feedback.reasonCategory is required when sending feedback", + ) + # Insert the remap feedback row. + self.session.add( + TaskingFeedback( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + author_user_auth_uid=str(current_user.user_uuid), + reason_category=body.feedback.reason_category, + notes=body.feedback.notes, + created_at=now, + ) + ) + await self._audit( + event_type="feedback_submitted", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "reasonCategory": body.feedback.reason_category.value, + }, + ) + new_status = TaskStatus.TO_REMAP + else: + new_status = TaskStatus.COMPLETED + + # Release the lock (auto_unlock). + await self.session.execute( + update(TaskingLock) + .where(TaskingLock.id == lock.id) + .values( + released_at=now, + release_reason=LockReleaseReason.AUTO_UNLOCK, + ) + ) + await self._audit( + event_type="task_unlocked", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "releaseReason": LockReleaseReason.AUTO_UNLOCK.value, + }, + ) + + # Apply state transition. + await self.session.execute( + update(TaskingTask) + .where(TaskingTask.id == task.id) + .values( + status=new_status, + last_mapper_id=new_last_mapper, + updated_at=now, + ) + ) + if new_status != previous_status: + await self._audit( + event_type="task_state_changed", + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "from": previous_status.value, + "to": new_status.value, + }, + ) + + await self.session.commit() + refreshed = await self._get_task(project_id, task_number) + return await self._to_task_response(refreshed) + + +__all__ = ["TaskingTaskRepository"] diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py new file mode 100644 index 0000000..1d38565 --- /dev/null +++ b/api/src/tasking/tasks/routes.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.tasks.dtos import ( + SaveTasksRequest, + SaveTasksResponse, + SubmitRequest, + TaskBoundariesFeatureCollection, + TaskListResponse, + TaskResponse, + ValidatePreviewResponse, +) +from api.src.tasking.tasks.repository import TaskingTaskRepository +from api.src.tasking.tasks.schemas import TaskStatus +from api.src.workspaces.repository import WorkspaceRepository + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects/{project_id}", + tags=["tasking-tasks"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_task_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingTaskRepository: + return TaskingTaskRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + await workspace_repo.getById(current_user, workspace_id) + + +def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: + if not current_user.isWorkspaceLead(workspace_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User does not have permission to edit this workspace", + ) + + +# --------------------------------------------------------------------------- +# Tasks — validate / save / list / get +# --------------------------------------------------------------------------- + + +@router.post("/tasks/grid", response_model=TaskBoundariesFeatureCollection) +async def generate_grid( + workspace_id: int, + project_id: int, + cell_size_meters: int = Query(1000, ge=50, le=100_000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + """Generate a regular grid of square cells over the project AOI. + + LEAD-only preview — does NOT persist. The client posts the same + FeatureCollection back through `POST /tasks/save` to commit. + """ + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.generate_grid( + workspace_id, project_id, cell_size_meters + ) + + +@router.post("/tasks/validate", response_model=ValidatePreviewResponse) +async def validate_tasks( + workspace_id: int, + project_id: int, + body: TaskBoundariesFeatureCollection = Body(...), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.validate(workspace_id, project_id, body) + + +@router.post("/tasks/save", response_model=SaveTasksResponse) +async def save_tasks( + workspace_id: int, + project_id: int, + body: SaveTasksRequest, + response: Response, + idempotency_key: Annotated[ + Optional[str], Header(alias="Idempotency-Key", min_length=8, max_length=128) + ] = None, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + payload, replayed = await task_repo.save( + workspace_id, project_id, current_user, body, idempotency_key + ) + response.status_code = ( + status.HTTP_200_OK if replayed else status.HTTP_201_CREATED + ) + return payload + + +@router.get("/tasks", response_model=TaskListResponse) +async def list_tasks( + workspace_id: int, + project_id: int, + status_filter: Annotated[ + Optional[TaskStatus], Query(alias="status") + ] = None, + locked_by_user_id: Optional[UUID] = Query(default=None), + last_mapper_id: Optional[UUID] = Query(default=None), + page: int = Query(1, ge=1), + page_size: int = Query(200, ge=1, le=1000), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.list_tasks( + workspace_id, + project_id, + status_filter=status_filter, + locked_by_user_id=locked_by_user_id, + last_mapper_id=last_mapper_id, + page=page, + page_size=page_size, + ) + + +@router.get("/tasks/{task_number}", response_model=TaskResponse) +async def get_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.get_task(workspace_id, project_id, task_number) + + +# --------------------------------------------------------------------------- +# Locks — acquire / release / extend / reset +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/lock", response_model=TaskResponse) +async def lock_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.lock_task( + workspace_id, project_id, task_number, current_user + ) + + +@router.delete( + "/tasks/{task_number}/lock", + status_code=status.HTTP_204_NO_CONTENT, +) +async def unlock_task( + workspace_id: int, + project_id: int, + task_number: int, + force: bool = Query(False), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await task_repo.unlock_task( + workspace_id, + project_id, + task_number, + current_user, + force=force, + ) + + +@router.post("/tasks/{task_number}/extend", response_model=TaskResponse) +async def extend_lock( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.extend_lock( + workspace_id, project_id, task_number, current_user + ) + + +@router.post("/tasks/{task_number}/reset", response_model=TaskResponse) +async def reset_task( + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + assert_workspace_lead(workspace_id, current_user) + return await task_repo.reset_task( + workspace_id, project_id, task_number, current_user + ) + + +# --------------------------------------------------------------------------- +# Submit — Done? flow +# --------------------------------------------------------------------------- + + +@router.post("/tasks/{task_number}/submit", response_model=TaskResponse) +async def submit_task( + workspace_id: int, + project_id: int, + task_number: int, + body: SubmitRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await task_repo.submit( + workspace_id, project_id, task_number, current_user, body + ) diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py new file mode 100644 index 0000000..40e5799 --- /dev/null +++ b/api/src/tasking/tasks/schemas.py @@ -0,0 +1,197 @@ + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any, Optional + +from geoalchemy2 import Geometry +from sqlalchemy import Column, Enum as SAEnum +from sqlmodel import Field, SQLModel + + +# --------------------------------------------------------------------------- +# Enums (mirrors of postgres enums in the migration) +# --------------------------------------------------------------------------- + + +class TaskStatus(StrEnum): + TO_MAP = "to_map" + TO_REVIEW = "to_review" + TO_REMAP = "to_remap" + COMPLETED = "completed" + + +class LockReleaseReason(StrEnum): + AUTO_UNLOCK = "auto_unlock" + MANUAL = "manual" + LEAD_RELEASE = "lead_release" + STALE_TIMEOUT = "stale_timeout" + RESET = "reset" + + +class FeedbackReason(StrEnum): + INCOMPLETE_MAPPING = "incomplete_mapping" + DATA_QUALITY_ISSUE = "data_quality_issue" + WRONG_AREA = "wrong_area" + OTHER = "other" + + +def _task_status_column(*, nullable: bool = False) -> Column: + return Column( + SAEnum( + TaskStatus, + name="tasking_task_status", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=nullable, + ) + + +def _release_reason_column() -> Column: + return Column( + SAEnum( + LockReleaseReason, + name="tasking_lock_release_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +def _feedback_reason_column() -> Column: + return Column( + SAEnum( + FeedbackReason, + name="tasking_feedback_reason", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=True, + ) + + +# --------------------------------------------------------------------------- +# Table models +# --------------------------------------------------------------------------- + + +class TaskingTask(SQLModel, table=True): + """Per-project task polygon — saved as part of a bulk batch.""" + + __tablename__ = "tasking_tasks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(nullable=False, index=True) + task_number: int = Field(nullable=False) + area_sqkm: Decimal = Field(nullable=False) + + status: TaskStatus = Field( + default=TaskStatus.TO_MAP, + sa_column=_task_status_column(), + ) + + last_mapper_id: Optional[str] = Field(default=None, nullable=True) + + geometry: Optional[Any] = Field( + default=None, + sa_column=Column( + Geometry(geometry_type="POLYGON", srid=4326), + nullable=False, + ), + ) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + +class TaskingLock(SQLModel, table=True): + """Active / historical lock on a task. + + Active rows have `released_at IS NULL`. Two partial unique indexes + enforce: at most one active lock per task, and at most one active + lock per (project, user). + """ + + __tablename__ = "tasking_locks" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + + task_status_at_lock: TaskStatus = Field( + sa_column=_task_status_column(), + ) + + locked_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + expires_at: datetime = Field(nullable=False) + released_at: Optional[datetime] = None + + release_reason: Optional[LockReleaseReason] = Field( + default=None, + sa_column=_release_reason_column(), + ) + + +class TaskingChangeset(SQLModel, table=True): + """One row per `/submit` call — links a lock session to an OSM changeset.""" + + __tablename__ = "tasking_changesets" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + lock_id: int = Field(nullable=False) + user_auth_uid: str = Field(nullable=False) + osm_changeset_id: int = Field(nullable=False) + + submitted_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +class TaskingFeedback(SQLModel, table=True): + """Per-task feedback row (remap rejections + free-form notes).""" + + __tablename__ = "tasking_feedback" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + task_id: int = Field(nullable=False) + project_id: int = Field(nullable=False) + author_user_auth_uid: str = Field(nullable=False) + + reason_category: Optional[FeedbackReason] = Field( + default=None, + sa_column=_feedback_reason_column(), + ) + notes: str = Field(nullable=False) + + created_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False}, + ) + + +__all__ = [ + "FeedbackReason", + "LockReleaseReason", + "TaskStatus", + "TaskingChangeset", + "TaskingFeedback", + "TaskingLock", + "TaskingTask", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a79b530 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,242 @@ + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Callable +from uuid import UUID, uuid4 + +import pytest + + +# Constants — referenced by both unit and integration suites. +SEED_WORKSPACE_ID = 1899 +SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") + + +@pytest.fixture +def seeded_workspace_id() -> int: + """Workspace id used by route URLs. + + Unit tests pretend this exists via a FakeWorkspaceRepository. + Integration overrides this fixture (and seeds a real workspaces + row with the same id) in ``tests/integration/conftest.py``. + """ + return SEED_WORKSPACE_ID + + +# --------------------------------------------------------------------------- +# HTTP client — ASGI transport (no socket); shared by unit and +# integration suites. The `request` / `response` event hooks log every +# call through stdlib `logging` so pytest-html captures the full HTTP +# trace per test in the report. +# --------------------------------------------------------------------------- + + +import logging # noqa: E402 (kept near the fixture for locality) + +_http_log = logging.getLogger("tests.http") + + +# --------------------------------------------------------------------------- +# pytest-html: surface each test's docstring as a "Description" column +# so the report is self-explanatory. +# --------------------------------------------------------------------------- + + +def _test_description(item) -> str: + """Pull the first non-empty docstring line off a pytest item.""" + fn = getattr(item, "function", None) or getattr(item, "obj", None) + doc = (getattr(fn, "__doc__", None) or "").strip() + if not doc: + return "" + return doc.splitlines()[0].strip() + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Annotate the test report with the docstring for pytest-html to render.""" + outcome = yield + report = outcome.get_result() + report.description = _test_description(item) + + +def pytest_html_results_table_header(cells): + """Inject a 'Description' column header next to the test name.""" + cells.insert(2, "Description") + + +def pytest_html_results_table_row(report, cells): + """Inject the docstring as the matching cell on each row.""" + cells.insert(2, f"{getattr(report, 'description', '') or '—'}") + + +def _redact(headers) -> str: + redact = {"authorization", "cookie", "set-cookie", "x-api-key"} + return ", ".join( + f"{k}={'***' if k.lower() in redact else v}" + for k, v in headers.items() + ) + + +@pytest.fixture +async def client() -> AsyncIterator: + from httpx import ASGITransport, AsyncClient + + from api.main import app + + async def _on_request(request): + body_preview = "" + try: + if request.content: + raw = request.content.decode(errors="replace") + body_preview = f" body={raw[:500]}{'…' if len(raw) > 500 else ''}" + except Exception: + pass + _http_log.info( + "→ %s %s%s%s", + request.method, + request.url.path, + f"?{request.url.query.decode()}" if request.url.query else "", + body_preview, + ) + + async def _on_response(response): + try: + await response.aread() + except Exception: + pass + body_preview = "" + if response.content: + try: + raw = response.content.decode(errors="replace") + body_preview = f" body={raw[:500]}{'…' if len(raw) > 500 else ''}" + except Exception: + pass + _http_log.info( + "← %s %s %s (%dB)%s", + response.status_code, + response.request.method, + response.request.url.path, + len(response.content), + body_preview, + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + event_hooks={"request": [_on_request], "response": [_on_response]}, + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Fake users — bypass TDEI/Keycloak by overriding `validate_token`. +# --------------------------------------------------------------------------- + + +def _make_user( + *, + role: str | None, + workspace_id: int, + pg_id: UUID, + is_poc: bool = False, +): + """Construct a UserInfo with the minimum fields the gates inspect.""" + from api.core.security import ( + TdeiProjectGroupRole, + UserInfo, + UserInfoPGMembership, + ) + from api.src.users.schemas import WorkspaceUserRoleType + + u = UserInfo() + u.credentials = "fake-token" + u.user_uuid = uuid4() + u.user_name = f"test-{role or 'outsider'}-{u.user_uuid.hex[:6]}" + + if role == "lead": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.LEAD]} + elif role == "validator": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.VALIDATOR]} + elif role == "contributor": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.CONTRIBUTOR]} + else: + u.osmWorkspaceRoles = {} + + pg_roles = [TdeiProjectGroupRole.MEMBER] + if is_poc: + pg_roles.append(TdeiProjectGroupRole.POINT_OF_CONTACT) + + # Outsiders belong to no project group at all -> 404 on tenancy gate. + if role is None and not is_poc: + u.projectGroups = [] + u.accessibleWorkspaceIds = {} + else: + u.projectGroups = [ + UserInfoPGMembership( + project_group_name="Test PG", + project_group_id=str(pg_id), + tdeiRoles=pg_roles, + ) + ] + u.accessibleWorkspaceIds = {str(pg_id): [workspace_id]} + + return u + + +@pytest.fixture +def override_user() -> Iterator[Callable]: + """Yields a setter that swaps `validate_token` for the duration of a test.""" + from api.core.security import validate_token + from api.main import app + + def _set(user) -> None: + app.dependency_overrides[validate_token] = lambda: user + + yield _set + app.dependency_overrides.pop(validate_token, None) + + +@pytest.fixture +def as_lead(override_user, seeded_workspace_id): + user = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_contributor(override_user, seeded_workspace_id): + user = _make_user( + role="contributor", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_validator(override_user, seeded_workspace_id): + user = _make_user( + role="validator", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_outsider(override_user, seeded_workspace_id): + """User with no project-group association — tenancy gate should 404.""" + user = _make_user( + role=None, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..b7f0bf5 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,510 @@ + +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import AsyncIterator, Iterator + +import pytest + +from tests.conftest import SEED_PROJECT_GROUP_ID, SEED_WORKSPACE_ID + + +# --------------------------------------------------------------------------- +# Docker availability gate — skip cleanly when the daemon is missing +# and surface the actual reason in the pytest skip message. +# --------------------------------------------------------------------------- + + +def _docker_status() -> tuple[bool, str]: + """Returns (ok, reason). 'reason' is empty when ok, otherwise diagnostic text.""" + try: + r = subprocess.run( + ["docker", "info", "--format", "{{.ServerVersion}}"], + capture_output=True, + text=True, + timeout=10, + ) + except FileNotFoundError: + return False, ( + "`docker` CLI not on PATH for subprocess. " + "If you use colima/OrbStack/Rancher Desktop, confirm the docker " + "binary is in /usr/local/bin or symlinked there." + ) + except subprocess.TimeoutExpired: + return False, ( + "`docker info` timed out after 10s — daemon is probably not " + "running. Start Docker Desktop (or your runtime of choice)." + ) + + if r.returncode == 0: + return True, "" + + err = (r.stderr or r.stdout or "").strip().splitlines() + # Keep the skip message readable: first non-empty line is usually enough. + first = next((ln for ln in err if ln.strip()), "") + return False, f"`docker info` exit {r.returncode}: {first[:200]}" + + +_DOCKER_OK, _DOCKER_REASON = _docker_status() + + +# --------------------------------------------------------------------------- +# PostGIS container. +# +# Booted in `pytest_configure` so the TASK_DATABASE_URL and +# OSM_DATABASE_URL environment variables are set before any test file +# imports `api.*` — the engines in `api.core.database` are constructed +# at module load and would otherwise bind to the URLs from `.env`. +# +# Each alembic tree runs against its own database to avoid colliding +# on the default `alembic_version` table: +# - default container DB → TASK_DATABASE_URL (workspaces, workspaces_*) +# - provisioned `/osm_test` → OSM_DATABASE_URL (users, teams, tasking_*) +# --------------------------------------------------------------------------- + + +async def _provision_osm_db(task_url: str, db_name: str) -> str: + """Create an additional database on the same Postgres instance. + + Returns the connection URL for the new DB. Uses AUTOCOMMIT isolation so + ``CREATE DATABASE`` doesn't error inside a transaction. + """ + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(task_url, isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :n"), + {"n": db_name}, + ) + if result.scalar() is None: + await conn.execute(text(f'CREATE DATABASE "{db_name}"')) + finally: + await engine.dispose() + return task_url.rsplit("/", 1)[0] + "/" + db_name + + +# Module-level state owned by pytest_configure / pytest_unconfigure. +_INTEGRATION_CONTAINER = None + + +def _wants_integration(config) -> bool: + """Inspect the markexpr to decide whether to boot the container this run.""" + m = (config.option.markexpr or "").strip() + if not m: + # Bare `pytest` runs with `addopts = -m 'not integration'`, so m + # will be 'not integration' — handled below. Defensive default. + return False + if m == "not integration": + return False + # Anything that mentions integration positively triggers a boot. + return "integration" in m + + +def pytest_configure(config): + """Boot the testcontainer BEFORE test files are imported (collection phase). + + This sets ``TASK_DATABASE_URL`` and ``OSM_DATABASE_URL`` in ``os.environ`` + so that ``api.core.config.settings`` — read at module load by + ``api.core.database`` to build engines — picks up the container URLs. + """ + global _INTEGRATION_CONTAINER + + if not _wants_integration(config): + return + if not _DOCKER_OK: + return # let the fixture surface the skip with a clean reason + try: + from testcontainers.postgres import PostgresContainer + except ImportError: + return # ditto — fixture-time skip with install instructions + + import asyncio + + container = PostgresContainer("postgis/postgis:16-3.4", driver="asyncpg") + container.start() + task_url = container.get_connection_url() + osm_url = asyncio.run(_provision_osm_db(task_url, "osm_test")) + + os.environ["TASK_DATABASE_URL"] = task_url + os.environ["OSM_DATABASE_URL"] = osm_url + _INTEGRATION_CONTAINER = container + + +def pytest_unconfigure(config): + """Tear the container down at the end of the pytest session.""" + global _INTEGRATION_CONTAINER + if _INTEGRATION_CONTAINER is not None: + try: + _INTEGRATION_CONTAINER.stop() + finally: + _INTEGRATION_CONTAINER = None + + +@pytest.fixture(scope="session") +def _pg_urls() -> Iterator[tuple[str, str]]: + """Yield (task_url, osm_url) populated by ``pytest_configure``.""" + if not _DOCKER_OK: + pytest.skip(f"Docker not available — {_DOCKER_REASON}") + try: + import testcontainers.postgres # noqa: F401 + except ImportError: + pytest.skip( + "testcontainers not installed; install with " + "`uv sync --extra integration`" + ) + + task_url = os.environ.get("TASK_DATABASE_URL") + osm_url = os.environ.get("OSM_DATABASE_URL") + if not task_url or not osm_url or _INTEGRATION_CONTAINER is None: + pytest.skip( + "Integration container is not running — invoke pytest with " + "`-m integration` so pytest_configure can boot it." + ) + yield task_url, osm_url + + +# Backwards-compatible alias for fixtures that only need one URL (the +# tasking_* tables live in the OSM database). +@pytest.fixture(scope="session") +def _pg_url(_pg_urls: tuple[str, str]) -> str: + return _pg_urls[1] + + +# --------------------------------------------------------------------------- +# Alembic upgrade — each tree against its own database. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]: + """Run alembic upgrades for both trees against their own databases. + + Returns ``(task_url, osm_url)`` after both heads are reached. + """ + task_url, osm_url = _pg_urls + repo_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + env = os.environ.copy() + env["TASK_DATABASE_URL"] = task_url + env["OSM_DATABASE_URL"] = osm_url + for db_name in ("task", "osm"): + result = subprocess.run( + [sys.executable, "-m", "alembic", "-n", db_name, "upgrade", "head"], + capture_output=True, + text=True, + cwd=repo_root, + env=env, + ) + if result.returncode != 0: + pytest.fail( + f"alembic '{db_name}' upgrade failed:\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + return task_url, osm_url + + +# --------------------------------------------------------------------------- +# Seed: one workspace row matching SEED_WORKSPACE_ID. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: + """Insert one workspace row so tenancy/permission gates resolve. + + Workspaces live in the TASK database (alongside teams, project groups), + so we point this at task_url. + """ + from uuid import uuid4 + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + task_url, _osm_url = _migrated_db + engine = create_async_engine(task_url, future=True) + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO workspaces " + "(id, type, title, \"tdeiProjectGroupId\", \"createdAt\", " + " \"createdBy\", \"createdByName\", \"externalAppAccess\") " + "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " + "ON CONFLICT (id) DO NOTHING" + ), + { + "id": SEED_WORKSPACE_ID, + "type": "osw", + "title": "Test Workspace", + "pgid": str(SEED_PROJECT_GROUP_ID), + "uid": str(uuid4()), + "uname": "seed", + }, + ) + await engine.dispose() + return SEED_WORKSPACE_ID + + +# Override the shared `seeded_workspace_id` fixture so that, inside the +# integration suite, requesting it triggers the testcontainer + migration +# + seed pipeline. Unit tests get the bare constant from tests/conftest.py. +@pytest.fixture +async def seeded_workspace_id(_seed_workspace_row: int) -> int: + return _seed_workspace_row + + +# --------------------------------------------------------------------------- +# User-row seeding — `tasking_project_roles.user_auth_uid` has a FK to +# `users.auth_uid`, so every fake user that hits the DB needs a matching +# row. Override the shared auth fixtures here to handle that. +# --------------------------------------------------------------------------- + + +async def _insert_user_row(osm_url: str, user) -> None: + """Insert a row into ``users`` matching a fabricated UserInfo.""" + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(osm_url) + try: + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO users (auth_uid, email, display_name) " + "VALUES (:uid, :email, :name) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "uid": str(user.user_uuid), + "email": f"{user.user_uuid}@test.local", + "name": user.user_name, + }, + ) + finally: + await engine.dispose() + + +def _override_token(user) -> None: + from api.core.security import validate_token + from api.main import app + + app.dependency_overrides[validate_token] = lambda: user + + +def _clear_token() -> None: + from api.core.security import validate_token + from api.main import app + + app.dependency_overrides.pop(validate_token, None) + + +# --------------------------------------------------------------------------- +# Per-test DB session override. +# +# `api/core/database.py` constructs its asyncpg engines at module load, +# binding their pool connections to the event loop active at that +# moment. pytest-asyncio uses a fresh event loop per function-scoped +# test, so a shared engine would carry stale connections across loops +# and asyncpg would raise ``InterfaceError: cannot perform operation: +# another operation is in progress``. +# +# The autouse fixture below replaces `get_task_session` and +# `get_osm_session` with per-test engines (NullPool, disposed on +# teardown), so each test opens its connections on its own loop. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +async def _per_test_db_sessions(_pg_urls): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import NullPool + from sqlmodel.ext.asyncio.session import AsyncSession + + from api.core.database import get_osm_session, get_task_session + from api.main import app + + task_url, osm_url = _pg_urls + + # NullPool disables connection reuse: each session checkout opens a + # fresh connection on the current event loop and disposes it on + # exit, preventing cross-loop pooled-connection errors. + task_engine = create_async_engine(task_url, future=True, poolclass=NullPool) + osm_engine = create_async_engine(osm_url, future=True, poolclass=NullPool) + + task_factory = sessionmaker( + class_=AsyncSession, expire_on_commit=False, bind=task_engine + ) + osm_factory = sessionmaker( + class_=AsyncSession, expire_on_commit=False, bind=osm_engine + ) + + async def _get_task(): + async with task_factory() as session: + try: + yield session + finally: + await session.close() + + async def _get_osm(): + async with osm_factory() as session: + try: + yield session + finally: + await session.close() + + app.dependency_overrides[get_task_session] = _get_task + app.dependency_overrides[get_osm_session] = _get_osm + + try: + yield + finally: + app.dependency_overrides.pop(get_task_session, None) + app.dependency_overrides.pop(get_osm_session, None) + await task_engine.dispose() + await osm_engine.dispose() + + +# Role fixtures — each inserts a matching `users` row and overrides +# `validate_token`. These shadow the unit-suite counterparts in +# tests/conftest.py for every test under tests/integration/. + +@pytest.fixture +async def as_lead(_pg_urls, seeded_workspace_id): + """LEAD user persisted in users table + overridden in validate_token.""" + from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + + _, osm_url = _pg_urls + user = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_contributor(_pg_urls, seeded_workspace_id): + """CONTRIBUTOR user persisted in users table + overridden in validate_token.""" + from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + + _, osm_url = _pg_urls + user = _make_user( + role="contributor", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_validator(_pg_urls, seeded_workspace_id): + """VALIDATOR user persisted in users table + overridden in validate_token.""" + from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + + _, osm_url = _pg_urls + user = _make_user( + role="validator", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +@pytest.fixture +async def as_outsider(_pg_urls, seeded_workspace_id): + """Outsider — no PG membership. + + Inserted into users so role tests don't break, but their workspace + role list is empty so the tenancy gate still 404s. + """ + from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + + _, osm_url = _pg_urls + user = _make_user( + role=None, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + _override_token(user) + yield user + _clear_token() + + +# --------------------------------------------------------------------------- +# Extra-user factory — for tests that need additional users beyond the +# single "active" caller. Inserts the matching `users` row so FKs hold, +# but does NOT swap `validate_token`. Tests that want to act-as the new +# user should pair this with the shared `override_user` fixture. +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def extra_user_factory(_pg_urls, seeded_workspace_id): + """Factory: ``await make_user("contributor")`` returns a fresh UserInfo + persisted in the OSM `users` table. + + The caller's `validate_token` override is left untouched — pair with + the `override_user` fixture from ``tests/conftest.py`` to act as the + returned user. + """ + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user + + _, osm_url = _pg_urls + + async def _make(role: str | None): + user = _make_user( + role=role, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + await _insert_user_row(osm_url, user) + return user + + return _make + + +# --------------------------------------------------------------------------- +# Per-test cleanup of tasking_* tables (opt-in). +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def reset_tasking(_pg_url: str) -> AsyncIterator[None]: + """Truncate tasking_* tables between tests that opt in. + + Workflow tests inside a class share state by design (class-scoped + project id passed between steps), so this fixture is *opt-in* — + only request it from tests that need a clean slate. + """ + yield + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(_pg_url, future=True) + async with engine.begin() as conn: + await conn.execute( + text( + "TRUNCATE TABLE " + " tasking_audit_events, tasking_feedback, " + " tasking_task_save_idempotency, tasking_locks, " + " tasking_tasks, tasking_project_roles, tasking_projects " + "RESTART IDENTITY CASCADE" + ) + ) + await engine.dispose() diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py new file mode 100644 index 0000000..c7ec969 --- /dev/null +++ b/tests/integration/test_projects_flow.py @@ -0,0 +1,322 @@ + +from __future__ import annotations + +import pytest + +# Mark the whole module — every test in this file requires the +# testcontainer + migrated DB + seeded workspace fixtures. +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + +# A simple unit-square polygon in WGS84 — well-formed, non-self-intersecting. +SQUARE_POLY = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +SQUARE_MULTI = { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]], + ], +} + + +# --------------------------------------------------------------------------- +# Workflow 1 — happy path through the full lifecycle. +# --------------------------------------------------------------------------- + + +class TestProjectLifecycle: + """draft -> upload AOI -> patch -> activate (still 422 w/o tasks).""" + + project_id: int | None = None + + async def test_01_create_draft(self, client, as_lead, seeded_workspace_id): + """Create a draft project — fresh row, status=draft, no AOI, no tasks.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot project", "review_required": True}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["status"] == "draft" + assert body["has_aoi"] is False + assert body["task_count"] == 0 + TestProjectLifecycle.project_id = body["id"] + + async def test_02_get_round_trip(self, client, as_lead, seeded_workspace_id): + """GET round-trips the project just created (same id).""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" + ) + assert r.status_code == 200 + assert r.json()["id"] == self.project_id + + async def test_03_patch_name(self, client, as_lead, seeded_workspace_id): + """PATCH the project name and confirm the update is reflected.""" + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}", + json={"name": "Pilot project (renamed)"}, + ) + assert r.status_code == 200 + assert r.json()["name"] == "Pilot project (renamed)" + + async def test_04_upload_polygon_aoi_is_upcast( + self, client, as_lead, seeded_workspace_id + ): + """Upload a Polygon AOI — storage column is MULTIPOLYGON so server upcasts.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=SQUARE_POLY, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["geometry"]["type"] == "MultiPolygon" + assert body["geometry"]["coordinates"][0] == SQUARE_POLY["coordinates"] + + async def test_05_activate_blocked_without_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Activate must fail with 422 when the project has no tasks yet.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/activate" + ) + assert r.status_code == 422 + assert "task" in r.json()["detail"].lower() + + async def test_06_aoi_get_returns_feature( + self, client, as_lead, seeded_workspace_id + ): + """GET /aoi returns a GeoJSON Feature wrapping a MultiPolygon.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi" + ) + assert r.status_code == 200 + body = r.json() + assert body["type"] == "Feature" + assert body["geometry"]["type"] == "MultiPolygon" + + async def test_07_soft_delete_clears_listing( + self, client, as_lead, seeded_workspace_id + ): + """DELETE soft-removes the project; it vanishes from list and direct GET 404s.""" + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" + ) + assert r.status_code == 204 + + r = await client.get(API.format(wid=seeded_workspace_id)) + ids = {row["id"] for row in r.json()["results"]} + assert self.project_id not in ids + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 2 — AOI input variants on a fresh project. +# --------------------------------------------------------------------------- + + +class TestAoiInputShapes: + """Polygon / MultiPolygon / Feature / FeatureCollection all accepted.""" + + @pytest.fixture + async def fresh_project(self, client, as_lead, seeded_workspace_id): + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": f"AOI shapes {id(self)}"}, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + @pytest.mark.parametrize( + "shape", + [ + SQUARE_POLY, + SQUARE_MULTI, + { + "type": "Feature", + "geometry": SQUARE_POLY, + "properties": {"note": "wrapped"}, + }, + { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": SQUARE_POLY}], + }, + ], + ids=["polygon", "multipolygon", "feature", "feature_collection"], + ) + async def test_aoi_shape_accepted( + self, client, as_lead, seeded_workspace_id, fresh_project, shape + ): + """Each of Polygon / MultiPolygon / Feature / FeatureCollection is accepted and normalised.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{fresh_project}/aoi", + json=shape, + ) + assert r.status_code == 200, r.text + assert r.json()["geometry"]["type"] == "MultiPolygon" + + async def test_invalid_aoi_rejected( + self, client, as_lead, seeded_workspace_id, fresh_project + ): + """Self-intersecting bowtie polygon is rejected with 422 (Shapely is_valid=False).""" + bowtie = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]], + } + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{fresh_project}/aoi", + json=bowtie, + ) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# Workflow 3 — permission + tenancy gates. +# --------------------------------------------------------------------------- + + +class TestProjectPermissions: + async def test_contributor_cannot_create( + self, client, as_contributor, seeded_workspace_id + ): + """Contributor is forbidden from creating projects (403, LEAD-only endpoint).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "should not exist"}, + ) + assert r.status_code == 403 + + async def test_contributor_can_list( + self, client, as_contributor, seeded_workspace_id + ): + """Contributor can list projects in their workspace (read access).""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 200 + assert "results" in r.json() + + async def test_outsider_404s_on_list( + self, client, as_outsider, seeded_workspace_id + ): + """Outsider with no project-group membership gets 404 — workspace existence hidden.""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 3b — error mapping (constraint violations → precise HTTP status). +# --------------------------------------------------------------------------- + + +class TestProjectCreateErrors: + async def test_role_assignment_with_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """`role_assignments` with a uuid that has no `users` row → 422 + missing list, not a 409 / 500.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={ + "name": "role-fk-error", + "role_assignments": [ + {"user_id": bogus, "role": "contributor"} + ], + }, + ) + assert r.status_code == 422, r.text + body = r.json() + # FastAPI nests structured `detail` payloads under the `detail` key. + assert "missing_user_ids" in body["detail"] + assert bogus in body["detail"]["missing_user_ids"] + assert "users" in body["detail"]["message"].lower() + + async def test_duplicate_project_name_returns_409_with_specific_message( + self, client, as_lead, seeded_workspace_id + ): + """A duplicate name surfaces as 409 with the name-conflict message — NOT the generic constraint hint.""" + name = "duplicate-name-test" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": name}, + ) + assert r.status_code == 201, r.text + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": name}, + ) + assert r.status_code == 409, r.text + assert "already exists" in r.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Workflow 4 — AOI delete / replace clears tasks. +# --------------------------------------------------------------------------- + + +class TestAoiReplaceSemantics: + async def test_aoi_replace_resets_boundary_type( + self, client, as_lead, seeded_workspace_id + ): + """Replacing the AOI clears task_boundary_type (per spec — geometry no longer matches).""" + # Create + first AOI. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "replace-aoi"}, + ) + pid = r.json()["id"] + r1 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_POLY, + ) + assert r1.status_code == 200 + + # Replace AOI with a different one. + r2 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_MULTI, + ) + assert r2.status_code == 200 + assert ( + r2.json()["geometry"]["coordinates"] + == SQUARE_MULTI["coordinates"] + ) + + # Boundary type should have been cleared (per spec). + proj = ( + await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") + ).json() + assert proj["task_boundary_type"] is None + + async def test_aoi_delete_round_trip( + self, client, as_lead, seeded_workspace_id + ): + """DELETE /aoi removes the AOI; subsequent GET returns 404.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "delete-aoi"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=SQUARE_POLY, + ) + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" + ) + assert r.status_code == 204 + + # Subsequent GET 404s. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" + ) + assert r.status_code == 404 diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py new file mode 100644 index 0000000..1cf712a --- /dev/null +++ b/tests/integration/test_tasks_flow.py @@ -0,0 +1,937 @@ + + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +# AOI that comfortably contains all task polygons below. +AOI_UNIT_SQUARE = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +# Small (~1 km²) task polygons that fit inside the AOI and stay below the +# default grid-size warning threshold (5000 m → 25 km²). +TASK_A = { + "type": "Polygon", + "coordinates": [ + [[0.00, 0.00], [0.01, 0.00], [0.01, 0.01], [0.00, 0.01], [0.00, 0.00]] + ], +} +TASK_B = { + "type": "Polygon", + "coordinates": [ + [[0.02, 0.02], [0.03, 0.02], [0.03, 0.03], [0.02, 0.03], [0.02, 0.02]] + ], +} +TASK_C = { + "type": "Polygon", + "coordinates": [ + [[0.04, 0.04], [0.05, 0.04], [0.05, 0.05], [0.04, 0.05], [0.04, 0.04]] + ], +} + +# A "fat" polygon (1°×1° ≈ 12 000 km²) — triggers the grid-size warning. +TASK_FAT = AOI_UNIT_SQUARE + + +def _fc(*polys: dict) -> dict: + return { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": p} for p in polys], + } + + +# --------------------------------------------------------------------------- +# Common setup helper — used by most test classes to land a project in the +# ``open`` state with N tasks saved and a contributor + validator allocated. +# --------------------------------------------------------------------------- + + +async def _create_open_project( + client, + workspace_id: int, + *, + contributor_uuid, + validator_uuid, + task_polygons: list[dict], + review_required: bool = True, + name_suffix: str = "", +) -> int: + """Create -> AOI -> save tasks -> activate. Returns the project id. + + Caller must already be acting as a LEAD (the route is LEAD-only). + The two role UUIDs satisfy the activation pre-check (≥1 contributor + or validator). Both users must exist in the OSM ``users`` table. + """ + name = f"flow-{id(task_polygons)}{name_suffix}" + r = await client.post( + API.format(wid=workspace_id), + json={ + "name": name, + "review_required": review_required, + "role_assignments": [ + {"user_id": str(contributor_uuid), "role": "contributor"}, + {"user_id": str(validator_uuid), "role": "validator"}, + ], + }, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200, r.text + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/tasks/save", + json={"source": "import", "feature_collection": _fc(*task_polygons)}, + ) + assert r.status_code == 201, r.text + + r = await client.post(f"{API.format(wid=workspace_id)}/{pid}/activate") + assert r.status_code == 200, r.text + assert r.json()["status"] == "open" + return pid + + +# --------------------------------------------------------------------------- +# Workflow 0 — Server-side grid generation. +# --------------------------------------------------------------------------- + + +class TestGridGeneration: + """LEAD: upload AOI, generate a grid, post the grid back through /tasks/save.""" + + async def test_grid_then_save_round_trip( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Grid generates clipped cells over the AOI; same payload commits via /tasks/save.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-flow"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + + # 1 km² cells over a 1° × 1° AOI ≈ 110 × 110 grid → ~12 000 cells. + # Use 25 km cells (~5x5 grid) so the test stays fast. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid?cell_size_meters=25000" + ) + assert r.status_code == 200, r.text + fc = r.json() + assert fc["type"] == "FeatureCollection" + assert len(fc["features"]) > 0 + # Every cell is a Polygon and inside the unit-square AOI bounds. + for feat in fc["features"]: + assert feat["geometry"]["type"] == "Polygon" + for ring in feat["geometry"]["coordinates"]: + for lon, lat in ring: + assert 0 <= lon <= 1 and 0 <= lat <= 1 + + # Round-trip: hand the same payload to /tasks/save with source=grid. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json={"source": "grid", "feature_collection": fc}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["task_count"] == len(fc["features"]) + assert body["task_boundary_type"] == "grid" + + async def test_grid_blocked_without_aoi( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Project AOI must be set before /tasks/grid will produce cells.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-no-aoi"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" + ) + assert r.status_code == 422, r.text + assert "aoi" in r.json()["detail"].lower() + + async def test_grid_blocked_outside_draft( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + reset_tasking, + ): + """/tasks/grid is rejected once the project leaves draft.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-grid-state", + ) + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" + ) + assert r.status_code == 422, r.text + assert "draft" in r.json()["detail"].lower() + + async def test_grid_multipolygon_straddling_cell_splits( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """A cell that straddles two disjoint AOI lobes is split into one Polygon per lobe. + + AOI is two 0.1°×0.1° lobes separated by a 0.1° east-west gap + (total east-west extent 0.3° ≈ 33 km at the equator). With a + 50 km cell (~0.45°), the entire AOI fits inside a single grid + cell whose intersection with the AOI is a MultiPolygon of two + pieces — the endpoint must surface those as two separate + Polygon features (one per lobe), not a single MultiPolygon. + """ + two_lobe_aoi = { + "type": "MultiPolygon", + "coordinates": [ + # Lobe A — west. + [[[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1], [0.0, 0.0]]], + # Lobe B — same latitude band, east of the gap. + [[[0.2, 0.0], [0.3, 0.0], [0.3, 0.1], [0.2, 0.1], [0.2, 0.0]]], + ], + } + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "grid-multipolygon"}, + ) + pid = r.json()["id"] + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=two_lobe_aoi, + ) + assert r.status_code == 200 + + # 50 km cell ≈ 0.45° — large enough to envelop both lobes + # (0.3° east-west extent) in a single straddling cell, forcing + # the MultiPolygon split path. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid?cell_size_meters=50000" + ) + assert r.status_code == 200, r.text + fc = r.json() + + assert fc["type"] == "FeatureCollection" + # The straddling cell produces exactly one Polygon per lobe. + assert len(fc["features"]) == 2, [f["geometry"] for f in fc["features"]] + for feat in fc["features"]: + assert feat["geometry"]["type"] == "Polygon", ( + "straddling cell was not split — got " + f"{feat['geometry']['type']}" + ) + + # The two output polygons should align with the two lobes — + # check each lies entirely within one lobe's x-range. + lobe_a_xs, lobe_b_xs = [], [] + for feat in fc["features"]: + xs = [pt[0] for ring in feat["geometry"]["coordinates"] for pt in ring] + if max(xs) <= 0.11: + lobe_a_xs.append(xs) + elif min(xs) >= 0.19: + lobe_b_xs.append(xs) + assert len(lobe_a_xs) == 1 and len(lobe_b_xs) == 1, ( + "expected one polygon per lobe, got " + f"{len(lobe_a_xs)} in lobe A, {len(lobe_b_xs)} in lobe B" + ) + + # Round-trip: the split features must persist cleanly via /tasks/save. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json={"source": "grid", "feature_collection": fc}, + ) + assert r.status_code == 201, r.text + assert r.json()["task_count"] == 2 + + async def test_grid_contributor_forbidden( + self, + client, + as_contributor, + seeded_workspace_id, + reset_tasking, + ): + """/tasks/grid is LEAD-only — contributors get 403 (no project needed for the gate).""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/999999/tasks/grid" + ) + assert r.status_code == 403, r.text + + +# --------------------------------------------------------------------------- +# Workflow 1 — Validate + Save round-trip. +# --------------------------------------------------------------------------- + + +class TestValidateAndSave: + """LEAD: upload AOI, validate, save, list, get a task.""" + + project_id: int | None = None + + async def test_01_create_draft_with_aoi( + self, client, as_lead, seeded_workspace_id + ): + """Create a draft project and upload the project AOI.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "validate-save"}, + ) + assert r.status_code == 201, r.text + TestValidateAndSave.project_id = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200, r.text + + async def test_02_validate_inside_aoi( + self, client, as_lead, seeded_workspace_id + ): + """Two in-AOI polygons validate cleanly with no warnings.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(TASK_A, TASK_B), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["valid"] is True + assert body["warnings"] == [] + assert body["source"] == "import" + + async def test_03_validate_polygon_outside_aoi_rejected( + self, client, as_lead, seeded_workspace_id + ): + """A polygon outside the project AOI is rejected with 422.""" + outside = { + "type": "Polygon", + "coordinates": [ + [[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]] + ], + } + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(outside), + ) + assert r.status_code == 422, r.text + + async def test_04_validate_oversize_warns( + self, client, as_lead, seeded_workspace_id + ): + """A polygon larger than the grid-size threshold emits a warning but stays valid.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", + json=_fc(TASK_FAT), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["valid"] is True + assert len(body["warnings"]) == 1 + assert body["warnings"][0]["issue"] == "polygon_exceeds_grid_size" + assert body["warnings"][0]["task_index"] == 0 + + async def test_05_save_persists_two_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Save round-trips: returns task count, sequential numbering, sets boundary type.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", + json={"source": "import", "feature_collection": _fc(TASK_A, TASK_B)}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["task_count"] == 2 + assert body["task_boundary_type"] == "import" + assert [t["task_number"] for t in body["tasks"]] == [1, 2] + # First task starts in to_map with no lock and no last_mapper. + assert body["tasks"][0]["status"] == "to_map" + assert body["tasks"][0]["lock"] is None + assert body["tasks"][0]["last_mapper"] is None + + async def test_06_double_save_rejected( + self, client, as_lead, seeded_workspace_id + ): + """A second save into a project that already has tasks 409s.""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", + json={"source": "import", "feature_collection": _fc(TASK_A)}, + ) + assert r.status_code == 409, r.text + + async def test_07_list_tasks_returns_geometry( + self, client, as_lead, seeded_workspace_id + ): + """GET /tasks paginates and always includes geometry.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["pagination"]["total"] == 2 + assert len(body["tasks"]) == 2 + assert body["tasks"][0]["geometry"]["type"] == "Polygon" + + async def test_08_get_single_task( + self, client, as_lead, seeded_workspace_id + ): + """GET /tasks/{n} returns one task with geometry + metadata.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["task_number"] == 1 + assert body["status"] == "to_map" + + async def test_09_aoi_replace_wipes_tasks( + self, client, as_lead, seeded_workspace_id + ): + """Re-uploading the AOI hard-deletes existing tasks (matches spec).""" + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/aoi", + json=AOI_UNIT_SQUARE, + ) + assert r.status_code == 200 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks" + ) + assert r.status_code == 200 + assert r.json()["pagination"]["total"] == 0 + + +# --------------------------------------------------------------------------- +# Workflow 2 — Idempotent save. +# --------------------------------------------------------------------------- + + +class TestSaveIdempotency: + """Idempotency-Key header: replay vs. key-reuse-with-different-body.""" + + async def test_idempotent_save_lifecycle( + self, client, as_lead, seeded_workspace_id, reset_tasking + ): + """Same key + body → 200 replayed; same key + different body → 409.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "idem"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json=AOI_UNIT_SQUARE, + ) + + body_a = {"source": "import", "feature_collection": _fc(TASK_A)} + body_b = {"source": "import", "feature_collection": _fc(TASK_B)} + key = "idem-key-001" + + r1 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_a, + headers={"Idempotency-Key": key}, + ) + assert r1.status_code == 201 + first_tasks = r1.json()["tasks"] + + # Replay: same key + same body returns 200 + replayed=true + same payload. + r2 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_a, + headers={"Idempotency-Key": key}, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["replayed"] is True + assert r2.json()["tasks"] == first_tasks + + # Same key with a different body → 409. + r3 = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/save", + json=body_b, + headers={"Idempotency-Key": key}, + ) + assert r3.status_code == 409, r3.text + + +# --------------------------------------------------------------------------- +# Workflow 3 — Lock acquire / release / extend / force-release / one-per-user. +# --------------------------------------------------------------------------- + + +class TestLockLifecycle: + """Lock acquire, extend, release; one-active-lock-per-user-per-project.""" + + project_id: int | None = None + contributor = None + validator = None + + async def test_01_setup_open_project( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Set up a project with 3 tasks + contributor + validator, then activate.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + TestLockLifecycle.contributor = contributor + TestLockLifecycle.validator = validator + + TestLockLifecycle.project_id = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A, TASK_B, TASK_C], + name_suffix="-lock", + ) + + async def test_02_contributor_locks_task_1( + self, client, override_user, seeded_workspace_id + ): + """Contributor acquires the lock on task 1 — task now reports the lock.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["lock"] is not None + assert body["lock"]["user_id"] == str(self.contributor.user_uuid) + + async def test_03_contributor_cannot_lock_second_task( + self, client, override_user, seeded_workspace_id + ): + """One-active-lock-per-user-per-project: locking task 2 returns 409 with existing-lock summary.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/2/lock" + ) + assert r.status_code == 409, r.text + detail = r.json()["detail"] + assert detail["existing_lock"]["task_number"] == 1 + + async def test_04_another_contributor_cannot_lock_task_1( + self, + client, + override_user, + seeded_workspace_id, + extra_user_factory, + ): + """A different contributor cannot lock a task that is already locked (409).""" + other = await extra_user_factory("contributor") + override_user(other) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 409, r.text + + async def test_05_extend_slides_expiry( + self, client, override_user, seeded_workspace_id + ): + """The lock holder can extend; expires_at moves forward.""" + override_user(self.contributor) + before = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + expires_before = before.json()["lock"]["expires_at"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/extend" + ) + assert r.status_code == 200, r.text + assert r.json()["lock"]["expires_at"] > expires_before + + async def test_06_release_own_lock( + self, client, override_user, seeded_workspace_id + ): + """Caller releases their own lock — 204, lock gone on the task.""" + override_user(self.contributor) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 204 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.json()["lock"] is None + + async def test_07_force_release_requires_lead( + self, + client, + override_user, + seeded_workspace_id, + ): + """force=true is LEAD-only; contributor gets 403 even for someone else's lock.""" + # Contributor re-locks task 1 first. + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200 + + # Validator tries to force-release (not a workspace LEAD) → 403. + override_user(self.validator) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock?force=true" + ) + assert r.status_code == 403, r.text + + async def test_08_lead_force_release_succeeds( + self, client, as_lead, seeded_workspace_id + ): + """LEAD force-releases the contributor's lock; release_reason='lead_release'.""" + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock?force=true" + ) + assert r.status_code == 204 + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" + ) + assert r.json()["lock"] is None + + async def test_09_unlock_without_active_lock_is_409( + self, client, override_user, seeded_workspace_id + ): + """Releasing an unlocked task returns 409.""" + override_user(self.contributor) + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 409, r.text + + +# --------------------------------------------------------------------------- +# Workflow 4 — Submit "Done?" flow through review. +# --------------------------------------------------------------------------- + + +class TestSubmitReviewFlow: + """to_map -> contributor submit -> to_review -> validator submit -> completed.""" + + project_id: int | None = None + contributor = None + validator = None + + async def test_01_setup_open_project( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Set up an open project with one task + a contributor and validator.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + TestSubmitReviewFlow.contributor = contributor + TestSubmitReviewFlow.validator = validator + TestSubmitReviewFlow.project_id = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-submit", + ) + + async def test_02_contributor_lock_and_submit_done( + self, client, override_user, seeded_workspace_id + ): + """Contributor locks task 1 then submits done=true → status becomes to_review, lock auto-released.""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200 + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", + json={"osm_changeset_id": 1001, "done": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_review" + assert body["lock"] is None + assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) + + async def test_03_contributor_cannot_lock_for_review( + self, client, override_user, seeded_workspace_id + ): + """to_review tasks reject contributor-role lock attempts (validator/lead only).""" + override_user(self.contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 403, r.text + + async def test_04_validator_locks_to_review( + self, client, override_user, seeded_workspace_id + ): + """Validator can lock a to_review task they did not last map.""" + override_user(self.validator) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + + async def test_05_validator_submit_done_no_feedback_completes( + self, client, override_user, seeded_workspace_id + ): + """Validator submit done=true + no feedback → status=completed.""" + override_user(self.validator) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", + json={"osm_changeset_id": 1002, "done": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "completed" + assert body["lock"] is None + + +# --------------------------------------------------------------------------- +# Workflow 5 — Submit done=false slides the lock; status unchanged. +# --------------------------------------------------------------------------- + + +class TestSubmitDoneFalseSlides: + async def test_submit_done_false_slides_expiry( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """done=false: state unchanged, lock expires_at slides to NOW + lock_timeout_hours.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-slide", + ) + + override_user(contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 200 + expiry_before = r.json()["lock"]["expires_at"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"osm_changeset_id": 5001, "done": False}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_map" # unchanged + assert body["lock"] is not None # still locked + # New expiry is at-or-after submitted_at + lock_timeout, so > original. + assert body["lock"]["expires_at"] >= expiry_before + + +# --------------------------------------------------------------------------- +# Workflow 6 — Validator-feedback remap loop. +# --------------------------------------------------------------------------- + + +class TestRemapFlow: + async def test_remap_loop( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """to_review + feedback → to_remap; feedback row is persisted.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-remap", + ) + + # Contributor maps → to_review. + override_user(contributor) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"osm_changeset_id": 7001, "done": True}, + ) + assert r.json()["status"] == "to_review" + + # Validator validates with feedback → to_remap. + override_user(validator) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={ + "osm_changeset_id": 7002, + "done": True, + "feedback": { + "reason_category": "incomplete_mapping", + "notes": "Please finish the missing footways on the north side.", + }, + }, + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "to_remap" + assert r.json()["lock"] is None + + # Contributor re-locks the remapped task (allowed on to_remap). + override_user(contributor) + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 200, r.text + + +# --------------------------------------------------------------------------- +# Workflow 7 — Self-validation guard. +# --------------------------------------------------------------------------- + + +class TestSelfValidationGuard: + async def test_validator_cannot_validate_own_last_mapping( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """A validator who is also the task's last_mapper cannot lock to_review.""" + validator = await extra_user_factory("validator") + # Need a second worker to satisfy the activation pre-check; the + # validator is doing double-duty (validator + mapper). + contributor = await extra_user_factory("contributor") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-self", + ) + + # Validator maps the task themselves (validators can also lock to_map). + override_user(validator) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"osm_changeset_id": 9001, "done": True}, + ) + + # Now they try to validate their own work → 403. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code == 403, r.text + + +# --------------------------------------------------------------------------- +# Workflow 8 — Per-task reset by LEAD. +# --------------------------------------------------------------------------- + + +class TestTaskReset: + async def test_reset_releases_lock_and_resets_status( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + reset_tasking, + ): + """LEAD reset on a locked to_review task: lock cleared, status back to to_map.""" + contributor = await extra_user_factory("contributor") + validator = await extra_user_factory("validator") + pid = await _create_open_project( + client, + seeded_workspace_id, + contributor_uuid=contributor.user_uuid, + validator_uuid=validator.user_uuid, + task_polygons=[TASK_A], + name_suffix="-rst", + ) + + # Contributor maps → to_review. + override_user(contributor) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", + json={"osm_changeset_id": 11001, "done": True}, + ) + # Validator picks it up. + override_user(validator) + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + + # Switch back to a LEAD token to invoke /reset. The integration + # `as_lead` fixture already inserted a lead users row, so the + # helper here just builds a UserInfo to bind to the override. + from api.core.security import validate_token + from api.main import app + from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + + lead = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + app.dependency_overrides[validate_token] = lambda: lead + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/reset" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "to_map" + assert body["lock"] is None + assert body["last_mapper"] is None diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..8dc9ca8 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,286 @@ + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from uuid import UUID, uuid4 + +import pytest + + +# --------------------------------------------------------------------------- +# Fake workspace repository — tenancy gate without a DB. +# --------------------------------------------------------------------------- + + +class FakeWorkspaceRepo: + """Mirrors ``WorkspaceRepository.getById`` semantics. + + Returns a stub workspace if the caller's accessibleWorkspaceIds + contain the requested id; raises ``NotFoundException`` otherwise. + """ + + async def getById(self, current_user, workspace_id: int): + from api.core.exceptions import NotFoundException + + all_ids = { + wid + for ids in current_user.accessibleWorkspaceIds.values() + for wid in ids + } + if workspace_id not in all_ids: + raise NotFoundException(f"Workspace {workspace_id} not found") + return SimpleNamespace(id=workspace_id) + + +# --------------------------------------------------------------------------- +# Fake project repository — in-memory storage of project rows. +# --------------------------------------------------------------------------- + + +class FakeProjectRepo: + """In-memory stand-in for ``TaskingProjectRepository``. + + Stores projects in a dict keyed by id; returns ``ProjectResponse`` / + ``ProjectListResponse`` exactly as the real repo would. Does NOT + exercise PostGIS, ``ON CONFLICT``, soft-delete predicates, or + cross-table joins — those are integration-suite territory. + """ + + def __init__(self) -> None: + self._projects: dict[int, dict[str, Any]] = {} + self._aois: dict[int, dict[str, Any]] = {} + self._next_id = 1 + + # ---- helpers -------------------------------------------------------- + + def _response(self, p: dict[str, Any]): + from api.src.tasking.projects.dtos import ProjectResponse + + return ProjectResponse( + id=p["id"], + workspace_id=p["workspace_id"], + name=p["name"], + instructions=p.get("instructions"), + status=p["status"], + review_required=p["review_required"], + lock_timeout_hours=p["lock_timeout_hours"], + task_boundary_type=p.get("task_boundary_type"), + has_aoi=p["id"] in self._aois, + task_count=p.get("task_count", 0), + created_by=p["created_by"], + created_by_name=p.get("created_by_name"), + created_at=p["created_at"], + updated_at=p["updated_at"], + ) + + # ---- create / list / get / patch / delete -------------------------- + + async def create(self, workspace_id: int, current_user, body): + from api.core.exceptions import AlreadyExistsException + + if any( + p["workspace_id"] == workspace_id and p["name"] == body.name + for p in self._projects.values() + ): + raise AlreadyExistsException( + "A project with this name already exists in the workspace" + ) + pid = self._next_id + self._next_id += 1 + now = datetime.now() + self._projects[pid] = { + "id": pid, + "workspace_id": workspace_id, + "name": body.name, + "instructions": body.instructions, + "status": "draft", + "review_required": body.review_required, + "lock_timeout_hours": body.lock_timeout_hours, + "task_boundary_type": None, + "created_by": current_user.user_uuid, + "created_by_name": current_user.user_name, + "created_at": now, + "updated_at": now, + "deleted_at": None, + } + if body.aoi is not None: + self._aois[pid] = {"upcast_to": "MultiPolygon"} + return self._response(self._projects[pid]) + + async def list_projects( + self, + workspace_id: int, + *, + status_filter=None, + text_search=None, + page: int = 1, + page_size: int = 20, + order_by: str = "created_at", + order_dir: str = "DESC", + ): + from api.src.tasking.projects.dtos import ( + Pagination, + ProjectListItem, + ProjectListResponse, + ) + + rows = [ + p + for p in self._projects.values() + if p["workspace_id"] == workspace_id and p["deleted_at"] is None + ] + if status_filter is not None: + rows = [p for p in rows if p["status"] == status_filter] + if text_search: + t = text_search.lower() + rows = [p for p in rows if t in p["name"].lower()] + + total = len(rows) + offset = (page - 1) * page_size + items = [ + ProjectListItem( + id=p["id"], + name=p["name"], + status=p["status"], + task_count=0, + percent_completed=0, + created_by=p["created_by"], + created_by_name=p.get("created_by_name"), + created_at=p["created_at"], + updated_at=p["updated_at"], + ) + for p in rows[offset : offset + page_size] + ] + return ProjectListResponse( + results=items, + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def get(self, workspace_id: int, project_id: int): + from api.core.exceptions import NotFoundException + + p = self._projects.get(project_id) + if p is None or p["workspace_id"] != workspace_id or p["deleted_at"]: + raise NotFoundException(f"Project {project_id} not found") + return self._response(p) + + async def patch(self, workspace_id, project_id, body): + p_resp = await self.get(workspace_id, project_id) + p = self._projects[project_id] + if body.name is not None: + p["name"] = body.name.strip() + if body.instructions is not None: + p["instructions"] = body.instructions + if body.lock_timeout_hours is not None: + p["lock_timeout_hours"] = body.lock_timeout_hours + if body.review_required is not None: + p["review_required"] = body.review_required + p["updated_at"] = datetime.now() + return self._response(p) + + async def soft_delete(self, workspace_id, project_id): + await self.get(workspace_id, project_id) + self._projects[project_id]["deleted_at"] = datetime.now() + + async def activate(self, workspace_id, project_id): + from fastapi import HTTPException, status + + # Mirrors the real repo's "needs tasks" rule. + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Project must have at least one task", + ) + + async def close(self, workspace_id, project_id): + from fastapi import HTTPException, status + + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Only open projects can be closed", + ) + + async def reset(self, workspace_id, project_id): + await self.get(workspace_id, project_id) + return self._response(self._projects[project_id]) + + # ---- AOI ------------------------------------------------------------ + + async def get_aoi(self, workspace_id, project_id): + from api.core.exceptions import NotFoundException + from api.src.tasking.projects.dtos import AoiFeature + from api.src.tasking.projects.schemas import _MultiPolygon + + await self.get(workspace_id, project_id) + if project_id not in self._aois: + raise NotFoundException("AOI is not set on this project") + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=[[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + ), + properties={}, + ) + + async def upload_aoi(self, workspace_id, project_id, aoi): + from api.src.tasking.projects.dtos import AoiFeature + from api.src.tasking.projects.schemas import _MultiPolygon + + await self.get(workspace_id, project_id) + self._aois[project_id] = {"raw": aoi} + return AoiFeature( + type="Feature", + geometry=_MultiPolygon( + type="MultiPolygon", + coordinates=[[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + ), + properties={}, + ) + + async def delete_aoi(self, workspace_id, project_id): + from api.core.exceptions import NotFoundException + + await self.get(workspace_id, project_id) + if project_id not in self._aois: + raise NotFoundException("AOI is not set on this project") + del self._aois[project_id] + + +# --------------------------------------------------------------------------- +# Dependency override fixtures. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_project_repo(): + """Swap ``get_project_repo`` with an in-memory FakeProjectRepo.""" + from api.main import app + from api.src.tasking.projects.routes import get_project_repo + + repo = FakeProjectRepo() + app.dependency_overrides[get_project_repo] = lambda: repo + yield repo + app.dependency_overrides.pop(get_project_repo, None) + + +@pytest.fixture +def fake_workspace_repo(): + """Swap ``get_workspace_repo`` with the in-memory FakeWorkspaceRepo.""" + from api.main import app + from api.src.tasking.projects.routes import get_workspace_repo + + repo = FakeWorkspaceRepo() + app.dependency_overrides[get_workspace_repo] = lambda: repo + yield repo + app.dependency_overrides.pop(get_workspace_repo, None) + + +# Convenience: most route tests need both. One fixture, request once. +@pytest.fixture +def fake_repos(fake_project_repo, fake_workspace_repo): + return SimpleNamespace( + project=fake_project_repo, + workspace=fake_workspace_repo, + ) diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py new file mode 100644 index 0000000..19d54a8 --- /dev/null +++ b/tests/unit/test_aoi_normalisation.py @@ -0,0 +1,74 @@ + + +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from shapely.geometry import MultiPolygon as ShapelyMultiPolygon + +from api.src.tasking.projects.repository import _aoi_to_shapely +from api.src.tasking.projects.schemas import ( + _Feature, + _FeatureCollection, + _MultiPolygon, + _Polygon, +) + + +SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] +TWO_SQUARES = [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]], +] + + +class TestAoiUpcast: + def test_polygon_upcasts_to_multipolygon(self): + """A bare GeoJSON Polygon is upcast to a single-member MultiPolygon for storage.""" + out = _aoi_to_shapely(_Polygon(type="Polygon", coordinates=SQUARE)) + assert isinstance(out, ShapelyMultiPolygon) + assert len(out.geoms) == 1 + + def test_multipolygon_passes_through(self): + """A GeoJSON MultiPolygon round-trips with all its constituent polygons intact.""" + out = _aoi_to_shapely( + _MultiPolygon(type="MultiPolygon", coordinates=TWO_SQUARES) + ) + assert isinstance(out, ShapelyMultiPolygon) + assert len(out.geoms) == 2 + + def test_feature_unwrapped(self): + """A GeoJSON Feature wrapping a Polygon is unwrapped and upcast.""" + feat = _Feature( + type="Feature", + geometry=_Polygon(type="Polygon", coordinates=SQUARE), + properties={"k": "v"}, + ) + out = _aoi_to_shapely(feat) + assert isinstance(out, ShapelyMultiPolygon) + + def test_feature_collection_unwrapped(self): + """A single-Feature FeatureCollection is unwrapped (collections with >1 feature are rejected upstream).""" + fc = _FeatureCollection( + type="FeatureCollection", + features=[ + _Feature( + type="Feature", + geometry=_Polygon(type="Polygon", coordinates=SQUARE), + ) + ], + ) + out = _aoi_to_shapely(fc) + assert isinstance(out, ShapelyMultiPolygon) + + +class TestInvalidGeometryRejected: + def test_self_intersecting_polygon_rejected(self): + """A self-intersecting 'bowtie' polygon is rejected with HTTP 422.""" + bowtie = _Polygon( + type="Polygon", + coordinates=[[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]], + ) + with pytest.raises(HTTPException) as exc: + _aoi_to_shapely(bowtie) + assert exc.value.status_code == 422 diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py new file mode 100644 index 0000000..b7395fd --- /dev/null +++ b/tests/unit/test_dtos_validation.py @@ -0,0 +1,99 @@ + + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from api.src.tasking.projects.dtos import ( + ProjectCreateRequest, + ProjectRoleAssignment, + ProjectUpdateRequest, +) + + +# --------------------------------------------------------------------------- +# ProjectCreateRequest +# --------------------------------------------------------------------------- + + +class TestProjectCreateRequest: + def test_minimal_body_accepted(self): + """A body with just `name` populates defaults (review_required=True, lock_timeout=8h).""" + m = ProjectCreateRequest(name="hello") + assert m.name == "hello" + assert m.review_required is True + assert m.lock_timeout_hours == 8 + assert m.aoi is None + assert m.role_assignments == [] + + def test_name_blank_rejected(self): + """Whitespace-only project names are rejected with a clear 'blank' error.""" + with pytest.raises(ValidationError) as exc: + ProjectCreateRequest(name=" ") + assert "blank" in str(exc.value).lower() + + def test_name_too_long_rejected(self): + """Project names longer than 255 characters are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="x" * 256) + + @pytest.mark.parametrize("hours", [0, -1, 721]) + def test_lock_timeout_out_of_range_rejected(self, hours): + """lock_timeout_hours must be in [1, 720]; out-of-range values are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="ok", lock_timeout_hours=hours) + + @pytest.mark.parametrize("hours", [1, 8, 720]) + def test_lock_timeout_in_range_accepted(self, hours): + """lock_timeout_hours boundary values (1, default, 720) are accepted.""" + m = ProjectCreateRequest(name="ok", lock_timeout_hours=hours) + assert m.lock_timeout_hours == hours + + def test_instructions_too_long_rejected(self): + """Instructions over 10,000 characters are rejected.""" + with pytest.raises(ValidationError): + ProjectCreateRequest(name="ok", instructions="x" * 10_001) + + +# --------------------------------------------------------------------------- +# ProjectUpdateRequest +# --------------------------------------------------------------------------- + + +class TestProjectUpdateRequest: + def test_all_fields_optional(self): + """An empty PATCH body is valid — every field is optional.""" + m = ProjectUpdateRequest() + assert m.name is None + assert m.instructions is None + assert m.lock_timeout_hours is None + assert m.review_required is None + + def test_partial_update(self): + """Only specified fields are populated; the rest stay None.""" + m = ProjectUpdateRequest(name="x") + assert m.name == "x" + assert m.review_required is None + + +# --------------------------------------------------------------------------- +# ProjectRoleAssignment +# --------------------------------------------------------------------------- + + +class TestProjectRoleAssignment: + def test_valid_roles(self): + """Each of the three role strings ('lead', 'validator', 'contributor') is accepted.""" + from uuid import uuid4 + + for role in ("lead", "validator", "contributor"): + m = ProjectRoleAssignment(user_id=uuid4(), role=role) + assert m.role == role + + def test_invalid_role_rejected(self): + """Unknown role strings (e.g. 'admin') are rejected by the Literal.""" + from uuid import uuid4 + + with pytest.raises(ValidationError): + ProjectRoleAssignment(user_id=uuid4(), role="admin") diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py new file mode 100644 index 0000000..86987ac --- /dev/null +++ b/tests/unit/test_project_routes.py @@ -0,0 +1,244 @@ + +from __future__ import annotations + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +# --------------------------------------------------------------------------- +# Permission gates +# --------------------------------------------------------------------------- + + +class TestPermissionGates: + async def test_outsider_404s_on_list( + self, client, as_outsider, seeded_workspace_id, fake_repos + ): + """Outsider (no project-group membership) gets 404 on list — tenancy gate hides existence.""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 404 + + async def test_contributor_can_list( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Contributor can list projects in their workspace (read access for any role).""" + r = await client.get(API.format(wid=seeded_workspace_id)) + assert r.status_code == 200 + body = r.json() + assert body["results"] == [] + assert body["pagination"]["total"] == 0 + + async def test_contributor_cannot_create( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Contributor cannot create projects — write endpoints require LEAD.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "x"}, + ) + assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# CRUD happy-path +# --------------------------------------------------------------------------- + + +class TestProjectCrud: + async def test_create_returns_201( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """LEAD creates a draft project — 201 with the persisted body.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot", "review_required": False}, + ) + assert r.status_code == 201 + body = r.json() + assert body["name"] == "Pilot" + assert body["status"] == "draft" + assert body["review_required"] is False + assert len(fake_repos.project._projects) == 1 + + async def test_create_blank_name_422( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Whitespace-only name is rejected by the Pydantic validator (422).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": " "}, + ) + assert r.status_code == 422 + + async def test_get_404_when_missing( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """GET on a non-existent project id returns 404.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/9999" + ) + assert r.status_code == 404 + + async def test_create_then_get_round_trip( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """A project created via POST is readable via GET with the same id.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "RT"}, + ) + pid = r.json()["id"] + + r2 = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}" + ) + assert r2.status_code == 200 + assert r2.json()["id"] == pid + + async def test_patch_name( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """PATCH updates only specified fields — name change is reflected on GET.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "before"}, + ) + ).json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}", + json={"name": "after"}, + ) + assert r.status_code == 200 + assert r.json()["name"] == "after" + + async def test_soft_delete_204_then_404( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """DELETE soft-removes the project; subsequent GET returns 404.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "to-delete"}, + ) + ).json()["id"] + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}" + ) + assert r.status_code == 204 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}" + ) + assert r.status_code == 404 + + async def test_duplicate_name_409( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Creating a project with a duplicate name in the same workspace returns 409.""" + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "dup"}, + ) + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "dup"}, + ) + assert r.status_code == 409 + + +# --------------------------------------------------------------------------- +# Lifecycle gates +# --------------------------------------------------------------------------- + + +class TestLifecycle: + async def test_activate_fake_always_422( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Activate is gated — without tasks it returns 422 (FakeRepo mirrors the real rule).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "act"}, + ) + ).json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/activate" + ) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# AOI sub-resource +# --------------------------------------------------------------------------- + + +class TestAoiRoutes: + async def test_upload_aoi_polygon( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Uploading a Polygon AOI returns a Feature wrapping a MultiPolygon (upcast).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "aoi"}, + ) + ).json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json={ + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + ) + assert r.status_code == 200 + assert r.json()["geometry"]["type"] == "MultiPolygon" + + async def test_get_aoi_404_when_unset( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """GET /aoi on a project with no AOI yet returns 404.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "no-aoi"}, + ) + ).json()["id"] + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" + ) + assert r.status_code == 404 + + async def test_delete_aoi_round_trip( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """DELETE /aoi clears the AOI; subsequent GET /aoi returns 404.""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "to-clear"}, + ) + ).json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi", + json={ + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + ) + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" + ) + assert r.status_code == 204 + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" + ) + assert r.status_code == 404 diff --git a/tests/unit/test_user_info_gates.py b/tests/unit/test_user_info_gates.py new file mode 100644 index 0000000..dcce1a4 --- /dev/null +++ b/tests/unit/test_user_info_gates.py @@ -0,0 +1,85 @@ + + +from __future__ import annotations + +from uuid import UUID + +from api.core.security import ( + TdeiProjectGroupRole, + UserInfo, + UserInfoPGMembership, +) +from api.src.users.schemas import WorkspaceUserRoleType + + +PG = "00000000-0000-0000-0000-000000000001" + + +def _user(*, osm_roles=None, pg_roles=None, accessible=None): + u = UserInfo() + u.credentials = "x" + u.user_uuid = UUID("11111111-1111-1111-1111-111111111111") + u.user_name = "test" + u.osmWorkspaceRoles = osm_roles or {} + u.projectGroups = [ + UserInfoPGMembership( + project_group_name="PG", + project_group_id=PG, + tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], + ) + ] if pg_roles is not None or accessible is not None else [] + u.accessibleWorkspaceIds = accessible or {} + return u + + +class TestIsWorkspaceLead: + def test_explicit_lead_role(self): + """User with WorkspaceUserRoleType.LEAD on the workspace is a lead.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.LEAD]}) + assert u.isWorkspaceLead(42) is True + + def test_poc_in_owning_pg_grants_lead(self): + """TDEI POINT_OF_CONTACT in the project group that owns the workspace implies lead.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.POINT_OF_CONTACT], + accessible={PG: [42]}, + ) + assert u.isWorkspaceLead(42) is True + + def test_member_only_is_not_lead(self): + """A plain TDEI MEMBER (no POC, no explicit LEAD) is NOT a lead.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.MEMBER], + accessible={PG: [42]}, + ) + assert u.isWorkspaceLead(42) is False + + def test_no_membership_is_not_lead(self): + """A user with no project-group membership at all is NOT a lead.""" + assert _user().isWorkspaceLead(42) is False + + +class TestIsWorkspaceValidator: + def test_explicit_validator_role(self): + """User with WorkspaceUserRoleType.VALIDATOR on the workspace is a validator.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.VALIDATOR]}) + assert u.isWorkspaceValidator(42) is True + + def test_lead_is_not_implicit_validator(self): + """LEAD does NOT implicitly grant VALIDATOR — distinct roles by design.""" + u = _user(osm_roles={42: [WorkspaceUserRoleType.LEAD]}) + assert u.isWorkspaceValidator(42) is False + + +class TestIsWorkspaceContributor: + def test_any_pg_membership_is_contributor(self): + """Any project-group membership that owns the workspace makes the user a contributor.""" + u = _user( + pg_roles=[TdeiProjectGroupRole.MEMBER], + accessible={PG: [42]}, + ) + assert u.isWorkspaceContributor(42) is True + + def test_outsider_is_not_contributor(self): + """An outsider (no PG membership) is NOT a contributor.""" + assert _user().isWorkspaceContributor(42) is False From 4827934730ddf5d912c439ad13b5e06f6f6f61ff Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 25 May 2026 00:25:02 +0530 Subject: [PATCH 055/159] remove unwanted notes --- api/src/tasking/projects/dtos.py | 10 +--------- api/src/tasking/projects/schemas.py | 6 ------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index c2c266f..286ef32 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -20,15 +20,7 @@ class WireModel(BaseModel): - """Common base for request and response DTOs. - - `extra="forbid"` rejects unknown keys on input bodies with a 422 so - callers get explicit feedback when they misspell a field or send a - property the endpoint does not accept (e.g. `aoi` on PATCH project, - which has its own sub-resource at `/aoi`). Responses are unaffected - because Pydantic only enforces `extra` during input validation. - """ - + model_config = ConfigDict(extra="forbid") diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 18cde11..829c142 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -39,17 +39,11 @@ class TaskingProject(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - # Cross-DB reference to workspaces.id; no FK by design, matching - # the existing `user_workspace_roles` convention. workspace_id: int = Field(nullable=False, index=True) name: str = Field(max_length=255, nullable=False) instructions: Optional[str] = None - # Bind to the Postgres enum from the migration. `name=` and - # `values_callable` are required so SQLAlchemy uses the existing - # `tasking_project_status` type (lowercase values) instead of - # auto-generating a new one keyed by member names. status: ProjectStatus = Field( default=ProjectStatus.DRAFT, sa_column=Column( From 916dcfa83990033597c610c956845e00b1da48c2 Mon Sep 17 00:00:00 2001 From: MashB Date: Tue, 26 May 2026 15:51:14 +0530 Subject: [PATCH 056/159] env setup issue --- .../9221408912dd_add_user_role_table.py | 6 +- alembic_task/versions/add6266277c7_.py | 2 +- pyproject.toml | 3 +- uv.lock | 114 ++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 3963de9..53e94f2 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -11,6 +11,8 @@ import sqlalchemy as sa from alembic import op from sqlalchemy import inspect, text +from sqlalchemy.dialects import postgresql + # revision identifiers, used by Alembic. revision: str = "9221408912dd" @@ -45,11 +47,11 @@ def upgrade() -> None: if not insp.has_table("user_workspace_roles"): op.create_table( "user_workspace_roles", - sa.Column("user_auth_uid", sa.Uuid(), nullable=False), + sa.Column("user_auth_uid", sa.String(), nullable=False), sa.Column("workspace_id", sa.BigInteger(), nullable=False), sa.Column( "role", - sa.Enum( + postgresql.ENUM( "lead", "validator", "contributor", diff --git a/alembic_task/versions/add6266277c7_.py b/alembic_task/versions/add6266277c7_.py index ed80db5..cd27884 100644 --- a/alembic_task/versions/add6266277c7_.py +++ b/alembic_task/versions/add6266277c7_.py @@ -13,7 +13,7 @@ revision = "add6266277c7" branch_labels = None depends_on = None -down_revision = None +down_revision = "c5121cbba124" def upgrade(): diff --git a/pyproject.toml b/pyproject.toml index b4e4b1f..4f507b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ "requests-cache>=1.2.1", "pyjwt", "sqlmodel>=0.0.8", - "cachetools" + "cachetools", + "shapely>=2.1.2", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index bbd35b2..519273f 100644 --- a/uv.lock +++ b/uv.lock @@ -627,6 +627,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -1055,6 +1116,57 @@ fastapi = [ { name = "fastapi" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1215,6 +1327,7 @@ dependencies = [ { name = "requests" }, { name = "requests-cache" }, { name = "sentry-sdk", extra = ["fastapi"] }, + { name = "shapely" }, { name = "sqlalchemy" }, { name = "sqlmodel" }, { name = "uvicorn" }, @@ -1248,6 +1361,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5" }, { name = "requests-cache", specifier = ">=1.2.1" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.48.0" }, + { name = "shapely", specifier = ">=2.1.2" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "sqlmodel", specifier = ">=0.0.8" }, { name = "uvicorn", specifier = ">=0.32.1" }, From fcfa8b1953fc3a3e5e97d2d0f6ef6dc72e31ae15 Mon Sep 17 00:00:00 2001 From: MashB Date: Wed, 27 May 2026 17:59:10 +0530 Subject: [PATCH 057/159] AOI fix --- api/src/tasking/tasks/repository.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index ab4ee13..bb6cf41 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -108,6 +108,29 @@ def _polygon_area_km2(geom: ShapelyPolygon) -> float: return float(geom.area) * _DEG2_TO_KM2 +# AOI containment is checked with `aoi.covers(poly)`, which uses exact +# predicates. Grid generation produces cells clipped against the AOI; +# the clipped vertices land on (or fractionally outside) the AOI edge +# due to floating-point rounding, so `covers` returns False even though +# the cell is topologically inside the AOI. +# +# Treat a polygon as inside the AOI when the area of `poly.difference(aoi)` +# is below this fraction of the polygon's own area. 1e-9 corresponds to +# ~1 µm² at this latitude — well below any real-world AOI authoring error. +_AOI_COVER_REL_TOLERANCE = 1e-9 + + +def _aoi_covers(aoi_geom, poly: ShapelyPolygon) -> bool: + """Tolerant containment test for AOI vs. task polygon.""" + if aoi_geom.covers(poly): + return True + poly_area = poly.area + if poly_area == 0: + return True + overrun = poly.difference(aoi_geom).area + return overrun <= _AOI_COVER_REL_TOLERANCE * poly_area + + def _generate_grid_over_aoi( aoi: ShapelyMultiPolygon, cell_size_m: float, @@ -430,7 +453,7 @@ async def validate( warnings: list[ValidateWarning] = [] for idx, feat in enumerate(fc.features): poly = _polygon_to_shapely(feat.geometry.model_dump()) - if not aoi_geom.covers(poly): + if not _aoi_covers(aoi_geom, poly): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Task feature {idx} is not fully inside the project AOI", @@ -523,7 +546,7 @@ async def save( created: list[TaskingTask] = [] for idx, feat in enumerate(body.feature_collection.features): poly = _polygon_to_shapely(feat.geometry.model_dump()) - if not aoi_geom.covers(poly): + if not _aoi_covers(aoi_geom, poly): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Task feature {idx} is not fully inside the project AOI", From a037f91d359813efaca0aa0900034a1b8df1fc11 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 28 May 2026 21:18:31 -0700 Subject: [PATCH 058/159] Fix double CREATE TYPE for workspace_role enum --- .../9221408912dd_add_user_role_table.py | 79 ++++--------------- 1 file changed, 16 insertions(+), 63 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index d34f97e..402cf9a 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -10,7 +10,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import inspect, text +from sqlalchemy import text # revision identifiers, used by Alembic. revision: str = "9221408912dd" @@ -20,69 +20,22 @@ def upgrade() -> None: - bind = op.get_bind() - assert bind is not None - insp = inspect(bind) - - # Add unique constraint on users.auth_uid (if not already present) - constraint_exists = bind.execute( - text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") - ).scalar() - if not constraint_exists: - op.create_unique_constraint("auth_uid_unique", "users", ["auth_uid"]) - - # Create the workspace_role enum type (if not already present) - result = bind.execute( - text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") + op.create_unique_constraint("auth_uid_unique", "users", ["auth_uid"]) + op.create_table( + "user_workspace_roles", + sa.Column("user_auth_uid", sa.String(), nullable=False), + sa.Column("workspace_id", sa.BigInteger(), nullable=False), + sa.Column( + "role", + sa.Enum("lead", "validator", "contributor", name="workspace_role"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), + sa.PrimaryKeyConstraint("user_auth_uid", "workspace_id"), ) - if not result.scalar(): - workspace_role = sa.Enum( - "lead", "validator", "contributor", name="workspace_role" - ) - workspace_role.create(bind) - - # Create the user_workspace_roles table (if not already present) - if not insp.has_table("user_workspace_roles"): - op.create_table( - "user_workspace_roles", - sa.Column("user_auth_uid", sa.String(), nullable=False), - sa.Column("workspace_id", sa.BigInteger(), nullable=False), - sa.Column( - "role", - sa.Enum( - "lead", - "validator", - "contributor", - name="workspace_role", - create_type=False, - ), - nullable=False, - ), - sa.ForeignKeyConstraint(["user_auth_uid"], ["users.auth_uid"]), - sa.PrimaryKeyConstraint("user_auth_uid", "workspace_id"), - ) def downgrade() -> None: - bind = op.get_bind() - assert bind is not None - insp = inspect(bind) - - if insp.has_table("user_workspace_roles"): - op.drop_table("user_workspace_roles") - - # Drop the enum type - result = bind.execute( - text("SELECT 1 FROM pg_type WHERE typname = 'workspace_role'") - ) - if result.scalar(): - workspace_role = sa.Enum( - "lead", "validator", "contributor", name="workspace_role" - ) - workspace_role.drop(bind) - - constraint_exists = bind.execute( - text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") - ).scalar() - if constraint_exists: - op.drop_constraint("auth_uid_unique", "users", type_="unique") + op.drop_table("user_workspace_roles") + op.execute(text("DROP TYPE workspace_role")) + op.drop_constraint("auth_uid_unique", "users", type_="unique") From baa60cb1b734e56c531e07143db38b2cdaed738a Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 1 Jun 2026 19:13:01 +0530 Subject: [PATCH 059/159] project roles apis --- .../9221408912dd_add_user_role_table.py | 21 + api/main.py | 20 + api/src/tasking/projects/dtos.py | 60 +++ api/src/tasking/projects/repository.py | 428 +++++++++++++++++ api/src/tasking/projects/routes.py | 174 ++++++- api/src/tasking/projects/schemas.py | 44 ++ docs/tasking-mvp/tasking-mvp.openapi.json | 273 +++++++++-- docs/tasking-mvp/tasking-mvp.openapi.yaml | 209 +++++++-- tests/conftest.py | 26 +- tests/integration/test_projects_flow.py | 442 ++++++++++++++++++ 10 files changed, 1616 insertions(+), 81 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 53e94f2..640453e 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -26,6 +26,27 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) + # `users` is normally owned and migrated by the OSM Rails app. When + # running against a fresh database without Rails (CI/testcontainers, + # or a dev box without the osm-rails service), create a minimal stub + # so the FK from `tasking_project_roles`/`user_workspace_roles` can + # be added below. Guarded by has_table so the production-owned + # schema wins when it is already present. + if not insp.has_table("users"): + op.create_table( + "users", + # `id` matches the Rails `users.id` numeric PK so the FK + # from `team_user.user_id` in the next migration can attach. + sa.Column( + "id", sa.BigInteger(), autoincrement=True, nullable=False + ), + sa.Column("auth_uid", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=True), + sa.Column("display_name", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("auth_uid", name="auth_uid_unique"), + ) + # Add unique constraint on users.auth_uid (if not already present) constraint_exists = bind.execute( text("SELECT 1 FROM pg_constraint WHERE conname = 'auth_uid_unique'") diff --git a/api/main.py b/api/main.py index d218e44..87a95bb 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,9 @@ init_tdei_client, validate_token, ) +from api.src.tasking.projects.routes import me_router as tasking_me_router +from api.src.tasking.projects.routes import router as tasking_projects_router +from api.src.tasking.tasks.routes import router as tasking_tasks_router from api.src.teams.routes import router as teams_router from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository @@ -91,6 +94,9 @@ async def lifespan(_app: FastAPI): app.include_router(teams_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1") app.include_router(workspaces_router, prefix="/api/v1") +app.include_router(tasking_projects_router, prefix="/api/v1") +app.include_router(tasking_me_router, prefix="/api/v1") +app.include_router(tasking_tasks_router, prefix="/api/v1") @app.get("/health") @@ -210,6 +216,20 @@ async def catch_all( Catch-all route to proxy requests to the OSM service. """ + # `/api/v1/...` paths belong to the FastAPI routers (workspaces, + # users, teams, tasking-projects, tasking-tasks). If none matched, + # the URL is wrong or the method is unsupported — surface that as + # a clean 404 instead of letting the OSM proxy swallow it with a + # misleading "No X-Workspace header supplied". + if request.url.path.startswith("/api/v1/"): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + f"No handler for {request.method} {request.url.path}. " + "Check the method and path; this URL is not proxied to OSM." + ), + ) + if request.headers.get("X-Workspace") is not None: try: workspace_id = int(request.headers.get("X-Workspace") or "-1") diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 286ef32..ef8848f 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -8,6 +8,7 @@ from api.src.tasking.projects.schemas import ( AoiInput, + ProjectRoleType, ProjectStatus, TaskBoundaryType, _MultiPolygon, @@ -123,6 +124,59 @@ class AoiFeature(WireModel): properties: dict[str, Any] = PydField(default_factory=dict) +# --------------------------------------------------------------------------- +# Project-role management DTOs +# --------------------------------------------------------------------------- + + +class ProjectRoleItem(WireModel): + """One row of `GET /projects/{pid}/roles`.""" + + user_id: UUID + user_name: Optional[str] = None + role: ProjectRoleType + updated_at: datetime + + +class ProjectRoleListResponse(WireModel): + """Paginated-but-flat list of role assignments for a project.""" + + results: list[ProjectRoleItem] + + +class ProjectRoleAddRequest(WireModel): + """Body for `POST /projects/{pid}/roles`.""" + + user_id: UUID + role: ProjectRoleType + + +class ProjectRoleUpdateRequest(WireModel): + """Body for `PATCH /projects/{pid}/roles/{user_id}`.""" + + role: ProjectRoleType + + +# --------------------------------------------------------------------------- +# Self project-roles — `GET /me/workspaces/{wid}/tasking/projects/roles`. +# One row per project in the workspace with the caller's effective role on +# that project: explicit `tasking_project_roles` row if present, else the +# workspace-level role (which is the implicit fallback). +# --------------------------------------------------------------------------- + + +class SelfProjectRoleItem(WireModel): + project_id: int + project_name: str + project_status: ProjectStatus + role: ProjectRoleType + + +class SelfProjectRolesResponse(WireModel): + workspace_role: ProjectRoleType + projects: list[SelfProjectRoleItem] + + __all__ = [ "AoiFeature", "Pagination", @@ -130,7 +184,13 @@ class AoiFeature(WireModel): "ProjectListItem", "ProjectListResponse", "ProjectResponse", + "ProjectRoleAddRequest", "ProjectRoleAssignment", + "ProjectRoleItem", + "ProjectRoleListResponse", + "ProjectRoleUpdateRequest", "ProjectUpdateRequest", + "SelfProjectRoleItem", + "SelfProjectRolesResponse", "WireModel", ] diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 1a1c6bd..819203e 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -26,10 +26,17 @@ ProjectListItem, ProjectListResponse, ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, ProjectUpdateRequest, + SelfProjectRoleItem, + SelfProjectRolesResponse, ) from api.src.tasking.projects.schemas import ( AoiInput, + ProjectRoleType, ProjectStatus, TaskingProject, _Feature, @@ -733,6 +740,427 @@ async def upload_aoi( await self.session.commit() return _shapely_to_aoi_feature(geom) + # ---- Project roles ------------------------------------------------ + # + # Schema lives in `tasking_project_roles (project_id, user_auth_uid, + # role, updated_at)` with PK on (project_id, user_auth_uid) and a FK + # to `users.auth_uid`. The `add` path inserts via raw SQL so duplicate + # rows raise the PK uniqueness violation translated into a 409; FK + # violations on `user_auth_uid` are caught with a preflight so the + # caller gets a 422 listing the missing user id. + + async def _is_project_lead( + self, project_id: int, user_uuid: UUID + ) -> bool: + """True if the user holds a `lead` role on the given project.""" + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid " + "AND role = 'lead' LIMIT 1" + ), + {"pid": project_id, "uid": str(user_uuid)}, + ) + return result.scalar() is not None + + async def assert_can_manage_roles( + self, + workspace_id: int, + project_id: int, + current_user: UserInfo, + ) -> None: + """Permission gate for role-management endpoints. + + Allows either a workspace-level LEAD (per `UserInfo.isWorkspaceLead`) + or a project-level LEAD recorded in `tasking_project_roles`. The + project must already exist; otherwise the underlying `_get_active` + call raises 404. + """ + await self._get_active(workspace_id, project_id) + if current_user.isWorkspaceLead(workspace_id): + return + if await self._is_project_lead(project_id, current_user.user_uuid): + return + raise ForbiddenException( + "Only a workspace lead or project lead can manage roles " + "on this project." + ) + + async def _lead_count(self, project_id: int) -> int: + from sqlalchemy import text + + result = await self.session.execute( + text( + "SELECT COUNT(*) FROM tasking_project_roles " + "WHERE project_id = :pid AND role = 'lead'" + ), + {"pid": project_id}, + ) + return int(result.scalar() or 0) + + async def list_roles( + self, workspace_id: int, project_id: int + ) -> ProjectRoleListResponse: + """Return every role assignment on the project, newest first.""" + await self._get_active(workspace_id, project_id) + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid " + "ORDER BY r.updated_at DESC" + ), + {"pid": project_id}, + ) + items = [ + ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + for uid, name, role, updated in rows.all() + ] + return ProjectRoleListResponse(results=items) + + async def add_role( + self, + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + ) -> ProjectRoleItem: + """Insert a new role row. 422 on missing user; 409 on duplicate.""" + await self._get_active(workspace_id, project_id) + + missing = await self._missing_user_auth_uids([body.user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed in " + "to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + existing = await self.session.execute( + text( + "SELECT 1 FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(body.user_id)}, + ) + if existing.scalar() is not None: + raise AlreadyExistsException( + "This user is already assigned a role on the project. " + "Use PATCH to change their role." + ) + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW())" + ), + { + "pid": project_id, + "uid": str(body.user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + # Re-read so the response carries server-set `updated_at` and the + # `display_name` join with `users`. + return await self._get_role(project_id, body.user_id) + + async def update_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> ProjectRoleItem: + """Change a user's role. 404 if not assigned. 422 if it would + remove the last LEAD (per spec — projects must always have one).""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + # Guard: demoting the only LEAD would orphan the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "UPDATE tasking_project_roles " + "SET role = :role, updated_at = NOW() " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + return await self._get_role(project_id, user_id) + + async def list_self_project_roles( + self, + workspace_id: int, + current_user: UserInfo, + ) -> SelfProjectRolesResponse: + """One row per non-deleted project in the workspace with the + caller's effective role on that project. + + Resolution rule: + 1. If `tasking_project_roles` has an explicit row for this + user on the project, that wins. + 2. Otherwise the caller's workspace-level role applies (the + implicit fallback that grants `contributor` access to + every project in the workspace). + + Workspace-level role is mapped to the same `ProjectRoleType` + enum the project rows use. + """ + # Workspace-level role first — used as the fallback for projects + # without an explicit override. + from api.src.users.schemas import WorkspaceUserRoleType + + ws_role_raw = current_user.effective_role(workspace_id) + ws_role = ProjectRoleType(ws_role_raw.value) + + from sqlalchemy import text + + rows = await self.session.execute( + text( + "SELECT p.id, p.name, p.status, r.role " + "FROM tasking_projects p " + "LEFT JOIN tasking_project_roles r " + " ON r.project_id = p.id " + " AND r.user_auth_uid = :uid " + "WHERE p.workspace_id = :wid " + " AND p.deleted_at IS NULL " + "ORDER BY p.created_at DESC" + ), + { + "wid": workspace_id, + "uid": str(current_user.user_uuid), + }, + ) + + items = [ + SelfProjectRoleItem( + project_id=pid, + project_name=name, + project_status=ProjectStatus(status_value), + role=( + ProjectRoleType(explicit_role) + if explicit_role is not None + else ws_role + ), + ) + for pid, name, status_value, explicit_role in rows.all() + ] + return SelfProjectRolesResponse( + workspace_role=ws_role, + projects=items, + ) + + async def get_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> ProjectRoleItem: + """Single-user role read. 404 if no assignment exists.""" + await self._get_active(workspace_id, project_id) + item = await self._get_role_or_none(project_id, user_id) + if item is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def upsert_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + ) -> tuple[ProjectRoleItem, bool]: + """PUT semantics: idempotent insert-or-update on a role row. + + Returns ``(item, created)`` where ``created`` is True iff the + row did not exist before this call. The route layer uses that + flag to pick 201 vs 200. + + Honours the last-LEAD guard: a PUT that would demote the only + remaining LEAD returns 422 — assign another LEAD first. + """ + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + + if current is not None: + # Update path — guard against orphaning the project. + if ( + current.role == ProjectRoleType.LEAD + and body.role != ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot demote the last LEAD on this project. " + "Assign another LEAD first." + ), + ) + else: + # Insert path — `user_id` must exist in `users`. Preflight so + # the caller gets a 422 with `missing_user_ids` rather than + # a generic 23503 FK violation. + missing = await self._missing_user_auth_uids([user_id]) + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "`user_id` refers to a user that has not signed " + "in to Workspaces yet — no `users` row exists." + ), + "missing_user_ids": missing, + }, + ) + + from sqlalchemy import text + + try: + await self.session.execute( + text( + "INSERT INTO tasking_project_roles " + "(project_id, user_auth_uid, role, updated_at) " + "VALUES (:pid, :uid, :role, NOW()) " + "ON CONFLICT (project_id, user_auth_uid) DO UPDATE " + "SET role = EXCLUDED.role, updated_at = NOW()" + ), + { + "pid": project_id, + "uid": str(user_id), + "role": body.role.value, + }, + ) + await self.session.commit() + except IntegrityError as e: + await self.session.rollback() + raise _translate_integrity_error(e) from e + + item = await self._get_role(project_id, user_id) + return item, current is None + + async def remove_role( + self, + workspace_id: int, + project_id: int, + user_id: UUID, + ) -> None: + """Drop a role assignment. 404 if absent. 422 on last-LEAD removal.""" + await self._get_active(workspace_id, project_id) + + current = await self._get_role_or_none(project_id, user_id) + if current is None: + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + + if ( + current.role == ProjectRoleType.LEAD + and await self._lead_count(project_id) <= 1 + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + "Cannot remove the last LEAD on this project. Assign " + "another LEAD first." + ), + ) + + from sqlalchemy import text + + await self.session.execute( + text( + "DELETE FROM tasking_project_roles " + "WHERE project_id = :pid AND user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + await self.session.commit() + + async def _get_role( + self, project_id: int, user_id: UUID + ) -> ProjectRoleItem: + item = await self._get_role_or_none(project_id, user_id) + if item is None: # pragma: no cover — only called after insert/update + raise NotFoundException( + f"User {user_id} has no role on project {project_id}" + ) + return item + + async def _get_role_or_none( + self, project_id: int, user_id: UUID + ) -> ProjectRoleItem | None: + from sqlalchemy import text + + row = await self.session.execute( + text( + "SELECT r.user_auth_uid, u.display_name, r.role, r.updated_at " + "FROM tasking_project_roles r " + "LEFT JOIN users u ON u.auth_uid = r.user_auth_uid " + "WHERE r.project_id = :pid AND r.user_auth_uid = :uid" + ), + {"pid": project_id, "uid": str(user_id)}, + ) + result = row.first() + if result is None: + return None + uid, name, role, updated = result + return ProjectRoleItem( + user_id=UUID(uid), + user_name=name, + role=ProjectRoleType(role), + updated_at=updated, + ) + async def delete_aoi(self, workspace_id: int, project_id: int) -> None: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index cd699ce..6f11a06 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -13,12 +13,18 @@ ProjectCreateRequest, ProjectListResponse, ProjectResponse, + ProjectRoleAddRequest, + ProjectRoleItem, + ProjectRoleListResponse, + ProjectRoleUpdateRequest, ProjectUpdateRequest, + SelfProjectRolesResponse, ) from api.src.tasking.projects.schemas import ( AoiInput, ProjectStatus, ) +from uuid import UUID from api.src.workspaces.repository import WorkspaceRepository router = APIRouter( @@ -26,6 +32,13 @@ tags=["tasking-projects"], ) +# Sibling router for caller-scoped (`/me/...`) tasking endpoints. Kept on +# its own prefix so the project-scoped router above can stay focused. +me_router = APIRouter( + prefix="/me/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-projects"], +) + # --------------------------------------------------------------------------- # Dependencies @@ -221,6 +234,134 @@ async def upload_project_aoi( return await project_repo.upload_aoi(workspace_id, project_id, body) +# --------------------------------------------------------------------------- +# Project role management +# +# Writes require either workspace LEAD or project LEAD (delegated to the +# repository so the project-LEAD check can hit `tasking_project_roles`). +# Reads are open to any caller who passes the workspace tenancy gate. +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/roles", + response_model=ProjectRoleListResponse, +) +async def list_project_roles( + workspace_id: int, + project_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_roles(workspace_id, project_id) + + +@router.post( + "/{project_id}/roles", + response_model=ProjectRoleItem, + status_code=status.HTTP_201_CREATED, +) +async def add_project_role( + workspace_id: int, + project_id: int, + body: ProjectRoleAddRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles( + workspace_id, project_id, current_user + ) + return await project_repo.add_role(workspace_id, project_id, body) + + +@router.get( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def get_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.get_role(workspace_id, project_id, user_id) + + +@router.put( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def put_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + response: Response, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Idempotent upsert. 201 on insert, 200 on update. Last-LEAD guarded.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles( + workspace_id, project_id, current_user + ) + item, created = await project_repo.upsert_role( + workspace_id, project_id, user_id, body + ) + if created: + response.status_code = status.HTTP_201_CREATED + return item + + +@router.patch( + "/{project_id}/roles/{user_id}", + response_model=ProjectRoleItem, +) +async def update_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + body: ProjectRoleUpdateRequest, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles( + workspace_id, project_id, current_user + ) + return await project_repo.update_role( + workspace_id, project_id, user_id, body + ) + + +@router.delete( + "/{project_id}/roles/{user_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_project_role( + workspace_id: int, + project_id: int, + user_id: UUID, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + await project_repo.assert_can_manage_roles( + workspace_id, project_id, current_user + ) + await project_repo.remove_role(workspace_id, project_id, user_id) + + @router.delete("/{project_id}/aoi", status_code=status.HTTP_204_NO_CONTENT) async def delete_project_aoi( workspace_id: int, @@ -232,3 +373,34 @@ async def delete_project_aoi( await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) await project_repo.delete_aoi(workspace_id, project_id) + + +# --------------------------------------------------------------------------- +# /me/* — caller-scoped views. +# +# Mounted on `me_router` so the path prefix is `/me/workspaces/{wid}/...` +# instead of the project-scoped router's `/workspaces/{wid}/...`. Reads +# only; no writes here. +# --------------------------------------------------------------------------- + + +@me_router.get( + "/roles", + response_model=SelfProjectRolesResponse, +) +async def list_self_project_roles( + workspace_id: int, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + """Per-project role for the caller across every project in the workspace. + + Returns the workspace-level role and a per-project list with the + project-LEVEL role override where one exists, else the workspace role. + Single round-trip for the project-list page. + """ + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await project_repo.list_self_project_roles( + workspace_id, current_user + ) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 829c142..f0b4695 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -94,6 +94,48 @@ class TaskingProject(SQLModel, table=True): deleted_at: Optional[datetime] = None +# --------------------------------------------------------------------------- +# Project role enum + table +# --------------------------------------------------------------------------- + + +class ProjectRoleType(StrEnum): + LEAD = "lead" + VALIDATOR = "validator" + CONTRIBUTOR = "contributor" + + +class TaskingProjectRole(SQLModel, table=True): + """Per-project role override stored in ``tasking_project_roles``. + + Maps a user (`users.auth_uid`) to a role within a single tasking + project. Created at project-seed time and managed via the + ``/projects/{pid}/roles`` endpoints thereafter. Cascades on project + delete; the user FK is intentionally non-cascading so user records + are not destroyed by project deletions. + """ + + __tablename__ = "tasking_project_roles" # type: ignore[assignment] + + project_id: int = Field(primary_key=True, nullable=False) + user_auth_uid: str = Field(primary_key=True, nullable=False) + role: ProjectRoleType = Field( + sa_column=Column( + SAEnum( + ProjectRoleType, + name="workspace_role", + create_type=False, + values_callable=lambda enum: [m.value for m in enum], + ), + nullable=False, + ), + ) + updated_at: datetime = Field( + default_factory=datetime.now, + sa_column_kwargs={"nullable": False, "onupdate": datetime.now}, + ) + + # --------------------------------------------------------------------------- # GeoJSON input shapes — accepted by the AOI endpoints. Polygon inputs # are upcast to single-member MultiPolygon at the repository layer. @@ -126,9 +168,11 @@ class _FeatureCollection(BaseModel): __all__ = [ "AoiInput", + "ProjectRoleType", "ProjectStatus", "TaskBoundaryType", "TaskingProject", + "TaskingProjectRole", "_Feature", "_FeatureCollection", "_MultiPolygon", diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json index 275acc8..6c003b8 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.json +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -1306,12 +1306,12 @@ "tags": [ "roles" ], - "summary": "List project-level role overrides on a project.", - "description": "Returns rows in `tasking_project_roles`. Sparse \u2014 only users\nwith an explicit override appear. Any workspace contributor.\n", + "summary": "List role assignments on a project.", + "description": "Returns every row in `tasking_project_roles` for the project,\njoined with `users.display_name` for human-readable labels.\nAny workspace contributor; the workspace tenancy gate still\napplies (404 for outsiders).\n", "operationId": "listProjectRoles", "responses": { "200": { - "description": "Overrides.", + "description": "All role assignments.", "content": { "application/json": { "schema": { @@ -1327,6 +1327,82 @@ "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" } } + }, + "post": { + "tags": [ + "roles" + ], + "summary": "Add a role assignment to a project.", + "description": "Workspace LEAD **or** project LEAD only. Inserts a row in\n`tasking_project_roles`. Duplicate `(project_id, user_id)` pairs\nreturn 409 \u2014 use PATCH to change a role. The `user_id` must\nalready exist in `users` (i.e. the user has signed in to\nWorkspaces at least once); otherwise 422 with a\n`missing_user_ids` list.\n", + "operationId": "addProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleAddRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Role added.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + }, + "409": { + "description": "User already has a role on this project \u2014 use PATCH.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "`user_id` does not exist in the `users` table.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "missing_user_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + } + } + } } }, "/workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}": { @@ -1345,16 +1421,16 @@ "tags": [ "roles" ], - "summary": "Get a user's project-level role.", - "description": "Project override if set, otherwise the workspace-level role.", + "summary": "Get a single user's role on a project.", + "description": "Returns the single `tasking_project_roles` row for this user on\nthis project. 404 if no assignment exists. Any workspace\ncontributor; the tenancy gate still applies.\n", "operationId": "getProjectRole", "responses": { "200": { - "description": "Role.", + "description": "Role assignment.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRoleEntry" + "$ref": "#/components/schemas/ProjectRoleItem" } } } @@ -1363,7 +1439,14 @@ "$ref": "#/components/responses/Unauthorized" }, "404": { - "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } }, @@ -1371,32 +1454,39 @@ "tags": [ "roles" ], - "summary": "Set (upsert) a project-level role override.", - "description": "LEAD only. Inserts/updates a row in `tasking_project_roles`.\nProject must not be `done` (422 otherwise). User must be a\nmember of the workspace's project group (422 otherwise).\n", + "summary": "Upsert a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Idempotent insert-or-\nupdate on `tasking_project_roles`. Returns **201** when the row\nis created, **200** when an existing row is updated.\n\n**Last-LEAD guard**: a PUT that demotes the only remaining LEAD\nreturns 422 \u2014 assign another LEAD first.\n\nFor partial-update semantics, prefer PATCH.\n", "operationId": "putProjectRole", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetRoleRequest" + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" } } } }, "responses": { "200": { - "description": "Upserted.", + "description": "Existing assignment updated.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectRoleEntry" + "$ref": "#/components/schemas/ProjectRoleItem" } } } }, - "400": { - "$ref": "#/components/responses/BadRequest" + "201": { + "description": "New assignment created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } }, "401": { "$ref": "#/components/responses/Unauthorized" @@ -1408,7 +1498,63 @@ "$ref": "#/components/responses/ProjectOrWorkspaceNotFound" }, "422": { - "description": "One of:\n - \"User is not a member of the workspace's project group\".\n - \"Project is closed\".\n", + "description": "One of:\n - `user_id` does not exist in `users` (insert path).\n - Would remove the last LEAD (demote path).\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "roles" + ], + "summary": "Change a user's role on a project.", + "description": "Workspace LEAD **or** project LEAD only. Updates the row in\n`tasking_project_roles`. Returns 404 if the user has no role\nassignment yet (use POST to add one).\n\n**Last-LEAD guard**: demoting the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "updateProjectRole", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Role updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRoleItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/ForbiddenLeadRequired" + }, + "404": { + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", "content": { "application/json": { "schema": { @@ -1423,12 +1569,12 @@ "tags": [ "roles" ], - "summary": "Clear a project-level role override.", - "description": "LEAD only. Falls back to the workspace-level role.", - "operationId": "deleteProjectRole", + "summary": "Remove a role assignment from a project.", + "description": "Workspace LEAD **or** project LEAD only. Deletes the row from\n`tasking_project_roles`.\n\n**Last-LEAD guard**: removing the only remaining LEAD on the\nproject returns 422 \u2014 assign another LEAD first.\n", + "operationId": "removeProjectRole", "responses": { "204": { - "description": "Removed." + "description": "Role removed." }, "401": { "$ref": "#/components/responses/Unauthorized" @@ -1437,7 +1583,17 @@ "$ref": "#/components/responses/ForbiddenLeadRequired" }, "404": { - "description": "No override row for this user/project.", + "description": "User has no role on this project, or project not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Would remove the last LEAD on the project.", "content": { "application/json": { "schema": { @@ -2709,18 +2865,31 @@ } } }, - "ProjectRoleEntry": { + "ProjectRoleItem": { "type": "object", + "description": "One row of `tasking_project_roles`, joined with `users` for the\ndisplay name. Returned by every `/projects/{pid}/roles[/{uid}]`\nendpoint.\n", "required": [ - "user", - "role" + "user_id", + "role", + "updated_at" ], "properties": { - "user": { - "$ref": "#/components/schemas/UserSummary" + "user_id": { + "type": "string", + "format": "uuid", + "description": "Maps to `users.auth_uid`." + }, + "user_name": { + "type": "string", + "nullable": true, + "description": "Mirror of `users.display_name`, joined at read time." }, "role": { "$ref": "#/components/schemas/WorkspaceUserRoleType" + }, + "updated_at": { + "type": "string", + "format": "date-time" } } }, @@ -2733,27 +2902,59 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/ProjectRoleEntry" + "$ref": "#/components/schemas/ProjectRoleItem" } } } }, - "SelfProjectRolesItem": { + "ProjectRoleAddRequest": { "type": "object", + "description": "Body for `POST /projects/{pid}/roles`.", "required": [ - "projectId", - "projectName", + "user_id", "role" ], + "additionalProperties": false, "properties": { - "projectId": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "ProjectRoleUpdateRequest": { + "type": "object", + "description": "Body for `PATCH /projects/{pid}/roles/{user_id}`.", + "required": [ + "role" + ], + "additionalProperties": false, + "properties": { + "role": { + "$ref": "#/components/schemas/WorkspaceUserRoleType" + } + } + }, + "SelfProjectRoleItem": { + "type": "object", + "required": [ + "project_id", + "project_name", + "project_status", + "role" + ], + "properties": { + "project_id": { "type": "integer", "format": "int64" }, - "projectName": { + "project_name": { "type": "string" }, - "projectStatus": { + "project_status": { "$ref": "#/components/schemas/ProjectStatus" }, "role": { @@ -2764,17 +2965,17 @@ "SelfProjectRolesResponse": { "type": "object", "required": [ - "workspaceRole", + "workspace_role", "projects" ], "properties": { - "workspaceRole": { + "workspace_role": { "$ref": "#/components/schemas/WorkspaceUserRoleType" }, "projects": { "type": "array", "items": { - "$ref": "#/components/schemas/SelfProjectRolesItem" + "$ref": "#/components/schemas/SelfProjectRoleItem" } } } diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index bdb4e05..6eb502b 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -897,19 +897,65 @@ paths: - $ref: "#/components/parameters/ProjectIdPath" get: tags: [roles] - summary: List project-level role overrides on a project. + summary: List role assignments on a project. description: | - Returns rows in `tasking_project_roles`. Sparse — only users - with an explicit override appear. Any workspace contributor. + Returns every row in `tasking_project_roles` for the project, + joined with `users.display_name` for human-readable labels. + Any workspace contributor; the workspace tenancy gate still + applies (404 for outsiders). operationId: listProjectRoles responses: "200": - description: Overrides. + description: All role assignments. content: application/json: schema: { $ref: "#/components/schemas/ProjectRoleListResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + post: + tags: [roles] + summary: Add a role assignment to a project. + description: | + Workspace LEAD **or** project LEAD only. Inserts a row in + `tasking_project_roles`. Duplicate `(project_id, user_id)` pairs + return 409 — use PATCH to change a role. The `user_id` must + already exist in `users` (i.e. the user has signed in to + Workspaces at least once); otherwise 422 with a + `missing_user_ids` list. + operationId: addProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleAddRequest" } + responses: + "201": + description: Role added. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "409": + description: User already has a role on this project — use PATCH. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: "`user_id` does not exist in the `users` table." + content: + application/json: + schema: + type: object + properties: + detail: + type: object + properties: + message: { type: string } + missing_user_ids: + type: array + items: { type: string, format: uuid } /workspaces/{workspace_id}/tasking/projects/{project_id}/roles/{user_id}: parameters: @@ -918,60 +964,120 @@ paths: - $ref: "#/components/parameters/UserIdPath" get: tags: [roles] - summary: Get a user's project-level role. - description: Project override if set, otherwise the workspace-level role. + summary: Get a single user's role on a project. + description: | + Returns the single `tasking_project_roles` row for this user on + this project. 404 if no assignment exists. Any workspace + contributor; the tenancy gate still applies. operationId: getProjectRole responses: "200": - description: Role. + description: Role assignment. content: application/json: - schema: { $ref: "#/components/schemas/ProjectRoleEntry" } + schema: { $ref: "#/components/schemas/ProjectRoleItem" } "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } put: tags: [roles] - summary: Set (upsert) a project-level role override. + summary: Upsert a user's role on a project. description: | - LEAD only. Inserts/updates a row in `tasking_project_roles`. - Project must not be `done` (422 otherwise). User must be a - member of the workspace's project group (422 otherwise). + Workspace LEAD **or** project LEAD only. Idempotent insert-or- + update on `tasking_project_roles`. Returns **201** when the row + is created, **200** when an existing row is updated. + + **Last-LEAD guard**: a PUT that demotes the only remaining LEAD + returns 422 — assign another LEAD first. + + For partial-update semantics, prefer PATCH. operationId: putProjectRole requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/SetRoleRequest" } + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } responses: "200": - description: Upserted. + description: Existing assignment updated. content: application/json: - schema: { $ref: "#/components/schemas/ProjectRoleEntry" } - "400": { $ref: "#/components/responses/BadRequest" } + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "201": + description: New assignment created. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } "404": { $ref: "#/components/responses/ProjectOrWorkspaceNotFound" } "422": description: | One of: - - "User is not a member of the workspace's project group". - - "Project is closed". + - `user_id` does not exist in `users` (insert path). + - Would remove the last LEAD (demote path). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + patch: + tags: [roles] + summary: Change a user's role on a project. + description: | + Workspace LEAD **or** project LEAD only. Updates the row in + `tasking_project_roles`. Returns 404 if the user has no role + assignment yet (use POST to add one). + + **Last-LEAD guard**: demoting the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: updateProjectRole + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleUpdateRequest" } + responses: + "200": + description: Role updated. + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectRoleItem" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } + "404": + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. content: application/json: schema: { $ref: "#/components/schemas/Error" } delete: tags: [roles] - summary: Clear a project-level role override. - description: LEAD only. Falls back to the workspace-level role. - operationId: deleteProjectRole + summary: Remove a role assignment from a project. + description: | + Workspace LEAD **or** project LEAD only. Deletes the row from + `tasking_project_roles`. + + **Last-LEAD guard**: removing the only remaining LEAD on the + project returns 422 — assign another LEAD first. + operationId: removeProjectRole responses: "204": - description: Removed. + description: Role removed. "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/ForbiddenLeadRequired" } "404": - description: No override row for this user/project. + description: User has no role on this project, or project not found. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Would remove the last LEAD on the project. content: application/json: schema: { $ref: "#/components/schemas/Error" } @@ -1686,12 +1792,24 @@ components: display_name: { type: string } role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } - ProjectRoleEntry: + ProjectRoleItem: type: object - required: [user, role] + description: | + One row of `tasking_project_roles`, joined with `users` for the + display name. Returned by every `/projects/{pid}/roles[/{uid}]` + endpoint. + required: [user_id, role, updated_at] properties: - user: { $ref: "#/components/schemas/UserSummary" } + user_id: + type: string + format: uuid + description: Maps to `users.auth_uid`. + user_name: + type: string + nullable: true + description: Mirror of `users.display_name`, joined at read time. role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + updated_at: { type: string, format: date-time } ProjectRoleListResponse: type: object @@ -1699,25 +1817,44 @@ components: properties: results: type: array - items: { $ref: "#/components/schemas/ProjectRoleEntry" } + items: { $ref: "#/components/schemas/ProjectRoleItem" } - SelfProjectRolesItem: + ProjectRoleAddRequest: type: object - required: [projectId, projectName, role] + description: Body for `POST /projects/{pid}/roles`. + required: [user_id, role] + additionalProperties: false properties: - projectId: { type: integer, format: int64 } - projectName: { type: string } - projectStatus: { $ref: "#/components/schemas/ProjectStatus" } + user_id: + type: string + format: uuid + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + ProjectRoleUpdateRequest: + type: object + description: Body for `PATCH /projects/{pid}/roles/{user_id}`. + required: [role] + additionalProperties: false + properties: + role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + + SelfProjectRoleItem: + type: object + required: [project_id, project_name, project_status, role] + properties: + project_id: { type: integer, format: int64 } + project_name: { type: string } + project_status: { $ref: "#/components/schemas/ProjectStatus" } role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } SelfProjectRolesResponse: type: object - required: [workspaceRole, projects] + required: [workspace_role, projects] properties: - workspaceRole: { $ref: "#/components/schemas/WorkspaceUserRoleType" } + workspace_role: { $ref: "#/components/schemas/WorkspaceUserRoleType" } projects: type: array - items: { $ref: "#/components/schemas/SelfProjectRolesItem" } + items: { $ref: "#/components/schemas/SelfProjectRoleItem" } # ---------- Audit ---------- diff --git a/tests/conftest.py b/tests/conftest.py index a79b530..fbbf416 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,14 +60,24 @@ def pytest_runtest_makereport(item, call): report.description = _test_description(item) -def pytest_html_results_table_header(cells): - """Inject a 'Description' column header next to the test name.""" - cells.insert(2, "Description") - - -def pytest_html_results_table_row(report, cells): - """Inject the docstring as the matching cell on each row.""" - cells.insert(2, f"{getattr(report, 'description', '') or '—'}") +# The two hooks below are owned by the `pytest-html` plugin. When the +# plugin isn't installed (e.g. `uv sync` doesn't include it as a base +# dependency), pluggy refuses to register them and aborts collection. +# Declare them conditionally so the suite runs either way. +try: + import pytest_html # noqa: F401 + + def pytest_html_results_table_header(cells): + """Inject a 'Description' column header next to the test name.""" + cells.insert(2, "Description") + + def pytest_html_results_table_row(report, cells): + """Inject the docstring as the matching cell on each row.""" + cells.insert( + 2, f"{getattr(report, 'description', '') or '—'}" + ) +except ImportError: + pass def _redact(headers) -> str: diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index c7ec969..58b0b3f 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -320,3 +320,445 @@ async def test_aoi_delete_round_trip( f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" ) assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 5 — project role management +# - LEAD can list/add/update/remove role assignments +# - workspace-LEAD passes the manage-roles gate even without a project row +# - contributor cannot manage roles (403) +# - 422 mapping for unknown user_id, duplicate (409), missing assignment (404) +# - last-LEAD guard blocks the demote / delete that would orphan the project +# --------------------------------------------------------------------------- + + +class TestProjectRoles: + async def test_list_includes_creator_auto_lead( + self, client, as_lead, seeded_workspace_id + ): + """Project creator is auto-seeded as LEAD and appears in GET /roles.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-list-1"}, + ) + pid = r.json()["id"] + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" + ) + assert r.status_code == 200, r.text + rows = r.json()["results"] + assert len(rows) == 1 + assert rows[0]["role"] == "lead" + assert rows[0]["user_id"] == str(as_lead.user_uuid) + + async def test_add_role_round_trip( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """POST /roles inserts a row; GET reflects it.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-add"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["user_id"] == str(contrib.user_uuid) + assert body["role"] == "contributor" + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" + ) + ids = {row["user_id"] for row in r.json()["results"]} + assert str(contrib.user_uuid) in ids + + async def test_add_duplicate_returns_409( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """Re-adding the same user returns 409 with an actionable hint.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-dup"}, + ) + pid = r.json()["id"] + + path = f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" + await client.post( + path, + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + r2 = await client.post( + path, + json={"user_id": str(contrib.user_uuid), "role": "validator"}, + ) + assert r2.status_code == 409, r2.text + assert "patch" in r2.json()["detail"].lower() + + async def test_add_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """An unknown user_id surfaces as 422 + missing_user_ids list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-unknown"}, + ) + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": bogus, "role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert bogus in r.json()["detail"]["missing_user_ids"] + + async def test_update_role_promotes_contributor_to_validator( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PATCH /roles/{uid} changes the stored role.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-patch"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_update_unknown_user_returns_404( + self, client, as_lead, seeded_workspace_id + ): + """PATCH on a user without a role row returns 404.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-patch-404"}, + ) + pid = r.json()["id"] + absent = "deadbeef-dead-dead-dead-deadbeefdead" + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{absent}", + json={"role": "validator"}, + ) + assert r.status_code == 404 + + async def test_remove_role_round_trip( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """DELETE /roles/{uid} removes the row; subsequent PATCH 404s.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-delete"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}" + ) + assert r.status_code == 204 + + # Subsequent PATCH is a 404 — row is gone. + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 404 + + async def test_last_lead_demote_blocked( + self, client, as_lead, seeded_workspace_id + ): + """Cannot demote the only LEAD — projects must always have one.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-last-lead-demote"}, + ) + pid = r.json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert "last lead" in r.json()["detail"].lower() + + async def test_last_lead_delete_blocked( + self, client, as_lead, seeded_workspace_id + ): + """Cannot delete the only LEAD — would orphan the project.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-last-lead-delete"}, + ) + pid = r.json()["id"] + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + ) + assert r.status_code == 422 + assert "last lead" in r.json()["detail"].lower() + + async def test_demote_lead_works_when_two_leads_exist( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """With two LEADs, demoting one is allowed.""" + lead2 = await extra_user_factory("lead") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-two-leads"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(lead2.user_uuid), "role": "lead"}, + ) + + # Now demote the second lead — first lead is still there. + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{lead2.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "contributor" + + async def test_get_single_role( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """GET /roles/{uid} returns just that user's row.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-get-single"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["user_id"] == str(contrib.user_uuid) + assert body["role"] == "contributor" + + async def test_get_single_role_404_when_absent( + self, client, as_lead, seeded_workspace_id + ): + """GET /roles/{uid} 404s when the user has no row on the project.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-get-404"}, + ) + pid = r.json()["id"] + absent = "feedf00d-feed-feed-feed-feedfeedfeed" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{absent}" + ) + assert r.status_code == 404 + + async def test_put_upsert_inserts_with_201( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PUT on a fresh user creates the row and returns 201.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-create"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 201, r.text + assert r.json()["role"] == "validator" + + async def test_put_upsert_updates_with_200( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + ): + """PUT on an existing user updates the role and returns 200.""" + contrib = await extra_user_factory("contributor") + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-update"}, + ) + pid = r.json()["id"] + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={"user_id": str(contrib.user_uuid), "role": "contributor"}, + ) + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{contrib.user_uuid}", + json={"role": "validator"}, + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" + + async def test_put_unknown_user_returns_422( + self, client, as_lead, seeded_workspace_id + ): + """PUT for a uuid with no `users` row returns 422 + missing list.""" + bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-unknown"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{bogus}", + json={"role": "contributor"}, + ) + assert r.status_code == 422, r.text + assert bogus in r.json()["detail"]["missing_user_ids"] + + async def test_put_last_lead_demote_blocked( + self, client, as_lead, seeded_workspace_id + ): + """PUT cannot demote the only LEAD any more than PATCH can.""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-put-last-lead"}, + ) + pid = r.json()["id"] + + r = await client.put( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" + f"{as_lead.user_uuid}", + json={"role": "contributor"}, + ) + assert r.status_code == 422 + assert "last lead" in r.json()["detail"].lower() + + async def test_self_project_roles_falls_back_to_workspace_role( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """`/me/.../roles` returns override where present, workspace role elsewhere.""" + # Project A with no explicit override — caller will get workspace role. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "self-roles-A"}, + ) + pid_a = r.json()["id"] + # Project B where the contributor gets an explicit validator role. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "self-roles-B"}, + ) + pid_b = r.json()["id"] + contributor = await extra_user_factory("contributor") + await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid_b}/roles", + json={"user_id": str(contributor.user_uuid), "role": "validator"}, + ) + + # Switch to the contributor and call /me/.../roles. + override_user(contributor) + r = await client.get( + f"/api/v1/me/workspaces/{seeded_workspace_id}/tasking/projects/roles" + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["workspace_role"] == "contributor" + by_pid = {p["project_id"]: p for p in body["projects"]} + # Project B has the explicit validator override. + assert by_pid[pid_b]["role"] == "validator" + # Project A falls back to workspace-level contributor. + assert by_pid[pid_a]["role"] == "contributor" + + async def test_contributor_cannot_manage_roles( + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, + ): + """A workspace contributor with no project-LEAD role is denied 403.""" + # Create project as LEAD, then act-as a contributor. + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "roles-contrib-denied"}, + ) + pid = r.json()["id"] + + contributor = await extra_user_factory("contributor") + override_user(contributor) + + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles", + json={ + "user_id": str(contributor.user_uuid), + "role": "contributor", + }, + ) + assert r.status_code == 403 From 414da7a69e168a936b1ce035fc3e9170da16c03d Mon Sep 17 00:00:00 2001 From: MashB Date: Tue, 2 Jun 2026 20:42:55 +0530 Subject: [PATCH 060/159] Project roles integration --- .../requirements/project-roles-integration.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/requirements/project-roles-integration.md diff --git a/docs/requirements/project-roles-integration.md b/docs/requirements/project-roles-integration.md new file mode 100644 index 0000000..87b25a9 --- /dev/null +++ b/docs/requirements/project-roles-integration.md @@ -0,0 +1,19 @@ +# User Role Management — Workspaces + Tasking Manager + +## Current Behaviour (Workspaces) + +- Users authenticate via TDEI and inherit **implicit contributor** access across all workspaces in their project groups — no manual provisioning needed. +- The user who creates a workspace is automatically assigned the **`lead`** role for that workspace and recorded in the system. +- Today, all collaboration within a workspace operates under this single workspace-level role model. + +## New Requirement (Tasking Manager) + +Tasking Manager introduces **project-level roles** (`lead`, `validator`, `contributor`) so that work inside a workspace can be delegated and reviewed by specific people, not just by anyone with workspace access. + +To deliver this, two new capabilities are needed: + +### 1. User Search +A workspace lead creating or managing a project must be able to search for the right users from within their project group — by name or username — and pick them. This requires **integration with the TDEI user-search API** to surface candidate users in the UI. + +### 2. Role Assignment +Once selected, users are assigned a project-level role (`lead`, `validator`, or `contributor`) and recorded against the project. The lead can later **add**, **change**, or **remove** these assignments as the project evolves, with safeguards to ensure every project always has at least one `lead`. \ No newline at end of file From ede4fee075e534afb94cd2d9114914dfce2f4a0e Mon Sep 17 00:00:00 2001 From: MashB Date: Thu, 4 Jun 2026 17:07:57 +0530 Subject: [PATCH 061/159] project role assignment verification --- api/core/security.py | 100 +++++++++++++++++++++ api/src/tasking/projects/repository.py | 110 ++++++++++++++++++++---- api/src/tasking/projects/routes.py | 12 ++- tests/integration/conftest.py | 33 +++++++ tests/integration/test_projects_flow.py | 42 ++++++++- tests/unit/conftest.py | 17 +++- 6 files changed, 290 insertions(+), 24 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index c283af4..9919699 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -43,6 +43,106 @@ async def close_tdei_client() -> None: _tdei_client = None +class TdeiProjectGroupUser: + """One member of a TDEI project group, as returned by + ``GET /project-group/{pg_id}/users``. + + Field names are normalised here because the upstream JSON shape varies + across deployments (`user_id` vs `id`, `username` vs `display_name`). + """ + + def __init__( + self, + *, + auth_uid: str, + email: str | None, + display_name: str | None, + ) -> None: + self.auth_uid = auth_uid + self.email = email + self.display_name = display_name + + +async def fetch_project_group_users( + project_group_id: str, + bearer_token: str, +) -> list[TdeiProjectGroupUser]: + """Page through ``GET /project-group/{pg_id}/users`` on TDEI. + + Returns every member of the project group. The endpoint is paginated + server-side; we fetch all pages so the caller can look up an + arbitrary user UUID without guessing page numbers. Raises 502 if + TDEI is unreachable, 401 if the token is rejected. + """ + if _tdei_client is None: # pragma: no cover — lifespan should init this + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TDEI client is not initialised", + ) + + headers = {"Authorization": f"Bearer {bearer_token}"} + page_no = 1 + page_size = 200 + out: list[TdeiProjectGroupUser] = [] + while True: + try: + response = await _tdei_client.get( + f"project-group/{project_group_id}/users", + headers=headers, + params={"page_no": page_no, "page_size": page_size}, + ) + except httpx.RequestError: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not reach TDEI backend to fetch project group users", + ) from None + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="TDEI rejected the bearer token", + ) + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=( + f"TDEI returned {response.status_code} when listing " + f"users for project group {project_group_id}" + ), + ) + + try: + page = response.json() + except Exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TDEI returned a non-JSON body", + ) from None + + if not isinstance(page, list) or not page: + break + + for row in page: + uid = row.get("user_id") + if not uid: + continue + out.append( + TdeiProjectGroupUser( + auth_uid=str(uid), + email=row.get("email"), + display_name=( + row.get("username") + ), + ) + ) + + if len(page) < page_size: + break + page_no += 1 + + return out + + def evict_user_from_cache(auth_uid: UUID) -> None: """ Evict a user's cached UserInfo object so that their next request re-fetches diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 819203e..a19089a 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -219,6 +219,63 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons updated_at=project.updated_at, ) + async def _provision_users_from_tdei( + self, + missing_uuids: list[str], + project_group_id: str, + bearer_token: str, + ) -> list[str]: + """Look up `missing_uuids` against TDEI's project-group members + and insert matching rows into `users`. Return the UUIDs that are + still unknown (not members of the project group at all). + + This is the auto-provisioning fallback for `role_assignments[]`: + a user who is known to TDEI but has not yet performed any action + that would write them into `users` (e.g. first-time sign-in). + """ + from api.core.security import fetch_project_group_users + from sqlalchemy import text + + try: + members = await fetch_project_group_users( + project_group_id, bearer_token + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to fetch project group users from TDEI: {e}", + ) from e + + by_uid = {m.auth_uid: m for m in members} + + resolved: set[str] = set() + for uid in missing_uuids: + member = by_uid.get(uid) + if member is None: + continue + await self.session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name) " + "VALUES (:uid, :email, :name) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "uid": uid, + "email": member.email, + "name": member.display_name, + }, + ) + resolved.add(uid) + + if resolved: + # Commit the new `users` rows in their own transaction so the + # subsequent FK insert into `tasking_project_roles` resolves. + await self.session.commit() + + return [u for u in missing_uuids if u not in resolved] + async def _missing_user_auth_uids( self, uuids: list[UUID] ) -> list[str]: @@ -359,38 +416,55 @@ async def create( workspace_id: int, current_user: UserInfo, body: ProjectCreateRequest, + tdei_project_group_id: str, ) -> ProjectResponse: # Preflight every user_auth_uid that will be inserted into # `tasking_project_roles` — the creator's auto-LEAD seed plus - # any explicit role_assignments. Returns a 422 listing the - # missing ids instead of a generic FK violation. + # any explicit role_assignments. Any UUID missing from `users` + # is looked up against TDEI's project-group member list and + # auto-provisioned; only UUIDs that are not members at all + # surface as a 422 to the caller. candidate_uuids: list[UUID] = [current_user.user_uuid] candidate_uuids.extend(ra.user_id for ra in body.role_assignments or []) missing = await self._missing_user_auth_uids(candidate_uuids) + if missing: creator_uid = str(current_user.user_uuid) if creator_uid in missing: - # Signed-in caller is not yet provisioned in `users`; - # distinct from a bad role_assignments entry. + # The signed-in caller is not in `users`. Pull them + # from TDEI just like any other role-assignment user; + # if even TDEI doesn't know them, surface 409 because + # the request can never succeed. + pass + + # Auto-provision via TDEI for the members of this project group. + still_missing = await self._provision_users_from_tdei( + missing, + project_group_id=tdei_project_group_id, + bearer_token=current_user.credentials, + ) + + if creator_uid in still_missing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( - "Your user record has not been provisioned yet. " - "Sign in to Workspaces once to create your `users` " - "row, then retry." + "Your user record could not be provisioned: TDEI " + "does not list you as a member of this project " + "group. Sign in to Workspaces once, then retry." ), ) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": ( - "One or more `role_assignments[].user_id` values " - "refer to a user that has not signed in to " - "Workspaces yet — no `users` row exists." - ), - "missing_user_ids": missing, - }, - ) + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "One or more `role_assignments[].user_id` " + "values are not members of this workspace's " + "project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) project = TaskingProject( workspace_id=workspace_id, diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 6f11a06..81f06f4 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -120,9 +120,17 @@ async def create_project( workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), project_repo: TaskingProjectRepository = Depends(get_project_repo), ): - await assert_workspace_visible(workspace_id, current_user, workspace_repo) + # `getById` enforces the tenancy gate AND returns the workspace + # row, whose `tdeiProjectGroupId` we hand to the repository so it + # can auto-provision `role_assignments[]` users from TDEI. + workspace = await workspace_repo.getById(current_user, workspace_id) assert_workspace_lead(workspace_id, current_user) - return await project_repo.create(workspace_id, current_user, body) + return await project_repo.create( + workspace_id, + current_user, + body, + tdei_project_group_id=str(workspace.tdeiProjectGroupId), + ) @router.get("/{project_id}", response_model=ProjectResponse) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b7f0bf5..c56b092 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -479,6 +479,39 @@ async def _make(role: str | None): return _make +# --------------------------------------------------------------------------- +# TDEI stub — no real TDEI backend is available in integration. The default +# stub returns an empty member list, so any role_assignments[].user_id that +# is not already in `users` stays missing and surfaces as 422. +# +# Tests that exercise the auto-provisioning path append to +# `tdei_project_group_users` to simulate TDEI returning specific members. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def tdei_project_group_users(monkeypatch): + """Stub `fetch_project_group_users` to return a controllable list.""" + members: list = [] + + async def _fake_fetch(project_group_id: str, bearer_token: str): + return list(members) + + import api.core.security + import api.src.tasking.projects.repository as proj_repo + + monkeypatch.setattr( + api.core.security, "fetch_project_group_users", _fake_fetch + ) + # The repository imports the symbol locally inside the helper, but be + # belt-and-braces in case that ever changes: + if hasattr(proj_repo, "fetch_project_group_users"): + monkeypatch.setattr( + proj_repo, "fetch_project_group_users", _fake_fetch + ) + return members + + # --------------------------------------------------------------------------- # Per-test cleanup of tasking_* tables (opt-in). # --------------------------------------------------------------------------- diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index 58b0b3f..f9a57cd 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -220,7 +220,7 @@ class TestProjectCreateErrors: async def test_role_assignment_with_unknown_user_returns_422( self, client, as_lead, seeded_workspace_id ): - """`role_assignments` with a uuid that has no `users` row → 422 + missing list, not a 409 / 500.""" + """A uuid that TDEI does not list as a PG member → 422 + missing list.""" bogus = "ffffffff-ffff-ffff-ffff-ffffffffffff" r = await client.post( API.format(wid=seeded_workspace_id), @@ -236,7 +236,45 @@ async def test_role_assignment_with_unknown_user_returns_422( # FastAPI nests structured `detail` payloads under the `detail` key. assert "missing_user_ids" in body["detail"] assert bogus in body["detail"]["missing_user_ids"] - assert "users" in body["detail"]["message"].lower() + assert "project group" in body["detail"]["message"].lower() + + async def test_role_assignment_auto_provisions_from_tdei( + self, + client, + as_lead, + seeded_workspace_id, + tdei_project_group_users, + ): + """A uuid that's not in `users` but IS a TDEI PG member is auto-provisioned + the project is created.""" + from api.core.security import TdeiProjectGroupUser + + new_user_uuid = "1abfdb85-54c0-449b-965c-0abfd835d6fa" + tdei_project_group_users.append( + TdeiProjectGroupUser( + auth_uid=new_user_uuid, + email=f"{new_user_uuid}@test.local", + display_name="Auto Provisioned", + ) + ) + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={ + "name": "role-auto-provision", + "role_assignments": [ + {"user_id": new_user_uuid, "role": "validator"}, + ], + }, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + # Confirm the role assignment landed. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/{new_user_uuid}" + ) + assert r.status_code == 200, r.text + assert r.json()["role"] == "validator" async def test_duplicate_project_name_returns_409_with_specific_message( self, client, as_lead, seeded_workspace_id diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 8dc9ca8..c19f1c9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -31,7 +31,14 @@ async def getById(self, current_user, workspace_id: int): } if workspace_id not in all_ids: raise NotFoundException(f"Workspace {workspace_id} not found") - return SimpleNamespace(id=workspace_id) + # Echo the project-group id the user is a member of (any one + # works for unit tests — only the project-create route reads it). + pg_id = ( + next(iter(current_user.accessibleWorkspaceIds.keys())) + if current_user.accessibleWorkspaceIds + else "00000000-0000-0000-0000-000000000000" + ) + return SimpleNamespace(id=workspace_id, tdeiProjectGroupId=pg_id) # --------------------------------------------------------------------------- @@ -77,7 +84,13 @@ def _response(self, p: dict[str, Any]): # ---- create / list / get / patch / delete -------------------------- - async def create(self, workspace_id: int, current_user, body): + async def create( + self, + workspace_id: int, + current_user, + body, + tdei_project_group_id: str | None = None, + ): from api.core.exceptions import AlreadyExistsException if any( From d1219301a4c41240183d0293d2872754111d4088 Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 8 Jun 2026 12:34:17 +0530 Subject: [PATCH 062/159] audit api changes --- api/main.py | 2 + api/src/tasking/audit/__init__.py | 0 api/src/tasking/audit/dtos.py | 41 ++++ api/src/tasking/audit/repository.py | 280 ++++++++++++++++++++++ api/src/tasking/audit/routes.py | 134 +++++++++++ api/src/tasking/audit/schemas.py | 62 +++++ docs/tasking-mvp/tasking-mvp.openapi.json | 54 ++--- docs/tasking-mvp/tasking-mvp.openapi.yaml | 50 ++-- tests/integration/test_audit_flow.py | 248 +++++++++++++++++++ 9 files changed, 819 insertions(+), 52 deletions(-) create mode 100644 api/src/tasking/audit/__init__.py create mode 100644 api/src/tasking/audit/dtos.py create mode 100644 api/src/tasking/audit/repository.py create mode 100644 api/src/tasking/audit/routes.py create mode 100644 api/src/tasking/audit/schemas.py create mode 100644 tests/integration/test_audit_flow.py diff --git a/api/main.py b/api/main.py index cbe1007..fcff505 100644 --- a/api/main.py +++ b/api/main.py @@ -22,6 +22,7 @@ init_tdei_client, validate_token, ) +from api.src.tasking.audit.routes import router as tasking_audit_router from api.src.tasking.projects.routes import me_router as tasking_me_router from api.src.tasking.projects.routes import router as tasking_projects_router from api.src.tasking.tasks.routes import router as tasking_tasks_router @@ -97,6 +98,7 @@ async def lifespan(_app: FastAPI): app.include_router(tasking_projects_router, prefix="/api/v1") app.include_router(tasking_me_router, prefix="/api/v1") app.include_router(tasking_tasks_router, prefix="/api/v1") +app.include_router(tasking_audit_router, prefix="/api/v1") @app.get("/health") diff --git a/api/src/tasking/audit/__init__.py b/api/src/tasking/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py new file mode 100644 index 0000000..b40f0f3 --- /dev/null +++ b/api/src/tasking/audit/dtos.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination, WireModel + + +class ActorRef(WireModel): + """Resolved actor for an audit event.""" + + user_id: UUID + display_name: Optional[str] = None + + +class AuditEvent(WireModel): + """One row in `tasking_audit_events`, with `actor` joined for display.""" + + id: int + event_type: AuditEventType + project_id: int + task_id: Optional[int] = None + task_number: Optional[int] = None + actor: ActorRef + occurred_at: datetime + details: dict[str, Any] + project_deleted: bool = False + + +class AuditEventListResponse(WireModel): + results: list[AuditEvent] + pagination: Pagination + + +__all__ = [ + "ActorRef", + "AuditEvent", + "AuditEventListResponse", +] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py new file mode 100644 index 0000000..e813d8e --- /dev/null +++ b/api/src/tasking/audit/repository.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from sqlalchemy import text +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import NotFoundException +from api.src.tasking.audit.dtos import ( + ActorRef, + AuditEvent, + AuditEventListResponse, +) +from api.src.tasking.audit.schemas import AuditEventType +from api.src.tasking.projects.dtos import Pagination + + +_ALLOWED_ORDER_DIR = {"ASC", "DESC"} + + +def _normalise_dir(order_dir: str) -> str: + return order_dir.upper() if order_dir.upper() in _ALLOWED_ORDER_DIR else "DESC" + + +def _clamp_page(page: int, page_size: int, max_page_size: int) -> tuple[int, int]: + return max(page, 1), max(min(page_size, max_page_size), 1) + + +class TaskingAuditRepository: + """Read-side queries for `tasking_audit_events`. + + Writes are owned by the task and project repositories — see + `_audit()` in `api/src/tasking/tasks/repository.py`. This module + only reads. + """ + + def __init__(self, session: AsyncSession): + self.session = session + + # ---- internal helpers ------------------------------------------------- + + async def _assert_project_visible( + self, workspace_id: int, project_id: int, *, include_deleted: bool + ) -> None: + """Confirm the project lives in the workspace; honour soft-delete + unless the caller explicitly opted into deleted projects. + """ + clause = ( + "SELECT 1 FROM tasking_projects " + "WHERE id = :pid AND workspace_id = :wid" + ) + if not include_deleted: + clause += " AND deleted_at IS NULL" + + result = await self.session.execute( + text(clause), {"pid": project_id, "wid": workspace_id} + ) + if result.scalar() is None: + raise NotFoundException(f"Project {project_id} not found") + + async def _task_id_from_number( + self, project_id: int, task_number: int + ) -> int: + result = await self.session.execute( + text( + "SELECT id FROM tasking_tasks " + "WHERE project_id = :pid AND task_number = :tn" + ), + {"pid": project_id, "tn": task_number}, + ) + task_id = result.scalar() + if task_id is None: + raise NotFoundException( + f"Task {task_number} not found on project {project_id}" + ) + return int(task_id) + + async def _resolve_actor_names( + self, auth_uids: set[str] + ) -> dict[str, Optional[str]]: + """Batch-load `users.display_name` for every distinct actor. + + Avoids the N+1 fetch when rendering long event lists. UIDs with + no `users` row map to None (the field is optional). + """ + if not auth_uids: + return {} + rows = await self.session.execute( + text( + "SELECT auth_uid, display_name FROM users " + "WHERE auth_uid = ANY(:uids)" + ), + {"uids": list(auth_uids)}, + ) + mapping = {row[0]: row[1] for row in rows.all()} + # Unknown actors still need a None entry so callers can `.get(uid)`. + for uid in auth_uids: + mapping.setdefault(uid, None) + return mapping + + @staticmethod + def _row_to_event( + row, + names: dict[str, Optional[str]], + ) -> AuditEvent: + ( + event_id, + event_type, + project_id, + task_id, + actor_uid, + occurred_at, + details, + project_deleted, + ) = row + details = details or {} + task_number = details.get("task_number") if isinstance(details, dict) else None + return AuditEvent( + id=event_id, + event_type=AuditEventType(event_type), + project_id=project_id, + task_id=task_id, + task_number=task_number, + actor=ActorRef( + user_id=UUID(str(actor_uid)), + display_name=names.get(str(actor_uid)), + ), + occurred_at=occurred_at, + details=details, + project_deleted=bool(project_deleted), + ) + + # ---- listing queries -------------------------------------------------- + + async def list_project_events( + self, + workspace_id: int, + project_id: int, + *, + event_type: Optional[AuditEventType] = None, + task_number: Optional[int] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + include_deleted: bool = False, + page: int = 1, + page_size: int = 50, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + await self._assert_project_visible( + workspace_id, project_id, include_deleted=include_deleted + ) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # `task_number` is stored inside `details` JSONB rather than as a + # column, so filter via the typed accessor. + where = ["project_id = :pid"] + params: dict = {"pid": project_id} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if task_number is not None: + where.append("(details->>'task_number')::int = :tn") + params["tn"] = task_number + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + async def list_task_events( + self, + workspace_id: int, + project_id: int, + task_number: int, + *, + event_type: Optional[AuditEventType] = None, + actor_user_id: Optional[UUID] = None, + occurred_from: Optional[datetime] = None, + occurred_to: Optional[datetime] = None, + page: int = 1, + page_size: int = 25, + order_dir: str = "DESC", + ) -> AuditEventListResponse: + # Task-level listings only ever surface live projects. + await self._assert_project_visible( + workspace_id, project_id, include_deleted=False + ) + task_id = await self._task_id_from_number(project_id, task_number) + + page, page_size = _clamp_page(page, page_size, max_page_size=200) + order_dir = _normalise_dir(order_dir) + + # Match either `task_id = :tid` or the `task_number` in `details` + # — early audit rows for task creation set task_id but later + # rows that reference a task (e.g. project-level lock-extensions) + # may only persist the task_number. Both forms point at the + # same task, so OR them. + where = [ + "project_id = :pid", + "(task_id = :tid OR (details->>'task_number')::int = :tn)", + ] + params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} + if event_type is not None: + where.append("event_type = :et") + params["et"] = event_type.value + if actor_user_id is not None: + where.append("actor_user_auth_uid = :au") + params["au"] = str(actor_user_id) + if occurred_from is not None: + where.append("occurred_at >= :from_ts") + params["from_ts"] = occurred_from + if occurred_to is not None: + where.append("occurred_at <= :to_ts") + params["to_ts"] = occurred_to + where_sql = " AND ".join(where) + + total_q = await self.session.execute( + text(f"SELECT COUNT(*) FROM tasking_audit_events WHERE {where_sql}"), + params, + ) + total = int(total_q.scalar() or 0) + + rows = await self.session.execute( + text( + "SELECT id, event_type, project_id, task_id, " + " actor_user_auth_uid, occurred_at, details, project_deleted " + "FROM tasking_audit_events " + f"WHERE {where_sql} " + f"ORDER BY occurred_at {order_dir}, id {order_dir} " + "LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + raw_rows = list(rows.all()) + actor_uids = {str(r[4]) for r in raw_rows} + names = await self._resolve_actor_names(actor_uids) + + return AuditEventListResponse( + results=[self._row_to_event(r, names) for r in raw_rows], + pagination=Pagination(page=page, page_size=page_size, total=total), + ) + + +__all__ = ["TaskingAuditRepository"] diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py new file mode 100644 index 0000000..992f38c --- /dev/null +++ b/api/src/tasking/audit/routes.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status # noqa: F401 +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.database import get_osm_session, get_task_session +from api.core.security import UserInfo, validate_token +from api.src.tasking.audit.dtos import AuditEventListResponse +from api.src.tasking.audit.repository import TaskingAuditRepository +from api.src.tasking.audit.schemas import AuditEventType +from api.src.workspaces.repository import WorkspaceRepository + + +router = APIRouter( + prefix="/workspaces/{workspace_id}/tasking/projects", + tags=["tasking-audit"], +) + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def get_audit_repo( + session: AsyncSession = Depends(get_osm_session), +) -> TaskingAuditRepository: + return TaskingAuditRepository(session) + + +def get_workspace_repo( + session: AsyncSession = Depends(get_task_session), +) -> WorkspaceRepository: + return WorkspaceRepository(session) + + +async def assert_workspace_visible( + workspace_id: int, + current_user: UserInfo, + workspace_repo: WorkspaceRepository, +) -> None: + """Tenancy gate: 404 if the caller's project groups don't own the + workspace (matches `WorkspaceRepository.getById`'s convention). + """ + await workspace_repo.getById(current_user, workspace_id) + + +# --------------------------------------------------------------------------- +# Project-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/audit", + response_model=AuditEventListResponse, +) +async def list_project_audit( + workspace_id: int, + project_id: int, + event_type: Optional[AuditEventType] = Query(default=None), + task_number: Optional[int] = Query(default=None, ge=1), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + include_deleted: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated project-level audit listing, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_project_events( + workspace_id, + project_id, + event_type=event_type, + task_number=task_number, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + include_deleted=include_deleted, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +# --------------------------------------------------------------------------- +# Task-level audit +# --------------------------------------------------------------------------- + + +@router.get( + "/{project_id}/tasks/{task_number}/audit", + response_model=AuditEventListResponse, +) +async def list_task_audit( + workspace_id: int, + project_id: int, + task_number: int, + event_type: Optional[AuditEventType] = Query(default=None), + actor_user_id: Optional[UUID] = Query(default=None), + occurred_from: Optional[datetime] = Query(default=None), + occurred_to: Optional[datetime] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=200), + order_by_type: str = Query(default="DESC"), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + audit_repo: TaskingAuditRepository = Depends(get_audit_repo), +): + """Paginated audit listing for a single task, newest first.""" + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + return await audit_repo.list_task_events( + workspace_id, + project_id, + task_number, + event_type=event_type, + actor_user_id=actor_user_id, + occurred_from=occurred_from, + occurred_to=occurred_to, + page=page, + page_size=page_size, + order_dir=order_by_type, + ) + + +__all__ = ["router"] diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py new file mode 100644 index 0000000..b24c405 --- /dev/null +++ b/api/src/tasking/audit/schemas.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel + + +class AuditEventType(StrEnum): + """Closed set of event types written to `tasking_audit_events`. + + Mirrors the spec's `AuditEventType` enum; values match the literal + strings persisted in the `event_type` column. + """ + + PROJECT_CREATED = "project_created" + PROJECT_ACTIVATED = "project_activated" + PROJECT_CLOSED = "project_closed" + PROJECT_EDITED = "project_edited" + PROJECT_DELETED = "project_deleted" + PROJECT_RESET = "project_reset" + AOI_UPLOADED = "aoi_uploaded" + AOI_DELETED = "aoi_deleted" + TASK_CREATED = "task_created" + TASK_STATE_CHANGED = "task_state_changed" + TASK_LOCKED = "task_locked" + TASK_LOCK_EXTENDED = "task_lock_extended" + TASK_LOCK_RENEWED = "task_lock_renewed" + TASK_UNLOCKED = "task_unlocked" + TASK_RESET = "task_reset" + CHANGESET_SUBMITTED = "changeset_submitted" + FEEDBACK_SUBMITTED = "feedback_submitted" + + +class TaskingAuditEvent(SQLModel, table=True): + """Read-only mirror of `tasking_audit_events`. + + Writes happen via raw SQL in the task/project repositories so the + insert path stays decoupled from this module. The table is FK-free + by design (it must survive a project soft-delete + child hard-delete). + """ + + __tablename__ = "tasking_audit_events" # type: ignore[assignment] + + id: Optional[int] = Field(default=None, primary_key=True) + event_type: str = Field(max_length=64, nullable=False) + project_id: int = Field(nullable=False, index=True) + task_id: Optional[int] = Field(default=None, nullable=True) + actor_user_auth_uid: UUID = Field(nullable=False) + occurred_at: datetime = Field(nullable=False) + details: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column(JSONB, nullable=False), + ) + project_deleted: bool = Field(default=False, nullable=False) + + +__all__ = ["AuditEventType", "TaskingAuditEvent"] diff --git a/docs/tasking-mvp/tasking-mvp.openapi.json b/docs/tasking-mvp/tasking-mvp.openapi.json index 6c003b8..6e066dc 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.json +++ b/docs/tasking-mvp/tasking-mvp.openapi.json @@ -1652,19 +1652,19 @@ "audit" ], "summary": "List project-level audit events.", - "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `includeDeleted=true`.\n", + "description": "Paginated, newest first. Any workspace contributor. Soft-deleted\nprojects are visible only with `include_deleted=true`.\n", "operationId": "listProjectAudit", "parameters": [ { "in": "query", - "name": "eventType", + "name": "event_type", "schema": { "$ref": "#/components/schemas/AuditEventType" } }, { "in": "query", - "name": "taskNumber", + "name": "task_number", "schema": { "type": "integer", "minimum": 1 @@ -1672,7 +1672,7 @@ }, { "in": "query", - "name": "actorUserId", + "name": "actor_user_id", "schema": { "type": "string", "format": "uuid" @@ -1680,7 +1680,7 @@ }, { "in": "query", - "name": "occurredFrom", + "name": "occurred_from", "schema": { "type": "string", "format": "date-time" @@ -1688,7 +1688,7 @@ }, { "in": "query", - "name": "occurredTo", + "name": "occurred_to", "schema": { "type": "string", "format": "date-time" @@ -1696,7 +1696,7 @@ }, { "in": "query", - "name": "includeDeleted", + "name": "include_deleted", "schema": { "type": "boolean", "default": false @@ -1713,7 +1713,7 @@ }, { "in": "query", - "name": "pageSize", + "name": "page_size", "schema": { "type": "integer", "minimum": 1, @@ -1723,7 +1723,7 @@ }, { "in": "query", - "name": "orderByType", + "name": "order_by_type", "schema": { "type": "string", "enum": [ @@ -1776,14 +1776,14 @@ "parameters": [ { "in": "query", - "name": "eventType", + "name": "event_type", "schema": { "$ref": "#/components/schemas/AuditEventType" } }, { "in": "query", - "name": "actorUserId", + "name": "actor_user_id", "schema": { "type": "string", "format": "uuid" @@ -1791,7 +1791,7 @@ }, { "in": "query", - "name": "occurredFrom", + "name": "occurred_from", "schema": { "type": "string", "format": "date-time" @@ -1799,7 +1799,7 @@ }, { "in": "query", - "name": "occurredTo", + "name": "occurred_to", "schema": { "type": "string", "format": "date-time" @@ -1816,7 +1816,7 @@ }, { "in": "query", - "name": "pageSize", + "name": "page_size", "schema": { "type": "integer", "minimum": 1, @@ -1826,7 +1826,7 @@ }, { "in": "query", - "name": "orderByType", + "name": "order_by_type", "schema": { "type": "string", "enum": [ @@ -2983,14 +2983,14 @@ "ActorRef": { "type": "object", "required": [ - "userId" + "user_id" ], "properties": { - "userId": { + "user_id": { "type": "string", "format": "uuid" }, - "displayName": { + "display_name": { "type": "string", "nullable": true } @@ -3000,10 +3000,10 @@ "type": "object", "required": [ "id", - "eventType", - "projectId", + "event_type", + "project_id", "actor", - "occurredAt", + "occurred_at", "details" ], "properties": { @@ -3011,19 +3011,19 @@ "type": "integer", "format": "int64" }, - "eventType": { + "event_type": { "$ref": "#/components/schemas/AuditEventType" }, - "projectId": { + "project_id": { "type": "integer", "format": "int64" }, - "taskId": { + "task_id": { "type": "integer", "format": "int64", "nullable": true }, - "taskNumber": { + "task_number": { "type": "integer", "nullable": true, "description": "Convenience copy from `details` (so list renderers don't peek inside JSONB)." @@ -3031,7 +3031,7 @@ "actor": { "$ref": "#/components/schemas/ActorRef" }, - "occurredAt": { + "occurred_at": { "type": "string", "format": "date-time" }, @@ -3039,7 +3039,7 @@ "type": "object", "additionalProperties": true }, - "projectDeleted": { + "project_deleted": { "type": "boolean", "default": false } diff --git a/docs/tasking-mvp/tasking-mvp.openapi.yaml b/docs/tasking-mvp/tasking-mvp.openapi.yaml index 6eb502b..8076441 100644 --- a/docs/tasking-mvp/tasking-mvp.openapi.yaml +++ b/docs/tasking-mvp/tasking-mvp.openapi.yaml @@ -1114,35 +1114,35 @@ paths: summary: List project-level audit events. description: | Paginated, newest first. Any workspace contributor. Soft-deleted - projects are visible only with `includeDeleted=true`. + projects are visible only with `include_deleted=true`. operationId: listProjectAudit parameters: - in: query - name: eventType + name: event_type schema: { $ref: "#/components/schemas/AuditEventType" } - in: query - name: taskNumber + name: task_number schema: { type: integer, minimum: 1 } - in: query - name: actorUserId + name: actor_user_id schema: { type: string, format: uuid } - in: query - name: occurredFrom + name: occurred_from schema: { type: string, format: date-time } - in: query - name: occurredTo + name: occurred_to schema: { type: string, format: date-time } - in: query - name: includeDeleted + name: include_deleted schema: { type: boolean, default: false } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query - name: pageSize + name: page_size schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - in: query - name: orderByType + name: order_by_type schema: { type: string, enum: [ASC, DESC], default: DESC } responses: "200": @@ -1165,25 +1165,25 @@ paths: operationId: listTaskAudit parameters: - in: query - name: eventType + name: event_type schema: { $ref: "#/components/schemas/AuditEventType" } - in: query - name: actorUserId + name: actor_user_id schema: { type: string, format: uuid } - in: query - name: occurredFrom + name: occurred_from schema: { type: string, format: date-time } - in: query - name: occurredTo + name: occurred_to schema: { type: string, format: date-time } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query - name: pageSize + name: page_size schema: { type: integer, minimum: 1, maximum: 200, default: 25 } - in: query - name: orderByType + name: order_by_type schema: { type: string, enum: [ASC, DESC], default: DESC } responses: "200": @@ -1860,32 +1860,32 @@ components: ActorRef: type: object - required: [userId] + required: [user_id] properties: - userId: { type: string, format: uuid } - displayName: { type: string, nullable: true } + user_id: { type: string, format: uuid } + display_name: { type: string, nullable: true } AuditEvent: type: object - required: [id, eventType, projectId, actor, occurredAt, details] + required: [id, event_type, project_id, actor, occurred_at, details] properties: id: { type: integer, format: int64 } - eventType: { $ref: "#/components/schemas/AuditEventType" } - projectId: { type: integer, format: int64 } - taskId: + event_type: { $ref: "#/components/schemas/AuditEventType" } + project_id: { type: integer, format: int64 } + task_id: type: integer format: int64 nullable: true - taskNumber: + task_number: type: integer nullable: true description: Convenience copy from `details` (so list renderers don't peek inside JSONB). actor: { $ref: "#/components/schemas/ActorRef" } - occurredAt: { type: string, format: date-time } + occurred_at: { type: string, format: date-time } details: type: object additionalProperties: true - projectDeleted: + project_deleted: type: boolean default: false diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py new file mode 100644 index 0000000..0d8d83e --- /dev/null +++ b/tests/integration/test_audit_flow.py @@ -0,0 +1,248 @@ +"""Integration tests for the tasking audit endpoints. + +Both readers are simple paginated GETs, but they need real rows in +`tasking_audit_events` — produced by exercising the project + task +lifecycle. Each class sets up enough state to verify a specific cut: +listing, filtering, soft-delete visibility, task-scoped reads. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.integration + + +API = "/api/v1/workspaces/{wid}/tasking/projects" + + +AOI_UNIT_SQUARE = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], +} + +TASK_A = { + "type": "Polygon", + "coordinates": [ + [[0.00, 0.00], [0.01, 0.00], [0.01, 0.01], [0.00, 0.01], [0.00, 0.00]] + ], +} +TASK_B = { + "type": "Polygon", + "coordinates": [ + [[0.02, 0.02], [0.03, 0.02], [0.03, 0.03], [0.02, 0.03], [0.02, 0.02]] + ], +} + + +def _fc(*polys): + return { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": p} for p in polys], + } + + +# --------------------------------------------------------------------------- +# Helpers — open a project with two tasks so the audit log has rows worth +# filtering across (project_created, aoi_uploaded, task_created x N, +# project_activated, plus task lock/unlock events later). +# --------------------------------------------------------------------------- + + +async def _open_project_with_tasks(client, workspace_id): + r = await client.post( + API.format(wid=workspace_id), + json={"name": f"audit-{id(client)}", "review_required": False}, + ) + assert r.status_code == 201, r.text + pid = r.json()["id"] + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/aoi", json=AOI_UNIT_SQUARE + ) + assert r.status_code == 200, r.text + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/tasks/save", + json={"feature_collection": _fc(TASK_A, TASK_B)}, + ) + assert r.status_code == 201, r.text + + r = await client.post( + f"{API.format(wid=workspace_id)}/{pid}/activate" + ) + assert r.status_code == 200, r.text + return pid + + +# --------------------------------------------------------------------------- +# Workflow 1 — project-level audit listing + filters. +# --------------------------------------------------------------------------- + + +class TestProjectAuditListing: + + async def test_lists_lifecycle_events_newest_first( + self, client, as_lead, seeded_workspace_id + ): + """Project create → AOI upload → tasks → activate all appear in audit.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit" + ) + assert r.status_code == 200, r.text + body = r.json() + assert "results" in body + assert body["pagination"]["total"] >= 1 + + seen = [row["event_type"] for row in body["results"]] + # Lifecycle events we know are emitted by repository code today. + assert "project_activated" in seen + # Newest first. + ts = [row["occurred_at"] for row in body["results"]] + assert ts == sorted(ts, reverse=True) + + async def test_filter_by_event_type( + self, client, as_lead, seeded_workspace_id + ): + """`event_type` query narrows results to one kind.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"event_type": "project_activated"}, + ) + assert r.status_code == 200, r.text + kinds = {row["event_type"] for row in r.json()["results"]} + assert kinds == {"project_activated"} + + async def test_filter_by_actor( + self, client, as_lead, seeded_workspace_id + ): + """`actor_user_id` filters to events emitted by that user only.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"actor_user_id": str(as_lead.user_uuid)}, + ) + assert r.status_code == 200 + for row in r.json()["results"]: + assert row["actor"]["user_id"] == str(as_lead.user_uuid) + + async def test_pagination_clamps_and_total( + self, client, as_lead, seeded_workspace_id + ): + """Page size of 1 still returns one row; total reflects the whole set.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"page_size": 1, "page": 1}, + ) + assert r.status_code == 200 + body = r.json() + assert len(body["results"]) == 1 + assert body["pagination"]["page_size"] == 1 + assert body["pagination"]["total"] >= 1 + + async def test_unknown_project_404( + self, client, as_lead, seeded_workspace_id + ): + """A bogus project id returns 404 from the tenancy / existence check.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/999999/audit" + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 2 — task-level audit listing. +# --------------------------------------------------------------------------- + + +class TestTaskAuditListing: + + async def test_lists_task_events( + self, client, as_lead, as_contributor, seeded_workspace_id + ): + """Lock/unlock on a task surface in /tasks/{n}/audit.""" + # `as_lead` opens the project (lead-only), then switch to contributor + # to perform lock + unlock so we generate task events. + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + # Contributor locks task 1. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code in (200, 201), r.text + + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" + ) + assert r.status_code in (200, 204), r.text + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/audit" + ) + assert r.status_code == 200, r.text + body = r.json() + kinds = {row["event_type"] for row in body["results"]} + assert "task_locked" in kinds + assert "task_unlocked" in kinds + # Every row should reference the right task (by id or task_number). + for row in body["results"]: + assert ( + row["task_id"] is not None + or row.get("task_number") == 1 + ) + + async def test_unknown_task_404( + self, client, as_lead, seeded_workspace_id + ): + """A bogus task number on a real project returns 404.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/99/audit" + ) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Workflow 3 — soft-deleted projects honour include_deleted. +# --------------------------------------------------------------------------- + + +class TestAuditIncludeDeleted: + + async def test_deleted_project_hidden_by_default( + self, client, as_lead, seeded_workspace_id + ): + """A soft-deleted project's audit returns 404 unless `include_deleted=true`.""" + pid = await _open_project_with_tasks(client, seeded_workspace_id) + + # Project must be closed before delete. + r = await client.post( + f"{API.format(wid=seeded_workspace_id)}/{pid}/close" + ) + # Some tasks may still be open; tolerate either path. The audit + # endpoint behaviour we care about only needs deleted_at to be + # set, which the delete call will do regardless of status. + r = await client.delete( + f"{API.format(wid=seeded_workspace_id)}/{pid}" + ) + if r.status_code != 204: + pytest.skip(f"Could not soft-delete project: {r.status_code} {r.text}") + + # Default = hidden. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit" + ) + assert r.status_code == 404 + + # Explicit opt-in = visible. + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", + params={"include_deleted": True}, + ) + assert r.status_code == 200, r.text + assert r.json()["pagination"]["total"] >= 1 From e3d1bffb6102c36bf521ff2a75f28d92c83814ab Mon Sep 17 00:00:00 2001 From: MashB Date: Mon, 8 Jun 2026 12:34:46 +0530 Subject: [PATCH 063/159] audit test --- tests/integration/test_audit_flow.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index 0d8d83e..4e3c7f3 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -1,10 +1,3 @@ -"""Integration tests for the tasking audit endpoints. - -Both readers are simple paginated GETs, but they need real rows in -`tasking_audit_events` — produced by exercising the project + task -lifecycle. Each class sets up enough state to verify a specific cut: -listing, filtering, soft-delete visibility, task-scoped reads. -""" from __future__ import annotations From 0966769a919ad89191b859ab6457cb3a1b32e933 Mon Sep 17 00:00:00 2001 From: sujata-m Date: Tue, 16 Jun 2026 13:55:24 +0530 Subject: [PATCH 064/159] Fixed CI pipeline --- .github/workflows/ci.yml | 4 ++-- .gitignore | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72a0ff..2de6a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] jobs: lint: diff --git a/.gitignore b/.gitignore index cf7489e..4516af1 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ docs/tasking-mvp/tasking-mvp.postman_collection.json docs/tasking-mvp/tasking-mvp.postman_environment.json docs/tasking-mvp/_enrich_postman.py docs/tasking-mvp/feature-coverage.md +.idea/ From f1e2b23111977125e5d5e832a0903d36c039874c Mon Sep 17 00:00:00 2001 From: sujata-m Date: Tue, 16 Jun 2026 13:59:46 +0530 Subject: [PATCH 065/159] Fixed isort --- .../9221408912dd_add_user_role_table.py | 5 +- .../a1b2c3d4e5f6_tasking_mvp_schema.py | 23 ++----- .../c5121cbba124_initial_task_schema.py | 6 +- api/core/security.py | 4 +- api/src/tasking/audit/repository.py | 14 +---- api/src/tasking/audit/routes.py | 1 - api/src/tasking/projects/dtos.py | 7 ++- api/src/tasking/projects/repository.py | 51 +++++---------- api/src/tasking/projects/routes.py | 37 +++-------- api/src/tasking/projects/schemas.py | 7 ++- api/src/tasking/tasks/dtos.py | 6 +- api/src/tasking/tasks/repository.py | 63 +++++-------------- api/src/tasking/tasks/routes.py | 23 ++++--- api/src/tasking/tasks/schemas.py | 5 +- tests/conftest.py | 16 ++--- tests/integration/conftest.py | 23 +++---- tests/integration/test_audit_flow.py | 46 ++++---------- tests/integration/test_projects_flow.py | 59 +++++------------ tests/integration/test_tasks_flow.py | 55 +++++----------- tests/unit/conftest.py | 6 +- tests/unit/test_aoi_normalisation.py | 3 - tests/unit/test_dtos_validation.py | 3 - tests/unit/test_project_routes.py | 38 +++-------- tests/unit/test_user_info_gates.py | 27 ++++---- 24 files changed, 152 insertions(+), 376 deletions(-) diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 640453e..7d7fa57 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -13,7 +13,6 @@ from sqlalchemy import inspect, text from sqlalchemy.dialects import postgresql - # revision identifiers, used by Alembic. revision: str = "9221408912dd" down_revision: Union[str, None] = None @@ -37,9 +36,7 @@ def upgrade() -> None: "users", # `id` matches the Rails `users.id` numeric PK so the FK # from `team_user.user_id` in the next migration can attach. - sa.Column( - "id", sa.BigInteger(), autoincrement=True, nullable=False - ), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), sa.Column("auth_uid", sa.String(), nullable=False), sa.Column("email", sa.String(), nullable=True), sa.Column("display_name", sa.String(), nullable=True), diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py index 8606ce9..d792f4c 100644 --- a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -1,5 +1,3 @@ - - from typing import Sequence, Union import sqlalchemy as sa @@ -244,9 +242,7 @@ def upgrade() -> None: ), sa.Column("project_id", sa.BigInteger(), nullable=False), sa.Column("task_number", sa.Integer(), nullable=False), - sa.Column( - "area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False - ), + sa.Column("area_sqkm", sa.Numeric(precision=10, scale=4), nullable=False), sa.Column( "status", postgresql.ENUM( @@ -287,13 +283,8 @@ def upgrade() -> None: "ON tasking_tasks USING GIST (geometry)" ) else: - op.execute( - "ALTER TABLE tasking_tasks " - "ADD COLUMN geometry BYTEA" - ) - op.create_index( - "tasking_tasks_project_idx", "tasking_tasks", ["project_id"] - ) + op.execute("ALTER TABLE tasking_tasks " "ADD COLUMN geometry BYTEA") + op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) # ---- tasking_locks ------------------------------------------------ @@ -439,13 +430,9 @@ def upgrade() -> None: sa.ForeignKeyConstraint( ["project_id"], ["tasking_projects.id"], ondelete="CASCADE" ), - sa.ForeignKeyConstraint( - ["author_user_auth_uid"], ["users.auth_uid"] - ), - ) - op.create_index( - "tasking_feedback_task_idx", "tasking_feedback", ["task_id"] + sa.ForeignKeyConstraint(["author_user_auth_uid"], ["users.auth_uid"]), ) + op.create_index("tasking_feedback_task_idx", "tasking_feedback", ["task_id"]) op.create_index( "tasking_feedback_project_idx", "tasking_feedback", ["project_id"] ) diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py index ef9befa..a06f085 100644 --- a/alembic_task/versions/c5121cbba124_initial_task_schema.py +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -1,4 +1,3 @@ - from typing import Sequence, Union import sqlalchemy as sa @@ -54,7 +53,9 @@ def upgrade() -> None: sa.Column("tdeiProjectGroupId", sa.Uuid(), nullable=False), sa.Column("tdeiRecordId", sa.Uuid(), nullable=True), sa.Column("tdeiServiceId", sa.Uuid(), nullable=True), - sa.Column("tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "tdeiMetadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), sa.Column("createdAt", sa.DateTime(), nullable=False), sa.Column("createdBy", sa.Uuid(), nullable=False), sa.Column("createdByName", sa.String(), nullable=False), @@ -98,7 +99,6 @@ def upgrade() -> None: ) - def downgrade() -> None: bind = op.get_bind() assert bind is not None diff --git a/api/core/security.py b/api/core/security.py index 9919699..6a8f350 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -130,9 +130,7 @@ async def fetch_project_group_users( TdeiProjectGroupUser( auth_uid=str(uid), email=row.get("email"), - display_name=( - row.get("username") - ), + display_name=(row.get("username")), ) ) diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py index e813d8e..4f589b4 100644 --- a/api/src/tasking/audit/repository.py +++ b/api/src/tasking/audit/repository.py @@ -8,15 +8,10 @@ from sqlmodel.ext.asyncio.session import AsyncSession from api.core.exceptions import NotFoundException -from api.src.tasking.audit.dtos import ( - ActorRef, - AuditEvent, - AuditEventListResponse, -) +from api.src.tasking.audit.dtos import ActorRef, AuditEvent, AuditEventListResponse from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.dtos import Pagination - _ALLOWED_ORDER_DIR = {"ASC", "DESC"} @@ -48,8 +43,7 @@ async def _assert_project_visible( unless the caller explicitly opted into deleted projects. """ clause = ( - "SELECT 1 FROM tasking_projects " - "WHERE id = :pid AND workspace_id = :wid" + "SELECT 1 FROM tasking_projects " "WHERE id = :pid AND workspace_id = :wid" ) if not include_deleted: clause += " AND deleted_at IS NULL" @@ -60,9 +54,7 @@ async def _assert_project_visible( if result.scalar() is None: raise NotFoundException(f"Project {project_id} not found") - async def _task_id_from_number( - self, project_id: int, task_number: int - ) -> int: + async def _task_id_from_number(self, project_id: int, task_number: int) -> int: result = await self.session.execute( text( "SELECT id FROM tasking_tasks " diff --git a/api/src/tasking/audit/routes.py b/api/src/tasking/audit/routes.py index 992f38c..e6ab06f 100644 --- a/api/src/tasking/audit/routes.py +++ b/api/src/tasking/audit/routes.py @@ -14,7 +14,6 @@ from api.src.tasking.audit.schemas import AuditEventType from api.src.workspaces.repository import WorkspaceRepository - router = APIRouter( prefix="/workspaces/{workspace_id}/tasking/projects", tags=["tasking-audit"], diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index ef8848f..488e981 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -4,7 +4,9 @@ from typing import Any, Literal, Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field as PydField, field_validator +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import field_validator from api.src.tasking.projects.schemas import ( AoiInput, @@ -14,14 +16,13 @@ _MultiPolygon, ) - # --------------------------------------------------------------------------- # Shared DTO base. # --------------------------------------------------------------------------- class WireModel(BaseModel): - + model_config = ConfigDict(extra="forbid") diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index a19089a..087a978 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -45,7 +45,6 @@ _Polygon, ) - # --------------------------------------------------------------------------- # AOI helpers # --------------------------------------------------------------------------- @@ -184,9 +183,7 @@ def __init__(self, session: AsyncSession): # ---- internal helpers -------------------------------------------- - async def _get_active( - self, workspace_id: int, project_id: int - ) -> TaskingProject: + async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProject: """Fetch a non-deleted project scoped to a workspace; raise 404 otherwise.""" result = await self.session.execute( select(TaskingProject).where( @@ -233,13 +230,12 @@ async def _provision_users_from_tdei( a user who is known to TDEI but has not yet performed any action that would write them into `users` (e.g. first-time sign-in). """ - from api.core.security import fetch_project_group_users from sqlalchemy import text + from api.core.security import fetch_project_group_users + try: - members = await fetch_project_group_users( - project_group_id, bearer_token - ) + members = await fetch_project_group_users(project_group_id, bearer_token) except HTTPException: raise except Exception as e: @@ -276,9 +272,7 @@ async def _provision_users_from_tdei( return [u for u in missing_uuids if u not in resolved] - async def _missing_user_auth_uids( - self, uuids: list[UUID] - ) -> list[str]: + async def _missing_user_auth_uids(self, uuids: list[UUID]) -> list[str]: """Return the subset of `uuids` without a matching `users` row. Preflight for the `tasking_project_roles.user_auth_uid` FK so @@ -291,9 +285,7 @@ async def _missing_user_auth_uids( from sqlalchemy import text rows = await self.session.execute( - text( - "SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)" - ), + text("SELECT auth_uid FROM users WHERE auth_uid = ANY(:uids)"), {"uids": [str(u) for u in uuids]}, ) existing = {row[0] for row in rows.all()} @@ -305,9 +297,7 @@ async def _task_count(self, project_id: int) -> int: from sqlalchemy import text result = await self.session.execute( - text( - "SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid" - ), + text("SELECT COUNT(*) FROM tasking_tasks WHERE project_id = :pid"), {"pid": project_id}, ) return int(result.scalar() or 0) @@ -626,9 +616,7 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: # ---- lifecycle transitions --------------------------------------- - async def activate( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -679,9 +667,7 @@ async def activate( await self.session.refresh(project) return self._to_response(project, task_count=tc) - async def close( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def close(self, workspace_id: int, project_id: int) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.OPEN: raise HTTPException( @@ -726,9 +712,7 @@ async def close( tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def reset( - self, workspace_id: int, project_id: int - ) -> ProjectResponse: + async def reset(self, workspace_id: int, project_id: int) -> ProjectResponse: """LEAD reset — see spec §projects.""" project = await self._get_active(workspace_id, project_id) if project.status == ProjectStatus.DRAFT: @@ -772,9 +756,7 @@ async def reset( # ---- AOI --------------------------------------------------------- - async def get_aoi( - self, workspace_id: int, project_id: int - ) -> AoiFeature: + async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: project = await self._get_active(workspace_id, project_id) if project.aoi is None: raise NotFoundException("AOI is not set on this project") @@ -823,9 +805,7 @@ async def upload_aoi( # violations on `user_auth_uid` are caught with a preflight so the # caller gets a 422 listing the missing user id. - async def _is_project_lead( - self, project_id: int, user_uuid: UUID - ) -> bool: + async def _is_project_lead(self, project_id: int, user_uuid: UUID) -> bool: """True if the user holds a `lead` role on the given project.""" from sqlalchemy import text @@ -858,8 +838,7 @@ async def assert_can_manage_roles( if await self._is_project_lead(project_id, current_user.user_uuid): return raise ForbiddenException( - "Only a workspace lead or project lead can manage roles " - "on this project." + "Only a workspace lead or project lead can manage roles " "on this project." ) async def _lead_count(self, project_id: int) -> int: @@ -1200,9 +1179,7 @@ async def remove_role( ) await self.session.commit() - async def _get_role( - self, project_id: int, user_id: UUID - ) -> ProjectRoleItem: + async def _get_role(self, project_id: int, user_id: UUID) -> ProjectRoleItem: item = await self._get_role_or_none(project_id, user_id) if item is None: # pragma: no cover — only called after insert/update raise NotFoundException( diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 81f06f4..e7367f1 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -1,13 +1,13 @@ from __future__ import annotations from typing import Annotated +from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session from api.core.security import UserInfo, validate_token -from api.src.tasking.projects.repository import TaskingProjectRepository from api.src.tasking.projects.dtos import ( AoiFeature, ProjectCreateRequest, @@ -20,11 +20,8 @@ ProjectUpdateRequest, SelfProjectRolesResponse, ) -from api.src.tasking.projects.schemas import ( - AoiInput, - ProjectStatus, -) -from uuid import UUID +from api.src.tasking.projects.repository import TaskingProjectRepository +from api.src.tasking.projects.schemas import AoiInput, ProjectStatus from api.src.workspaces.repository import WorkspaceRepository router = APIRouter( @@ -84,9 +81,7 @@ def assert_workspace_lead(workspace_id: int, current_user: UserInfo) -> None: @router.get("", response_model=ProjectListResponse) async def list_projects( workspace_id: int, - status_filter: Annotated[ - ProjectStatus | None, Query(alias="status") - ] = None, + status_filter: Annotated[ProjectStatus | None, Query(alias="status")] = None, text_search: str | None = Query(default=None, max_length=255), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), @@ -280,9 +275,7 @@ async def add_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) return await project_repo.add_role(workspace_id, project_id, body) @@ -318,9 +311,7 @@ async def put_project_role( ): """Idempotent upsert. 201 on insert, 200 on update. Last-LEAD guarded.""" await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) item, created = await project_repo.upsert_role( workspace_id, project_id, user_id, body ) @@ -343,12 +334,8 @@ async def update_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) - return await project_repo.update_role( - workspace_id, project_id, user_id, body - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) + return await project_repo.update_role(workspace_id, project_id, user_id, body) @router.delete( @@ -364,9 +351,7 @@ async def remove_project_role( project_repo: TaskingProjectRepository = Depends(get_project_repo), ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) - await project_repo.assert_can_manage_roles( - workspace_id, project_id, current_user - ) + await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) await project_repo.remove_role(workspace_id, project_id, user_id) @@ -409,6 +394,4 @@ async def list_self_project_roles( Single round-trip for the project-list page. """ await assert_workspace_visible(workspace_id, current_user, workspace_repo) - return await project_repo.list_self_project_roles( - workspace_id, current_user - ) + return await project_repo.list_self_project_roles(workspace_id, current_user) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index f0b4695..901d5f6 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -6,11 +6,12 @@ from uuid import UUID from geoalchemy2 import Geometry -from pydantic import BaseModel, Field as PydField -from sqlalchemy import Column, Enum as SAEnum +from pydantic import BaseModel +from pydantic import Field as PydField +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel - # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) # --------------------------------------------------------------------------- diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py index edfec34..374bed2 100644 --- a/api/src/tasking/tasks/dtos.py +++ b/api/src/tasking/tasks/dtos.py @@ -7,11 +7,7 @@ from pydantic import Field as PydField from api.src.tasking.projects.dtos import Pagination, WireModel -from api.src.tasking.tasks.schemas import ( - FeedbackReason, - TaskStatus, -) - +from api.src.tasking.tasks.schemas import FeedbackReason, TaskStatus # --------------------------------------------------------------------------- # Task boundary GeoJSON (input for /tasks/validate and /tasks/save) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index bb6cf41..0e785be 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -24,10 +24,7 @@ ) from api.core.security import UserInfo from api.src.tasking.projects.dtos import Pagination -from api.src.tasking.projects.schemas import ( - ProjectStatus, - TaskingProject, -) +from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject from api.src.tasking.tasks.dtos import ( ExistingLockSummary, FeedbackInput, @@ -53,7 +50,6 @@ TaskStatus, ) - # Equirectangular approximation for area calculations on small # EPSG:4326 polygons: 1 degree latitude ≈ 111.32 km. Sufficient for # the grid-size warning threshold; precise areas need a metric @@ -149,9 +145,7 @@ def _generate_grid_over_aoi( minx, miny, maxx, maxy = aoi.bounds center_lat = (miny + maxy) / 2.0 lat_step = cell_size_m / 111_320.0 - lon_step = cell_size_m / ( - 111_320.0 * max(math.cos(math.radians(center_lat)), 0.01) - ) + lon_step = cell_size_m / (111_320.0 * max(math.cos(math.radians(center_lat)), 0.01)) cells: list[ShapelyPolygon] = [] # Safety cap for accidental large-AOI + small-cell combinations. @@ -176,15 +170,10 @@ def _generate_grid_over_aoi( # `intersection` can return a Polygon, MultiPolygon, # or GeometryCollection; retain polygon pieces only. geoms = ( - list(clipped.geoms) - if hasattr(clipped, "geoms") - else [clipped] + list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] ) for piece in geoms: - if ( - isinstance(piece, ShapelyPolygon) - and piece.area > 0 - ): + if isinstance(piece, ShapelyPolygon) and piece.area > 0: cells.append(piece) if len(cells) >= cell_cap: return cells @@ -212,9 +201,7 @@ def __init__(self, session: AsyncSession): # ---- common helpers --------------------------------------------------- - async def _get_project( - self, workspace_id: int, project_id: int - ) -> TaskingProject: + async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProject: rs = await self.session.execute( select(TaskingProject).where( (TaskingProject.id == project_id) @@ -227,9 +214,7 @@ async def _get_project( raise NotFoundException(f"Project {project_id} not found") return project - async def _get_task( - self, project_id: int, task_number: int - ) -> TaskingTask: + async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: rs = await self.session.execute( select(TaskingTask).where( (TaskingTask.project_id == project_id) @@ -243,13 +228,10 @@ async def _get_task( ) return task - async def _get_active_lock( - self, task_id: int - ) -> Optional[TaskingLock]: + async def _get_active_lock(self, task_id: int) -> Optional[TaskingLock]: rs = await self.session.execute( select(TaskingLock).where( - (TaskingLock.task_id == task_id) - & (TaskingLock.released_at.is_(None)) + (TaskingLock.task_id == task_id) & (TaskingLock.released_at.is_(None)) ) ) return rs.scalar_one_or_none() @@ -320,15 +302,11 @@ async def _audit( }, ) - async def _lookup_user_display( - self, user_auth_uid: Optional[str] - ) -> Optional[str]: + async def _lookup_user_display(self, user_auth_uid: Optional[str]) -> Optional[str]: if not user_auth_uid: return None rs = await self.session.execute( - text( - "SELECT display_name FROM users WHERE auth_uid = :uid" - ), + text("SELECT display_name FROM users WHERE auth_uid = :uid"), {"uid": user_auth_uid}, ) return rs.scalar_one_or_none() @@ -498,9 +476,7 @@ async def save( detail="Project AOI is required before saving tasks", ) - body_bytes = json.dumps( - body.model_dump(mode="json"), sort_keys=True - ).encode() + body_bytes = json.dumps(body.model_dump(mode="json"), sort_keys=True).encode() body_hash = hashlib.sha256(body_bytes).hexdigest() # Idempotent replay path. @@ -530,9 +506,7 @@ async def save( # Refuse if tasks already exist (re-upload AOI to wipe). existing = await self.session.execute( - text( - "SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1" - ), + text("SELECT 1 FROM tasking_tasks WHERE project_id = :pid LIMIT 1"), {"pid": project.id}, ) if existing.scalar() is not None: @@ -692,9 +666,7 @@ async def lock_task( task = await self._get_task(project_id, task_number) # Eligibility table. - role = await self._project_role( - project_id, current_user, workspace_id - ) + role = await self._project_role(project_id, current_user, workspace_id) if role is None: raise ForbiddenException("User has no access to this project") @@ -708,13 +680,10 @@ async def lock_task( raise ForbiddenException( "Role does not permit locking this task for validation" ) - if ( - task.last_mapper_id - and task.last_mapper_id == str(current_user.user_uuid) + if task.last_mapper_id and task.last_mapper_id == str( + current_user.user_uuid ): - raise ForbiddenException( - "Cannot validate a task you last mapped" - ) + raise ForbiddenException("Cannot validate a task you last mapped") else: raise ForbiddenException("Task is in a terminal state") diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 1d38565..24bf14e 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -3,7 +3,16 @@ from typing import Annotated, Optional from uuid import UUID -from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response, status +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Query, + Response, + status, +) from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -81,9 +90,7 @@ async def generate_grid( """ await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await task_repo.generate_grid( - workspace_id, project_id, cell_size_meters - ) + return await task_repo.generate_grid(workspace_id, project_id, cell_size_meters) @router.post("/tasks/validate", response_model=ValidatePreviewResponse) @@ -118,9 +125,7 @@ async def save_tasks( payload, replayed = await task_repo.save( workspace_id, project_id, current_user, body, idempotency_key ) - response.status_code = ( - status.HTTP_200_OK if replayed else status.HTTP_201_CREATED - ) + response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED return payload @@ -128,9 +133,7 @@ async def save_tasks( async def list_tasks( workspace_id: int, project_id: int, - status_filter: Annotated[ - Optional[TaskStatus], Query(alias="status") - ] = None, + status_filter: Annotated[Optional[TaskStatus], Query(alias="status")] = None, locked_by_user_id: Optional[UUID] = Query(default=None), last_mapper_id: Optional[UUID] = Query(default=None), page: int = Query(1, ge=1), diff --git a/api/src/tasking/tasks/schemas.py b/api/src/tasking/tasks/schemas.py index 40e5799..f3c114b 100644 --- a/api/src/tasking/tasks/schemas.py +++ b/api/src/tasking/tasks/schemas.py @@ -1,4 +1,3 @@ - from __future__ import annotations from datetime import datetime @@ -7,10 +6,10 @@ from typing import Any, Optional from geoalchemy2 import Geometry -from sqlalchemy import Column, Enum as SAEnum +from sqlalchemy import Column +from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel - # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index fbbf416..309c430 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations from collections.abc import AsyncIterator, Iterator @@ -7,7 +6,6 @@ import pytest - # Constants — referenced by both unit and integration suites. SEED_WORKSPACE_ID = 1899 SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") @@ -73,9 +71,8 @@ def pytest_html_results_table_header(cells): def pytest_html_results_table_row(report, cells): """Inject the docstring as the matching cell on each row.""" - cells.insert( - 2, f"{getattr(report, 'description', '') or '—'}" - ) + cells.insert(2, f"{getattr(report, 'description', '') or '—'}") + except ImportError: pass @@ -83,8 +80,7 @@ def pytest_html_results_table_row(report, cells): def _redact(headers) -> str: redact = {"authorization", "cookie", "set-cookie", "x-api-key"} return ", ".join( - f"{k}={'***' if k.lower() in redact else v}" - for k, v in headers.items() + f"{k}={'***' if k.lower() in redact else v}" for k, v in headers.items() ) @@ -152,11 +148,7 @@ def _make_user( is_poc: bool = False, ): """Construct a UserInfo with the minimum fields the gates inspect.""" - from api.core.security import ( - TdeiProjectGroupRole, - UserInfo, - UserInfoPGMembership, - ) + from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership from api.src.users.schemas import WorkspaceUserRoleType u = UserInfo() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c56b092..be2be67 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations import os @@ -10,7 +9,6 @@ from tests.conftest import SEED_PROJECT_GROUP_ID, SEED_WORKSPACE_ID - # --------------------------------------------------------------------------- # Docker availability gate — skip cleanly when the daemon is missing # and surface the actual reason in the pytest skip message. @@ -232,8 +230,8 @@ async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: await conn.execute( text( "INSERT INTO workspaces " - "(id, type, title, \"tdeiProjectGroupId\", \"createdAt\", " - " \"createdBy\", \"createdByName\", \"externalAppAccess\") " + '(id, type, title, "tdeiProjectGroupId", "createdAt", ' + ' "createdBy", "createdByName", "externalAppAccess") ' "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " "ON CONFLICT (id) DO NOTHING" ), @@ -374,10 +372,11 @@ async def _get_osm(): # `validate_token`. These shadow the unit-suite counterparts in # tests/conftest.py for every test under tests/integration/. + @pytest.fixture async def as_lead(_pg_urls, seeded_workspace_id): """LEAD user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -394,7 +393,7 @@ async def as_lead(_pg_urls, seeded_workspace_id): @pytest.fixture async def as_contributor(_pg_urls, seeded_workspace_id): """CONTRIBUTOR user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -411,7 +410,7 @@ async def as_contributor(_pg_urls, seeded_workspace_id): @pytest.fixture async def as_validator(_pg_urls, seeded_workspace_id): """VALIDATOR user persisted in users table + overridden in validate_token.""" - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -432,7 +431,7 @@ async def as_outsider(_pg_urls, seeded_workspace_id): Inserted into users so role tests don't break, but their workspace role list is empty so the tenancy gate still 404s. """ - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user _, osm_url = _pg_urls user = _make_user( @@ -500,15 +499,11 @@ async def _fake_fetch(project_group_id: str, bearer_token: str): import api.core.security import api.src.tasking.projects.repository as proj_repo - monkeypatch.setattr( - api.core.security, "fetch_project_group_users", _fake_fetch - ) + monkeypatch.setattr(api.core.security, "fetch_project_group_users", _fake_fetch) # The repository imports the symbol locally inside the helper, but be # belt-and-braces in case that ever changes: if hasattr(proj_repo, "fetch_project_group_users"): - monkeypatch.setattr( - proj_repo, "fetch_project_group_users", _fake_fetch - ) + monkeypatch.setattr(proj_repo, "fetch_project_group_users", _fake_fetch) return members diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index 4e3c7f3..3cc37ec 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -1,4 +1,3 @@ - from __future__ import annotations import pytest @@ -61,9 +60,7 @@ async def _open_project_with_tasks(client, workspace_id): ) assert r.status_code == 201, r.text - r = await client.post( - f"{API.format(wid=workspace_id)}/{pid}/activate" - ) + r = await client.post(f"{API.format(wid=workspace_id)}/{pid}/activate") assert r.status_code == 200, r.text return pid @@ -81,9 +78,7 @@ async def test_lists_lifecycle_events_newest_first( """Project create → AOI upload → tasks → activate all appear in audit.""" pid = await _open_project_with_tasks(client, seeded_workspace_id) - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/audit" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") assert r.status_code == 200, r.text body = r.json() assert "results" in body @@ -96,9 +91,7 @@ async def test_lists_lifecycle_events_newest_first( ts = [row["occurred_at"] for row in body["results"]] assert ts == sorted(ts, reverse=True) - async def test_filter_by_event_type( - self, client, as_lead, seeded_workspace_id - ): + async def test_filter_by_event_type(self, client, as_lead, seeded_workspace_id): """`event_type` query narrows results to one kind.""" pid = await _open_project_with_tasks(client, seeded_workspace_id) @@ -110,9 +103,7 @@ async def test_filter_by_event_type( kinds = {row["event_type"] for row in r.json()["results"]} assert kinds == {"project_activated"} - async def test_filter_by_actor( - self, client, as_lead, seeded_workspace_id - ): + async def test_filter_by_actor(self, client, as_lead, seeded_workspace_id): """`actor_user_id` filters to events emitted by that user only.""" pid = await _open_project_with_tasks(client, seeded_workspace_id) r = await client.get( @@ -138,13 +129,9 @@ async def test_pagination_clamps_and_total( assert body["pagination"]["page_size"] == 1 assert body["pagination"]["total"] >= 1 - async def test_unknown_project_404( - self, client, as_lead, seeded_workspace_id - ): + async def test_unknown_project_404(self, client, as_lead, seeded_workspace_id): """A bogus project id returns 404 from the tenancy / existence check.""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/999999/audit" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/999999/audit") assert r.status_code == 404 @@ -184,14 +171,9 @@ async def test_lists_task_events( assert "task_unlocked" in kinds # Every row should reference the right task (by id or task_number). for row in body["results"]: - assert ( - row["task_id"] is not None - or row.get("task_number") == 1 - ) + assert row["task_id"] is not None or row.get("task_number") == 1 - async def test_unknown_task_404( - self, client, as_lead, seeded_workspace_id - ): + async def test_unknown_task_404(self, client, as_lead, seeded_workspace_id): """A bogus task number on a real project returns 404.""" pid = await _open_project_with_tasks(client, seeded_workspace_id) r = await client.get( @@ -214,22 +196,16 @@ async def test_deleted_project_hidden_by_default( pid = await _open_project_with_tasks(client, seeded_workspace_id) # Project must be closed before delete. - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/close" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/close") # Some tasks may still be open; tolerate either path. The audit # endpoint behaviour we care about only needs deleted_at to be # set, which the delete call will do regardless of status. - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}") if r.status_code != 204: pytest.skip(f"Could not soft-delete project: {r.status_code} {r.text}") # Default = hidden. - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/audit" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") assert r.status_code == 404 # Explicit opt-in = visible. diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index f9a57cd..c1575de 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -1,4 +1,3 @@ - from __future__ import annotations import pytest @@ -50,9 +49,7 @@ async def test_01_create_draft(self, client, as_lead, seeded_workspace_id): async def test_02_get_round_trip(self, client, as_lead, seeded_workspace_id): """GET round-trips the project just created (same id).""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{self.project_id}") assert r.status_code == 200 assert r.json()["id"] == self.project_id @@ -113,9 +110,7 @@ async def test_07_soft_delete_clears_listing( ids = {row["id"] for row in r.json()["results"]} assert self.project_id not in ids - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{self.project_id}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{self.project_id}") assert r.status_code == 404 @@ -226,9 +221,7 @@ async def test_role_assignment_with_unknown_user_returns_422( API.format(wid=seeded_workspace_id), json={ "name": "role-fk-error", - "role_assignments": [ - {"user_id": bogus, "role": "contributor"} - ], + "role_assignments": [{"user_id": bogus, "role": "contributor"}], }, ) assert r.status_code == 422, r.text @@ -323,20 +316,13 @@ async def test_aoi_replace_resets_boundary_type( json=SQUARE_MULTI, ) assert r2.status_code == 200 - assert ( - r2.json()["geometry"]["coordinates"] - == SQUARE_MULTI["coordinates"] - ) + assert r2.json()["geometry"]["coordinates"] == SQUARE_MULTI["coordinates"] # Boundary type should have been cleared (per spec). - proj = ( - await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") - ).json() + proj = (await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}")).json() assert proj["task_boundary_type"] is None - async def test_aoi_delete_round_trip( - self, client, as_lead, seeded_workspace_id - ): + async def test_aoi_delete_round_trip(self, client, as_lead, seeded_workspace_id): """DELETE /aoi removes the AOI; subsequent GET returns 404.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -348,15 +334,11 @@ async def test_aoi_delete_round_trip( json=SQUARE_POLY, ) - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 204 # Subsequent GET 404s. - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 @@ -380,9 +362,7 @@ async def test_list_includes_creator_auto_lead( json={"name": "roles-list-1"}, ) pid = r.json()["id"] - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") assert r.status_code == 200, r.text rows = r.json()["results"] assert len(rows) == 1 @@ -413,9 +393,7 @@ async def test_add_role_round_trip( assert body["user_id"] == str(contrib.user_uuid) assert body["role"] == "contributor" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/roles") ids = {row["user_id"] for row in r.json()["results"]} assert str(contrib.user_uuid) in ids @@ -527,8 +505,7 @@ async def test_remove_role_round_trip( ) r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{contrib.user_uuid}" + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" ) assert r.status_code == 204 @@ -540,9 +517,7 @@ async def test_remove_role_round_trip( ) assert r.status_code == 404 - async def test_last_lead_demote_blocked( - self, client, as_lead, seeded_workspace_id - ): + async def test_last_lead_demote_blocked(self, client, as_lead, seeded_workspace_id): """Cannot demote the only LEAD — projects must always have one.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -558,9 +533,7 @@ async def test_last_lead_demote_blocked( assert r.status_code == 422, r.text assert "last lead" in r.json()["detail"].lower() - async def test_last_lead_delete_blocked( - self, client, as_lead, seeded_workspace_id - ): + async def test_last_lead_delete_blocked(self, client, as_lead, seeded_workspace_id): """Cannot delete the only LEAD — would orphan the project.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -596,8 +569,7 @@ async def test_demote_lead_works_when_two_leads_exist( # Now demote the second lead — first lead is still there. r = await client.patch( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{lead2.user_uuid}", + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{lead2.user_uuid}", json={"role": "contributor"}, ) assert r.status_code == 200, r.text @@ -623,8 +595,7 @@ async def test_get_single_role( ) r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" - f"{contrib.user_uuid}" + f"{API.format(wid=seeded_workspace_id)}/{pid}/roles/" f"{contrib.user_uuid}" ) assert r.status_code == 200, r.text body = r.json() diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 1cf712a..189e791 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -161,9 +159,7 @@ async def test_grid_blocked_without_aoi( ) pid = r.json()["id"] - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") assert r.status_code == 422, r.text assert "aoi" in r.json()["detail"].lower() @@ -187,9 +183,7 @@ async def test_grid_blocked_outside_draft( name_suffix="-grid-state", ) - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/grid") assert r.status_code == 422, r.text assert "draft" in r.json()["detail"].lower() @@ -240,8 +234,7 @@ async def test_grid_multipolygon_straddling_cell_splits( assert len(fc["features"]) == 2, [f["geometry"] for f in fc["features"]] for feat in fc["features"]: assert feat["geometry"]["type"] == "Polygon", ( - "straddling cell was not split — got " - f"{feat['geometry']['type']}" + "straddling cell was not split — got " f"{feat['geometry']['type']}" ) # The two output polygons should align with the two lobes — @@ -290,9 +283,7 @@ class TestValidateAndSave: project_id: int | None = None - async def test_01_create_draft_with_aoi( - self, client, as_lead, seeded_workspace_id - ): + async def test_01_create_draft_with_aoi(self, client, as_lead, seeded_workspace_id): """Create a draft project and upload the project AOI.""" r = await client.post( API.format(wid=seeded_workspace_id), @@ -307,9 +298,7 @@ async def test_01_create_draft_with_aoi( ) assert r.status_code == 200, r.text - async def test_02_validate_inside_aoi( - self, client, as_lead, seeded_workspace_id - ): + async def test_02_validate_inside_aoi(self, client, as_lead, seeded_workspace_id): """Two in-AOI polygons validate cleanly with no warnings.""" r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", @@ -327,9 +316,7 @@ async def test_03_validate_polygon_outside_aoi_rejected( """A polygon outside the project AOI is rejected with 422.""" outside = { "type": "Polygon", - "coordinates": [ - [[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]] - ], + "coordinates": [[[5, 5], [5.1, 5], [5.1, 5.1], [5, 5.1], [5, 5]]], } r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/validate", @@ -370,9 +357,7 @@ async def test_05_save_persists_two_tasks( assert body["tasks"][0]["lock"] is None assert body["tasks"][0]["last_mapper"] is None - async def test_06_double_save_rejected( - self, client, as_lead, seeded_workspace_id - ): + async def test_06_double_save_rejected(self, client, as_lead, seeded_workspace_id): """A second save into a project that already has tasks 409s.""" r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/save", @@ -393,9 +378,7 @@ async def test_07_list_tasks_returns_geometry( assert len(body["tasks"]) == 2 assert body["tasks"][0]["geometry"]["type"] == "Polygon" - async def test_08_get_single_task( - self, client, as_lead, seeded_workspace_id - ): + async def test_08_get_single_task(self, client, as_lead, seeded_workspace_id): """GET /tasks/{n} returns one task with geometry + metadata.""" r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1" @@ -788,9 +771,7 @@ async def test_remap_loop( # Contributor maps → to_review. override_user(contributor) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 7001, "done": True}, @@ -799,9 +780,7 @@ async def test_remap_loop( # Validator validates with feedback → to_remap. override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ @@ -856,9 +835,7 @@ async def test_validator_cannot_validate_own_last_mapping( # Validator maps the task themselves (validators can also lock to_map). override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 9001, "done": True}, @@ -900,25 +877,21 @@ async def test_reset_releases_lock_and_resets_status( # Contributor maps → to_review. override_user(contributor) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={"osm_changeset_id": 11001, "done": True}, ) # Validator picks it up. override_user(validator) - await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock" - ) + await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") # Switch back to a LEAD token to invoke /reset. The integration # `as_lead` fixture already inserted a lead users row, so the # helper here just builds a UserInfo to bind to the override. from api.core.security import validate_token from api.main import app - from tests.conftest import _make_user, SEED_PROJECT_GROUP_ID + from tests.conftest import SEED_PROJECT_GROUP_ID, _make_user lead = _make_user( role="lead", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c19f1c9..56ab9ce 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,4 +1,3 @@ - from __future__ import annotations from datetime import datetime @@ -8,7 +7,6 @@ import pytest - # --------------------------------------------------------------------------- # Fake workspace repository — tenancy gate without a DB. # --------------------------------------------------------------------------- @@ -25,9 +23,7 @@ async def getById(self, current_user, workspace_id: int): from api.core.exceptions import NotFoundException all_ids = { - wid - for ids in current_user.accessibleWorkspaceIds.values() - for wid in ids + wid for ids in current_user.accessibleWorkspaceIds.values() for wid in ids } if workspace_id not in all_ids: raise NotFoundException(f"Workspace {workspace_id} not found") diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py index 19d54a8..86b253b 100644 --- a/tests/unit/test_aoi_normalisation.py +++ b/tests/unit/test_aoi_normalisation.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -14,7 +12,6 @@ _Polygon, ) - SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] TWO_SQUARES = [ [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py index b7395fd..8bad12f 100644 --- a/tests/unit/test_dtos_validation.py +++ b/tests/unit/test_dtos_validation.py @@ -1,5 +1,3 @@ - - from __future__ import annotations import pytest @@ -11,7 +9,6 @@ ProjectUpdateRequest, ) - # --------------------------------------------------------------------------- # ProjectCreateRequest # --------------------------------------------------------------------------- diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py index 86987ac..1620aba 100644 --- a/tests/unit/test_project_routes.py +++ b/tests/unit/test_project_routes.py @@ -1,7 +1,5 @@ - from __future__ import annotations - API = "/api/v1/workspaces/{wid}/tasking/projects" @@ -74,9 +72,7 @@ async def test_get_404_when_missing( self, client, as_lead, seeded_workspace_id, fake_repos ): """GET on a non-existent project id returns 404.""" - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/9999" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/9999") assert r.status_code == 404 async def test_create_then_get_round_trip( @@ -89,15 +85,11 @@ async def test_create_then_get_round_trip( ) pid = r.json()["id"] - r2 = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r2 = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r2.status_code == 200 assert r2.json()["id"] == pid - async def test_patch_name( - self, client, as_lead, seeded_workspace_id, fake_repos - ): + async def test_patch_name(self, client, as_lead, seeded_workspace_id, fake_repos): """PATCH updates only specified fields — name change is reflected on GET.""" pid = ( await client.post( @@ -124,14 +116,10 @@ async def test_soft_delete_204_then_404( ) ).json()["id"] - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r.status_code == 204 - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}") assert r.status_code == 404 async def test_duplicate_name_409( @@ -166,9 +154,7 @@ async def test_activate_fake_always_422( ) ).json()["id"] - r = await client.post( - f"{API.format(wid=seeded_workspace_id)}/{pid}/activate" - ) + r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/activate") assert r.status_code == 422 @@ -210,9 +196,7 @@ async def test_get_aoi_404_when_unset( ) ).json()["id"] - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 async def test_delete_aoi_round_trip( @@ -233,12 +217,8 @@ async def test_delete_aoi_round_trip( }, ) - r = await client.delete( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.delete(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 204 - r = await client.get( - f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi" - ) + r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/aoi") assert r.status_code == 404 diff --git a/tests/unit/test_user_info_gates.py b/tests/unit/test_user_info_gates.py index dcce1a4..32fcfa2 100644 --- a/tests/unit/test_user_info_gates.py +++ b/tests/unit/test_user_info_gates.py @@ -1,17 +1,10 @@ - - from __future__ import annotations from uuid import UUID -from api.core.security import ( - TdeiProjectGroupRole, - UserInfo, - UserInfoPGMembership, -) +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership from api.src.users.schemas import WorkspaceUserRoleType - PG = "00000000-0000-0000-0000-000000000001" @@ -21,13 +14,17 @@ def _user(*, osm_roles=None, pg_roles=None, accessible=None): u.user_uuid = UUID("11111111-1111-1111-1111-111111111111") u.user_name = "test" u.osmWorkspaceRoles = osm_roles or {} - u.projectGroups = [ - UserInfoPGMembership( - project_group_name="PG", - project_group_id=PG, - tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], - ) - ] if pg_roles is not None or accessible is not None else [] + u.projectGroups = ( + [ + UserInfoPGMembership( + project_group_name="PG", + project_group_id=PG, + tdeiRoles=pg_roles or [TdeiProjectGroupRole.MEMBER], + ) + ] + if pg_roles is not None or accessible is not None + else [] + ) u.accessibleWorkspaceIds = accessible or {} return u From 90d1deff3146a827e745001987a996b529781ad2 Mon Sep 17 00:00:00 2001 From: MashB Date: Tue, 16 Jun 2026 20:31:28 +0530 Subject: [PATCH 066/159] ignore creating the extension via migration --- .../a1b2c3d4e5f6_tasking_mvp_schema.py | 46 +++++++++---------- .../c5121cbba124_initial_task_schema.py | 28 +++++------ 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py index d792f4c..068d28d 100644 --- a/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py +++ b/alembic_osm/versions/a1b2c3d4e5f6_tasking_mvp_schema.py @@ -47,12 +47,18 @@ def _drop_enum_if_present(bind, name: str) -> None: bind.execute(text(f'DROP TYPE IF EXISTS "{name}"')) -def _postgis_available(bind) -> bool: - return bool( +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( bind.execute( - text("SELECT 1 FROM pg_available_extensions WHERE name = 'postgis'") + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") ).scalar() ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) def upgrade() -> None: @@ -60,9 +66,7 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) - use_postgis = _postgis_available(bind) - if use_postgis: - op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + _assert_postgis_installed(bind) # ---- teams / team_user ------------------------------------------- # @@ -173,12 +177,11 @@ def upgrade() -> None: sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), ) - if use_postgis: - op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") - op.execute( - "ALTER TABLE tasking_projects " - "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" - ) + op.execute("ALTER TABLE tasking_projects DROP COLUMN aoi") + op.execute( + "ALTER TABLE tasking_projects " + "ADD COLUMN aoi GEOMETRY(MultiPolygon, 4326)" + ) # Unique project name per workspace among non-deleted rows. op.execute( @@ -273,17 +276,14 @@ def upgrade() -> None: "project_id", "task_number", name="tasking_tasks_pn_unique" ), ) - if use_postgis: - op.execute( - "ALTER TABLE tasking_tasks " - "ADD COLUMN geometry GEOMETRY(Polygon, 4326) NOT NULL" - ) - op.execute( - "CREATE INDEX tasking_tasks_geometry_idx " - "ON tasking_tasks USING GIST (geometry)" - ) - else: - op.execute("ALTER TABLE tasking_tasks " "ADD COLUMN geometry BYTEA") + op.execute( + "ALTER TABLE tasking_tasks " + "ADD COLUMN geometry GEOMETRY(Polygon, 4326) NOT NULL" + ) + op.execute( + "CREATE INDEX tasking_tasks_geometry_idx " + "ON tasking_tasks USING GIST (geometry)" + ) op.create_index("tasking_tasks_project_idx", "tasking_tasks", ["project_id"]) # ---- tasking_locks ------------------------------------------------ diff --git a/alembic_task/versions/c5121cbba124_initial_task_schema.py b/alembic_task/versions/c5121cbba124_initial_task_schema.py index a06f085..a112007 100644 --- a/alembic_task/versions/c5121cbba124_initial_task_schema.py +++ b/alembic_task/versions/c5121cbba124_initial_task_schema.py @@ -12,12 +12,18 @@ depends_on: Union[str, Sequence[str], None] = None -def _postgis_available(bind) -> bool: - return bool( +def _assert_postgis_installed(bind) -> None: + """Require the postgis extension to be installed in this database.""" + installed = bool( bind.execute( - text("SELECT 1 FROM pg_available_extensions WHERE name = 'postgis'") + text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'") ).scalar() ) + if not installed: + raise RuntimeError( + "postgis extension is not installed in this database. " + "Run `CREATE EXTENSION IF NOT EXISTS postgis;` before migrations." + ) def upgrade() -> None: @@ -25,18 +31,12 @@ def upgrade() -> None: assert bind is not None insp = inspect(bind) - use_postgis = _postgis_available(bind) - if use_postgis: - op.execute("CREATE EXTENSION IF NOT EXISTS postgis") + _assert_postgis_installed(bind) - geometry_column = ( - sa.Column( - "geometry", - Geometry(geometry_type="MULTIPOLYGON", srid=4326), - nullable=True, - ) - if use_postgis - else sa.Column("geometry", sa.Text(), nullable=True) + geometry_column = sa.Column( + "geometry", + Geometry(geometry_type="MULTIPOLYGON", srid=4326), + nullable=True, ) # The TASK tree owns `workspaces` and `workspaces_*` only. From 4be81bcdafed85f75fc60ad9fe0dbf3a4afca0bc Mon Sep 17 00:00:00 2001 From: Anuj Kumar Date: Wed, 17 Jun 2026 17:51:01 +0530 Subject: [PATCH 067/159] Tagging workflow included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## DevBoard - https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3739 ## Changes Added a new GitHub Actions workflow (`.github/workflows/tag.yml`) that automatically updates environment tags when pull requests are merged. **Workflow Details:** - **Trigger:** Pull request closure events on `develop`, `staging`, and `production` branches (only executes when PR is merged) - **Tag Mapping:** Maps base branch to environment tag: - `develop` → `dev` - `staging` → `stage` - `production` → `prod` - **Execution:** Force-updates the git tag locally and pushes it to the remote repository with the `--force` flag - **Permissions:** Requires `contents: write` to push tags The workflow enables automated environment tagging aligned with branch-based deployments, ensuring consistent tag versions across environments. --- .github/workflows/tag.yml | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/tag.yml diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..229dea4 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,42 @@ +# Whenever there is a pull request merged into the branches, create tag +name: Update Environment Tag +on: + pull_request: + types: [closed] + branches: + - develop + - staging + - production +jobs: + update-tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Get the current branch name + id: get_branch + run: | + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + + if [ "$TARGET_BRANCH" = "develop" ]; then + echo "ENV_TAG=dev" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "staging" ]; then + echo "ENV_TAG=stage" >> $GITHUB_ENV + elif [ "$TARGET_BRANCH" = "production" ]; then + echo "ENV_TAG=prod" >> $GITHUB_ENV + else + echo "ENV_TAG=" >> $GITHUB_ENV + fi + - name: Force update tag + if: ${{ env.ENV_TAG != '' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + echo "Targeting tag: ${{ env.ENV_TAG }}" + git tag -f $ENV_TAG + git push origin $ENV_TAG --force From bdef21abef5415a6b1569e2826ba9a4cfdd98ec7 Mon Sep 17 00:00:00 2001 From: Cy Rossignol Date: Thu, 18 Jun 2026 02:07:29 -0700 Subject: [PATCH 068/159] Add auto-flagging changesets for review --- .../b3f8a2c91e04_add_auto_flag_review.py | 35 +++++++++++++++++ api/main.py | 38 ++++++++++++++++++- api/src/osm/repository.py | 29 ++++++++++++++ api/src/osm/routes.py | 34 ++++++++++++++++- api/src/workspaces/routes.py | 2 - api/src/workspaces/schemas.py | 3 ++ 6 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py diff --git a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py new file mode 100644 index 0000000..e6095bb --- /dev/null +++ b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py @@ -0,0 +1,35 @@ +"""Add autoFlagReview column to workspaces table + +Revision ID: b3f8a2c91e04 +Revises: add6266277c7 +Create Date: 2026-03-05 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "b3f8a2c91e04" +down_revision: Union[str, None] = "add6266277c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("workspaces", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "autoFlagReview", + sa.Boolean(), + nullable=False, + server_default="false", + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("workspaces", schema=None) as batch_op: + batch_op.drop_column("autoFlagReview") diff --git a/api/main.py b/api/main.py index 381076a..9939839 100644 --- a/api/main.py +++ b/api/main.py @@ -2,6 +2,7 @@ import re import sys from contextlib import asynccontextmanager +from xml.etree import ElementTree as ET import httpx import sentry_sdk @@ -118,10 +119,14 @@ def get_workspace_repository( # h/t: https://stackoverflow.com/questions/70610266/proxy-an-external-website-using-python-fast-api-not-supporting-query-params # -# According to HTTP/1.1, a proxy must not forward these "hop-by-hop" headers: +# According to HTTP/1.1, a proxy must not forward these "hop-by-hop" headers. +# We also exclude Content-Length because httpx recomputes the length from the +# actual content bytes, and this value may differ from the original after the +# proxy rewrites body content (i.e. after changset tag injection). HOP_BY_HOP_HEADERS = frozenset( [ "connection", + "content-length", "keep-alive", "proxy-authenticate", "proxy-authorization", @@ -151,6 +156,9 @@ def get_workspace_repository( (re.compile(r"^/api/0\.6/user/[^/]+$"), {"PUT"}), ] +# Changeset create path — buffered for potential tag injection +_CHANGESET_CREATE_RE = re.compile(r"^/api/0\.6/changeset/create$") + @app.get("/api/capabilities.json") async def capabilities(request: Request): @@ -210,6 +218,7 @@ async def catch_all( Catch-all route to proxy requests to the OSM service. """ + workspace_id: int | None = None if request.headers.get("X-Workspace") is not None: try: workspace_id = int(request.headers.get("X-Workspace") or "-1") @@ -252,8 +261,33 @@ async def catch_all( (b"X-Forwarded-Proto", request.url.scheme.encode()), ] + # For changeset creation, inject review_requested tag for contributors: + request_content: object = request.stream() + if ( + workspace_id is not None + and request.method == "PUT" + and _CHANGESET_CREATE_RE.fullmatch(request.url.path) + ): + workspace = await repository.getById(current_user, workspace_id) + + if ( + workspace.autoFlagReview + and current_user.effective_role(workspace_id) == "contributor" + ): + logger.info("Injecting review request tag") + body = await request.body() + root = ET.fromstring(body) + changeset_el = root.find("changeset") + if changeset_el is not None: + ET.SubElement(changeset_el, "tag", k="review_requested", v="yes") + request_content = ET.tostring(root, encoding="unicode").encode("utf-8") + else: + # Body was not consumed; fall back to buffered bytes to avoid + # double-read issues after the workspace fetch above. + request_content = await request.body() + rp_req = client.build_request( - request.method, url, headers=req_headers, content=request.stream() + request.method, url, headers=req_headers, content=request_content ) try: rp_resp = await client.send(rp_req, stream=True) diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index f57fcdc..d0781de 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -49,3 +49,32 @@ async def getChangesetAdiff( ) return result.mappings().all() + + async def resolveChangeset( + self, + workspace_id: int, + changeset_id: int, + reviewer_uuid: str, + ) -> None: + await self.session.execute( + text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") + ) + + await self.session.execute( + text( + "DELETE FROM changeset_tags" + " WHERE changeset_id = :cs_id AND k = 'review_requested'" + ), + {"cs_id": changeset_id}, + ) + + await self.session.execute( + text( + "INSERT INTO changeset_tags (changeset_id, k, v)" + " VALUES (:cs_id, 'reviewed_by', :uid)" + " ON CONFLICT (changeset_id, k) DO UPDATE SET v = :uid" + ), + {"cs_id": changeset_id, "uid": reviewer_uuid}, + ) + + await self.session.commit() diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py index ca0ff2f..d2f4677 100644 --- a/api/src/osm/routes.py +++ b/api/src/osm/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlmodel.ext.asyncio.session import AsyncSession from api.core.database import get_osm_session, get_task_session @@ -47,3 +47,35 @@ async def get_changeset_adiff( f"in workspace {workspace_id}: {str(e)}" ) raise + + +@router.put( + "/{workspace_id}/changesets/{changeset_id}/resolve", + status_code=status.HTTP_204_NO_CONTENT, +) +async def resolve_changeset( + workspace_id: int, + changeset_id: int, + repository_ws: WorkspaceRepository = Depends(get_workspace_repo), + repository_osm: OSMRepository = Depends(get_osm_repo), + current_user: UserInfo = Depends(validate_token), +) -> None: + if (not current_user.isWorkspaceLead(workspace_id) + and not current_user.isWorkspaceValidator(workspace_id)): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only workspace leads and validators can resolve changesets", + ) + + await repository_ws.getById(current_user, workspace_id) + + try: + await repository_osm.resolveChangeset( + workspace_id, changeset_id, str(current_user.user_uuid) + ) + except Exception as e: + logger.error( + f"Failed to resolve changeset {changeset_id}" + f" in workspace {workspace_id}: {str(e)}" + ) + raise diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index 405313d..67267a2 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,5 +1,4 @@ import json -from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Response, status @@ -22,7 +21,6 @@ QuestDefinitionTypeName, QuestSettingsPatch, QuestSettingsResponse, - Workspace, WorkspaceCreate, WorkspaceImagery, WorkspacePatch, diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 766f032..882cd95 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -138,6 +138,7 @@ class WorkspacePatch(SQLModel): title: Optional[str] = None description: Optional[str] = None externalAppAccess: Optional[ExternalAppsDefinitionType] = None + autoFlagReview: Optional[bool] = None class QuestSettingsPatch(SQLModel): @@ -209,6 +210,7 @@ class WorkspaceResponse(SQLModel): createdByName: str externalAppAccess: ExternalAppsDefinitionType kartaViewToken: Optional[str] = None + autoFlagReview: bool = False role: str # Included in single-workspace GET for mobile app consumption. TODO: remove # this when the app fetches these from dedicated endpoints: @@ -238,6 +240,7 @@ def from_workspace( createdByName=workspace.createdByName, externalAppAccess=workspace.externalAppAccess, kartaViewToken=workspace.kartaViewToken, + autoFlagReview=workspace.autoFlagReview, role=user.effective_role(workspace.id), imageryListDef=imagery_list_def, longFormQuestDef=long_form_quest_def, From 2c8af4856a72b824e36b8f7a29b2b5b98a0ba84d Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:27:44 +0530 Subject: [PATCH 069/159] Refactor audit event handling and update event types for tasking and project actions --- api/src/tasking/audit/dtos.py | 2 - api/src/tasking/audit/repository.py | 3 - api/src/tasking/audit/schemas.py | 2 +- api/src/tasking/projects/repository.py | 82 +++++++++++++++++++++++--- api/src/tasking/projects/routes.py | 14 ++--- api/src/tasking/tasks/repository.py | 41 +++++++------ 6 files changed, 103 insertions(+), 41 deletions(-) diff --git a/api/src/tasking/audit/dtos.py b/api/src/tasking/audit/dtos.py index b40f0f3..c8003de 100644 --- a/api/src/tasking/audit/dtos.py +++ b/api/src/tasking/audit/dtos.py @@ -21,8 +21,6 @@ class AuditEvent(WireModel): id: int event_type: AuditEventType project_id: int - task_id: Optional[int] = None - task_number: Optional[int] = None actor: ActorRef occurred_at: datetime details: dict[str, Any] diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py index 4f589b4..0234f0f 100644 --- a/api/src/tasking/audit/repository.py +++ b/api/src/tasking/audit/repository.py @@ -108,13 +108,10 @@ def _row_to_event( project_deleted, ) = row details = details or {} - task_number = details.get("task_number") if isinstance(details, dict) else None return AuditEvent( id=event_id, event_type=AuditEventType(event_type), project_id=project_id, - task_id=task_id, - task_number=task_number, actor=ActorRef( user_id=UUID(str(actor_uid)), display_name=names.get(str(actor_uid)), diff --git a/api/src/tasking/audit/schemas.py b/api/src/tasking/audit/schemas.py index b24c405..9dc27be 100644 --- a/api/src/tasking/audit/schemas.py +++ b/api/src/tasking/audit/schemas.py @@ -25,7 +25,7 @@ class AuditEventType(StrEnum): PROJECT_RESET = "project_reset" AOI_UPLOADED = "aoi_uploaded" AOI_DELETED = "aoi_deleted" - TASK_CREATED = "task_created" + TASKS_CREATED = "tasks_created" TASK_STATE_CHANGED = "task_state_changed" TASK_LOCKED = "task_locked" TASK_LOCK_EXTENDED = "task_lock_extended" diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 087a978..b23ea3b 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime from typing import Any from uuid import UUID @@ -9,7 +10,7 @@ from shapely.geometry import MultiPolygon as ShapelyMultiPolygon from shapely.geometry import Polygon as ShapelyPolygon from shapely.geometry import shape as shapely_shape -from sqlalchemy import func, or_, select, update +from sqlalchemy import func, text, or_, select, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -34,6 +35,7 @@ SelfProjectRoleItem, SelfProjectRolesResponse, ) +from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.schemas import ( AoiInput, ProjectRoleType, @@ -181,6 +183,28 @@ class TaskingProjectRepository: def __init__(self, session: AsyncSession): self.session = session + async def _audit( + self, + *, + event_type: AuditEventType, + project_id: int, + actor_uuid: UUID, + details: dict[str, Any] | None = None, + ) -> None: + await self.session.execute( + text( + "INSERT INTO tasking_audit_events " + "(event_type, project_id, task_id, actor_user_auth_uid, details) " + "VALUES (:et, :pid, NULL, :uid, CAST(:dt AS jsonb))" + ), + { + "et": event_type, + "pid": project_id, + "uid": str(actor_uuid), + "dt": json.dumps(details or {}), + }, + ) + # ---- internal helpers -------------------------------------------- async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProject: @@ -511,6 +535,12 @@ async def create( }, ) + await self._audit( + event_type=AuditEventType.PROJECT_CREATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={"name": project.name}, + ) await self.session.commit() await self.session.refresh(project) except IntegrityError as e: @@ -529,6 +559,7 @@ async def patch( workspace_id: int, project_id: int, body: ProjectUpdateRequest, + current_user: UserInfo, ) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) @@ -567,6 +598,12 @@ async def patch( .where(TaskingProject.id == project.id) .values(**updates) ) + await self._audit( + event_type=AuditEventType.PROJECT_EDITED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + details={"updatedFields": [k for k in updates if k != "updated_at"]}, + ) await self.session.commit() except IntegrityError as e: await self.session.rollback() @@ -576,7 +613,7 @@ async def patch( tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def soft_delete(self, workspace_id: int, project_id: int) -> None: + async def soft_delete(self, workspace_id: int, project_id: int, current_user: UserInfo) -> None: project = await self._get_active(workspace_id, project_id) # Refuse if any active task locks remain. @@ -605,6 +642,12 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: text("DELETE FROM tasking_tasks WHERE project_id = :pid"), {"pid": project.id}, ) + # Insert the deletion event before flagging so this row is caught too. + await self._audit( + event_type=AuditEventType.PROJECT_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) await self.session.execute( text( "UPDATE tasking_audit_events SET project_deleted = TRUE " @@ -616,7 +659,7 @@ async def soft_delete(self, workspace_id: int, project_id: int) -> None: # ---- lifecycle transitions --------------------------------------- - async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: + async def activate(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -663,11 +706,16 @@ async def activate(self, workspace_id: int, project_id: int) -> ProjectResponse: .where(TaskingProject.id == project.id) .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) ) + await self._audit( + event_type=AuditEventType.PROJECT_ACTIVATED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) await self.session.commit() await self.session.refresh(project) return self._to_response(project, task_count=tc) - async def close(self, workspace_id: int, project_id: int) -> ProjectResponse: + async def close(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.OPEN: raise HTTPException( @@ -707,12 +755,17 @@ async def close(self, workspace_id: int, project_id: int) -> ProjectResponse: .where(TaskingProject.id == project.id) .values(status=ProjectStatus.DONE, updated_at=datetime.now()) ) + await self._audit( + event_type=AuditEventType.PROJECT_CLOSED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) await self.session.commit() await self.session.refresh(project) tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def reset(self, workspace_id: int, project_id: int) -> ProjectResponse: + async def reset(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: """LEAD reset — see spec §projects.""" project = await self._get_active(workspace_id, project_id) if project.status == ProjectStatus.DRAFT: @@ -749,6 +802,11 @@ async def reset(self, workspace_id: int, project_id: int) -> ProjectResponse: .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) ) + await self._audit( + event_type=AuditEventType.PROJECT_RESET, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) await self.session.commit() await self.session.refresh(project) tc = await self._task_count(project.id) # type: ignore[arg-type] @@ -766,7 +824,7 @@ async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: return _shapely_to_aoi_feature(geom) async def upload_aoi( - self, workspace_id: int, project_id: int, aoi: AoiInput + self, workspace_id: int, project_id: int, aoi: AoiInput, current_user: UserInfo ) -> AoiFeature: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: @@ -793,6 +851,11 @@ async def upload_aoi( updated_at=datetime.now(), ) ) + await self._audit( + event_type=AuditEventType.AOI_UPLOADED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) await self.session.commit() return _shapely_to_aoi_feature(geom) @@ -1212,7 +1275,7 @@ async def _get_role_or_none( updated_at=updated, ) - async def delete_aoi(self, workspace_id: int, project_id: int) -> None: + async def delete_aoi(self, workspace_id: int, project_id: int, current_user: UserInfo) -> None: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -1237,6 +1300,11 @@ async def delete_aoi(self, workspace_id: int, project_id: int) -> None: updated_at=datetime.now(), ) ) + await self._audit( + event_type=AuditEventType.AOI_DELETED, + project_id=project.id, # type: ignore[arg-type] + actor_uuid=current_user.user_uuid, + ) await self.session.commit() diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index e7367f1..7389246 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -151,7 +151,7 @@ async def update_project( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await project_repo.patch(workspace_id, project_id, body) + return await project_repo.patch(workspace_id, project_id, body, current_user) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -164,7 +164,7 @@ async def delete_project( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - await project_repo.soft_delete(workspace_id, project_id) + await project_repo.soft_delete(workspace_id, project_id, current_user) @router.post("/{project_id}/activate", response_model=ProjectResponse) @@ -177,7 +177,7 @@ async def activate_project( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await project_repo.activate(workspace_id, project_id) + return await project_repo.activate(workspace_id, project_id, current_user) @router.post("/{project_id}/close", response_model=ProjectResponse) @@ -190,7 +190,7 @@ async def close_project( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await project_repo.close(workspace_id, project_id) + return await project_repo.close(workspace_id, project_id, current_user) @router.post("/{project_id}/reset", response_model=ProjectResponse) @@ -203,7 +203,7 @@ async def reset_project( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await project_repo.reset(workspace_id, project_id) + return await project_repo.reset(workspace_id, project_id, current_user) # --------------------------------------------------------------------------- @@ -234,7 +234,7 @@ async def upload_project_aoi( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - return await project_repo.upload_aoi(workspace_id, project_id, body) + return await project_repo.upload_aoi(workspace_id, project_id, body, current_user) # --------------------------------------------------------------------------- @@ -365,7 +365,7 @@ async def delete_project_aoi( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) assert_workspace_lead(workspace_id, current_user) - await project_repo.delete_aoi(workspace_id, project_id) + await project_repo.delete_aoi(workspace_id, project_id, current_user) # --------------------------------------------------------------------------- diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 0e785be..3c6219a 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -23,6 +23,7 @@ NotFoundException, ) from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.dtos import Pagination from api.src.tasking.projects.schemas import ProjectStatus, TaskingProject from api.src.tasking.tasks.dtos import ( @@ -281,7 +282,7 @@ async def _project_role( async def _audit( self, *, - event_type: str, + event_type: AuditEventType, project_id: int, task_id: Optional[int], actor_uuid: UUID, @@ -547,15 +548,13 @@ async def save( ) ) - # Audit one row per task. - for t in created: - await self._audit( - event_type="task_created", - project_id=project.id, # type: ignore[arg-type] - task_id=t.id, - actor_uuid=current_user.user_uuid, - details={"taskNumber": t.task_number}, - ) + await self._audit( + event_type=AuditEventType.TASKS_CREATED, + project_id=project.id, # type: ignore[arg-type] + task_id=None, + actor_uuid=current_user.user_uuid, + details={"taskCount": len(created), "source": body.source}, + ) task_responses = [await self._to_task_response(t) for t in created] response = SaveTasksResponse( @@ -729,7 +728,7 @@ async def lock_task( self.session.add(lock) await self.session.flush() await self._audit( - event_type="task_locked", + event_type=AuditEventType.TASK_LOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -777,7 +776,7 @@ async def unlock_task( .values(released_at=now, release_reason=release_reason) ) await self._audit( - event_type="task_unlocked", + event_type=AuditEventType.TASK_UNLOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -820,7 +819,7 @@ async def extend_lock( .values(expires_at=new_expiry) ) await self._audit( - event_type="task_lock_extended", + event_type=AuditEventType.TASK_LOCK_EXTENDED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -861,7 +860,7 @@ async def reset_task( ) ) await self._audit( - event_type="task_unlocked", + event_type=AuditEventType.TASK_UNLOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -883,7 +882,7 @@ async def reset_task( ) ) await self._audit( - event_type="task_state_changed", + event_type=AuditEventType.TASK_STATE_CHANGED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -902,7 +901,7 @@ async def reset_task( ) await self._audit( - event_type="task_reset", + event_type=AuditEventType.TASK_RESET, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -963,7 +962,7 @@ async def submit( await self.session.flush() await self._audit( - event_type="changeset_submitted", + event_type=AuditEventType.CHANGESET_SUBMITTED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -983,7 +982,7 @@ async def submit( .values(expires_at=new_expiry) ) await self._audit( - event_type="task_lock_renewed", + event_type=AuditEventType.TASK_LOCK_RENEWED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -1029,7 +1028,7 @@ async def submit( ) ) await self._audit( - event_type="feedback_submitted", + event_type=AuditEventType.FEEDBACK_SUBMITTED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -1052,7 +1051,7 @@ async def submit( ) ) await self._audit( - event_type="task_unlocked", + event_type=AuditEventType.TASK_UNLOCKED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, @@ -1074,7 +1073,7 @@ async def submit( ) if new_status != previous_status: await self._audit( - event_type="task_state_changed", + event_type=AuditEventType.TASK_STATE_CHANGED, project_id=project_id, task_id=task.id, actor_uuid=current_user.user_uuid, From 2c3d6a4d8536c8636d719126ffc3337e481a58bc Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:19:16 +0530 Subject: [PATCH 070/159] Refactor function signatures and import order in TaskingProjectRepository --- api/src/tasking/projects/repository.py | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index b23ea3b..eaa81eb 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -10,7 +10,7 @@ from shapely.geometry import MultiPolygon as ShapelyMultiPolygon from shapely.geometry import Polygon as ShapelyPolygon from shapely.geometry import shape as shapely_shape -from sqlalchemy import func, text, or_, select, update +from sqlalchemy import func, or_, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -20,6 +20,7 @@ NotFoundException, ) from api.core.security import UserInfo +from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.dtos import ( AoiFeature, Pagination, @@ -35,7 +36,6 @@ SelfProjectRoleItem, SelfProjectRolesResponse, ) -from api.src.tasking.audit.schemas import AuditEventType from api.src.tasking.projects.schemas import ( AoiInput, ProjectRoleType, @@ -602,7 +602,9 @@ async def patch( event_type=AuditEventType.PROJECT_EDITED, project_id=project.id, # type: ignore[arg-type] actor_uuid=current_user.user_uuid, - details={"updatedFields": [k for k in updates if k != "updated_at"]}, + details={ + "updatedFields": [k for k in updates if k != "updated_at"] + }, ) await self.session.commit() except IntegrityError as e: @@ -613,7 +615,9 @@ async def patch( tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def soft_delete(self, workspace_id: int, project_id: int, current_user: UserInfo) -> None: + async def soft_delete( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: project = await self._get_active(workspace_id, project_id) # Refuse if any active task locks remain. @@ -659,7 +663,9 @@ async def soft_delete(self, workspace_id: int, project_id: int, current_user: Us # ---- lifecycle transitions --------------------------------------- - async def activate(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: + async def activate( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( @@ -715,7 +721,9 @@ async def activate(self, workspace_id: int, project_id: int, current_user: UserI await self.session.refresh(project) return self._to_response(project, task_count=tc) - async def close(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: + async def close( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.OPEN: raise HTTPException( @@ -765,7 +773,9 @@ async def close(self, workspace_id: int, project_id: int, current_user: UserInfo tc = await self._task_count(project.id) # type: ignore[arg-type] return self._to_response(project, task_count=tc) - async def reset(self, workspace_id: int, project_id: int, current_user: UserInfo) -> ProjectResponse: + async def reset( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> ProjectResponse: """LEAD reset — see spec §projects.""" project = await self._get_active(workspace_id, project_id) if project.status == ProjectStatus.DRAFT: @@ -1275,7 +1285,9 @@ async def _get_role_or_none( updated_at=updated, ) - async def delete_aoi(self, workspace_id: int, project_id: int, current_user: UserInfo) -> None: + async def delete_aoi( + self, workspace_id: int, project_id: int, current_user: UserInfo + ) -> None: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( From b09eea1cc5e7c1fb67e40f4e0c091f1d59d391f3 Mon Sep 17 00:00:00 2001 From: Rajesh Kantipudi <44539669+iamrajeshk@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:28:33 +0530 Subject: [PATCH 071/159] Add current_user parameter to patch, soft_delete, activate, close, reset, upload_aoi, and delete_aoi methods in FakeProjectRepo --- tests/unit/conftest.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 56ab9ce..20ac3f5 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -175,7 +175,7 @@ async def get(self, workspace_id: int, project_id: int): raise NotFoundException(f"Project {project_id} not found") return self._response(p) - async def patch(self, workspace_id, project_id, body): + async def patch(self, workspace_id, project_id, body, current_user): p_resp = await self.get(workspace_id, project_id) p = self._projects[project_id] if body.name is not None: @@ -189,11 +189,11 @@ async def patch(self, workspace_id, project_id, body): p["updated_at"] = datetime.now() return self._response(p) - async def soft_delete(self, workspace_id, project_id): + async def soft_delete(self, workspace_id, project_id, current_user): await self.get(workspace_id, project_id) self._projects[project_id]["deleted_at"] = datetime.now() - async def activate(self, workspace_id, project_id): + async def activate(self, workspace_id, project_id, current_user): from fastapi import HTTPException, status # Mirrors the real repo's "needs tasks" rule. @@ -202,7 +202,7 @@ async def activate(self, workspace_id, project_id): detail="Project must have at least one task", ) - async def close(self, workspace_id, project_id): + async def close(self, workspace_id, project_id, current_user): from fastapi import HTTPException, status raise HTTPException( @@ -210,7 +210,7 @@ async def close(self, workspace_id, project_id): detail="Only open projects can be closed", ) - async def reset(self, workspace_id, project_id): + async def reset(self, workspace_id, project_id, current_user): await self.get(workspace_id, project_id) return self._response(self._projects[project_id]) @@ -233,7 +233,7 @@ async def get_aoi(self, workspace_id, project_id): properties={}, ) - async def upload_aoi(self, workspace_id, project_id, aoi): + async def upload_aoi(self, workspace_id, project_id, aoi, current_user): from api.src.tasking.projects.dtos import AoiFeature from api.src.tasking.projects.schemas import _MultiPolygon @@ -248,7 +248,7 @@ async def upload_aoi(self, workspace_id, project_id, aoi): properties={}, ) - async def delete_aoi(self, workspace_id, project_id): + async def delete_aoi(self, workspace_id, project_id, current_user): from api.core.exceptions import NotFoundException await self.get(workspace_id, project_id) From 14994f864170216ff06fea05ec0128b1b7cf4628 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 24 Jun 2026 15:58:41 +0530 Subject: [PATCH 072/159] Geojson file for fetching the task data Task data fetching URL --- api/src/tasking/tasks/repository.py | 24 ++++++++++++++++++++++++ api/src/tasking/tasks/routes.py | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 3c6219a..28b70af 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -645,6 +645,30 @@ async def get_task( await self._get_project(workspace_id, project_id) task = await self._get_task(project_id, task_number) return await self._to_task_response(task) + + async def get_task_geojson( + self, workspace_id: int, project_id: int, task_number: int + ) -> TaskBoundariesFeatureCollection: + await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + geom_shape = to_shape(task.geometry) if task.geometry is not None else None + if geom_shape is None or not isinstance(geom_shape, ShapelyPolygon): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Task geometry missing or non-Polygon", + ) + task_geometry = _shapely_polygon_to_geojson(geom_shape) + task_feature = TaskBoundaryFeature( + type="Feature", + geometry=task_geometry, + properties={"taskNumber": task.task_number}, + ) + task_feature_collection = TaskBoundariesFeatureCollection( + type="FeatureCollection", + features=[task_feature], + ) + return task_feature_collection + # ---- lock / unlock / extend / reset ---------------------------------- diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 24bf14e..f38f7ca 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -166,6 +166,14 @@ async def get_task( await assert_workspace_visible(workspace_id, current_user, workspace_repo) return await task_repo.get_task(workspace_id, project_id, task_number) +@router.get("/tasks/{task_number}.geojson", response_model=TaskBoundariesFeatureCollection) +async def get_task_geojson( + workspace_id: int, + project_id: int, + task_number: int, + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + return await task_repo.get_task_geojson(workspace_id, project_id, task_number) # --------------------------------------------------------------------------- # Locks — acquire / release / extend / reset From de3189160c0c3ff41bb2ed419cf09ef2597e17d3 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 24 Jun 2026 20:25:26 +0530 Subject: [PATCH 073/159] Update routes.py --- api/src/tasking/tasks/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index f38f7ca..20c02c7 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -166,7 +166,7 @@ async def get_task( await assert_workspace_visible(workspace_id, current_user, workspace_repo) return await task_repo.get_task(workspace_id, project_id, task_number) -@router.get("/tasks/{task_number}.geojson", response_model=TaskBoundariesFeatureCollection) +@router.get("/tasks/{task_number}/boundary.geojson", response_model=TaskBoundariesFeatureCollection) async def get_task_geojson( workspace_id: int, project_id: int, From 7abb5a0fab4043f6572dc5fc516fc39a095cf1f0 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 24 Jun 2026 20:31:21 +0530 Subject: [PATCH 074/159] Linting issues fixed --- api/src/tasking/tasks/repository.py | 3 +-- api/src/tasking/tasks/routes.py | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 28b70af..60d8ddc 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -645,7 +645,7 @@ async def get_task( await self._get_project(workspace_id, project_id) task = await self._get_task(project_id, task_number) return await self._to_task_response(task) - + async def get_task_geojson( self, workspace_id: int, project_id: int, task_number: int ) -> TaskBoundariesFeatureCollection: @@ -668,7 +668,6 @@ async def get_task_geojson( features=[task_feature], ) return task_feature_collection - # ---- lock / unlock / extend / reset ---------------------------------- diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index 20c02c7..bef56ac 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -166,7 +166,11 @@ async def get_task( await assert_workspace_visible(workspace_id, current_user, workspace_repo) return await task_repo.get_task(workspace_id, project_id, task_number) -@router.get("/tasks/{task_number}/boundary.geojson", response_model=TaskBoundariesFeatureCollection) + +@router.get( + "/tasks/{task_number}/boundary.geojson", + response_model=TaskBoundariesFeatureCollection, +) async def get_task_geojson( workspace_id: int, project_id: int, @@ -175,6 +179,7 @@ async def get_task_geojson( ): return await task_repo.get_task_geojson(workspace_id, project_id, task_number) + # --------------------------------------------------------------------------- # Locks — acquire / release / extend / reset # --------------------------------------------------------------------------- From 2cb1b8e0f2110089fda13af4e001d51060bd16e7 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 13:52:12 -0400 Subject: [PATCH 075/159] First pass --- CLAUDE.md | 32 ++++ pyproject.toml | 1 + tests/README.md | 94 ++++++++++++ tests/conftest.py | 69 +++++++++ tests/integration/__init__.py | 0 tests/integration/test_health.py | 13 ++ tests/integration/test_proxy.py | 68 +++++++++ tests/integration/test_teams.py | 78 ++++++++++ tests/integration/test_workspaces.py | 130 +++++++++++++++++ tests/support/__init__.py | 4 + tests/support/factories.py | 106 ++++++++++++++ tests/support/fakes.py | 185 ++++++++++++++++++++++++ tests/support/http.py | 46 ++++++ tests/test_main.py | 11 -- tests/unit/__init__.py | 0 tests/unit/test_user_info.py | 54 +++++++ tests/unit/test_workspace_repository.py | 94 ++++++++++++ 17 files changed, 974 insertions(+), 11 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_health.py create mode 100644 tests/integration/test_proxy.py create mode 100644 tests/integration/test_teams.py create mode 100644 tests/integration/test_workspaces.py create mode 100644 tests/support/__init__.py create mode 100644 tests/support/factories.py create mode 100644 tests/support/fakes.py create mode 100644 tests/support/http.py delete mode 100644 tests/test_main.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_user_info.py create mode 100644 tests/unit/test_workspace_repository.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c2d7f3a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md + +Guidance for working in this repo. Focused on the test infrastructure and +conventions established for it; see `README.md` for app setup. + +## Permission Structure + +Project Group Admin ("POC") +* Superuser for the whole project group +* Implied by "poc" role in TDEI + +Lead/Owner/Workspace Admin +* Admin-level access for a workspace +* Configures workspace settings and quest definitions +* Assigns users to workspace teams +* Ability to merge changes from other workspace +* Exports data to TDEI (with appropriate TDEI core roles) +* Granted by Workspaces setting. + +Contributor/Data Generator +* Modifies workspace data--all modifications need validation +* Implied by membership in TDEI project group + +Validator +* Modifies workspace data and approves changes from contributors +* Granted by Workspaces setting. + +Viewer/Member/Everyone Else +* Read-only access to workspace data +* With express TDEI sign-up, the need for this access level diminishes greatly +* Granted by Workspaces setting. + diff --git a/pyproject.toml b/pyproject.toml index b4e4b1f..910a8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ addopts = "-v --cov=api --cov-report=term-missing" testpaths = ["tests"] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.black] line-length = 88 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cd90ce5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,94 @@ +# Tests + +Two layers of tests, both fast and dependency-free (no Postgres, PostGIS, +Docker, or network): + +| Layer | Location | What it exercises | +|-------|----------|-------------------| +| **Unit** | `tests/unit/` | Pure logic and individual classes (e.g. `UserInfo` permission rules, a repository in isolation). | +| **Integration** | `tests/integration/` | Real HTTP requests through the real FastAPI app: routing, auth wiring, repositories, schemas, and serialization. | + +## The mocking boundary: the "data fetcher", not the repository + +Integration tests run the **real** routes and repositories. The only things +swapped out are: + +1. **The database sessions** (`get_task_session`, `get_osm_session`) — replaced + with a `FakeSession` that returns pre-programmed rows instead of running SQL. + This is the "data fetcher" boundary: everything *above* the `AsyncSession` + (repositories, models, routes, Pydantic serialization) runs for real. +2. **`validate_token`** — replaced with a real `UserInfo` built by the factories, + skipping JWT decoding and the TDEI network call. The permission logic + (`isWorkspaceLead`, `isWorkspaceContributor`, …) is still real. +3. **The upstream OSM client** (`api.main._osm_client`, proxy tests only) — + replaced with a streamable mock transport. + +Mocking at the session level (rather than mocking whole repositories) means +the SQLModel object construction, repository logic, route guards, and response +serialization are all genuinely tested. + +## Writing an integration test + +```python +async def test_get_workspace_by_id(client, login, task_session): + login() # authenticate + task_session.queue(fakes.rows(factories.make_workspace(id=7))) + response = await client.get("/api/v1/workspaces/7") + assert response.status_code == 200 +``` + +### Fixtures (`conftest.py`) + +- `client` — async HTTP client bound to the app over an in-process ASGI transport. +- `login(user_info=None)` — set the authenticated user. Call with a + `factories.make_user_info(...)` to control roles/permissions. +- `task_session` / `osm_session` — the two `FakeSession`s. Queue results on them. + +### The call-order contract + +Because the mock is at the session level, **queue results in the order the +repository issues queries**. Each result builder models one DB round-trip: + +| Builder | Models | Returned by | +|---------|--------|-------------| +| `fakes.rows(*entities)` | a SELECT | `scalars().all()`, `scalar_one_or_none()`, `all()` | +| `fakes.empty()` | a SELECT with no rows | drives NotFound paths | +| `fakes.affected(n)` | an UPDATE/DELETE | `result.rowcount` | +| `fakes.mappings(*dicts)` | a raw-SQL result | `result.mappings()` | +| `fakes.scalar(value)` | `session.scalar(...)` | e.g. an `EXISTS` check | + +Routes that touch both DBs (e.g. teams, workspace create/delete) queue results +on **both** `task_session` and `osm_session`. `SET search_path` statements are +recognized and do not consume a queued result. `commit`/`add`/`rollback` calls +are recorded on the session (`session.commits`, `session.added`, …) for assertions. + +The repository docstrings and the existing tests show the query sequence for +each route. + +## Running + +```bash +uv run pytest # full suite with coverage (see pyproject.toml) +uv run pytest --no-cov -q # quick, no coverage +uv run pytest tests/unit # one layer +uv run pytest -k workspaces # by keyword +``` + +## Layout + +``` +tests/ + conftest.py # fixtures: client, login, task_session, osm_session + support/ + fakes.py # FakeSession + FakeResult + result builders + factories.py # make_user_info / make_workspace / make_user / make_team + http.py # streamable mock transport for the OSM proxy + unit/ + test_user_info.py + test_workspace_repository.py + integration/ + test_health.py + test_workspaces.py + test_teams.py + test_proxy.py +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2fef3b5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,69 @@ +"""Shared pytest fixtures. + +Integration tests drive the *real* FastAPI app through an ASGI HTTP client, +overriding only three dependencies: + +* ``get_task_session`` / ``get_osm_session`` -> :class:`FakeSession` (the + "data fetcher" boundary; queue simulated rows per test). +* ``validate_token`` -> a real ``UserInfo`` built by the factories (skips + JWT decoding and the TDEI network call, but the permission logic is real). + +Everything above that boundary -- routes, repositories, schemas, Pydantic +serialization -- runs unmodified. +""" + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from api.core.database import get_osm_session, get_task_session +from api.core.security import validate_token +from api.main import app as fastapi_app +from tests.support import factories +from tests.support.fakes import FakeSession + + +@pytest.fixture +def task_session() -> FakeSession: + """Fake session backing the tasking-manager / workspaces DB.""" + return FakeSession() + + +@pytest.fixture +def osm_session() -> FakeSession: + """Fake session backing the OSM DB.""" + return FakeSession() + + +@pytest.fixture +def app(task_session, osm_session): + """The real app with its DB sessions overridden by fakes.""" + fastapi_app.dependency_overrides[get_task_session] = lambda: task_session + fastapi_app.dependency_overrides[get_osm_session] = lambda: osm_session + yield fastapi_app + fastapi_app.dependency_overrides.clear() + + +@pytest.fixture +def login(app): + """Authenticate the request as a given user. + + Call with a ``UserInfo`` (see ``factories.make_user_info``) to set the + authenticated principal; called with no args it logs in a default user. + Returns the ``UserInfo`` in effect. + """ + + def _login(user_info=None): + user_info = user_info or factories.make_user_info() + app.dependency_overrides[validate_token] = lambda: user_info + return user_info + + return _login + + +@pytest_asyncio.fixture +async def client(app): + """Async HTTP client bound to the app over an in-process ASGI transport.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py new file mode 100644 index 0000000..f79475e --- /dev/null +++ b/tests/integration/test_health.py @@ -0,0 +1,13 @@ +"""Integration smoke tests for unauthenticated endpoints.""" + + +async def test_health_check(client): + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +async def test_root_redirects_to_docs(client): + response = await client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/docs" diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py new file mode 100644 index 0000000..aab0ffd --- /dev/null +++ b/tests/integration/test_proxy.py @@ -0,0 +1,68 @@ +"""Integration tests for the OSM proxy (catch-all) routes. + +The proxy forwards to an upstream OSM service via a module-level +``httpx.AsyncClient`` and re-streams the response. We swap that client for +one backed by a streamable mock transport so the proxy path is exercised +end-to-end without a real upstream. +""" + +import pytest + +import api.main +from tests.support import factories +from tests.support.http import osm_mock_client + + +@pytest.fixture +def mock_osm(monkeypatch): + """Replace the upstream OSM client with a recording mock transport.""" + client, transport = osm_mock_client() + monkeypatch.setattr(api.main, "_osm_client", client) + return transport + + +async def test_capabilities_proxies_without_auth(client, mock_osm): + # No X-Workspace header and no bearer token required for capabilities. + response = await client.get("/api/capabilities.json") + + assert response.status_code == 200 + assert b"osm version" in response.content + assert mock_osm.last_request.url.path == "/api/capabilities.json" + + +async def test_proxy_requires_workspace_header(client, login, mock_osm): + login(factories.make_user_info()) + # A non-whitelisted path with no X-Workspace header is rejected. + response = await client.get("/api/0.6/map") + + assert response.status_code == 400 + + +async def test_proxy_forbidden_without_workspace_access(client, login, mock_osm): + # User has no access to workspace 1. + login(factories.make_user_info(accessible_workspace_ids={})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 403 + + +async def test_proxy_forwards_for_workspace_contributor(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 200 + assert b"osm version" in response.content + # Spoofable forwarding headers are normalized before reaching upstream. + forwarded = mock_osm.last_request.headers + assert "x-forwarded-for" in forwarded # set by the proxy itself + assert forwarded["host"] == "osm-web" + + +async def test_proxy_rejects_non_integer_workspace_header(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "abc"}) + + assert response.status_code == 400 diff --git a/tests/integration/test_teams.py b/tests/integration/test_teams.py new file mode 100644 index 0000000..fdd92a0 --- /dev/null +++ b/tests/integration/test_teams.py @@ -0,0 +1,78 @@ +"""Integration tests for the /workspaces/{id}/teams routes. + +Team routes touch BOTH databases: the workspace access guard hits the task +DB (``workspace_repo.getById``) and the team operations hit the OSM DB. +Queue results on the matching fake session in call order. +""" + +from tests.support import factories, fakes + + +def teams_url(workspace_id: int = 1) -> str: + return f"/api/v1/workspaces/{workspace_id}/teams" + + +async def test_list_teams(client, login, task_session, osm_session): + login() + # getById (task DB) guards access... + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + # ...then team_repo.get_all (OSM DB) returns teams with members. + osm_session.queue( + fakes.rows( + factories.make_team(id=1, name="Alpha", users=[factories.make_user()]), + factories.make_team(id=2, name="Beta", users=[]), + ) + ) + + response = await client.get(teams_url()) + + assert response.status_code == 200 + body = response.json() + assert body[0] == {"id": 1, "name": "Alpha", "member_count": 1} + assert body[1]["member_count"] == 0 + + +async def test_create_team_requires_lead(client, login, task_session, osm_session): + login() # plain contributor + response = await client.post(teams_url(), json={"name": "New Team"}) + assert response.status_code == 403 + + +async def test_create_team_as_lead(client, login, task_session, osm_session): + from api.src.users.schemas import WorkspaceUserRoleType + + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + # lead check -> getById (task) guard -> team_repo.create (OSM): add+commit+refresh + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + + response = await client.post(teams_url(), json={"name": "New Team"}) + + assert response.status_code == 201 + assert isinstance(response.json(), int) + assert osm_session.commits == 1 + + +async def test_get_team_not_in_workspace_returns_404( + client, login, task_session, osm_session +): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + # assert_team_in_workspace -> session.scalar(EXISTS) returns False + osm_session.queue(fakes.scalar(False)) + + response = await client.get(f"{teams_url()}/999") + + assert response.status_code == 404 + + +async def test_list_teams_for_inaccessible_workspace_is_404( + client, login, task_session +): + login() + task_session.queue(fakes.empty()) # getById guard finds no workspace + + response = await client.get(teams_url(42)) + + assert response.status_code == 404 diff --git a/tests/integration/test_workspaces.py b/tests/integration/test_workspaces.py new file mode 100644 index 0000000..663e12a --- /dev/null +++ b/tests/integration/test_workspaces.py @@ -0,0 +1,130 @@ +"""Integration tests for the /workspaces routes. + +Each test drives a real HTTP request through the real route + repository +code, queueing simulated rows on the fake DB sessions. Comments note the +query sequence each route issues (= the order to queue results). +""" + +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +API = "/api/v1/workspaces" + + +async def test_get_my_workspaces(client, login, task_session): + login() + # getAll -> one SELECT on the task DB + task_session.queue( + fakes.rows( + factories.make_workspace(id=1, title="WS One"), + factories.make_workspace(id=2, title="WS Two"), + ) + ) + + response = await client.get(f"{API}/mine") + + assert response.status_code == 200 + body = response.json() + assert [w["id"] for w in body] == [1, 2] + assert body[0]["title"] == "WS One" + assert body[0]["role"] == "contributor" # WorkspaceResponse includes role + + +async def test_get_workspace_by_id(client, login, task_session): + login() + # getById -> one SELECT + task_session.queue(fakes.rows(factories.make_workspace(id=7, title="Lucky"))) + + response = await client.get(f"{API}/7") + + assert response.status_code == 200 + assert response.json()["title"] == "Lucky" + + +async def test_get_workspace_not_found(client, login, task_session): + login() + task_session.queue(fakes.empty()) # getById finds nothing + + response = await client.get(f"{API}/404") + + assert response.status_code == 404 + + +async def test_create_workspace(client, login, task_session, osm_session): + login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) + # create (task DB) -> add + commit + refresh; then assign_member_role + # (OSM DB) checks the user exists via session.scalar(...). + osm_session.queue(fakes.scalar(1)) + + response = await client.post( + f"{API}", + json={ + "type": "osw", + "title": "Fresh Workspace", + "tdeiProjectGroupId": factories.DEFAULT_PG_ID, + }, + ) + + assert response.status_code == 201 + assert response.json()["workspaceId"] is not None + assert task_session.commits == 1 + assert osm_session.commits == 1 + + +async def test_update_workspace_requires_lead(client, login, task_session): + # Plain contributor, not a lead -> 403 before any DB write. + login(factories.make_user_info()) + + response = await client.patch(f"{API}/1", json={"title": "Renamed"}) + + assert response.status_code == 403 + assert task_session.commits == 0 + + +async def test_update_workspace_as_lead(client, login, task_session): + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + # update -> UPDATE (rowcount) ... commit ... then getById -> SELECT + task_session.queue( + fakes.affected(1), + fakes.rows(factories.make_workspace(id=1, title="Renamed")), + ) + + response = await client.patch(f"{API}/1", json={"title": "Renamed"}) + + assert response.status_code == 204 + assert task_session.commits == 1 + + +async def test_update_workspace_rejects_empty_patch(client, login): + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + + response = await client.patch(f"{API}/1", json={}) + + assert response.status_code == 400 + + +async def test_delete_workspace_as_lead(client, login, task_session, osm_session): + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + # delete flow: get_privileged_workspace_members (OSM) -> delete (task) -> + # remove_all_member_roles (OSM) + osm_session.queue(fakes.rows()) # no privileged members + task_session.queue(fakes.affected(1)) + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert task_session.commits == 1 + + +async def test_delete_workspace_forbidden_for_non_lead(client, login): + login(factories.make_user_info()) + + response = await client.delete(f"{API}/1") + + assert response.status_code == 403 diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..1d2d1d0 --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1,4 @@ +"""Shared test support: fake DB sessions and object factories. + +See ``tests/README.md`` for the testing philosophy and call-order contract. +""" diff --git a/tests/support/factories.py b/tests/support/factories.py new file mode 100644 index 0000000..9cdd402 --- /dev/null +++ b/tests/support/factories.py @@ -0,0 +1,106 @@ +"""Factories for the domain objects tests need. + +These build real ``UserInfo`` / SQLModel instances (not mocks) so the +permission logic and serialization under test run for real. Defaults are +deterministic so tests can assert on concrete values. +""" + +from datetime import datetime +from uuid import UUID + +from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership +from api.src.teams.schemas import WorkspaceTeam +from api.src.users.schemas import User +from api.src.workspaces.schemas import Workspace, WorkspaceType + +# Stable identifiers reused across tests. +DEFAULT_PG_ID = "11111111-1111-1111-1111-111111111111" +DEFAULT_USER_ID = "22222222-2222-2222-2222-222222222222" + + +def make_user_info( + *, + user_id: str = DEFAULT_USER_ID, + user_name: str = "Test User", + project_group_ids: list[str] | None = None, + accessible_workspace_ids: dict[str, list[int]] | None = None, + osm_workspace_roles: dict[int, list] | None = None, + poc_group_ids: tuple[str, ...] = (), +) -> UserInfo: + """Build a ``UserInfo`` the way ``validate_token`` would have. + + ``project_group_ids`` -- TDEI project groups the user belongs to. + ``accessible_workspace_ids`` -- pg_id -> workspace ids (from the task DB). + ``osm_workspace_roles`` -- workspace_id -> roles (from the OSM DB). + ``poc_group_ids`` -- subset of groups where the user is a POC + (grants lead rights on owned workspaces). + """ + info = UserInfo() + info.credentials = "test-token" + info.user_uuid = UUID(user_id) + info.user_name = user_name + + if project_group_ids is None: + project_group_ids = [DEFAULT_PG_ID] + + info.projectGroups = [ + UserInfoPGMembership( + project_group_name=f"PG {pg_id}", + project_group_id=pg_id, + tdeiRoles=( + [TdeiProjectGroupRole.POINT_OF_CONTACT] + if pg_id in poc_group_ids + else [TdeiProjectGroupRole.MEMBER] + ), + ) + for pg_id in project_group_ids + ] + info.accessibleWorkspaceIds = accessible_workspace_ids or {} + info.osmWorkspaceRoles = osm_workspace_roles or {} + return info + + +def make_workspace( + *, + id: int | None = 1, + title: str = "Test Workspace", + type: WorkspaceType = WorkspaceType.OSW, + tdei_project_group_id: str = DEFAULT_PG_ID, + created_by: str = DEFAULT_USER_ID, + created_by_name: str = "Test User", + **extra, +) -> Workspace: + return Workspace( + id=id, + title=title, + type=type, + tdeiProjectGroupId=UUID(tdei_project_group_id), + createdBy=UUID(created_by), + createdByName=created_by_name, + createdAt=datetime(2026, 1, 1), + **extra, + ) + + +def make_user( + *, + id: int = 1, + auth_uid: str = DEFAULT_USER_ID, + email: str = "user@example.com", + display_name: str = "User One", +) -> User: + return User(id=id, auth_uid=auth_uid, email=email, display_name=display_name) + + +def make_team( + *, + id: int = 1, + name: str = "Team Alpha", + workspace_id: int = 1, + users: list[User] | None = None, +) -> WorkspaceTeam: + team = WorkspaceTeam(id=id, name=name, workspace_id=workspace_id) + # ``WorkspaceTeamItem.from_team`` reads ``len(team.users)``; set it + # explicitly since the relationship isn't loaded from a real DB here. + team.users = users or [] + return team diff --git a/tests/support/fakes.py b/tests/support/fakes.py new file mode 100644 index 0000000..a989fd1 --- /dev/null +++ b/tests/support/fakes.py @@ -0,0 +1,185 @@ +"""Fake async DB session for integration tests. + +The application talks to its databases exclusively through SQLAlchemy / +SQLModel ``AsyncSession`` objects (the "data fetcher"). Repositories, +routes, schemas and serialization all sit *above* that boundary. + +``FakeSession`` stands in for a real session: instead of running SQL it +returns pre-programmed ``FakeResult`` objects from a FIFO queue. This lets +a test drive a real HTTP request through the real repository code while +feeding it simulated rows -- no Postgres, PostGIS or Docker required. + +Because the boundary is the session, a test must queue results in the +ORDER the repository issues queries. Each repository method documents its +query sequence; the integration tests show the common patterns. The most +common helpers are :func:`rows` (a SELECT result) and :func:`affected` +(an UPDATE/DELETE rowcount). +""" + +from collections import deque +from itertools import count + +from sqlalchemy.exc import NoResultFound + + +class _Scalars: + """Mimics the object returned by ``result.scalars()``.""" + + def __init__(self, rows): + self._rows = list(rows) + + def all(self): + return list(self._rows) + + def first(self): + return self._rows[0] if self._rows else None + + def __iter__(self): + return iter(self._rows) + + +class _Mappings: + """Mimics the object returned by ``result.mappings()`` (raw-SQL dict rows).""" + + def __init__(self, mappings): + self._mappings = list(mappings) + + def all(self): + return list(self._mappings) + + def first(self): + return self._mappings[0] if self._mappings else None + + def __iter__(self): + return iter(self._mappings) + + +class FakeResult: + """A canned result returned by :meth:`FakeSession.execute` / ``exec``. + + ``rows`` -- ORM entities for ``scalars()`` / ``scalar_one_or_none()``. + ``mappings`` -- dict rows for ``mappings()`` (used by raw-SQL queries). + ``rowcount`` -- value returned by ``result.rowcount`` (UPDATE/DELETE). + ``value`` -- value returned by ``session.scalar(...)``. + """ + + def __init__(self, rows=None, mappings=None, rowcount=1, value=None): + self._rows = list(rows) if rows is not None else [] + self._mappings = list(mappings) if mappings is not None else [] + self.rowcount = rowcount + self.value = value + + def scalars(self): + return _Scalars(self._rows) + + def scalar_one_or_none(self): + return self._rows[0] if self._rows else None + + def scalar_one(self): + if len(self._rows) != 1: + raise NoResultFound("FakeResult.scalar_one() expected exactly one row") + return self._rows[0] + + def mappings(self): + return _Mappings(self._mappings) + + def all(self): + # Used by queries that select multiple columns (rows are tuples). + return list(self._rows) + + def first(self): + return self._rows[0] if self._rows else None + + +class FakeSession: + """Drop-in async stand-in for a SQLModel ``AsyncSession``. + + Queue results with :meth:`queue` (or pass them to the constructor); each + ``execute`` / ``exec`` call pops the next one. ``commit`` / ``add`` / + ``rollback`` / ``refresh`` are recorded so tests can assert on writes. + """ + + def __init__(self, *responses): + self._responses = deque(responses) + self.added = [] + self.commits = 0 + self.rollbacks = 0 + self.closed = False + self._id_seq = count(1) + + def queue(self, *responses): + """Append results to the response queue. Returns self for chaining.""" + self._responses.extend(responses) + return self + + def _next(self): + return self._responses.popleft() if self._responses else FakeResult(rows=[]) + + @staticmethod + def _is_session_setup(statement): + # Raw ``SET search_path ...`` statements (used by the OSM bbox query) + # carry no result and should not consume a queued response. + try: + return str(statement).strip().upper().startswith("SET ") + except Exception: + return False + + async def execute(self, statement, *args, **kwargs): + if self._is_session_setup(statement): + return FakeResult(rows=[]) + return self._next() + + async def exec(self, statement, *args, **kwargs): + return self._next() + + async def scalar(self, statement, *args, **kwargs): + result = self._next() + return result.value if isinstance(result, FakeResult) else result + + def add(self, obj): + self.added.append(obj) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + async def refresh(self, obj, *args, **kwargs): + # Simulate the DB assigning an autoincrement primary key on insert. + if getattr(obj, "id", "missing") is None: + obj.id = next(self._id_seq) + + async def close(self): + self.closed = True + + +# --- result builders ------------------------------------------------------- + + +def rows(*entities, rowcount=None): + """A SELECT result yielding ``entities`` for ``scalars()`` / ``scalar_one*``.""" + return FakeResult( + rows=list(entities), + rowcount=len(entities) if rowcount is None else rowcount, + ) + + +def empty(): + """A SELECT result with no rows (drives NotFound paths).""" + return FakeResult(rows=[], rowcount=0) + + +def affected(n): + """An UPDATE/DELETE result reporting ``n`` affected rows.""" + return FakeResult(rows=[], rowcount=n) + + +def mappings(*dict_rows): + """A raw-SQL result yielding dict rows for ``result.mappings()``.""" + return FakeResult(mappings=list(dict_rows)) + + +def scalar(value): + """A result for ``session.scalar(...)`` (e.g. an EXISTS check).""" + return FakeResult(value=value) diff --git a/tests/support/http.py b/tests/support/http.py new file mode 100644 index 0000000..71cff70 --- /dev/null +++ b/tests/support/http.py @@ -0,0 +1,46 @@ +"""HTTP test doubles for the OSM proxy. + +The proxy streams upstream responses with ``response.aiter_raw()``. The +stock ``httpx.MockTransport`` eagerly buffers the body, so re-streaming it +raises ``StreamConsumed``. This transport returns a genuinely streamable +response and records the forwarded request for assertions. +""" + +import httpx + + +class StreamingMockTransport(httpx.AsyncBaseTransport): + """An httpx transport that returns streamable canned responses. + + ``handler(request) -> (status_code, headers_dict, body_bytes)``. + The most recent request is available as ``.last_request``. + """ + + def __init__(self, handler): + self._handler = handler + self.last_request: httpx.Request | None = None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + # Read the request body so the upstream call completes cleanly. + await request.aread() + self.last_request = request + status_code, headers, body = self._handler(request) + + async def stream(): + yield body + + return httpx.Response(status_code, headers=headers, content=stream()) + + +def osm_mock_client(handler=None, *, base_url: str = "http://osm-web"): + """Build an ``AsyncClient`` standing in for the upstream OSM service. + + Returns ``(client, transport)`` so tests can inspect ``transport.last_request``. + """ + + def default_handler(_request): + return 200, {"content-type": "text/xml"}, b"" + + transport = StreamingMockTransport(handler or default_handler) + client = httpx.AsyncClient(transport=transport, base_url=base_url) + return client, transport diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index bc2f395..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi.testclient import TestClient - -from api.main import app - -client = TestClient(app) - - -def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "ok"} diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_user_info.py b/tests/unit/test_user_info.py new file mode 100644 index 0000000..3547344 --- /dev/null +++ b/tests/unit/test_user_info.py @@ -0,0 +1,54 @@ +"""Unit tests for UserInfo permission logic (pure, no app or DB).""" + +from api.core.security import WorkspaceUserRoleType +from tests.support import factories + + +def test_get_project_group_ids(): + user = factories.make_user_info(project_group_ids=["pg-a", "pg-b"]) + assert user.getProjectGroupIds() == ["pg-a", "pg-b"] + + +def test_is_workspace_lead_via_osm_role(): + user = factories.make_user_info( + osm_workspace_roles={5: [WorkspaceUserRoleType.LEAD]} + ) + assert user.isWorkspaceLead(5) is True + assert user.isWorkspaceLead(6) is False + + +def test_is_workspace_lead_via_poc_group(): + # A point-of-contact in a group that owns workspace 9 is a lead there. + user = factories.make_user_info( + project_group_ids=["pg-owner"], + poc_group_ids=("pg-owner",), + accessible_workspace_ids={"pg-owner": [9]}, + ) + assert user.isWorkspaceLead(9) is True + assert user.isWorkspaceLead(10) is False + + +def test_poc_without_workspace_ownership_is_not_lead(): + user = factories.make_user_info( + project_group_ids=["pg-owner"], + poc_group_ids=("pg-owner",), + accessible_workspace_ids={"pg-owner": []}, + ) + assert user.isWorkspaceLead(9) is False + + +def test_is_workspace_validator(): + user = factories.make_user_info( + osm_workspace_roles={3: [WorkspaceUserRoleType.VALIDATOR]} + ) + assert user.isWorkspaceValidator(3) is True + assert user.isWorkspaceValidator(4) is False + + +def test_is_workspace_contributor(): + user = factories.make_user_info( + accessible_workspace_ids={"pg-a": [1, 2], "pg-b": [3]} + ) + assert user.isWorkspaceContributor(2) is True + assert user.isWorkspaceContributor(3) is True + assert user.isWorkspaceContributor(99) is False diff --git a/tests/unit/test_workspace_repository.py b/tests/unit/test_workspace_repository.py new file mode 100644 index 0000000..84fc9e4 --- /dev/null +++ b/tests/unit/test_workspace_repository.py @@ -0,0 +1,94 @@ +"""Unit tests for WorkspaceRepository against a fake session. + +These exercise the repository in isolation (no HTTP layer) to show how the +data-fetcher boundary is mocked: queue the rows the DB "would" return, then +assert on the repository's behavior and writes. +""" + +from typing import cast +from uuid import UUID + +import pytest +from sqlmodel.ext.asyncio.session import AsyncSession + +from api.core.exceptions import ForbiddenException, NotFoundException +from api.src.workspaces.repository import WorkspaceRepository +from api.src.workspaces.schemas import WorkspaceCreate, WorkspaceType +from tests.support import factories, fakes + + +def _repo(session: fakes.FakeSession) -> WorkspaceRepository: + # FakeSession implements the AsyncSession surface the repository uses. + return WorkspaceRepository(cast(AsyncSession, session)) + + +@pytest.fixture +def user(): + return factories.make_user_info() + + +async def test_get_by_id_returns_workspace(user): + workspace = factories.make_workspace(id=1, title="Mapping WS") + session = fakes.FakeSession(fakes.rows(workspace)) + + result = await _repo(session).getById(user, 1) + + assert result.id == 1 + assert result.title == "Mapping WS" + + +async def test_get_by_id_missing_raises_not_found(user): + session = fakes.FakeSession(fakes.empty()) + + with pytest.raises(NotFoundException): + await _repo(session).getById(user, 404) + + +async def test_get_all_returns_list(user): + session = fakes.FakeSession( + fakes.rows(factories.make_workspace(id=1), factories.make_workspace(id=2)) + ) + + result = await _repo(session).getAll(user) + + assert [w.id for w in result] == [1, 2] + + +async def test_create_commits_and_assigns_id(user): + session = fakes.FakeSession() + + workspace = await _repo(session).create( + user, + WorkspaceCreate( + type=WorkspaceType.OSW, + title="Brand New", + tdeiProjectGroupId=UUID(factories.DEFAULT_PG_ID), + ), + ) + + assert session.commits == 1 + assert workspace in session.added + assert workspace.id is not None # assigned by refresh() + assert workspace.createdByName == "Test User" + + +async def test_create_in_unauthorized_group_raises(user): + session = fakes.FakeSession() + + with pytest.raises(ForbiddenException): + await _repo(session).create( + user, + WorkspaceCreate( + type=WorkspaceType.OSW, + title="Forbidden", + tdeiProjectGroupId=UUID("99999999-9999-9999-9999-999999999999"), + ), + ) + assert session.commits == 0 + + +async def test_delete_missing_raises_not_found(user): + session = fakes.FakeSession(fakes.affected(0)) + + with pytest.raises(NotFoundException): + await _repo(session).delete(user, 1) From 43741a07628af50d99e4efc1e9c37cd976ed0c2b Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 13:52:28 -0400 Subject: [PATCH 076/159] Test comments and typing cleanup --- api/core/config.py | 5 +- api/core/json_schema.py | 58 ++++++++++++++++-------- api/core/jwt.py | 4 ++ api/core/security.py | 24 ++++++++-- api/main.py | 30 ++++++++++-- api/src/teams/repository.py | 6 +++ api/src/teams/routes.py | 64 ++++++++++++++++++++++++++ api/src/teams/schemas.py | 15 ++++-- api/src/users/repository.py | 6 +++ api/src/users/routes.py | 20 ++++++++ api/src/users/schemas.py | 10 +++- api/src/workspaces/repository.py | 12 ++++- api/src/workspaces/routes.py | 78 +++++++++++++++++++++++++++++++- api/src/workspaces/schemas.py | 19 ++++++-- 14 files changed, 311 insertions(+), 40 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index 16cedfd..5824796 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,6 +1,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict - +# Test outline: +# @test: Test that environment variables of the same name as the members of this class are correctly loaded into the members of this class. +# @test: Test that any environment variables that are not set in the environment are correctly loaded into the members of this class with their default values. +# @test: Test that strings and number values, empty strings and URLs are all correctly loaded as exemplified by the default values class Settings(BaseSettings): """Application settings.""" diff --git a/api/core/json_schema.py b/api/core/json_schema.py index 2c2317b..4d6f54a 100644 --- a/api/core/json_schema.py +++ b/api/core/json_schema.py @@ -1,6 +1,6 @@ import asyncio import json -from typing import Any +from typing import Any, NoReturn import httpx import jsonschema @@ -17,6 +17,10 @@ _imagery_schema: dict | None = None _imagery_schema_lock = asyncio.Lock() +# Test outline: +# @test: Test that this class validates JSON payloads properly against the JSON schema fetched +# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation def init_json_schema_client() -> None: global _http_client @@ -29,6 +33,15 @@ def get_http_client() -> httpx.AsyncClient | None: return _http_client +def _require_http_client() -> httpx.AsyncClient: + if _http_client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Schema HTTP client is not initialized", + ) + return _http_client + + async def close_json_schema_client() -> None: global _http_client if _http_client is not None: @@ -38,27 +51,35 @@ async def close_json_schema_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 + cached = _longform_schema + if cached is not None: + return cached + async with _longform_schema_lock: + if _longform_schema is None: + response = await _require_http_client().get(settings.LONGFORM_SCHEMA_URL) + response.raise_for_status() + schema: dict = response.json() + _longform_schema = schema + return schema + 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: + cached = _imagery_schema + if cached is not None: + return cached + async with _imagery_schema_lock: + if _imagery_schema is None: + response = await _require_http_client().get(settings.IMAGERY_SCHEMA_URL) + response.raise_for_status() + schema: dict = response.json() + _imagery_schema = schema + return schema + return _imagery_schema + + +def _raise_for_fetch_error(e: Exception, label: str) -> NoReturn: if isinstance(e, httpx.TimeoutException): raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, @@ -103,7 +124,6 @@ async def validate_quest_definition_schema(definition: str) -> None: 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. diff --git a/api/core/jwt.py b/api/core/jwt.py index 46e04c8..522a8e2 100644 --- a/api/core/jwt.py +++ b/api/core/jwt.py @@ -5,6 +5,10 @@ # Singleton JWKS client reused to take advantage of internal cert/key caching: _jwks_client: jwt.PyJWKClient | None = None +# Test outline: +# @test: Test that this class validates JSON payloads properly against the JSON schema fetched +# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation def _get_jwks_client() -> jwt.PyJWKClient: global _jwks_client diff --git a/api/core/security.py b/api/core/security.py index c283af4..7e8baa1 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -14,6 +14,12 @@ from api.core.logging import get_logger from api.src.users.schemas import WorkspaceUserRoleType +# Test outline: +# @test: Test that the permissions structure here matches what is described in CLAUDE.md +# @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles +# @test: Test that any failed network requests are handled gracefully +# @test: Test that the caching mechanism works correctly and evicts entries when roles change + # Set up logger for this module logger = get_logger(__name__) @@ -22,7 +28,7 @@ # cached record. _user_info_cache: cachetools.TTLCache[UUID, "UserInfo"] = cachetools.TTLCache( maxsize=1000, ttl=60 * 60 -) +) # type: ignore[assignment] # cachetools ctor can't infer key/value types # Shared HTTP client for TDEI backend calls. Initialized by main.py lifespan. _tdei_client: httpx.AsyncClient | None = None @@ -56,7 +62,7 @@ def evict_user_from_cache(auth_uid: UUID) -> None: security = HTTPBearer() - +# @test: Test that this matches what is described in CLAUDE.md and that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles class TdeiProjectGroupRole(StrEnum): MEMBER = "member" POINT_OF_CONTACT = "poc" @@ -80,7 +86,9 @@ def __init__( self.project_group_id = project_group_id self.tdeiRoles = tdeiRoles - +# @test: Test that the values populated in this class match the expected values from the JWT and TDEI API responses, and that the methods return the correct roles based on the user's project group memberships and workspace roles +# @test: Test that the osmWorkspaceRoles, accessibleWorkspaceIds, and projectGroups attributes are correctly populated based on the user's roles in the OSM DB and TDEI API responses +# @test: Test that the comments in this doc that describe what is supposed to be in the attributes are accurate and match the actual data being stored in those attributes and what is returned. The comments should be considered authoratative class UserInfo: credentials: str user_uuid: UUID @@ -159,6 +167,7 @@ def get_task_db_session( ) -> AsyncSession: return session +# @test: async def validate_token( credentials: HTTPAuthorizationCredentials = Depends(security), @@ -245,8 +254,15 @@ async def _validate_token_uncached( # get user's project groups and roles from TDEI pgs = [] + client = _tdei_client + if client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="TDEI client is not initialized", + ) + try: - response = await _tdei_client.get( + response = await client.get( f"project-group-roles/{user_uuid}", headers=headers, params={"page_no": 1, "page_size": 1000}, diff --git a/api/main.py b/api/main.py index dd82fcc..a2582dc 100644 --- a/api/main.py +++ b/api/main.py @@ -46,6 +46,15 @@ _osm_client: httpx.AsyncClient | None = None +def _require_osm_client() -> httpx.AsyncClient: + if _osm_client is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OSM proxy client is not initialized", + ) + return _osm_client + + @asynccontextmanager async def lifespan(_app: FastAPI): # only run migrations when not under test @@ -111,6 +120,18 @@ def get_workspace_repository( return WorkspaceRepository(session) +# @test: Any headers defined in STRIP_REQUEST_HEADERS are not forwarded to the OSM service +# @test: Any headers defined in HOP_BY_HOP_HEADERS are not forwarded to the client +# @test: /api/capabilities.json is proxied to the OSM service without requiring authentication +# @test: Any request to the OSM service that returns a 4xx or 5xx status code is logged to Sentry with the correct message and the correct status code is returned to the client +# @test: Any request that matches the RegEx in TENANT_BYPASSES is allowed to proceed without an X-Workspace header, +# and any request that does not match the Regex in TENANT_BYPASSES and does not have an X-Workspace header returns a 400 Bad Request error +# @test: Only the methods defined in the @app.api_route decorator are allowed to be proxied to the OSM service, and any other methods return a 405 Method Not Allowed error +# @test: Any request with an X-Workspace header that does not match the user's accessible workspaces returns a 403 Forbidden error +# @test: Any request with a missing X-Workspace header that does not match the TENANT_BYPASSES returns a 400 Bad Request error +# @test: All the values for Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto headers are correctly set when proxied to the OSM service +# @test: The response from the OSM service is correctly streamed back to the client with the correct status code and headers, and the response body is not modified in any way + # This API route catches anything not otherwise defined above--MUST be last in this file # # h/t: https://stackoverflow.com/questions/70610266/proxy-an-external-website-using-python-fast-api-not-supporting-query-params @@ -154,13 +175,14 @@ def get_workspace_repository( async def capabilities(request: Request): """Proxy OSM capabilities manifest without requiring authentication.""" + client = _require_osm_client() client_host = request.client.host if request.client else "unknown" req_headers = [ (k.encode(), v.encode()) for k, v in request.headers.items() if k.lower() not in STRIP_REQUEST_HEADERS ] + [ - (b"Host", _osm_client.base_url.host.encode()), + (b"Host", client.base_url.host.encode()), (b"X-Real-IP", client_host.encode()), (b"X-Forwarded-For", client_host.encode()), (b"X-Forwarded-Host", (request.url.hostname or "").encode()), @@ -168,10 +190,10 @@ async def capabilities(request: Request): ] url = httpx.URL(path="/api/capabilities.json") - rp_req = _osm_client.build_request("GET", url, headers=req_headers) + rp_req = client.build_request("GET", url, headers=req_headers) try: - rp_resp = await _osm_client.send(rp_req, stream=True) + rp_resp = await client.send(rp_req, stream=True) except httpx.TimeoutException: raise HTTPException( status_code=status.HTTP_504_GATEWAY_TIMEOUT, @@ -236,7 +258,7 @@ async def catch_all( path=request.url.path.strip(), query=request.url.query.encode("utf-8") ) - client = _osm_client + client = _require_osm_client() client_host = request.client.host if request.client else "unknown" req_headers = [ (k.encode(), v.encode()) diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 2b6c09e..597aa96 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -1,3 +1,9 @@ +# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather +# than ``Mapped[int]``, so Pyright reads ``Column == value`` as a ``bool`` and +# misjudges ``where()``/``exec()``/``select()``/``selectinload`` calls. These are +# framework false positives; the queries are valid at runtime. +# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false + from sqlalchemy import delete, exists, select from sqlalchemy.orm import selectinload from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index 5163259..2e1e760 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -36,6 +36,11 @@ def get_team_repo( repo = WorkspaceTeamRepository(session) return repo +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to fetch the team from the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem @router.get("") async def get_all_teams_for_workspace( @@ -48,6 +53,11 @@ async def get_all_teams_for_workspace( await workspace_repo.getById(current_user, workspace_id) return await team_repo.get_all(workspace_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to add the team to the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamCreate @router.post("", status_code=status.HTTP_201_CREATED) async def create_team_for_workspace( @@ -67,6 +77,12 @@ async def create_team_for_workspace( await workspace_repo.getById(current_user, workspace_id) return await team_repo.create(workspace_id, team) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly calls the repository to fetch the team from the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem @router.get("/{team_id}") async def get_team_for_workspace( @@ -81,6 +97,14 @@ async def get_team_for_workspace( await team_repo.assert_team_in_workspace(team_id, workspace_id) return await team_repo.get_item(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly calls the repository to remove the team from the workspace +# @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamUpdate +# @test: Test that this method properly calls the repo to update the team @router.put("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_team_for_workspace( @@ -102,6 +126,12 @@ async def update_team_for_workspace( await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.update(team_id, team) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly calls the repository to remove the team from the workspace @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_team_from_workspace( @@ -122,6 +152,14 @@ async def delete_team_from_workspace( await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.delete(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member team +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the team is not associated with the workspace +# @test: Test that this endpoint properly handles the case where the user is not a member of the workspace passed +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed +# @test: Test that this endpoint properly calls the repository to fetch the users of the team @router.get("/{team_id}/members") async def get_members_in_workspace_team( @@ -137,6 +175,14 @@ async def get_members_in_workspace_team( return await team_repo.get_members(team_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member of the workspace via the team +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not a member of the workspace passed +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed +# @test: Test that this endpoint properly calls the repository to add the user to the team + @router.post("/{team_id}/members") async def join_workspace_team( workspace_id: int, @@ -153,6 +199,14 @@ async def join_workspace_team( await team_repo.add_member(team_id, user.id) return user +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the workspace +# @test: Test that this endpoint properly calls the repository to add the user to the team @router.put("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def add_member_to_workspace_team( @@ -174,6 +228,16 @@ async def add_member_to_workspace_team( await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.add_member(team_id, user_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that the endpoint properly validates that the team needs to be associated with this workspace and errors if not +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the team +# @test: Test that this endpoint properly calls the repository to remove the user from the team @router.delete("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_member_from_workspace_team( diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index 7d623d4..0aefcf0 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -3,13 +3,16 @@ from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: - from api.src.workspaces.schemas import User - + from api.src.users.schemas import User +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeamUser(SQLModel, table=True): """Team to User link table""" - __tablename__ = "team_user" + __tablename__ = "team_user" # type: ignore[assignment] team_id: int | None = Field(default=None, primary_key=True, foreign_key="teams.id") user_id: int | None = Field(default=None, primary_key=True, foreign_key="users.id") @@ -20,7 +23,10 @@ class WorkspaceTeamBase(SQLModel): name: str = Field(min_length=1) - +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeam(WorkspaceTeamBase, table=True): """Workspace teams""" @@ -43,6 +49,7 @@ class WorkspaceTeamItem(WorkspaceTeamBase): @classmethod def from_team(cls, team: WorkspaceTeam) -> Self: + assert team.id is not None # persisted team always has an id return cls(id=team.id, name=team.name, member_count=len(team.users)) diff --git a/api/src/users/repository.py b/api/src/users/repository.py index f7769e4..2961ad9 100644 --- a/api/src/users/repository.py +++ b/api/src/users/repository.py @@ -1,3 +1,9 @@ +# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather +# than ``Mapped[int]``, so Pyright reads ``Column == value`` as a ``bool`` and +# misjudges ``where()``/``exec()``/``select()`` calls and ``result.rowcount``. +# These are framework false positives; the queries are valid at runtime. +# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false + from uuid import UUID from sqlalchemy import delete, select diff --git a/api/src/users/routes.py b/api/src/users/routes.py index 54d3527..9a96162 100644 --- a/api/src/users/routes.py +++ b/api/src/users/routes.py @@ -24,6 +24,10 @@ def get_workspace_repo( ) -> WorkspaceRepository: return WorkspaceRepository(session) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not associated with this workspace at contributor or above +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace (returns a 403 error) @router.get("", response_model=list[WorkspaceUserRoleItem]) async def get_privileged_workspace_members( @@ -40,6 +44,16 @@ async def get_privileged_workspace_members( return await user_repo.get_privileged_workspace_members(workspace_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this endpoint properly allows users to be workspace leads or validators, and to unset the user of either role and become a contributor again +# @test: Test that this endpoint doesn't allow changing workspaces the user doesn't have workspace lead permissions for, or roles for users not already associated with the workspace +# @test: Test that this endpoint doesn't allow setting the workspace role to a POC + @router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) async def assign_member_role( workspace_id: int, @@ -64,6 +78,12 @@ async def assign_member_role( await user_repo.assign_member_role(workspace_id, user_id, body.role) evict_user_from_cache(user_id) +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member_role( diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py index 7b4731b..bc8e3be 100644 --- a/api/src/users/schemas.py +++ b/api/src/users/schemas.py @@ -16,7 +16,10 @@ class WorkspaceUserRoleType(StrEnum): VALIDATOR = "validator" CONTRIBUTOR = "contributor" - +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceUserRole(SQLModel, table=True): """Associates users with workspaces and their roles""" @@ -62,7 +65,10 @@ class WorkspaceUserRoleItem(SQLModel): display_name: str role: WorkspaceUserRoleType - +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class User(SQLModel, table=True): """Users in the OSM DB""" diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 8c1c87c..3c16cb0 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,3 +1,10 @@ +# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather +# than ``Mapped[int]``, so Pyright reads ``Column == value`` as a ``bool`` and +# misjudges ``where()``/``select()`` calls, ``result.rowcount``, and table-model +# constructors. These are framework false positives; the queries are valid at +# runtime. Genuine type bugs surface via other rules, which stay enabled. +# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false + from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -142,8 +149,11 @@ async def resolve_quest_def(quest: WorkspaceLongQuest | None) -> str | None: return quest.definition or None if quest.url: + client = get_http_client() + if client is None: + return None try: - response = await get_http_client().get(quest.url, timeout=10) + response = await 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 2ba802f..fdc0a95 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -1,5 +1,4 @@ import json -from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Response, status @@ -20,7 +19,6 @@ QuestDefinitionTypeName, QuestSettingsPatch, QuestSettingsResponse, - Workspace, WorkspaceCreate, WorkspaceImagery, WorkspacePatch, @@ -53,6 +51,14 @@ def get_user_repository( return UserRepository(session) +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly checks permissions to see if the user has access to the workspace and if they don't, it doesn't appear in the list +# @test: Test that this method properly handles the case where the workspace does not exist and doesn't include it +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse +# @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 + # 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( @@ -66,6 +72,12 @@ async def get_my_workspaces( logger.error(f"Failed to fetch workspaces: {str(e)}") raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly checks permissions to see if the user has access to the workspace and returns a 403 if they do not +# @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse # Returns JSON payload or 204 if not found @router.get("/{workspace_id}", response_model=WorkspaceResponse) @@ -98,6 +110,10 @@ async def get_workspace( logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test taht this workspace returns proper values for a workspace that exists and that the bbox matches the expected values +# @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 @router.get("/{workspace_id}/bbox", response_model=None) async def get_workspace_bbox( @@ -115,6 +131,13 @@ async def get_workspace_bbox( logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this method properly calls the repository method to create the workspace and that the repository method properly creates the workspace in the database +# @test: Test that this method properly sets the creator as the lead of the workspace and that the repository method properly assigns the lead role in the database +# @test: Test that this method properly handles inputs that match the schema in WorkspaceCreate and that the repository method properly creates the workspace in the database with those values +# @test: Test that this method properly handles inputs that do not match the schema in WorkspaceCreate and that the repository method properly raises an error and does not update the workspace in the database with those values +# @test: Test that this method won't allow users to modify an existing workspace in any way, or create a workspace with the same workspace_id as an existing one # Returns 201 on success? @router.post("", status_code=status.HTTP_201_CREATED) @@ -126,6 +149,7 @@ async def create_workspace( ) -> dict[str, int]: try: workspace = await repository_ws.create(current_user, workspace_data) + assert workspace.id is not None # freshly persisted workspace has an id # Assign the creator as lead so that non-POC members can manage their # own workspace: @@ -147,6 +171,13 @@ async def create_workspace( logger.error(f"Failed to create workspace: {str(e)}") raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to update the workspace and that the repository method properly updates the workspace in the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same +# @test: Test that this method properly handles inputs that match the schema in WorkspacePatch and that the repository method properly updates the workspace in the database with those values +# @test: Test that this method properly handles inputs that do not match the schema in WorkspacePatch and that the repository method properly raises an error and does not update the workspace in the database with those values @router.patch("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_workspace( @@ -174,6 +205,14 @@ async def update_workspace( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to delete the workspace and that the repository method properly deletes the workspace from the database +# @test: Test that this method properly handles the case where the workspace has members and that the repository method properly removes all member roles from the database +# @test: Test that this method properly handles numeric workspace_id input and invalid values for the same + # Returns 204 on success @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( @@ -201,6 +240,12 @@ async def delete_workspace( # QUESTS +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the long quest definition + +# FIXME: Why are there two methods to fetch the long quest? One for legacy purposes? Can we migrate callers? # Return the resolved quest definition content as JSON, or 204 if not set: @router.get("/{workspace_id}/quests/long") @@ -225,6 +270,11 @@ async def get_long_quest_def( ) raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the long quest definition, and if it's not defined, the default value as defined +# in this method # Returns JSON payload or 204 if not set @router.get( @@ -263,6 +313,13 @@ async def get_long_quest_settings( logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly validates the long quest definition against the JSON schema and returns a 400 if the definition is invalid +# @test: Test that this endpoint properly saves the long quest definition to the database and returns a 204 on success +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads +# @test: Test that this method properly calls the repository method to save the long quest definition and that the repository method properly saves the definition to the database # Returns 204 on success @router.patch( @@ -281,6 +338,11 @@ async def update_long_quest_settings( ) if long_quest_data.type == QuestDefinitionTypeName.JSON: + if long_quest_data.definition is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'definition' is required for JSON quest type.", + ) await validate_quest_definition_schema(long_quest_data.definition) try: @@ -294,6 +356,10 @@ async def update_long_quest_settings( # IMAGERY +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this method properly calls the repository method to fetch the imagery definition # Returns JSON payload or 204 if not set @router.get("/{workspace_id}/imagery/settings") @@ -320,6 +386,14 @@ async def get_imagery_settings( raise +# @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead +# @test: Test that this endpoint properly validates the imagery definition against the JSON schema and returns a 400 if the definition is invalid +# @test: Test that this endpoint properly saves the imagery definition to the database and returns a 204 on success +# @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs +# @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 +# @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads +# @test: Test that this method properly calls the repository method to save the imagery definition and that the repository method properly saves the definition to the database + # Returns 204 on success @router.patch( "/{workspace_id}/imagery/settings", status_code=status.HTTP_204_NO_CONTENT diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index fa86ce6..34f28f7 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -78,7 +78,10 @@ class QuestDefinitionTypeName(StrEnum): JSON = "JSON" URL = "URL" - +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -102,7 +105,10 @@ class WorkspaceLongQuest(SQLModel, table=True): modifiedBy: UUID modifiedByName: str - +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceImagery(SQLModel, table=True): """Stores imagery list for a workspace""" @@ -215,6 +221,9 @@ class WorkspaceResponse(SQLModel): longFormQuestDef: Optional[Any] = None imageryListDef: Optional[Any] = None + # @test: Test that this class properly serializes the workspace data for API responses, including the effective role for the user making the request + # @test: Test that the values are populated in this class match the expected values from the database and that the relationships are correctly serialized + @classmethod def from_workspace( cls, @@ -224,6 +233,7 @@ def from_workspace( imagery_list_def: Any = None, long_form_quest_def: Any = None, ) -> Self: + assert workspace.id is not None # persisted workspace always has an id return cls( id=workspace.id, type=workspace.type, @@ -243,7 +253,10 @@ def from_workspace( longFormQuestDef=long_form_quest_def, ) - +# @test: Test that this class matches the Alembic-defined database schema +# @test: Test that the foreign key references are correct and that the relationships are correctly defined +# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any serialization/deserialization doesn't lose any precision or data class Workspace(SQLModel, table=True): """Workspaces""" From fa8df698e28513e6b63d9079e6ab0476f60bb1bd Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 14:18:26 -0400 Subject: [PATCH 077/159] Tests --- tests/conftest.py | 12 + tests/integration/test_proxy.py | 176 ++++++++-- tests/integration/test_teams.py | 341 +++++++++++++++++-- tests/integration/test_users.py | 186 ++++++++++ tests/integration/test_workspaces.py | 466 +++++++++++++++++++++++--- tests/support/fakes.py | 18 +- tests/unit/test_config.py | 59 ++++ tests/unit/test_json_schema.py | 137 ++++++++ tests/unit/test_jwt.py | 71 ++++ tests/unit/test_security.py | 334 ++++++++++++++++++ tests/unit/test_teams_schemas.py | 69 ++++ tests/unit/test_users_schemas.py | 78 +++++ tests/unit/test_workspaces_schemas.py | 202 +++++++++++ 13 files changed, 2037 insertions(+), 112 deletions(-) create mode 100644 tests/integration/test_users.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_json_schema.py create mode 100644 tests/unit/test_jwt.py create mode 100644 tests/unit/test_security.py create mode 100644 tests/unit/test_teams_schemas.py create mode 100644 tests/unit/test_users_schemas.py create mode 100644 tests/unit/test_workspaces_schemas.py diff --git a/tests/conftest.py b/tests/conftest.py index 2fef3b5..a5b6c4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,3 +67,15 @@ async def client(app): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://testserver") as c: yield c + + +@pytest_asyncio.fixture +async def error_client(app): + """Like ``client`` but turns unhandled exceptions into 500 responses. + + By default httpx's ASGI transport re-raises app exceptions; this fixture + lets tests assert on the 500 the server would actually return in prod. + """ + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py index aab0ffd..6cd818d 100644 --- a/tests/integration/test_proxy.py +++ b/tests/integration/test_proxy.py @@ -1,68 +1,190 @@ -"""Integration tests for the OSM proxy (catch-all) routes. - -The proxy forwards to an upstream OSM service via a module-level -``httpx.AsyncClient`` and re-streams the response. We swap that client for -one backed by a streamable mock transport so the proxy path is exercised -end-to-end without a real upstream. +"""Integration tests for the OSM proxy (catch-all + capabilities) routes. + +Covers the @test comments in api/main.py: +- STRIP_REQUEST_HEADERS are not forwarded upstream +- HOP_BY_HOP_HEADERS are not forwarded back to the client +- /api/capabilities.json is proxied without auth +- 4xx/5xx upstream responses are logged to Sentry and the status is preserved +- TENANT_BYPASSES allow specific paths/methods without an X-Workspace header +- only the decorator's methods are proxied (others -> 405) +- X-Workspace not in the user's accessible workspaces -> 403 +- missing X-Workspace and no bypass -> 400 +- Host / X-Real-IP / X-Forwarded-* are set correctly upstream +- the response is streamed back unmodified with its status and headers """ +import httpx import pytest import api.main from tests.support import factories -from tests.support.http import osm_mock_client +from tests.support.http import StreamingMockTransport @pytest.fixture def mock_osm(monkeypatch): - """Replace the upstream OSM client with a recording mock transport.""" - client, transport = osm_mock_client() - monkeypatch.setattr(api.main, "_osm_client", client) + """Default upstream returning 200 text/xml; records the forwarded request.""" + transport = StreamingMockTransport( + lambda req: (200, {"content-type": "text/xml"}, b"") + ) + monkeypatch.setattr( + api.main, + "_osm_client", + httpx.AsyncClient(transport=transport, base_url="http://osm-web"), + ) + return transport + + +def install_osm(monkeypatch, handler): + transport = StreamingMockTransport(handler) + monkeypatch.setattr( + api.main, + "_osm_client", + httpx.AsyncClient(transport=transport, base_url="http://osm-web"), + ) return transport +# --- capabilities ---------------------------------------------------------- + + async def test_capabilities_proxies_without_auth(client, mock_osm): - # No X-Workspace header and no bearer token required for capabilities. response = await client.get("/api/capabilities.json") - assert response.status_code == 200 assert b"osm version" in response.content assert mock_osm.last_request.url.path == "/api/capabilities.json" -async def test_proxy_requires_workspace_header(client, login, mock_osm): +# --- auth / tenant gating -------------------------------------------------- + + +async def test_missing_workspace_header_without_bypass_returns_400( + client, login, mock_osm +): login(factories.make_user_info()) - # A non-whitelisted path with no X-Workspace header is rejected. response = await client.get("/api/0.6/map") - assert response.status_code == 400 -async def test_proxy_forbidden_without_workspace_access(client, login, mock_osm): - # User has no access to workspace 1. +async def test_workspace_header_without_access_returns_403(client, login, mock_osm): login(factories.make_user_info(accessible_workspace_ids={})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + assert response.status_code == 403 + +async def test_non_integer_workspace_header_returns_400(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "abc"}) + assert response.status_code == 400 + + +async def test_contributor_request_is_proxied(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + assert response.status_code == 200 + assert b"osm version" in response.content - assert response.status_code == 403 + +async def test_tenant_bypass_allows_workspace_put_without_header( + client, login, mock_osm +): + # PUT /api/0.6/workspaces/{id} is in TENANT_BYPASSES -> no X-Workspace needed. + login(factories.make_user_info()) + response = await client.put("/api/0.6/workspaces/123") + assert response.status_code == 200 + assert mock_osm.last_request is not None -async def test_proxy_forwards_for_workspace_contributor(client, login, mock_osm): +async def test_tenant_bypass_does_not_apply_to_wrong_method(client, login, mock_osm): + # The bypass for /workspaces/{id} is PUT/DELETE only; GET still needs a header. + login(factories.make_user_info()) + response = await client.get("/api/0.6/workspaces/123") + assert response.status_code == 400 + + +async def test_disallowed_method_returns_405(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + # TRACE is not in the @app.api_route methods list. + response = await client.request( + "TRACE", "/api/0.6/map", headers={"X-Workspace": "1"} + ) + assert response.status_code == 405 + + +# --- header handling ------------------------------------------------------- + + +async def test_spoofed_request_headers_are_stripped(client, login, mock_osm): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + await client.get( + "/api/0.6/map", + headers={ + "X-Workspace": "1", + "Host": "evil.example", + "X-Forwarded-For": "9.9.9.9", + "X-Real-IP": "9.9.9.9", + }, + ) + + fwd = mock_osm.last_request.headers + # Host is rewritten to the upstream host, not the spoofed value. + assert fwd["host"] == "osm-web" + # X-Forwarded-* / X-Real-IP are set by the proxy, not passed through. + assert fwd["x-forwarded-for"] != "9.9.9.9" + assert fwd["x-real-ip"] != "9.9.9.9" + assert "x-forwarded-proto" in fwd + assert "x-forwarded-host" in fwd + + +async def test_hop_by_hop_response_headers_are_stripped(client, login, monkeypatch): + install_osm( + monkeypatch, + lambda req: ( + 200, + {"content-type": "text/xml", "keep-alive": "timeout=5", "x-custom": "v"}, + b"", + ), + ) login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) assert response.status_code == 200 - assert b"osm version" in response.content - # Spoofable forwarding headers are normalized before reaching upstream. - forwarded = mock_osm.last_request.headers - assert "x-forwarded-for" in forwarded # set by the proxy itself - assert forwarded["host"] == "osm-web" + # Non-hop-by-hop headers pass through; hop-by-hop ones are dropped. + assert response.headers.get("x-custom") == "v" + assert "keep-alive" not in response.headers -async def test_proxy_rejects_non_integer_workspace_header(client, login, mock_osm): +# --- upstream status + body fidelity --------------------------------------- + + +async def test_upstream_error_is_logged_and_status_preserved( + client, login, monkeypatch +): + install_osm(monkeypatch, lambda req: (503, {"content-type": "text/plain"}, b"down")) + captured = [] + monkeypatch.setattr( + api.main.sentry_sdk, "capture_message", lambda msg: captured.append(msg) + ) login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) - response = await client.get("/api/0.6/map", headers={"X-Workspace": "abc"}) + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) - assert response.status_code == 400 + assert response.status_code == 503 + assert captured, "expected a Sentry capture_message for the 5xx upstream response" + assert "503" in captured[0] + + +async def test_response_body_is_streamed_unmodified(client, login, monkeypatch): + body = b"\n \n" + install_osm( + monkeypatch, lambda req: (200, {"content-type": "application/xml"}, body) + ) + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + + response = await client.get("/api/0.6/map", headers={"X-Workspace": "1"}) + + assert response.status_code == 200 + assert response.content == body + assert response.headers["content-type"] == "application/xml" diff --git a/tests/integration/test_teams.py b/tests/integration/test_teams.py index fdd92a0..41ae522 100644 --- a/tests/integration/test_teams.py +++ b/tests/integration/test_teams.py @@ -1,22 +1,48 @@ """Integration tests for the /workspaces/{id}/teams routes. -Team routes touch BOTH databases: the workspace access guard hits the task -DB (``workspace_repo.getById``) and the team operations hit the OSM DB. -Queue results on the matching fake session in call order. +Covers the @test comments in api/src/teams/routes.py across all eight +endpoints. Team routes touch both DBs: the access guard / workspace lookup +hits the task DB (``workspace_repo.getById``) and team/user operations hit +the OSM DB. Queue results on the matching fake session in call order. + +Note on access errors: for endpoints with no explicit lead check, access is +enforced by ``getById``, which raises 404 (NotFound) when the workspace is +missing or inaccessible -- so "not a member" surfaces as 404, not 403, there. """ +import pytest + +from api.src.users.schemas import WorkspaceUserRoleType from tests.support import factories, fakes -def teams_url(workspace_id: int = 1) -> str: +def base(workspace_id=1): return f"/api/v1/workspaces/{workspace_id}/teams" -async def test_list_teams(client, login, task_session, osm_session): - login() - # getById (task DB) guards access... +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +def member(): + # A non-lead user (default factory grants no lead/validator role). + return factories.make_user_info() + + +def ws_ok(task_session): + """Queue a successful workspace access guard on the task DB.""" task_session.queue(fakes.rows(factories.make_workspace(id=1))) - # ...then team_repo.get_all (OSM DB) returns teams with members. + + +# === GET "" list teams ===================================================== + + +async def test_list_teams_returns_items(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) osm_session.queue( fakes.rows( factories.make_team(id=1, name="Alpha", users=[factories.make_user()]), @@ -24,7 +50,7 @@ async def test_list_teams(client, login, task_session, osm_session): ) ) - response = await client.get(teams_url()) + response = await client.get(base()) assert response.status_code == 200 body = response.json() @@ -32,47 +58,300 @@ async def test_list_teams(client, login, task_session, osm_session): assert body[1]["member_count"] == 0 -async def test_create_team_requires_lead(client, login, task_session, osm_session): - login() # plain contributor - response = await client.post(teams_url(), json={"name": "New Team"}) - assert response.status_code == 403 +async def test_list_teams_inaccessible_workspace_404(client, login, task_session): + login(member()) + task_session.queue(fakes.empty()) # getById guard -> NotFound + response = await client.get(base(42)) + assert response.status_code == 404 -async def test_create_team_as_lead(client, login, task_session, osm_session): - from api.src.users.schemas import WorkspaceUserRoleType +async def test_list_teams_unexpected_error_500( + error_client, login, task_session, osm_session +): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.raises(RuntimeError("boom"))) + response = await error_client.get(base()) + assert response.status_code == 500 - login( - factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) - ) - # lead check -> getById (task) guard -> team_repo.create (OSM): add+commit+refresh - task_session.queue(fakes.rows(factories.make_workspace(id=1))) - response = await client.post(teams_url(), json={"name": "New Team"}) +# === POST "" create team =================================================== + +async def test_create_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.post(base(), json={"name": "New"}) + assert response.status_code == 403 + + +async def test_create_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) # getById guard + # create -> add + commit + refresh assigns the id + response = await client.post(base(), json={"name": "New Team"}) assert response.status_code == 201 assert isinstance(response.json(), int) assert osm_session.commits == 1 -async def test_get_team_not_in_workspace_returns_404( +async def test_create_team_workspace_missing_404(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.empty()) + response = await client.post(base(), json={"name": "New"}) + assert response.status_code == 404 + + +async def test_create_team_rejects_blank_name(client, login, lead): + login(lead) + response = await client.post(base(), json={"name": ""}) + assert response.status_code == 422 + + +# === GET "/{team_id}" get one team ========================================= + + +async def test_get_team_returns_item(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_team(id=3, name="Gamma", users=[])), # get_item + ) + + response = await client.get(f"{base()}/3") + + assert response.status_code == 200 + assert response.json()["id"] == 3 + + +async def test_get_team_not_in_workspace_404(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) # assert_team_in_workspace -> False + response = await client.get(f"{base()}/999") + assert response.status_code == 404 + + +async def test_get_team_workspace_missing_404(client, login, task_session): + login(member()) + task_session.queue(fakes.empty()) + response = await client.get(f"{base()}/3") + assert response.status_code == 404 + + +# === PUT "/{team_id}" update team ========================================== + + +async def test_update_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + assert response.status_code == 403 + + +async def test_update_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_team(id=1, name="Old")), # update -> get(id) + ) + + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_update_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.put(f"{base()}/1", json={"name": "Renamed"}) + assert response.status_code == 404 + + +# === DELETE "/{team_id}" delete team ======================================= + + +async def test_delete_team_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{base()}/1") + assert response.status_code == 403 + + +async def test_delete_team_as_lead(client, login, lead, task_session, osm_session): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(True)) # assert_team_in_workspace; delete follows + response = await client.delete(f"{base()}/1") + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_delete_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.delete(f"{base()}/1") + assert response.status_code == 404 + + +# === GET "/{team_id}/members" list members ================================= + + +async def test_get_members_returns_users(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(factories.make_user(id=1, display_name="Mem")), # get_members + ) + + response = await client.get(f"{base()}/1/members") + + assert response.status_code == 200 + assert response.json()[0]["display_name"] == "Mem" + + +async def test_get_members_team_not_in_workspace_404( client, login, task_session, osm_session ): - login() - task_session.queue(fakes.rows(factories.make_workspace(id=1))) - # assert_team_in_workspace -> session.scalar(EXISTS) returns False + login(member()) + ws_ok(task_session) osm_session.queue(fakes.scalar(False)) + response = await client.get(f"{base()}/1/members") + assert response.status_code == 404 + + +# === POST "/{team_id}/members" join team =================================== + + +async def test_join_team_adds_current_user(client, login, task_session, osm_session): + login(member()) + joining = factories.make_user(id=7, display_name="Joiner") + joining_again = factories.make_user(id=7, display_name="Joiner") + joining_again.teams = [] # not yet on the team + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(joining), # get_current_user (scalar_one) + fakes.rows(joining_again), # add_member: load user w/ teams + fakes.rows(factories.make_team(id=1, name="T")), # add_member: get(team) + ) + + response = await client.post(f"{base()}/1/members") + + assert response.status_code == 200 + assert response.json()["id"] == 7 + assert osm_session.commits == 1 + + +async def test_join_team_not_in_workspace_404(client, login, task_session, osm_session): + login(member()) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.post(f"{base()}/1/members") + assert response.status_code == 404 + + +# === PUT "/{team_id}/members/{user_id}" add member ========================= + + +async def test_add_member_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 403 + + +async def test_add_member_as_lead(client, login, lead, task_session, osm_session): + login(lead) + target = factories.make_user(id=5) + target.teams = [] + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(target), # add_member: load user + fakes.rows(factories.make_team(id=1, name="T")), # add_member: get(team) + ) - response = await client.get(f"{teams_url()}/999") + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_add_member_missing_user_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.empty(), # add_member: user not found + ) + response = await client.put(f"{base()}/1/members/5") assert response.status_code == 404 -async def test_list_teams_for_inaccessible_workspace_is_404( - client, login, task_session +async def test_add_member_team_not_in_workspace_404( + client, login, lead, task_session, osm_session ): - login() - task_session.queue(fakes.empty()) # getById guard finds no workspace + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.put(f"{base()}/1/members/5") + assert response.status_code == 404 - response = await client.get(teams_url(42)) +# === DELETE "/{team_id}/members/{user_id}" remove member =================== + + +async def test_remove_member_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 403 + + +async def test_remove_member_as_lead(client, login, lead, task_session, osm_session): + login(lead) + team = factories.make_team(id=1, name="T") + target = factories.make_user(id=5) + target.teams = [team] # currently a member + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.rows(target), # remove_member: load user + fakes.rows(team), # remove_member: get(team) + ) + + response = await client.delete(f"{base()}/1/members/5") + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_remove_member_missing_user_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue( + fakes.scalar(True), # assert_team_in_workspace + fakes.empty(), # remove_member: user not found + ) + response = await client.delete(f"{base()}/1/members/5") + assert response.status_code == 404 + + +async def test_remove_member_team_not_in_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + ws_ok(task_session) + osm_session.queue(fakes.scalar(False)) + response = await client.delete(f"{base()}/1/members/5") assert response.status_code == 404 diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 0000000..ed083cb --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,186 @@ +"""Integration tests for the /workspaces/{id}/users routes. + +Covers the @test comments in api/src/users/routes.py: +- listing members requires contributor-or-above (403 otherwise) +- assign/remove role require workspace lead (403 otherwise) +- workspace-missing -> 404, user-missing -> 404 +- unexpected errors -> 500 +- role can be set to lead/validator and unset back to contributor +- POC / contributor cannot be assigned directly (422) +- the affected user's cache is evicted after a change +""" + +from uuid import UUID + +import pytest + +import api.src.users.routes as users_routes +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +TARGET_USER = "33333333-3333-3333-3333-333333333333" + + +def url(workspace_id=1): + return f"/api/v1/workspaces/{workspace_id}/users" + + +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +@pytest.fixture +def evictions(monkeypatch): + """Record evict_user_from_cache(...) calls made by the routes.""" + calls = [] + monkeypatch.setattr(users_routes, "evict_user_from_cache", calls.append) + return calls + + +# --- GET members ----------------------------------------------------------- + + +async def test_list_members_requires_membership(client, login): + login(factories.make_user_info(accessible_workspace_ids={})) + response = await client.get(url()) + assert response.status_code == 403 + + +async def test_list_members_returns_privileged_users(client, login, osm_session): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + user = factories.make_user(id=5, auth_uid=TARGET_USER, display_name="Lead Lou") + # repo joins User + role -> rows of (user, role) tuples + osm_session.queue(fakes.rows((user, "lead"))) + + response = await client.get(url()) + + assert response.status_code == 200 + body = response.json() + assert body == [ + {"id": 5, "auth_uid": TARGET_USER, "display_name": "Lead Lou", "role": "lead"} + ] + + +async def test_list_members_unexpected_error_returns_500( + error_client, login, osm_session +): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + osm_session.queue(fakes.raises(RuntimeError("db exploded"))) + + response = await error_client.get(url()) + assert response.status_code == 500 + + +# --- PUT assign role ------------------------------------------------------- + + +async def test_assign_role_requires_lead(client, login): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 403 + + +async def test_assign_role_workspace_missing_returns_404( + client, login, lead, task_session +): + login(lead) + task_session.queue(fakes.empty()) # getById finds nothing + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 404 + + +async def test_assign_role_user_missing_returns_404( + client, login, lead, task_session, osm_session +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(None)) # user has never signed in + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + assert response.status_code == 404 + + +async def test_assign_lead_role_succeeds_and_evicts( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(1)) # user exists + + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "lead"}) + + assert response.status_code == 204 + assert osm_session.commits == 1 + assert evictions == [UUID(TARGET_USER)] + + +async def test_assign_validator_role_succeeds( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.scalar(1)) + + response = await client.put( + f"{url()}/{TARGET_USER}/role", json={"role": "validator"} + ) + assert response.status_code == 204 + + +async def test_assign_contributor_role_rejected(client, login, lead): + login(lead) + # CONTRIBUTOR is implicit and cannot be assigned directly -> 422. + response = await client.put( + f"{url()}/{TARGET_USER}/role", json={"role": "contributor"} + ) + assert response.status_code == 422 + + +async def test_assign_poc_role_rejected(client, login, lead): + login(lead) + # "poc" is a TDEI role, not a workspace role -> validation error. + response = await client.put(f"{url()}/{TARGET_USER}/role", json={"role": "poc"}) + assert response.status_code == 422 + + +# --- DELETE remove role ---------------------------------------------------- + + +async def test_remove_role_requires_lead(client, login): + login(factories.make_user_info(accessible_workspace_ids={"pg": [1]})) + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 403 + + +async def test_remove_role_unsets_to_contributor_and_evicts( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1)) # one role row removed + + response = await client.delete(f"{url()}/{TARGET_USER}") + + assert response.status_code == 204 + assert evictions == [UUID(TARGET_USER)] + + +async def test_remove_role_when_not_assigned_returns_404( + client, login, lead, task_session, osm_session +): + login(lead) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(0)) # nothing to delete + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 404 + + +async def test_remove_role_workspace_missing_returns_404( + client, login, lead, task_session +): + login(lead) + task_session.queue(fakes.empty()) + response = await client.delete(f"{url()}/{TARGET_USER}") + assert response.status_code == 404 diff --git a/tests/integration/test_workspaces.py b/tests/integration/test_workspaces.py index 663e12a..fbd30cb 100644 --- a/tests/integration/test_workspaces.py +++ b/tests/integration/test_workspaces.py @@ -1,23 +1,63 @@ """Integration tests for the /workspaces routes. -Each test drives a real HTTP request through the real route + repository -code, queueing simulated rows on the fake DB sessions. Comments note the -query sequence each route issues (= the order to queue results). +Covers the @test comments in api/src/workspaces/routes.py across all +endpoints (list, get, bbox, create, update, delete, quest get/settings, +imagery get/settings). Each test drives a real HTTP request through the +real route + repository code, queueing simulated rows on the fake sessions. + +Note: read endpoints (get, bbox, quest, imagery) gate access via +``getById``, which raises 404 when the workspace is missing or inaccessible +-- so "no access" surfaces as 404 there rather than 403. The mutating +endpoints additionally require ``isWorkspaceLead`` and return 403 otherwise. """ +from datetime import datetime +from uuid import UUID + +import pytest + +import api.src.workspaces.routes as ws_routes from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.schemas import QuestDefinitionType, WorkspaceLongQuest from tests.support import factories, fakes API = "/api/v1/workspaces" -async def test_get_my_workspaces(client, login, task_session): +@pytest.fixture +def lead(): + return factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + + +@pytest.fixture +def no_schema_validation(monkeypatch): + """Make schema validation a no-op (avoid network in update endpoints).""" + + async def ok(_definition): + return None + + monkeypatch.setattr(ws_routes, "validate_quest_definition_schema", ok) + monkeypatch.setattr(ws_routes, "validate_imagery_definition_schema", ok) + + +@pytest.fixture +def evictions(monkeypatch): + calls = [] + monkeypatch.setattr(ws_routes, "evict_user_from_cache", calls.append) + return calls + + +# === GET /mine ============================================================= + + +async def test_list_my_workspaces(client, login, task_session): login() - # getAll -> one SELECT on the task DB task_session.queue( fakes.rows( - factories.make_workspace(id=1, title="WS One"), - factories.make_workspace(id=2, title="WS Two"), + factories.make_workspace(id=1, title="One"), + factories.make_workspace(id=2, title="Two"), ) ) @@ -26,105 +66,429 @@ async def test_get_my_workspaces(client, login, task_session): assert response.status_code == 200 body = response.json() assert [w["id"] for w in body] == [1, 2] - assert body[0]["title"] == "WS One" - assert body[0]["role"] == "contributor" # WorkspaceResponse includes role + assert body[0]["role"] == "contributor" + + +async def test_list_my_workspaces_empty(client, login, task_session): + login() + task_session.queue(fakes.rows()) + response = await client.get(f"{API}/mine") + assert response.status_code == 200 + assert response.json() == [] + + +async def test_list_my_workspaces_unexpected_error_500( + error_client, login, task_session +): + login() + task_session.queue(fakes.raises(RuntimeError("db"))) + response = await error_client.get(f"{API}/mine") + assert response.status_code == 500 + + +async def test_list_matches_get_by_id(client, login, task_session): + # The same workspace serialized via /mine and via /{id} agree on shared fields. + login( + factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) + ) + task_session.queue( + fakes.rows(factories.make_workspace(id=1, title="Shared")), + fakes.rows(factories.make_workspace(id=1, title="Shared")), + ) + + listed = (await client.get(f"{API}/mine")).json()[0] + fetched = (await client.get(f"{API}/1")).json() + + shared = ["id", "title", "type", "tdeiProjectGroupId", "role"] + assert {k: listed[k] for k in shared} == {k: fetched[k] for k in shared} + + +# === GET /{id} ============================================================= async def test_get_workspace_by_id(client, login, task_session): login() - # getById -> one SELECT task_session.queue(fakes.rows(factories.make_workspace(id=7, title="Lucky"))) - response = await client.get(f"{API}/7") - assert response.status_code == 200 assert response.json()["title"] == "Lucky" -async def test_get_workspace_not_found(client, login, task_session): +async def test_get_workspace_missing_404(client, login, task_session): login() - task_session.queue(fakes.empty()) # getById finds nothing - + task_session.queue(fakes.empty()) response = await client.get(f"{API}/404") + assert response.status_code == 404 + +async def test_get_workspace_non_integer_id_422(client, login): + login() + response = await client.get(f"{API}/not-a-number") + assert response.status_code == 422 + + +async def test_get_workspace_unexpected_error_500(error_client, login, task_session): + login() + task_session.queue(fakes.raises(RuntimeError("db"))) + response = await error_client.get(f"{API}/7") + assert response.status_code == 500 + + +# === GET /{id}/bbox ======================================================== + + +async def test_get_bbox_returns_values(client, login, task_session, osm_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue( + fakes.mappings({"max_lat": 1.5, "max_lon": 2.5, "min_lat": 0.5, "min_lon": 1.5}) + ) + + response = await client.get(f"{API}/1/bbox") + + assert response.status_code == 200 + assert response.json()["max_lat"] == 1.5 + + +async def test_get_bbox_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/bbox") + assert response.status_code == 404 + + +async def test_get_bbox_no_nodes_404(client, login, task_session, osm_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.mappings()) # no rows -> bbox None -> NotFound + response = await client.get(f"{API}/1/bbox") assert response.status_code == 404 -async def test_create_workspace(client, login, task_session, osm_session): +# === POST "" create ======================================================== + + +async def test_create_workspace(client, login, task_session, osm_session, evictions): login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) - # create (task DB) -> add + commit + refresh; then assign_member_role - # (OSM DB) checks the user exists via session.scalar(...). - osm_session.queue(fakes.scalar(1)) + osm_session.queue(fakes.scalar(1)) # assign_member_role: user exists response = await client.post( f"{API}", json={ "type": "osw", - "title": "Fresh Workspace", + "title": "Fresh", "tdeiProjectGroupId": factories.DEFAULT_PG_ID, }, ) assert response.status_code == 201 assert response.json()["workspaceId"] is not None - assert task_session.commits == 1 - assert osm_session.commits == 1 + assert task_session.commits == 1 # workspace insert + assert osm_session.commits == 1 # role insert + assert evictions == [UUID(factories.DEFAULT_USER_ID)] # creator's cache evicted + + +async def test_create_in_unauthorized_group_403(client, login): + # User belongs to DEFAULT_PG_ID only; creating in another group is forbidden. + login(factories.make_user_info(project_group_ids=[factories.DEFAULT_PG_ID])) + response = await client.post( + f"{API}", + json={ + "type": "osw", + "title": "Nope", + "tdeiProjectGroupId": "99999999-9999-9999-9999-999999999999", + }, + ) + assert response.status_code == 403 + + +async def test_create_invalid_body_422(client, login): + login() + # Missing required 'title' and 'tdeiProjectGroupId'. + response = await client.post(f"{API}", json={"type": "osw"}) + assert response.status_code == 422 + + +# === PATCH /{id} update ==================================================== -async def test_update_workspace_requires_lead(client, login, task_session): - # Plain contributor, not a lead -> 403 before any DB write. +async def test_update_requires_lead(client, login): login(factories.make_user_info()) + response = await client.patch(f"{API}/1", json={"title": "X"}) + assert response.status_code == 403 + + +async def test_update_empty_patch_400(client, login, lead): + login(lead) + response = await client.patch(f"{API}/1", json={}) + assert response.status_code == 400 + +async def test_update_success(client, login, lead, task_session): + login(lead) + task_session.queue( + fakes.affected(1), # UPDATE + fakes.rows(factories.make_workspace(id=1, title="Renamed")), # getById + ) response = await client.patch(f"{API}/1", json={"title": "Renamed"}) + assert response.status_code == 204 + + +async def test_update_missing_workspace_404(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.affected(0)) # UPDATE matched nothing -> NotFound + response = await client.patch(f"{API}/1", json={"title": "X"}) + assert response.status_code == 404 + +async def test_update_invalid_body_422(client, login, lead): + login(lead) + response = await client.patch(f"{API}/1", json={"externalAppAccess": 999}) + assert response.status_code == 422 + + +# === DELETE /{id} ========================================================== + + +async def test_delete_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.delete(f"{API}/1") assert response.status_code == 403 - assert task_session.commits == 0 -async def test_update_workspace_as_lead(client, login, task_session): - login( - factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) +async def test_delete_success_no_members( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + osm_session.queue(fakes.rows()) # get_privileged_workspace_members: none + task_session.queue(fakes.affected(1)) # delete + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert task_session.commits == 1 + assert evictions == [] # no members to evict + + +async def test_delete_evicts_member_caches( + client, login, lead, task_session, osm_session, evictions +): + login(lead) + member = factories.make_user(id=9, auth_uid=factories.DEFAULT_USER_ID) + osm_session.queue(fakes.rows((member, "lead"))) # privileged members + task_session.queue(fakes.affected(1)) + + response = await client.delete(f"{API}/1") + + assert response.status_code == 204 + assert evictions == [UUID(factories.DEFAULT_USER_ID)] + + +async def test_delete_missing_workspace_404( + client, login, lead, task_session, osm_session +): + login(lead) + osm_session.queue(fakes.rows()) # no members + task_session.queue(fakes.affected(0)) # delete matched nothing -> NotFound + response = await client.delete(f"{API}/1") + assert response.status_code == 404 + + +# === GET /{id}/quests/long ================================================= + + +async def test_get_long_quest_def_none_returns_204(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) # no quest def + response = await client.get(f"{API}/1/quests/long") + assert response.status_code == 204 + + +async def test_get_long_quest_def_json(client, login, task_session): + login() + ws = factories.make_workspace(id=1) + ws.longFormQuestDef = WorkspaceLongQuest( + workspace_id=1, + type=QuestDefinitionType.JSON, + definition='{"quest": true}', + modifiedAt=datetime(2026, 1, 1), + modifiedBy=UUID(factories.DEFAULT_USER_ID), + modifiedByName="x", ) - # update -> UPDATE (rowcount) ... commit ... then getById -> SELECT - task_session.queue( - fakes.affected(1), - fakes.rows(factories.make_workspace(id=1, title="Renamed")), + task_session.queue(fakes.rows(ws)) + + response = await client.get(f"{API}/1/quests/long") + + assert response.status_code == 200 + assert response.json() == {"quest": True} + + +async def test_get_long_quest_def_workspace_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/quests/long") + assert response.status_code == 404 + + +# === GET /{id}/quests/long/settings ======================================== + + +async def test_get_long_quest_settings_default_when_unset(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + response = await client.get(f"{API}/1/quests/long/settings") + assert response.status_code == 200 + body = response.json() + assert body["type"] == "NONE" + assert body["workspace_id"] == 1 + + +async def test_get_long_quest_settings_from_def(client, login, task_session): + login() + ws = factories.make_workspace(id=1) + ws.longFormQuestDef = WorkspaceLongQuest( + workspace_id=1, + type=QuestDefinitionType.URL, + url="https://q.example", + modifiedAt=datetime(2026, 1, 1), + modifiedBy=UUID(factories.DEFAULT_USER_ID), + modifiedByName="Editor", ) + task_session.queue(fakes.rows(ws)) - response = await client.patch(f"{API}/1", json={"title": "Renamed"}) + response = await client.get(f"{API}/1/quests/long/settings") + + assert response.status_code == 200 + body = response.json() + assert body["type"] == "URL" + assert body["url"] == "https://q.example" + + +async def test_get_long_quest_settings_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/quests/long/settings") + assert response.status_code == 404 + + +# === PATCH /{id}/quests/long/settings ====================================== + +async def test_update_quest_settings_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "NONE"} + ) + assert response.status_code == 403 + + +async def test_update_quest_settings_none_success(client, login, lead, task_session): + login(lead) + task_session.queue(fakes.affected(1)) # save_longform_quest UPDATE + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "NONE"} + ) assert response.status_code == 204 - assert task_session.commits == 1 -async def test_update_workspace_rejects_empty_patch(client, login): - login( - factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) +async def test_update_quest_settings_json_validates_schema( + client, login, lead, task_session, no_schema_validation +): + login(lead) + task_session.queue(fakes.affected(1)) + response = await client.patch( + f"{API}/1/quests/long/settings", + json={"type": "JSON", "definition": '{"a": 1}'}, ) + assert response.status_code == 204 - response = await client.patch(f"{API}/1", json={}) +async def test_update_quest_settings_invalid_schema_400( + client, login, lead, monkeypatch +): + from fastapi import HTTPException + + async def reject(_definition): + raise HTTPException(status_code=400, detail="bad schema") + + monkeypatch.setattr(ws_routes, "validate_quest_definition_schema", reject) + login(lead) + + response = await client.patch( + f"{API}/1/quests/long/settings", + json={"type": "JSON", "definition": '{"a": 1}'}, + ) assert response.status_code == 400 -async def test_delete_workspace_as_lead(client, login, task_session, osm_session): - login( - factories.make_user_info(osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]}) +async def test_update_quest_settings_json_without_definition_422(client, login, lead): + login(lead) + # QuestSettingsPatch validator rejects JSON type with no definition. + response = await client.patch( + f"{API}/1/quests/long/settings", json={"type": "JSON"} ) - # delete flow: get_privileged_workspace_members (OSM) -> delete (task) -> - # remove_all_member_roles (OSM) - osm_session.queue(fakes.rows()) # no privileged members - task_session.queue(fakes.affected(1)) + assert response.status_code == 422 - response = await client.delete(f"{API}/1") - assert response.status_code == 204 - assert task_session.commits == 1 +# === GET /{id}/imagery/settings ============================================ -async def test_delete_workspace_forbidden_for_non_lead(client, login): - login(factories.make_user_info()) +async def test_get_imagery_settings_default_when_unset(client, login, task_session): + login() + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + response = await client.get(f"{API}/1/imagery/settings") + assert response.status_code == 200 + assert response.json()["definition"] == [] + + +async def test_get_imagery_settings_missing_404(client, login, task_session): + login() + task_session.queue(fakes.empty()) + response = await client.get(f"{API}/1/imagery/settings") + assert response.status_code == 404 - response = await client.delete(f"{API}/1") +# === PATCH /{id}/imagery/settings ========================================== + + +async def test_update_imagery_requires_lead(client, login): + login(factories.make_user_info()) + response = await client.patch(f"{API}/1/imagery/settings", json={"definition": []}) assert response.status_code == 403 + + +async def test_update_imagery_success( + client, login, lead, task_session, no_schema_validation +): + login(lead) + task_session.queue(fakes.affected(1)) # save_imagery_def UPDATE + response = await client.patch( + f"{API}/1/imagery/settings", + json={"definition": [{"name": "layer", "url": "https://x"}]}, + ) + assert response.status_code == 204 + + +async def test_update_imagery_invalid_schema_400(client, login, lead, monkeypatch): + from fastapi import HTTPException + + async def reject(_definition): + raise HTTPException(status_code=400, detail="bad imagery") + + monkeypatch.setattr(ws_routes, "validate_imagery_definition_schema", reject) + login(lead) + + response = await client.patch( + f"{API}/1/imagery/settings", json={"definition": [{"bad": True}]} + ) + assert response.status_code == 400 + + +async def test_update_imagery_empty_skips_validation_success( + client, login, lead, task_session +): + # Empty definition is falsy -> validation skipped, save proceeds. + login(lead) + task_session.queue(fakes.affected(1)) + response = await client.patch(f"{API}/1/imagery/settings", json={"definition": []}) + assert response.status_code == 204 diff --git a/tests/support/fakes.py b/tests/support/fakes.py index a989fd1..61fcdd0 100644 --- a/tests/support/fakes.py +++ b/tests/support/fakes.py @@ -124,16 +124,23 @@ def _is_session_setup(statement): except Exception: return False + @staticmethod + def _raise_if_exc(item): + # A queued exception simulates a DB-layer failure (drives 500 paths). + if isinstance(item, BaseException): + raise item + return item + async def execute(self, statement, *args, **kwargs): if self._is_session_setup(statement): return FakeResult(rows=[]) - return self._next() + return self._raise_if_exc(self._next()) async def exec(self, statement, *args, **kwargs): - return self._next() + return self._raise_if_exc(self._next()) async def scalar(self, statement, *args, **kwargs): - result = self._next() + result = self._raise_if_exc(self._next()) return result.value if isinstance(result, FakeResult) else result def add(self, obj): @@ -183,3 +190,8 @@ def mappings(*dict_rows): def scalar(value): """A result for ``session.scalar(...)`` (e.g. an EXISTS check).""" return FakeResult(value=value) + + +def raises(exc): + """Queue an exception so the next DB call raises it (drives 500 paths).""" + return exc diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..3a06d3b --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,59 @@ +"""Tests for api/core/config.py Settings. + +Covers the @test comments on the Settings class: +- env vars of the same name override the members +- unset env vars fall back to declared defaults +- strings / numbers / empty strings / URLs all load as the defaults exemplify +""" + +from api.core.config import Settings + + +def test_defaults_loaded_when_env_unset(): + # _env_file=None ignores any local .env so we observe the declared defaults. + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.PROJECT_NAME == "Workspaces API" + assert s.CORS_ORIGINS == [] + assert s.DEBUG is False + assert s.SENTRY_DSN == "" + assert s.WS_OSM_HOST == "http://osm-web" + assert s.TDEI_OIDC_REALM == "tdei" + assert s.TASK_DATABASE_URL.startswith("postgresql+asyncpg://") + assert s.OSM_DATABASE_URL.startswith("postgresql+asyncpg://") + + +def test_env_vars_override_members(monkeypatch): + monkeypatch.setenv("PROJECT_NAME", "Custom Name") + monkeypatch.setenv("DEBUG", "true") + monkeypatch.setenv("WS_OSM_HOST", "http://osm.example") + monkeypatch.setenv("SENTRY_DSN", "https://sentry.example/123") + + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.PROJECT_NAME == "Custom Name" + assert s.DEBUG is True + assert s.WS_OSM_HOST == "http://osm.example" + assert s.SENTRY_DSN == "https://sentry.example/123" + + +def test_cors_origins_parsed_from_json_env(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["https://a.example", "https://b.example"]') + + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert s.CORS_ORIGINS == ["https://a.example", "https://b.example"] + + +def test_value_types_and_formats(): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + + assert isinstance(s.PROJECT_NAME, str) + assert isinstance(s.CORS_ORIGINS, list) + assert isinstance(s.DEBUG, bool) + # empty-string default is preserved (not coerced to None): + assert s.SENTRY_DSN == "" + # URL-shaped defaults load verbatim: + assert s.TDEI_BACKEND_URL.startswith("https://") + assert s.LONGFORM_SCHEMA_URL.startswith("https://") + assert s.IMAGERY_SCHEMA_URL.startswith("https://") diff --git a/tests/unit/test_json_schema.py b/tests/unit/test_json_schema.py new file mode 100644 index 0000000..3b83f87 --- /dev/null +++ b/tests/unit/test_json_schema.py @@ -0,0 +1,137 @@ +"""Tests for api/core/json_schema.py. + +Covers the @test comments: +- payloads are validated against the fetched JSON schema +- malformed / non-object payloads return a 400 (not a generic Exception) +- network failures map to a proper 502/504 and never produce a false-positive + (i.e. a fetch failure must raise, never silently "pass" validation) +""" + +from types import SimpleNamespace + +import httpx +import pytest +from fastapi import HTTPException + +import api.core.json_schema as js + +# --- validate_quest_definition_schema ------------------------------------- + + +async def test_quest_valid_definition_passes(monkeypatch): + async def fake_schema(): + return {"type": "object", "required": ["x"]} + + monkeypatch.setattr(js, "_fetch_longform_schema", fake_schema) + + # Should not raise. + await js.validate_quest_definition_schema('{"x": 1}') + + +async def test_quest_schema_violation_returns_400(monkeypatch): + async def fake_schema(): + return {"type": "object", "required": ["x"]} + + monkeypatch.setattr(js, "_fetch_longform_schema", fake_schema) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"y": 1}') + assert exc.value.status_code == 400 + + +async def test_quest_malformed_json_returns_400(): + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema("{not valid json") + assert exc.value.status_code == 400 + + +async def test_quest_non_object_returns_400(): + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema("[1, 2, 3]") + assert exc.value.status_code == 400 + + +async def test_quest_fetch_timeout_maps_to_504(monkeypatch): + async def boom(): + raise httpx.TimeoutException("slow") + + monkeypatch.setattr(js, "_fetch_longform_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"x": 1}') + assert exc.value.status_code == 504 + + +async def test_quest_fetch_connect_error_maps_to_502(monkeypatch): + async def boom(): + raise httpx.ConnectError("down") + + monkeypatch.setattr(js, "_fetch_longform_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_quest_definition_schema('{"x": 1}') + assert exc.value.status_code == 502 + + +# --- validate_imagery_definition_schema ----------------------------------- + + +async def test_imagery_valid_passes(monkeypatch): + async def fake_schema(): + return {"type": "array", "items": {"type": "string"}} + + monkeypatch.setattr(js, "_fetch_imagery_schema", fake_schema) + + await js.validate_imagery_definition_schema(["a", "b"]) + + +async def test_imagery_violation_returns_400(monkeypatch): + async def fake_schema(): + return {"type": "array", "items": {"type": "string"}} + + monkeypatch.setattr(js, "_fetch_imagery_schema", fake_schema) + + with pytest.raises(HTTPException) as exc: + await js.validate_imagery_definition_schema([1, 2]) + assert exc.value.status_code == 400 + + +async def test_imagery_fetch_error_maps_to_502(monkeypatch): + async def boom(): + raise httpx.ConnectError("down") + + monkeypatch.setattr(js, "_fetch_imagery_schema", boom) + + with pytest.raises(HTTPException) as exc: + await js.validate_imagery_definition_schema([]) + assert exc.value.status_code == 502 + + +# --- client + caching ------------------------------------------------------ + + +def test_require_http_client_raises_503_when_uninitialized(monkeypatch): + monkeypatch.setattr(js, "_http_client", None) + with pytest.raises(HTTPException) as exc: + js._require_http_client() + assert exc.value.status_code == 503 + + +async def test_fetch_longform_schema_caches_result(monkeypatch): + monkeypatch.setattr(js, "_longform_schema", None) + calls = {"n": 0} + + class FakeClient: + async def get(self, url): + calls["n"] += 1 + return SimpleNamespace( + raise_for_status=lambda: None, json=lambda: {"fetched": True} + ) + + monkeypatch.setattr(js, "_http_client", FakeClient()) + + first = await js._fetch_longform_schema() + second = await js._fetch_longform_schema() + + assert first == second == {"fetched": True} + assert calls["n"] == 1 # second call served from cache diff --git a/tests/unit/test_jwt.py b/tests/unit/test_jwt.py new file mode 100644 index 0000000..2db88b3 --- /dev/null +++ b/tests/unit/test_jwt.py @@ -0,0 +1,71 @@ +"""Tests for api/core/jwt.py token validation. + +The @test comments are boilerplate; the module's real job is to validate a +JWT's RS256 signature against the JWKS and decode its payload. We cover: +- a properly-signed token validates and its payload is returned +- a malformed token raises (rather than returning a payload) +- a token signed by the wrong key raises (no false-positive validation) +- JWKS/network failures propagate as a typed error (not a bare Exception) +""" + +from types import SimpleNamespace + +import jwt as pyjwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.exceptions import PyJWKClientError, PyJWTError + +import api.core.jwt as jwtmod + + +@pytest.fixture +def rsa_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _fake_jwks(monkeypatch, *, public_key=None, exc=None): + class FakeJWKSClient: + def get_signing_key_from_jwt(self, token): + if exc is not None: + raise exc + return SimpleNamespace(key=public_key) + + monkeypatch.setattr(jwtmod, "_get_jwks_client", lambda: FakeJWKSClient()) + + +def test_valid_token_decodes_payload(monkeypatch, rsa_key): + token = pyjwt.encode( + {"sub": "user-abc", "jti": "j1", "preferred_username": "alice"}, + rsa_key, + algorithm="RS256", + ) + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + payload = jwtmod.validate_and_decode_token(token) + + assert payload["sub"] == "user-abc" + assert payload["preferred_username"] == "alice" + + +def test_malformed_token_raises(monkeypatch, rsa_key): + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + with pytest.raises(PyJWTError): + jwtmod.validate_and_decode_token("not-a-real-jwt") + + +def test_token_signed_with_wrong_key_raises(monkeypatch, rsa_key): + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + token = pyjwt.encode({"sub": "user-abc"}, other_key, algorithm="RS256") + # JWKS returns the *wrong* public key -> signature check must fail. + _fake_jwks(monkeypatch, public_key=rsa_key.public_key()) + + with pytest.raises(PyJWTError): + jwtmod.validate_and_decode_token(token) + + +def test_jwks_network_error_propagates_typed(monkeypatch): + _fake_jwks(monkeypatch, exc=PyJWKClientError("could not fetch JWKS")) + + with pytest.raises(PyJWKClientError): + jwtmod.validate_and_decode_token("a.b.c") diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py new file mode 100644 index 0000000..1c6ae61 --- /dev/null +++ b/tests/unit/test_security.py @@ -0,0 +1,334 @@ +"""Tests for api/core/security.py. + +Covers the @test comments on the module / UserInfo / validate_token: +- the permission structure matches CLAUDE.md +- UserInfo methods return correct values for given PG/workspace roles +- attributes are populated correctly from JWT + TDEI + DB data +- network failures are handled gracefully (typed HTTP errors, no false success) +- the user-info cache works and evicts on token rotation / explicit eviction +""" + +from typing import cast +from uuid import UUID + +import httpx +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as sec +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +USER_ID = "22222222-2222-2222-2222-222222222222" + + +@pytest.fixture(autouse=True) +def clear_cache(): + sec._user_info_cache.clear() + yield + sec._user_info_cache.clear() + + +def _creds(token="tok"): + return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + + +class _FakeResp: + def __init__(self, status_code=200, data=None, raises=False): + self.status_code = status_code + self._data = data + self._raises = raises + + def json(self): + if self._raises: + raise ValueError("bad json") + return self._data + + +class _FakeTdeiClient: + def __init__(self, resp=None, exc=None): + self._resp = resp + self._exc = exc + + async def get(self, *args, **kwargs): + if self._exc is not None: + raise self._exc + return self._resp + + +# --- CLAUDE.md permission structure --------------------------------------- + + +def test_permission_structure_matches_claude_md(): + # POC ("Project Group Admin") -> lead on workspaces the group owns. + poc = factories.make_user_info( + project_group_ids=["pg"], + poc_group_ids=("pg",), + accessible_workspace_ids={"pg": [1]}, + ) + assert poc.isWorkspaceLead(1) is True + + # Lead granted via Workspaces setting (an OSM-DB role). + lead = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + assert lead.effective_role(1) == WorkspaceUserRoleType.LEAD + + # Validator granted via Workspaces setting. + validator = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.VALIDATOR]} + ) + assert validator.effective_role(1) == WorkspaceUserRoleType.VALIDATOR + + # Contributor implied by project-group membership. + contributor = factories.make_user_info(accessible_workspace_ids={"pg": [1]}) + assert contributor.isWorkspaceContributor(1) is True + assert contributor.effective_role(1) == WorkspaceUserRoleType.CONTRIBUTOR + + +def test_effective_role_precedence_lead_over_validator(): + user = factories.make_user_info( + osm_workspace_roles={ + 1: [WorkspaceUserRoleType.VALIDATOR, WorkspaceUserRoleType.LEAD] + } + ) + assert user.effective_role(1) == WorkspaceUserRoleType.LEAD + + +def test_tdei_role_enum_values(): + assert sec.TdeiProjectGroupRole.POINT_OF_CONTACT == "poc" + assert sec.TdeiProjectGroupRole.MEMBER == "member" + + +# --- validate_token: success + attribute population ------------------------ + + +async def _run_validate(monkeypatch, *, payload, tdei, task, osm): + monkeypatch.setattr(sec, "validate_and_decode_token", lambda _t: payload) + monkeypatch.setattr(sec, "_tdei_client", tdei) + return await sec.validate_token( + _creds(), + cast(AsyncSession, osm), + cast(AsyncSession, task), + ) + + +async def test_validate_token_populates_attributes(monkeypatch): + pg_id = "pg-1" + tdei = _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG One", + "roles": ["poc"], + } + ], + ) + ) + task = fakes.FakeSession(fakes.mappings({"tdeiProjectGroupId": pg_id, "id": 5})) + osm = fakes.FakeSession(fakes.mappings({"workspace_id": 5, "role": "lead"})) + + info = await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1", "preferred_username": "alice"}, + tdei=tdei, + task=task, + osm=osm, + ) + + assert info.user_uuid == UUID(USER_ID) + assert info.user_name == "alice" + assert info.getProjectGroupIds() == [pg_id] + assert info.accessibleWorkspaceIds == {pg_id: [5]} + assert info.osmWorkspaceRoles == {5: ["lead"]} + assert info.isWorkspaceLead(5) is True + + +# --- validate_token: graceful failure handling ----------------------------- + + +async def test_malformed_token_returns_401(monkeypatch): + def boom(_t): + raise ValueError("bad token") + + monkeypatch.setattr(sec, "validate_and_decode_token", boom) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_missing_sub_returns_401(monkeypatch): + monkeypatch.setattr(sec, "validate_and_decode_token", lambda _t: {"jti": "j"}) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_invalid_uuid_sub_returns_401(monkeypatch): + monkeypatch.setattr( + sec, "validate_and_decode_token", lambda _t: {"sub": "not-a-uuid"} + ) + with pytest.raises(HTTPException) as exc: + await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert exc.value.status_code == 401 + + +async def test_tdei_network_error_returns_502(monkeypatch): + tdei = _FakeTdeiClient(exc=httpx.ConnectError("down")) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 502 + + +async def test_tdei_non_200_returns_401(monkeypatch): + tdei = _FakeTdeiClient(_FakeResp(403, None)) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 401 + + +async def test_tdei_bad_json_returns_401(monkeypatch): + tdei = _FakeTdeiClient(_FakeResp(200, raises=True)) + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=tdei, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 401 + + +async def test_uninitialized_tdei_client_returns_503(monkeypatch): + with pytest.raises(HTTPException) as exc: + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j"}, + tdei=None, + task=fakes.FakeSession(), + osm=fakes.FakeSession(), + ) + assert exc.value.status_code == 503 + + +# --- caching --------------------------------------------------------------- + + +async def test_cache_hit_skips_refetch(monkeypatch): + pg_id = "pg-1" + + def fresh_tdei(): + return _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG", + "roles": ["member"], + } + ], + ) + ) + + # First call populates the cache. + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1"}, + tdei=fresh_tdei(), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + + # Second call with the same jti must NOT hit TDEI again: a raising client + # proves the cached entry is returned without a refetch. + monkeypatch.setattr( + sec, "_tdei_client", _FakeTdeiClient(exc=httpx.ConnectError("x")) + ) + monkeypatch.setattr( + sec, "validate_and_decode_token", lambda _t: {"sub": USER_ID, "jti": "j1"} + ) + info = await sec.validate_token( + _creds(), + cast(AsyncSession, fakes.FakeSession()), + cast(AsyncSession, fakes.FakeSession()), + ) + assert info.user_uuid == UUID(USER_ID) + + +async def test_cache_evicts_on_token_rotation(monkeypatch): + pg_id = "pg-1" + + def tdei_with_role(role): + return _FakeTdeiClient( + _FakeResp( + 200, + [ + { + "tdei_project_group_id": pg_id, + "project_group_name": "PG", + "roles": [role], + } + ], + ) + ) + + await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j1"}, + tdei=tdei_with_role("member"), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + + # New jti -> stale entry evicted, fresh fetch performed. + info = await _run_validate( + monkeypatch, + payload={"sub": USER_ID, "jti": "j2"}, + tdei=tdei_with_role("poc"), + task=fakes.FakeSession(fakes.mappings()), + osm=fakes.FakeSession(fakes.mappings()), + ) + assert sec.TdeiProjectGroupRole.POINT_OF_CONTACT in info.projectGroups[0].tdeiRoles + + +def test_evict_user_from_cache_removes_entry(): + uid = UUID(USER_ID) + sentinel = factories.make_user_info(user_id=USER_ID) + sec._user_info_cache[uid] = sentinel + assert uid in sec._user_info_cache + + sec.evict_user_from_cache(uid) + assert uid not in sec._user_info_cache + + # Evicting an absent key is a no-op (no KeyError). + sec.evict_user_from_cache(uid) diff --git a/tests/unit/test_teams_schemas.py b/tests/unit/test_teams_schemas.py new file mode 100644 index 0000000..7a02421 --- /dev/null +++ b/tests/unit/test_teams_schemas.py @@ -0,0 +1,69 @@ +"""Schema tests for api/src/teams/schemas.py. + +Covers the @test comments on the table models: +- table name / columns match the DB schema +- foreign keys and relationships are correctly defined +- values serialize/deserialize without loss +""" + +import pytest +from pydantic import ValidationError + +from api.src.teams.schemas import ( + WorkspaceTeam, + WorkspaceTeamCreate, + WorkspaceTeamItem, + WorkspaceTeamUser, +) +from tests.support import factories + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +def test_team_user_link_table_schema(): + table = _table(WorkspaceTeamUser) + assert table.name == "team_user" + assert set(table.columns.keys()) == {"team_id", "user_id"} + assert {c.name for c in table.primary_key.columns} == {"team_id", "user_id"} + assert _fk_targets(WorkspaceTeamUser) == {"teams.id", "users.id"} + + +def test_team_table_schema(): + table = _table(WorkspaceTeam) + assert table.name == "teams" + assert {"id", "name", "workspace_id"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + # workspace_id is indexed for lookups by workspace. + assert table.columns["workspace_id"].index is True + + +def test_team_has_users_relationship(): + # The many-to-many to User goes through the team_user link model. + rel = WorkspaceTeam.__sqlmodel_relationships__ + assert "users" in rel + + +def test_team_item_from_team_round_trip(): + user = factories.make_user(id=1) + team = factories.make_team(id=7, name="Alpha", users=[user]) + + item = WorkspaceTeamItem.from_team(team) + + assert item.id == 7 + assert item.name == "Alpha" + assert item.member_count == 1 + # Serializes cleanly to a dict for API responses. + assert item.model_dump() == {"id": 7, "name": "Alpha", "member_count": 1} + + +def test_team_create_requires_nonempty_name(): + assert WorkspaceTeamCreate(name="ok").name == "ok" + with pytest.raises(ValidationError): + WorkspaceTeamCreate(name="") diff --git a/tests/unit/test_users_schemas.py b/tests/unit/test_users_schemas.py new file mode 100644 index 0000000..2db9f84 --- /dev/null +++ b/tests/unit/test_users_schemas.py @@ -0,0 +1,78 @@ +"""Schema tests for api/src/users/schemas.py. + +Covers the @test comments on the User and WorkspaceUserRole table models: +- table name / columns match the DB schema (and the Alembic migration) +- foreign keys and relationships are correctly defined +- role enum values serialize/deserialize without loss +""" + +import pytest +from pydantic import ValidationError + +from api.src.users.schemas import ( + SetRoleRequest, + User, + WorkspaceUserRole, + WorkspaceUserRoleType, +) + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +def test_user_table_schema(): + table = _table(User) + assert table.name == "users" + assert {"id", "auth_uid", "email", "display_name"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + # auth_uid and email are unique + indexed. + assert table.columns["auth_uid"].unique is True + assert table.columns["email"].unique is True + + +def test_user_has_teams_relationship(): + assert "teams" in User.__sqlmodel_relationships__ + + +def test_workspace_user_role_table_matches_alembic(): + # Mirrors alembic_osm migration 9221408912dd: + # user_workspace_roles(user_auth_uid, workspace_id, role enum), + # PK(user_auth_uid, workspace_id), FK user_auth_uid -> users.auth_uid + table = _table(WorkspaceUserRole) + assert table.name == "user_workspace_roles" + assert set(table.columns.keys()) == {"user_auth_uid", "workspace_id", "role"} + assert {c.name for c in table.primary_key.columns} == { + "user_auth_uid", + "workspace_id", + } + assert _fk_targets(WorkspaceUserRole) == {"users.auth_uid"} + + +def test_role_enum_values(): + assert WorkspaceUserRoleType.LEAD == "lead" + assert WorkspaceUserRoleType.VALIDATOR == "validator" + assert WorkspaceUserRoleType.CONTRIBUTOR == "contributor" + + +def test_set_role_request_rejects_contributor(): + # CONTRIBUTOR is implicit and may not be assigned directly. + assert SetRoleRequest(role=WorkspaceUserRoleType.LEAD).role == ( + WorkspaceUserRoleType.LEAD + ) + with pytest.raises(ValidationError): + SetRoleRequest(role=WorkspaceUserRoleType.CONTRIBUTOR) + + +def test_user_serialization_round_trip(): + user = User(id=3, auth_uid="abc", email="u@example.com", display_name="U") + dumped = user.model_dump() + assert dumped["id"] == 3 + assert dumped["auth_uid"] == "abc" + assert dumped["email"] == "u@example.com" + assert dumped["display_name"] == "U" diff --git a/tests/unit/test_workspaces_schemas.py b/tests/unit/test_workspaces_schemas.py new file mode 100644 index 0000000..15f6266 --- /dev/null +++ b/tests/unit/test_workspaces_schemas.py @@ -0,0 +1,202 @@ +"""Schema tests for api/src/workspaces/schemas.py. + +Covers the @test comments on the table models and WorkspaceResponse: +- table names / columns / PKs / FKs match the DB schema +- relationships are correctly defined +- enum TypeDecorators serialize to the DB and back without loss +- UUID / datetime values round-trip without precision loss +- WorkspaceResponse.from_workspace serializes for API responses incl. role +""" + +from datetime import datetime +from uuid import UUID + +import pytest +from pydantic import ValidationError +from sqlalchemy.engine.default import DefaultDialect + +from api.src.users.schemas import WorkspaceUserRoleType +from api.src.workspaces.schemas import ( + ExternalAppsDefinitionType, + IntEnumType, + QuestDefinitionType, + QuestDefinitionTypeName, + QuestSettingsPatch, + StrEnumType, + Workspace, + WorkspaceImagery, + WorkspaceLongQuest, + WorkspaceResponse, + WorkspaceType, +) +from tests.support import factories + +_DIALECT = DefaultDialect() + + +def _table(model): + # __table__ is added by SQLAlchemy at runtime; getattr keeps type-checkers happy. + return getattr(model, "__table__") + + +def _fk_targets(model): + return {fk.target_fullname for fk in _table(model).foreign_keys} + + +# --- table schemas --------------------------------------------------------- + + +def test_workspace_table_schema(): + table = _table(Workspace) + assert table.name == "workspaces" + expected = { + "id", + "type", + "title", + "description", + "tdeiProjectGroupId", + "tdeiRecordId", + "tdeiServiceId", + "tdeiMetadata", + "createdAt", + "createdBy", + "createdByName", + "geometry", + "externalAppAccess", + "kartaViewToken", + } + assert expected <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["id"] + + +def test_workspace_relationships_defined(): + rels = Workspace.__sqlmodel_relationships__ + assert "longFormQuestDef" in rels + assert "imageryListDef" in rels + + +def test_long_quest_table_schema(): + table = _table(WorkspaceLongQuest) + assert table.name == "workspaces_long_quests" + assert {"workspace_id", "type", "definition", "url", "modifiedAt"} <= set( + table.columns.keys() + ) + assert [c.name for c in table.primary_key.columns] == ["workspace_id"] + assert _fk_targets(WorkspaceLongQuest) == {"workspaces.id"} + + +def test_imagery_table_schema(): + table = _table(WorkspaceImagery) + assert table.name == "workspaces_imagery" + assert {"workspace_id", "definition", "modifiedAt"} <= set(table.columns.keys()) + assert [c.name for c in table.primary_key.columns] == ["workspace_id"] + assert _fk_targets(WorkspaceImagery) == {"workspaces.id"} + + +# --- enum TypeDecorator round-trips (Python <-> DB) ------------------------ + + +def test_int_enum_type_round_trip(): + deco = IntEnumType(QuestDefinitionType) + # bind: enum -> int + assert deco.process_bind_param(QuestDefinitionType.JSON, _DIALECT) == 1 + # result: int -> enum + assert deco.process_result_value(1, _DIALECT) == QuestDefinitionType.JSON + # None passes through both directions + assert deco.process_bind_param(None, _DIALECT) is None + assert deco.process_result_value(None, _DIALECT) is None + + +def test_str_enum_type_round_trip(): + deco = StrEnumType(WorkspaceType) + assert deco.process_bind_param(WorkspaceType.OSW, _DIALECT) == "osw" + assert deco.process_result_value("osw", _DIALECT) == WorkspaceType.OSW + assert deco.process_bind_param(None, _DIALECT) is None + assert deco.process_result_value(None, _DIALECT) is None + + +def test_external_apps_enum_round_trip(): + deco = IntEnumType(ExternalAppsDefinitionType) + assert deco.process_bind_param(ExternalAppsDefinitionType.PUBLIC, _DIALECT) == 1 + assert ( + deco.process_result_value(2, _DIALECT) + == ExternalAppsDefinitionType.PROJECT_GROUP + ) + + +# --- value preservation ---------------------------------------------------- + + +def test_workspace_preserves_uuid_and_datetime(): + pg = UUID("11111111-1111-1111-1111-111111111111") + created = datetime(2026, 1, 2, 3, 4, 5) + ws = Workspace( + id=1, + type=WorkspaceType.OSW, + title="T", + tdeiProjectGroupId=pg, + createdBy=pg, + createdByName="N", + createdAt=created, + ) + assert ws.tdeiProjectGroupId == pg + assert ws.createdAt == created # no truncation + assert ws.type == WorkspaceType.OSW + + +# --- WorkspaceResponse serialization -------------------------------------- + + +def test_workspace_response_includes_effective_role(): + user = factories.make_user_info( + osm_workspace_roles={1: [WorkspaceUserRoleType.LEAD]} + ) + ws = factories.make_workspace(id=1, title="Mappy") + + resp = WorkspaceResponse.from_workspace(ws, user) + + assert resp.id == 1 + assert resp.title == "Mappy" + assert resp.role == WorkspaceUserRoleType.LEAD + assert resp.type == WorkspaceType.OSW + + +def test_workspace_response_passes_through_defs(): + user = factories.make_user_info() + ws = factories.make_workspace(id=2) + + resp = WorkspaceResponse.from_workspace( + ws, user, imagery_list_def=[{"a": 1}], long_form_quest_def={"q": 2} + ) + + assert resp.imageryListDef == [{"a": 1}] + assert resp.longFormQuestDef == {"q": 2} + assert resp.role == WorkspaceUserRoleType.CONTRIBUTOR + + +# --- QuestSettingsPatch validation ---------------------------------------- + + +def test_quest_settings_json_requires_object_definition(): + ok = QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition='{"a": 1}') + assert ok.type == QuestDefinitionTypeName.JSON + + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition=None) + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.JSON, definition="not-json") + + +def test_quest_settings_url_requires_url(): + ok = QuestSettingsPatch(type=QuestDefinitionTypeName.URL, url="https://x") + assert ok.url == "https://x" + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.URL, url=None) + + +def test_quest_settings_none_rejects_payload(): + assert QuestSettingsPatch(type=QuestDefinitionTypeName.NONE).type == ( + QuestDefinitionTypeName.NONE + ) + with pytest.raises(ValidationError): + QuestSettingsPatch(type=QuestDefinitionTypeName.NONE, definition='{"a":1}') From b786099cc9eb5c0aaeb0d73db1f5422ced5afb4d Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 14:21:09 -0400 Subject: [PATCH 078/159] Docs --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 20 +++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c2d7f3a..ee0eade 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,3 +30,69 @@ Viewer/Member/Everyone Else * With express TDEI sign-up, the need for this access level diminishes greatly * Granted by Workspaces setting. +## Testing + +Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or +network). `tests/README.md` has the full reference; the essentials: + +* **Unit** (`tests/unit/`) — pure logic and individual classes (permission + rules, schema/DTO behavior, a repository in isolation). +* **Integration** (`tests/integration/`) — real HTTP requests driven through + the real FastAPI app: routing, auth wiring, repositories, serialization. + +### The mocking boundary is the "data fetcher", not the repository + +Integration tests run the **real** routes and repositories. Only three things +are swapped out, via `app.dependency_overrides` and a fake: + +1. `get_task_session` / `get_osm_session` → a `FakeSession` (in + `tests/support/fakes.py`) that returns pre-programmed `FakeResult`s instead + of running SQL. This is the data-fetcher boundary: everything above the + `AsyncSession` runs for real. +2. `validate_token` → a real `UserInfo` built by `tests/support/factories.py` + (skips JWT decode + the TDEI call; the permission logic is still real). +3. `api.main._osm_client` → a streamable mock transport (proxy tests only; + `tests/support/http.py`). + +Because the mock is at the session level, **queue results in the order the +repository issues queries**. Routes that touch both DBs queue on both +`task_session` and `osm_session`. Builders: `rows()`, `empty()`, `affected(n)`, +`mappings()`, `scalar(v)`, and `raises(exc)` (drives 500 paths). The +`error_client` fixture turns unhandled exceptions into 500 responses (httpx's +ASGI transport re-raises by default). + +### `@test:` comment outlines + +Modules carry `# @test:` comments describing intended coverage. They are the +spec for the test suite; when adding behavior, add matching `@test:` lines and +tests. Treat the docstring/attribute comments as authoritative when they and +the code disagree — file a fix rather than silently matching the code. + +### Known behavior discrepancy: read endpoints return 404, not 403 + +Several `@test:` comments on read endpoints (get workspace, list teams, quest +and imagery GETs) specify a **403** when the caller lacks access. The code +enforces access via `WorkspaceRepository.getById`, which raises **404 +NotFound** when the workspace is missing *or* inaccessible — so "not a member" +currently surfaces as 404 on those routes. The tests assert the actual 404 +behavior and flag this in their docstrings. If 403 is the intended contract, +that is a code change in the read routes, not a test change. + +### SQLModel + Pyright + +SQLModel declares columns as plain annotations (e.g. `id: int | None`) rather +than `Mapped[int]`, so Pyright reads `Column == value` as `bool` and flags +`where()`/`exec()`/`select()`/`selectinload` calls and `result.rowcount`. These +are framework false positives. The three repository modules carry a documented +file-level `# pyright: reportArgumentType=false, reportCallIssue=false, +reportAttributeAccessIssue=false` directive; other rules stay enabled so real +bugs still surface. Keep `api/` and `tests/` at zero Pyright errors. + +### Alembic enum migrations + +Postgres `ENUM` types must be created/dropped idempotently. Declare the enum +with `create_type=False` and manage it explicitly with +`enum.create(op.get_bind(), checkfirst=True)` / `enum.drop(..., checkfirst=True)` +so a migration is safe whether or not the type already exists (and never +double-creates it via implicit table DDL). + diff --git a/README.md b/README.md index 7ac5971..7909fbd 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,23 @@ uv sync uv run uvicorn api.main:app ``` +## Running the tests + +Tests are fast and require no database, Docker, or network (see +`tests/README.md` for the design, and `CLAUDE.md` for conventions). + +``` +uv run pytest # full suite with coverage (configured in pyproject.toml) +uv run pytest --no-cov -q # quick run, no coverage +uv run pytest tests/unit # unit tests only +uv run pytest tests/integration # integration tests only +uv run pytest -k workspaces # filter by keyword +``` + +Type-check and format (matches the pre-commit hooks): + +``` +uvx pyright --pythonpath .venv/bin/python api tests +uv run black api tests && uv run isort api tests +``` + From f655772a832447e2bd68d57a034d150463667f01 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 14:26:52 -0400 Subject: [PATCH 079/159] Update ci.yml --- .github/workflows/ci.yml | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72a0ff..a2e8097 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Check code formatting with black run: uv run black --check . + - name: Type-check with pyright + run: uvx pyright --pythonpath .venv/bin/python api tests + test: needs: lint runs-on: ubuntu-latest @@ -58,25 +61,7 @@ jobs: - name: Install the project run: uv sync --all-extras + # The suite needs no database: migrations are skipped under pytest and the + # DB session is mocked at the data-fetcher boundary (see CLAUDE.md). - name: Run tests - run: uv run pytest tests - env: - TASK_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db" - OSM_DATABASE_URL: "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db" - JWT_SECRET: "test_secret_key" - JWT_ALGORITHM: "HS256" - - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: test_db - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 \ No newline at end of file + run: uv run pytest tests \ No newline at end of file From 65a9d6687ff37e9bcee5f6357970cbb5bc53922f Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 14:30:49 -0400 Subject: [PATCH 080/159] isort fix --- api/core/config.py | 1 + pyproject.toml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/api/core/config.py b/api/core/config.py index 5824796..c4c6bde 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,5 +1,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict + # Test outline: # @test: Test that environment variables of the same name as the members of this class are correctly loaded into the members of this class. # @test: Test that any environment variables that are not set in the environment are correctly loaded into the members of this class with their default values. diff --git a/pyproject.toml b/pyproject.toml index 910a8ac..c211d27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,3 +55,7 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true line_length = 88 +# Declare our packages explicitly. Without this, the top-level alembic/ dir +# makes isort misclassify the installed `alembic` package as first-party. +known_first_party = ["api", "tests"] +known_third_party = ["alembic"] From e7728469452f3731d539eae2d6067d6344ce9f96 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 14:31:30 -0400 Subject: [PATCH 081/159] Black linter fixes --- api/core/json_schema.py | 4 +++- api/core/jwt.py | 3 ++- api/core/security.py | 8 ++++++-- api/main.py | 2 +- api/src/teams/routes.py | 29 +++++++++++++++++++++++------ api/src/teams/schemas.py | 12 +++++++----- api/src/users/routes.py | 9 +++++++-- api/src/users/schemas.py | 10 ++++++---- api/src/workspaces/routes.py | 23 ++++++++++++++++++++--- api/src/workspaces/schemas.py | 15 +++++++++------ 10 files changed, 84 insertions(+), 31 deletions(-) diff --git a/api/core/json_schema.py b/api/core/json_schema.py index 4d6f54a..4048de5 100644 --- a/api/core/json_schema.py +++ b/api/core/json_schema.py @@ -19,9 +19,10 @@ # Test outline: # @test: Test that this class validates JSON payloads properly against the JSON schema fetched -# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that malformed JSON or JSON that doesn't validate # @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + def init_json_schema_client() -> None: global _http_client _http_client = httpx.AsyncClient( @@ -124,6 +125,7 @@ async def validate_quest_definition_schema(definition: str) -> None: 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. diff --git a/api/core/jwt.py b/api/core/jwt.py index 522a8e2..ab10f6a 100644 --- a/api/core/jwt.py +++ b/api/core/jwt.py @@ -7,9 +7,10 @@ # Test outline: # @test: Test that this class validates JSON payloads properly against the JSON schema fetched -# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that malformed JSON or JSON that doesn't validate # @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + def _get_jwks_client() -> jwt.PyJWKClient: global _jwks_client diff --git a/api/core/security.py b/api/core/security.py index 7e8baa1..90c5b6a 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -17,7 +17,7 @@ # Test outline: # @test: Test that the permissions structure here matches what is described in CLAUDE.md # @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles -# @test: Test that any failed network requests are handled gracefully +# @test: Test that any failed network requests are handled gracefully # @test: Test that the caching mechanism works correctly and evicts entries when roles change # Set up logger for this module @@ -62,6 +62,7 @@ def evict_user_from_cache(auth_uid: UUID) -> None: security = HTTPBearer() + # @test: Test that this matches what is described in CLAUDE.md and that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles class TdeiProjectGroupRole(StrEnum): MEMBER = "member" @@ -86,6 +87,7 @@ def __init__( self.project_group_id = project_group_id self.tdeiRoles = tdeiRoles + # @test: Test that the values populated in this class match the expected values from the JWT and TDEI API responses, and that the methods return the correct roles based on the user's project group memberships and workspace roles # @test: Test that the osmWorkspaceRoles, accessibleWorkspaceIds, and projectGroups attributes are correctly populated based on the user's roles in the OSM DB and TDEI API responses # @test: Test that the comments in this doc that describe what is supposed to be in the attributes are accurate and match the actual data being stored in those attributes and what is returned. The comments should be considered authoratative @@ -167,7 +169,9 @@ def get_task_db_session( ) -> AsyncSession: return session -# @test: + +# @test: + async def validate_token( credentials: HTTPAuthorizationCredentials = Depends(security), diff --git a/api/main.py b/api/main.py index a2582dc..11fd7a0 100644 --- a/api/main.py +++ b/api/main.py @@ -124,7 +124,7 @@ def get_workspace_repository( # @test: Any headers defined in HOP_BY_HOP_HEADERS are not forwarded to the client # @test: /api/capabilities.json is proxied to the OSM service without requiring authentication # @test: Any request to the OSM service that returns a 4xx or 5xx status code is logged to Sentry with the correct message and the correct status code is returned to the client -# @test: Any request that matches the RegEx in TENANT_BYPASSES is allowed to proceed without an X-Workspace header, +# @test: Any request that matches the RegEx in TENANT_BYPASSES is allowed to proceed without an X-Workspace header, # and any request that does not match the Regex in TENANT_BYPASSES and does not have an X-Workspace header returns a 400 Bad Request error # @test: Only the methods defined in the @app.api_route decorator are allowed to be proxied to the OSM service, and any other methods return a 405 Method Not Allowed error # @test: Any request with an X-Workspace header that does not match the user's accessible workspaces returns a 403 Forbidden error diff --git a/api/src/teams/routes.py b/api/src/teams/routes.py index 2e1e760..678989a 100644 --- a/api/src/teams/routes.py +++ b/api/src/teams/routes.py @@ -36,12 +36,14 @@ def get_team_repo( repo = WorkspaceTeamRepository(session) return repo + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this endpoint properly calls the repository to fetch the team from the database # @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem + @router.get("") async def get_all_teams_for_workspace( workspace_id: int, @@ -53,12 +55,14 @@ async def get_all_teams_for_workspace( await workspace_repo.getById(current_user, workspace_id) return await team_repo.get_all(workspace_id) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this endpoint properly calls the repository to add the team to the database # @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamCreate + @router.post("", status_code=status.HTTP_201_CREATED) async def create_team_for_workspace( workspace_id: int, @@ -77,6 +81,7 @@ async def create_team_for_workspace( await workspace_repo.getById(current_user, workspace_id) return await team_repo.create(workspace_id, team) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace member of any level # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 @@ -84,6 +89,7 @@ async def create_team_for_workspace( # @test: Test that this endpoint properly calls the repository to fetch the team from the database # @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamItem + @router.get("/{team_id}") async def get_team_for_workspace( workspace_id: int, @@ -97,14 +103,16 @@ async def get_team_for_workspace( await team_repo.assert_team_in_workspace(team_id, workspace_id) return await team_repo.get_item(team_id) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 -# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team # @test: Test that this endpoint properly calls the repository to remove the team from the workspace # @test: Test that this method properly handles inputs that match the schema in WorkspaceTeamUpdate -# @test: Test that this method properly calls the repo to update the team +# @test: Test that this method properly calls the repo to update the team + @router.put("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_team_for_workspace( @@ -126,13 +134,15 @@ async def update_team_for_workspace( await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.update(team_id, team) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 -# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team # @test: Test that this endpoint properly calls the repository to remove the team from the workspace + @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_team_from_workspace( workspace_id: int, @@ -152,6 +162,7 @@ async def delete_team_from_workspace( await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.delete(team_id) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a member team # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 @@ -161,6 +172,7 @@ async def delete_team_from_workspace( # @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed # @test: Test that this endpoint properly calls the repository to fetch the users of the team + @router.get("/{team_id}/members") async def get_members_in_workspace_team( workspace_id: int, @@ -183,6 +195,7 @@ async def get_members_in_workspace_team( # @test: Test that this endpoint properly handles the case where the workspace is not associated with the team passed # @test: Test that this endpoint properly calls the repository to add the user to the team + @router.post("/{team_id}/members") async def join_workspace_team( workspace_id: int, @@ -199,15 +212,17 @@ async def join_workspace_team( await team_repo.add_member(team_id, user.id) return user + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 -# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team # @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the workspace # @test: Test that this endpoint properly calls the repository to add the user to the team + @router.put("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def add_member_to_workspace_team( workspace_id: int, @@ -228,17 +243,19 @@ async def add_member_to_workspace_team( await team_repo.assert_team_in_workspace(team_id, workspace_id) await team_repo.add_member(team_id, user_id) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that the endpoint properly validates that the team needs to be associated with this workspace and errors if not # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the team does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 -# @test: Test that this endpoint properly handles the case where the user is not associated with the team -# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team +# @test: Test that this endpoint properly handles the case where the user is not associated with the team +# @test: Test that this endpoint properly handles the case where the workspace is not associated with the team # @test: Test that this endpoint doesn't allow changing teams the user doesn't have workspace lead permissions for, or users not already associated with the team # @test: Test that this endpoint properly calls the repository to remove the user from the team + @router.delete("/{team_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_member_from_workspace_team( workspace_id: int, diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index 0aefcf0..361f2a4 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -5,14 +5,15 @@ if TYPE_CHECKING: from api.src.users.schemas import User -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeamUser(SQLModel, table=True): """Team to User link table""" - __tablename__ = "team_user" # type: ignore[assignment] + __tablename__ = "team_user" # type: ignore[assignment] team_id: int | None = Field(default=None, primary_key=True, foreign_key="teams.id") user_id: int | None = Field(default=None, primary_key=True, foreign_key="users.id") @@ -23,10 +24,11 @@ class WorkspaceTeamBase(SQLModel): name: str = Field(min_length=1) -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeam(WorkspaceTeamBase, table=True): """Workspace teams""" diff --git a/api/src/users/routes.py b/api/src/users/routes.py index 9a96162..50197c9 100644 --- a/api/src/users/routes.py +++ b/api/src/users/routes.py @@ -24,11 +24,13 @@ def get_workspace_repo( ) -> WorkspaceRepository: return WorkspaceRepository(session) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not associated with this workspace at contributor or above # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the user is not associated with the workspace (returns a 403 error) + @router.get("", response_model=list[WorkspaceUserRoleItem]) async def get_privileged_workspace_members( workspace_id: int, @@ -48,12 +50,13 @@ async def get_privileged_workspace_members( # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 -# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace # @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour # @test: Test that this endpoint properly allows users to be workspace leads or validators, and to unset the user of either role and become a contributor again # @test: Test that this endpoint doesn't allow changing workspaces the user doesn't have workspace lead permissions for, or roles for users not already associated with the workspace # @test: Test that this endpoint doesn't allow setting the workspace role to a POC + @router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT) async def assign_member_role( workspace_id: int, @@ -78,13 +81,15 @@ async def assign_member_role( await user_repo.assign_member_role(workspace_id, user_id, body.role) evict_user_from_cache(user_id) + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this endpoint properly handles the case where the user does not exist and returns a 404 -# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace +# @test: Test that this endpoint properly handles the case where the user is not associated with the workspace # @test: Test that this endpoint properly evicts any cached data for the user after the user is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour + @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member_role( workspace_id: int, diff --git a/api/src/users/schemas.py b/api/src/users/schemas.py index bc8e3be..be2ebbe 100644 --- a/api/src/users/schemas.py +++ b/api/src/users/schemas.py @@ -16,10 +16,11 @@ class WorkspaceUserRoleType(StrEnum): VALIDATOR = "validator" CONTRIBUTOR = "contributor" -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceUserRole(SQLModel, table=True): """Associates users with workspaces and their roles""" @@ -65,10 +66,11 @@ class WorkspaceUserRoleItem(SQLModel): display_name: str role: WorkspaceUserRoleType -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class User(SQLModel, table=True): """Users in the OSM DB""" diff --git a/api/src/workspaces/routes.py b/api/src/workspaces/routes.py index fdc0a95..e5d35dd 100644 --- a/api/src/workspaces/routes.py +++ b/api/src/workspaces/routes.py @@ -56,9 +56,10 @@ def get_user_repository( # @test: Test that this method properly handles numeric workspace_id input and invalid values for the same # @test: Test that this method properly checks permissions to see if the user has access to the workspace and if they don't, it doesn't appear in the list # @test: Test that this method properly handles the case where the workspace does not exist and doesn't include it -# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse # @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 + # 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( @@ -72,12 +73,14 @@ async def get_my_workspaces( logger.error(f"Failed to fetch workspaces: {str(e)}") raise + # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this method properly calls the repository method to fetch the workspace and that the repository method properly fetches the workspace from the database # @test: Test that this method properly handles numeric workspace_id input and invalid values for the same # @test: Test that this method properly checks permissions to see if the user has access to the workspace and returns a 403 if they do not # @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 -# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse +# @test: Test that this method properly handles inputs that match the schema in WorkspaceResponse + # Returns JSON payload or 204 if not found @router.get("/{workspace_id}", response_model=WorkspaceResponse) @@ -110,11 +113,13 @@ async def get_workspace( logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise + # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this method properly handles numeric workspace_id input and invalid values for the same # @test: Test taht this workspace returns proper values for a workspace that exists and that the bbox matches the expected values # @test: Test that this method properly handles the case where the workspace does not exist and returns a 404 + @router.get("/{workspace_id}/bbox", response_model=None) async def get_workspace_bbox( workspace_id: int, @@ -131,6 +136,7 @@ async def get_workspace_bbox( logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise + # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly evicts any cached data for the user after the workspace is deleted so that their next request reflects the deletion rather than serving stale data for up to an hour # @test: Test that this method properly calls the repository method to create the workspace and that the repository method properly creates the workspace in the database @@ -139,6 +145,7 @@ async def get_workspace_bbox( # @test: Test that this method properly handles inputs that do not match the schema in WorkspaceCreate and that the repository method properly raises an error and does not update the workspace in the database with those values # @test: Test that this method won't allow users to modify an existing workspace in any way, or create a workspace with the same workspace_id as an existing one + # Returns 201 on success? @router.post("", status_code=status.HTTP_201_CREATED) async def create_workspace( @@ -171,6 +178,7 @@ async def create_workspace( logger.error(f"Failed to create workspace: {str(e)}") raise + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 @@ -179,6 +187,7 @@ async def create_workspace( # @test: Test that this method properly handles inputs that match the schema in WorkspacePatch and that the repository method properly updates the workspace in the database with those values # @test: Test that this method properly handles inputs that do not match the schema in WorkspacePatch and that the repository method properly raises an error and does not update the workspace in the database with those values + @router.patch("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def update_workspace( workspace_id: int, @@ -213,6 +222,7 @@ async def update_workspace( # @test: Test that this method properly handles the case where the workspace has members and that the repository method properly removes all member roles from the database # @test: Test that this method properly handles numeric workspace_id input and invalid values for the same + # Returns 204 on success @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( @@ -245,7 +255,8 @@ async def delete_workspace( # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this method properly calls the repository method to fetch the long quest definition -# FIXME: Why are there two methods to fetch the long quest? One for legacy purposes? Can we migrate callers? +# FIXME: Why are there two methods to fetch the long quest? One for legacy purposes? Can we migrate callers? + # Return the resolved quest definition content as JSON, or 204 if not set: @router.get("/{workspace_id}/quests/long") @@ -270,12 +281,14 @@ async def get_long_quest_def( ) raise + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly handles any exceptions and returns a 500 if an unexpected error occurs # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this method properly calls the repository method to fetch the long quest definition, and if it's not defined, the default value as defined # in this method + # Returns JSON payload or 204 if not set @router.get( "/{workspace_id}/quests/long/settings", response_model=QuestSettingsResponse @@ -313,6 +326,7 @@ async def get_long_quest_settings( logger.error(f"Failed to fetch workspace {workspace_id}: {str(e)}") raise + # @test: Test that this endpoint properly validates the user's permissions and returns a 403 if the user is not a workspace lead # @test: Test that this endpoint properly validates the long quest definition against the JSON schema and returns a 400 if the definition is invalid # @test: Test that this endpoint properly saves the long quest definition to the database and returns a 204 on success @@ -321,6 +335,7 @@ async def get_long_quest_settings( # @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads # @test: Test that this method properly calls the repository method to save the long quest definition and that the repository method properly saves the definition to the database + # Returns 204 on success @router.patch( "/{workspace_id}/quests/long/settings", status_code=status.HTTP_204_NO_CONTENT @@ -361,6 +376,7 @@ async def update_long_quest_settings( # @test: Test that this endpoint properly handles the case where the workspace does not exist and returns a 404 # @test: Test that this method properly calls the repository method to fetch the imagery definition + # Returns JSON payload or 204 if not set @router.get("/{workspace_id}/imagery/settings") async def get_imagery_settings( @@ -394,6 +410,7 @@ async def get_imagery_settings( # @test: Test that this endpoint properly handles input values that are properly formed, malformed and edge cases, including empty lists, null values, and large payloads # @test: Test that this method properly calls the repository method to save the imagery definition and that the repository method properly saves the definition to the database + # Returns 204 on success @router.patch( "/{workspace_id}/imagery/settings", status_code=status.HTTP_204_NO_CONTENT diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 34f28f7..07355cc 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -78,10 +78,11 @@ class QuestDefinitionTypeName(StrEnum): JSON = "JSON" URL = "URL" -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceLongQuest(SQLModel, table=True): """Stores mobile app quest definitions for a workspace""" @@ -105,10 +106,11 @@ class WorkspaceLongQuest(SQLModel, table=True): modifiedBy: UUID modifiedByName: str -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceImagery(SQLModel, table=True): """Stores imagery list for a workspace""" @@ -253,10 +255,11 @@ def from_workspace( longFormQuestDef=long_form_quest_def, ) -# @test: Test that this class matches the Alembic-defined database schema + +# @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined # @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python -# @test: Test that any serialization/deserialization doesn't lose any precision or data +# @test: Test that any serialization/deserialization doesn't lose any precision or data class Workspace(SQLModel, table=True): """Workspaces""" From a00362be66a4678a0defc1f1ca681e01cfbc971e Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 24 Jun 2026 14:33:35 -0400 Subject: [PATCH 082/159] Merge jobs --- .github/workflows/ci.yml | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e8097..6f914c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [ main ] jobs: - lint: + ci: runs-on: ubuntu-latest strategy: matrix: @@ -20,12 +20,13 @@ jobs: uses: astral-sh/setup-uv@v4 with: version: "0.5.8" - - - name: Set up Python + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.12" - + python-version: ${{ matrix.python-version }} + - name: Install the project run: uv sync --all-extras @@ -38,30 +39,7 @@ jobs: - name: Type-check with pyright run: uvx pyright --pythonpath .venv/bin/python api tests - test: - needs: lint - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - version: "0.5.8" - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install the project - run: uv sync --all-extras - # The suite needs no database: migrations are skipped under pytest and the # DB session is mocked at the data-fetcher boundary (see CLAUDE.md). - name: Run tests - run: uv run pytest tests \ No newline at end of file + run: uv run pytest tests From 48d8eec657b1d42bf8878528e9588ee70013a5d5 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 25 Jun 2026 13:45:16 -0400 Subject: [PATCH 083/159] Update CLAUDE.md --- CLAUDE.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ee0eade..c2b1a35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,74 @@ Viewer/Member/Everyone Else * With express TDEI sign-up, the need for this access level diminishes greatly * Granted by Workspaces setting. +## What Each Role Can Do + +Project Lead +* Edit Metadata +* Edit Longform Quests +* Toggle App-Enabled Flag +* Delete Workspace +* Define User Teams +* Define Groups or Roles +* Export to TDEI +* Validate Changeset +* Move Workspace from Project Group to Project Group +* Edit POSM Element + +Validator +* Export to TDEI +* Validate Changeset +* Edit POSM Element + +Contributor +* Edit POSM Element + +Authenticated User With PG/Workspace Association +* Edit POSM Element + +### What this backend actually enforces (vs. the matrix above) + +The matrix above is the intended product model. It is only **partially** +enforced in `api/` — this service is a proxy in front of the OSM website, +cgimap, and TDEI, so several capabilities are enforced downstream (or not yet +at all). Validated against the code: + +**Enforced here, Lead-gated (`isWorkspaceLead` → 403).** POC inherits these +(POC on the owning project group satisfies `isWorkspaceLead`): + +| Capability | Endpoint | +|---|---| +| Edit Metadata | PATCH `/workspaces/{id}` | +| Edit Longform Quests | PATCH `/workspaces/{id}/quests/long/settings` | +| Toggle App-Enabled Flag | PATCH `/workspaces/{id}` (`externalAppAccess`) | +| Delete Workspace | DELETE `/workspaces/{id}` | +| Define User Teams | `/workspaces/{id}/teams...` (create/update/delete/members) | +| Define Groups or Roles | PUT/DELETE `/workspaces/{id}/users/{user_id}...` | + +**Not enforced / not present here:** + +* **Export to TDEI** — no endpoint exists in this backend. +* **Move Workspace PG→PG** — not possible; `WorkspacePatch` has no + `tdeiProjectGroupId` field, so no route can change a workspace's project group. +* **Validate Changeset** and **Edit POSM Element** — these go through the OSM + proxy catch-all (`api/main.py`), which gates *every* proxied operation on + `isWorkspaceContributor` alone. There is no Validator- or Lead-level check on + proxied traffic. + +**The Validator role grants nothing extra at this layer.** +`isWorkspaceValidator` exists in `api/core/security.py` but no endpoint +authorizes on it — it only appears in the `role` field of `WorkspaceResponse`. +A Validator and a Contributor have identical permissions in this backend. + +**"Contributor" and "Authenticated User With PG/Workspace Association" are the +same gate.** `isWorkspaceContributor` simply checks whether the workspace is in +one of the user's project groups (`accessibleWorkspaceIds`), i.e. PG/workspace +association — so both rows collapse to the same check. + +If the Validator/Lead distinctions for changeset validation and TDEI export are +required, they must be enforced downstream (`workspaces-openstreetmap-website/`, +`workspaces-cgimap/`) — that has not been audited here. + ## Testing Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or From ed9cf1e77090063814ad6a5a9083adfc2aa3f7da Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 26 Jun 2026 11:48:14 +0530 Subject: [PATCH 084/159] added Route to submit Changesets for task - Added route to submit changeset for a task - Removed the changeset ID from Submit request. --- api/src/tasking/tasks/dtos.py | 16 ++++- api/src/tasking/tasks/repository.py | 90 +++++++++++++++++++++------- api/src/tasking/tasks/routes.py | 31 ++++++++++ tests/integration/test_tasks_flow.py | 14 ++--- 4 files changed, 120 insertions(+), 31 deletions(-) diff --git a/api/src/tasking/tasks/dtos.py b/api/src/tasking/tasks/dtos.py index 374bed2..79a8bdb 100644 --- a/api/src/tasking/tasks/dtos.py +++ b/api/src/tasking/tasks/dtos.py @@ -110,11 +110,25 @@ class FeedbackInput(WireModel): class SubmitRequest(WireModel): - osm_changeset_id: int = PydField(ge=1) + # osm_changeset_id: int = PydField(ge=1) done: bool feedback: Optional[FeedbackInput] = None +class SubmitTaskChangeset(WireModel): + osm_changeset_id: int = PydField(ge=1) + + +class SubmitTaskChangesetResponse(WireModel): + osm_changeset_id: int = PydField(ge=1) + task_number: int + project_id: int + workspace_id: int + inserted_id: Optional[int] = ( + None # ID of the newly inserted changeset row, if applicable + ) + + class ExistingLockSummary(WireModel): task_number: int task_status: TaskStatus diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 60d8ddc..b29ff53 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -972,29 +972,29 @@ async def submit( now = datetime.now() - # Record the changeset row. - cs = TaskingChangeset( - task_id=task.id, # type: ignore[arg-type] - project_id=project_id, - lock_id=lock.id, # type: ignore[arg-type] - user_auth_uid=str(current_user.user_uuid), - osm_changeset_id=body.osm_changeset_id, - submitted_at=now, - ) - self.session.add(cs) - await self.session.flush() - - await self._audit( - event_type=AuditEventType.CHANGESET_SUBMITTED, - project_id=project_id, - task_id=task.id, - actor_uuid=current_user.user_uuid, - details={ - "taskNumber": task.task_number, - "osmChangesetId": body.osm_changeset_id, - "done": body.done, - }, - ) + # # Record the changeset row. + # cs = TaskingChangeset( + # task_id=task.id, # type: ignore[arg-type] + # project_id=project_id, + # lock_id=lock.id, # type: ignore[arg-type] + # user_auth_uid=str(current_user.user_uuid), + # osm_changeset_id=body.osm_changeset_id, + # submitted_at=now, + # ) + # self.session.add(cs) + # await self.session.flush() + + # await self._audit( + # event_type=AuditEventType.CHANGESET_SUBMITTED, + # project_id=project_id, + # task_id=task.id, + # actor_uuid=current_user.user_uuid, + # details={ + # "taskNumber": task.task_number, + # "osmChangesetId": body.osm_changeset_id, + # "done": body.done, + # }, + # ) if not body.done: # Slide lock expiry from submitted_at + lock_timeout_hours. @@ -1111,5 +1111,49 @@ async def submit( refreshed = await self._get_task(project_id, task_number) return await self._to_task_response(refreshed) + async def submit_changeset( + self, + workspace_id: int, + project_id: int, + task_number: int, + current_user: UserInfo, + changesetId: int, + ) -> int: + project = await self._get_project(workspace_id, project_id) + task = await self._get_task(project_id, task_number) + lock = await self._get_active_lock(task.id) # type: ignore[arg-type] + if lock is None or lock.user_auth_uid != str(current_user.user_uuid): + raise ForbiddenException("Caller does not hold the active lock") + now = datetime.now() + + # Record the changeset row. + cs = TaskingChangeset( + task_id=task.id, # type: ignore[arg-type] + project_id=project_id, + lock_id=lock.id, # type: ignore[arg-type] + user_auth_uid=str(current_user.user_uuid), + osm_changeset_id=changesetId, + submitted_at=now, + ) + self.session.add(cs) + await self.session.flush() + # Get the id of the newly inserted changeset row + changeset_row_id = cs.id # type: ignore[assignment] + print(f"Inserted changeset row with ID: {changeset_row_id}") + + # Audit record for changeset submission + await self._audit( + event_type=AuditEventType.CHANGESET_SUBMITTED, + project_id=project_id, + task_id=task.id, + actor_uuid=current_user.user_uuid, + details={ + "taskNumber": task.task_number, + "osmChangesetId": changesetId, + }, + ) + + return cs.id # Return the ID of the newly inserted changeset row + __all__ = ["TaskingTaskRepository"] diff --git a/api/src/tasking/tasks/routes.py b/api/src/tasking/tasks/routes.py index bef56ac..08fe0b3 100644 --- a/api/src/tasking/tasks/routes.py +++ b/api/src/tasking/tasks/routes.py @@ -21,6 +21,8 @@ SaveTasksRequest, SaveTasksResponse, SubmitRequest, + SubmitTaskChangeset, + SubmitTaskChangesetResponse, TaskBoundariesFeatureCollection, TaskListResponse, TaskResponse, @@ -273,3 +275,32 @@ async def submit_task( return await task_repo.submit( workspace_id, project_id, task_number, current_user, body ) + + +@router.post( + "/tasks/{task_number}/submit-changeset", response_model=SubmitTaskChangesetResponse +) +async def submit_changeset( + workspace_id: int, + project_id: int, + task_number: int, + changeset_model: SubmitTaskChangeset, + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + task_repo: TaskingTaskRepository = Depends(get_task_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + cs_id = await task_repo.submit_changeset( + workspace_id, + project_id, + task_number, + current_user, + changeset_model.osm_changeset_id, + ) + return SubmitTaskChangesetResponse( + osm_changeset_id=changeset_model.osm_changeset_id, + task_number=task_number, + project_id=project_id, + workspace_id=workspace_id, + inserted_id=cs_id, + ) diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 189e791..b2e7ecc 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -653,7 +653,7 @@ async def test_02_contributor_lock_and_submit_done( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={"osm_changeset_id": 1001, "done": True}, + json={ "done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -688,7 +688,7 @@ async def test_05_validator_submit_done_no_feedback_completes( override_user(self.validator) r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={"osm_changeset_id": 1002, "done": True}, + json={ "done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -732,7 +732,7 @@ async def test_submit_done_false_slides_expiry( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 5001, "done": False}, + json={ "done": False}, ) assert r.status_code == 200, r.text body = r.json() @@ -774,7 +774,7 @@ async def test_remap_loop( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 7001, "done": True}, + json={ "done": True}, ) assert r.json()["status"] == "to_review" @@ -784,7 +784,7 @@ async def test_remap_loop( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ - "osm_changeset_id": 7002, + "done": True, "feedback": { "reason_category": "incomplete_mapping", @@ -838,7 +838,7 @@ async def test_validator_cannot_validate_own_last_mapping( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 9001, "done": True}, + json={ "done": True}, ) # Now they try to validate their own work → 403. @@ -880,7 +880,7 @@ async def test_reset_releases_lock_and_resets_status( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={"osm_changeset_id": 11001, "done": True}, + json={ "done": True}, ) # Validator picks it up. override_user(validator) From 24ace556457b258a67878c122821f18fd9300cac Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 26 Jun 2026 11:54:35 +0530 Subject: [PATCH 085/159] Update test_tasks_flow.py Linting issue fixed --- tests/integration/test_tasks_flow.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index b2e7ecc..5af5c50 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -653,7 +653,7 @@ async def test_02_contributor_lock_and_submit_done( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -688,7 +688,7 @@ async def test_05_validator_submit_done_no_feedback_completes( override_user(self.validator) r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{self.project_id}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) assert r.status_code == 200, r.text body = r.json() @@ -732,7 +732,7 @@ async def test_submit_done_false_slides_expiry( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={ "done": False}, + json={"done": False}, ) assert r.status_code == 200, r.text body = r.json() @@ -774,7 +774,7 @@ async def test_remap_loop( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) assert r.json()["status"] == "to_review" @@ -784,7 +784,6 @@ async def test_remap_loop( r = await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", json={ - "done": True, "feedback": { "reason_category": "incomplete_mapping", @@ -838,7 +837,7 @@ async def test_validator_cannot_validate_own_last_mapping( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) # Now they try to validate their own work → 403. @@ -880,7 +879,7 @@ async def test_reset_releases_lock_and_resets_status( await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/lock") await client.post( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/1/submit", - json={ "done": True}, + json={"done": True}, ) # Validator picks it up. override_user(validator) From 47e272c81519c8fcee66133bde7ef723c7955a63 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 26 Jun 2026 11:57:55 +0530 Subject: [PATCH 086/159] Update repository.py Minor fix --- api/src/tasking/tasks/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index b29ff53..ad99c12 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -1152,7 +1152,7 @@ async def submit_changeset( "osmChangesetId": changesetId, }, ) - + await self.session.commit() return cs.id # Return the ID of the newly inserted changeset row From b40c7742eb6671366ac2c34eadd2fdec8abd8aa0 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 10:57:25 -0400 Subject: [PATCH 087/159] Rabbit --- api/core/jwt.py | 5 ++--- api/src/teams/schemas.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/api/core/jwt.py b/api/core/jwt.py index ab10f6a..9a5b594 100644 --- a/api/core/jwt.py +++ b/api/core/jwt.py @@ -6,11 +6,10 @@ _jwks_client: jwt.PyJWKClient | None = None # Test outline: -# @test: Test that this class validates JSON payloads properly against the JSON schema fetched -# @test: Test that malformed JSON or JSON that doesn't validate +# @test: Test that this class validates JSON payloads properly against the expected JWT/token format +# @test: Test that malformed JSON or JSON that doesn't validate doesn't succeed # @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation - def _get_jwks_client() -> jwt.PyJWKClient: global _jwks_client diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index 361f2a4..f31ec8d 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -8,7 +8,7 @@ # @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined -# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any values from Python are properly serialized to the database and that any values from the database are properly deserialized to Python # @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeamUser(SQLModel, table=True): """Team to User link table""" @@ -27,7 +27,7 @@ class WorkspaceTeamBase(SQLModel): # @test: Test that this class matches the Alembic-defined database schema # @test: Test that the foreign key references are correct and that the relationships are correctly defined -# @test: Test that any values from Python and properly serialized to the database and that any values from the database are properly deserialized to Python +# @test: Test that any values from Python are properly serialized to the database and that any values from the database are properly deserialized to Python # @test: Test that any serialization/deserialization doesn't lose any precision or data class WorkspaceTeam(WorkspaceTeamBase, table=True): """Workspace teams""" From b77e12941886d2d59b6e2d7a88c442b8e6c0818a Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 11:00:14 -0400 Subject: [PATCH 088/159] Update jwt.py --- api/core/jwt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/core/jwt.py b/api/core/jwt.py index 9a5b594..efb536b 100644 --- a/api/core/jwt.py +++ b/api/core/jwt.py @@ -10,6 +10,7 @@ # @test: Test that malformed JSON or JSON that doesn't validate doesn't succeed # @test: Test that any failed network requests are handled gracefully and return a proper error (not just "Exception") or worse a false positive validation + def _get_jwks_client() -> jwt.PyJWKClient: global _jwks_client From ee404604a8d5d34d612830467715f1509f27487c Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 11:02:00 -0400 Subject: [PATCH 089/159] Linter setup --- .pre-commit-config.yaml | 2 +- .vscode/settings.json | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47d11bc..95f6511 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: name: isort (python) - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.10.0 hooks: - id: black language_version: python3.12 diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f626e0..f8efae9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,16 @@ "alembic" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "isort.args": [ + "--profile", + "black" + ] } \ No newline at end of file From b810841a664fb2eceebcceb84c564a23db6451d5 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 11:02:04 -0400 Subject: [PATCH 090/159] Update schemas.py --- api/src/teams/schemas.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/src/teams/schemas.py b/api/src/teams/schemas.py index f31ec8d..3a625ba 100644 --- a/api/src/teams/schemas.py +++ b/api/src/teams/schemas.py @@ -58,10 +58,6 @@ def from_team(cls, team: WorkspaceTeam) -> Self: class WorkspaceTeamCreate(WorkspaceTeamBase): """New workspace team DTO""" - pass - class WorkspaceTeamUpdate(WorkspaceTeamBase): """Modify workspace team DTO""" - - pass From 2c3e2df51810bb436010f9ac4d6628ff4252ffef Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 12:18:44 -0400 Subject: [PATCH 091/159] Update ci.yml --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f914c6..113eda6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [ main ] +# Cancel any in-progress run for the same branch/PR when a newer commit lands. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: ci: runs-on: ubuntu-latest From 5e4cabe7a52033b0d1713e35513442faaff1e962 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 12:20:46 -0400 Subject: [PATCH 092/159] Update ci.yml --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113eda6..5050c8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,10 @@ jobs: python-version: ["3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v6 with: version: "0.5.8" enable-cache: true From 0f1d5068e4918ddbb490ee0017a7ea1b72662e2d Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 12:20:56 -0400 Subject: [PATCH 093/159] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5050c8c..f585636 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From e0628afdb44f223476b4184ae63b716dbd74aa64 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 26 Jun 2026 15:58:15 -0400 Subject: [PATCH 094/159] Update main.py --- api/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/main.py b/api/main.py index 11fd7a0..b8618ff 100644 --- a/api/main.py +++ b/api/main.py @@ -31,9 +31,11 @@ sentry_sdk.init( dsn=config.settings.SENTRY_DSN, environment=os.getenv("ENV", "unknown"), + release=os.getenv("CODE_VERSION", "unknown"), debug=settings.DEBUG, ) +# Kept alongside `release` for any dashboards that query the `version` tag. sentry_sdk.set_tag("version", os.getenv("CODE_VERSION", "unknown")) # Set up logging configuration From abe497e4d4ca141084bc83e8f6eb0231c3ff7aa8 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Thu, 2 Jul 2026 11:32:08 +0530 Subject: [PATCH 095/159] Create push-docker-image.yml --- .github/workflows/push-docker-image.yml | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/push-docker-image.yml diff --git a/.github/workflows/push-docker-image.yml b/.github/workflows/push-docker-image.yml new file mode 100644 index 0000000..f8cdee6 --- /dev/null +++ b/.github/workflows/push-docker-image.yml @@ -0,0 +1,64 @@ +name: Push Docker Image to Azure Container Registry + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'develop' + type: choice + options: + - develop + - staging + - production + - testing + +jobs: + set-github-environment: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-env.outputs.target_env }} + steps: + - name: Set Environment + id: set-env + run: | + case "${{ inputs.environment }}" in + testing) echo "target_env=test" >> $GITHUB_OUTPUT ;; + develop) echo "target_env=dev" >> $GITHUB_OUTPUT ;; + staging) echo "target_env=stage" >> $GITHUB_OUTPUT ;; + production) echo "target_env=prod" >> $GITHUB_OUTPUT ;; + esac + build: + runs-on: ubuntu-latest + name: "Build" + needs: set-github-environment + concurrency: + group: build + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.environment }} + + - uses: docker/login-action@v3 + with: + registry: ${{ vars.DOCKERHUB_REGISTRY }} + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ vars.DOCKERHUB_REGISTRY }}/workspaces-backend-v2:${{ needs.set-github-environment.outputs.environment }}, + ${{ vars.DOCKERHUB_REGISTRY }}/workspaces-backend-v2:${{github.sha}} + \ No newline at end of file From 6f04fcbede825b82adfa1abb106a6747016d3911 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 3 Jul 2026 10:58:27 +0530 Subject: [PATCH 096/159] Update repository.py Adds the pending columns when a TDEI user is provisioned. This is taken directly from the existing code of CGIMap --- api/src/tasking/projects/repository.py | 53 ++++++++++++++++++++------ 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index eaa81eb..5c6cb4c 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -270,23 +270,54 @@ async def _provision_users_from_tdei( by_uid = {m.auth_uid: m for m in members} + ''' + INSERT INTO users ( + email, + display_name, + auth_uid, + auth_provider, + status, + pass_crypt, + data_public, + email_valid, + terms_seen, + creation_time, + terms_agreed, + tou_agreed) + VALUES ( + $1, + $2, + $3, + 'TDEI', + 'active', + 'none', + true, + true, + true, + (now() at time zone 'utc'), + (now() at time zone 'utc'), + (now() at time zone 'utc')) + RETURNING id + ) + ''' + resolved: set[str] = set() for uid in missing_uuids: member = by_uid.get(uid) if member is None: continue await self.session.execute( - text( - "INSERT INTO users (auth_uid, email, display_name) " - "VALUES (:uid, :email, :name) " - "ON CONFLICT (auth_uid) DO NOTHING" - ), - { - "uid": uid, - "email": member.email, - "name": member.display_name, - }, - ) + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, status, pass_crypt, data_public, email_valid, terms_seen, creation_time, terms_agreed, tou_agreed) " + "VALUES (:uid, :email, :name, 'TDEI', 'active', 'none', true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + params={ + "uid": uid, + "email": member.email, + "name": member.display_name, + }, + ) resolved.add(uid) if resolved: From 3e1f692a97419c6ff0600b9f9067eb60ccf9c132 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 3 Jul 2026 11:06:46 +0530 Subject: [PATCH 097/159] Update repository.py linting fixes --- api/src/tasking/projects/repository.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 5c6cb4c..17dd312 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -270,7 +270,7 @@ async def _provision_users_from_tdei( by_uid = {m.auth_uid: m for m in members} - ''' + """ INSERT INTO users ( email, display_name, @@ -299,7 +299,7 @@ async def _provision_users_from_tdei( (now() at time zone 'utc')) RETURNING id ) - ''' + """ resolved: set[str] = set() for uid in missing_uuids: @@ -307,17 +307,17 @@ async def _provision_users_from_tdei( if member is None: continue await self.session.execute( - text( - "INSERT INTO users (auth_uid, email, display_name, auth_provider, status, pass_crypt, data_public, email_valid, terms_seen, creation_time, terms_agreed, tou_agreed) " - "VALUES (:uid, :email, :name, 'TDEI', 'active', 'none', true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " - "ON CONFLICT (auth_uid) DO NOTHING" - ), - params={ - "uid": uid, - "email": member.email, - "name": member.display_name, - }, - ) + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, status, pass_crypt, data_public, email_valid, terms_seen, creation_time, terms_agreed, tou_agreed) " + "VALUES (:uid, :email, :name, 'TDEI', 'active', 'none', true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + params={ + "uid": uid, + "email": member.email, + "name": member.display_name, + }, + ) resolved.add(uid) if resolved: From 7be1a5320fd1dc4de5f0fd237dd07aede596390d Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 3 Jul 2026 14:50:20 +0530 Subject: [PATCH 098/159] User gets added with contributor When a contributor is added to the TM project, if not available, the user needs to be provisioned in the workspaces db --- api/src/tasking/projects/repository.py | 17 +++++++---------- api/src/tasking/projects/routes.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 17dd312..27aaa83 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -990,21 +990,18 @@ async def add_role( workspace_id: int, project_id: int, body: ProjectRoleAddRequest, + user_token: str, + project_group_id: str, ) -> ProjectRoleItem: """Insert a new role row. 422 on missing user; 409 on duplicate.""" await self._get_active(workspace_id, project_id) missing = await self._missing_user_auth_uids([body.user_id]) - if missing: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": ( - "`user_id` refers to a user that has not signed in " - "to Workspaces yet — no `users` row exists." - ), - "missing_user_ids": missing, - }, + if missing: # User never logged in, so we try to provision them from TDEI + self._provision_users_from_tdei( + missing, + project_group_id=project_group_id, + bearer_token=user_token, ) from sqlalchemy import text diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 7389246..d4979b0 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -276,7 +276,17 @@ async def add_project_role( ): await assert_workspace_visible(workspace_id, current_user, workspace_repo) await project_repo.assert_can_manage_roles(workspace_id, project_id, current_user) - return await project_repo.add_role(workspace_id, project_id, body) + # Get the project Group ID and Bearer token + brearer_token = current_user.credentials + workspace = await workspace_repo.getById(current_user, workspace_id) + project_group_id = str(workspace.tdeiProjectGroupId) + return await project_repo.add_role( + workspace_id, + project_id, + body, + user_token=brearer_token, + project_group_id=project_group_id, + ) @router.get( From f19fcb76cf22bfdad627c5397d70c70638e3b922 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 3 Jul 2026 16:31:19 +0530 Subject: [PATCH 099/159] Update repository.py Added missing await --- api/src/tasking/projects/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 27aaa83..7e31c69 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -998,7 +998,7 @@ async def add_role( missing = await self._missing_user_auth_uids([body.user_id]) if missing: # User never logged in, so we try to provision them from TDEI - self._provision_users_from_tdei( + provisioned_users = await self._provision_users_from_tdei( missing, project_group_id=project_group_id, bearer_token=user_token, From b3db3b3c7484b7951d8e3d452fa748790f57bc64 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 16:15:10 -0400 Subject: [PATCH 100/159] Fix post-merge --- tests/conftest.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index a5b6c4b..866df1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,8 @@ serialization -- runs unmodified. """ +from uuid import UUID, uuid4 + import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient @@ -22,6 +24,60 @@ from tests.support import factories from tests.support.fakes import FakeSession +# Shared with the testcontainers-backed integration suite +# (``tests/integration/conftest.py``), which imports these to seed a real +# workspaces row and to synthesize ``UserInfo`` principals. Kept here so both +# the FakeSession-based and testcontainers-based test layers reference one +# canonical workspace/project-group identity. +SEED_WORKSPACE_ID = 1899 +SEED_PROJECT_GROUP_ID = UUID("00000000-0000-0000-0000-000000001899") + + +def _make_user( + *, + role: str | None, + workspace_id: int, + pg_id: UUID, + is_poc: bool = False, +): + """Construct a UserInfo with the minimum fields the gates inspect.""" + from api.core.security import TdeiProjectGroupRole, UserInfo, UserInfoPGMembership + from api.src.users.schemas import WorkspaceUserRoleType + + u = UserInfo() + u.credentials = "fake-token" + u.user_uuid = uuid4() + u.user_name = f"test-{role or 'outsider'}-{u.user_uuid.hex[:6]}" + + if role == "lead": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.LEAD]} + elif role == "validator": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.VALIDATOR]} + elif role == "contributor": + u.osmWorkspaceRoles = {workspace_id: [WorkspaceUserRoleType.CONTRIBUTOR]} + else: + u.osmWorkspaceRoles = {} + + pg_roles = [TdeiProjectGroupRole.MEMBER] + if is_poc: + pg_roles.append(TdeiProjectGroupRole.POINT_OF_CONTACT) + + # Outsiders belong to no project group at all -> 404 on tenancy gate. + if role is None and not is_poc: + u.projectGroups = [] + u.accessibleWorkspaceIds = {} + else: + u.projectGroups = [ + UserInfoPGMembership( + project_group_name="Test PG", + project_group_id=str(pg_id), + tdeiRoles=pg_roles, + ) + ] + u.accessibleWorkspaceIds = {str(pg_id): [workspace_id]} + + return u + @pytest.fixture def task_session() -> FakeSession: From 6ed272662b5dc3bafe93d5ad834543b5f8c25142 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 16:18:10 -0400 Subject: [PATCH 101/159] Update conftest.py --- tests/conftest.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 866df1f..c59f02d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,8 @@ serialization -- runs unmodified. """ +from collections.abc import Iterator +from typing import Callable from uuid import UUID, uuid4 import pytest @@ -135,3 +137,80 @@ async def error_client(app): transport = ASGITransport(app=app, raise_app_exceptions=False) async with AsyncClient(transport=transport, base_url="http://testserver") as c: yield c + + +# --------------------------------------------------------------------------- +# Sync auth fixtures for the FakeSession/repository-level unit suite. +# The testcontainers integration layer overrides these async in +# ``tests/integration/conftest.py`` to seed real rows; unit tests use the +# lightweight versions below. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def seeded_workspace_id() -> int: + """Workspace id used by route URLs. + + Unit tests pretend this exists via a FakeWorkspaceRepository. + Integration overrides this fixture (and seeds a real workspaces + row with the same id) in ``tests/integration/conftest.py``. + """ + return SEED_WORKSPACE_ID + + +@pytest.fixture +def override_user() -> Iterator[Callable]: + """Yields a setter that swaps `validate_token` for the duration of a test.""" + from api.core.security import validate_token + from api.main import app + + def _set(user) -> None: + app.dependency_overrides[validate_token] = lambda: user + + yield _set + app.dependency_overrides.pop(validate_token, None) + + +@pytest.fixture +def as_lead(override_user, seeded_workspace_id): + user = _make_user( + role="lead", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_contributor(override_user, seeded_workspace_id): + user = _make_user( + role="contributor", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_validator(override_user, seeded_workspace_id): + user = _make_user( + role="validator", + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user + + +@pytest.fixture +def as_outsider(override_user, seeded_workspace_id): + """User with no project-group association — tenancy gate should 404.""" + user = _make_user( + role=None, + workspace_id=seeded_workspace_id, + pg_id=SEED_PROJECT_GROUP_ID, + ) + override_user(user) + return user From 358184845bae85d11b04ccb84b0c1a1785c02cdd Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 16:50:51 -0400 Subject: [PATCH 102/159] Typing fixes --- api/src/tasking/projects/repository.py | 8 ++++++++ api/src/tasking/tasks/repository.py | 11 ++++++++++- tests/integration/conftest.py | 17 +++++++++-------- tests/integration/test_tasks_flow.py | 4 ++-- tests/unit/test_aoi_normalisation.py | 4 ++-- tests/unit/test_dtos_validation.py | 2 +- 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 7e31c69..fd58fc9 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1,3 +1,11 @@ +# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather +# than ``Mapped[int]``, so Pyright reads ``Column == value`` as ``bool`` and +# misjudges ``where()``/``select()``/``selectinload`` calls; the nullable +# ``deleted_at`` column additionally trips ``reportOptionalMemberAccess`` on +# ``.is_(None)``. These are framework false positives; the queries are valid at +# runtime. Other rules stay enabled so real bugs still surface. +# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportOptionalMemberAccess=false + from __future__ import annotations import json diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index ad99c12..76fa0e4 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -1,3 +1,11 @@ +# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather +# than ``Mapped[int]``, so Pyright reads ``Column == value`` as ``bool`` and +# misjudges ``where()``/``select()``/``selectinload`` calls; nullable columns +# (``deleted_at``, ``released_at``) additionally trip ``reportOptionalMemberAccess`` +# on ``.is_(None)``. These are framework false positives; the queries are valid at +# runtime. Other rules stay enabled so real bugs still surface. +# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportOptionalMemberAccess=false + from __future__ import annotations import hashlib @@ -1153,7 +1161,8 @@ async def submit_changeset( }, ) await self.session.commit() - return cs.id # Return the ID of the newly inserted changeset row + # PK is populated after flush(); SQLModel still types it ``int | None``. + return cs.id # type: ignore[return-value] __all__ = ["TaskingTaskRepository"] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index be2be67..a60efaf 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -117,7 +117,9 @@ def pytest_configure(config): if not _DOCKER_OK: return # let the fixture surface the skip with a clean reason try: - from testcontainers.postgres import PostgresContainer + from testcontainers.postgres import ( # pyright: ignore[reportMissingImports] + PostgresContainer, + ) except ImportError: return # ditto — fixture-time skip with install instructions @@ -149,7 +151,7 @@ def _pg_urls() -> Iterator[tuple[str, str]]: if not _DOCKER_OK: pytest.skip(f"Docker not available — {_DOCKER_REASON}") try: - import testcontainers.postgres # noqa: F401 + import testcontainers.postgres # noqa: F401 # pyright: ignore[reportMissingImports] except ImportError: pytest.skip( "testcontainers not installed; install with " @@ -319,8 +321,7 @@ def _clear_token() -> None: @pytest.fixture(autouse=True) async def _per_test_db_sessions(_pg_urls): - from sqlalchemy.ext.asyncio import create_async_engine - from sqlalchemy.orm import sessionmaker + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from sqlmodel.ext.asyncio.session import AsyncSession @@ -335,11 +336,11 @@ async def _per_test_db_sessions(_pg_urls): task_engine = create_async_engine(task_url, future=True, poolclass=NullPool) osm_engine = create_async_engine(osm_url, future=True, poolclass=NullPool) - task_factory = sessionmaker( - class_=AsyncSession, expire_on_commit=False, bind=task_engine + task_factory = async_sessionmaker( + bind=task_engine, class_=AsyncSession, expire_on_commit=False ) - osm_factory = sessionmaker( - class_=AsyncSession, expire_on_commit=False, bind=osm_engine + osm_factory = async_sessionmaker( + bind=osm_engine, class_=AsyncSession, expire_on_commit=False ) async def _get_task(): diff --git a/tests/integration/test_tasks_flow.py b/tests/integration/test_tasks_flow.py index 5af5c50..7b63ba8 100644 --- a/tests/integration/test_tasks_flow.py +++ b/tests/integration/test_tasks_flow.py @@ -503,7 +503,7 @@ async def test_02_contributor_locks_task_1( assert r.status_code == 200, r.text body = r.json() assert body["lock"] is not None - assert body["lock"]["user_id"] == str(self.contributor.user_uuid) + assert body["lock"]["user_id"] == str(self.contributor.user_uuid) # type: ignore[union-attr] async def test_03_contributor_cannot_lock_second_task( self, client, override_user, seeded_workspace_id @@ -659,7 +659,7 @@ async def test_02_contributor_lock_and_submit_done( body = r.json() assert body["status"] == "to_review" assert body["lock"] is None - assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) + assert body["last_mapper"]["user_id"] == str(self.contributor.user_uuid) # type: ignore[union-attr] async def test_03_contributor_cannot_lock_for_review( self, client, override_user, seeded_workspace_id diff --git a/tests/unit/test_aoi_normalisation.py b/tests/unit/test_aoi_normalisation.py index 86b253b..8e84a21 100644 --- a/tests/unit/test_aoi_normalisation.py +++ b/tests/unit/test_aoi_normalisation.py @@ -12,8 +12,8 @@ _Polygon, ) -SQUARE = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] -TWO_SQUARES = [ +SQUARE: list[list[list[float]]] = [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] +TWO_SQUARES: list[list[list[list[float]]]] = [ [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], [[[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]], ] diff --git a/tests/unit/test_dtos_validation.py b/tests/unit/test_dtos_validation.py index 8bad12f..6e206ca 100644 --- a/tests/unit/test_dtos_validation.py +++ b/tests/unit/test_dtos_validation.py @@ -93,4 +93,4 @@ def test_invalid_role_rejected(self): from uuid import uuid4 with pytest.raises(ValidationError): - ProjectRoleAssignment(user_id=uuid4(), role="admin") + ProjectRoleAssignment(user_id=uuid4(), role="admin") # type: ignore[arg-type] From 2eab87cd1e5f1d5ccfd1b7eda9541cb41b06ab4b Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 19:47:39 -0400 Subject: [PATCH 103/159] CI update and post-merge update to fix autoFlagReview --- .github/workflows/ci.yml | 21 ++++------------ api/main.py | 4 ++++ api/src/workspaces/schemas.py | 7 +++++- scripts/ci.sh | 45 +++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 17 deletions(-) create mode 100755 scripts/ci.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a32c5e..d55d041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,19 +32,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install the project - run: uv sync --all-extras - - - name: Check imports with isort - run: uv run isort --check-only --diff . - - - name: Check code formatting with black - run: uv run black --check . - - - name: Type-check with pyright - run: uvx pyright --pythonpath .venv/bin/python api tests - - # The suite needs no database: migrations are skipped under pytest and the - # DB session is mocked at the data-fetcher boundary (see CLAUDE.md). - - name: Run tests - run: uv run pytest tests + # Runs the same checks locals get from scripts/ci.sh: uv sync, isort, + # black, pyright, and pytest. The script runs every check and exits + # non-zero if any fail, so a single red step still lists all failures. + - name: Run CI checks + run: ./scripts/ci.sh diff --git a/api/main.py b/api/main.py index 533cf85..d537b72 100644 --- a/api/main.py +++ b/api/main.py @@ -24,6 +24,10 @@ validate_token, ) from api.src.osm.routes import router as osm_router +from api.src.tasking.audit.routes import router as tasking_audit_router +from api.src.tasking.projects.routes import me_router as tasking_me_router +from api.src.tasking.projects.routes import router as tasking_projects_router +from api.src.tasking.tasks.routes import router as tasking_tasks_router from api.src.teams.routes import router as teams_router from api.src.users.routes import router as users_router from api.src.workspaces.repository import WorkspaceRepository diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index cce4992..5099557 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -6,7 +6,7 @@ from geoalchemy2 import Geometry from pydantic import model_validator from sqlalchemy import JSON as SAJson -from sqlalchemy import Column, SmallInteger, TypeDecorator, Unicode +from sqlalchemy import Boolean, Column, SmallInteger, TypeDecorator, Unicode from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: @@ -301,6 +301,11 @@ class Workspace(SQLModel, table=True): kartaViewToken: Optional[str] = None + autoFlagReview: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + longFormQuestDef: Optional[WorkspaceLongQuest] = Relationship( sa_relationship_kwargs={ "uselist": False, diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..23c0c9a --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Run the CI checks locally, mirroring .github/workflows/ci.yml. +# +# Runs every check by default; a failure in one step does not stop the others, +# and the script exits non-zero if any step failed. Pass --fail-fast to stop at +# the first failure instead. +set -uo pipefail + +cd "$(dirname "$0")/.." + +fail_fast=0 +[[ "${1:-}" == "--fail-fast" ]] && fail_fast=1 + +failed=() + +run() { + local name="$1"; shift + echo "" + echo "==> ${name}" + if "$@"; then + echo "--- ${name}: OK" + else + echo "--- ${name}: FAILED" + failed+=("${name}") + [[ "${fail_fast}" == 1 ]] && summary_and_exit + fi +} + +summary_and_exit() { + echo "" + if [[ ${#failed[@]} -eq 0 ]]; then + echo "All CI checks passed." + exit 0 + fi + echo "CI checks FAILED: ${failed[*]}" + exit 1 +} + +run "Install the project" uv sync --all-extras +run "Check imports with isort" uv run isort --check-only --diff . +run "Check code formatting (black)" uv run black --check . +run "Type-check with pyright" uvx pyright --pythonpath .venv/bin/python api tests +run "Run tests" uv run pytest tests + +summary_and_exit From c2945a85b3d46044626a2687154e337d7925f45a Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 20:33:19 -0400 Subject: [PATCH 104/159] Fixes of TM tests --- ...61d7b3a_add_osm_augmented_diff_function.py | 10 +- api/src/tasking/projects/repository.py | 16 +- pyproject.toml | 11 ++ scripts/ci.sh | 22 ++- tests/integration/conftest.py | 138 ++++++++++++++---- tests/integration/test_audit_flow.py | 90 +++++++++--- uv.lock | 120 +++++++++++++++ 7 files changed, 355 insertions(+), 52 deletions(-) diff --git a/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py index c55a7a0..1fa4002 100644 --- a/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py +++ b/alembic_osm/versions/5303f61d7b3a_add_osm_augmented_diff_function.py @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision: str = "5303f61d7b3a" -down_revision: Union[str, None] = "9221408912dd" +down_revision: Union[str, None] = "a1b2c3d4e5f6" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,6 +22,14 @@ def upgrade() -> None: + # This SQL function references OSM core tables (nodes, ways, way_nodes) + # owned by the Rails website. Disable body validation for this transaction + # so the function can be created even when those tables aren't present yet + # (a fresh DB where alembic runs before the Rails schema load, or the + # integration-test container). pg_dump does the same when restoring + # SQL-language functions. The function is only invoked at runtime, by + # which point the OSM tables exist. + op.execute(text("SET LOCAL check_function_bodies = off")) op.execute(text((_SQL_DIR / "osm_augmented_diff.sql").read_text())) diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index fd58fc9..5e7da42 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1006,11 +1006,25 @@ async def add_role( missing = await self._missing_user_auth_uids([body.user_id]) if missing: # User never logged in, so we try to provision them from TDEI - provisioned_users = await self._provision_users_from_tdei( + still_missing = await self._provision_users_from_tdei( missing, project_group_id=project_group_id, bearer_token=user_token, ) + # If TDEI doesn't list the user either, surface the same structured + # 422 the create path returns rather than falling through to a raw + # foreign-key violation with a generic string detail. + if still_missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": ( + "The `user_id` is not a member of this " + "workspace's project group in TDEI." + ), + "missing_user_ids": still_missing, + }, + ) from sqlalchemy import text diff --git a/pyproject.toml b/pyproject.toml index a5001bf..032fdbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,22 @@ dependencies = [ "shapely>=2.1.2", ] +[project.optional-dependencies] +# Integration tests boot a real PostGIS database via testcontainers (needs a +# running Docker daemon). Install with `uv sync --extra integration` or +# `--all-extras`, then run them with `pytest -m integration`. +integration = [ + "testcontainers[postgres]>=4.0.0", +] + [tool.pytest.ini_options] addopts = "-v --cov=api --cov-report=term-missing" testpaths = ["tests"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +markers = [ + "integration: tests that require a live PostGIS database (Docker + testcontainers)", +] [tool.black] line-length = 88 diff --git a/scripts/ci.sh b/scripts/ci.sh index 23c0c9a..76c6179 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -2,14 +2,26 @@ # Run the CI checks locally, mirroring .github/workflows/ci.yml. # # Runs every check by default; a failure in one step does not stop the others, -# and the script exits non-zero if any step failed. Pass --fail-fast to stop at -# the first failure instead. +# and the script exits non-zero if any step failed. +# +# Flags: +# --fail-fast stop at the first failing step instead of running all +# --integration also run the integration suite (`pytest -m integration`), +# which boots a real PostGIS database via testcontainers and +# therefore needs a running Docker daemon set -uo pipefail cd "$(dirname "$0")/.." fail_fast=0 -[[ "${1:-}" == "--fail-fast" ]] && fail_fast=1 +integration=0 +for arg in "$@"; do + case "${arg}" in + --fail-fast) fail_fast=1 ;; + --integration) integration=1 ;; + *) echo "Unknown option: ${arg}" >&2; exit 2 ;; + esac +done failed=() @@ -42,4 +54,8 @@ run "Check code formatting (black)" uv run black --check . run "Type-check with pyright" uvx pyright --pythonpath .venv/bin/python api tests run "Run tests" uv run pytest tests +if [[ "${integration}" == 1 ]]; then + run "Run integration tests" uv run pytest tests -m integration +fi + summary_and_exit diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a60efaf..6231ce8 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -180,13 +180,71 @@ def _pg_url(_pg_urls: tuple[str, str]) -> str: # --------------------------------------------------------------------------- +async def _bootstrap_osm_database(osm_url: str) -> None: + """Prepare the provisioned OSM database for the osm alembic tree. + + Two things the tree assumes but does not create itself: + + * **PostGIS** — the tasking_* migration requires the extension and raises + if it is missing. The container image ships PostGIS but the extension + must be enabled per-database, and the freshly ``CREATE DATABASE``'d + ``osm_test`` doesn't inherit it. + * **users** — owned by the OSM Rails website, not these alembic trees: + migration ``9221408912dd`` adds a unique constraint to it and the + ``tasking_*`` foreign keys reference ``users.id`` / ``users.auth_uid``, + all assuming it already exists. We stand up a stripped-down version + (just the columns the migrations and ``_insert_user_row`` touch). + """ + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(osm_url, isolation_level="AUTOCOMMIT") + try: + async with engine.connect() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) + await conn.execute( + text( + # Columns mirror the subset of the OSM Rails `users` table + # that the tasking code reads or writes: the FK targets + # (id, auth_uid), the seed/display columns, and every column + # the TDEI auto-provisioning INSERT populates + # (see TaskingProjectRepository._provision_users_from_tdei). + "CREATE TABLE IF NOT EXISTS users (" + " id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY," + " auth_uid varchar," + " email varchar," + " display_name varchar," + " auth_provider varchar," + " status varchar," + " pass_crypt varchar," + " data_public boolean," + " email_valid boolean," + " terms_seen boolean," + " creation_time timestamp," + " terms_agreed timestamp," + " tou_agreed timestamp" + ")" + ) + ) + finally: + await engine.dispose() + + @pytest.fixture(scope="session") def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]: """Run alembic upgrades for both trees against their own databases. Returns ``(task_url, osm_url)`` after both heads are reached. """ + import asyncio + task_url, osm_url = _pg_urls + + # PostGIS + the OSM-website-owned `users` table must exist before the osm + # alembic tree upgrades (its migrations require the extension and + # constrain `users.auth_uid`). + asyncio.run(_bootstrap_osm_database(osm_url)) + repo_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) @@ -214,39 +272,50 @@ def _migrated_db(_pg_urls: tuple[str, str]) -> tuple[str, str]: # --------------------------------------------------------------------------- +async def _insert_workspace_row(task_url: str) -> None: + """Insert the seed workspace row into the TASK database.""" + from uuid import uuid4 + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine(task_url, future=True) + try: + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO workspaces " + '(id, type, title, "tdeiProjectGroupId", "createdAt", ' + ' "createdBy", "createdByName", "externalAppAccess") ' + "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " + "ON CONFLICT (id) DO NOTHING" + ), + { + "id": SEED_WORKSPACE_ID, + "type": "osw", + "title": "Test Workspace", + "pgid": str(SEED_PROJECT_GROUP_ID), + "uid": str(uuid4()), + "uname": "seed", + }, + ) + finally: + await engine.dispose() + + @pytest.fixture(scope="session") -async def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: +def _seed_workspace_row(_migrated_db: tuple[str, str]) -> int: """Insert one workspace row so tenancy/permission gates resolve. Workspaces live in the TASK database (alongside teams, project groups), - so we point this at task_url. + so we point this at task_url. Kept synchronous (driving the async insert + via ``asyncio.run``) because a session-scoped *async* fixture would clash + with pytest-asyncio's function-scoped event loop. """ - from uuid import uuid4 - - from sqlalchemy import text - from sqlalchemy.ext.asyncio import create_async_engine + import asyncio task_url, _osm_url = _migrated_db - engine = create_async_engine(task_url, future=True) - async with engine.begin() as conn: - await conn.execute( - text( - "INSERT INTO workspaces " - '(id, type, title, "tdeiProjectGroupId", "createdAt", ' - ' "createdBy", "createdByName", "externalAppAccess") ' - "VALUES (:id, :type, :title, :pgid, NOW(), :uid, :uname, 0) " - "ON CONFLICT (id) DO NOTHING" - ), - { - "id": SEED_WORKSPACE_ID, - "type": "osw", - "title": "Test Workspace", - "pgid": str(SEED_PROJECT_GROUP_ID), - "uid": str(uuid4()), - "uname": "seed", - }, - ) - await engine.dispose() + asyncio.run(_insert_workspace_row(task_url)) return SEED_WORKSPACE_ID @@ -319,6 +388,23 @@ def _clear_token() -> None: # --------------------------------------------------------------------------- +@pytest.fixture +def app(): + """Real app for the integration suite. + + Overrides the unit-suite ``app`` fixture (tests/conftest.py), which swaps + in ``FakeSession`` objects for the DB dependencies. Here we want the real + engines wired up by the autouse ``_per_test_db_sessions`` fixture, so this + just yields the actual FastAPI app untouched. The shared ``client`` + fixture depends on ``app`` by name and picks this up for integration + tests. Per-test overrides (sessions, ``validate_token``) are installed and + torn down by their own fixtures. + """ + from api.main import app as fastapi_app + + return fastapi_app + + @pytest.fixture(autouse=True) async def _per_test_db_sessions(_pg_urls): from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index 3cc37ec..c322539 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -41,10 +41,23 @@ def _fc(*polys): # --------------------------------------------------------------------------- -async def _open_project_with_tasks(client, workspace_id): +async def _open_project_with_tasks(client, workspace_id, contributor): + """Create → AOI → save tasks → activate; returns the project id. + + Caller must already be acting as a LEAD (every step here is LEAD-only). + ``contributor`` is a UserInfo already persisted in the OSM ``users`` table + (via ``extra_user_factory``); allocating it satisfies the activation + pre-check that a project have ≥1 contributor or validator. + """ r = await client.post( API.format(wid=workspace_id), - json={"name": f"audit-{id(client)}", "review_required": False}, + json={ + "name": f"audit-{id(client)}", + "review_required": False, + "role_assignments": [ + {"user_id": str(contributor.user_uuid), "role": "contributor"}, + ], + }, ) assert r.status_code == 201, r.text pid = r.json()["id"] @@ -56,7 +69,7 @@ async def _open_project_with_tasks(client, workspace_id): r = await client.post( f"{API.format(wid=workspace_id)}/{pid}/tasks/save", - json={"feature_collection": _fc(TASK_A, TASK_B)}, + json={"source": "import", "feature_collection": _fc(TASK_A, TASK_B)}, ) assert r.status_code == 201, r.text @@ -73,10 +86,13 @@ async def _open_project_with_tasks(client, workspace_id): class TestProjectAuditListing: async def test_lists_lifecycle_events_newest_first( - self, client, as_lead, seeded_workspace_id + self, client, as_lead, seeded_workspace_id, extra_user_factory ): """Project create → AOI upload → tasks → activate all appear in audit.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") assert r.status_code == 200, r.text @@ -91,9 +107,14 @@ async def test_lists_lifecycle_events_newest_first( ts = [row["occurred_at"] for row in body["results"]] assert ts == sorted(ts, reverse=True) - async def test_filter_by_event_type(self, client, as_lead, seeded_workspace_id): + async def test_filter_by_event_type( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): """`event_type` query narrows results to one kind.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", @@ -103,9 +124,14 @@ async def test_filter_by_event_type(self, client, as_lead, seeded_workspace_id): kinds = {row["event_type"] for row in r.json()["results"]} assert kinds == {"project_activated"} - async def test_filter_by_actor(self, client, as_lead, seeded_workspace_id): + async def test_filter_by_actor( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): """`actor_user_id` filters to events emitted by that user only.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", params={"actor_user_id": str(as_lead.user_uuid)}, @@ -115,10 +141,13 @@ async def test_filter_by_actor(self, client, as_lead, seeded_workspace_id): assert row["actor"]["user_id"] == str(as_lead.user_uuid) async def test_pagination_clamps_and_total( - self, client, as_lead, seeded_workspace_id + self, client, as_lead, seeded_workspace_id, extra_user_factory ): """Page size of 1 still returns one row; total reflects the whole set.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", params={"page_size": 1, "page": 1}, @@ -143,12 +172,22 @@ async def test_unknown_project_404(self, client, as_lead, seeded_workspace_id): class TestTaskAuditListing: async def test_lists_task_events( - self, client, as_lead, as_contributor, seeded_workspace_id + self, + client, + as_lead, + seeded_workspace_id, + extra_user_factory, + override_user, ): """Lock/unlock on a task surface in /tasks/{n}/audit.""" - # `as_lead` opens the project (lead-only), then switch to contributor - # to perform lock + unlock so we generate task events. - pid = await _open_project_with_tasks(client, seeded_workspace_id) + # `as_lead` opens the project (lead-only) with a contributor allocated, + # then we switch to that contributor to lock + unlock so we generate + # task events. + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) + override_user(contributor) # Contributor locks task 1. r = await client.post( @@ -169,13 +208,19 @@ async def test_lists_task_events( kinds = {row["event_type"] for row in body["results"]} assert "task_locked" in kinds assert "task_unlocked" in kinds - # Every row should reference the right task (by id or task_number). + # The endpoint is task-scoped via `details.task_number`, so every row + # should reference task 1. for row in body["results"]: - assert row["task_id"] is not None or row.get("task_number") == 1 + assert row["details"].get("task_number") == 1 - async def test_unknown_task_404(self, client, as_lead, seeded_workspace_id): + async def test_unknown_task_404( + self, client, as_lead, seeded_workspace_id, extra_user_factory + ): """A bogus task number on a real project returns 404.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/99/audit" ) @@ -190,10 +235,13 @@ async def test_unknown_task_404(self, client, as_lead, seeded_workspace_id): class TestAuditIncludeDeleted: async def test_deleted_project_hidden_by_default( - self, client, as_lead, seeded_workspace_id + self, client, as_lead, seeded_workspace_id, extra_user_factory ): """A soft-deleted project's audit returns 404 unless `include_deleted=true`.""" - pid = await _open_project_with_tasks(client, seeded_workspace_id) + contributor = await extra_user_factory("contributor") + pid = await _open_project_with_tasks( + client, seeded_workspace_id, contributor + ) # Project must be closed before delete. r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/close") diff --git a/uv.lock b/uv.lock index 519273f..8d2c188 100644 --- a/uv.lock +++ b/uv.lock @@ -365,6 +365,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "ecdsa" version = "0.19.0" @@ -933,6 +947,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1239,6 +1272,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -1333,6 +1382,11 @@ dependencies = [ { name = "uvicorn" }, ] +[package.optional-dependencies] +integration = [ + { name = "testcontainers" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, @@ -1364,5 +1418,71 @@ requires-dist = [ { name = "shapely", specifier = ">=2.1.2" }, { name = "sqlalchemy", specifier = ">=2.0.36" }, { name = "sqlmodel", specifier = ">=0.0.8" }, + { name = "testcontainers", extras = ["postgres"], marker = "extra == 'integration'", specifier = ">=4.0.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] +provides-extras = ["integration"] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] From e42410c917ed1083af781e155668e27ec9501e4e Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 20:38:59 -0400 Subject: [PATCH 105/159] Update test_audit_flow.py --- tests/integration/test_audit_flow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index c322539..d12c535 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -208,10 +208,11 @@ async def test_lists_task_events( kinds = {row["event_type"] for row in body["results"]} assert "task_locked" in kinds assert "task_unlocked" in kinds - # The endpoint is task-scoped via `details.task_number`, so every row - # should reference task 1. + # The endpoint scopes by the `task_id` column; the task number is + # echoed in `details` as `taskNumber` (camelCase, as emitted by the + # task repository). Every row should reference task 1. for row in body["results"]: - assert row["details"].get("task_number") == 1 + assert row["details"].get("taskNumber") == 1 async def test_unknown_task_404( self, client, as_lead, seeded_workspace_id, extra_user_factory From 1c7673c080f0936ca3c5f609434964ac4c4359ac Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 20:47:00 -0400 Subject: [PATCH 106/159] Test cleanup --- api/src/tasking/audit/repository.py | 13 ++++---- pyproject.toml | 8 +++++ tests/integration/conftest.py | 47 +++++++++++++++++++++-------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/api/src/tasking/audit/repository.py b/api/src/tasking/audit/repository.py index 0234f0f..f5985b8 100644 --- a/api/src/tasking/audit/repository.py +++ b/api/src/tasking/audit/repository.py @@ -145,15 +145,16 @@ async def list_project_events( page, page_size = _clamp_page(page, page_size, max_page_size=200) order_dir = _normalise_dir(order_dir) - # `task_number` is stored inside `details` JSONB rather than as a - # column, so filter via the typed accessor. + # The task number is stored inside `details` JSONB rather than as a + # column, under the camelCase key `taskNumber` that the task + # repository emits, so filter via the typed accessor on that key. where = ["project_id = :pid"] params: dict = {"pid": project_id} if event_type is not None: where.append("event_type = :et") params["et"] = event_type.value if task_number is not None: - where.append("(details->>'task_number')::int = :tn") + where.append("(details->>'taskNumber')::int = :tn") params["tn"] = task_number if actor_user_id is not None: where.append("actor_user_auth_uid = :au") @@ -215,14 +216,14 @@ async def list_task_events( page, page_size = _clamp_page(page, page_size, max_page_size=200) order_dir = _normalise_dir(order_dir) - # Match either `task_id = :tid` or the `task_number` in `details` + # Match either `task_id = :tid` or the `taskNumber` in `details` # — early audit rows for task creation set task_id but later # rows that reference a task (e.g. project-level lock-extensions) - # may only persist the task_number. Both forms point at the + # may only persist the taskNumber. Both forms point at the # same task, so OR them. where = [ "project_id = :pid", - "(task_id = :tid OR (details->>'task_number')::int = :tn)", + "(task_id = :tid OR (details->>'taskNumber')::int = :tn)", ] params: dict = {"pid": project_id, "tid": task_id, "tn": task_number} if event_type is not None: diff --git a/pyproject.toml b/pyproject.toml index 032fdbc..79ade0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,14 @@ asyncio_default_fixture_loop_scope = "function" markers = [ "integration: tests that require a live PostGIS database (Docker + testcontainers)", ] +filterwarnings = [ + # SQLModel deprecates `session.execute()` in favour of `session.exec()`, + # but many repository queries are raw `text()` SQL where `execute()` is the + # correct call. Silence this one nudge rather than rewrite ~85 call sites; + # every other warning still surfaces. (?s) lets `.*` span the multi-line + # warning body. + "ignore:(?s).*You probably want to use .session.exec:DeprecationWarning", +] [tool.black] line-length = 88 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6231ce8..d99bfc2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -389,24 +389,45 @@ def _clear_token() -> None: @pytest.fixture -def app(): - """Real app for the integration suite. - - Overrides the unit-suite ``app`` fixture (tests/conftest.py), which swaps - in ``FakeSession`` objects for the DB dependencies. Here we want the real - engines wired up by the autouse ``_per_test_db_sessions`` fixture, so this - just yields the actual FastAPI app untouched. The shared ``client`` - fixture depends on ``app`` by name and picks this up for integration - tests. Per-test overrides (sessions, ``validate_token``) are installed and - torn down by their own fixtures. +def app(request, task_session, osm_session): + """App fixture for everything under ``tests/integration/``. + + This directory hosts two kinds of test that share one conftest: + + * **Container-backed** tests (marked ``integration``) run against a real + PostGIS database; their DB sessions are the real engines wired by the + autouse ``_per_test_db_sessions`` fixture, so we hand back the app + untouched here. + * **FakeSession-backed** tests (unmarked — the CLAUDE.md data-fetcher + model) run the real routes/repositories but fake the ``AsyncSession``. + For those we wire the fakes in exactly like the unit-suite ``app`` + fixture this shadows. + + Keying on the ``integration`` marker keeps the container machinery from + leaking onto the FakeSession tests (which need no Docker). """ + from api.core.database import get_osm_session, get_task_session from api.main import app as fastapi_app - return fastapi_app + if request.node.get_closest_marker("integration"): + yield fastapi_app + return + + fastapi_app.dependency_overrides[get_task_session] = lambda: task_session + fastapi_app.dependency_overrides[get_osm_session] = lambda: osm_session + yield fastapi_app + fastapi_app.dependency_overrides.clear() @pytest.fixture(autouse=True) -async def _per_test_db_sessions(_pg_urls): +async def _per_test_db_sessions(request): + # Only container-backed (``integration``-marked) tests need real DB + # sessions; for FakeSession tests this fixture is a no-op so they never + # pull in ``_pg_urls`` (which would boot / require the testcontainer). + if not request.node.get_closest_marker("integration"): + yield + return + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from sqlmodel.ext.asyncio.session import AsyncSession @@ -414,7 +435,7 @@ async def _per_test_db_sessions(_pg_urls): from api.core.database import get_osm_session, get_task_session from api.main import app - task_url, osm_url = _pg_urls + task_url, osm_url = request.getfixturevalue("_pg_urls") # NullPool disables connection reuse: each session checkout opens a # fresh connection on the current event loop and disposes it on From 6e24d5ccffaac3ece5e8868eeec74b26611136a5 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 20:48:22 -0400 Subject: [PATCH 107/159] Linter fixes --- .../b3f8a2c91e04_add_auto_flag_review.py | 1 - api/src/osm/repository.py | 6 +--- api/src/osm/routes.py | 5 ++-- api/src/workspaces/schemas.py | 1 - scripts/fix-lint.sh | 20 +++++++++++++ tests/integration/test_audit_flow.py | 28 +++++-------------- 6 files changed, 31 insertions(+), 30 deletions(-) create mode 100755 scripts/fix-lint.sh diff --git a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py index e6095bb..2aeb46a 100644 --- a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py +++ b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py @@ -9,7 +9,6 @@ from typing import Sequence, Union import sqlalchemy as sa - from alembic import op revision: str = "b3f8a2c91e04" diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index d0781de..53dcee8 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -35,11 +35,7 @@ async def getWorkspaceBBox( return retVal - async def getChangesetAdiff( - self, - workspace_id: int, - changeset_id: int - ) -> list: + async def getChangesetAdiff(self, workspace_id: int, changeset_id: int) -> list: await self.session.execute( text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") ) diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py index d2f4677..452b2d4 100644 --- a/api/src/osm/routes.py +++ b/api/src/osm/routes.py @@ -60,8 +60,9 @@ async def resolve_changeset( repository_osm: OSMRepository = Depends(get_osm_repo), current_user: UserInfo = Depends(validate_token), ) -> None: - if (not current_user.isWorkspaceLead(workspace_id) - and not current_user.isWorkspaceValidator(workspace_id)): + if not current_user.isWorkspaceLead( + workspace_id + ) and not current_user.isWorkspaceValidator(workspace_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only workspace leads and validators can resolve changesets", diff --git a/api/src/workspaces/schemas.py b/api/src/workspaces/schemas.py index 5099557..9614338 100644 --- a/api/src/workspaces/schemas.py +++ b/api/src/workspaces/schemas.py @@ -320,4 +320,3 @@ class Workspace(SQLModel, table=True): "cascade": "all, delete-orphan", } ) - diff --git a/scripts/fix-lint.sh b/scripts/fix-lint.sh new file mode 100755 index 0000000..f8319ef --- /dev/null +++ b/scripts/fix-lint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Auto-fix the formatting issues that scripts/ci.sh checks for. +# +# Runs isort then black in write mode over the repo (the same tools/config CI +# enforces, minus the `--check`). Safe to run repeatedly; it only rewrites files +# that are not already compliant. Does not touch pyright — type errors are not +# auto-fixable. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Sorting imports (isort)" +uv run isort . + +echo "" +echo "==> Formatting code (black)" +uv run black . + +echo "" +echo "Done. Review the changes with 'git diff', then re-run ./scripts/ci.sh." diff --git a/tests/integration/test_audit_flow.py b/tests/integration/test_audit_flow.py index d12c535..26a6fdb 100644 --- a/tests/integration/test_audit_flow.py +++ b/tests/integration/test_audit_flow.py @@ -90,9 +90,7 @@ async def test_lists_lifecycle_events_newest_first( ): """Project create → AOI upload → tasks → activate all appear in audit.""" contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get(f"{API.format(wid=seeded_workspace_id)}/{pid}/audit") assert r.status_code == 200, r.text @@ -112,9 +110,7 @@ async def test_filter_by_event_type( ): """`event_type` query narrows results to one kind.""" contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", @@ -129,9 +125,7 @@ async def test_filter_by_actor( ): """`actor_user_id` filters to events emitted by that user only.""" contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", params={"actor_user_id": str(as_lead.user_uuid)}, @@ -145,9 +139,7 @@ async def test_pagination_clamps_and_total( ): """Page size of 1 still returns one row; total reflects the whole set.""" contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/audit", params={"page_size": 1, "page": 1}, @@ -184,9 +176,7 @@ async def test_lists_task_events( # then we switch to that contributor to lock + unlock so we generate # task events. contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) override_user(contributor) # Contributor locks task 1. @@ -219,9 +209,7 @@ async def test_unknown_task_404( ): """A bogus task number on a real project returns 404.""" contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) r = await client.get( f"{API.format(wid=seeded_workspace_id)}/{pid}/tasks/99/audit" ) @@ -240,9 +228,7 @@ async def test_deleted_project_hidden_by_default( ): """A soft-deleted project's audit returns 404 unless `include_deleted=true`.""" contributor = await extra_user_factory("contributor") - pid = await _open_project_with_tasks( - client, seeded_workspace_id, contributor - ) + pid = await _open_project_with_tasks(client, seeded_workspace_id, contributor) # Project must be closed before delete. r = await client.post(f"{API.format(wid=seeded_workspace_id)}/{pid}/close") From f346cb07e9a8cc2e45d429e573ca7e7664ee5ba6 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 20:51:07 -0400 Subject: [PATCH 108/159] Update repository.py --- api/src/osm/repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index 53dcee8..56bc6b7 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -44,7 +44,7 @@ async def getChangesetAdiff(self, workspace_id: int, changeset_id: int) -> list: {"changeset_id": changeset_id}, ) - return result.mappings().all() + return list(result.mappings().all()) async def resolveChangeset( self, From 9a1ca70966cbb8380ed9f6fbf81422270efb1726 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 20:53:35 -0400 Subject: [PATCH 109/159] Update ci.yml --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d55d041..619c43f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,10 @@ jobs: python-version: ${{ matrix.python-version }} # Runs the same checks locals get from scripts/ci.sh: uv sync, isort, - # black, pyright, and pytest. The script runs every check and exits - # non-zero if any fail, so a single red step still lists all failures. + # black, pyright, and pytest. `--integration` additionally runs the + # PostGIS/testcontainers suite (`pytest -m integration`); the ubuntu-latest + # runner ships a running Docker daemon, which testcontainers needs to boot + # the database. The script runs every check and exits non-zero if any fail, + # so a single red step still lists all failures. - name: Run CI checks - run: ./scripts/ci.sh + run: ./scripts/ci.sh --integration From 8c4c4ae135587ebf9233b61299c9c80e3d020f40 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 7 Jul 2026 22:03:26 -0400 Subject: [PATCH 110/159] Fix blanket file-wide type ignores --- CLAUDE.md | 12 ++-- api/src/tasking/projects/repository.py | 52 +++++++++++------ api/src/tasking/tasks/repository.py | 80 +++++++++++++++++--------- api/src/teams/repository.py | 54 +++++++++++------ api/src/users/repository.py | 32 ++++++----- api/src/workspaces/repository.py | 23 ++++---- 6 files changed, 158 insertions(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2b1a35..8e25c07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,10 +151,14 @@ that is a code change in the read routes, not a test change. SQLModel declares columns as plain annotations (e.g. `id: int | None`) rather than `Mapped[int]`, so Pyright reads `Column == value` as `bool` and flags `where()`/`exec()`/`select()`/`selectinload` calls and `result.rowcount`. These -are framework false positives. The three repository modules carry a documented -file-level `# pyright: reportArgumentType=false, reportCallIssue=false, -reportAttributeAccessIssue=false` directive; other rules stay enabled so real -bugs still surface. Keep `api/` and `tests/` at zero Pyright errors. +are framework false positives. Suppress them with **targeted, inline** +`# pyright: ignore[]` comments at the specific offending call sites (e.g. +`# pyright: ignore[reportArgumentType]` on a `.where(Column == value)` line) — +not blanket file-level `# pyright:` directives, which would hide genuine errors +of those rules elsewhere in the file. Note Black may wrap a long query line and +move a trailing comment off the flagged line; place the ignore on the line +Pyright actually reports (often the inner `== value` line) so it survives +formatting. Keep `api/` and `tests/` at zero Pyright errors. ### Alembic enum migrations diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 5e7da42..3aa6820 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1,11 +1,3 @@ -# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather -# than ``Mapped[int]``, so Pyright reads ``Column == value`` as ``bool`` and -# misjudges ``where()``/``select()``/``selectinload`` calls; the nullable -# ``deleted_at`` column additionally trips ``reportOptionalMemberAccess`` on -# ``.is_(None)``. These are framework false positives; the queries are valid at -# runtime. Other rules stay enabled so real bugs still surface. -# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportOptionalMemberAccess=false - from __future__ import annotations import json @@ -96,7 +88,7 @@ def _aoi_to_shapely(aoi: AoiInput) -> ShapelyMultiPolygon: if not geom.is_valid: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", + detail=f"AOI is not a valid polygon: {geom.is_valid_reason if hasattr(geom, 'is_valid_reason') else 'self-intersection or invalid ring'}", # pyright: ignore[reportAttributeAccessIssue] ) return geom @@ -221,7 +213,11 @@ async def _get_active(self, workspace_id: int, project_id: int) -> TaskingProjec select(TaskingProject).where( (TaskingProject.id == project_id) & (TaskingProject.workspace_id == workspace_id) - & (TaskingProject.deleted_at.is_(None)) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) project = result.scalar_one_or_none() @@ -387,7 +383,9 @@ async def list_projects( col = col.desc() if order_dir.upper() == "DESC" else col.asc() where = (TaskingProject.workspace_id == workspace_id) & ( - TaskingProject.deleted_at.is_(None) + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) ) if status_filter is not None: where = where & (TaskingProject.status == status_filter) @@ -634,7 +632,10 @@ async def patch( try: await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id + == project.id # pyright: ignore[reportArgumentType] + ) .values(**updates) ) await self._audit( @@ -678,7 +679,9 @@ async def soft_delete( # Soft-delete the project, hard-delete its tasks, flag audit rows. await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values(deleted_at=datetime.now()) ) await self.session.execute( @@ -748,7 +751,9 @@ async def activate( await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) ) await self._audit( @@ -799,7 +804,9 @@ async def close( await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values(status=ProjectStatus.DONE, updated_at=datetime.now()) ) await self._audit( @@ -847,7 +854,10 @@ async def reset( if project.status == ProjectStatus.DONE: await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id + == project.id # pyright: ignore[reportArgumentType] + ) .values(status=ProjectStatus.OPEN, updated_at=datetime.now()) ) @@ -870,7 +880,7 @@ async def get_aoi(self, workspace_id: int, project_id: int) -> AoiFeature: geom = to_shape(project.aoi) if isinstance(geom, ShapelyPolygon): # defensive geom = ShapelyMultiPolygon([geom]) - return _shapely_to_aoi_feature(geom) + return _shapely_to_aoi_feature(geom) # pyright: ignore[reportArgumentType] async def upload_aoi( self, workspace_id: int, project_id: int, aoi: AoiInput, current_user: UserInfo @@ -893,7 +903,9 @@ async def upload_aoi( ) await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values( aoi=from_shape(geom, srid=4326), task_boundary_type=None, @@ -1355,7 +1367,9 @@ async def delete_aoi( ) await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values( aoi=None, task_boundary_type=None, diff --git a/api/src/tasking/tasks/repository.py b/api/src/tasking/tasks/repository.py index 76fa0e4..40e8232 100644 --- a/api/src/tasking/tasks/repository.py +++ b/api/src/tasking/tasks/repository.py @@ -1,11 +1,3 @@ -# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather -# than ``Mapped[int]``, so Pyright reads ``Column == value`` as ``bool`` and -# misjudges ``where()``/``select()``/``selectinload`` calls; nullable columns -# (``deleted_at``, ``released_at``) additionally trip ``reportOptionalMemberAccess`` -# on ``.is_(None)``. These are framework false positives; the queries are valid at -# runtime. Other rules stay enabled so real bugs still surface. -# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportOptionalMemberAccess=false - from __future__ import annotations import hashlib @@ -179,7 +171,11 @@ def _generate_grid_over_aoi( # `intersection` can return a Polygon, MultiPolygon, # or GeometryCollection; retain polygon pieces only. geoms = ( - list(clipped.geoms) if hasattr(clipped, "geoms") else [clipped] + list( + clipped.geoms # pyright: ignore[reportAttributeAccessIssue] + ) + if hasattr(clipped, "geoms") + else [clipped] ) for piece in geoms: if isinstance(piece, ShapelyPolygon) and piece.area > 0: @@ -215,7 +211,11 @@ async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProje select(TaskingProject).where( (TaskingProject.id == project_id) & (TaskingProject.workspace_id == workspace_id) - & (TaskingProject.deleted_at.is_(None)) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) project = rs.scalar_one_or_none() @@ -226,7 +226,9 @@ async def _get_project(self, workspace_id: int, project_id: int) -> TaskingProje async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: rs = await self.session.execute( select(TaskingTask).where( - (TaskingTask.project_id == project_id) + ( + TaskingTask.project_id == project_id + ) # pyright: ignore[reportArgumentType] & (TaskingTask.task_number == task_number) ) ) @@ -240,7 +242,12 @@ async def _get_task(self, project_id: int, task_number: int) -> TaskingTask: async def _get_active_lock(self, task_id: int) -> Optional[TaskingLock]: rs = await self.session.execute( select(TaskingLock).where( - (TaskingLock.task_id == task_id) & (TaskingLock.released_at.is_(None)) + (TaskingLock.task_id == task_id) + & ( + TaskingLock.released_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) return rs.scalar_one_or_none() @@ -252,7 +259,11 @@ async def _get_active_lock_for_user_in_project( select(TaskingLock).where( (TaskingLock.project_id == project_id) & (TaskingLock.user_auth_uid == user_auth_uid) - & (TaskingLock.released_at.is_(None)) + & ( + TaskingLock.released_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) ) ) return rs.scalar_one_or_none() @@ -390,7 +401,9 @@ async def generate_grid( if isinstance(aoi_geom, ShapelyPolygon): aoi_geom = ShapelyMultiPolygon([aoi_geom]) - cells = _generate_grid_over_aoi(aoi_geom, float(cell_size_m)) + cells = _generate_grid_over_aoi( + aoi_geom, float(cell_size_m) # pyright: ignore[reportArgumentType] + ) if not cells: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -537,7 +550,9 @@ async def save( task = TaskingTask( project_id=project.id, # type: ignore[arg-type] task_number=idx + 1, - area_sqkm=round(_polygon_area_km2(poly), 4), + area_sqkm=round( + _polygon_area_km2(poly), 4 + ), # pyright: ignore[reportArgumentType] status=TaskStatus.TO_MAP, geometry=from_shape(poly, srid=4326), ) @@ -549,7 +564,9 @@ async def save( # Set boundary type + bump project updated_at. await self.session.execute( update(TaskingProject) - .where(TaskingProject.id == project.id) + .where( + TaskingProject.id == project.id # pyright: ignore[reportArgumentType] + ) .values( task_boundary_type=body.source, updated_at=datetime.now(), @@ -616,7 +633,9 @@ async def list_tasks( where = where & (TaskingTask.last_mapper_id == str(last_mapper_id)) total_q = await self.session.execute( - select(func.count()).select_from(TaskingTask).where(where) + select(func.count()) + .select_from(TaskingTask) + .where(where) # pyright: ignore[reportArgumentType] ) total = int(total_q.scalar() or 0) @@ -626,8 +645,10 @@ async def list_tasks( rows = await self.session.execute( select(TaskingTask) - .where(where) - .order_by(TaskingTask.task_number.asc()) + .where(where) # pyright: ignore[reportArgumentType] + .order_by( + TaskingTask.task_number.asc() # pyright: ignore[reportAttributeAccessIssue] + ) .limit(page_size) .offset(offset) ) @@ -727,7 +748,10 @@ async def lock_task( ) if other is not None: other_task_rs = await self.session.execute( - select(TaskingTask).where(TaskingTask.id == other.task_id) + select(TaskingTask).where( + TaskingTask.id + == other.task_id # pyright: ignore[reportArgumentType] + ) ) other_task = other_task_rs.scalar_one() summary = ExistingLockSummary( @@ -803,7 +827,7 @@ async def unlock_task( now = datetime.now() await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values(released_at=now, release_reason=release_reason) ) await self._audit( @@ -846,7 +870,7 @@ async def extend_lock( new_expiry = prev + timedelta(hours=project.lock_timeout_hours) await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values(expires_at=new_expiry) ) await self._audit( @@ -884,7 +908,7 @@ async def reset_task( if lock is not None: await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values( released_at=now, release_reason=LockReleaseReason.RESET, @@ -905,7 +929,7 @@ async def reset_task( if previous_status != TaskStatus.TO_MAP: await self.session.execute( update(TaskingTask) - .where(TaskingTask.id == task.id) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] .values( status=TaskStatus.TO_MAP, last_mapper_id=None, @@ -927,7 +951,7 @@ async def reset_task( # Clear last_mapper_id even if state was already to_map. await self.session.execute( update(TaskingTask) - .where(TaskingTask.id == task.id) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] .values(last_mapper_id=None, updated_at=now) ) @@ -1009,7 +1033,7 @@ async def submit( new_expiry = now + timedelta(hours=project.lock_timeout_hours) await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values(expires_at=new_expiry) ) await self._audit( @@ -1075,7 +1099,7 @@ async def submit( # Release the lock (auto_unlock). await self.session.execute( update(TaskingLock) - .where(TaskingLock.id == lock.id) + .where(TaskingLock.id == lock.id) # pyright: ignore[reportArgumentType] .values( released_at=now, release_reason=LockReleaseReason.AUTO_UNLOCK, @@ -1095,7 +1119,7 @@ async def submit( # Apply state transition. await self.session.execute( update(TaskingTask) - .where(TaskingTask.id == task.id) + .where(TaskingTask.id == task.id) # pyright: ignore[reportArgumentType] .values( status=new_status, last_mapper_id=new_last_mapper, diff --git a/api/src/teams/repository.py b/api/src/teams/repository.py index 597aa96..c7a9f9e 100644 --- a/api/src/teams/repository.py +++ b/api/src/teams/repository.py @@ -1,9 +1,3 @@ -# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather -# than ``Mapped[int]``, so Pyright reads ``Column == value`` as a ``bool`` and -# misjudges ``where()``/``exec()``/``select()``/``selectinload`` calls. These are -# framework false positives; the queries are valid at runtime. -# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false - from sqlalchemy import delete, exists, select from sqlalchemy.orm import selectinload from sqlmodel.ext.asyncio.session import AsyncSession @@ -24,21 +18,32 @@ def __init__(self, session: AsyncSession): self.session = session async def get_all(self, workspace_id: int) -> list[WorkspaceTeamItem]: - result = await self.session.exec( + result = await self.session.exec( # pyright: ignore[reportCallIssue] select(WorkspaceTeam) - .options(selectinload(WorkspaceTeam.users)) - .where(WorkspaceTeam.workspace_id == workspace_id) + .options( + selectinload(WorkspaceTeam.users) # pyright: ignore[reportArgumentType] + ) + .where( + WorkspaceTeam.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) return [WorkspaceTeamItem.from_team(x) for x in result.scalars().all()] async def get(self, id: int, load_members: bool = False) -> WorkspaceTeam: - query = select(WorkspaceTeam).where(WorkspaceTeam.id == id) + query = select(WorkspaceTeam).where( + WorkspaceTeam.id == id # pyright: ignore[reportArgumentType] + ) if load_members: - query = query.options(selectinload(WorkspaceTeam.users)) + query = query.options( + selectinload(WorkspaceTeam.users) # pyright: ignore[reportArgumentType] + ) - result = await self.session.exec(query) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + query # pyright: ignore[reportArgumentType] + ) team = result.scalar_one_or_none() if not team: @@ -56,8 +61,11 @@ async def assert_team_in_workspace(self, id: int, workspace_id: int): team_exists = await self.session.scalar( select( exists() - .where(WorkspaceTeam.id == id) - .where(WorkspaceTeam.workspace_id == workspace_id) + .where(WorkspaceTeam.id == id) # pyright: ignore[reportArgumentType] + .where( + WorkspaceTeam.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) ) @@ -86,13 +94,19 @@ async def delete(self, id: int) -> None: await self.session.commit() async def get_members(self, id: int) -> list[User]: - result = await self.session.exec(select(User).where(User.teams.any(id=id))) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User).where( # pyright: ignore[reportArgumentType] + User.teams.any(id=id) # pyright: ignore[reportAttributeAccessIssue] + ) + ) return result.scalars().all() async def add_member(self, id: int, user_id: int): - user_result = await self.session.exec( - select(User).options(selectinload(User.teams)).where(User.id == user_id) + user_result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User) + .options(selectinload(User.teams)) # pyright: ignore[reportArgumentType] + .where(User.id == user_id) # pyright: ignore[reportArgumentType] ) user = user_result.scalar_one_or_none() @@ -109,8 +123,10 @@ async def add_member(self, id: int, user_id: int): await self.session.commit() async def remove_member(self, id: int, user_id: int): - user_result = await self.session.exec( - select(User).options(selectinload(User.teams)).where(User.id == user_id) + user_result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User) + .options(selectinload(User.teams)) # pyright: ignore[reportArgumentType] + .where(User.id == user_id) # pyright: ignore[reportArgumentType] ) user = user_result.scalar_one_or_none() diff --git a/api/src/users/repository.py b/api/src/users/repository.py index 2961ad9..d8177fd 100644 --- a/api/src/users/repository.py +++ b/api/src/users/repository.py @@ -1,9 +1,3 @@ -# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather -# than ``Mapped[int]``, so Pyright reads ``Column == value`` as a ``bool`` and -# misjudges ``where()``/``exec()``/``select()`` calls and ``result.rowcount``. -# These are framework false positives; the queries are valid at runtime. -# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false - from uuid import UUID from sqlalchemy import delete, select @@ -33,7 +27,9 @@ async def get_privileged_workspace_members( # group members implicitly have the base "contributor" role. # query = ( - select(User, WorkspaceUserRole.role) + select( # pyright: ignore[reportCallIssue] + User, WorkspaceUserRole.role # pyright: ignore[reportArgumentType] + ) .join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid) .where(WorkspaceUserRole.workspace_id == workspace_id) ) @@ -50,8 +46,11 @@ async def get_privileged_workspace_members( ] async def get_current_user(self, current_user: UserInfo) -> User: - result = await self.session.exec( - select(User).where(User.auth_uid == str(current_user.user_uuid)) + result = await self.session.exec( # pyright: ignore[reportCallIssue] + select(User).where( + User.auth_uid + == str(current_user.user_uuid) # pyright: ignore[reportArgumentType] + ) ) # Current user should exist--throw if it doesn't: @@ -65,7 +64,11 @@ async def assign_member_role( ) -> None: # Ensure the user has a local user record (signed in at least once): user_exists = await self.session.scalar( - select(User.id).where(User.auth_uid == str(user_id)) + select( # pyright: ignore[reportCallIssue] + User.id # pyright: ignore[reportArgumentType] + ).where( # pyright: ignore[reportCallIssue] + User.auth_uid == str(user_id) + ) ) if not user_exists: raise NotFoundException( @@ -92,13 +95,15 @@ async def remove_member_role( user_id: UUID, ) -> None: query = delete(WorkspaceUserRole).where( - (WorkspaceUserRole.workspace_id == workspace_id) + ( + WorkspaceUserRole.workspace_id == workspace_id + ) # pyright: ignore[reportArgumentType] & (WorkspaceUserRole.user_auth_uid == str(user_id)) ) result = await self.session.execute(query) - if result.rowcount != 1: + if result.rowcount != 1: # pyright: ignore[reportAttributeAccessIssue] raise NotFoundException( f"No role assigned for workspace {workspace_id}, user {user_id}" ) @@ -108,7 +113,8 @@ async def remove_member_role( async def remove_all_member_roles(self, workspace_id: int) -> None: await self.session.execute( delete(WorkspaceUserRole).where( - WorkspaceUserRole.workspace_id == workspace_id + WorkspaceUserRole.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] ) ) await self.session.commit() diff --git a/api/src/workspaces/repository.py b/api/src/workspaces/repository.py index 7c7b5b6..90ac74f 100644 --- a/api/src/workspaces/repository.py +++ b/api/src/workspaces/repository.py @@ -1,10 +1,3 @@ -# SQLModel declares columns as plain annotations (e.g. ``id: int | None``) rather -# than ``Mapped[int]``, so Pyright reads ``Column == value`` as a ``bool`` and -# misjudges ``where()``/``select()`` calls, ``result.rowcount``, and table-model -# constructors. These are framework false positives; the queries are valid at -# runtime. Genuine type bugs surface via other rules, which stay enabled. -# pyright: reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false - from sqlalchemy import delete, select, text, update from sqlalchemy.exc import IntegrityError from sqlmodel.ext.asyncio.session import AsyncSession @@ -115,13 +108,16 @@ async def save_longform_quest( modifiedBy=current_user.user_uuid, modifiedByName=current_user.user_name, ) - .where(WorkspaceLongQuest.workspace_id == workspace_id) + .where( + WorkspaceLongQuest.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) result = await self.session.execute(query) - if result.rowcount == 0: + if result.rowcount == 0: # pyright: ignore[reportAttributeAccessIssue] self.session.add( - WorkspaceLongQuest( + WorkspaceLongQuest( # pyright: ignore[reportCallIssue] workspace_id=workspace_id, type=QuestDefinitionType[longform_quest_data.type].value, definition=longform_quest_data.definition, @@ -173,14 +169,17 @@ async def save_imagery_def( modifiedBy=current_user.user_uuid, modifiedByName=current_user.user_name, ) - .where(WorkspaceImagery.workspace_id == workspace_id) + .where( + WorkspaceImagery.workspace_id + == workspace_id # pyright: ignore[reportArgumentType] + ) ) result = await self.session.execute(query) if result.rowcount == 0: # type: ignore[attr-defined] self.session.add( - WorkspaceImagery( + WorkspaceImagery( # pyright: ignore[reportCallIssue] workspace_id=workspace_id, definition=imagery_def_data.definition, modifiedBy=current_user.user_uuid, From c960f3d39a0805c09fa4b2a81284ca291f2973da Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 11:47:04 -0400 Subject: [PATCH 111/159] Add branch index section to README Added branch index section to README for clarity on environment branches. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 7909fbd..f495746 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ This is a combination API backend for workspaces, providing /workspaces* methods the OSM API ("openstreetmap website") plus OSM CGI-map (the C-accelerated methods) that enforces authorization and authentication based on a TDEI/Keycloak JWT token (see main.py for this proxy logic). +## Branch Index + +* ```develop``` do your work here +* ```development``` keep this up to date with the "development" environment / dev tag +* ```staging``` keep this up to date with the "staging" environment / stage tag +* ```production``` keep this up to date with the "production" environment / prod tag +* ## To start on your local machine for dev work ``` From f757f06536de17407b94093bb5225651e551c6b6 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 11:47:31 -0400 Subject: [PATCH 112/159] Update README for clarity on local development setup Removed extra line in README and clarified local setup instructions. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f495746..462c65b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ authorization and authentication based on a TDEI/Keycloak JWT token (see main.py * ```development``` keep this up to date with the "development" environment / dev tag * ```staging``` keep this up to date with the "staging" environment / stage tag * ```production``` keep this up to date with the "production" environment / prod tag -* + ## To start on your local machine for dev work ``` From bc9afe32f332387223ce92d4188dd141b0c39e88 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 14:19:03 -0400 Subject: [PATCH 113/159] Idempotentency Test --- .claude/settings.json | 16 ++++++++++++++++ README.md | 3 +-- .../b3f8a2c91e04_add_auto_flag_review.py | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..fa7924c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -rln *)", + "Bash(grep -nA12 '\\\\[project.optional-dependencies\\\\]\\\\|optional-dependencies\\\\|\\\\[dependency-groups\\\\]' pyproject.toml)", + "Bash(grep -vE \"^$\")", + "Bash(git ls-tree *)", + "Bash(grep -E '\\\\.py$')", + "Bash(grep -v '\\\\.sample$')", + "Bash(git config *)", + "Bash(command -v gh)", + "Bash(gh api *)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")" + ] + } +} diff --git a/README.md b/README.md index 462c65b..8ffa3f5 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ authorization and authentication based on a TDEI/Keycloak JWT token (see main.py ## Branch Index -* ```develop``` do your work here -* ```development``` keep this up to date with the "development" environment / dev tag +* ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag * ```staging``` keep this up to date with the "staging" environment / stage tag * ```production``` keep this up to date with the "production" environment / prod tag diff --git a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py index 2aeb46a..f0b39ba 100644 --- a/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py +++ b/alembic_task/versions/b3f8a2c91e04_add_auto_flag_review.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy import inspect revision: str = "b3f8a2c91e04" down_revision: Union[str, None] = "add6266277c7" @@ -17,7 +18,20 @@ depends_on: Union[str, Sequence[str], None] = None +def _has_column(name: str) -> bool: + """True if `workspaces.` already exists. + + Guards the add/drop so the migration is safe whether or not the column + was created out-of-band (e.g. by a parallel branch) — a plain + ``ADD COLUMN`` would raise ``DuplicateColumnError`` otherwise. + """ + insp = inspect(op.get_bind()) + return name in {c["name"] for c in insp.get_columns("workspaces")} + + def upgrade() -> None: + if _has_column("autoFlagReview"): + return with op.batch_alter_table("workspaces", schema=None) as batch_op: batch_op.add_column( sa.Column( @@ -30,5 +44,7 @@ def upgrade() -> None: def downgrade() -> None: + if not _has_column("autoFlagReview"): + return with op.batch_alter_table("workspaces", schema=None) as batch_op: batch_op.drop_column("autoFlagReview") From 3cf1e0ca137116bc0687db7aed27c130343dbc98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:38:11 +0000 Subject: [PATCH 114/159] Bump cryptography from 44.0.0 to 48.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 44.0.0 to 48.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/44.0.0...48.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-version: 48.0.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 174 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 124 insertions(+), 50 deletions(-) diff --git a/uv.lock b/uv.lock index 8d2c188..da195c9 100644 --- a/uv.lock +++ b/uv.lock @@ -160,35 +160,87 @@ wheels = [ [[package]] name = "cffi" -version = "1.17.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] @@ -318,33 +370,55 @@ wheels = [ [[package]] name = "cryptography" -version = "44.0.0" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657, upload-time = "2024-11-27T18:07:10.168Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833, upload-time = "2024-11-27T18:05:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710, upload-time = "2024-11-27T18:05:58.621Z" }, - { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546, upload-time = "2024-11-27T18:06:01.062Z" }, - { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420, upload-time = "2024-11-27T18:06:03.487Z" }, - { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498, upload-time = "2024-11-27T18:06:05.763Z" }, - { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569, upload-time = "2024-11-27T18:06:07.489Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721, upload-time = "2024-11-27T18:06:11.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915, upload-time = "2024-11-27T18:06:13.515Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925, upload-time = "2024-11-27T18:06:16.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055, upload-time = "2024-11-27T18:06:19.113Z" }, - { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801, upload-time = "2024-11-27T18:06:21.431Z" }, - { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613, upload-time = "2024-11-27T18:06:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925, upload-time = "2024-11-27T18:06:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417, upload-time = "2024-11-27T18:06:28.959Z" }, - { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160, upload-time = "2024-11-27T18:06:30.866Z" }, - { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331, upload-time = "2024-11-27T18:06:33.432Z" }, - { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372, upload-time = "2024-11-27T18:06:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657, upload-time = "2024-11-27T18:06:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672, upload-time = "2024-11-27T18:06:43.566Z" }, - { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071, upload-time = "2024-11-27T18:06:45.586Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] From 7e11f69750208aaea7df051717b72d24eb1c5e69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:38:14 +0000 Subject: [PATCH 115/159] Bump python-multipart from 0.0.20 to 0.0.31 Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.20 to 0.0.31. - [Release notes](https://github.com/Kludex/python-multipart/releases) - [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md) - [Commits](https://github.com/Kludex/python-multipart/compare/0.0.20...0.0.31) --- updated-dependencies: - dependency-name: python-multipart dependency-version: 0.0.31 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 79ade0e..92ae238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "isort>=5.13.0", "pre-commit>=4.0.1", "autoflake>=2.3.1", - "python-multipart>=0.0.20", + "python-multipart>=0.0.31", "greenlet>=3.1.1", "geoalchemy2>=0.18.1", "jsonschema>=4.25.1", diff --git a/uv.lock b/uv.lock index 8d2c188..0eff14a 100644 --- a/uv.lock +++ b/uv.lock @@ -940,11 +940,11 @@ cryptography = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, ] [[package]] @@ -1411,7 +1411,7 @@ requires-dist = [ { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, - { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "python-multipart", specifier = ">=0.0.31" }, { name = "requests", specifier = ">=2.32.5" }, { name = "requests-cache", specifier = ">=1.2.1" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.48.0" }, From 97d9d41c8af691879fd618561fd8eb3ff9754bb2 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 17:22:13 -0400 Subject: [PATCH 116/159] Update ci.yml --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 619c43f..35d921d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, develop ] + branches: [ develop ] pull_request: - branches: [ main, develop ] + branches: [ develop ] # Cancel any in-progress run for the same branch/PR when a newer commit lands. concurrency: From 804bd70c74abd21cf9b248daa8d20ae6f58ea9ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:38:19 +0000 Subject: [PATCH 117/159] Bump pyjwt from 2.10.1 to 2.13.0 Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.10.1 to 2.13.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.1...2.13.0) --- updated-dependencies: - dependency-name: pyjwt dependency-version: 2.13.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 8d2c188..cb1ca88 100644 --- a/uv.lock +++ b/uv.lock @@ -863,11 +863,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [[package]] From 88a63eb55ee5dc625b851eb6e7366d16b6558891 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:28:34 +0000 Subject: [PATCH 118/159] Bump python-dotenv from 1.0.1 to 1.2.2 Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.0.1 to 1.2.2. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.2.2) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 92ae238..83cdcd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "asyncpg>=0.30.0", "fastapi>=0.115.6", "pydantic-settings>=2.6.1", - "python-dotenv>=1.0.1", + "python-dotenv>=1.2.2", "sqlalchemy>=2.0.36", "uvicorn>=0.32.1", "python-jose[cryptography]>=3.3.0", diff --git a/uv.lock b/uv.lock index 5b03150..2a1ef3a 100644 --- a/uv.lock +++ b/uv.lock @@ -986,11 +986,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1483,7 +1483,7 @@ requires-dist = [ { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=4.1.0" }, - { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart", specifier = ">=0.0.31" }, { name = "requests", specifier = ">=2.32.5" }, From 1d90e417cd2912f698198ddb917047ba53d341c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:28:35 +0000 Subject: [PATCH 119/159] Bump urllib3 from 2.6.2 to 2.7.0 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.2 to 2.7.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.2...2.7.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.7.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 5b03150..f7d70b1 100644 --- a/uv.lock +++ b/uv.lock @@ -1385,11 +1385,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From 0c1db8302fe0a7b124ad686ed4c79e04036d81f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:28:37 +0000 Subject: [PATCH 120/159] Bump mako from 1.3.8 to 1.3.12 Bumps [mako](https://github.com/sqlalchemy/mako) from 1.3.8 to 1.3.12. - [Release notes](https://github.com/sqlalchemy/mako/releases) - [Changelog](https://github.com/sqlalchemy/mako/blob/main/CHANGES) - [Commits](https://github.com/sqlalchemy/mako/commits) --- updated-dependencies: - dependency-name: mako dependency-version: 1.3.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 5b03150..2db537b 100644 --- a/uv.lock +++ b/uv.lock @@ -649,14 +649,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.8" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/d9/8518279534ed7dace1795d5a47e49d5299dd0994eed1053996402a8902f9/mako-1.3.8.tar.gz", hash = "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8", size = 392069, upload-time = "2024-12-07T18:41:33.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/bf/7a6a36ce2e4cafdfb202752be68850e22607fccd692847c45c1ae3c17ba6/Mako-1.3.8-py3-none-any.whl", hash = "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", size = 78569, upload-time = "2024-12-07T18:41:35.983Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] From 3370c172ce55241fd8310c17584a4f6f51f4ff07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:28:38 +0000 Subject: [PATCH 121/159] Bump idna from 3.10 to 3.15 Bumps [idna](https://github.com/kjd/idna) from 3.10 to 3.15. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md) - [Commits](https://github.com/kjd/idna/compare/v3.10...v3.15) --- updated-dependencies: - dependency-name: idna dependency-version: '3.15' dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 5b03150..e750466 100644 --- a/uv.lock +++ b/uv.lock @@ -595,11 +595,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] From a6282436fd76bdb194909fb150fba50d3c041332 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 19:54:23 -0400 Subject: [PATCH 122/159] Dep updates --- uv.lock | 399 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 256 insertions(+), 143 deletions(-) diff --git a/uv.lock b/uv.lock index 4bd8ba4..955c1c5 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,16 @@ requires-python = ">=3.12" [[package]] name = "alembic" -version = "1.14.0" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/1e/8cb8900ba1b6360431e46fb7a89922916d3a1b017a8908a7c0499cc7e5f6/alembic-1.14.0.tar.gz", hash = "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b", size = 1916172, upload-time = "2024-11-04T18:44:22.066Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/06/8b505aea3d77021b18dcbd8133aa1418f1a1e37e432a465b14c46b2c0eaa/alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25", size = 233482, upload-time = "2024-11-04T18:44:24.335Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] [[package]] @@ -41,26 +41,42 @@ wheels = [ [[package]] name = "asyncpg" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162, upload-time = "2024-10-20T00:29:41.88Z" }, - { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025, upload-time = "2024-10-20T00:29:43.352Z" }, - { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243, upload-time = "2024-10-20T00:29:44.922Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059, upload-time = "2024-10-20T00:29:46.891Z" }, - { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596, upload-time = "2024-10-20T00:29:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632, upload-time = "2024-10-20T00:29:50.768Z" }, - { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186, upload-time = "2024-10-20T00:29:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064, upload-time = "2024-10-20T00:29:53.757Z" }, - { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, - { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] [[package]] @@ -74,14 +90,14 @@ wheels = [ [[package]] name = "autoflake" -version = "2.3.1" +version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642, upload-time = "2024-03-13T03:41:28.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/0b/70c277eef225133763bf05c02c88df182e57d5c5c0730d3998958096a82e/autoflake-2.3.3.tar.gz", hash = "sha256:c24809541e23999f7a7b0d2faadf15deb0bc04cdde49728a2fd943a0c8055504", size = 16515, upload-time = "2026-02-20T05:01:43.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, + { url = "https://files.pythonhosted.org/packages/da/21/26f1680ec3a598ea31768f9ebcd427e42986d077a005416094b580635532/autoflake-2.3.3-py3-none-any.whl", hash = "sha256:a51a3412aff16135ee5b3ec25922459fef10c1f23ce6d6c4977188df859e8b53", size = 17715, upload-time = "2026-02-20T05:01:42.137Z" }, ] [[package]] @@ -503,48 +519,82 @@ wheels = [ [[package]] name = "geoalchemy2" -version = "0.18.1" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/df/f6d689120a15a2287794e16696c3bdb4cf2e53038255d288b61a4d59e1fa/geoalchemy2-0.18.1.tar.gz", hash = "sha256:4bdc7daf659e36f6456e2f2c3bcce222b879584921a4f50a803ab05fa2bb3124", size = 239302, upload-time = "2025-11-18T15:12:05.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/74/6cb1ef591bf47d28f41aa770f2f3a91c0a570aee0a4083bed7f8c533d8df/geoalchemy2-0.20.0.tar.gz", hash = "sha256:450f427f4bc3cf2d5ddee0af3763aed0f3eea2384e7c9a99798d8f1508279322", size = 280805, upload-time = "2026-05-12T14:50:26.132Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/25/b3d6fc757d8d909e0e666ec6fbf1b7914e9ad18d6e1b08994cd9d2e63330/geoalchemy2-0.18.1-py3-none-any.whl", hash = "sha256:a49d9559bf7acbb69129a01c6e1861657c15db420886ad0a09b1871fb0ff4bdb", size = 81261, upload-time = "2025-11-18T15:12:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/4e/08/b66ad4239f592e05202e25925c08cdd04cc14c3994000ec70ec61fea202c/geoalchemy2-0.20.0-py3-none-any.whl", hash = "sha256:1489a1d106519542a79c97cd0b4c537d80462c353610ebc2429cf2c43daac717", size = 96467, upload-time = "2026-05-12T14:50:24.998Z" }, ] [[package]] name = "greenlet" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022, upload-time = "2024-09-20T18:21:04.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260, upload-time = "2024-09-20T17:08:07.301Z" }, - { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064, upload-time = "2024-09-20T17:36:47.628Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420, upload-time = "2024-09-20T17:39:21.258Z" }, - { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035, upload-time = "2024-09-20T17:44:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105, upload-time = "2024-09-20T17:08:42.048Z" }, - { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077, upload-time = "2024-09-20T17:08:33.707Z" }, - { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975, upload-time = "2024-09-20T17:44:15.989Z" }, - { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955, upload-time = "2024-09-20T17:09:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655, upload-time = "2024-09-20T17:21:22.427Z" }, - { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990, upload-time = "2024-09-20T17:08:26.312Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175, upload-time = "2024-09-20T17:36:48.983Z" }, - { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425, upload-time = "2024-09-20T17:39:22.705Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736, upload-time = "2024-09-20T17:44:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347, upload-time = "2024-09-20T17:08:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583, upload-time = "2024-09-20T17:08:36.85Z" }, - { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039, upload-time = "2024-09-20T17:44:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716, upload-time = "2024-09-20T17:09:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490, upload-time = "2024-09-20T17:17:09.501Z" }, - { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731, upload-time = "2024-09-20T17:36:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304, upload-time = "2024-09-20T17:39:24.55Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537, upload-time = "2024-09-20T17:44:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506, upload-time = "2024-09-20T17:08:47.852Z" }, - { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753, upload-time = "2024-09-20T17:08:38.079Z" }, - { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731, upload-time = "2024-09-20T17:44:20.556Z" }, - { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112, upload-time = "2024-09-20T17:09:28.753Z" }, +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] @@ -622,7 +672,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -630,9 +680,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -823,7 +873,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.0.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -832,9 +882,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678, upload-time = "2024-10-08T16:09:37.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713, upload-time = "2024-10-08T16:09:35.726Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -857,16 +907,17 @@ wheels = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/0f/27908242621b14e649a84e62b133de45f84c255eecb350ab02979844a788/pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9", size = 786486, upload-time = "2024-12-03T15:59:02.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997, upload-time = "2024-12-03T15:58:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -876,54 +927,91 @@ email = [ [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785, upload-time = "2024-11-22T00:24:49.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239, upload-time = "2024-11-22T00:22:13.775Z" }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070, upload-time = "2024-11-22T00:22:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096, upload-time = "2024-11-22T00:22:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708, upload-time = "2024-11-22T00:22:19.412Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751, upload-time = "2024-11-22T00:22:20.979Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863, upload-time = "2024-11-22T00:22:22.951Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161, upload-time = "2024-11-22T00:22:24.785Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294, upload-time = "2024-11-22T00:22:27.076Z" }, - { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468, upload-time = "2024-11-22T00:22:29.346Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413, upload-time = "2024-11-22T00:22:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735, upload-time = "2024-11-22T00:22:32.616Z" }, - { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633, upload-time = "2024-11-22T00:22:35.027Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973, upload-time = "2024-11-22T00:22:37.502Z" }, - { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215, upload-time = "2024-11-22T00:22:39.186Z" }, - { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033, upload-time = "2024-11-22T00:22:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542, upload-time = "2024-11-22T00:22:43.341Z" }, - { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854, upload-time = "2024-11-22T00:22:44.96Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389, upload-time = "2024-11-22T00:22:47.305Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934, upload-time = "2024-11-22T00:22:49.093Z" }, - { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176, upload-time = "2024-11-22T00:22:50.822Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720, upload-time = "2024-11-22T00:22:52.638Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972, upload-time = "2024-11-22T00:22:54.31Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477, upload-time = "2024-11-22T00:22:56.451Z" }, - { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186, upload-time = "2024-11-22T00:22:58.226Z" }, - { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429, upload-time = "2024-11-22T00:22:59.985Z" }, - { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713, upload-time = "2024-11-22T00:23:01.715Z" }, - { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897, upload-time = "2024-11-22T00:23:03.497Z" }, - { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983, upload-time = "2024-11-22T00:23:05.983Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] name = "pydantic-settings" -version = "2.6.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646, upload-time = "2024-11-01T11:00:05.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595, upload-time = "2024-11-01T11:00:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -995,16 +1083,16 @@ wheels = [ [[package]] name = "python-jose" -version = "3.3.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ecdsa" }, { name = "pyasn1" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/19/b2c86504116dc5f0635d29f802da858404d77d930a25633d2e86a64a35b3/python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a", size = 129068, upload-time = "2021-06-05T03:30:40.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/2d/e94b2f7bab6773c70efc70a61d66e312e1febccd9e0db6b9e0adf58cbad1/python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a", size = 33530, upload-time = "2021-06-05T03:30:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, ] [package.optional-dependencies] @@ -1014,11 +1102,11 @@ cryptography = [ [[package]] name = "python-multipart" -version = "0.0.31" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -1082,7 +1170,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1090,14 +1178,14 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "requests-cache" -version = "1.2.1" +version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1107,9 +1195,9 @@ dependencies = [ { name = "url-normalize" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/be/7b2a95a9e7a7c3e774e43d067c51244e61dea8b120ae2deff7089a93fb2b/requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1", size = 3018209, upload-time = "2024-06-18T17:18:03.774Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ab/a340c7f529646f16e5656a8ba1424ed0de406203e4554868491786628730/requests_cache-1.3.3.tar.gz", hash = "sha256:79b72d5ac5143992d1836ad78f4d8e65666061dd44e220548caab3723089826b", size = 101179, upload-time = "2026-07-03T19:48:57.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/2e/8f4051119f460cfc786aa91f212165bb6e643283b533db572d7b33952bd2/requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603", size = 61425, upload-time = "2024-06-18T17:17:45Z" }, + { url = "https://files.pythonhosted.org/packages/a5/bf/c1775e49b350225bd851576ba75263bc728d8f05c0e31439a45f3429cc7b/requests_cache-1.3.3-py3-none-any.whl", hash = "sha256:c8df20ff874ebfc026959e3874e6c12bd6724934cdb10925915908453d4b17e4", size = 70788, upload-time = "2026-07-03T19:48:56.693Z" }, ] [[package]] @@ -1207,15 +1295,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.48.0" +version = "2.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f0/0e9dc590513d5e742d7799e2038df3a05167cba084c6ca4f3cdd75b55164/sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473", size = 384828, upload-time = "2025-12-16T14:55:41.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, ] [package.optional-dependencies] @@ -1294,44 +1382,57 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.36" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485, upload-time = "2024-10-15T19:41:44.446Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/bf/005dc47f0e57556e14512d5542f3f183b94fde46e15ff1588ec58ca89555/SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", size = 2092378, upload-time = "2024-10-16T00:43:55.469Z" }, - { url = "https://files.pythonhosted.org/packages/94/65/f109d5720779a08e6e324ec89a744f5f92c48bd8005edc814bf72fbb24e5/SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", size = 2082778, upload-time = "2024-10-16T00:43:57.304Z" }, - { url = "https://files.pythonhosted.org/packages/60/f6/d9aa8c49c44f9b8c9b9dada1f12fa78df3d4c42aa2de437164b83ee1123c/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53", size = 3232191, upload-time = "2024-10-15T21:31:12.896Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ab/81d4514527c068670cb1d7ab62a81a185df53a7c379bd2a5636e83d09ede/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", size = 3243044, upload-time = "2024-10-15T20:16:28.954Z" }, - { url = "https://files.pythonhosted.org/packages/35/b4/f87c014ecf5167dc669199cafdb20a7358ff4b1d49ce3622cc48571f811c/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", size = 3178511, upload-time = "2024-10-15T21:31:16.792Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/badfc9293bc3ccba6ede05e5f2b44a760aa47d84da1fc5a326e963e3d4d9/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", size = 3205147, upload-time = "2024-10-15T20:16:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/c8/60/70e681de02a13c4b27979b7b78da3058c49bacc9858c89ba672e030f03f2/SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", size = 2062709, upload-time = "2024-10-15T20:16:29.946Z" }, - { url = "https://files.pythonhosted.org/packages/b7/ed/f6cd9395e41bfe47dd253d74d2dfc3cab34980d4e20c8878cb1117306085/SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", size = 2088433, upload-time = "2024-10-15T20:16:33.501Z" }, - { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651, upload-time = "2024-10-16T00:43:59.168Z" }, - { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132, upload-time = "2024-10-16T00:44:01.279Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559, upload-time = "2024-10-15T21:31:18.961Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c5/07f18a897b997f6d6b234fab2bf31dccf66d5d16a79fe329aefc95cd7461/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", size = 3177897, upload-time = "2024-10-15T20:16:35.048Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cd/e16f3cbefd82b5c40b33732da634ec67a5f33b587744c7ab41699789d492/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", size = 3111289, upload-time = "2024-10-15T21:31:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/15/85/5b8a3b0bc29c9928aa62b5c91fcc8335f57c1de0a6343873b5f372e3672b/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", size = 3139491, upload-time = "2024-10-15T20:16:38.048Z" }, - { url = "https://files.pythonhosted.org/packages/a1/95/81babb6089938680dfe2cd3f88cd3fd39cccd1543b7cb603b21ad881bff1/SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", size = 2060439, upload-time = "2024-10-15T20:16:36.182Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/5f7428df55660d6879d0522adc73a3364970b5ef33ec17fa125c5dbcac1d/SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", size = 2084574, upload-time = "2024-10-15T20:16:38.686Z" }, - { url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787, upload-time = "2024-10-15T20:04:30.265Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] name = "sqlmodel" -version = "0.0.31" +version = "0.0.39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/b8/e7cd6def4a773f25d6e29ffce63ccbfd6cf9488b804ab6fb9b80d334b39d/sqlmodel-0.0.31.tar.gz", hash = "sha256:2d41a8a9ee05e40736e2f9db8ea28cbfe9b5d4e5a18dd139e80605025e0c516c", size = 94952, upload-time = "2025-12-28T12:35:01.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057, upload-time = "2026-06-25T13:01:38.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/72/5aa5be921800f6418a949a73c9bb7054890881143e6bc604a93d228a95a3/sqlmodel-0.0.31-py3-none-any.whl", hash = "sha256:6d946d56cac4c2db296ba1541357cee2e795d68174e2043cd138b916794b1513", size = 27093, upload-time = "2025-12-28T12:35:00.108Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" }, ] [[package]] @@ -1364,11 +1465,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] From 73ba63aecdf8ed8d9b39160b83e15bc8a45a9026 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:59:20 +0000 Subject: [PATCH 123/159] Bump the uv group across 1 directory with 7 updates Bumps the uv group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | [pytest](https://github.com/pytest-dev/pytest) | `8.3.4` | `9.0.3` | | [black](https://github.com/psf/black) | `24.10.0` | `26.3.1` | | [filelock](https://github.com/tox-dev/py-filelock) | `3.16.1` | `3.20.3` | | [h11](https://github.com/python-hyper/h11) | `0.14.0` | `0.16.0` | | [pyasn1](https://github.com/pyasn1/pyasn1) | `0.6.1` | `0.6.3` | | [starlette](https://github.com/Kludex/starlette) | `0.41.3` | `1.3.1` | | [virtualenv](https://github.com/pypa/virtualenv) | `20.28.0` | `20.36.1` | Updates `pytest` from 8.3.4 to 9.0.3 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.4...9.0.3) Updates `black` from 24.10.0 to 26.3.1 - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.10.0...26.3.1) Updates `filelock` from 3.16.1 to 3.20.3 - [Release notes](https://github.com/tox-dev/py-filelock/releases) - [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/py-filelock/compare/3.16.1...3.20.3) Updates `h11` from 0.14.0 to 0.16.0 - [Commits](https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0) Updates `pyasn1` from 0.6.1 to 0.6.3 - [Release notes](https://github.com/pyasn1/pyasn1/releases) - [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst) - [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.1...v0.6.3) Updates `starlette` from 0.41.3 to 1.3.1 - [Release notes](https://github.com/Kludex/starlette/releases) - [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/starlette/compare/0.41.3...1.3.1) Updates `virtualenv` from 20.28.0 to 20.36.1 - [Release notes](https://github.com/pypa/virtualenv/releases) - [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst) - [Commits](https://github.com/pypa/virtualenv/compare/20.28.0...20.36.1) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production dependency-group: uv - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:production dependency-group: uv - dependency-name: filelock dependency-version: 3.20.3 dependency-type: indirect dependency-group: uv - dependency-name: h11 dependency-version: 0.16.0 dependency-type: indirect dependency-group: uv - dependency-name: pyasn1 dependency-version: 0.6.3 dependency-type: indirect dependency-group: uv - dependency-name: starlette dependency-version: 1.3.1 dependency-type: indirect dependency-group: uv - dependency-name: virtualenv dependency-version: 20.36.1 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] --- pyproject.toml | 4 +- uv.lock | 146 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 105 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83cdcd8..fbb068c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,11 @@ dependencies = [ "bcrypt==4.0.1", "passlib==1.7.4", "pydantic[email]>=2.5.2", - "pytest>=8.0.0", + "pytest>=9.0.3", "pytest-asyncio>=0.23.5", "httpx>=0.27.0", "pytest-cov>=4.1.0", - "black>=24.1.0", + "black>=26.3.1", "isort>=5.13.0", "pre-commit>=4.0.1", "autoflake>=2.3.1", diff --git a/uv.lock b/uv.lock index 955c1c5..c8e01b6 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -121,7 +130,7 @@ wheels = [ [[package]] name = "black" -version = "24.10.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -129,18 +138,26 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, + { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813, upload-time = "2024-10-07T19:20:50.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/04/bf74c71f592bcd761610bbf67e23e6a3cff824780761f536512437f1e655/black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", size = 1644256, upload-time = "2024-10-07T19:27:53.355Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ea/a77bab4cf1887f4b2e0bce5516ea0b3ff7d04ba96af21d65024629afedb6/black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", size = 1448534, upload-time = "2024-10-07T19:26:44.953Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3e/443ef8bc1fbda78e61f79157f303893f3fddf19ca3c8989b163eb3469a12/black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", size = 1761892, upload-time = "2024-10-07T19:24:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/52/93/eac95ff229049a6901bc84fec6908a5124b8a0b7c26ea766b3b8a5debd22/black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", size = 1434796, upload-time = "2024-10-07T19:25:06.239Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986, upload-time = "2024-10-07T19:28:50.684Z" }, - { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085, upload-time = "2024-10-07T19:28:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928, upload-time = "2024-10-07T19:24:15.233Z" }, - { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875, upload-time = "2024-10-07T19:24:42.762Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898, upload-time = "2024-10-07T19:20:48.317Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -496,25 +513,27 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.6" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336, upload-time = "2024-12-03T22:46:01.629Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843, upload-time = "2024-12-03T22:45:59.368Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] [[package]] name = "filelock" -version = "3.16.1" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload-time = "2024-09-17T19:02:01.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] [[package]] @@ -599,24 +618,24 @@ wheels = [ [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -846,11 +865,11 @@ wheels = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -889,11 +908,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -1023,6 +1042,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyjwt" version = "2.13.0" @@ -1034,29 +1062,31 @@ wheels = [ [[package]] name = "pytest" -version = "8.3.4" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -1109,6 +1139,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -1437,14 +1496,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.41.3" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159, upload-time = "2024-11-18T19:45:04.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -1520,16 +1580,16 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.28.0" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368, upload-time = "2024-11-26T04:32:39.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702, upload-time = "2024-11-26T04:32:36.948Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]] @@ -1580,7 +1640,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0" }, { name = "autoflake", specifier = ">=2.3.1" }, { name = "bcrypt", specifier = "==4.0.1" }, - { name = "black", specifier = ">=24.1.0" }, + { name = "black", specifier = ">=26.3.1" }, { name = "cachetools" }, { name = "fastapi", specifier = ">=0.115.6" }, { name = "geoalchemy2", specifier = ">=0.18.1" }, @@ -1593,7 +1653,7 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.5.2" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "pyjwt" }, - { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, From 0c0ff298cae50aa7d5b279626d3bc91d1aecfc9a Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 20:14:43 -0400 Subject: [PATCH 124/159] Lint fix --- api/core/security.py | 6 ++---- api/src/osm/repository.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/api/core/security.py b/api/core/security.py index cd5343c..0ff32e2 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -414,10 +414,8 @@ async def _validate_token_uncached( # workspace roles from OSM DB result = await osm_db_session.execute( - text( - "SELECT workspace_id, role FROM user_workspace_roles \ - WHERE user_auth_uid = :auth_uid" - ), + text("SELECT workspace_id, role FROM user_workspace_roles \ + WHERE user_auth_uid = :auth_uid"), {"auth_uid": str(r.user_uuid)}, ) workspaceRoles = list(result.mappings().all()) diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index 56bc6b7..5d73a80 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -22,10 +22,8 @@ async def getWorkspaceBBox( text(f"SET search_path TO 'workspace-{int(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" - ) + 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() From 402f5c27ea6302f092484fbc3c8757b859055916 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Wed, 8 Jul 2026 22:54:22 -0400 Subject: [PATCH 125/159] Update repository.py --- api/src/osm/repository.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index 5d73a80..5ca1c9a 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -22,8 +22,12 @@ async def getWorkspaceBBox( text(f"SET search_path TO 'workspace-{int(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") + # OSM stores node latitude/longitude as integers scaled by 1e7 + # (100-nanodegree units), so divide by 1e7 to return decimal degrees. + sql_query = text( + "select MAX(latitude) / 1e7 AS max_lat, MAX(longitude) / 1e7 AS max_lon, \ + MIN(latitude) / 1e7 AS min_lat, MIN(longitude) / 1e7 AS min_lon from nodes" + ) result = await self.session.execute(sql_query) retVal = result.mappings().first() From cfa643e1050b914ee9d08e31bac2756b68dbcdc8 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 9 Jul 2026 11:16:04 -0400 Subject: [PATCH 126/159] Defensive check on changeset resolve plus tests --- CLAUDE.md | 38 ++++++++--- api/src/osm/repository.py | 17 ++++- api/src/osm/routes.py | 2 +- tests/integration/test_osm.py | 117 ++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 tests/integration/test_osm.py diff --git a/CLAUDE.md b/CLAUDE.md index 8e25c07..1e5e9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,24 +79,42 @@ at all). Validated against the code: * **Export to TDEI** — no endpoint exists in this backend. * **Move Workspace PG→PG** — not possible; `WorkspacePatch` has no `tdeiProjectGroupId` field, so no route can change a workspace's project group. -* **Validate Changeset** and **Edit POSM Element** — these go through the OSM - proxy catch-all (`api/main.py`), which gates *every* proxied operation on +* **Edit POSM Element** — goes through the OSM proxy catch-all + (`api/main.py`), which gates *every* proxied operation on `isWorkspaceContributor` alone. There is no Validator- or Lead-level check on - proxied traffic. + proxied traffic. Raw changeset commits (proxied `PUT /api/0.6/changeset/...`) + are likewise Contributor-gated; the proxy only *tags* a contributor's new + changeset with `review_requested=yes` when the workspace has `autoFlagReview` + set — it does not enforce validation. -**The Validator role grants nothing extra at this layer.** -`isWorkspaceValidator` exists in `api/core/security.py` but no endpoint -authorizes on it — it only appears in the `role` field of `WorkspaceResponse`. -A Validator and a Contributor have identical permissions in this backend. +**Enforced here, Validator-gated (`isWorkspaceLead || isWorkspaceValidator` → +403).** This is a native FastAPI route, not proxied traffic. Leads (and POC via +`isWorkspaceLead`) inherit it: + +| Capability | Endpoint | +|---|---| +| Resolve/Validate Changeset | PUT `/workspaces/{id}/changesets/{changeset_id}/resolve` | + +Resolving clears the `review_requested` tag and stamps `reviewed_by` with the +reviewer's UUID. The gate is enforced both in the route +(`api/src/osm/routes.py`) and, defensively, inside +`OSMRepository.resolveChangeset` (`api/src/osm/repository.py`). + +**The Validator role grants exactly one thing at this layer:** the ability to +resolve changesets via the endpoint above. Aside from that, a Validator and a +Contributor have identical permissions in this backend. `isWorkspaceValidator` +otherwise only appears in the `role` field of `WorkspaceResponse`. **"Contributor" and "Authenticated User With PG/Workspace Association" are the same gate.** `isWorkspaceContributor` simply checks whether the workspace is in one of the user's project groups (`accessibleWorkspaceIds`), i.e. PG/workspace association — so both rows collapse to the same check. -If the Validator/Lead distinctions for changeset validation and TDEI export are -required, they must be enforced downstream (`workspaces-openstreetmap-website/`, -`workspaces-cgimap/`) — that has not been audited here. +Changeset *resolution* is Validator/Lead-gated here (see above). But the +Validator/Lead distinction on raw changeset *commits* and TDEI export is not +enforced at this layer; if required, it must be enforced downstream +(`workspaces-openstreetmap-website/`, `workspaces-cgimap/`) — that has not been +audited here. ## Testing diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index 5ca1c9a..5a29b7e 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -1,7 +1,8 @@ from sqlalchemy import text from sqlmodel.ext.asyncio.session import AsyncSession -from api.core.exceptions import NotFoundException +from api.core.exceptions import ForbiddenException, NotFoundException +from api.core.security import UserInfo class OSMRepository: @@ -50,10 +51,22 @@ async def getChangesetAdiff(self, workspace_id: int, changeset_id: int) -> list: async def resolveChangeset( self, + current_user: UserInfo, workspace_id: int, changeset_id: int, - reviewer_uuid: str, ) -> None: + # Defense in depth: resolving a changeset is a validator/lead + # capability. The route also enforces this, but gate here too so the + # repository cannot be misused from another call site. + if not current_user.isWorkspaceLead( + workspace_id + ) and not current_user.isWorkspaceValidator(workspace_id): + raise ForbiddenException( + "Only workspace leads and validators can resolve changesets" + ) + + reviewer_uuid = str(current_user.user_uuid) + await self.session.execute( text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") ) diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py index 452b2d4..9542e5a 100644 --- a/api/src/osm/routes.py +++ b/api/src/osm/routes.py @@ -72,7 +72,7 @@ async def resolve_changeset( try: await repository_osm.resolveChangeset( - workspace_id, changeset_id, str(current_user.user_uuid) + current_user, workspace_id, changeset_id ) except Exception as e: logger.error( diff --git a/tests/integration/test_osm.py b/tests/integration/test_osm.py new file mode 100644 index 0000000..b4862d8 --- /dev/null +++ b/tests/integration/test_osm.py @@ -0,0 +1,117 @@ +"""Integration tests for the /workspaces OSM routes (api/src/osm/routes.py). + +Each test drives a real HTTP request through the real route + repository, +queueing simulated rows on the fake sessions. + +Focus: PUT /{id}/changesets/{cid}/resolve is gated to workspace leads and +validators (403 otherwise). The gate is enforced both in the route and, +defensively, inside ``OSMRepository.resolveChangeset`` -- so a contributor is +rejected before any DB work, and the repository would reject a bad call site +even if the route check were bypassed. +""" + +import pytest + +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +API = "/api/v1/workspaces" + + +def _resolve_url(workspace_id=1, changeset_id=99): + return f"{API}/{workspace_id}/changesets/{changeset_id}/resolve" + + +def _user_with_role(role, workspace_id=1): + return factories.make_user_info(osm_workspace_roles={workspace_id: [role]}) + + +# === PUT /{id}/changesets/{cid}/resolve ==================================== + + +async def test_resolve_changeset_validator_204( + client, login, task_session, osm_session +): + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) # getById + osm_session.queue(fakes.affected(1), fakes.affected(1)) # DELETE, INSERT + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 # resolveChangeset ran to completion + + +async def test_resolve_changeset_lead_204(client, login, task_session, osm_session): + login(_user_with_role(WorkspaceUserRoleType.LEAD)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1), fakes.affected(1)) + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_resolve_changeset_poc_204(client, login, task_session, osm_session): + # POC on the owning project group satisfies isWorkspaceLead. + login( + factories.make_user_info( + accessible_workspace_ids={factories.DEFAULT_PG_ID: [1]}, + poc_group_ids=(factories.DEFAULT_PG_ID,), + ) + ) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1), fakes.affected(1)) + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_resolve_changeset_contributor_403( + client, login, task_session, osm_session +): + # A contributor (PG association, no validator/lead grant) is rejected + # before any DB work -- neither session should be touched. + login( + factories.make_user_info( + accessible_workspace_ids={factories.DEFAULT_PG_ID: [1]} + ) + ) + + response = await client.put(_resolve_url()) + + assert response.status_code == 403 + assert osm_session.commits == 0 + + +async def test_resolve_changeset_no_access_403(client, login): + # A user with no association to the workspace is likewise forbidden. + login() + + response = await client.put(_resolve_url()) + + assert response.status_code == 403 + + +async def test_resolve_changeset_validator_of_other_workspace_403(client, login): + # Validator rights on workspace 2 do not authorize resolving on workspace 1. + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR, workspace_id=2)) + + response = await client.put(_resolve_url(workspace_id=1)) + + assert response.status_code == 403 + + +async def test_resolve_changeset_unexpected_error_500( + error_client, login, task_session, osm_session +): + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.raises(RuntimeError("db"))) # DELETE blows up + + response = await error_client.put(_resolve_url()) + + assert response.status_code == 500 From a5191fe9199f3c9a31e72cb50808a392d8b97885 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 9 Jul 2026 11:26:53 -0400 Subject: [PATCH 127/159] Update routes.py --- api/src/osm/routes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py index 9542e5a..cadee1e 100644 --- a/api/src/osm/routes.py +++ b/api/src/osm/routes.py @@ -71,9 +71,7 @@ async def resolve_changeset( await repository_ws.getById(current_user, workspace_id) try: - await repository_osm.resolveChangeset( - current_user, workspace_id, changeset_id - ) + await repository_osm.resolveChangeset(current_user, workspace_id, changeset_id) except Exception as e: logger.error( f"Failed to resolve changeset {changeset_id}" From c87a118ebdbbc655184af6c736fcb8a88bc2439d Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 10 Jul 2026 15:43:55 +0530 Subject: [PATCH 128/159] Deploy code feature Deploying the required code --- .github/workflows/trigger-deploy-on-merge.yml | 36 +++++++++++++++++++ api/core/config.py | 8 ++--- api/main.py | 4 ++- 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/trigger-deploy-on-merge.yml diff --git a/.github/workflows/trigger-deploy-on-merge.yml b/.github/workflows/trigger-deploy-on-merge.yml new file mode 100644 index 0000000..82265c8 --- /dev/null +++ b/.github/workflows/trigger-deploy-on-merge.yml @@ -0,0 +1,36 @@ +name: Trigger Deployment on Pull Request Merge +on: + pull_request: + types: [closed] + branches: [ develop, staging, production] +permissions: + contents: read + actions: write +jobs: + on-merge: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-environment.outputs.environment }} + steps: + - name: Set Environment + id: set-environment + run: | + case "${{ github.base_ref }}" in + develop) echo "environment=develop" >> $GITHUB_OUTPUT ;; + staging) echo "environment=staging" >> $GITHUB_OUTPUT ;; + production) echo "environment=production" >> $GITHUB_OUTPUT ;; + testing) echo "environment=testing" >> $GITHUB_OUTPUT ;; + esac + deploy: + needs: on-merge + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Trigger Deployment Workflow + run: | + gh workflow run push-docker-image.yml \ + --repo ${{ github.repository }} \ + --ref ${{ github.base_ref }} \ + --field environment=${{ needs.on-merge.outputs.environment }} \ No newline at end of file diff --git a/api/core/config.py b/api/core/config.py index c4c6bde..643e586 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -10,11 +10,9 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Workspaces API" - # JSON array of allowed CORS origins. For example: - # - # ["https://workspaces.example.com", "https://leaderboard.example.com"] - # - CORS_ORIGINS: list[str] = [] + # Comma separated list of origins, or "*" to allow all origins. Defaults to an empty list. + # Example: CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + CORS_ORIGINS: str = "" TASK_DATABASE_URL: str = ( "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" diff --git a/api/main.py b/api/main.py index d537b72..9123a4e 100644 --- a/api/main.py +++ b/api/main.py @@ -97,7 +97,9 @@ async def lifespan(_app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, + allow_origins=[ + origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip() + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], From 090c4bc747e98a1c378a6682effff4dbb4ea1cfc Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 10 Jul 2026 15:49:29 +0530 Subject: [PATCH 129/159] Update test_config.py Updated unit tests for CORS --- tests/unit/test_config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 3a06d3b..4a9ab45 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -14,7 +14,7 @@ def test_defaults_loaded_when_env_unset(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg assert s.PROJECT_NAME == "Workspaces API" - assert s.CORS_ORIGINS == [] + assert s.CORS_ORIGINS == "" assert s.DEBUG is False assert s.SENTRY_DSN == "" assert s.WS_OSM_HOST == "http://osm-web" @@ -38,18 +38,18 @@ def test_env_vars_override_members(monkeypatch): def test_cors_origins_parsed_from_json_env(monkeypatch): - monkeypatch.setenv("CORS_ORIGINS", '["https://a.example", "https://b.example"]') + monkeypatch.setenv("CORS_ORIGINS", "https://a.example,https://b.example") s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg - assert s.CORS_ORIGINS == ["https://a.example", "https://b.example"] + assert s.CORS_ORIGINS == "https://a.example,https://b.example" def test_value_types_and_formats(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg assert isinstance(s.PROJECT_NAME, str) - assert isinstance(s.CORS_ORIGINS, list) + assert isinstance(s.CORS_ORIGINS, str) assert isinstance(s.DEBUG, bool) # empty-string default is preserved (not coerced to None): assert s.SENTRY_DSN == "" From 0070a2f28eb1b752bc43b18ad766ddf5a99217a4 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 15:11:17 -0400 Subject: [PATCH 130/159] Enable osm-web API endpoints with auth token --- api/core/config.py | 15 +++ api/core/security.py | 139 ++++++++++++++++++++++ tests/unit/test_token_bridge.py | 197 ++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 tests/unit/test_token_bridge.py diff --git a/api/core/config.py b/api/core/config.py index 643e586..7911264 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -38,6 +38,21 @@ class Settings(BaseSettings): # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" + # OSM token bridge: when a TDEI token is validated, mirror it into the OSM + # database's `oauth_access_tokens` table so osm-rails (doorkeeper) and cgimap + # authenticate it via their standard OAuth2 path -- no custom JWT handling + # needed in those services. Disabled while WS_OSM_OAUTH_APPLICATION_ID is 0. + # Set it to the id of the doorkeeper `oauth_applications` row these tokens + # should belong to (create one via the OSM `register_apps` rake task or SQL). + WS_OSM_OAUTH_APPLICATION_ID: int = 0 + + # Scopes granted to the mirrored token. Must cover the OSM API operations the + # frontend performs; see `lib/oauth.rb` in the OSM website for valid values. + WS_OSM_OAUTH_SCOPES: str = ( + "read_prefs write_prefs write_api write_changeset_comments " + "read_gpx write_gpx write_notes" + ) + SENTRY_DSN: str = "" model_config = SettingsConfigDict( diff --git a/api/core/security.py b/api/core/security.py index 0ff32e2..1db25f9 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,3 +1,4 @@ +import time from enum import StrEnum from uuid import UUID @@ -19,6 +20,8 @@ # @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles # @test: Test that any failed network requests are handled gracefully # @test: Test that the caching mechanism works correctly and evicts entries when roles change +# @test: Test that a validated token is mirrored into the OSM oauth_access_tokens table (with the user provisioned and expires_in from the JWT exp) when WS_OSM_OAUTH_APPLICATION_ID is set, and is a no-op otherwise +# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_OAUTH_APPLICATION_ID # Set up logger for this module logger = get_logger(__name__) @@ -316,6 +319,14 @@ async def validate_token( logger.info("Token validation cache hit") return cached logger.info("Token validation cache miss: token rotated") + # The old token is superseded; revoke its OSM row so it stops + # authenticating against osm-rails/cgimap before its own exp. + # TODO: this assumes a single active token per user. Truly concurrent + # sessions (multiple valid jtis for one user) will flap -- each request + # revokes the other session's OSM token and reactivates its own via the + # DO UPDATE upsert. To support multi-session, track multiple active jtis + # per user (cache + revoke set) instead of a single cached token. + await _revoke_osm_token(osm_db_session, cached.credentials) del _user_info_cache[user_uuid] # Cache miss: fetch TDEI roles and DB data: @@ -327,6 +338,123 @@ async def validate_token( return user_info +async def _bridge_token_to_osm( + session: AsyncSession, + *, + user_uuid: UUID, + user_name: str, + email: str | None, + token: str, + exp: int | None, +) -> None: + """Mirror a validated TDEI token into the OSM database so osm-rails + (doorkeeper) and cgimap authenticate it through their standard OAuth2 path, + with no custom JWT handling required in those services. + + Two idempotent writes: provision the OSM ``users`` row (owns the token via + ``resource_owner_id``) and insert a plaintext ``oauth_access_tokens`` row. + Doorkeeper stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT + matches on lookup for both osm-rails and cgimap; ``expires_in`` tracks the + JWT's own ``exp`` so OSM expires it in lockstep with TDEI. + + No-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is set. Best-effort: a failure + here must not break token validation -- the ``/api/v1`` routes keep working; + only the proxied OSM calls would 401 until the row exists. + """ + if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + return + + auth_uid = str(user_uuid) + try: + # Provision the users row (same shape as _provision_users_from_tdei). + await session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, " + "status, pass_crypt, data_public, email_valid, terms_seen, " + "creation_time, terms_agreed, tou_agreed) " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "true, true, true, (now() at time zone 'utc'), " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + {"auth_uid": auth_uid, "email": email, "name": user_name}, + ) + row = ( + await session.execute( + text( + "SELECT id FROM users " + "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" + ), + {"auth_uid": auth_uid}, + ) + ).first() + if row is None: + return + user_id = row[0] + + expires_in = max(0, exp - int(time.time())) if exp else None + await session.execute( + text( + "INSERT INTO oauth_access_tokens (application_id, " + "resource_owner_id, token, scopes, created_at, expires_in) " + "VALUES (:app_id, :user_id, :token, :scopes, " + "(now() at time zone 'utc'), :expires_in) " + # Re-presenting a token reactivates it: refresh the expiry to + # track this validation and clear any prior revocation, so a + # token revoked on rotation heals if it is used again. + "ON CONFLICT (token) DO UPDATE SET " + "resource_owner_id = EXCLUDED.resource_owner_id, " + "scopes = EXCLUDED.scopes, " + "created_at = EXCLUDED.created_at, " + "expires_in = EXCLUDED.expires_in, " + "revoked_at = NULL" + ), + { + "app_id": settings.WS_OSM_OAUTH_APPLICATION_ID, + "user_id": user_id, + "token": token, + "scopes": settings.WS_OSM_OAUTH_SCOPES, + "expires_in": expires_in, + }, + ) + await session.commit() + except Exception as e: + # Never fail auth on a bridge error; the OSM row just won't exist yet. + logger.warning( + "Failed to bridge TDEI token into OSM oauth_access_tokens: %s", e + ) + await session.rollback() + + +async def _revoke_osm_token(session: AsyncSession, token: str) -> None: + """Revoke a superseded token's OSM ``oauth_access_tokens`` row (best-effort). + + Called when a user's token rotates (new ``jti``) so the previous JWT stops + authenticating against osm-rails/cgimap before its own ``exp`` rather than + lingering until it expires. No-op unless the bridge is configured. + + Note: OSM/cgimap are reachable only *through* this proxy, and every request + first passes ``validate_token`` -- so a TDEI-revoked token is already + rejected upstream. This revocation is defense-in-depth for the superseded + token and keeps the OSM token store tidy. + """ + if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + return + try: + await session.execute( + text( + "UPDATE oauth_access_tokens " + "SET revoked_at = (now() at time zone 'utc') " + "WHERE token = :token AND revoked_at IS NULL" + ), + {"token": token}, + ) + await session.commit() + except Exception as e: + logger.warning("Failed to revoke superseded OSM token: %s", e) + await session.rollback() + + async def _validate_token_uncached( token: str, user_uuid: UUID, @@ -427,6 +555,17 @@ async def _validate_token_uncached( osmRoles[i["workspace_id"]].append(i["role"]) r.osmWorkspaceRoles = osmRoles + # Mirror this validated token into the OSM DB so proxied OSM/cgimap calls + # authenticate via the standard OAuth2 path. No-op unless configured. + await _bridge_token_to_osm( + osm_db_session, + user_uuid=user_uuid, + user_name=r.user_name, + email=payload.get("email"), + token=token, + exp=payload.get("exp"), + ) + logger.info("Finished validation of token") return r diff --git a/tests/unit/test_token_bridge.py b/tests/unit/test_token_bridge.py new file mode 100644 index 0000000..401ad73 --- /dev/null +++ b/tests/unit/test_token_bridge.py @@ -0,0 +1,197 @@ +"""Tests for the OSM token bridge in ``api/core/security.py``. + +``_bridge_token_to_osm`` mirrors a validated TDEI token into the OSM database's +``oauth_access_tokens`` table so osm-rails (doorkeeper) and cgimap authenticate +it through their standard OAuth2 path. We cover: + +- it is a no-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is configured, +- when enabled it provisions the ``users`` row and inserts a plaintext + ``oauth_access_tokens`` row owned by that user, with ``expires_in`` derived + from the JWT ``exp``, +- a DB failure never propagates (auth must not break) and rolls back. +""" + +import time +from typing import cast +from uuid import uuid4 + +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as security +from api.core.config import settings + + +class RecordingSession: + """Async session stand-in that records (sql, params) and returns a user id + row for the ``SELECT id FROM users`` lookup.""" + + def __init__(self, user_id=99): + self.calls: list[tuple[str, dict]] = [] + self.commits = 0 + self.rollbacks = 0 + self._user_id = user_id + + async def execute(self, statement, params=None): + sql = str(statement) + self.calls.append((sql, params or {})) + + class _R: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + if "SELECT id FROM users" in sql: + return _R([(self._user_id,)]) + return _R([]) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + def sql_for(self, needle): + return [c for c in self.calls if needle in c[0]] + + +async def test_bridge_is_noop_when_disabled(monkeypatch): + """With WS_OSM_OAUTH_APPLICATION_ID == 0 the bridge touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email="alice@example.com", + token="tok", + exp=None, + ) + + assert session.calls == [] + assert session.commits == 0 + + +async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): + """When enabled: users upsert -> id lookup -> oauth_access_tokens upsert.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_OAUTH_SCOPES", "write_api read_prefs") + uid = uuid4() + exp = int(time.time()) + 3600 + session = RecordingSession(user_id=42) + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uid, + user_name="alice", + email="alice@example.com", + token="jwt-token", + exp=exp, + ) + + # users row provisioned idempotently, keyed by auth_uid = str(sub) + user_inserts = session.sql_for("INSERT INTO users") + assert user_inserts and "ON CONFLICT (auth_uid)" in user_inserts[0][0] + assert user_inserts[0][1]["auth_uid"] == str(uid) + assert user_inserts[0][1]["email"] == "alice@example.com" + + # token mirrored with the configured application/scopes and exp-derived TTL + token_inserts = session.sql_for("oauth_access_tokens") + assert token_inserts and "ON CONFLICT (token)" in token_inserts[0][0] + # Re-presenting a token must reactivate it (clear revocation, refresh expiry). + assert "DO UPDATE" in token_inserts[0][0] + assert "revoked_at = NULL" in token_inserts[0][0] + params = token_inserts[0][1] + assert params["app_id"] == 7 + assert params["user_id"] == 42 + assert params["token"] == "jwt-token" + assert params["scopes"] == "write_api read_prefs" + assert 3590 <= params["expires_in"] <= 3600 + + assert session.commits == 1 + assert session.rollbacks == 0 + + +async def test_bridge_expires_in_none_without_exp(monkeypatch): + """A token without an `exp` claim mirrors with a NULL expires_in.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="jwt-token", + exp=None, + ) + + params = session.sql_for("oauth_access_tokens")[0][1] + assert params["expires_in"] is None + + +async def test_bridge_is_best_effort_on_db_error(monkeypatch): + """A DB failure must not propagate out of validation; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + # Must not raise. + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="tok", + exp=None, + ) + + assert session.rollbacks == 1 + assert session.commits == 0 + + +async def test_revoke_is_noop_when_disabled(monkeypatch): + """With the bridge off, revoking a rotated token touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + assert session.calls == [] + assert session.commits == 0 + + +async def test_revoke_marks_only_the_superseded_token(monkeypatch): + """Revocation flips revoked_at for exactly the given token, if not already.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + updates = session.sql_for("UPDATE oauth_access_tokens") + assert updates and "revoked_at" in updates[0][0] + assert "WHERE token = :token AND revoked_at IS NULL" in updates[0][0] + assert updates[0][1]["token"] == "old-token" + assert session.commits == 1 + + +async def test_revoke_is_best_effort_on_db_error(monkeypatch): + """A DB failure during revocation must not propagate; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "tok") + + assert session.rollbacks == 1 + assert session.commits == 0 From 90f8a999c1f52408500ccf114403e8b82a74590f Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 15:48:01 -0400 Subject: [PATCH 131/159] CORS config file fix --- api/core/config.py | 30 ++++++++++++++++++++++++++++-- api/main.py | 4 +--- tests/unit/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index 7911264..dbda4b3 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,3 +1,5 @@ +import json + from pydantic_settings import BaseSettings, SettingsConfigDict @@ -10,8 +12,12 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Workspaces API" - # Comma separated list of origins, or "*" to allow all origins. Defaults to an empty list. - # Example: CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # Allowed CORS origins. Accepts either a comma-separated list OR a JSON + # array (the deployment stack historically supplies a JSON array), plus "*" + # for all origins. Read via `cors_origins_list`, not this raw string. + # Examples: + # CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # CORS_ORIGINS='["https://workspaces.example.com"]' CORS_ORIGINS: str = "" TASK_DATABASE_URL: str = ( @@ -55,6 +61,26 @@ class Settings(BaseSettings): SENTRY_DSN: str = "" + @property + def cors_origins_list(self) -> list[str]: + """Allowed CORS origins as a list. + + Tolerates both formats the deployment has used: a JSON array + (``["https://a","https://b"]``) and a comma-separated string + (``https://a,https://b``). ``"*"`` is passed through as-is. + """ + raw = self.CORS_ORIGINS.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(o).strip() for o in parsed if str(o).strip()] + return [o.strip() for o in raw.split(",") if o.strip()] + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/api/main.py b/api/main.py index 9123a4e..c3708b3 100644 --- a/api/main.py +++ b/api/main.py @@ -97,9 +97,7 @@ async def lifespan(_app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=[ - origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip() - ], + allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 4a9ab45..cc96fa0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -45,6 +45,43 @@ def test_cors_origins_parsed_from_json_env(monkeypatch): assert s.CORS_ORIGINS == "https://a.example,https://b.example" +def test_cors_origins_list_comma_separated(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://a.example, https://b.example") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_json_array(monkeypatch): + # The deployment stack supplies a JSON array; it must parse, not become one + # malformed bracketed origin. + monkeypatch.setenv("CORS_ORIGINS", '["https://a.example","https://b.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_single_json_array(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["https://only.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://only.example"] + + +def test_cors_origins_list_empty_and_wildcard(monkeypatch): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == [] + + monkeypatch.setenv("CORS_ORIGINS", "*") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["*"] + + +def test_cors_origins_list_malformed_json_falls_back_to_comma(monkeypatch): + # A value that starts with "[" but isn't valid JSON should not crash; fall + # back to comma-splitting rather than raising. + monkeypatch.setenv("CORS_ORIGINS", "[not json") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["[not json"] + + def test_value_types_and_formats(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg From cd4cd645de338dda1dd8708f81d94c201434eb74 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 16:21:49 -0400 Subject: [PATCH 132/159] Tweak to enable OSM token bridge by default --- api/core/config.py | 32 +++++--- api/core/security.py | 137 +++++++++++++++++++++++--------- tests/unit/test_token_bridge.py | 98 ++++++++++++++--------- 3 files changed, 181 insertions(+), 86 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index 7911264..50a860b 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -38,21 +38,33 @@ class Settings(BaseSettings): # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" - # OSM token bridge: when a TDEI token is validated, mirror it into the OSM - # database's `oauth_access_tokens` table so osm-rails (doorkeeper) and cgimap - # authenticate it via their standard OAuth2 path -- no custom JWT handling - # needed in those services. Disabled while WS_OSM_OAUTH_APPLICATION_ID is 0. - # Set it to the id of the doorkeeper `oauth_applications` row these tokens - # should belong to (create one via the OSM `register_apps` rake task or SQL). - WS_OSM_OAUTH_APPLICATION_ID: int = 0 - - # Scopes granted to the mirrored token. Must cover the OSM API operations the - # frontend performs; see `lib/oauth.rb` in the OSM website for valid values. + # OSM token bridge: when enabled, a validated TDEI token is mirrored into the + # OSM database's `oauth_access_tokens` table so osm-rails (doorkeeper) and + # cgimap authenticate it via their standard OAuth2 path -- no custom JWT + # handling needed in those services. The backend also auto-creates the + # doorkeeper `oauth_applications` row (keyed by WS_OSM_OAUTH_CLIENT_UID and + # owned by the dedicated system user below) that these tokens belong to, so + # no manual OSM setup is required. + WS_OSM_TOKEN_BRIDGE_ENABLED: bool = True + + # Stable client id (uid) for the auto-created doorkeeper application. Point + # this at an existing application's uid to reuse it instead of creating one. + WS_OSM_OAUTH_CLIENT_UID: str = "workspaces-backend" + + # Scopes granted to the application and mirrored tokens. Must cover the OSM + # API operations the frontend performs; see `lib/oauth.rb` in the OSM website. WS_OSM_OAUTH_SCOPES: str = ( "read_prefs write_prefs write_api write_changeset_comments " "read_gpx write_gpx write_notes" ) + # Dedicated OSM `users` row that owns the auto-created doorkeeper application + # (satisfies oauth_applications.owner_id, a NOT-NULL FK to users). It never + # signs in; these values just need to be stable and unique among users. + WS_OSM_SYSTEM_USER_AUTH_UID: str = "workspaces-backend-system" + WS_OSM_SYSTEM_USER_DISPLAY_NAME: str = "Workspaces Backend (system)" + WS_OSM_SYSTEM_USER_EMAIL: str = "workspaces-backend-system@tdei.us" + SENTRY_DSN: str = "" model_config = SettingsConfigDict( diff --git a/api/core/security.py b/api/core/security.py index 1db25f9..adeaa38 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,3 +1,4 @@ +import secrets import time from enum import StrEnum from uuid import UUID @@ -20,8 +21,8 @@ # @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles # @test: Test that any failed network requests are handled gracefully # @test: Test that the caching mechanism works correctly and evicts entries when roles change -# @test: Test that a validated token is mirrored into the OSM oauth_access_tokens table (with the user provisioned and expires_in from the JWT exp) when WS_OSM_OAUTH_APPLICATION_ID is set, and is a no-op otherwise -# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_OAUTH_APPLICATION_ID +# @test: Test that when WS_OSM_TOKEN_BRIDGE_ENABLED, a validated token is mirrored into oauth_access_tokens with the doorkeeper application + system-owner user + caller user auto-provisioned and expires_in from the JWT exp; and is a no-op when disabled +# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_TOKEN_BRIDGE_ENABLED # Set up logger for this module logger = get_logger(__name__) @@ -338,6 +339,84 @@ async def validate_token( return user_info +async def _ensure_osm_user( + session: AsyncSession, + *, + auth_uid: str, + email: str | None, + display_name: str, +) -> int | None: + """Idempotently provision an OSM ``users`` row (auth_provider ``TDEI``) and + return its id. ``email`` is synthesised when absent -- the column is UNIQUE + and NOT NULL.""" + await session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, " + "status, pass_crypt, data_public, email_valid, terms_seen, " + "creation_time, terms_agreed, tou_agreed) " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "true, true, true, (now() at time zone 'utc'), " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "auth_uid": auth_uid, + "email": email or f"{auth_uid}@tdei.invalid", + "name": display_name, + }, + ) + row = ( + await session.execute( + text( + "SELECT id FROM users " + "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" + ), + {"auth_uid": auth_uid}, + ) + ).first() + return row[0] if row else None + + +async def _ensure_osm_oauth_application(session: AsyncSession) -> int | None: + """Ensure the doorkeeper application the mirrored tokens belong to exists, + creating it (owned by a dedicated system user) if needed. Idempotent by the + configured client uid; returns the application id.""" + owner_id = await _ensure_osm_user( + session, + auth_uid=settings.WS_OSM_SYSTEM_USER_AUTH_UID, + email=settings.WS_OSM_SYSTEM_USER_EMAIL, + display_name=settings.WS_OSM_SYSTEM_USER_DISPLAY_NAME, + ) + if owner_id is None: + return None + await session.execute( + text( + "INSERT INTO oauth_applications (owner_type, owner_id, name, uid, " + "secret, redirect_uri, scopes, confidential, created_at, updated_at) " + "VALUES ('User', :owner_id, :name, :uid, :secret, " + "'urn:ietf:wg:oauth:2.0:oob', :scopes, true, " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (uid) DO NOTHING" + ), + { + "owner_id": owner_id, + "name": "Workspaces Backend", + "uid": settings.WS_OSM_OAUTH_CLIENT_UID, + # Never used (tokens are inserted directly rather than issued via the + # OAuth flow), but the column is NOT NULL. + "secret": secrets.token_hex(32), + "scopes": settings.WS_OSM_OAUTH_SCOPES, + }, + ) + row = ( + await session.execute( + text("SELECT id FROM oauth_applications WHERE uid = :uid"), + {"uid": settings.WS_OSM_OAUTH_CLIENT_UID}, + ) + ).first() + return row[0] if row else None + + async def _bridge_token_to_osm( session: AsyncSession, *, @@ -351,46 +430,28 @@ async def _bridge_token_to_osm( (doorkeeper) and cgimap authenticate it through their standard OAuth2 path, with no custom JWT handling required in those services. - Two idempotent writes: provision the OSM ``users`` row (owns the token via - ``resource_owner_id``) and insert a plaintext ``oauth_access_tokens`` row. - Doorkeeper stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT - matches on lookup for both osm-rails and cgimap; ``expires_in`` tracks the - JWT's own ``exp`` so OSM expires it in lockstep with TDEI. + Auto-provisions everything it needs: the doorkeeper ``oauth_applications`` + row (owned by a dedicated system user), the caller's ``users`` row, and a + plaintext ``oauth_access_tokens`` row -- no manual OSM setup. Doorkeeper + stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT matches on + lookup for both osm-rails and cgimap; ``expires_in`` tracks the JWT's ``exp``. - No-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is set. Best-effort: a failure - here must not break token validation -- the ``/api/v1`` routes keep working; - only the proxied OSM calls would 401 until the row exists. + No-op unless ``WS_OSM_TOKEN_BRIDGE_ENABLED``. Best-effort: a failure here + must not break token validation -- the ``/api/v1`` routes keep working; only + the proxied OSM calls would 401 until the row exists. """ - if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED: return - auth_uid = str(user_uuid) try: - # Provision the users row (same shape as _provision_users_from_tdei). - await session.execute( - text( - "INSERT INTO users (auth_uid, email, display_name, auth_provider, " - "status, pass_crypt, data_public, email_valid, terms_seen, " - "creation_time, terms_agreed, tou_agreed) " - "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " - "true, true, true, (now() at time zone 'utc'), " - "(now() at time zone 'utc'), (now() at time zone 'utc')) " - "ON CONFLICT (auth_uid) DO NOTHING" - ), - {"auth_uid": auth_uid, "email": email, "name": user_name}, + app_id = await _ensure_osm_oauth_application(session) + if app_id is None: + return + user_id = await _ensure_osm_user( + session, auth_uid=str(user_uuid), email=email, display_name=user_name ) - row = ( - await session.execute( - text( - "SELECT id FROM users " - "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" - ), - {"auth_uid": auth_uid}, - ) - ).first() - if row is None: + if user_id is None: return - user_id = row[0] expires_in = max(0, exp - int(time.time())) if exp else None await session.execute( @@ -410,7 +471,7 @@ async def _bridge_token_to_osm( "revoked_at = NULL" ), { - "app_id": settings.WS_OSM_OAUTH_APPLICATION_ID, + "app_id": app_id, "user_id": user_id, "token": token, "scopes": settings.WS_OSM_OAUTH_SCOPES, @@ -431,14 +492,14 @@ async def _revoke_osm_token(session: AsyncSession, token: str) -> None: Called when a user's token rotates (new ``jti``) so the previous JWT stops authenticating against osm-rails/cgimap before its own ``exp`` rather than - lingering until it expires. No-op unless the bridge is configured. + lingering until it expires. No-op unless the bridge is enabled. Note: OSM/cgimap are reachable only *through* this proxy, and every request first passes ``validate_token`` -- so a TDEI-revoked token is already rejected upstream. This revocation is defense-in-depth for the superseded token and keeps the OSM token store tidy. """ - if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED: return try: await session.execute( diff --git a/tests/unit/test_token_bridge.py b/tests/unit/test_token_bridge.py index 401ad73..20e338f 100644 --- a/tests/unit/test_token_bridge.py +++ b/tests/unit/test_token_bridge.py @@ -2,13 +2,15 @@ ``_bridge_token_to_osm`` mirrors a validated TDEI token into the OSM database's ``oauth_access_tokens`` table so osm-rails (doorkeeper) and cgimap authenticate -it through their standard OAuth2 path. We cover: +it through their standard OAuth2 path. It auto-provisions everything it needs: -- it is a no-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is configured, -- when enabled it provisions the ``users`` row and inserts a plaintext - ``oauth_access_tokens`` row owned by that user, with ``expires_in`` derived - from the JWT ``exp``, -- a DB failure never propagates (auth must not break) and rolls back. +- a dedicated *system* ``users`` row that owns the doorkeeper application, +- the doorkeeper ``oauth_applications`` row (idempotent by the client uid), +- the caller's ``users`` row (owns the token), +- the plaintext ``oauth_access_tokens`` row (expires_in from the JWT exp). + +We cover: the no-op when disabled, the full provisioning chain when enabled, +the wiring of ids between rows, best-effort failure handling, and revocation. """ import time @@ -22,18 +24,21 @@ class RecordingSession: - """Async session stand-in that records (sql, params) and returns a user id - row for the ``SELECT id FROM users`` lookup.""" + """Async session stand-in that records (sql, params) and answers the two id + lookups: ``users`` (system vs caller, by auth_uid) and ``oauth_applications``.""" - def __init__(self, user_id=99): + def __init__(self, system_user_id=1, caller_user_id=42, app_id=7): self.calls: list[tuple[str, dict]] = [] self.commits = 0 self.rollbacks = 0 - self._user_id = user_id + self._system_user_id = system_user_id + self._caller_user_id = caller_user_id + self._app_id = app_id async def execute(self, statement, params=None): + params = params or {} sql = str(statement) - self.calls.append((sql, params or {})) + self.calls.append((sql, params)) class _R: def __init__(self, rows): @@ -43,7 +48,11 @@ def first(self): return self._rows[0] if self._rows else None if "SELECT id FROM users" in sql: - return _R([(self._user_id,)]) + if params.get("auth_uid") == settings.WS_OSM_SYSTEM_USER_AUTH_UID: + return _R([(self._system_user_id,)]) + return _R([(self._caller_user_id,)]) + if "SELECT id FROM oauth_applications" in sql: + return _R([(self._app_id,)]) return _R([]) async def commit(self): @@ -57,8 +66,8 @@ def sql_for(self, needle): async def test_bridge_is_noop_when_disabled(monkeypatch): - """With WS_OSM_OAUTH_APPLICATION_ID == 0 the bridge touches no DB.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + """With the bridge disabled the bridge touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", False) session = RecordingSession() await security._bridge_token_to_osm( @@ -74,13 +83,15 @@ async def test_bridge_is_noop_when_disabled(monkeypatch): assert session.commits == 0 -async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): - """When enabled: users upsert -> id lookup -> oauth_access_tokens upsert.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) +async def test_bridge_provisions_app_users_and_token(monkeypatch): + """Enabled: system user -> application -> caller user -> mirrored token, + with the ids wired between rows.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + monkeypatch.setattr(settings, "WS_OSM_OAUTH_CLIENT_UID", "workspaces-backend") monkeypatch.setattr(settings, "WS_OSM_OAUTH_SCOPES", "write_api read_prefs") uid = uuid4() exp = int(time.time()) + 3600 - session = RecordingSession(user_id=42) + session = RecordingSession(system_user_id=1, caller_user_id=42, app_id=7) await security._bridge_token_to_osm( cast(AsyncSession, session), @@ -91,19 +102,25 @@ async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): exp=exp, ) - # users row provisioned idempotently, keyed by auth_uid = str(sub) + # System user provisioned (owns the app) and caller user provisioned. user_inserts = session.sql_for("INSERT INTO users") - assert user_inserts and "ON CONFLICT (auth_uid)" in user_inserts[0][0] - assert user_inserts[0][1]["auth_uid"] == str(uid) - assert user_inserts[0][1]["email"] == "alice@example.com" - - # token mirrored with the configured application/scopes and exp-derived TTL + inserted_auth_uids = {c[1]["auth_uid"] for c in user_inserts} + assert settings.WS_OSM_SYSTEM_USER_AUTH_UID in inserted_auth_uids + assert str(uid) in inserted_auth_uids + + # Application created idempotently by uid, owned by the system user (id 1). + app_inserts = session.sql_for("INSERT INTO oauth_applications") + assert app_inserts and "ON CONFLICT (uid)" in app_inserts[0][0] + assert app_inserts[0][1]["uid"] == "workspaces-backend" + assert app_inserts[0][1]["owner_id"] == 1 + assert app_inserts[0][1]["scopes"] == "write_api read_prefs" + + # Token mirrored: app id from the lookup, resource_owner = caller (42), + # self-healing upsert, exp-derived TTL. token_inserts = session.sql_for("oauth_access_tokens") - assert token_inserts and "ON CONFLICT (token)" in token_inserts[0][0] - # Re-presenting a token must reactivate it (clear revocation, refresh expiry). - assert "DO UPDATE" in token_inserts[0][0] - assert "revoked_at = NULL" in token_inserts[0][0] - params = token_inserts[0][1] + assert token_inserts + sql, params = token_inserts[0] + assert "DO UPDATE" in sql and "revoked_at = NULL" in sql assert params["app_id"] == 7 assert params["user_id"] == 42 assert params["token"] == "jwt-token" @@ -114,27 +131,33 @@ async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): assert session.rollbacks == 0 -async def test_bridge_expires_in_none_without_exp(monkeypatch): - """A token without an `exp` claim mirrors with a NULL expires_in.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) +async def test_bridge_synthesises_email_when_absent(monkeypatch): + """A caller without an email claim still provisions (email is UNIQUE/NOT NULL).""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + uid = uuid4() session = RecordingSession() await security._bridge_token_to_osm( cast(AsyncSession, session), - user_uuid=uuid4(), + user_uuid=uid, user_name="alice", email=None, token="jwt-token", exp=None, ) + caller_insert = next( + c for c in session.sql_for("INSERT INTO users") if c[1]["auth_uid"] == str(uid) + ) + assert caller_insert[1]["email"] == f"{uid}@tdei.invalid" + params = session.sql_for("oauth_access_tokens")[0][1] assert params["expires_in"] is None async def test_bridge_is_best_effort_on_db_error(monkeypatch): """A DB failure must not propagate out of validation; it rolls back.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) class BoomSession(RecordingSession): async def execute(self, statement, params=None): @@ -142,7 +165,6 @@ async def execute(self, statement, params=None): session = BoomSession() - # Must not raise. await security._bridge_token_to_osm( cast(AsyncSession, session), user_uuid=uuid4(), @@ -158,7 +180,7 @@ async def execute(self, statement, params=None): async def test_revoke_is_noop_when_disabled(monkeypatch): """With the bridge off, revoking a rotated token touches no DB.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", False) session = RecordingSession() await security._revoke_osm_token(cast(AsyncSession, session), "old-token") @@ -169,7 +191,7 @@ async def test_revoke_is_noop_when_disabled(monkeypatch): async def test_revoke_marks_only_the_superseded_token(monkeypatch): """Revocation flips revoked_at for exactly the given token, if not already.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) session = RecordingSession() await security._revoke_osm_token(cast(AsyncSession, session), "old-token") @@ -183,7 +205,7 @@ async def test_revoke_marks_only_the_superseded_token(monkeypatch): async def test_revoke_is_best_effort_on_db_error(monkeypatch): """A DB failure during revocation must not propagate; it rolls back.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) class BoomSession(RecordingSession): async def execute(self, statement, params=None): From 935d3442b8aa7402fc6f2a5f715eb509a104c986 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 16:40:42 -0400 Subject: [PATCH 133/159] Change stub user to validate OSM rails models --- ...f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py | 47 +++++++++++++++++++ api/core/security.py | 8 +++- api/src/tasking/projects/repository.py | 8 +++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py diff --git a/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py b/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py new file mode 100644 index 0000000..5ff6bca --- /dev/null +++ b/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py @@ -0,0 +1,47 @@ +"""heal short TDEI-provisioned users.pass_crypt + +TDEI users are provisioned by the backend via raw SQL with a placeholder +``pass_crypt``. Older rows used a 4-char value (``"none"``), which violates OSM's +``pass_crypt`` length 8..255 ``User`` validation. That makes the user invalid, +and Rails operations that re-validate the author via ``validates :author, +:associated => true`` (posting a changeset comment or a note comment) then fail +to save. Replace any too-short value with a random throwaway -- TDEI manages +auth, so ``pass_crypt`` is never used to authenticate. + +Revision ID: f3a7b9c1d2e4 +Revises: 5303f61d7b3a +Create Date: 2026-07-11 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "f3a7b9c1d2e4" +down_revision: Union[str, None] = "5303f61d7b3a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + # `users` is owned by the OSM Rails website, not this tree; guard so the + # migration is a no-op if it hasn't been created yet. + if not inspect(bind).has_table("users"): + return + + # Idempotent: re-running only touches rows that are still too short. + op.execute( + text( + "UPDATE users SET pass_crypt = md5(random()::text) " + "WHERE auth_provider = 'TDEI' AND length(pass_crypt) < 8" + ) + ) + + +def downgrade() -> None: + # The original short values are unrecoverable (and were invalid anyway). + pass diff --git a/api/core/security.py b/api/core/security.py index adeaa38..020abf8 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -354,7 +354,7 @@ async def _ensure_osm_user( "INSERT INTO users (auth_uid, email, display_name, auth_provider, " "status, pass_crypt, data_public, email_valid, terms_seen, " "creation_time, terms_agreed, tou_agreed) " - "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', :pass_crypt, " "true, true, true, (now() at time zone 'utc'), " "(now() at time zone 'utc'), (now() at time zone 'utc')) " "ON CONFLICT (auth_uid) DO NOTHING" @@ -363,6 +363,12 @@ async def _ensure_osm_user( "auth_uid": auth_uid, "email": email or f"{auth_uid}@tdei.invalid", "name": display_name, + # OSM's User model validates pass_crypt length 8..255. TDEI manages + # auth, so this is a throwaway that just satisfies that rule (a too- + # short value makes the user invalid and breaks Rails operations + # that re-validate it via `validates :author, :associated => true`, + # e.g. posting a changeset comment). + "pass_crypt": secrets.token_hex(16), }, ) row = ( diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 3aa6820..44bbc42 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import secrets from datetime import datetime from typing import Any from uuid import UUID @@ -313,13 +314,18 @@ async def _provision_users_from_tdei( await self.session.execute( text( "INSERT INTO users (auth_uid, email, display_name, auth_provider, status, pass_crypt, data_public, email_valid, terms_seen, creation_time, terms_agreed, tou_agreed) " - "VALUES (:uid, :email, :name, 'TDEI', 'active', 'none', true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " + "VALUES (:uid, :email, :name, 'TDEI', 'active', :pass_crypt, true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " "ON CONFLICT (auth_uid) DO NOTHING" ), params={ "uid": uid, "email": member.email, "name": member.display_name, + # OSM validates pass_crypt length 8..255; a too-short value + # makes the user invalid and breaks Rails ops that re-validate + # the author (changeset/note comments). TDEI manages auth, so + # this is a throwaway. + "pass_crypt": secrets.token_hex(16), }, ) resolved.add(uid) From b56f1cdb5c85961990cfc95f0919959f0c3f11f0 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 16:52:01 -0400 Subject: [PATCH 134/159] Docs --- .claude/settings.json | 4 ++- CLAUDE.md | 78 +++++++++++++++++++++++++++++++++++++++++++ README.md | 42 +++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index fa7924c..ea52c82 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,9 @@ "Bash(git config *)", "Bash(command -v gh)", "Bash(gh api *)", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")" + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")", + "Bash(grep -nA6 \"TENANT_BYPASSES: list\" api/main.py)", + "Bash(grep -nA10 \"STRIP_REQUEST_HEADERS = \" api/main.py)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 1e5e9d6..d15dea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,84 @@ enforced at this layer; if required, it must be enforced downstream (`workspaces-openstreetmap-website/`, `workspaces-cgimap/`) — that has not been audited here. +## The OSM proxy layer: routing, auth, and user provisioning + +This service is a reverse proxy in front of the OSM website (`osm-rails`) and +cgimap. The non-obvious parts, learned the hard way: + +### Deployment routing (workspaces-stack) + +In `workspaces-stack`, the **api container (this backend) serves the public OSM +host** (`osm.workspaces-...`) alongside the API hosts — its traefik router +rule is `Host(new-api) || Host(api) || Host(osm)`, and the `osm-web` / +`osm-log-proxy` routers are commented out. So **every OSM call the frontend +makes routes through this backend**: `validate_token`, the `X-Workspace` gate, +and the CORS middleware all apply. The backend then proxies `/api/0.6/*` to +`WS_OSM_HOST` (config default `http://osm-web` — the internal service, *not* the +public domain). `osm-web`'s nginx splits traffic: changeset read/write subpaths +to cgimap, everything else to osm-rails. + +The frontend (`services/osm.ts`) calls the OSM host directly with +`credentials: 'include'` + a Bearer TDEI token. Because it's *credentialed* +CORS, `CORS_ORIGINS` must list the exact frontend origin (no `*`; +`allow_credentials` is on). The stack supplies `WS_API_CORS_ORIGINS` as a **JSON +array** (`["https://..."]`), while older config comma-split it — a JSON array +would then become one malformed origin. `api/main.py` therefore reads +`settings.cors_origins_list`, which parses **both** a JSON array and a +comma-separated string (and `*`); set `CORS_ORIGINS` in either form. + +### Two databases; `users` is owned by OSM Rails + +`TASK_DATABASE_URL` (`alembic_task` tree) holds workspaces / tasking-manager +tables; `OSM_DATABASE_URL` (`alembic_osm` tree) holds OSM data, `users`, and the +`tasking_*` tables. The **`users` table is owned by the OSM Rails website**, not +either alembic tree — the trees only FK to it, and integration tests stub it +(`tests/integration/conftest.py`). The backend provisions `users` rows itself +via raw SQL with `auth_provider='TDEI'` and `auth_uid = str()` (the OSM +`auth_uid` **is** the token's `sub` claim). + +### How OSM authenticates, and the TDEI token bridge + +* **osm-rails** authenticates API calls *only* via **doorkeeper OAuth2**: it + looks the bearer token up in `oauth_access_tokens` (`token` → + `resource_owner_id` → `users.id`). Doorkeeper stores tokens **plaintext** + (`SecretStoring::Plain`), so the raw JWT matches directly. It has **no** + TDEI/JWT auth path. +* **cgimap** has both: the oauth2 lookup *and* a custom TDEI path + (`get_user_id_for_tdei_token`, which verifies the JWT and matches + `users.auth_uid`). + +To make TDEI tokens work against osm-rails without forking it, the **token +bridge** (`_bridge_token_to_osm` in `api/core/security.py`) mirrors a validated +TDEI JWT into `oauth_access_tokens` — on the cache-miss path of `validate_token` +(so ~once per token, not per request). Gated by `WS_OSM_TOKEN_BRIDGE_ENABLED`. +It auto-provisions everything: a dedicated **system user** (`WS_OSM_SYSTEM_USER_*`) +to own the doorkeeper `oauth_applications` row it creates (keyed by +`WS_OSM_OAUTH_CLIENT_UID`; `oauth_applications.owner_id` is a NOT-NULL FK to +`users`, hence the system user), the caller's `users` row, and the plaintext +`oauth_access_tokens` row (`expires_in` from the JWT `exp`; +`ON CONFLICT (token) DO UPDATE` so re-presenting reactivates it). On token +rotation (new `jti`) the superseded token is revoked. Since the token then lives +in `oauth_access_tokens`, both osm-rails and cgimap authenticate it via plain +OAuth2 — cgimap's custom TDEI path becomes redundant. + +### Provisioning gotcha: `pass_crypt` must be 8..255 chars + +The OSM `User` model has `validates :pass_crypt, :length => 8..255`. When the +backend provisions a `users` row, **`pass_crypt` must be 8–255 chars** (use a +random throwaway — TDEI manages auth, so it's never used to log in). A too-short +value (the old `'none'`) makes the user *invalid*. This is silent for **cgimap** +operations — cgimap runs no Rails validations — but breaks any **osm-rails** +operation that re-validates the author via `validates :author, :associated => +true`: notably `POST /api/0.6/changeset/{id}/comment` and note comments. The +comment fails to save (no `id`), then rendering it throws *"Unable to serialize +… without an id"*. Both provisioning paths (`_ensure_osm_user`, +`_provision_users_from_tdei`) set a valid `pass_crypt`; migration +`alembic_osm/versions/f3a7b9c1d2e4` heals legacy short rows. General principle: +a backend-provisioned `users` row must satisfy OSM's `User` validations (also +`display_name` 3..255 + unique, `email` present + unique) or Rails operations +that touch it will fail even though cgimap operations succeed. + ## Testing Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or diff --git a/README.md b/README.md index 8ffa3f5..7b905d3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,48 @@ This is a combination API backend for workspaces, providing /workspaces* methods the OSM API ("openstreetmap website") plus OSM CGI-map (the C-accelerated methods) that enforces authorization and authentication based on a TDEI/Keycloak JWT token (see main.py for this proxy logic). +## What the proxy must provide for osm-rails / osm-web + +This backend is the **only entry point** to the OSM tier (osm-rails + cgimap, +behind `osm-web`): in the deployment, the public OSM host routes to this +container, which proxies `/api/0.6/*` to `WS_OSM_HOST` (default +`http://osm-web`). For the OSM services to work, the proxy must uphold the +following contract. `CLAUDE.md` has the full rationale. + +1. **Bridge TDEI auth into OSM's OAuth2.** osm-rails authenticates the API *only* + via doorkeeper OAuth2 (`oauth_access_tokens`); it has no TDEI/JWT path. So on + token validation the backend mirrors the TDEI JWT into `oauth_access_tokens` + in the OSM DB (the "token bridge" in `api/core/security.py`), and forwards the + incoming `Authorization: Bearer ` header unchanged. Then osm-rails and + cgimap authenticate the token via plain OAuth2. Controlled by + `WS_OSM_TOKEN_BRIDGE_ENABLED` (on by default) — with it off, osm-rails returns + **401** for TDEI tokens. The backend auto-creates the doorkeeper application + (and a system user to own it), so no manual OSM setup is required. + +2. **Provision valid OSM `users` rows.** The backend creates OSM `users` rows for + TDEI users (`auth_provider='TDEI'`, `auth_uid` = the JWT `sub`). These must + satisfy OSM's `User` validations — in particular `pass_crypt` length 8..255. + A too-short value is invisible to cgimap but makes osm-rails operations that + re-validate the user fail (e.g. posting a changeset comment or a note), + surfacing as *"Unable to serialize … without an id"*. The + `alembic_osm` migration `*_heal_short_tdei_pass_crypt` repairs legacy rows. + +3. **Carry workspace tenancy.** Workspace-scoped OSM requests must include an + `X-Workspace: ` header. The proxy authorizes it against the caller's + workspaces and forwards it (it is *not* stripped) so cgimap/osm-rails scope to + the `workspace-` schema. A few paths are exempt (`TENANT_BYPASSES` in + `api/main.py`): workspace create/delete (`PUT`/`DELETE /api/0.6/workspaces/{id}`) + and user provisioning during sign-in (`PUT /api/0.6/user/{uid}`). + +4. **Set the proxy headers.** The proxy rewrites `Host` to the OSM host and sets + `X-Real-IP` / `X-Forwarded-For` / `-Host` / `-Proto`, while stripping + hop-by-hop headers and any spoofed forwarding headers from the client. It does + *not* strip `Authorization` or `X-Workspace`. + +5. **Connectivity.** `WS_OSM_HOST` must reach `osm-web`, and the backend needs + **both** `OSM_DATABASE_URL` and `TASK_DATABASE_URL` — the token bridge and + user provisioning write to the OSM database. + ## Branch Index * ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag From ecc0f4fff5f58a61511c2688b6e455624cc3ae35 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 11:24:12 +0530 Subject: [PATCH 135/159] initial docker setup done - Added notes to local development. - Need to add more details on initial development. --- .gitignore | 1 + README.md | 25 ++++++++++++++ api/core/config.py | 1 + docker-compose.local.yml | 71 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 docker-compose.local.yml diff --git a/.gitignore b/.gitignore index 4516af1..d929d58 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ docs/tasking-mvp/tasking-mvp.postman_environment.json docs/tasking-mvp/_enrich_postman.py docs/tasking-mvp/feature-coverage.md .idea/ +data/ \ No newline at end of file diff --git a/README.md b/README.md index 7b905d3..a8867e1 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,28 @@ uvx pyright --pythonpath .venv/bin/python api tests uv run black api tests && uv run isort api tests ``` +## Development with local environment + +Use the file `docker-compose.local.yml` to build and deploy local code changes. This allows you to run the entire system at once instead of +connecting to existing Databases. + +### Initial setup. +- On first launch, rails-worker will fail because migrations are not done +- Go to the `osm-rails` `/bin/sh` and execute `bundle exec rails db:migrate` +- The above script runs the migration code for osm-rails +- The rails-worker will be able to run +- The backend code will fail first time becase `workspaces-tasks-local` database is not available +- Login to postgresql container and run the following commands + `psql --username postgres` + `create database "workspaces-tasks-local";` + `psql --username postgres --dbname "workspaces-tasks-local";` + `create extension if not exists postgis;` +- Run the backend code now and it should be able to run + +### Commands to start and stop the docker compose + +`docker compose --file docker-compose.local.yml build up -d` + +`docker compose --file docker-compose.local.yml down` + +Backend code will be available at `http://localhost:3000` \ No newline at end of file diff --git a/api/core/config.py b/api/core/config.py index 04693ac..c6554c7 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -96,6 +96,7 @@ def cors_origins_list(self) -> list[str]: model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", + extra="ignore", # ignore unknown environment variables ) diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..8bfeae3 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,71 @@ +services: + database: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: testing + POSTGRES_DB: workspaces-osm-local + ports: + - 5432:5432 + volumes: + - ./data/db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d workspaces-osm-local"] + interval: 10s + timeout: 5s + retries: 5 + osm-cgimap: + image: opensidewalksdev.azurecr.io/workspaces-osm-cgimap:dev + environment: + CGIMAP_INSTANCES: 10 + CGIMAP_HOST: database + CGIMAP_USERNAME: postgres + CGIMAP_PASSWORD: testing + CGIMAP_DBNAME: workspaces-osm-local + CGIMAP_MAX_CHANGESET_ELEMENTS: 100000000 # max features per import or save + CGIMAP_MAX_PAYLOAD: 1000000000 # max size of dataset uploads + CGIMAP_MAP_NODES: 100000000 # max number of nodes per requeset + CGIMAP_MAP_AREA: 1 # max area per request in square degrees + ports: + - 8000 + osm-rails: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + ports: + - 3000 + command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"] + + osm-rails-worker: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + command: ["bundle", "exec", "rake", "jobs:work"] + + backend: + build: . + container_name: workspaces-backend + volumes: + - .:/app + ports: + - 8000:8000 + environment: + TDEI_BACKEND_URL: ${TDEI_BACKEND_URL} + TDEI_OIDC_REALM: ${TDEI_OIDC_REALM} + TDEI_OIDC_URL: ${TDEI_OIDC_URL} + OSM_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-osm-local + TASK_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-tasks-local \ No newline at end of file From ac2700fb4f54f764dfa4df2664429816fe8ae089 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 12:19:16 +0530 Subject: [PATCH 136/159] Added custom_imagery column Migration logic yet to be written --- api/src/tasking/projects/dtos.py | 3 +++ api/src/tasking/projects/repository.py | 4 ++++ api/src/tasking/projects/schemas.py | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 488e981..709b80f 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -47,6 +47,7 @@ class ProjectCreateRequest(WireModel): lock_timeout_hours: int = PydField(default=8, ge=1, le=720) aoi: Optional[AoiInput] = None role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + custom_imagery: Optional[Any] = PydField(default=None) @field_validator("name") @classmethod @@ -68,6 +69,7 @@ class ProjectUpdateRequest(WireModel): instructions: Optional[str] = PydField(default=None, max_length=10_000) lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) review_required: Optional[bool] = None + custom_imagery: Optional[Any] = PydField(default=None) class ProjectResponse(WireModel): @@ -87,6 +89,7 @@ class ProjectResponse(WireModel): created_by_name: Optional[str] = None created_at: datetime updated_at: datetime + custom_imagery: Optional[Any] = None class ProjectListItem(WireModel): diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 44bbc42..b046436 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -243,6 +243,7 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons created_by_name=project.created_by_name, created_at=project.created_at, updated_at=project.updated_at, + custom_imagery=project.custom_imagery, # type: ignore[arg-type] ) async def _provision_users_from_tdei( @@ -531,6 +532,7 @@ async def create( lock_timeout_hours=body.lock_timeout_hours, created_by=current_user.user_uuid, created_by_name=current_user.user_name, + custom_imagery=body.custom_imagery, # type: ignore[arg-type] ) if body.aoi is not None: geom = _aoi_to_shapely(body.aoi) @@ -632,6 +634,8 @@ async def patch( updates["lock_timeout_hours"] = body.lock_timeout_hours if body.review_required is not None: updates["review_required"] = body.review_required + if body.custom_imagery is not None: + updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type] if updates: updates["updated_at"] = datetime.now() diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 901d5f6..8150f4b 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -11,6 +11,7 @@ from sqlalchemy import Column from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel +from sqlalchemy.dialects.postgresql import JSONB # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) @@ -94,6 +95,12 @@ class TaskingProject(SQLModel, table=True): ) deleted_at: Optional[datetime] = None + # Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer. + custom_imagery: Optional[Any] = Field( + default=None, + sa_column=Column(JSONB, nullable=True,default=None) + ) + # --------------------------------------------------------------------------- # Project role enum + table From 70b14ccac6244f0526a0141a1071d94908415cae Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 15:30:15 +0530 Subject: [PATCH 137/159] imagery and description added --- README.md | 2 +- ...61f527ef_custom_imagery_and_description.py | 37 +++++++++++++++++++ api/src/tasking/projects/dtos.py | 7 +++- api/src/tasking/projects/repository.py | 2 + api/src/tasking/projects/schemas.py | 7 ++-- 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py diff --git a/README.md b/README.md index a8867e1..ebba814 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ connecting to existing Databases. ### Commands to start and stop the docker compose -`docker compose --file docker-compose.local.yml build up -d` +`docker compose --file docker-compose.local.yml up --build -d` `docker compose --file docker-compose.local.yml down` diff --git a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py new file mode 100644 index 0000000..6a4fdcc --- /dev/null +++ b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py @@ -0,0 +1,37 @@ +"""custom_imagery_and_description + +Revision ID: a92361f527ef +Revises: f3a7b9c1d2e4 +Create Date: 2026-07-14 15:08:42.142553 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "a92361f527ef" +down_revision: Union[str, None] = "f3a7b9c1d2e4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasking_projects", + sa.Column("custom_imagery", JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "tasking_projects", + sa.Column("description", sa.String(length=10000), nullable=True), + ) + pass + + +def downgrade() -> None: + op.drop_column("tasking_projects", "custom_imagery") + op.drop_column("tasking_projects", "description") + pass diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 709b80f..601c09e 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -47,7 +47,8 @@ class ProjectCreateRequest(WireModel): lock_timeout_hours: int = PydField(default=8, ge=1, le=720) aoi: Optional[AoiInput] = None role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) - custom_imagery: Optional[Any] = PydField(default=None) + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) @field_validator("name") @classmethod @@ -69,7 +70,8 @@ class ProjectUpdateRequest(WireModel): instructions: Optional[str] = PydField(default=None, max_length=10_000) lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) review_required: Optional[bool] = None - custom_imagery: Optional[Any] = PydField(default=None) + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) class ProjectResponse(WireModel): @@ -90,6 +92,7 @@ class ProjectResponse(WireModel): created_at: datetime updated_at: datetime custom_imagery: Optional[Any] = None + description: Optional[str] = None class ProjectListItem(WireModel): diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index b046436..71c4eb9 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -244,6 +244,7 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons created_at=project.created_at, updated_at=project.updated_at, custom_imagery=project.custom_imagery, # type: ignore[arg-type] + description=project.description, # type: ignore[arg-type] ) async def _provision_users_from_tdei( @@ -533,6 +534,7 @@ async def create( created_by=current_user.user_uuid, created_by_name=current_user.user_name, custom_imagery=body.custom_imagery, # type: ignore[arg-type] + description=body.description, # type: ignore[arg-type] ) if body.aoi is not None: geom = _aoi_to_shapely(body.aoi) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 8150f4b..85fc0b2 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -10,8 +10,8 @@ from pydantic import Field as PydField from sqlalchemy import Column from sqlalchemy import Enum as SAEnum -from sqlmodel import Field, SQLModel from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) @@ -97,10 +97,11 @@ class TaskingProject(SQLModel, table=True): # Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer. custom_imagery: Optional[Any] = Field( - default=None, - sa_column=Column(JSONB, nullable=True,default=None) + default=None, sa_column=Column(JSONB, nullable=True, default=None) ) + description: Optional[str] = Field(default=None, nullable=True) + # --------------------------------------------------------------------------- # Project role enum + table From 49c11b6e1ede198768882ef346beb17d11844f4f Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 16:06:52 +0530 Subject: [PATCH 138/159] Added routes for validate name Added routes for validating the project name and unit tests for the same --- api/src/tasking/projects/dtos.py | 5 ++ api/src/tasking/projects/repository.py | 16 +++++ api/src/tasking/projects/routes.py | 21 +++++++ tests/integration/test_projects_flow.py | 32 ++++++++++ tests/unit/conftest.py | 8 +++ tests/unit/test_project_routes.py | 80 +++++++++++++++++++++++++ 6 files changed, 162 insertions(+) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 601c09e..01057a8 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -120,6 +120,10 @@ class ProjectListResponse(WireModel): pagination: Pagination +class ProjectNameValidationResponse(WireModel): + exists: bool + + # --------------------------------------------------------------------------- # AOI response shape (canonical Feature wrapping a MultiPolygon) # --------------------------------------------------------------------------- @@ -190,6 +194,7 @@ class SelfProjectRolesResponse(WireModel): "ProjectCreateRequest", "ProjectListItem", "ProjectListResponse", + "ProjectNameValidationResponse", "ProjectResponse", "ProjectRoleAddRequest", "ProjectRoleAssignment", diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 71c4eb9..beee8e6 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -470,6 +470,22 @@ async def list_projects( pagination=Pagination(page=page, page_size=page_size, total=total), ) + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + result = await self.session.execute( + select(func.count()) + .select_from(TaskingProject) + .where( + (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.name == name) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) + ) + ) + return int(result.scalar() or 0) > 0 + async def create( self, workspace_id: int, diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index d4979b0..4d531d0 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -12,6 +12,7 @@ AoiFeature, ProjectCreateRequest, ProjectListResponse, + ProjectNameValidationResponse, ProjectResponse, ProjectRoleAddRequest, ProjectRoleItem, @@ -128,6 +129,26 @@ async def create_project( ) +@router.get("/validate-name", response_model=ProjectNameValidationResponse) +async def validate_project_name( + workspace_id: int, + name: str = Query(..., min_length=1, max_length=255), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + normalized_name = name.strip() + if not normalized_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="name cannot be blank", + ) + return ProjectNameValidationResponse( + exists=await project_repo.project_name_exists(workspace_id, normalized_name) + ) + + @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( workspace_id: int, diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index c1575de..088c614 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -206,6 +206,38 @@ async def test_outsider_404s_on_list( assert r.status_code == 404 +class TestProjectNameValidation: + async def test_validate_name_false_then_true( + self, client, as_lead, seeded_workspace_id + ): + """validate-name reports false before create and true after create for same workspace.""" + path = f"{API.format(wid=seeded_workspace_id)}/validate-name" + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": False} + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "name-check"}, + ) + assert r.status_code == 201, r.text + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": True} + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id + ): + """Outsider receives 404 from tenancy gate on validate-name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "anything"}, + ) + assert r.status_code == 404 + + # --------------------------------------------------------------------------- # Workflow 3b — error mapping (constraint violations → precise HTTP status). # --------------------------------------------------------------------------- diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 20ac3f5..e9fc3ad 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -175,6 +175,14 @@ async def get(self, workspace_id: int, project_id: int): raise NotFoundException(f"Project {project_id} not found") return self._response(p) + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + return any( + p["workspace_id"] == workspace_id + and p["name"] == name + and p["deleted_at"] is None + for p in self._projects.values() + ) + async def patch(self, workspace_id, project_id, body, current_user): p_resp = await self.get(workspace_id, project_id) p = self._projects[project_id] diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py index 1620aba..792103a 100644 --- a/tests/unit/test_project_routes.py +++ b/tests/unit/test_project_routes.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + API = "/api/v1/workspaces/{wid}/tasking/projects" @@ -68,6 +70,17 @@ async def test_create_blank_name_422( ) assert r.status_code == 422 + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, True, ["x"]]) + async def test_create_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """custom_imagery must be a JSON object; scalar/array values are rejected (422).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot", "custom_imagery": bad_value}, + ) + assert r.status_code == 422 + async def test_get_404_when_missing( self, client, as_lead, seeded_workspace_id, fake_repos ): @@ -105,6 +118,24 @@ async def test_patch_name(self, client, as_lead, seeded_workspace_id, fake_repos assert r.status_code == 200 assert r.json()["name"] == "after" + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, False, ["x"]]) + async def test_patch_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """PATCH rejects custom_imagery when it is not a JSON object (422).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "before"}, + ) + ).json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}", + json={"custom_imagery": bad_value}, + ) + assert r.status_code == 422 + async def test_soft_delete_204_then_404( self, client, as_lead, seeded_workspace_id, fake_repos ): @@ -137,6 +168,55 @@ async def test_duplicate_name_409( assert r.status_code == 409 +class TestProjectNameValidation: + async def test_validate_name_returns_false_when_missing( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Validation returns exists=false when no active project uses the name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "new-project"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": False} + + async def test_validate_name_returns_true_when_exists( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Validation returns exists=true when an active project with same name exists.""" + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "pilot-check"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": True} + + async def test_validate_name_blank_rejected_422( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Whitespace-only name is rejected with 422.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": " "}, + ) + assert r.status_code == 422 + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id, fake_repos + ): + """Tenancy gate hides workspace existence for outsiders.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 404 + + # --------------------------------------------------------------------------- # Lifecycle gates # --------------------------------------------------------------------------- From ed1b7f45d7e9b9c27212fc4d601e6273c7f9d445 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 16:16:09 +0530 Subject: [PATCH 139/159] Update a92361f527ef_custom_imagery_and_description.py removed unnecessary pass statement --- .../versions/a92361f527ef_custom_imagery_and_description.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py index 6a4fdcc..6f1bc64 100644 --- a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py +++ b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py @@ -28,10 +28,8 @@ def upgrade() -> None: "tasking_projects", sa.Column("description", sa.String(length=10000), nullable=True), ) - pass def downgrade() -> None: op.drop_column("tasking_projects", "custom_imagery") op.drop_column("tasking_projects", "description") - pass From 1129280b57018067103b9c986b779a881c949521 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 16:26:26 +0530 Subject: [PATCH 140/159] updated required settings --- README.md | 2 +- api/src/tasking/projects/repository.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ebba814..06ba988 100644 --- a/README.md +++ b/README.md @@ -106,4 +106,4 @@ connecting to existing Databases. `docker compose --file docker-compose.local.yml down` -Backend code will be available at `http://localhost:3000` \ No newline at end of file +Backend code will be available at `http://localhost:8000` \ No newline at end of file diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index beee8e6..95875c1 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -654,6 +654,8 @@ async def patch( updates["review_required"] = body.review_required if body.custom_imagery is not None: updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type] + if body.description is not None: + updates["description"] = body.description if updates: updates["updated_at"] = datetime.now() From 730d4883c017d36385894866a994a89d965f2eed Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 14 Jul 2026 10:18:05 -0400 Subject: [PATCH 141/159] Expand README with deployment architecture and services Added detailed deployment architecture and services information to README. --- README.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 06ba988..429a472 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,78 @@ following contract. `CLAUDE.md` has the full rationale. * ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag * ```staging``` keep this up to date with the "staging" environment / stage tag * ```production``` keep this up to date with the "production" environment / prod tag + +## Deployment architecture + +The deployed system is defined by [`docker-compose.az.yml`](docker-compose.az.yml). It runs the +**application tier** as four containers; the **data tier** (Postgres/PostGIS) is external, managed +Azure Database for PostgreSQL, not part of this compose file. + +When deployed by `workspaces-stack` this model also holds. + +``` + client (TDEI/Keycloak JWT) + │ + ▼ :8000 + ┌──────────────────────────────────────┐ + │ workspaces-backend │ this repo — FastAPI front door. + │ authn/authz + OSM reverse proxy │ Serves /api/v1/*, proxies the rest. + └───┬──────────────────────────────┬────┘ + WS_OSM_HOST TASK_DATABASE_URL + (→ osm-rails) OSM_DATABASE_URL + │ │ + ▼ │ + ┌──────────────┐ │ + │ osm-rails │ OSM website (Rails); the single OSM entry point. + │ :3000 │ Serves the API/UI and fronts cgimap for the + └──────┬───────┘ performance-critical /api/0.6 calls. + │ (internal) │ + ▼ │ + ┌──────────────┐ │ + │ osm-cgimap │ C-accelerated /api/0.6 (map, changeset bulk) + │ :8000 │ │ + └──────────────┘ │ + │ + osm-rails-worker (rake jobs:work) │ background jobs + │ │ + │ backend, rails, cgimap, worker all connect to ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ data tier — Azure Postgres (external, PostGIS) │ + │ opensidewalks-${ENV}.postgres.database.azure.com:5432 │ + │ • workspaces-tasks-${ENV} TASK db (alembic_task; backend) │ + │ • workspaces-osm-${ENV} OSM db (alembic_osm; all four) │ + └─────────────────────────────────────────────────────────────────┘ +``` + +### Services + +| Service | Image | Role | +|---|---|---| +| `workspaces-backend` | `workspaces-backend-v2:${ENV}` | This repo. The only host-exposed service (`8000:8000`). Validates the TDEI/Keycloak JWT, enforces workspace authorization, serves `/api/v1/*`, and proxies everything else to the OSM tier. Connects to **both** databases. | +| `osm-rails` | `workspaces-osm-rails-v2:${ENV}` | The OpenStreetMap website (Rails) — the **single OSM entry point** the backend proxies to (`WS_OSM_HOST`). Serves the OSM API/UI and fronts cgimap for the heavy `/api/0.6` calls. Connects to the OSM db. | +| `osm-cgimap` | `workspaces-osm-cgimap-v2:${ENV}` | C++ reimplementation of the performance-critical OSM `0.6` calls (map queries, changeset upload/download), sitting behind `osm-rails`. Tuned here for large imports (`CGIMAP_MAX_*`). Connects to the OSM db. | +| `osm-rails-worker` | `workspaces-osm-rails-v2:${ENV}` | Background job runner (`rake jobs:work`) for the Rails app. Connects to the OSM db. | + +### Two databases + +The backend holds two connections, and the two alembic trees target them independently (see +`CLAUDE.md` and `api/utils/migrations.py`): + +* **TASK db** (`TASK_DATABASE_URL` → `workspaces-tasks-${ENV}`) — the workspaces + tasking-manager + schema, built by the `alembic_task` tree. Only the backend connects here. +* **OSM db** (`OSM_DATABASE_URL` → `workspaces-osm-${ENV}`) — OSM data plus `users` and the + `tasking_*` tables, built by the `alembic_osm` tree. The backend, cgimap, rails, and the worker + all connect here. + +On startup (outside of pytest) the backend runs `alembic -n task upgrade head` and +`alembic -n osm upgrade head`, applying each tree to its database. + +### Environment templating + +Every image tag, database name/user, and server host is parameterized by `${ENV}` +(`dev` / `stage` / `prod`), and secrets are injected from the shell environment +(`${WS_TASKS_DB_PASS}`, `${WS_OSM_DB_PASS}`, `${WS_OSM_SECRET_KEY_BASE}`). Branches map to these +environments — see the Branch Index below. ## To start on your local machine for dev work @@ -106,4 +178,4 @@ connecting to existing Databases. `docker compose --file docker-compose.local.yml down` -Backend code will be available at `http://localhost:8000` \ No newline at end of file +Backend code will be available at `http://localhost:8000` From 90ddfc5c3d122bc68c8ec22ee100c8d52b07bd4b Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 15 Jul 2026 14:38:06 +0530 Subject: [PATCH 142/159] Update README.md Readme updated with steps to run local development --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 06ba988..4a1f9fe 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,55 @@ uv run black api tests && uv run isort api tests Use the file `docker-compose.local.yml` to build and deploy local code changes. This allows you to run the entire system at once instead of connecting to existing Databases. -### Initial setup. -- On first launch, rails-worker will fail because migrations are not done -- Go to the `osm-rails` `/bin/sh` and execute `bundle exec rails db:migrate` -- The above script runs the migration code for osm-rails -- The rails-worker will be able to run -- The backend code will fail first time becase `workspaces-tasks-local` database is not available -- Login to postgresql container and run the following commands - `psql --username postgres` - `create database "workspaces-tasks-local";` - `psql --username postgres --dbname "workspaces-tasks-local";` - `create extension if not exists postgis;` -- Run the backend code now and it should be able to run +### Initial setup for development local environment + +Step 1: Login to azure docker + +The docker compose relies on images in `opensidewalksdev` azure container registry. Make sure your docker system is logged into it before pulling the images and trying to run the containers. + +Docker login command: + +`docker login opensidewalksdev.azurecr.io -u opensidewalksdev ` + +Password needs to be obtained from Azure portal + +Step 2: Run docker compose for the first time + +Use the following command to start the containers first time + +`docker compose --file docker-compose.local.yml up --build` + +Step 3: Run the migration scripts. + +You will observe that only `osm-rails` component seems to work but the backend and other services may be down. this is because the database migrations on the base osm database are not done. To do the base migrations, do the following: + +- Connect to the `osm-rails` container. If you are using docker hub for desktop, just go to the exec section of the container. + If you want to use command line, execute the command `docker exec -it /bin/bash` where `container_name` is the name of osm-rails container +- Execute the migration script in the /bin/bash with `bundle exec rails db:migrate` +- The above command runs the migration script for databases + +Step 4: Add `workspaces-tasks-local` database in postgresql + +Workspaces backend relies on an additional database. This is needed for some older migrations code. + +- Connect to `database` container. +- Run the following set of commands one by one + +```shell +psql --username postgres +create database "workspaces-tasks-local"; +exit; +psql --username postgres --dbname "workspaces-tasks-local"; +create extension if not exists postgis; + +``` + +Step 5: Restart the docker compose again + +- `docker compose --file docker-compose.local.yml down` +- `docker compose --file docker-compose.local.yml up --build` + + ### Commands to start and stop the docker compose From 7b587c48711715556cc8a73f4fb817fb2ee14dea Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 9 Jul 2026 11:16:04 -0400 Subject: [PATCH 143/159] Defensive check on changeset resolve plus tests --- CLAUDE.md | 38 ++++++++--- api/src/osm/repository.py | 17 ++++- api/src/osm/routes.py | 2 +- tests/integration/test_osm.py | 117 ++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 tests/integration/test_osm.py diff --git a/CLAUDE.md b/CLAUDE.md index 8e25c07..1e5e9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,24 +79,42 @@ at all). Validated against the code: * **Export to TDEI** — no endpoint exists in this backend. * **Move Workspace PG→PG** — not possible; `WorkspacePatch` has no `tdeiProjectGroupId` field, so no route can change a workspace's project group. -* **Validate Changeset** and **Edit POSM Element** — these go through the OSM - proxy catch-all (`api/main.py`), which gates *every* proxied operation on +* **Edit POSM Element** — goes through the OSM proxy catch-all + (`api/main.py`), which gates *every* proxied operation on `isWorkspaceContributor` alone. There is no Validator- or Lead-level check on - proxied traffic. + proxied traffic. Raw changeset commits (proxied `PUT /api/0.6/changeset/...`) + are likewise Contributor-gated; the proxy only *tags* a contributor's new + changeset with `review_requested=yes` when the workspace has `autoFlagReview` + set — it does not enforce validation. -**The Validator role grants nothing extra at this layer.** -`isWorkspaceValidator` exists in `api/core/security.py` but no endpoint -authorizes on it — it only appears in the `role` field of `WorkspaceResponse`. -A Validator and a Contributor have identical permissions in this backend. +**Enforced here, Validator-gated (`isWorkspaceLead || isWorkspaceValidator` → +403).** This is a native FastAPI route, not proxied traffic. Leads (and POC via +`isWorkspaceLead`) inherit it: + +| Capability | Endpoint | +|---|---| +| Resolve/Validate Changeset | PUT `/workspaces/{id}/changesets/{changeset_id}/resolve` | + +Resolving clears the `review_requested` tag and stamps `reviewed_by` with the +reviewer's UUID. The gate is enforced both in the route +(`api/src/osm/routes.py`) and, defensively, inside +`OSMRepository.resolveChangeset` (`api/src/osm/repository.py`). + +**The Validator role grants exactly one thing at this layer:** the ability to +resolve changesets via the endpoint above. Aside from that, a Validator and a +Contributor have identical permissions in this backend. `isWorkspaceValidator` +otherwise only appears in the `role` field of `WorkspaceResponse`. **"Contributor" and "Authenticated User With PG/Workspace Association" are the same gate.** `isWorkspaceContributor` simply checks whether the workspace is in one of the user's project groups (`accessibleWorkspaceIds`), i.e. PG/workspace association — so both rows collapse to the same check. -If the Validator/Lead distinctions for changeset validation and TDEI export are -required, they must be enforced downstream (`workspaces-openstreetmap-website/`, -`workspaces-cgimap/`) — that has not been audited here. +Changeset *resolution* is Validator/Lead-gated here (see above). But the +Validator/Lead distinction on raw changeset *commits* and TDEI export is not +enforced at this layer; if required, it must be enforced downstream +(`workspaces-openstreetmap-website/`, `workspaces-cgimap/`) — that has not been +audited here. ## Testing diff --git a/api/src/osm/repository.py b/api/src/osm/repository.py index 5ca1c9a..5a29b7e 100644 --- a/api/src/osm/repository.py +++ b/api/src/osm/repository.py @@ -1,7 +1,8 @@ from sqlalchemy import text from sqlmodel.ext.asyncio.session import AsyncSession -from api.core.exceptions import NotFoundException +from api.core.exceptions import ForbiddenException, NotFoundException +from api.core.security import UserInfo class OSMRepository: @@ -50,10 +51,22 @@ async def getChangesetAdiff(self, workspace_id: int, changeset_id: int) -> list: async def resolveChangeset( self, + current_user: UserInfo, workspace_id: int, changeset_id: int, - reviewer_uuid: str, ) -> None: + # Defense in depth: resolving a changeset is a validator/lead + # capability. The route also enforces this, but gate here too so the + # repository cannot be misused from another call site. + if not current_user.isWorkspaceLead( + workspace_id + ) and not current_user.isWorkspaceValidator(workspace_id): + raise ForbiddenException( + "Only workspace leads and validators can resolve changesets" + ) + + reviewer_uuid = str(current_user.user_uuid) + await self.session.execute( text(f"SET search_path TO 'workspace-{int(workspace_id)}', public") ) diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py index 452b2d4..9542e5a 100644 --- a/api/src/osm/routes.py +++ b/api/src/osm/routes.py @@ -72,7 +72,7 @@ async def resolve_changeset( try: await repository_osm.resolveChangeset( - workspace_id, changeset_id, str(current_user.user_uuid) + current_user, workspace_id, changeset_id ) except Exception as e: logger.error( diff --git a/tests/integration/test_osm.py b/tests/integration/test_osm.py new file mode 100644 index 0000000..b4862d8 --- /dev/null +++ b/tests/integration/test_osm.py @@ -0,0 +1,117 @@ +"""Integration tests for the /workspaces OSM routes (api/src/osm/routes.py). + +Each test drives a real HTTP request through the real route + repository, +queueing simulated rows on the fake sessions. + +Focus: PUT /{id}/changesets/{cid}/resolve is gated to workspace leads and +validators (403 otherwise). The gate is enforced both in the route and, +defensively, inside ``OSMRepository.resolveChangeset`` -- so a contributor is +rejected before any DB work, and the repository would reject a bad call site +even if the route check were bypassed. +""" + +import pytest + +from api.src.users.schemas import WorkspaceUserRoleType +from tests.support import factories, fakes + +API = "/api/v1/workspaces" + + +def _resolve_url(workspace_id=1, changeset_id=99): + return f"{API}/{workspace_id}/changesets/{changeset_id}/resolve" + + +def _user_with_role(role, workspace_id=1): + return factories.make_user_info(osm_workspace_roles={workspace_id: [role]}) + + +# === PUT /{id}/changesets/{cid}/resolve ==================================== + + +async def test_resolve_changeset_validator_204( + client, login, task_session, osm_session +): + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) # getById + osm_session.queue(fakes.affected(1), fakes.affected(1)) # DELETE, INSERT + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 # resolveChangeset ran to completion + + +async def test_resolve_changeset_lead_204(client, login, task_session, osm_session): + login(_user_with_role(WorkspaceUserRoleType.LEAD)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1), fakes.affected(1)) + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_resolve_changeset_poc_204(client, login, task_session, osm_session): + # POC on the owning project group satisfies isWorkspaceLead. + login( + factories.make_user_info( + accessible_workspace_ids={factories.DEFAULT_PG_ID: [1]}, + poc_group_ids=(factories.DEFAULT_PG_ID,), + ) + ) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.affected(1), fakes.affected(1)) + + response = await client.put(_resolve_url()) + + assert response.status_code == 204 + assert osm_session.commits == 1 + + +async def test_resolve_changeset_contributor_403( + client, login, task_session, osm_session +): + # A contributor (PG association, no validator/lead grant) is rejected + # before any DB work -- neither session should be touched. + login( + factories.make_user_info( + accessible_workspace_ids={factories.DEFAULT_PG_ID: [1]} + ) + ) + + response = await client.put(_resolve_url()) + + assert response.status_code == 403 + assert osm_session.commits == 0 + + +async def test_resolve_changeset_no_access_403(client, login): + # A user with no association to the workspace is likewise forbidden. + login() + + response = await client.put(_resolve_url()) + + assert response.status_code == 403 + + +async def test_resolve_changeset_validator_of_other_workspace_403(client, login): + # Validator rights on workspace 2 do not authorize resolving on workspace 1. + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR, workspace_id=2)) + + response = await client.put(_resolve_url(workspace_id=1)) + + assert response.status_code == 403 + + +async def test_resolve_changeset_unexpected_error_500( + error_client, login, task_session, osm_session +): + login(_user_with_role(WorkspaceUserRoleType.VALIDATOR)) + task_session.queue(fakes.rows(factories.make_workspace(id=1))) + osm_session.queue(fakes.raises(RuntimeError("db"))) # DELETE blows up + + response = await error_client.put(_resolve_url()) + + assert response.status_code == 500 From 2d9c4a61a1613636ca4c4773b3997cdb8a9eed33 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Thu, 9 Jul 2026 11:26:53 -0400 Subject: [PATCH 144/159] Update routes.py --- api/src/osm/routes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/src/osm/routes.py b/api/src/osm/routes.py index 9542e5a..cadee1e 100644 --- a/api/src/osm/routes.py +++ b/api/src/osm/routes.py @@ -71,9 +71,7 @@ async def resolve_changeset( await repository_ws.getById(current_user, workspace_id) try: - await repository_osm.resolveChangeset( - current_user, workspace_id, changeset_id - ) + await repository_osm.resolveChangeset(current_user, workspace_id, changeset_id) except Exception as e: logger.error( f"Failed to resolve changeset {changeset_id}" From 89978376abb03009190934f2039c1aafb37bcb83 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 10 Jul 2026 15:43:55 +0530 Subject: [PATCH 145/159] Deploy code feature Deploying the required code --- .github/workflows/trigger-deploy-on-merge.yml | 36 +++++++++++++++++++ api/core/config.py | 8 ++--- api/main.py | 4 ++- 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/trigger-deploy-on-merge.yml diff --git a/.github/workflows/trigger-deploy-on-merge.yml b/.github/workflows/trigger-deploy-on-merge.yml new file mode 100644 index 0000000..82265c8 --- /dev/null +++ b/.github/workflows/trigger-deploy-on-merge.yml @@ -0,0 +1,36 @@ +name: Trigger Deployment on Pull Request Merge +on: + pull_request: + types: [closed] + branches: [ develop, staging, production] +permissions: + contents: read + actions: write +jobs: + on-merge: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-environment.outputs.environment }} + steps: + - name: Set Environment + id: set-environment + run: | + case "${{ github.base_ref }}" in + develop) echo "environment=develop" >> $GITHUB_OUTPUT ;; + staging) echo "environment=staging" >> $GITHUB_OUTPUT ;; + production) echo "environment=production" >> $GITHUB_OUTPUT ;; + testing) echo "environment=testing" >> $GITHUB_OUTPUT ;; + esac + deploy: + needs: on-merge + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Trigger Deployment Workflow + run: | + gh workflow run push-docker-image.yml \ + --repo ${{ github.repository }} \ + --ref ${{ github.base_ref }} \ + --field environment=${{ needs.on-merge.outputs.environment }} \ No newline at end of file diff --git a/api/core/config.py b/api/core/config.py index c4c6bde..643e586 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -10,11 +10,9 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Workspaces API" - # JSON array of allowed CORS origins. For example: - # - # ["https://workspaces.example.com", "https://leaderboard.example.com"] - # - CORS_ORIGINS: list[str] = [] + # Comma separated list of origins, or "*" to allow all origins. Defaults to an empty list. + # Example: CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + CORS_ORIGINS: str = "" TASK_DATABASE_URL: str = ( "postgresql+asyncpg://user:pass@localhost:5432/tasking_manager" diff --git a/api/main.py b/api/main.py index d537b72..9123a4e 100644 --- a/api/main.py +++ b/api/main.py @@ -97,7 +97,9 @@ async def lifespan(_app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, + allow_origins=[ + origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip() + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], From 3d9ea37b4d97c414c2be02ec56315e7ae6ec6563 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Fri, 10 Jul 2026 15:49:29 +0530 Subject: [PATCH 146/159] Update test_config.py Updated unit tests for CORS --- tests/unit/test_config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 3a06d3b..4a9ab45 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -14,7 +14,7 @@ def test_defaults_loaded_when_env_unset(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg assert s.PROJECT_NAME == "Workspaces API" - assert s.CORS_ORIGINS == [] + assert s.CORS_ORIGINS == "" assert s.DEBUG is False assert s.SENTRY_DSN == "" assert s.WS_OSM_HOST == "http://osm-web" @@ -38,18 +38,18 @@ def test_env_vars_override_members(monkeypatch): def test_cors_origins_parsed_from_json_env(monkeypatch): - monkeypatch.setenv("CORS_ORIGINS", '["https://a.example", "https://b.example"]') + monkeypatch.setenv("CORS_ORIGINS", "https://a.example,https://b.example") s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg - assert s.CORS_ORIGINS == ["https://a.example", "https://b.example"] + assert s.CORS_ORIGINS == "https://a.example,https://b.example" def test_value_types_and_formats(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg assert isinstance(s.PROJECT_NAME, str) - assert isinstance(s.CORS_ORIGINS, list) + assert isinstance(s.CORS_ORIGINS, str) assert isinstance(s.DEBUG, bool) # empty-string default is preserved (not coerced to None): assert s.SENTRY_DSN == "" From e9456cd504b334f2202322baac67713acbacef3a Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 15:11:17 -0400 Subject: [PATCH 147/159] Enable osm-web API endpoints with auth token --- api/core/config.py | 15 +++ api/core/security.py | 139 ++++++++++++++++++++++ tests/unit/test_token_bridge.py | 197 ++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 tests/unit/test_token_bridge.py diff --git a/api/core/config.py b/api/core/config.py index 643e586..7911264 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -38,6 +38,21 @@ class Settings(BaseSettings): # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" + # OSM token bridge: when a TDEI token is validated, mirror it into the OSM + # database's `oauth_access_tokens` table so osm-rails (doorkeeper) and cgimap + # authenticate it via their standard OAuth2 path -- no custom JWT handling + # needed in those services. Disabled while WS_OSM_OAUTH_APPLICATION_ID is 0. + # Set it to the id of the doorkeeper `oauth_applications` row these tokens + # should belong to (create one via the OSM `register_apps` rake task or SQL). + WS_OSM_OAUTH_APPLICATION_ID: int = 0 + + # Scopes granted to the mirrored token. Must cover the OSM API operations the + # frontend performs; see `lib/oauth.rb` in the OSM website for valid values. + WS_OSM_OAUTH_SCOPES: str = ( + "read_prefs write_prefs write_api write_changeset_comments " + "read_gpx write_gpx write_notes" + ) + SENTRY_DSN: str = "" model_config = SettingsConfigDict( diff --git a/api/core/security.py b/api/core/security.py index 0ff32e2..1db25f9 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,3 +1,4 @@ +import time from enum import StrEnum from uuid import UUID @@ -19,6 +20,8 @@ # @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles # @test: Test that any failed network requests are handled gracefully # @test: Test that the caching mechanism works correctly and evicts entries when roles change +# @test: Test that a validated token is mirrored into the OSM oauth_access_tokens table (with the user provisioned and expires_in from the JWT exp) when WS_OSM_OAUTH_APPLICATION_ID is set, and is a no-op otherwise +# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_OAUTH_APPLICATION_ID # Set up logger for this module logger = get_logger(__name__) @@ -316,6 +319,14 @@ async def validate_token( logger.info("Token validation cache hit") return cached logger.info("Token validation cache miss: token rotated") + # The old token is superseded; revoke its OSM row so it stops + # authenticating against osm-rails/cgimap before its own exp. + # TODO: this assumes a single active token per user. Truly concurrent + # sessions (multiple valid jtis for one user) will flap -- each request + # revokes the other session's OSM token and reactivates its own via the + # DO UPDATE upsert. To support multi-session, track multiple active jtis + # per user (cache + revoke set) instead of a single cached token. + await _revoke_osm_token(osm_db_session, cached.credentials) del _user_info_cache[user_uuid] # Cache miss: fetch TDEI roles and DB data: @@ -327,6 +338,123 @@ async def validate_token( return user_info +async def _bridge_token_to_osm( + session: AsyncSession, + *, + user_uuid: UUID, + user_name: str, + email: str | None, + token: str, + exp: int | None, +) -> None: + """Mirror a validated TDEI token into the OSM database so osm-rails + (doorkeeper) and cgimap authenticate it through their standard OAuth2 path, + with no custom JWT handling required in those services. + + Two idempotent writes: provision the OSM ``users`` row (owns the token via + ``resource_owner_id``) and insert a plaintext ``oauth_access_tokens`` row. + Doorkeeper stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT + matches on lookup for both osm-rails and cgimap; ``expires_in`` tracks the + JWT's own ``exp`` so OSM expires it in lockstep with TDEI. + + No-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is set. Best-effort: a failure + here must not break token validation -- the ``/api/v1`` routes keep working; + only the proxied OSM calls would 401 until the row exists. + """ + if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + return + + auth_uid = str(user_uuid) + try: + # Provision the users row (same shape as _provision_users_from_tdei). + await session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, " + "status, pass_crypt, data_public, email_valid, terms_seen, " + "creation_time, terms_agreed, tou_agreed) " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "true, true, true, (now() at time zone 'utc'), " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + {"auth_uid": auth_uid, "email": email, "name": user_name}, + ) + row = ( + await session.execute( + text( + "SELECT id FROM users " + "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" + ), + {"auth_uid": auth_uid}, + ) + ).first() + if row is None: + return + user_id = row[0] + + expires_in = max(0, exp - int(time.time())) if exp else None + await session.execute( + text( + "INSERT INTO oauth_access_tokens (application_id, " + "resource_owner_id, token, scopes, created_at, expires_in) " + "VALUES (:app_id, :user_id, :token, :scopes, " + "(now() at time zone 'utc'), :expires_in) " + # Re-presenting a token reactivates it: refresh the expiry to + # track this validation and clear any prior revocation, so a + # token revoked on rotation heals if it is used again. + "ON CONFLICT (token) DO UPDATE SET " + "resource_owner_id = EXCLUDED.resource_owner_id, " + "scopes = EXCLUDED.scopes, " + "created_at = EXCLUDED.created_at, " + "expires_in = EXCLUDED.expires_in, " + "revoked_at = NULL" + ), + { + "app_id": settings.WS_OSM_OAUTH_APPLICATION_ID, + "user_id": user_id, + "token": token, + "scopes": settings.WS_OSM_OAUTH_SCOPES, + "expires_in": expires_in, + }, + ) + await session.commit() + except Exception as e: + # Never fail auth on a bridge error; the OSM row just won't exist yet. + logger.warning( + "Failed to bridge TDEI token into OSM oauth_access_tokens: %s", e + ) + await session.rollback() + + +async def _revoke_osm_token(session: AsyncSession, token: str) -> None: + """Revoke a superseded token's OSM ``oauth_access_tokens`` row (best-effort). + + Called when a user's token rotates (new ``jti``) so the previous JWT stops + authenticating against osm-rails/cgimap before its own ``exp`` rather than + lingering until it expires. No-op unless the bridge is configured. + + Note: OSM/cgimap are reachable only *through* this proxy, and every request + first passes ``validate_token`` -- so a TDEI-revoked token is already + rejected upstream. This revocation is defense-in-depth for the superseded + token and keeps the OSM token store tidy. + """ + if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + return + try: + await session.execute( + text( + "UPDATE oauth_access_tokens " + "SET revoked_at = (now() at time zone 'utc') " + "WHERE token = :token AND revoked_at IS NULL" + ), + {"token": token}, + ) + await session.commit() + except Exception as e: + logger.warning("Failed to revoke superseded OSM token: %s", e) + await session.rollback() + + async def _validate_token_uncached( token: str, user_uuid: UUID, @@ -427,6 +555,17 @@ async def _validate_token_uncached( osmRoles[i["workspace_id"]].append(i["role"]) r.osmWorkspaceRoles = osmRoles + # Mirror this validated token into the OSM DB so proxied OSM/cgimap calls + # authenticate via the standard OAuth2 path. No-op unless configured. + await _bridge_token_to_osm( + osm_db_session, + user_uuid=user_uuid, + user_name=r.user_name, + email=payload.get("email"), + token=token, + exp=payload.get("exp"), + ) + logger.info("Finished validation of token") return r diff --git a/tests/unit/test_token_bridge.py b/tests/unit/test_token_bridge.py new file mode 100644 index 0000000..401ad73 --- /dev/null +++ b/tests/unit/test_token_bridge.py @@ -0,0 +1,197 @@ +"""Tests for the OSM token bridge in ``api/core/security.py``. + +``_bridge_token_to_osm`` mirrors a validated TDEI token into the OSM database's +``oauth_access_tokens`` table so osm-rails (doorkeeper) and cgimap authenticate +it through their standard OAuth2 path. We cover: + +- it is a no-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is configured, +- when enabled it provisions the ``users`` row and inserts a plaintext + ``oauth_access_tokens`` row owned by that user, with ``expires_in`` derived + from the JWT ``exp``, +- a DB failure never propagates (auth must not break) and rolls back. +""" + +import time +from typing import cast +from uuid import uuid4 + +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as security +from api.core.config import settings + + +class RecordingSession: + """Async session stand-in that records (sql, params) and returns a user id + row for the ``SELECT id FROM users`` lookup.""" + + def __init__(self, user_id=99): + self.calls: list[tuple[str, dict]] = [] + self.commits = 0 + self.rollbacks = 0 + self._user_id = user_id + + async def execute(self, statement, params=None): + sql = str(statement) + self.calls.append((sql, params or {})) + + class _R: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + if "SELECT id FROM users" in sql: + return _R([(self._user_id,)]) + return _R([]) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + def sql_for(self, needle): + return [c for c in self.calls if needle in c[0]] + + +async def test_bridge_is_noop_when_disabled(monkeypatch): + """With WS_OSM_OAUTH_APPLICATION_ID == 0 the bridge touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email="alice@example.com", + token="tok", + exp=None, + ) + + assert session.calls == [] + assert session.commits == 0 + + +async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): + """When enabled: users upsert -> id lookup -> oauth_access_tokens upsert.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_OAUTH_SCOPES", "write_api read_prefs") + uid = uuid4() + exp = int(time.time()) + 3600 + session = RecordingSession(user_id=42) + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uid, + user_name="alice", + email="alice@example.com", + token="jwt-token", + exp=exp, + ) + + # users row provisioned idempotently, keyed by auth_uid = str(sub) + user_inserts = session.sql_for("INSERT INTO users") + assert user_inserts and "ON CONFLICT (auth_uid)" in user_inserts[0][0] + assert user_inserts[0][1]["auth_uid"] == str(uid) + assert user_inserts[0][1]["email"] == "alice@example.com" + + # token mirrored with the configured application/scopes and exp-derived TTL + token_inserts = session.sql_for("oauth_access_tokens") + assert token_inserts and "ON CONFLICT (token)" in token_inserts[0][0] + # Re-presenting a token must reactivate it (clear revocation, refresh expiry). + assert "DO UPDATE" in token_inserts[0][0] + assert "revoked_at = NULL" in token_inserts[0][0] + params = token_inserts[0][1] + assert params["app_id"] == 7 + assert params["user_id"] == 42 + assert params["token"] == "jwt-token" + assert params["scopes"] == "write_api read_prefs" + assert 3590 <= params["expires_in"] <= 3600 + + assert session.commits == 1 + assert session.rollbacks == 0 + + +async def test_bridge_expires_in_none_without_exp(monkeypatch): + """A token without an `exp` claim mirrors with a NULL expires_in.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="jwt-token", + exp=None, + ) + + params = session.sql_for("oauth_access_tokens")[0][1] + assert params["expires_in"] is None + + +async def test_bridge_is_best_effort_on_db_error(monkeypatch): + """A DB failure must not propagate out of validation; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + # Must not raise. + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="tok", + exp=None, + ) + + assert session.rollbacks == 1 + assert session.commits == 0 + + +async def test_revoke_is_noop_when_disabled(monkeypatch): + """With the bridge off, revoking a rotated token touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + assert session.calls == [] + assert session.commits == 0 + + +async def test_revoke_marks_only_the_superseded_token(monkeypatch): + """Revocation flips revoked_at for exactly the given token, if not already.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + updates = session.sql_for("UPDATE oauth_access_tokens") + assert updates and "revoked_at" in updates[0][0] + assert "WHERE token = :token AND revoked_at IS NULL" in updates[0][0] + assert updates[0][1]["token"] == "old-token" + assert session.commits == 1 + + +async def test_revoke_is_best_effort_on_db_error(monkeypatch): + """A DB failure during revocation must not propagate; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "tok") + + assert session.rollbacks == 1 + assert session.commits == 0 From 474f4230e6ebe5de3876b2ec1d109df31a502d50 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 15:48:01 -0400 Subject: [PATCH 148/159] CORS config file fix --- api/core/config.py | 30 ++++++++++++++++++++++++++++-- api/main.py | 4 +--- tests/unit/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index 7911264..dbda4b3 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -1,3 +1,5 @@ +import json + from pydantic_settings import BaseSettings, SettingsConfigDict @@ -10,8 +12,12 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Workspaces API" - # Comma separated list of origins, or "*" to allow all origins. Defaults to an empty list. - # Example: CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # Allowed CORS origins. Accepts either a comma-separated list OR a JSON + # array (the deployment stack historically supplies a JSON array), plus "*" + # for all origins. Read via `cors_origins_list`, not this raw string. + # Examples: + # CORS_ORIGINS="https://workspaces.example.com,https://leaderboard.example.com" + # CORS_ORIGINS='["https://workspaces.example.com"]' CORS_ORIGINS: str = "" TASK_DATABASE_URL: str = ( @@ -55,6 +61,26 @@ class Settings(BaseSettings): SENTRY_DSN: str = "" + @property + def cors_origins_list(self) -> list[str]: + """Allowed CORS origins as a list. + + Tolerates both formats the deployment has used: a JSON array + (``["https://a","https://b"]``) and a comma-separated string + (``https://a,https://b``). ``"*"`` is passed through as-is. + """ + raw = self.CORS_ORIGINS.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(o).strip() for o in parsed if str(o).strip()] + return [o.strip() for o in raw.split(",") if o.strip()] + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/api/main.py b/api/main.py index 9123a4e..c3708b3 100644 --- a/api/main.py +++ b/api/main.py @@ -97,9 +97,7 @@ async def lifespan(_app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=[ - origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip() - ], + allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 4a9ab45..cc96fa0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -45,6 +45,43 @@ def test_cors_origins_parsed_from_json_env(monkeypatch): assert s.CORS_ORIGINS == "https://a.example,https://b.example" +def test_cors_origins_list_comma_separated(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", "https://a.example, https://b.example") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_json_array(monkeypatch): + # The deployment stack supplies a JSON array; it must parse, not become one + # malformed bracketed origin. + monkeypatch.setenv("CORS_ORIGINS", '["https://a.example","https://b.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://a.example", "https://b.example"] + + +def test_cors_origins_list_single_json_array(monkeypatch): + monkeypatch.setenv("CORS_ORIGINS", '["https://only.example"]') + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["https://only.example"] + + +def test_cors_origins_list_empty_and_wildcard(monkeypatch): + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == [] + + monkeypatch.setenv("CORS_ORIGINS", "*") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["*"] + + +def test_cors_origins_list_malformed_json_falls_back_to_comma(monkeypatch): + # A value that starts with "[" but isn't valid JSON should not crash; fall + # back to comma-splitting rather than raising. + monkeypatch.setenv("CORS_ORIGINS", "[not json") + s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg + assert s.cors_origins_list == ["[not json"] + + def test_value_types_and_formats(): s = Settings(_env_file=None) # type: ignore[call-arg] # pydantic-settings init kwarg From 6a9b034d091936f987a8273652cf66cc32343b87 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 16:21:49 -0400 Subject: [PATCH 149/159] Tweak to enable OSM token bridge by default --- api/core/config.py | 32 +++++--- api/core/security.py | 137 +++++++++++++++++++++++--------- tests/unit/test_token_bridge.py | 98 ++++++++++++++--------- 3 files changed, 181 insertions(+), 86 deletions(-) diff --git a/api/core/config.py b/api/core/config.py index dbda4b3..04693ac 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -44,21 +44,33 @@ class Settings(BaseSettings): # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" - # OSM token bridge: when a TDEI token is validated, mirror it into the OSM - # database's `oauth_access_tokens` table so osm-rails (doorkeeper) and cgimap - # authenticate it via their standard OAuth2 path -- no custom JWT handling - # needed in those services. Disabled while WS_OSM_OAUTH_APPLICATION_ID is 0. - # Set it to the id of the doorkeeper `oauth_applications` row these tokens - # should belong to (create one via the OSM `register_apps` rake task or SQL). - WS_OSM_OAUTH_APPLICATION_ID: int = 0 - - # Scopes granted to the mirrored token. Must cover the OSM API operations the - # frontend performs; see `lib/oauth.rb` in the OSM website for valid values. + # OSM token bridge: when enabled, a validated TDEI token is mirrored into the + # OSM database's `oauth_access_tokens` table so osm-rails (doorkeeper) and + # cgimap authenticate it via their standard OAuth2 path -- no custom JWT + # handling needed in those services. The backend also auto-creates the + # doorkeeper `oauth_applications` row (keyed by WS_OSM_OAUTH_CLIENT_UID and + # owned by the dedicated system user below) that these tokens belong to, so + # no manual OSM setup is required. + WS_OSM_TOKEN_BRIDGE_ENABLED: bool = True + + # Stable client id (uid) for the auto-created doorkeeper application. Point + # this at an existing application's uid to reuse it instead of creating one. + WS_OSM_OAUTH_CLIENT_UID: str = "workspaces-backend" + + # Scopes granted to the application and mirrored tokens. Must cover the OSM + # API operations the frontend performs; see `lib/oauth.rb` in the OSM website. WS_OSM_OAUTH_SCOPES: str = ( "read_prefs write_prefs write_api write_changeset_comments " "read_gpx write_gpx write_notes" ) + # Dedicated OSM `users` row that owns the auto-created doorkeeper application + # (satisfies oauth_applications.owner_id, a NOT-NULL FK to users). It never + # signs in; these values just need to be stable and unique among users. + WS_OSM_SYSTEM_USER_AUTH_UID: str = "workspaces-backend-system" + WS_OSM_SYSTEM_USER_DISPLAY_NAME: str = "Workspaces Backend (system)" + WS_OSM_SYSTEM_USER_EMAIL: str = "workspaces-backend-system@tdei.us" + SENTRY_DSN: str = "" @property diff --git a/api/core/security.py b/api/core/security.py index 1db25f9..adeaa38 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,3 +1,4 @@ +import secrets import time from enum import StrEnum from uuid import UUID @@ -20,8 +21,8 @@ # @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles # @test: Test that any failed network requests are handled gracefully # @test: Test that the caching mechanism works correctly and evicts entries when roles change -# @test: Test that a validated token is mirrored into the OSM oauth_access_tokens table (with the user provisioned and expires_in from the JWT exp) when WS_OSM_OAUTH_APPLICATION_ID is set, and is a no-op otherwise -# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_OAUTH_APPLICATION_ID +# @test: Test that when WS_OSM_TOKEN_BRIDGE_ENABLED, a validated token is mirrored into oauth_access_tokens with the doorkeeper application + system-owner user + caller user auto-provisioned and expires_in from the JWT exp; and is a no-op when disabled +# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_TOKEN_BRIDGE_ENABLED # Set up logger for this module logger = get_logger(__name__) @@ -338,6 +339,84 @@ async def validate_token( return user_info +async def _ensure_osm_user( + session: AsyncSession, + *, + auth_uid: str, + email: str | None, + display_name: str, +) -> int | None: + """Idempotently provision an OSM ``users`` row (auth_provider ``TDEI``) and + return its id. ``email`` is synthesised when absent -- the column is UNIQUE + and NOT NULL.""" + await session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, " + "status, pass_crypt, data_public, email_valid, terms_seen, " + "creation_time, terms_agreed, tou_agreed) " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "true, true, true, (now() at time zone 'utc'), " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + { + "auth_uid": auth_uid, + "email": email or f"{auth_uid}@tdei.invalid", + "name": display_name, + }, + ) + row = ( + await session.execute( + text( + "SELECT id FROM users " + "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" + ), + {"auth_uid": auth_uid}, + ) + ).first() + return row[0] if row else None + + +async def _ensure_osm_oauth_application(session: AsyncSession) -> int | None: + """Ensure the doorkeeper application the mirrored tokens belong to exists, + creating it (owned by a dedicated system user) if needed. Idempotent by the + configured client uid; returns the application id.""" + owner_id = await _ensure_osm_user( + session, + auth_uid=settings.WS_OSM_SYSTEM_USER_AUTH_UID, + email=settings.WS_OSM_SYSTEM_USER_EMAIL, + display_name=settings.WS_OSM_SYSTEM_USER_DISPLAY_NAME, + ) + if owner_id is None: + return None + await session.execute( + text( + "INSERT INTO oauth_applications (owner_type, owner_id, name, uid, " + "secret, redirect_uri, scopes, confidential, created_at, updated_at) " + "VALUES ('User', :owner_id, :name, :uid, :secret, " + "'urn:ietf:wg:oauth:2.0:oob', :scopes, true, " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (uid) DO NOTHING" + ), + { + "owner_id": owner_id, + "name": "Workspaces Backend", + "uid": settings.WS_OSM_OAUTH_CLIENT_UID, + # Never used (tokens are inserted directly rather than issued via the + # OAuth flow), but the column is NOT NULL. + "secret": secrets.token_hex(32), + "scopes": settings.WS_OSM_OAUTH_SCOPES, + }, + ) + row = ( + await session.execute( + text("SELECT id FROM oauth_applications WHERE uid = :uid"), + {"uid": settings.WS_OSM_OAUTH_CLIENT_UID}, + ) + ).first() + return row[0] if row else None + + async def _bridge_token_to_osm( session: AsyncSession, *, @@ -351,46 +430,28 @@ async def _bridge_token_to_osm( (doorkeeper) and cgimap authenticate it through their standard OAuth2 path, with no custom JWT handling required in those services. - Two idempotent writes: provision the OSM ``users`` row (owns the token via - ``resource_owner_id``) and insert a plaintext ``oauth_access_tokens`` row. - Doorkeeper stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT - matches on lookup for both osm-rails and cgimap; ``expires_in`` tracks the - JWT's own ``exp`` so OSM expires it in lockstep with TDEI. + Auto-provisions everything it needs: the doorkeeper ``oauth_applications`` + row (owned by a dedicated system user), the caller's ``users`` row, and a + plaintext ``oauth_access_tokens`` row -- no manual OSM setup. Doorkeeper + stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT matches on + lookup for both osm-rails and cgimap; ``expires_in`` tracks the JWT's ``exp``. - No-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is set. Best-effort: a failure - here must not break token validation -- the ``/api/v1`` routes keep working; - only the proxied OSM calls would 401 until the row exists. + No-op unless ``WS_OSM_TOKEN_BRIDGE_ENABLED``. Best-effort: a failure here + must not break token validation -- the ``/api/v1`` routes keep working; only + the proxied OSM calls would 401 until the row exists. """ - if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED: return - auth_uid = str(user_uuid) try: - # Provision the users row (same shape as _provision_users_from_tdei). - await session.execute( - text( - "INSERT INTO users (auth_uid, email, display_name, auth_provider, " - "status, pass_crypt, data_public, email_valid, terms_seen, " - "creation_time, terms_agreed, tou_agreed) " - "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " - "true, true, true, (now() at time zone 'utc'), " - "(now() at time zone 'utc'), (now() at time zone 'utc')) " - "ON CONFLICT (auth_uid) DO NOTHING" - ), - {"auth_uid": auth_uid, "email": email, "name": user_name}, + app_id = await _ensure_osm_oauth_application(session) + if app_id is None: + return + user_id = await _ensure_osm_user( + session, auth_uid=str(user_uuid), email=email, display_name=user_name ) - row = ( - await session.execute( - text( - "SELECT id FROM users " - "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" - ), - {"auth_uid": auth_uid}, - ) - ).first() - if row is None: + if user_id is None: return - user_id = row[0] expires_in = max(0, exp - int(time.time())) if exp else None await session.execute( @@ -410,7 +471,7 @@ async def _bridge_token_to_osm( "revoked_at = NULL" ), { - "app_id": settings.WS_OSM_OAUTH_APPLICATION_ID, + "app_id": app_id, "user_id": user_id, "token": token, "scopes": settings.WS_OSM_OAUTH_SCOPES, @@ -431,14 +492,14 @@ async def _revoke_osm_token(session: AsyncSession, token: str) -> None: Called when a user's token rotates (new ``jti``) so the previous JWT stops authenticating against osm-rails/cgimap before its own ``exp`` rather than - lingering until it expires. No-op unless the bridge is configured. + lingering until it expires. No-op unless the bridge is enabled. Note: OSM/cgimap are reachable only *through* this proxy, and every request first passes ``validate_token`` -- so a TDEI-revoked token is already rejected upstream. This revocation is defense-in-depth for the superseded token and keeps the OSM token store tidy. """ - if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED: return try: await session.execute( diff --git a/tests/unit/test_token_bridge.py b/tests/unit/test_token_bridge.py index 401ad73..20e338f 100644 --- a/tests/unit/test_token_bridge.py +++ b/tests/unit/test_token_bridge.py @@ -2,13 +2,15 @@ ``_bridge_token_to_osm`` mirrors a validated TDEI token into the OSM database's ``oauth_access_tokens`` table so osm-rails (doorkeeper) and cgimap authenticate -it through their standard OAuth2 path. We cover: +it through their standard OAuth2 path. It auto-provisions everything it needs: -- it is a no-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is configured, -- when enabled it provisions the ``users`` row and inserts a plaintext - ``oauth_access_tokens`` row owned by that user, with ``expires_in`` derived - from the JWT ``exp``, -- a DB failure never propagates (auth must not break) and rolls back. +- a dedicated *system* ``users`` row that owns the doorkeeper application, +- the doorkeeper ``oauth_applications`` row (idempotent by the client uid), +- the caller's ``users`` row (owns the token), +- the plaintext ``oauth_access_tokens`` row (expires_in from the JWT exp). + +We cover: the no-op when disabled, the full provisioning chain when enabled, +the wiring of ids between rows, best-effort failure handling, and revocation. """ import time @@ -22,18 +24,21 @@ class RecordingSession: - """Async session stand-in that records (sql, params) and returns a user id - row for the ``SELECT id FROM users`` lookup.""" + """Async session stand-in that records (sql, params) and answers the two id + lookups: ``users`` (system vs caller, by auth_uid) and ``oauth_applications``.""" - def __init__(self, user_id=99): + def __init__(self, system_user_id=1, caller_user_id=42, app_id=7): self.calls: list[tuple[str, dict]] = [] self.commits = 0 self.rollbacks = 0 - self._user_id = user_id + self._system_user_id = system_user_id + self._caller_user_id = caller_user_id + self._app_id = app_id async def execute(self, statement, params=None): + params = params or {} sql = str(statement) - self.calls.append((sql, params or {})) + self.calls.append((sql, params)) class _R: def __init__(self, rows): @@ -43,7 +48,11 @@ def first(self): return self._rows[0] if self._rows else None if "SELECT id FROM users" in sql: - return _R([(self._user_id,)]) + if params.get("auth_uid") == settings.WS_OSM_SYSTEM_USER_AUTH_UID: + return _R([(self._system_user_id,)]) + return _R([(self._caller_user_id,)]) + if "SELECT id FROM oauth_applications" in sql: + return _R([(self._app_id,)]) return _R([]) async def commit(self): @@ -57,8 +66,8 @@ def sql_for(self, needle): async def test_bridge_is_noop_when_disabled(monkeypatch): - """With WS_OSM_OAUTH_APPLICATION_ID == 0 the bridge touches no DB.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + """With the bridge disabled the bridge touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", False) session = RecordingSession() await security._bridge_token_to_osm( @@ -74,13 +83,15 @@ async def test_bridge_is_noop_when_disabled(monkeypatch): assert session.commits == 0 -async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): - """When enabled: users upsert -> id lookup -> oauth_access_tokens upsert.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) +async def test_bridge_provisions_app_users_and_token(monkeypatch): + """Enabled: system user -> application -> caller user -> mirrored token, + with the ids wired between rows.""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + monkeypatch.setattr(settings, "WS_OSM_OAUTH_CLIENT_UID", "workspaces-backend") monkeypatch.setattr(settings, "WS_OSM_OAUTH_SCOPES", "write_api read_prefs") uid = uuid4() exp = int(time.time()) + 3600 - session = RecordingSession(user_id=42) + session = RecordingSession(system_user_id=1, caller_user_id=42, app_id=7) await security._bridge_token_to_osm( cast(AsyncSession, session), @@ -91,19 +102,25 @@ async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): exp=exp, ) - # users row provisioned idempotently, keyed by auth_uid = str(sub) + # System user provisioned (owns the app) and caller user provisioned. user_inserts = session.sql_for("INSERT INTO users") - assert user_inserts and "ON CONFLICT (auth_uid)" in user_inserts[0][0] - assert user_inserts[0][1]["auth_uid"] == str(uid) - assert user_inserts[0][1]["email"] == "alice@example.com" - - # token mirrored with the configured application/scopes and exp-derived TTL + inserted_auth_uids = {c[1]["auth_uid"] for c in user_inserts} + assert settings.WS_OSM_SYSTEM_USER_AUTH_UID in inserted_auth_uids + assert str(uid) in inserted_auth_uids + + # Application created idempotently by uid, owned by the system user (id 1). + app_inserts = session.sql_for("INSERT INTO oauth_applications") + assert app_inserts and "ON CONFLICT (uid)" in app_inserts[0][0] + assert app_inserts[0][1]["uid"] == "workspaces-backend" + assert app_inserts[0][1]["owner_id"] == 1 + assert app_inserts[0][1]["scopes"] == "write_api read_prefs" + + # Token mirrored: app id from the lookup, resource_owner = caller (42), + # self-healing upsert, exp-derived TTL. token_inserts = session.sql_for("oauth_access_tokens") - assert token_inserts and "ON CONFLICT (token)" in token_inserts[0][0] - # Re-presenting a token must reactivate it (clear revocation, refresh expiry). - assert "DO UPDATE" in token_inserts[0][0] - assert "revoked_at = NULL" in token_inserts[0][0] - params = token_inserts[0][1] + assert token_inserts + sql, params = token_inserts[0] + assert "DO UPDATE" in sql and "revoked_at = NULL" in sql assert params["app_id"] == 7 assert params["user_id"] == 42 assert params["token"] == "jwt-token" @@ -114,27 +131,33 @@ async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): assert session.rollbacks == 0 -async def test_bridge_expires_in_none_without_exp(monkeypatch): - """A token without an `exp` claim mirrors with a NULL expires_in.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) +async def test_bridge_synthesises_email_when_absent(monkeypatch): + """A caller without an email claim still provisions (email is UNIQUE/NOT NULL).""" + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) + uid = uuid4() session = RecordingSession() await security._bridge_token_to_osm( cast(AsyncSession, session), - user_uuid=uuid4(), + user_uuid=uid, user_name="alice", email=None, token="jwt-token", exp=None, ) + caller_insert = next( + c for c in session.sql_for("INSERT INTO users") if c[1]["auth_uid"] == str(uid) + ) + assert caller_insert[1]["email"] == f"{uid}@tdei.invalid" + params = session.sql_for("oauth_access_tokens")[0][1] assert params["expires_in"] is None async def test_bridge_is_best_effort_on_db_error(monkeypatch): """A DB failure must not propagate out of validation; it rolls back.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) class BoomSession(RecordingSession): async def execute(self, statement, params=None): @@ -142,7 +165,6 @@ async def execute(self, statement, params=None): session = BoomSession() - # Must not raise. await security._bridge_token_to_osm( cast(AsyncSession, session), user_uuid=uuid4(), @@ -158,7 +180,7 @@ async def execute(self, statement, params=None): async def test_revoke_is_noop_when_disabled(monkeypatch): """With the bridge off, revoking a rotated token touches no DB.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", False) session = RecordingSession() await security._revoke_osm_token(cast(AsyncSession, session), "old-token") @@ -169,7 +191,7 @@ async def test_revoke_is_noop_when_disabled(monkeypatch): async def test_revoke_marks_only_the_superseded_token(monkeypatch): """Revocation flips revoked_at for exactly the given token, if not already.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) session = RecordingSession() await security._revoke_osm_token(cast(AsyncSession, session), "old-token") @@ -183,7 +205,7 @@ async def test_revoke_marks_only_the_superseded_token(monkeypatch): async def test_revoke_is_best_effort_on_db_error(monkeypatch): """A DB failure during revocation must not propagate; it rolls back.""" - monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_TOKEN_BRIDGE_ENABLED", True) class BoomSession(RecordingSession): async def execute(self, statement, params=None): From 9e607eb8e41b32cee840f50315f9f404075271ea Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 16:40:42 -0400 Subject: [PATCH 150/159] Change stub user to validate OSM rails models --- ...f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py | 47 +++++++++++++++++++ api/core/security.py | 8 +++- api/src/tasking/projects/repository.py | 8 +++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py diff --git a/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py b/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py new file mode 100644 index 0000000..5ff6bca --- /dev/null +++ b/alembic_osm/versions/f3a7b9c1d2e4_heal_short_tdei_pass_crypt.py @@ -0,0 +1,47 @@ +"""heal short TDEI-provisioned users.pass_crypt + +TDEI users are provisioned by the backend via raw SQL with a placeholder +``pass_crypt``. Older rows used a 4-char value (``"none"``), which violates OSM's +``pass_crypt`` length 8..255 ``User`` validation. That makes the user invalid, +and Rails operations that re-validate the author via ``validates :author, +:associated => true`` (posting a changeset comment or a note comment) then fail +to save. Replace any too-short value with a random throwaway -- TDEI manages +auth, so ``pass_crypt`` is never used to authenticate. + +Revision ID: f3a7b9c1d2e4 +Revises: 5303f61d7b3a +Create Date: 2026-07-11 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import inspect, text + +# revision identifiers, used by Alembic. +revision: str = "f3a7b9c1d2e4" +down_revision: Union[str, None] = "5303f61d7b3a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + # `users` is owned by the OSM Rails website, not this tree; guard so the + # migration is a no-op if it hasn't been created yet. + if not inspect(bind).has_table("users"): + return + + # Idempotent: re-running only touches rows that are still too short. + op.execute( + text( + "UPDATE users SET pass_crypt = md5(random()::text) " + "WHERE auth_provider = 'TDEI' AND length(pass_crypt) < 8" + ) + ) + + +def downgrade() -> None: + # The original short values are unrecoverable (and were invalid anyway). + pass diff --git a/api/core/security.py b/api/core/security.py index adeaa38..020abf8 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -354,7 +354,7 @@ async def _ensure_osm_user( "INSERT INTO users (auth_uid, email, display_name, auth_provider, " "status, pass_crypt, data_public, email_valid, terms_seen, " "creation_time, terms_agreed, tou_agreed) " - "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', :pass_crypt, " "true, true, true, (now() at time zone 'utc'), " "(now() at time zone 'utc'), (now() at time zone 'utc')) " "ON CONFLICT (auth_uid) DO NOTHING" @@ -363,6 +363,12 @@ async def _ensure_osm_user( "auth_uid": auth_uid, "email": email or f"{auth_uid}@tdei.invalid", "name": display_name, + # OSM's User model validates pass_crypt length 8..255. TDEI manages + # auth, so this is a throwaway that just satisfies that rule (a too- + # short value makes the user invalid and breaks Rails operations + # that re-validate it via `validates :author, :associated => true`, + # e.g. posting a changeset comment). + "pass_crypt": secrets.token_hex(16), }, ) row = ( diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 3aa6820..44bbc42 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import secrets from datetime import datetime from typing import Any from uuid import UUID @@ -313,13 +314,18 @@ async def _provision_users_from_tdei( await self.session.execute( text( "INSERT INTO users (auth_uid, email, display_name, auth_provider, status, pass_crypt, data_public, email_valid, terms_seen, creation_time, terms_agreed, tou_agreed) " - "VALUES (:uid, :email, :name, 'TDEI', 'active', 'none', true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " + "VALUES (:uid, :email, :name, 'TDEI', 'active', :pass_crypt, true, true, true, (now() at time zone 'utc'), (now() at time zone 'utc'), (now() at time zone 'utc')) " "ON CONFLICT (auth_uid) DO NOTHING" ), params={ "uid": uid, "email": member.email, "name": member.display_name, + # OSM validates pass_crypt length 8..255; a too-short value + # makes the user invalid and breaks Rails ops that re-validate + # the author (changeset/note comments). TDEI manages auth, so + # this is a throwaway. + "pass_crypt": secrets.token_hex(16), }, ) resolved.add(uid) From a7b41092f255bed6a88e6573aebc93488135ff50 Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Fri, 10 Jul 2026 16:52:01 -0400 Subject: [PATCH 151/159] Docs --- .claude/settings.json | 4 ++- CLAUDE.md | 78 +++++++++++++++++++++++++++++++++++++++++++ README.md | 42 +++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index fa7924c..ea52c82 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,9 @@ "Bash(git config *)", "Bash(command -v gh)", "Bash(gh api *)", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")" + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('protected:', d.get\\('protected'\\)\\); p=d.get\\('protection',{}\\); print\\('required_pull_request_reviews:', 'yes' if p.get\\('required_pull_request_reviews'\\) else 'no'\\); print\\('enforce_admins:', \\(p.get\\('enforce_admins'\\) or {}\\).get\\('enabled'\\)\\)\")", + "Bash(grep -nA6 \"TENANT_BYPASSES: list\" api/main.py)", + "Bash(grep -nA10 \"STRIP_REQUEST_HEADERS = \" api/main.py)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 1e5e9d6..d15dea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,84 @@ enforced at this layer; if required, it must be enforced downstream (`workspaces-openstreetmap-website/`, `workspaces-cgimap/`) — that has not been audited here. +## The OSM proxy layer: routing, auth, and user provisioning + +This service is a reverse proxy in front of the OSM website (`osm-rails`) and +cgimap. The non-obvious parts, learned the hard way: + +### Deployment routing (workspaces-stack) + +In `workspaces-stack`, the **api container (this backend) serves the public OSM +host** (`osm.workspaces-...`) alongside the API hosts — its traefik router +rule is `Host(new-api) || Host(api) || Host(osm)`, and the `osm-web` / +`osm-log-proxy` routers are commented out. So **every OSM call the frontend +makes routes through this backend**: `validate_token`, the `X-Workspace` gate, +and the CORS middleware all apply. The backend then proxies `/api/0.6/*` to +`WS_OSM_HOST` (config default `http://osm-web` — the internal service, *not* the +public domain). `osm-web`'s nginx splits traffic: changeset read/write subpaths +to cgimap, everything else to osm-rails. + +The frontend (`services/osm.ts`) calls the OSM host directly with +`credentials: 'include'` + a Bearer TDEI token. Because it's *credentialed* +CORS, `CORS_ORIGINS` must list the exact frontend origin (no `*`; +`allow_credentials` is on). The stack supplies `WS_API_CORS_ORIGINS` as a **JSON +array** (`["https://..."]`), while older config comma-split it — a JSON array +would then become one malformed origin. `api/main.py` therefore reads +`settings.cors_origins_list`, which parses **both** a JSON array and a +comma-separated string (and `*`); set `CORS_ORIGINS` in either form. + +### Two databases; `users` is owned by OSM Rails + +`TASK_DATABASE_URL` (`alembic_task` tree) holds workspaces / tasking-manager +tables; `OSM_DATABASE_URL` (`alembic_osm` tree) holds OSM data, `users`, and the +`tasking_*` tables. The **`users` table is owned by the OSM Rails website**, not +either alembic tree — the trees only FK to it, and integration tests stub it +(`tests/integration/conftest.py`). The backend provisions `users` rows itself +via raw SQL with `auth_provider='TDEI'` and `auth_uid = str()` (the OSM +`auth_uid` **is** the token's `sub` claim). + +### How OSM authenticates, and the TDEI token bridge + +* **osm-rails** authenticates API calls *only* via **doorkeeper OAuth2**: it + looks the bearer token up in `oauth_access_tokens` (`token` → + `resource_owner_id` → `users.id`). Doorkeeper stores tokens **plaintext** + (`SecretStoring::Plain`), so the raw JWT matches directly. It has **no** + TDEI/JWT auth path. +* **cgimap** has both: the oauth2 lookup *and* a custom TDEI path + (`get_user_id_for_tdei_token`, which verifies the JWT and matches + `users.auth_uid`). + +To make TDEI tokens work against osm-rails without forking it, the **token +bridge** (`_bridge_token_to_osm` in `api/core/security.py`) mirrors a validated +TDEI JWT into `oauth_access_tokens` — on the cache-miss path of `validate_token` +(so ~once per token, not per request). Gated by `WS_OSM_TOKEN_BRIDGE_ENABLED`. +It auto-provisions everything: a dedicated **system user** (`WS_OSM_SYSTEM_USER_*`) +to own the doorkeeper `oauth_applications` row it creates (keyed by +`WS_OSM_OAUTH_CLIENT_UID`; `oauth_applications.owner_id` is a NOT-NULL FK to +`users`, hence the system user), the caller's `users` row, and the plaintext +`oauth_access_tokens` row (`expires_in` from the JWT `exp`; +`ON CONFLICT (token) DO UPDATE` so re-presenting reactivates it). On token +rotation (new `jti`) the superseded token is revoked. Since the token then lives +in `oauth_access_tokens`, both osm-rails and cgimap authenticate it via plain +OAuth2 — cgimap's custom TDEI path becomes redundant. + +### Provisioning gotcha: `pass_crypt` must be 8..255 chars + +The OSM `User` model has `validates :pass_crypt, :length => 8..255`. When the +backend provisions a `users` row, **`pass_crypt` must be 8–255 chars** (use a +random throwaway — TDEI manages auth, so it's never used to log in). A too-short +value (the old `'none'`) makes the user *invalid*. This is silent for **cgimap** +operations — cgimap runs no Rails validations — but breaks any **osm-rails** +operation that re-validates the author via `validates :author, :associated => +true`: notably `POST /api/0.6/changeset/{id}/comment` and note comments. The +comment fails to save (no `id`), then rendering it throws *"Unable to serialize +… without an id"*. Both provisioning paths (`_ensure_osm_user`, +`_provision_users_from_tdei`) set a valid `pass_crypt`; migration +`alembic_osm/versions/f3a7b9c1d2e4` heals legacy short rows. General principle: +a backend-provisioned `users` row must satisfy OSM's `User` validations (also +`display_name` 3..255 + unique, `email` present + unique) or Rails operations +that touch it will fail even though cgimap operations succeed. + ## Testing Two layers, both fast and dependency-free (no Postgres, PostGIS, Docker, or diff --git a/README.md b/README.md index 8ffa3f5..7b905d3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,48 @@ This is a combination API backend for workspaces, providing /workspaces* methods the OSM API ("openstreetmap website") plus OSM CGI-map (the C-accelerated methods) that enforces authorization and authentication based on a TDEI/Keycloak JWT token (see main.py for this proxy logic). +## What the proxy must provide for osm-rails / osm-web + +This backend is the **only entry point** to the OSM tier (osm-rails + cgimap, +behind `osm-web`): in the deployment, the public OSM host routes to this +container, which proxies `/api/0.6/*` to `WS_OSM_HOST` (default +`http://osm-web`). For the OSM services to work, the proxy must uphold the +following contract. `CLAUDE.md` has the full rationale. + +1. **Bridge TDEI auth into OSM's OAuth2.** osm-rails authenticates the API *only* + via doorkeeper OAuth2 (`oauth_access_tokens`); it has no TDEI/JWT path. So on + token validation the backend mirrors the TDEI JWT into `oauth_access_tokens` + in the OSM DB (the "token bridge" in `api/core/security.py`), and forwards the + incoming `Authorization: Bearer ` header unchanged. Then osm-rails and + cgimap authenticate the token via plain OAuth2. Controlled by + `WS_OSM_TOKEN_BRIDGE_ENABLED` (on by default) — with it off, osm-rails returns + **401** for TDEI tokens. The backend auto-creates the doorkeeper application + (and a system user to own it), so no manual OSM setup is required. + +2. **Provision valid OSM `users` rows.** The backend creates OSM `users` rows for + TDEI users (`auth_provider='TDEI'`, `auth_uid` = the JWT `sub`). These must + satisfy OSM's `User` validations — in particular `pass_crypt` length 8..255. + A too-short value is invisible to cgimap but makes osm-rails operations that + re-validate the user fail (e.g. posting a changeset comment or a note), + surfacing as *"Unable to serialize … without an id"*. The + `alembic_osm` migration `*_heal_short_tdei_pass_crypt` repairs legacy rows. + +3. **Carry workspace tenancy.** Workspace-scoped OSM requests must include an + `X-Workspace: ` header. The proxy authorizes it against the caller's + workspaces and forwards it (it is *not* stripped) so cgimap/osm-rails scope to + the `workspace-` schema. A few paths are exempt (`TENANT_BYPASSES` in + `api/main.py`): workspace create/delete (`PUT`/`DELETE /api/0.6/workspaces/{id}`) + and user provisioning during sign-in (`PUT /api/0.6/user/{uid}`). + +4. **Set the proxy headers.** The proxy rewrites `Host` to the OSM host and sets + `X-Real-IP` / `X-Forwarded-For` / `-Host` / `-Proto`, while stripping + hop-by-hop headers and any spoofed forwarding headers from the client. It does + *not* strip `Authorization` or `X-Workspace`. + +5. **Connectivity.** `WS_OSM_HOST` must reach `osm-web`, and the backend needs + **both** `OSM_DATABASE_URL` and `TASK_DATABASE_URL` — the token bridge and + user provisioning write to the OSM database. + ## Branch Index * ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag From 6df53b032e584f5933ade5f30d95ecb799bf283e Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 11:24:12 +0530 Subject: [PATCH 152/159] initial docker setup done - Added notes to local development. - Need to add more details on initial development. --- .gitignore | 1 + README.md | 25 ++++++++++++++ api/core/config.py | 1 + docker-compose.local.yml | 71 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 docker-compose.local.yml diff --git a/.gitignore b/.gitignore index 4516af1..d929d58 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ docs/tasking-mvp/tasking-mvp.postman_environment.json docs/tasking-mvp/_enrich_postman.py docs/tasking-mvp/feature-coverage.md .idea/ +data/ \ No newline at end of file diff --git a/README.md b/README.md index 7b905d3..a8867e1 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,28 @@ uvx pyright --pythonpath .venv/bin/python api tests uv run black api tests && uv run isort api tests ``` +## Development with local environment + +Use the file `docker-compose.local.yml` to build and deploy local code changes. This allows you to run the entire system at once instead of +connecting to existing Databases. + +### Initial setup. +- On first launch, rails-worker will fail because migrations are not done +- Go to the `osm-rails` `/bin/sh` and execute `bundle exec rails db:migrate` +- The above script runs the migration code for osm-rails +- The rails-worker will be able to run +- The backend code will fail first time becase `workspaces-tasks-local` database is not available +- Login to postgresql container and run the following commands + `psql --username postgres` + `create database "workspaces-tasks-local";` + `psql --username postgres --dbname "workspaces-tasks-local";` + `create extension if not exists postgis;` +- Run the backend code now and it should be able to run + +### Commands to start and stop the docker compose + +`docker compose --file docker-compose.local.yml build up -d` + +`docker compose --file docker-compose.local.yml down` + +Backend code will be available at `http://localhost:3000` \ No newline at end of file diff --git a/api/core/config.py b/api/core/config.py index 04693ac..c6554c7 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -96,6 +96,7 @@ def cors_origins_list(self) -> list[str]: model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", + extra="ignore", # ignore unknown environment variables ) diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..8bfeae3 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,71 @@ +services: + database: + image: postgis/postgis:16-3.4 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: testing + POSTGRES_DB: workspaces-osm-local + ports: + - 5432:5432 + volumes: + - ./data/db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d workspaces-osm-local"] + interval: 10s + timeout: 5s + retries: 5 + osm-cgimap: + image: opensidewalksdev.azurecr.io/workspaces-osm-cgimap:dev + environment: + CGIMAP_INSTANCES: 10 + CGIMAP_HOST: database + CGIMAP_USERNAME: postgres + CGIMAP_PASSWORD: testing + CGIMAP_DBNAME: workspaces-osm-local + CGIMAP_MAX_CHANGESET_ELEMENTS: 100000000 # max features per import or save + CGIMAP_MAX_PAYLOAD: 1000000000 # max size of dataset uploads + CGIMAP_MAP_NODES: 100000000 # max number of nodes per requeset + CGIMAP_MAP_AREA: 1 # max area per request in square degrees + ports: + - 8000 + osm-rails: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + ports: + - 3000 + command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"] + + osm-rails-worker: + image: opensidewalksdev.azurecr.io/workspaces-osm-rails:dev + environment: + RAILS_ENV: production + SECRET_KEY_BASE: osm_secret_key + WS_OSM_DB_HOST: database + WS_OSM_DB_USER: postgres + WS_OSM_DB_PASS: testing + WS_OSM_DB_NAME: workspaces-osm-local + stdin_open: true + tty: true + command: ["bundle", "exec", "rake", "jobs:work"] + + backend: + build: . + container_name: workspaces-backend + volumes: + - .:/app + ports: + - 8000:8000 + environment: + TDEI_BACKEND_URL: ${TDEI_BACKEND_URL} + TDEI_OIDC_REALM: ${TDEI_OIDC_REALM} + TDEI_OIDC_URL: ${TDEI_OIDC_URL} + OSM_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-osm-local + TASK_DATABASE_URL: postgresql+asyncpg://postgres:testing@database:5432/workspaces-tasks-local \ No newline at end of file From ba9cdc24b9dcd635bd31e4f6434925e1808f9e5e Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 12:19:16 +0530 Subject: [PATCH 153/159] Added custom_imagery column Migration logic yet to be written --- api/src/tasking/projects/dtos.py | 3 +++ api/src/tasking/projects/repository.py | 4 ++++ api/src/tasking/projects/schemas.py | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 488e981..709b80f 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -47,6 +47,7 @@ class ProjectCreateRequest(WireModel): lock_timeout_hours: int = PydField(default=8, ge=1, le=720) aoi: Optional[AoiInput] = None role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) + custom_imagery: Optional[Any] = PydField(default=None) @field_validator("name") @classmethod @@ -68,6 +69,7 @@ class ProjectUpdateRequest(WireModel): instructions: Optional[str] = PydField(default=None, max_length=10_000) lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) review_required: Optional[bool] = None + custom_imagery: Optional[Any] = PydField(default=None) class ProjectResponse(WireModel): @@ -87,6 +89,7 @@ class ProjectResponse(WireModel): created_by_name: Optional[str] = None created_at: datetime updated_at: datetime + custom_imagery: Optional[Any] = None class ProjectListItem(WireModel): diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 44bbc42..b046436 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -243,6 +243,7 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons created_by_name=project.created_by_name, created_at=project.created_at, updated_at=project.updated_at, + custom_imagery=project.custom_imagery, # type: ignore[arg-type] ) async def _provision_users_from_tdei( @@ -531,6 +532,7 @@ async def create( lock_timeout_hours=body.lock_timeout_hours, created_by=current_user.user_uuid, created_by_name=current_user.user_name, + custom_imagery=body.custom_imagery, # type: ignore[arg-type] ) if body.aoi is not None: geom = _aoi_to_shapely(body.aoi) @@ -632,6 +634,8 @@ async def patch( updates["lock_timeout_hours"] = body.lock_timeout_hours if body.review_required is not None: updates["review_required"] = body.review_required + if body.custom_imagery is not None: + updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type] if updates: updates["updated_at"] = datetime.now() diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 901d5f6..8150f4b 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -11,6 +11,7 @@ from sqlalchemy import Column from sqlalchemy import Enum as SAEnum from sqlmodel import Field, SQLModel +from sqlalchemy.dialects.postgresql import JSONB # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) @@ -94,6 +95,12 @@ class TaskingProject(SQLModel, table=True): ) deleted_at: Optional[datetime] = None + # Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer. + custom_imagery: Optional[Any] = Field( + default=None, + sa_column=Column(JSONB, nullable=True,default=None) + ) + # --------------------------------------------------------------------------- # Project role enum + table From 856ebb66b5a210073cf3a8d6c201de912fe92efb Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 15:30:15 +0530 Subject: [PATCH 154/159] imagery and description added --- README.md | 2 +- ...61f527ef_custom_imagery_and_description.py | 37 +++++++++++++++++++ api/src/tasking/projects/dtos.py | 7 +++- api/src/tasking/projects/repository.py | 2 + api/src/tasking/projects/schemas.py | 7 ++-- 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py diff --git a/README.md b/README.md index a8867e1..ebba814 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ connecting to existing Databases. ### Commands to start and stop the docker compose -`docker compose --file docker-compose.local.yml build up -d` +`docker compose --file docker-compose.local.yml up --build -d` `docker compose --file docker-compose.local.yml down` diff --git a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py new file mode 100644 index 0000000..6a4fdcc --- /dev/null +++ b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py @@ -0,0 +1,37 @@ +"""custom_imagery_and_description + +Revision ID: a92361f527ef +Revises: f3a7b9c1d2e4 +Create Date: 2026-07-14 15:08:42.142553 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "a92361f527ef" +down_revision: Union[str, None] = "f3a7b9c1d2e4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasking_projects", + sa.Column("custom_imagery", JSONB(astext_type=sa.Text()), nullable=True), + ) + op.add_column( + "tasking_projects", + sa.Column("description", sa.String(length=10000), nullable=True), + ) + pass + + +def downgrade() -> None: + op.drop_column("tasking_projects", "custom_imagery") + op.drop_column("tasking_projects", "description") + pass diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 709b80f..601c09e 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -47,7 +47,8 @@ class ProjectCreateRequest(WireModel): lock_timeout_hours: int = PydField(default=8, ge=1, le=720) aoi: Optional[AoiInput] = None role_assignments: list[ProjectRoleAssignment] = PydField(default_factory=list) - custom_imagery: Optional[Any] = PydField(default=None) + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) @field_validator("name") @classmethod @@ -69,7 +70,8 @@ class ProjectUpdateRequest(WireModel): instructions: Optional[str] = PydField(default=None, max_length=10_000) lock_timeout_hours: Optional[int] = PydField(default=None, ge=1, le=720) review_required: Optional[bool] = None - custom_imagery: Optional[Any] = PydField(default=None) + custom_imagery: Optional[dict[str, Any]] = PydField(default=None) + description: Optional[str] = PydField(default=None, max_length=10_000) class ProjectResponse(WireModel): @@ -90,6 +92,7 @@ class ProjectResponse(WireModel): created_at: datetime updated_at: datetime custom_imagery: Optional[Any] = None + description: Optional[str] = None class ProjectListItem(WireModel): diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index b046436..71c4eb9 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -244,6 +244,7 @@ def _to_response(project: TaskingProject, task_count: int = 0) -> ProjectRespons created_at=project.created_at, updated_at=project.updated_at, custom_imagery=project.custom_imagery, # type: ignore[arg-type] + description=project.description, # type: ignore[arg-type] ) async def _provision_users_from_tdei( @@ -533,6 +534,7 @@ async def create( created_by=current_user.user_uuid, created_by_name=current_user.user_name, custom_imagery=body.custom_imagery, # type: ignore[arg-type] + description=body.description, # type: ignore[arg-type] ) if body.aoi is not None: geom = _aoi_to_shapely(body.aoi) diff --git a/api/src/tasking/projects/schemas.py b/api/src/tasking/projects/schemas.py index 8150f4b..85fc0b2 100644 --- a/api/src/tasking/projects/schemas.py +++ b/api/src/tasking/projects/schemas.py @@ -10,8 +10,8 @@ from pydantic import Field as PydField from sqlalchemy import Column from sqlalchemy import Enum as SAEnum -from sqlmodel import Field, SQLModel from sqlalchemy.dialects.postgresql import JSONB +from sqlmodel import Field, SQLModel # --------------------------------------------------------------------------- # Enums (mirrors of postgres enums in the migration) @@ -97,10 +97,11 @@ class TaskingProject(SQLModel, table=True): # Custom Imagery data for the project. Stored as JSONB in the database and converted to / from a Pydantic model in the repository layer. custom_imagery: Optional[Any] = Field( - default=None, - sa_column=Column(JSONB, nullable=True,default=None) + default=None, sa_column=Column(JSONB, nullable=True, default=None) ) + description: Optional[str] = Field(default=None, nullable=True) + # --------------------------------------------------------------------------- # Project role enum + table From dbf41e2854c7838c16ea34f9c6f702185afa10fa Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 16:06:52 +0530 Subject: [PATCH 155/159] Added routes for validate name Added routes for validating the project name and unit tests for the same --- api/src/tasking/projects/dtos.py | 5 ++ api/src/tasking/projects/repository.py | 16 +++++ api/src/tasking/projects/routes.py | 21 +++++++ tests/integration/test_projects_flow.py | 32 ++++++++++ tests/unit/conftest.py | 8 +++ tests/unit/test_project_routes.py | 80 +++++++++++++++++++++++++ 6 files changed, 162 insertions(+) diff --git a/api/src/tasking/projects/dtos.py b/api/src/tasking/projects/dtos.py index 601c09e..01057a8 100644 --- a/api/src/tasking/projects/dtos.py +++ b/api/src/tasking/projects/dtos.py @@ -120,6 +120,10 @@ class ProjectListResponse(WireModel): pagination: Pagination +class ProjectNameValidationResponse(WireModel): + exists: bool + + # --------------------------------------------------------------------------- # AOI response shape (canonical Feature wrapping a MultiPolygon) # --------------------------------------------------------------------------- @@ -190,6 +194,7 @@ class SelfProjectRolesResponse(WireModel): "ProjectCreateRequest", "ProjectListItem", "ProjectListResponse", + "ProjectNameValidationResponse", "ProjectResponse", "ProjectRoleAddRequest", "ProjectRoleAssignment", diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index 71c4eb9..beee8e6 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -470,6 +470,22 @@ async def list_projects( pagination=Pagination(page=page, page_size=page_size, total=total), ) + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + result = await self.session.execute( + select(func.count()) + .select_from(TaskingProject) + .where( + (TaskingProject.workspace_id == workspace_id) + & (TaskingProject.name == name) + & ( + TaskingProject.deleted_at.is_( # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + None + ) + ) + ) + ) + return int(result.scalar() or 0) > 0 + async def create( self, workspace_id: int, diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index d4979b0..4d531d0 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -12,6 +12,7 @@ AoiFeature, ProjectCreateRequest, ProjectListResponse, + ProjectNameValidationResponse, ProjectResponse, ProjectRoleAddRequest, ProjectRoleItem, @@ -128,6 +129,26 @@ async def create_project( ) +@router.get("/validate-name", response_model=ProjectNameValidationResponse) +async def validate_project_name( + workspace_id: int, + name: str = Query(..., min_length=1, max_length=255), + current_user: UserInfo = Depends(validate_token), + workspace_repo: WorkspaceRepository = Depends(get_workspace_repo), + project_repo: TaskingProjectRepository = Depends(get_project_repo), +): + await assert_workspace_visible(workspace_id, current_user, workspace_repo) + normalized_name = name.strip() + if not normalized_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="name cannot be blank", + ) + return ProjectNameValidationResponse( + exists=await project_repo.project_name_exists(workspace_id, normalized_name) + ) + + @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( workspace_id: int, diff --git a/tests/integration/test_projects_flow.py b/tests/integration/test_projects_flow.py index c1575de..088c614 100644 --- a/tests/integration/test_projects_flow.py +++ b/tests/integration/test_projects_flow.py @@ -206,6 +206,38 @@ async def test_outsider_404s_on_list( assert r.status_code == 404 +class TestProjectNameValidation: + async def test_validate_name_false_then_true( + self, client, as_lead, seeded_workspace_id + ): + """validate-name reports false before create and true after create for same workspace.""" + path = f"{API.format(wid=seeded_workspace_id)}/validate-name" + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": False} + + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "name-check"}, + ) + assert r.status_code == 201, r.text + + r = await client.get(path, params={"name": "name-check"}) + assert r.status_code == 200, r.text + assert r.json() == {"exists": True} + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id + ): + """Outsider receives 404 from tenancy gate on validate-name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "anything"}, + ) + assert r.status_code == 404 + + # --------------------------------------------------------------------------- # Workflow 3b — error mapping (constraint violations → precise HTTP status). # --------------------------------------------------------------------------- diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 20ac3f5..e9fc3ad 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -175,6 +175,14 @@ async def get(self, workspace_id: int, project_id: int): raise NotFoundException(f"Project {project_id} not found") return self._response(p) + async def project_name_exists(self, workspace_id: int, name: str) -> bool: + return any( + p["workspace_id"] == workspace_id + and p["name"] == name + and p["deleted_at"] is None + for p in self._projects.values() + ) + async def patch(self, workspace_id, project_id, body, current_user): p_resp = await self.get(workspace_id, project_id) p = self._projects[project_id] diff --git a/tests/unit/test_project_routes.py b/tests/unit/test_project_routes.py index 1620aba..792103a 100644 --- a/tests/unit/test_project_routes.py +++ b/tests/unit/test_project_routes.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + API = "/api/v1/workspaces/{wid}/tasking/projects" @@ -68,6 +70,17 @@ async def test_create_blank_name_422( ) assert r.status_code == 422 + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, True, ["x"]]) + async def test_create_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """custom_imagery must be a JSON object; scalar/array values are rejected (422).""" + r = await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "Pilot", "custom_imagery": bad_value}, + ) + assert r.status_code == 422 + async def test_get_404_when_missing( self, client, as_lead, seeded_workspace_id, fake_repos ): @@ -105,6 +118,24 @@ async def test_patch_name(self, client, as_lead, seeded_workspace_id, fake_repos assert r.status_code == 200 assert r.json()["name"] == "after" + @pytest.mark.parametrize("bad_value", ["not-an-object", 123, False, ["x"]]) + async def test_patch_rejects_non_object_custom_imagery_422( + self, client, as_lead, seeded_workspace_id, fake_repos, bad_value + ): + """PATCH rejects custom_imagery when it is not a JSON object (422).""" + pid = ( + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "before"}, + ) + ).json()["id"] + + r = await client.patch( + f"{API.format(wid=seeded_workspace_id)}/{pid}", + json={"custom_imagery": bad_value}, + ) + assert r.status_code == 422 + async def test_soft_delete_204_then_404( self, client, as_lead, seeded_workspace_id, fake_repos ): @@ -137,6 +168,55 @@ async def test_duplicate_name_409( assert r.status_code == 409 +class TestProjectNameValidation: + async def test_validate_name_returns_false_when_missing( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Validation returns exists=false when no active project uses the name.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "new-project"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": False} + + async def test_validate_name_returns_true_when_exists( + self, client, as_lead, seeded_workspace_id, fake_repos + ): + """Validation returns exists=true when an active project with same name exists.""" + await client.post( + API.format(wid=seeded_workspace_id), + json={"name": "pilot-check"}, + ) + + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 200 + assert r.json() == {"exists": True} + + async def test_validate_name_blank_rejected_422( + self, client, as_contributor, seeded_workspace_id, fake_repos + ): + """Whitespace-only name is rejected with 422.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": " "}, + ) + assert r.status_code == 422 + + async def test_validate_name_outsider_404( + self, client, as_outsider, seeded_workspace_id, fake_repos + ): + """Tenancy gate hides workspace existence for outsiders.""" + r = await client.get( + f"{API.format(wid=seeded_workspace_id)}/validate-name", + params={"name": "pilot-check"}, + ) + assert r.status_code == 404 + + # --------------------------------------------------------------------------- # Lifecycle gates # --------------------------------------------------------------------------- From edb1241afcd2440e777026e3407a3b4bb6723c89 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 16:16:09 +0530 Subject: [PATCH 156/159] Update a92361f527ef_custom_imagery_and_description.py removed unnecessary pass statement --- .../versions/a92361f527ef_custom_imagery_and_description.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py index 6a4fdcc..6f1bc64 100644 --- a/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py +++ b/alembic_osm/versions/a92361f527ef_custom_imagery_and_description.py @@ -28,10 +28,8 @@ def upgrade() -> None: "tasking_projects", sa.Column("description", sa.String(length=10000), nullable=True), ) - pass def downgrade() -> None: op.drop_column("tasking_projects", "custom_imagery") op.drop_column("tasking_projects", "description") - pass From 31bd237313810e111f528d33f638ebaefc705759 Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Tue, 14 Jul 2026 16:26:26 +0530 Subject: [PATCH 157/159] updated required settings --- README.md | 2 +- api/src/tasking/projects/repository.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ebba814..06ba988 100644 --- a/README.md +++ b/README.md @@ -106,4 +106,4 @@ connecting to existing Databases. `docker compose --file docker-compose.local.yml down` -Backend code will be available at `http://localhost:3000` \ No newline at end of file +Backend code will be available at `http://localhost:8000` \ No newline at end of file diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index beee8e6..95875c1 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -654,6 +654,8 @@ async def patch( updates["review_required"] = body.review_required if body.custom_imagery is not None: updates["custom_imagery"] = body.custom_imagery # type: ignore[arg-type] + if body.description is not None: + updates["description"] = body.description if updates: updates["updated_at"] = datetime.now() From d7dc66cdd02a2d5df16e9ca7d02f9cc7ac1c192a Mon Sep 17 00:00:00 2001 From: Jeff Maki Date: Tue, 14 Jul 2026 10:18:05 -0400 Subject: [PATCH 158/159] Expand README with deployment architecture and services Added detailed deployment architecture and services information to README. --- README.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 06ba988..429a472 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,78 @@ following contract. `CLAUDE.md` has the full rationale. * ```develop``` merge your work here; keep this up to date with the "development" environment / dev tag * ```staging``` keep this up to date with the "staging" environment / stage tag * ```production``` keep this up to date with the "production" environment / prod tag + +## Deployment architecture + +The deployed system is defined by [`docker-compose.az.yml`](docker-compose.az.yml). It runs the +**application tier** as four containers; the **data tier** (Postgres/PostGIS) is external, managed +Azure Database for PostgreSQL, not part of this compose file. + +When deployed by `workspaces-stack` this model also holds. + +``` + client (TDEI/Keycloak JWT) + │ + ▼ :8000 + ┌──────────────────────────────────────┐ + │ workspaces-backend │ this repo — FastAPI front door. + │ authn/authz + OSM reverse proxy │ Serves /api/v1/*, proxies the rest. + └───┬──────────────────────────────┬────┘ + WS_OSM_HOST TASK_DATABASE_URL + (→ osm-rails) OSM_DATABASE_URL + │ │ + ▼ │ + ┌──────────────┐ │ + │ osm-rails │ OSM website (Rails); the single OSM entry point. + │ :3000 │ Serves the API/UI and fronts cgimap for the + └──────┬───────┘ performance-critical /api/0.6 calls. + │ (internal) │ + ▼ │ + ┌──────────────┐ │ + │ osm-cgimap │ C-accelerated /api/0.6 (map, changeset bulk) + │ :8000 │ │ + └──────────────┘ │ + │ + osm-rails-worker (rake jobs:work) │ background jobs + │ │ + │ backend, rails, cgimap, worker all connect to ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ data tier — Azure Postgres (external, PostGIS) │ + │ opensidewalks-${ENV}.postgres.database.azure.com:5432 │ + │ • workspaces-tasks-${ENV} TASK db (alembic_task; backend) │ + │ • workspaces-osm-${ENV} OSM db (alembic_osm; all four) │ + └─────────────────────────────────────────────────────────────────┘ +``` + +### Services + +| Service | Image | Role | +|---|---|---| +| `workspaces-backend` | `workspaces-backend-v2:${ENV}` | This repo. The only host-exposed service (`8000:8000`). Validates the TDEI/Keycloak JWT, enforces workspace authorization, serves `/api/v1/*`, and proxies everything else to the OSM tier. Connects to **both** databases. | +| `osm-rails` | `workspaces-osm-rails-v2:${ENV}` | The OpenStreetMap website (Rails) — the **single OSM entry point** the backend proxies to (`WS_OSM_HOST`). Serves the OSM API/UI and fronts cgimap for the heavy `/api/0.6` calls. Connects to the OSM db. | +| `osm-cgimap` | `workspaces-osm-cgimap-v2:${ENV}` | C++ reimplementation of the performance-critical OSM `0.6` calls (map queries, changeset upload/download), sitting behind `osm-rails`. Tuned here for large imports (`CGIMAP_MAX_*`). Connects to the OSM db. | +| `osm-rails-worker` | `workspaces-osm-rails-v2:${ENV}` | Background job runner (`rake jobs:work`) for the Rails app. Connects to the OSM db. | + +### Two databases + +The backend holds two connections, and the two alembic trees target them independently (see +`CLAUDE.md` and `api/utils/migrations.py`): + +* **TASK db** (`TASK_DATABASE_URL` → `workspaces-tasks-${ENV}`) — the workspaces + tasking-manager + schema, built by the `alembic_task` tree. Only the backend connects here. +* **OSM db** (`OSM_DATABASE_URL` → `workspaces-osm-${ENV}`) — OSM data plus `users` and the + `tasking_*` tables, built by the `alembic_osm` tree. The backend, cgimap, rails, and the worker + all connect here. + +On startup (outside of pytest) the backend runs `alembic -n task upgrade head` and +`alembic -n osm upgrade head`, applying each tree to its database. + +### Environment templating + +Every image tag, database name/user, and server host is parameterized by `${ENV}` +(`dev` / `stage` / `prod`), and secrets are injected from the shell environment +(`${WS_TASKS_DB_PASS}`, `${WS_OSM_DB_PASS}`, `${WS_OSM_SECRET_KEY_BASE}`). Branches map to these +environments — see the Branch Index below. ## To start on your local machine for dev work @@ -106,4 +178,4 @@ connecting to existing Databases. `docker compose --file docker-compose.local.yml down` -Backend code will be available at `http://localhost:8000` \ No newline at end of file +Backend code will be available at `http://localhost:8000` From 63fab7c65d64191c7df6cb9252317ce420642cfa Mon Sep 17 00:00:00 2001 From: Naresh Kumar D Date: Wed, 15 Jul 2026 14:38:06 +0530 Subject: [PATCH 159/159] Update README.md Readme updated with steps to run local development --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 429a472..b76ca45 100644 --- a/README.md +++ b/README.md @@ -159,18 +159,55 @@ uv run black api tests && uv run isort api tests Use the file `docker-compose.local.yml` to build and deploy local code changes. This allows you to run the entire system at once instead of connecting to existing Databases. -### Initial setup. -- On first launch, rails-worker will fail because migrations are not done -- Go to the `osm-rails` `/bin/sh` and execute `bundle exec rails db:migrate` -- The above script runs the migration code for osm-rails -- The rails-worker will be able to run -- The backend code will fail first time becase `workspaces-tasks-local` database is not available -- Login to postgresql container and run the following commands - `psql --username postgres` - `create database "workspaces-tasks-local";` - `psql --username postgres --dbname "workspaces-tasks-local";` - `create extension if not exists postgis;` -- Run the backend code now and it should be able to run +### Initial setup for development local environment + +Step 1: Login to azure docker + +The docker compose relies on images in `opensidewalksdev` azure container registry. Make sure your docker system is logged into it before pulling the images and trying to run the containers. + +Docker login command: + +`docker login opensidewalksdev.azurecr.io -u opensidewalksdev ` + +Password needs to be obtained from Azure portal + +Step 2: Run docker compose for the first time + +Use the following command to start the containers first time + +`docker compose --file docker-compose.local.yml up --build` + +Step 3: Run the migration scripts. + +You will observe that only `osm-rails` component seems to work but the backend and other services may be down. this is because the database migrations on the base osm database are not done. To do the base migrations, do the following: + +- Connect to the `osm-rails` container. If you are using docker hub for desktop, just go to the exec section of the container. + If you want to use command line, execute the command `docker exec -it /bin/bash` where `container_name` is the name of osm-rails container +- Execute the migration script in the /bin/bash with `bundle exec rails db:migrate` +- The above command runs the migration script for databases + +Step 4: Add `workspaces-tasks-local` database in postgresql + +Workspaces backend relies on an additional database. This is needed for some older migrations code. + +- Connect to `database` container. +- Run the following set of commands one by one + +```shell +psql --username postgres +create database "workspaces-tasks-local"; +exit; +psql --username postgres --dbname "workspaces-tasks-local"; +create extension if not exists postgis; + +``` + +Step 5: Restart the docker compose again + +- `docker compose --file docker-compose.local.yml down` +- `docker compose --file docker-compose.local.yml up --build` + + ### Commands to start and stop the docker compose