refactor: update module docstrings for clarity and consistency

This commit is contained in:
Abhimanyu Saharan
2026-02-09 15:49:50 +05:30
parent 78bb08d4a3
commit 7ca1899d9f
99 changed files with 2345 additions and 855 deletions

View File

@@ -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,