Skip to content

Commit 00043fa

Browse files
committed
Finish implementation of stubbed roles APIs
1 parent 3a38839 commit 00043fa

11 files changed

Lines changed: 383 additions & 171 deletions

File tree

api/core/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ def __init__(self, detail: str = "Resource already exists"):
1515
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
1616

1717

18+
class ConflictException(HTTPException):
19+
"""Base exception for conflict errors."""
20+
21+
def __init__(self, detail: str = "Conflict"):
22+
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
23+
24+
1825
class UnauthorizedException(HTTPException):
1926
"""Base exception for unauthorized access errors."""
2027

api/core/security.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from api.core.database import get_osm_session, get_task_session
1313
from api.core.jwt import validate_and_decode_token
1414
from api.core.logging import get_logger
15-
from api.src.workspaces.schemas import WorkspaceUserRoleType
15+
from api.src.users.schemas import WorkspaceUserRoleType
1616

1717
# Set up logger for this module
1818
logger = get_logger(__name__)
@@ -125,6 +125,13 @@ def isWorkspaceContributor(self, workspaceId: int) -> bool:
125125
return True
126126
return False
127127

128+
def effective_role(self, workspaceId: int) -> str:
129+
if self.isWorkspaceLead(workspaceId):
130+
return "lead"
131+
if self.isWorkspaceValidator(workspaceId):
132+
return "validator"
133+
return "contributor"
134+
128135

129136
# can't use the ORM here since the ORM uses us! (circular dependency)
130137
def get_osm_db_session(

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
validate_token,
2323
)
2424
from api.src.teams.routes import router as teams_router
25+
from api.src.users.routes import router as users_router
2526
from api.src.workspaces.repository import WorkspaceRepository
2627
from api.src.workspaces.routes import router as workspaces_router
2728
from api.utils.migrations import run_migrations
@@ -85,6 +86,7 @@ async def lifespan(_app: FastAPI):
8586

8687
# Include routers
8788
app.include_router(teams_router, prefix="/api/v1")
89+
app.include_router(users_router, prefix="/api/v1")
8890
app.include_router(workspaces_router, prefix="/api/v1")
8991

9092

api/src/teams/repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
WorkspaceTeamItem,
1010
WorkspaceTeamUpdate,
1111
)
12-
from api.src.workspaces.schemas import User
12+
from api.src.users.schemas import User
1313

1414

1515
class WorkspaceTeamRepository:

api/src/teams/routes.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from fastapi import APIRouter, Depends, status
1+
from fastapi import APIRouter, Depends, HTTPException, status
22
from sqlmodel.ext.asyncio.session import AsyncSession
33

44
from api.core.database import get_osm_session, get_task_session
@@ -9,8 +9,9 @@
99
WorkspaceTeamItem,
1010
WorkspaceTeamUpdate,
1111
)
12-
from api.src.workspaces.repository import OSMRepository, WorkspaceRepository
13-
from api.src.workspaces.schemas import User
12+
from api.src.users.repository import UserRepository
13+
from api.src.users.schemas import User
14+
from api.src.workspaces.repository import WorkspaceRepository
1415

1516
router = APIRouter(prefix="/workspaces/{workspace_id}/teams", tags=["teams"])
1617

