- 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 и не при с
223 lines
8.0 KiB
Python
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))
|