Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 6 additions & 23 deletions alembic_osm/versions/9221408912dd_add_user_role_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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")
15 changes: 9 additions & 6 deletions api/src/tasking/projects/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
135 changes: 133 additions & 2 deletions api/src/tasking/projects/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
)
5 changes: 3 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<td>{getattr(report, 'description', '') or '—'}</td>")

cells.insert(
2, f"<td>{getattr(report, 'description', '') or '—'}</td>"
)
except ImportError:
pass

Expand Down
Loading
Loading