|
1 | 1 | import uuid |
2 | 2 | from datetime import timezone |
3 | | -from typing import Awaitable, Callable, List, Optional, Tuple |
| 3 | +from typing import Awaitable, Callable, List, Optional, Tuple, Union |
4 | 4 |
|
5 | 5 | from sqlalchemy import delete, select, update |
6 | 6 | from sqlalchemy import func as safunc |
@@ -75,8 +75,8 @@ async def list_user_accessible_projects( |
75 | 75 | ) -> List[Project]: |
76 | 76 | """ |
77 | 77 | Returns all projects accessible to the user: |
78 | | - - For global admins: ALL projects in the system |
79 | | - - For regular users: Projects where user is a member + public projects where user is NOT a member |
| 78 | + - Projects where user is a member (public or private) |
| 79 | + - Public projects where user is NOT a member |
80 | 80 | """ |
81 | 81 | if user.global_role == GlobalRole.ADMIN: |
82 | 82 | projects = await list_project_models(session=session) |
@@ -231,6 +231,99 @@ async def set_project_members( |
231 | 231 | await session.commit() |
232 | 232 |
|
233 | 233 |
|
| 234 | +async def add_project_members( |
| 235 | + session: AsyncSession, |
| 236 | + user: UserModel, |
| 237 | + project: ProjectModel, |
| 238 | + members: List[MemberSetting], |
| 239 | +): |
| 240 | + """Add multiple members to a project.""" |
| 241 | + # reload with members |
| 242 | + project = await get_project_model_by_name_or_error( |
| 243 | + session=session, |
| 244 | + project_name=project.name, |
| 245 | + ) |
| 246 | + requesting_user_role = get_user_project_role(user=user, project=project) |
| 247 | + |
| 248 | + # Check if this is a self-join to public project |
| 249 | + is_self_join_to_public = ( |
| 250 | + len(members) == 1 and |
| 251 | + project.is_public and |
| 252 | + (members[0].username == user.name or members[0].username == user.email) and |
| 253 | + requesting_user_role is None # User is not already a member |
| 254 | + ) |
| 255 | + |
| 256 | + # Check permissions: only managers/admins can add members, EXCEPT for self-join to public projects |
| 257 | + if not is_self_join_to_public: |
| 258 | + if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]: |
| 259 | + raise ForbiddenError("Access denied: insufficient permissions to add members") |
| 260 | + |
| 261 | + # For project managers, check if they're trying to add admins |
| 262 | + if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER: |
| 263 | + for member in members: |
| 264 | + if member.project_role == ProjectRole.ADMIN: |
| 265 | + raise ForbiddenError("Access denied: only global admins can add project admins") |
| 266 | + else: |
| 267 | + # For self-join to public project, only allow USER role |
| 268 | + if members[0].project_role != ProjectRole.USER: |
| 269 | + raise ForbiddenError("Access denied: can only join public projects as user role") |
| 270 | + |
| 271 | + # Collect all usernames to query |
| 272 | + usernames = [member.username for member in members] |
| 273 | + |
| 274 | + # Find all users (by username or email) |
| 275 | + res = await session.execute( |
| 276 | + select(UserModel).where( |
| 277 | + (UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)) |
| 278 | + ) |
| 279 | + ) |
| 280 | + users_found = res.scalars().all() |
| 281 | + |
| 282 | + # Create lookup maps for both username and email |
| 283 | + username_to_user = {user.name: user for user in users_found} |
| 284 | + email_to_user = {user.email: user for user in users_found if user.email} |
| 285 | + |
| 286 | + # Build a set of current member IDs so we can quickly check if users are already members |
| 287 | + member_ids = {m.user_id for m in project.members} |
| 288 | + # Build a map from user_id to member for efficient existing member updates |
| 289 | + member_by_user_id = {m.user_id: m for m in project.members} |
| 290 | + |
| 291 | + # Get current max member_num |
| 292 | + max_member_num = 0 |
| 293 | + for member in project.members: |
| 294 | + if member.member_num is not None and member.member_num > max_member_num: |
| 295 | + max_member_num = member.member_num |
| 296 | + |
| 297 | + # Process each member to add |
| 298 | + for member_setting in members: |
| 299 | + user_to_add = username_to_user.get(member_setting.username) or email_to_user.get(member_setting.username) |
| 300 | + if user_to_add is None: |
| 301 | + # Error on adding non-existing users instead of silently skipping |
| 302 | + raise ServerClientError(f"User not found: {member_setting.username}") |
| 303 | + |
| 304 | + # Check if user is already a member using our fast lookup set |
| 305 | + if user_to_add.id in member_ids: |
| 306 | + # Update existing member role if different using our efficient lookup |
| 307 | + existing_member = member_by_user_id[user_to_add.id] |
| 308 | + if existing_member.project_role != member_setting.project_role: |
| 309 | + existing_member.project_role = member_setting.project_role |
| 310 | + else: |
| 311 | + # Add new member |
| 312 | + max_member_num += 1 |
| 313 | + await add_project_member( |
| 314 | + session=session, |
| 315 | + project=project, |
| 316 | + user=user_to_add, |
| 317 | + project_role=member_setting.project_role, |
| 318 | + member_num=max_member_num, |
| 319 | + commit=False, |
| 320 | + ) |
| 321 | + # Keep track of newly added members for subsequent iterations |
| 322 | + member_ids.add(user_to_add.id) |
| 323 | + |
| 324 | + await session.commit() |
| 325 | + |
| 326 | + |
234 | 327 | async def add_project_member( |
235 | 328 | session: AsyncSession, |
236 | 329 | project: ProjectModel, |
@@ -511,3 +604,92 @@ def _is_project_admin( |
511 | 604 | if m.project_role == ProjectRole.ADMIN: |
512 | 605 | return True |
513 | 606 | return False |
| 607 | + |
| 608 | + |
| 609 | +async def remove_project_members( |
| 610 | + session: AsyncSession, |
| 611 | + user: UserModel, |
| 612 | + project: ProjectModel, |
| 613 | + usernames: List[str], |
| 614 | +): |
| 615 | + """Remove multiple members from a project.""" |
| 616 | + # reload with members |
| 617 | + project = await get_project_model_by_name_or_error( |
| 618 | + session=session, |
| 619 | + project_name=project.name, |
| 620 | + ) |
| 621 | + requesting_user_role = get_user_project_role(user=user, project=project) |
| 622 | + |
| 623 | + # Check if this is a self-leave (user removing themselves) |
| 624 | + is_self_leave = ( |
| 625 | + len(usernames) == 1 and |
| 626 | + (usernames[0] == user.name or usernames[0] == user.email) and |
| 627 | + requesting_user_role is not None # User is actually a member |
| 628 | + ) |
| 629 | + |
| 630 | + # Check basic permissions: only managers/admins can remove members, EXCEPT for self-leave |
| 631 | + if not is_self_leave: |
| 632 | + if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]: |
| 633 | + raise ForbiddenError("Access denied: insufficient permissions to remove members") |
| 634 | + |
| 635 | + # Find all users to remove (by username or email) |
| 636 | + res = await session.execute( |
| 637 | + select(UserModel).where( |
| 638 | + (UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)) |
| 639 | + ) |
| 640 | + ) |
| 641 | + users_found = res.scalars().all() |
| 642 | + |
| 643 | + # Create lookup maps |
| 644 | + username_to_user = {user.name: user for user in users_found} |
| 645 | + email_to_user = {user.email: user for user in users_found if user.email} |
| 646 | + |
| 647 | + # Build a set of member IDs for faster membership checks |
| 648 | + member_user_ids = {m.user_id for m in project.members} |
| 649 | + # Build a map from user_id to member for efficient member lookups |
| 650 | + member_by_user_id = {m.user_id: m for m in project.members} |
| 651 | + |
| 652 | + # Find members to remove and validate permissions |
| 653 | + members_to_remove = [] |
| 654 | + admin_removals = 0 |
| 655 | + |
| 656 | + for username in usernames: |
| 657 | + user_to_remove = username_to_user.get(username) or email_to_user.get(username) |
| 658 | + if user_to_remove is None: |
| 659 | + # Error on removing non-existing users instead of silently skipping |
| 660 | + raise ServerClientError(f"User not found: {username}") |
| 661 | + |
| 662 | + # Check if user is actually a member before trying to remove them |
| 663 | + if user_to_remove.id not in member_user_ids: |
| 664 | + # Error on removing non-members instead of silently skipping |
| 665 | + raise ServerClientError(f"User is not a member of this project: {username}") |
| 666 | + |
| 667 | + # Get the member to remove using our efficient lookup |
| 668 | + member_to_remove = member_by_user_id[user_to_remove.id] |
| 669 | + |
| 670 | + # Check if trying to remove project admin |
| 671 | + if member_to_remove.project_role == ProjectRole.ADMIN: |
| 672 | + if is_self_leave: |
| 673 | + # For self-leave, check if user is the last admin |
| 674 | + total_admins = sum(1 for member in project.members if member.project_role == ProjectRole.ADMIN) |
| 675 | + if total_admins <= 1: |
| 676 | + raise ServerClientError("Cannot leave project: you are the last admin") |
| 677 | + else: |
| 678 | + # For manager/admin removing other admins, only global admins can do this |
| 679 | + if user.global_role != GlobalRole.ADMIN: |
| 680 | + raise ForbiddenError(f"Access denied: only global admins can remove project admins (user: {username})") |
| 681 | + admin_removals += 1 |
| 682 | + |
| 683 | + members_to_remove.append(member_to_remove) |
| 684 | + |
| 685 | + # Check we're not removing all admins (for non-self-leave operations) |
| 686 | + if not is_self_leave: |
| 687 | + total_admins = sum(1 for member in project.members if member.project_role == ProjectRole.ADMIN) |
| 688 | + if admin_removals >= total_admins: |
| 689 | + raise ServerClientError("Cannot remove all project admins") |
| 690 | + |
| 691 | + # Remove all members |
| 692 | + for member in members_to_remove: |
| 693 | + await session.delete(member) |
| 694 | + |
| 695 | + await session.commit() |
0 commit comments