@@ -22,10 +23,10 @@ def get_workspace_repo(
2223
return repo
2324

2425

25-
def get_osm_repo(
26+
def get_user_repo(
2627
session: AsyncSession = Depends(get_osm_session),
27-
) -> OSMRepository:
28-
repository = OSMRepository(session)
28+
) -> UserRepository:
29+
repository = UserRepository(session)
2930
return repository
3031

3132

@@ -56,6 +57,12 @@ async def create_team_for_workspace(
5657
team_repo=Depends(get_team_repo),
5758
current_user: UserInfo = Depends(validate_token),
5859
) -> int:
60+
if not current_user.isWorkspaceLead(workspace_id):
61+
raise HTTPException(
62+
status_code=status.HTTP_403_FORBIDDEN,
63+
detail="Only workspace leads can create teams",
64+
)
65+
5966
# Repo guards if workspace doesn't exist or user cannot access:
6067
await workspace_repo.getById(current_user, workspace_id)
6168
return await team_repo.create(workspace_id, team)
@@ -84,6 +91,12 @@ async def update_team_for_workspace(
8491
team_repo=Depends(get_team_repo),
8592
current_user: UserInfo = Depends(validate_token),
8693
):
94+
if not current_user.isWorkspaceLead(workspace_id):
95+
raise HTTPException(
96+
status_code=status.HTTP_403_FORBIDDEN,
97+
detail="Only workspace leads can update teams",
98+
)
99+
87100
# Repo guards if workspace doesn't exist or user cannot access:
88101
await workspace_repo.getById(current_user, workspace_id)
89102
await team_repo.assert_team_in_workspace(team_id, workspace_id)
@@ -98,6 +111,12 @@ async def delete_team_from_workspace(
98111
team_repo=Depends(get_team_repo),
99112
current_user: UserInfo = Depends(validate_token),
100113
):
114+
if not current_user.isWorkspaceLead(workspace_id):
115+
raise HTTPException(
116+
status_code=status.HTTP_403_FORBIDDEN,
117+
detail="Only workspace leads can delete teams",
118+
)
119+
101120
# Repo guards if workspace doesn't exist or user cannot access:
102121
await workspace_repo.getById(current_user, workspace_id)
103122
await team_repo.assert_team_in_workspace(team_id, workspace_id)
@@ -123,14 +142,14 @@ async def join_workspace_team(
123142
workspace_id: int,
124143
team_id: int,
125144
workspace_repo=Depends(get_workspace_repo),
126-
osm_repo=Depends(get_osm_repo),
145+
user_repo=Depends(get_user_repo),
127146
team_repo=Depends(get_team_repo),
128147
current_user: UserInfo = Depends(validate_token),
129148
) -> User:
130149
# Repo guards if workspace doesn't exist or user cannot access:
131150
await workspace_repo.getById(current_user, workspace_id)
132151
await team_repo.assert_team_in_workspace(team_id, workspace_id)
133-
user = await osm_repo.get_current_user(current_user)
152+
user = await user_repo.get_current_user(current_user)
134153
await team_repo.add_member(team_id, user.id)
135154
return user
136155

@@ -144,6 +163,12 @@ async def add_member_to_workspace_team(
144163
team_repo=Depends(get_team_repo),
145164
current_user: UserInfo = Depends(validate_token),
146165
):
166+
if not current_user.isWorkspaceLead(workspace_id):
167+
raise HTTPException(
168+
status_code=status.HTTP_403_FORBIDDEN,
169+
detail="Only workspace leads can add team members",
170+
)
171+
147172
# Repo guards if workspace doesn't exist or user cannot access:
148173
await workspace_repo.getById(current_user, workspace_id)
149174
await team_repo.assert_team_in_workspace(team_id, workspace_id)
@@ -159,6 +184,12 @@ async def delete_member_from_workspace_team(
159184
team_repo=Depends(get_team_repo),
160185
current_user: UserInfo = Depends(validate_token),
161186
):
187+
if not current_user.isWorkspaceLead(workspace_id):
188+
raise HTTPException(
189+
status_code=status.HTTP_403_FORBIDDEN,
190+
detail="Only workspace leads can remove team members",
191+
)
192+
162193
# Repo guards if workspace doesn't exist or user cannot access:
163194
await workspace_repo.getById(current_user, workspace_id)
164195
await team_repo.assert_team_in_workspace(team_id, workspace_id)

api/src/users/repository.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from uuid import UUID
2+
3+
from sqlalchemy import delete, select, update
4+
from sqlmodel.ext.asyncio.session import AsyncSession
5+
6+
from api.core.exceptions import NotFoundException
7+
from api.core.security import UserInfo
8+
from api.src.users.schemas import (
9+
User,
10+
WorkspaceUserRole,
11+
WorkspaceUserRoleItem,
12+
WorkspaceUserRoleType,
13+
)
14+
15+
16+
class UserRepository:
17+
18+
def __init__(self, session: AsyncSession):
19+
self.session = session
20+
21+
async def get_privileged_workspace_members(
22+
self,
23+
workspace_id: int,
24+
) -> list[WorkspaceUserRoleItem]:
25+
# The table only stores "lead" and "validator" role assignments. Project
26+
# group members implicitly have the base "contributor" role.
27+
#
28+
query = (
29+
select(User, WorkspaceUserRole.role)
30+
.join(WorkspaceUserRole, User.auth_uid == WorkspaceUserRole.user_auth_uid)
31+
.where(WorkspaceUserRole.workspace_id == workspace_id)
32+
)
33+
result = await self.session.execute(query)
34+
35+
return [
36+
WorkspaceUserRoleItem(
37+
id=user.id,
38+
auth_uid=user.auth_uid,
39+
email=user.email,
40+
display_name=user.display_name,
41+
role=role,
42+
)
43+
for user, role in result.all()
44+
]
45+
46+
async def get_current_user(self, current_user: UserInfo) -> User:
47+
result = await self.session.exec(
48+
select(User).where(User.auth_uid == str(current_user.user_uuid))
49+
)
50+
51+
# Current user should exist--throw if it doesn't:
52+
return result.scalar_one()
53+
54+
async def assign_member_role(
55+
self,
56+
workspace_id: int,
57+
user_id: UUID,
58+
role: WorkspaceUserRoleType,
59+
) -> None:
60+
# Ensure the user has a local user record (signed in at least once):
61+
user_exists = await self.session.scalar(
62+
select(User.id).where(User.auth_uid == str(user_id))
63+
)
64+
if not user_exists:
65+
raise NotFoundException(
66+
f"User {user_id} has not signed in to Workspaces yet"
67+
)
68+
69+
# Update role if the user already has one for this workspace:
70+
result = await self.session.execute(
71+
update(WorkspaceUserRole)
72+
.where(
73+
(WorkspaceUserRole.user_auth_uid == str(user_id))
74+
& (WorkspaceUserRole.workspace_id == workspace_id)
75+
)
76+
.values(role=role)
77+
)
78+
79+
if result.rowcount == 0:
80+
self.session.add(
81+
WorkspaceUserRole(
82+
user_auth_uid=str(user_id),
83+
workspace_id=workspace_id,
84+
role=role,
85+
)
86+
)
87+
88+
await self.session.commit()
89+
90+
async def remove_member_role(
91+
self,
92+
workspace_id: int,
93+
user_id: UUID,
94+
) -> None:
95+
query = delete(WorkspaceUserRole).where(
96+
(WorkspaceUserRole.workspace_id == workspace_id)
97+
& (WorkspaceUserRole.user_auth_uid == str(user_id))
98+
)
99+
100+
result = await self.session.execute(query)
101+
102+
if result.rowcount != 1:
103+
raise NotFoundException(
104+
f"No role assigned for workspace {workspace_id}, user {user_id}"
105+
)
106+
107+
await self.session.commit()
108+
109+
async def remove_all_member_roles(self, workspace_id: int) -> None:
110+
await self.session.execute(
111+
delete(WorkspaceUserRole).where(
112+
WorkspaceUserRole.workspace_id == workspace_id
113+
)
114+
)
115+
await self.session.commit()

api/src/users/routes.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from uuid import UUID
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlmodel.ext.asyncio.session import AsyncSession
5+
6+
from api.core.database import get_osm_session
7+
from api.core.security import UserInfo, validate_token
8+
from api.src.users.repository import UserRepository
9+
from api.src.users.schemas import SetRoleRequest, WorkspaceUserRoleItem
10+
11+
router = APIRouter(prefix="/workspaces/{workspace_id}/users", tags=["users"])
12+
13+
14+
def get_user_repo(
15+
session: AsyncSession = Depends(get_osm_session),
16+
) -> UserRepository:
17+
repository = UserRepository(session)
18+
return repository
19+
20+
21+
@router.get("", response_model=list[WorkspaceUserRoleItem])
22+
async def get_privileged_workspace_members(
23+
workspace_id: int,
24+
current_user: UserInfo = Depends(validate_token),
25+
user_repo: UserRepository = Depends(get_user_repo),
26+
):
27+
if not current_user.isWorkspaceContributor(workspace_id):
28+
raise HTTPException(
29+
status_code=status.HTTP_403_FORBIDDEN,
30+
detail="Project group membership required to view members",
31+
)
32+
33+
return await user_repo.get_privileged_workspace_members(workspace_id)
34+
35+
36+
@router.put("/{user_id}/role", status_code=status.HTTP_204_NO_CONTENT)
37+
async def assign_member_role(
38+
workspace_id: int,
39+
user_id: UUID,
40+
body: SetRoleRequest,
41+
current_user: UserInfo = Depends(validate_token),
42+
user_repo: UserRepository = Depends(get_user_repo),
43+
):
44+
if current_user.isWorkspaceLead(workspace_id) is False:
45+
raise HTTPException(
46+
status_code=status.HTTP_403_FORBIDDEN,
47+
detail="Must be a workspace owner to assign roles",
48+
)
49+
50+
await user_repo.assign_member_role(workspace_id, user_id, body.role)
51+
52+
53+
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
54+
async def remove_member_role(
55+
workspace_id: int,
56+
user_id: UUID,
57+
current_user: UserInfo = Depends(validate_token),
58+
user_repo: UserRepository = Depends(get_user_repo),
59+
):
60+
if current_user.isWorkspaceLead(workspace_id) is False:
61+
raise HTTPException(
62+
status_code=status.HTTP_403_FORBIDDEN,
63+
detail="Must be a workspace owner to remove roles",
64+
)
65+
66+
await user_repo.remove_member_role(workspace_id, user_id)

0 commit comments

Comments
 (0)