- 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 и не при с
81 lines
2.4 KiB
Python
81 lines
2.4 KiB
Python
"""Store originals and resized avatar images on disk (longest side 54 / 128 / 256)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageOps
|
|
|
|
AVATAR_SIZES: tuple[int, ...] = (54, 128, 256)
|
|
MAX_AVATAR_BYTES = 5 * 1024 * 1024
|
|
ALLOWED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp"})
|
|
|
|
|
|
def avatar_dir(base: Path, user_id: str) -> Path:
|
|
return base / user_id
|
|
|
|
|
|
def normalize_extension(filename: str) -> str:
|
|
ext = Path(filename).suffix.lower()
|
|
if ext not in ALLOWED_EXTENSIONS:
|
|
return ".jpg"
|
|
return ext
|
|
|
|
|
|
def delete_avatar_files(base: Path, user_id: str) -> None:
|
|
d = avatar_dir(base, user_id)
|
|
if d.is_dir():
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
|
|
def process_and_store_avatar(base: Path, user_id: str, raw: bytes, original_filename: str) -> str:
|
|
"""
|
|
Save original bytes and JPEG thumbnails for each size (square crop, center).
|
|
Returns stored original extension including dot (e.g. '.png').
|
|
"""
|
|
if len(raw) > MAX_AVATAR_BYTES:
|
|
raise ValueError("Avatar file is too large (max 5 MB).")
|
|
|
|
ext = normalize_extension(original_filename)
|
|
uid_dir = avatar_dir(base, user_id)
|
|
uid_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
original_path = uid_dir / f"original{ext}"
|
|
original_path.write_bytes(raw)
|
|
|
|
try:
|
|
im = Image.open(io.BytesIO(raw))
|
|
im.load()
|
|
except Exception as exc: # noqa: BLE001
|
|
raise ValueError("Invalid image file.") from exc
|
|
|
|
if im.mode not in ("RGB", "RGBA"):
|
|
im = im.convert("RGBA") if "A" in im.getbands() else im.convert("RGB")
|
|
|
|
for size in AVATAR_SIZES:
|
|
thumb = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS, centering=(0.5, 0.5))
|
|
out = thumb.convert("RGB")
|
|
out.save(uid_dir / f"{size}.jpg", "JPEG", quality=88, optimize=True)
|
|
|
|
return ext
|
|
|
|
|
|
def avatar_urls(user_id: str, avatar_ext: str | None, cache_key: str | None) -> dict[str, str | None]:
|
|
if not avatar_ext:
|
|
return {
|
|
"avatar_54": None,
|
|
"avatar_128": None,
|
|
"avatar_256": None,
|
|
"avatar_original": None,
|
|
}
|
|
v = f"?v={cache_key}" if cache_key else ""
|
|
b = f"/media/avatars/{user_id}"
|
|
return {
|
|
"avatar_54": f"{b}/54.jpg{v}",
|
|
"avatar_128": f"{b}/128.jpg{v}",
|
|
"avatar_256": f"{b}/256.jpg{v}",
|
|
"avatar_original": f"{b}/original{avatar_ext}{v}",
|
|
}
|