Files
RoleForge/app/routers/teams.py
Sergey Antropoff b2d3b6b803 Профиль и аккаунт
- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки.
- Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок
  инициализации (показ admin-only после initAuthSession, currentUser).
- GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool).

Команды и роли
- Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды
  в модалке, только команды с активным членством; API team_id в details/ update.
- GET /api/v1/teams?membership=active для списка «своих» команд.
- Форма роли: сегмент Team, панель выбора команды только при Team и не при
  с
2026-05-05 08:15:21 +03:00

433 lines
16 KiB
Python

"""Teams: create, join requests, invitations, membership, team-scoped Ansible roles."""
from __future__ import annotations
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.deps.auth import get_current_user_id
from app.db.pool import get_pool
from app.schemas.teams import TeamCreate, TeamInviteRequest, TeamUpdate
router = APIRouter(prefix="/teams", tags=["teams"])
def _uuid(x: str, label: str = "id") -> str:
try:
UUID(x)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid {label}") from exc
return x
async def _get_membership(conn, team_id: str, user_id: str) -> dict[str, Any] | None:
row = await conn.fetchrow(
"""
select team_role, status
from team_memberships
where team_id = $1::uuid and user_id = $2::uuid
""",
team_id,
user_id,
)
return dict(row) if row else None
async def _require_team_owner(conn, team_id: str, user_id: str) -> None:
m = await _get_membership(conn, team_id, user_id)
if not m or m.get("team_role") != "owner" or m.get("status") != "active":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Team owner access required")
async def _team_exists(conn, team_id: str) -> bool:
return bool(await conn.fetchval("select 1 from teams where id=$1::uuid", team_id))
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_team(body: TeamCreate, user_id: str = Depends(get_current_user_id)) -> dict[str, Any]:
pool = get_pool()
async with pool.acquire() as conn:
tid = await conn.fetchval(
"""
insert into teams (name, description, created_by)
values ($1, $2, $3::uuid)
returning id::text
""",
body.name.strip(),
body.description.strip(),
user_id,
)
await conn.execute(
"""
insert into team_memberships (team_id, user_id, team_role, status)
values ($1::uuid, $2::uuid, 'owner', 'active')
""",
tid,
user_id,
)
return {"id": tid, "name": body.name.strip()}
@router.get("")
async def list_teams(
user_id: str = Depends(get_current_user_id),
membership: str | None = Query(
None,
description="If 'active', return only teams where the current user is an active member.",
),
) -> dict[str, Any]:
pool = get_pool()
filter_active = str(membership or "").strip().lower() == "active"
async with pool.acquire() as conn:
if filter_active:
rows = await conn.fetch(
"""
select
t.id::text as id,
t.name,
t.description,
t.created_at,
t.created_by::text as created_by,
u.username as created_by_username,
coalesce(mc.cnt, 0)::int as member_count,
tm.team_role as my_team_role,
tm.status as my_status
from teams t
inner join team_memberships tm on tm.team_id = t.id
and tm.user_id = $1::uuid and tm.status = 'active'
left join users u on u.id = t.created_by
left join (
select team_id, count(*)::int as cnt
from team_memberships
where status = 'active'
group by team_id
) mc on mc.team_id = t.id
order by t.created_at desc
""",
user_id,
)
else:
rows = await conn.fetch(
"""
select
t.id::text as id,
t.name,
t.description,
t.created_at,
t.created_by::text as created_by,
u.username as created_by_username,
coalesce(mc.cnt, 0)::int as member_count,
tm.team_role as my_team_role,
tm.status as my_status
from teams t
left join users u on u.id = t.created_by
left join team_memberships tm on tm.team_id = t.id and tm.user_id = $1::uuid
left join (
select team_id, count(*)::int as cnt
from team_memberships
where status = 'active'
group by team_id
) mc on mc.team_id = t.id
order by t.created_at desc
""",
user_id,
)
items = []
for r in rows:
d = dict(r)
if d.get("created_at") and hasattr(d["created_at"], "isoformat"):
d["created_at"] = d["created_at"].isoformat()
items.append(d)
return {"items": items}
@router.get("/{team_id}")
async def get_team(team_id: str, user_id: str = Depends(get_current_user_id)) -> dict[str, Any]:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
if not await _team_exists(conn, team_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
team = await conn.fetchrow(
"""
select
t.id::text as id,
t.name,
t.description,
t.created_at,
t.created_by::text as created_by,
u.username as created_by_username
from teams t
join users u on u.id = t.created_by
where t.id = $1::uuid
""",
team_id,
)
mym = await _get_membership(conn, team_id, user_id)
members = await conn.fetch(
"""
select
m.user_id::text as user_id,
m.team_role,
m.status,
m.created_at,
usr.username,
usr.email,
usr.full_name
from team_memberships m
join users usr on usr.id = m.user_id
where m.team_id = $1::uuid
order by m.team_role desc, usr.username nulls last
""",
team_id,
)
mem_out = []
for m in members:
d = dict(m)
if d.get("created_at") and hasattr(d["created_at"], "isoformat"):
d["created_at"] = d["created_at"].isoformat()
mem_out.append(d)
td = dict(team)
if td.get("created_at") and hasattr(td["created_at"], "isoformat"):
td["created_at"] = td["created_at"].isoformat()
return {
"team": td,
"my_membership": dict(mym) if mym else None,
"members": mem_out,
}
@router.patch("/{team_id}")
async def update_team(
team_id: str,
body: TeamUpdate,
user_id: str = Depends(get_current_user_id),
) -> dict[str, str]:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
await _require_team_owner(conn, team_id, user_id)
parts: list[str] = []
vals: list[Any] = []
if body.name is not None:
vals.append(body.name.strip())
parts.append(f"name=${len(vals)}")
if body.description is not None:
vals.append(body.description.strip())
parts.append(f"description=${len(vals)}")
if not parts:
return {"status": "noop"}
vals.append(team_id)
await conn.execute(
f"update teams set {', '.join(parts)} where id=${len(vals)}::uuid",
*vals,
)
return {"status": "updated"}
@router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_team(team_id: str, user_id: str = Depends(get_current_user_id)) -> None:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
await _require_team_owner(conn, team_id, user_id)
await conn.execute("delete from teams where id=$1::uuid", team_id)
@router.post("/{team_id}/join", status_code=status.HTTP_201_CREATED)
async def request_join_team(team_id: str, user_id: str = Depends(get_current_user_id)) -> dict[str, str]:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
if not await _team_exists(conn, team_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
m = await _get_membership(conn, team_id, user_id)
if m and m["status"] == "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member")
if m and m["status"] == "pending_join":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Join request already pending")
if m and m["status"] == "invited":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You have a pending invitation — accept it instead",
)
await conn.execute(
"""
insert into team_memberships (team_id, user_id, team_role, status)
values ($1::uuid, $2::uuid, 'member', 'pending_join')
""",
team_id,
user_id,
)
return {"status": "requested"}
@router.delete("/{team_id}/join", status_code=status.HTTP_204_NO_CONTENT)
async def cancel_join_request(team_id: str, user_id: str = Depends(get_current_user_id)) -> None:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
delete from team_memberships
where team_id=$1::uuid and user_id=$2::uuid and status='pending_join'
""",
team_id,
user_id,
)
@router.post("/{team_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
async def leave_team(team_id: str, user_id: str = Depends(get_current_user_id)) -> None:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
m = await _get_membership(conn, team_id, user_id)
if not m or m["status"] != "active":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Not an active member")
if m["team_role"] == "owner":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transfer ownership or delete the team instead of leaving",
)
await conn.execute(
"""
delete from team_memberships
where team_id=$1::uuid and user_id=$2::uuid
""",
team_id,
user_id,
)
@router.post("/{team_id}/invitations", status_code=status.HTTP_201_CREATED)
async def invite_user(
team_id: str,
body: TeamInviteRequest,
user_id: str = Depends(get_current_user_id),
) -> dict[str, str]:
_uuid(team_id, "team_id")
invitee = _uuid(body.user_id.strip(), "user_id")
if invitee == user_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot invite yourself")
pool = get_pool()
async with pool.acquire() as conn:
await _require_team_owner(conn, team_id, user_id)
exists_u = await conn.fetchval("select 1 from users where id=$1::uuid", invitee)
if not exists_u:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
m = await _get_membership(conn, team_id, invitee)
if m and m["status"] == "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User is already a member")
if m and m["status"] == "invited":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invitation already pending")
if m and m["status"] == "pending_join":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User already requested to join — approve the request instead",
)
await conn.execute(
"""
insert into team_memberships (team_id, user_id, team_role, status, invited_by)
values ($1::uuid, $2::uuid, 'member', 'invited', $3::uuid)
""",
team_id,
invitee,
user_id,
)
return {"status": "invited"}
@router.post("/{team_id}/invitations/accept", status_code=status.HTTP_200_OK)
async def accept_invitation(team_id: str, user_id: str = Depends(get_current_user_id)) -> dict[str, str]:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
m = await _get_membership(conn, team_id, user_id)
if not m or m["status"] != "invited":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No invitation for this team")
row = await conn.fetchrow(
"""
update team_memberships
set status='active'
where team_id=$1::uuid and user_id=$2::uuid and status='invited'
returning 1
""",
team_id,
user_id,
)
if not row:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No invitation for this team")
return {"status": "joined"}
@router.delete("/{team_id}/invitations/me", status_code=status.HTTP_204_NO_CONTENT)
async def decline_invitation(team_id: str, user_id: str = Depends(get_current_user_id)) -> None:
_uuid(team_id, "team_id")
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
delete from team_memberships
where team_id=$1::uuid and user_id=$2::uuid and status='invited'
""",
team_id,
user_id,
)
@router.post("/{team_id}/members/{member_user_id}/approve", status_code=status.HTTP_200_OK)
async def approve_join_request(
team_id: str,
member_user_id: str,
user_id: str = Depends(get_current_user_id),
) -> dict[str, str]:
_uuid(team_id, "team_id")
mid = _uuid(member_user_id, "member_user_id")
pool = get_pool()
async with pool.acquire() as conn:
await _require_team_owner(conn, team_id, user_id)
row = await conn.fetchrow(
"""
update team_memberships
set status='active'
where team_id=$1::uuid and user_id=$2::uuid and status='pending_join'
returning 1
""",
team_id,
mid,
)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No pending request for this user")
return {"status": "approved"}
@router.delete("/{team_id}/members/{member_user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def reject_or_remove_member(
team_id: str,
member_user_id: str,
user_id: str = Depends(get_current_user_id),
) -> None:
_uuid(team_id, "team_id")
mid = _uuid(member_user_id, "member_user_id")
pool = get_pool()
async with pool.acquire() as conn:
await _require_team_owner(conn, team_id, user_id)
if mid == user_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove yourself this way")
m = await _get_membership(conn, team_id, mid)
if not m:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Membership not found")
if m["team_role"] == "owner":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove team owner")
await conn.execute(
"""
delete from team_memberships
where team_id=$1::uuid and user_id=$2::uuid
""",
team_id,
mid,
)