Files
RoleForge/app/routers/profile.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

223 lines
8.0 KiB
Python

"""User profile and avatar API."""
from datetime import datetime
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from app.core.config import get_settings
from app.core.security import hash_password, verify_password
from app.db.pool import get_pool
from app.deps.auth import get_current_user_id
from app.services.founder_policy import account_group_from_role
from app.schemas.profile import ProfileMeResponse, ProfilePasswordChange, ProfileUpdate, PublicProfileResponse
from app.services.avatar_storage import (
avatar_urls,
delete_avatar_files,
process_and_store_avatar,
)
router = APIRouter(prefix="/profile", tags=["profile"])
def _cache_ts(updated_at: datetime | None) -> str | None:
if not updated_at:
return None
return str(int(updated_at.timestamp()))
async def _fetch_user_row(conn, user_id: str) -> dict | None:
return await conn.fetchrow(
"""
select
id::text as id,
username,
email,
full_name,
coalesce(role, 'user') as role,
coalesce(is_founder, false) as is_founder,
coalesce(profile_bio, '') as profile_bio,
avatar_ext,
avatar_updated_at,
deleted_at
from users where id=$1::uuid
""",
user_id,
)
def _me_payload(row: dict) -> ProfileMeResponse:
urls = avatar_urls(row["id"], row.get("avatar_ext"), _cache_ts(row.get("avatar_updated_at")))
r = str(row["role"] or "user")
return ProfileMeResponse(
id=row["id"],
username=row["username"] or "",
email=row["email"] or "",
full_name=row["full_name"] or "",
role=r,
account_group=account_group_from_role(r),
is_founder=bool(row.get("is_founder")),
profile_bio=str(row.get("profile_bio") or ""),
avatar_ext=row.get("avatar_ext"),
avatar_54=urls["avatar_54"],
avatar_128=urls["avatar_128"],
avatar_256=urls["avatar_256"],
avatar_original=urls["avatar_original"],
)
@router.get("/me", response_model=ProfileMeResponse)
async def get_profile_me(user_id: str = Depends(get_current_user_id)) -> ProfileMeResponse:
pool = get_pool()
async with pool.acquire() as conn:
row = await _fetch_user_row(conn, user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _me_payload(dict(row))
@router.get("/users/{target_user_id}", response_model=PublicProfileResponse)
async def get_public_profile(
target_user_id: str,
_: str = Depends(get_current_user_id),
) -> PublicProfileResponse:
pool = get_pool()
async with pool.acquire() as conn:
row = await _fetch_user_row(conn, target_user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
urls = avatar_urls(row["id"], row.get("avatar_ext"), _cache_ts(row.get("avatar_updated_at")))
pr = str(row["role"] or "user")
return PublicProfileResponse(
id=row["id"],
username=row["username"] or "",
full_name=row["full_name"] or "",
profile_bio=str(row.get("profile_bio") or ""),
role=pr,
account_group=account_group_from_role(pr),
is_founder=bool(row.get("is_founder")),
is_removed=row.get("deleted_at") is not None,
avatar_54=urls["avatar_54"],
avatar_128=urls["avatar_128"],
avatar_256=urls["avatar_256"],
)
@router.patch("/me", response_model=ProfileMeResponse)
async def patch_profile_me(
payload: ProfileUpdate,
user_id: str = Depends(get_current_user_id),
) -> ProfileMeResponse:
assignments: list[tuple[str, object]] = []
if payload.full_name is not None:
assignments.append(("full_name", payload.full_name.strip()))
if payload.profile_bio is not None:
assignments.append(("profile_bio", payload.profile_bio.strip()))
if payload.username is not None:
u = payload.username.strip()
if not u:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
assignments.append(("username", u))
pool = get_pool()
if not assignments:
async with pool.acquire() as conn:
row = await _fetch_user_row(conn, user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _me_payload(dict(row))
async with pool.acquire() as conn:
if payload.username is not None:
taken = await conn.fetchval(
"select 1 from users where lower(username)=lower($1) and id<>$2::uuid",
payload.username.strip(),
user_id,
)
if taken:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
cols = [f"{col}=${i + 1}" for i, (col, _) in enumerate(assignments)]
vals = [v for _, v in assignments]
q = f"update users set {', '.join(cols)} where id=${len(assignments) + 1}::uuid"
await conn.execute(q, *vals, user_id)
row = await _fetch_user_row(conn, user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _me_payload(dict(row))
@router.post("/me/password")
async def change_my_password(
payload: ProfilePasswordChange,
user_id: str = Depends(get_current_user_id),
) -> dict[str, str]:
if payload.new_password != payload.new_password_confirm:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New passwords do not match",
)
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"select password_hash from users where id=$1::uuid",
user_id,
)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not verify_password(payload.current_password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
await conn.execute(
"update users set password_hash=$1 where id=$2::uuid",
hash_password(payload.new_password),
user_id,
)
return {"status": "updated"}
@router.post("/me/avatar", response_model=ProfileMeResponse)
async def upload_my_avatar(
file: UploadFile = File(...),
user_id: str = Depends(get_current_user_id),
) -> ProfileMeResponse:
raw = await file.read()
settings = get_settings()
base = settings.avatar_storage_path
try:
ext = process_and_store_avatar(base, user_id, raw, file.filename or "avatar.jpg")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
update users
set avatar_ext=$1, avatar_updated_at=now()
where id=$2::uuid
""",
ext,
user_id,
)
row = await _fetch_user_row(conn, user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _me_payload(dict(row))
@router.delete("/me/avatar", response_model=ProfileMeResponse)
async def delete_my_avatar(user_id: str = Depends(get_current_user_id)) -> ProfileMeResponse:
settings = get_settings()
delete_avatar_files(settings.avatar_storage_path, user_id)
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"update users set avatar_ext=null, avatar_updated_at=null where id=$1::uuid",
user_id,
)
row = await _fetch_user_row(conn, user_id)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return _me_payload(dict(row))