- 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 и не при с
433 lines
16 KiB
Python
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,
|
|
)
|