diff --git a/alembic_osm/versions/9221408912dd_add_user_role_table.py b/alembic_osm/versions/9221408912dd_add_user_role_table.py index 7d7fa57..04beaf7 100644 --- a/alembic_osm/versions/9221408912dd_add_user_role_table.py +++ b/alembic_osm/versions/9221408912dd_add_user_role_table.py @@ -36,7 +36,9 @@ 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), @@ -84,25 +86,6 @@ def upgrade() -> None: 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") diff --git a/api/src/tasking/projects/repository.py b/api/src/tasking/projects/repository.py index eaa81eb..3a73f66 100644 --- a/api/src/tasking/projects/repository.py +++ b/api/src/tasking/projects/repository.py @@ -878,7 +878,9 @@ 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 @@ -911,7 +913,8 @@ 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: @@ -1252,7 +1255,9 @@ 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( @@ -1285,9 +1290,7 @@ 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) -> None: project = await self._get_active(workspace_id, project_id) if project.status != ProjectStatus.DRAFT: raise HTTPException( diff --git a/api/src/tasking/projects/routes.py b/api/src/tasking/projects/routes.py index 7389246..a6fa719 100644 --- a/api/src/tasking/projects/routes.py +++ b/api/src/tasking/projects/routes.py @@ -22,6 +22,7 @@ ) from api.src.tasking.projects.repository import TaskingProjectRepository from api.src.tasking.projects.schemas import AoiInput, ProjectStatus +from uuid import UUID from api.src.workspaces.repository import WorkspaceRepository router = APIRouter( @@ -355,6 +356,134 @@ async def remove_project_role( await project_repo.remove_role(workspace_id, project_id, user_id) +# --------------------------------------------------------------------------- +# 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, @@ -365,7 +494,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, current_user) + await project_repo.delete_aoi(workspace_id, project_id) # --------------------------------------------------------------------------- @@ -394,4 +523,6 @@ 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/tests/conftest.py b/tests/conftest.py index 309c430..ed1fb9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,8 +71,9 @@ 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"