refactor: update module docstrings for clarity and consistency
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
"""Organization management endpoints and membership/invite flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Any, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import require_org_admin, require_org_member
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.core.auth import get_auth_context
|
||||
from app.core.time import utcnow
|
||||
from app.db import crud
|
||||
from app.db.pagination import paginate
|
||||
@@ -63,10 +64,21 @@ from app.services.organizations import (
|
||||
set_active_organization,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
AUTH_DEP = Depends(get_auth_context)
|
||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
|
||||
|
||||
def _member_to_read(member: OrganizationMember, user: User | None) -> OrganizationMemberRead:
|
||||
def _member_to_read(
|
||||
member: OrganizationMember, user: User | None,
|
||||
) -> OrganizationMemberRead:
|
||||
model = OrganizationMemberRead.model_validate(member, from_attributes=True)
|
||||
if user is not None:
|
||||
model.user = OrganizationUserRead.model_validate(user, from_attributes=True)
|
||||
@@ -100,9 +112,10 @@ async def _require_org_invite(
|
||||
@router.post("", response_model=OrganizationRead)
|
||||
async def create_organization(
|
||||
payload: OrganizationCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> OrganizationRead:
|
||||
"""Create an organization and assign the caller as owner."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
name = payload.name.strip()
|
||||
@@ -110,7 +123,9 @@ async def create_organization(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
existing = (
|
||||
await session.exec(
|
||||
select(Organization).where(func.lower(col(Organization.name)) == name.lower())
|
||||
select(Organization).where(
|
||||
func.lower(col(Organization.name)) == name.lower(),
|
||||
),
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
@@ -140,19 +155,25 @@ async def create_organization(
|
||||
|
||||
@router.get("/me/list", response_model=list[OrganizationListItem])
|
||||
async def list_my_organizations(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> list[OrganizationListItem]:
|
||||
"""List organizations where the current user is a member."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
await get_active_membership(session, auth.user)
|
||||
db_user = await User.objects.by_id(auth.user.id).first(session)
|
||||
active_id = db_user.active_organization_id if db_user else auth.user.active_organization_id
|
||||
active_id = (
|
||||
db_user.active_organization_id if db_user else auth.user.active_organization_id
|
||||
)
|
||||
|
||||
statement = (
|
||||
select(Organization, OrganizationMember)
|
||||
.join(OrganizationMember, col(OrganizationMember.organization_id) == col(Organization.id))
|
||||
.join(
|
||||
OrganizationMember,
|
||||
col(OrganizationMember.organization_id) == col(Organization.id),
|
||||
)
|
||||
.where(col(OrganizationMember.user_id) == auth.user.id)
|
||||
.order_by(func.lower(col(Organization.name)).asc())
|
||||
)
|
||||
@@ -171,30 +192,37 @@ async def list_my_organizations(
|
||||
@router.patch("/me/active", response_model=OrganizationRead)
|
||||
async def set_active_org(
|
||||
payload: OrganizationActiveUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> OrganizationRead:
|
||||
"""Set the caller's active organization."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
member = await set_active_organization(
|
||||
session, user=auth.user, organization_id=payload.organization_id
|
||||
session, user=auth.user, organization_id=payload.organization_id,
|
||||
)
|
||||
organization = await Organization.objects.by_id(member.organization_id).first(
|
||||
session,
|
||||
)
|
||||
organization = await Organization.objects.by_id(member.organization_id).first(session)
|
||||
if organization is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return OrganizationRead.model_validate(organization, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/me", response_model=OrganizationRead)
|
||||
async def get_my_org(ctx: OrganizationContext = Depends(require_org_member)) -> OrganizationRead:
|
||||
async def get_my_org(
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> OrganizationRead:
|
||||
"""Return the caller's active organization."""
|
||||
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/me", response_model=OkResponse)
|
||||
async def delete_my_org(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete the active organization and related entities."""
|
||||
if ctx.member.role != "owner":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -206,28 +234,39 @@ async def delete_my_org(
|
||||
task_ids = select(Task.id).where(col(Task.board_id).in_(board_ids))
|
||||
agent_ids = select(Agent.id).where(col(Agent.board_id).in_(board_ids))
|
||||
member_ids = select(OrganizationMember.id).where(
|
||||
col(OrganizationMember.organization_id) == org_id
|
||||
col(OrganizationMember.organization_id) == org_id,
|
||||
)
|
||||
invite_ids = select(OrganizationInvite.id).where(
|
||||
col(OrganizationInvite.organization_id) == org_id
|
||||
col(OrganizationInvite.organization_id) == org_id,
|
||||
)
|
||||
group_ids = select(BoardGroup.id).where(col(BoardGroup.organization_id) == org_id)
|
||||
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.task_id).in_(task_ids), commit=False
|
||||
session, ActivityEvent, col(ActivityEvent.task_id).in_(task_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.agent_id).in_(agent_ids), commit=False
|
||||
session,
|
||||
ActivityEvent,
|
||||
col(ActivityEvent.agent_id).in_(agent_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, TaskDependency, col(TaskDependency.board_id).in_(board_ids), commit=False
|
||||
session,
|
||||
TaskDependency,
|
||||
col(TaskDependency.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, TaskFingerprint, col(TaskFingerprint.board_id).in_(board_ids), commit=False
|
||||
session,
|
||||
TaskFingerprint,
|
||||
col(TaskFingerprint.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Approval, col(Approval.board_id).in_(board_ids), commit=False)
|
||||
await crud.delete_where(
|
||||
session, BoardMemory, col(BoardMemory.board_id).in_(board_ids), commit=False
|
||||
session, Approval, col(Approval.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, BoardMemory, col(BoardMemory.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
@@ -259,9 +298,15 @@ async def delete_my_org(
|
||||
col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Task, col(Task.board_id).in_(board_ids), commit=False)
|
||||
await crud.delete_where(session, Agent, col(Agent.board_id).in_(board_ids), commit=False)
|
||||
await crud.delete_where(session, Board, col(Board.organization_id) == org_id, commit=False)
|
||||
await crud.delete_where(
|
||||
session, Task, col(Task.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Agent, col(Agent.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Board, col(Board.organization_id) == org_id, commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardGroupMemory,
|
||||
@@ -269,9 +314,11 @@ async def delete_my_org(
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, BoardGroup, col(BoardGroup.organization_id) == org_id, commit=False
|
||||
session, BoardGroup, col(BoardGroup.organization_id) == org_id, commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Gateway, col(Gateway.organization_id) == org_id, commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Gateway, col(Gateway.organization_id) == org_id, commit=False)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
OrganizationInvite,
|
||||
@@ -291,32 +338,39 @@ async def delete_my_org(
|
||||
active_organization_id=None,
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Organization, col(Organization.id) == org_id, commit=False)
|
||||
await crud.delete_where(
|
||||
session, Organization, col(Organization.id) == org_id, commit=False,
|
||||
)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/me/member", response_model=OrganizationMemberRead)
|
||||
async def get_my_membership(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Get the caller's membership record in the active organization."""
|
||||
user = await User.objects.by_id(ctx.member.user_id).first(session)
|
||||
access_rows = await OrganizationBoardAccess.objects.filter_by(
|
||||
organization_member_id=ctx.member.id
|
||||
organization_member_id=ctx.member.id,
|
||||
).all(session)
|
||||
model = _member_to_read(ctx.member, user)
|
||||
model.board_access = [
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) for row in access_rows
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True)
|
||||
for row in access_rows
|
||||
]
|
||||
return model
|
||||
|
||||
|
||||
@router.get("/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead])
|
||||
@router.get(
|
||||
"/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead],
|
||||
)
|
||||
async def list_org_members(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> DefaultLimitOffsetPage[OrganizationMemberRead]:
|
||||
"""List members for the active organization."""
|
||||
statement = (
|
||||
select(OrganizationMember, User)
|
||||
.join(User, col(User.id) == col(OrganizationMember.user_id))
|
||||
@@ -336,9 +390,10 @@ async def list_org_members(
|
||||
@router.get("/me/members/{member_id}", response_model=OrganizationMemberRead)
|
||||
async def get_org_member(
|
||||
member_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Get a specific organization member by id."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -348,11 +403,12 @@ async def get_org_member(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
user = await User.objects.by_id(member.user_id).first(session)
|
||||
access_rows = await OrganizationBoardAccess.objects.filter_by(
|
||||
organization_member_id=member.id
|
||||
organization_member_id=member.id,
|
||||
).all(session)
|
||||
model = _member_to_read(member, user)
|
||||
model.board_access = [
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) for row in access_rows
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True)
|
||||
for row in access_rows
|
||||
]
|
||||
return model
|
||||
|
||||
@@ -361,9 +417,10 @@ async def get_org_member(
|
||||
async def update_org_member(
|
||||
member_id: UUID,
|
||||
payload: OrganizationMemberUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Update a member's role in the organization."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -382,9 +439,10 @@ async def update_org_member(
|
||||
async def update_member_access(
|
||||
member_id: UUID,
|
||||
payload: OrganizationMemberAccessUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Update board-level access settings for a member."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -395,7 +453,9 @@ async def update_member_access(
|
||||
if board_ids:
|
||||
valid_board_ids = {
|
||||
board.id
|
||||
for board in await Board.objects.filter_by(organization_id=ctx.organization.id)
|
||||
for board in await Board.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
.filter(col(Board.id).in_(board_ids))
|
||||
.all(session)
|
||||
}
|
||||
@@ -412,9 +472,10 @@ async def update_member_access(
|
||||
@router.delete("/me/members/{member_id}", response_model=OkResponse)
|
||||
async def remove_org_member(
|
||||
member_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Remove a member from the active organization."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -432,7 +493,9 @@ async def remove_org_member(
|
||||
)
|
||||
if member.role == "owner":
|
||||
owners = (
|
||||
await OrganizationMember.objects.filter_by(organization_id=ctx.organization.id)
|
||||
await OrganizationMember.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
.filter(col(OrganizationMember.role) == "owner")
|
||||
.all(session)
|
||||
)
|
||||
@@ -463,7 +526,9 @@ async def remove_org_member(
|
||||
user.active_organization_id = fallback_membership
|
||||
else:
|
||||
user.active_organization_id = (
|
||||
fallback_membership.organization_id if fallback_membership is not None else None
|
||||
fallback_membership.organization_id
|
||||
if fallback_membership is not None
|
||||
else None
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -471,11 +536,14 @@ async def remove_org_member(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead])
|
||||
@router.get(
|
||||
"/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead],
|
||||
)
|
||||
async def list_org_invites(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> DefaultLimitOffsetPage[OrganizationInviteRead]:
|
||||
"""List pending invites for the active organization."""
|
||||
statement = (
|
||||
OrganizationInvite.objects.filter_by(organization_id=ctx.organization.id)
|
||||
.filter(col(OrganizationInvite.accepted_at).is_(None))
|
||||
@@ -488,9 +556,10 @@ async def list_org_invites(
|
||||
@router.post("/me/invites", response_model=OrganizationInviteRead)
|
||||
async def create_org_invite(
|
||||
payload: OrganizationInviteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationInviteRead:
|
||||
"""Create an organization invite for an email address."""
|
||||
email = normalize_invited_email(payload.invited_email)
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
@@ -526,13 +595,17 @@ async def create_org_invite(
|
||||
if board_ids:
|
||||
valid_board_ids = {
|
||||
board.id
|
||||
for board in await Board.objects.filter_by(organization_id=ctx.organization.id)
|
||||
for board in await Board.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
.filter(col(Board.id).in_(board_ids))
|
||||
.all(session)
|
||||
}
|
||||
if valid_board_ids != board_ids:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
await apply_invite_board_access(session, invite=invite, entries=payload.board_access)
|
||||
await apply_invite_board_access(
|
||||
session, invite=invite, entries=payload.board_access,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(invite)
|
||||
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
|
||||
@@ -541,9 +614,10 @@ async def create_org_invite(
|
||||
@router.delete("/me/invites/{invite_id}", response_model=OrganizationInviteRead)
|
||||
async def revoke_org_invite(
|
||||
invite_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationInviteRead:
|
||||
"""Revoke a pending invite from the active organization."""
|
||||
invite = await _require_org_invite(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -562,9 +636,10 @@ async def revoke_org_invite(
|
||||
@router.post("/invites/accept", response_model=OrganizationMemberRead)
|
||||
async def accept_org_invite(
|
||||
payload: OrganizationInviteAccept,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Accept an invite and return resulting membership."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
invite = await OrganizationInvite.objects.filter(
|
||||
@@ -573,11 +648,13 @@ async def accept_org_invite(
|
||||
).first(session)
|
||||
if invite is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if invite.invited_email and auth.user.email:
|
||||
if normalize_invited_email(invite.invited_email) != normalize_invited_email(
|
||||
auth.user.email
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if (
|
||||
invite.invited_email
|
||||
and auth.user.email
|
||||
and normalize_invited_email(invite.invited_email)
|
||||
!= normalize_invited_email(auth.user.email)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
existing = await get_member(
|
||||
session,
|
||||
|
||||
Reference in New Issue
Block a user