"""Пользователи: создание из внешней личности, ник, активная группа, профиль.""" from __future__ import annotations import re from datetime import datetime, timezone from pathlib import Path from sqlmodel import Session, select from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError from app.core.timeutil import utcnow from app.models import AuthIdentity, Faction, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _BIO_MAX = 500 # Аватары: файл на диске под settings.upload_dir/avatars, путь относительный. _AVATAR_SUBDIR = "avatars" _IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} def get_user(session: Session, user_id: int) -> User: user = session.get(User, user_id) if user is None: raise NotFoundError("Пользователь не найден.") return user def nickname_available(session: Session, nickname: str, exclude_user_id: int | None = None) -> bool: stmt = select(User).where(User.nickname == nickname) existing = session.exec(stmt).first() return existing is None or existing.id == exclude_user_id def search_users(session: Session, query: str, limit: int = 3) -> list[User]: """Подсказки по нику: активные игроки (role='player'), без учёта регистра. SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в Python (casefold). Пользователей десятки, полный проход дёшев; при росте до тысяч — заменить на индексированную колонку nickname_lower. Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту. """ q = (query or "").strip().casefold() if not q: return [] users = session.exec( select(User).where(User.role == "player", User.is_active == True) # noqa: E712 ).all() matched = [u for u in users if q in u.nickname.casefold()] matched.sort( key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold()) ) return matched[:limit] def nickname_format_ok(nickname: str) -> bool: """Ник подходит по формату (2–64, буквы/цифры/пробел/.-_).""" return bool(_NICK_RE.match((nickname or "").strip())) def _unique_nickname(session: Session, base: str) -> str: base = (base or "Игрок").strip()[:60] or "Игрок" candidate = base suffix = 1 while session.exec(select(User).where(User.nickname == candidate)).first() is not None: suffix += 1 candidate = f"{base} {suffix}" return candidate def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None: """Существующий пользователь по внешней личности (provider+external_id). Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому вход с любого устройства/после смены тега ведёт к одному и тому же пользователю. """ link = session.exec( select(AuthIdentity).where( AuthIdentity.provider == identity.provider, AuthIdentity.external_id == identity.external_id, ) ).first() return get_user(session, link.user_id) if link is not None else None def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User: user = User( nickname=nickname, role="player", auth_provider=identity.provider, telegram_id=identity.telegram_id, ) session.add(user) session.flush() session.add( AuthIdentity( user_id=user.id, # type: ignore[arg-type] provider=identity.provider, external_id=identity.external_id, ) ) session.commit() session.refresh(user) return user def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: """Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику).""" user = find_by_identity(session, identity) if user is not None: return user nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id) return _create_from_identity(session, identity, nickname) def register_from_identity( session: Session, identity: ExternalIdentity, nickname: str ) -> User: """Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram). В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий ник вызывает ошибку, чтобы пользователь выбрал другой. """ nickname = (nickname or "").strip() if not nickname_format_ok(nickname): raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") if not nickname_available(session, nickname): raise NicknameTakenError() return _create_from_identity(session, identity, nickname) def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not nickname_format_ok(new_nickname): raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") if not nickname_available(session, new_nickname, exclude_user_id=user.id): raise NicknameTakenError() user.nickname = new_nickname session.add(user) session.commit() session.refresh(user) return user def set_active_group(session: Session, user: User, group_id: int | None) -> User: if group_id is not None: from app.services import group_service # избегаем цикла импорта if group_service.get_membership(session, group_id, user.id) is None: raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.") user.active_group_id = group_id session.add(user) session.commit() session.refresh(user) return user # ─── Профиль: «о себе», аватар ──────────────────────────────────────────────── def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None: """URL аватара с кэш-бастингом по updated_at. None, если аватара нет. Один источник для build_me, публичного профиля и лидерборда — чтобы клиент подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" if not avatar_path: return None # В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает # его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL # (strftime('%s') читает его как UTC) — один аватар качался браузером дважды. version = 0 if updated_at is not None: aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc) version = int(aware.timestamp()) return f"/api/users/{user_id}/avatar?v={version}" def update_bio(session: Session, user: User, bio: str | None) -> User: bio = (bio or "").strip() if len(bio) > _BIO_MAX: raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.") user.bio = bio or None session.add(user) session.commit() session.refresh(user) return user _HISTORY_MODES = {"all", "best"} _HISTORY_DETAILS = {"compact", "full"} def update_history_prefs( session: Session, user: User, *, mode: str | None = None, detail: str | None = None ) -> User: """Витрина истории партий: что показывать и насколько подробно. None — не менять.""" if mode is not None: if mode not in _HISTORY_MODES: raise ValidationError("Неизвестный режим истории партий.") user.history_mode = mode if detail is not None: if detail not in _HISTORY_DETAILS: raise ValidationError("Неизвестная подробность истории партий.") user.history_detail = detail session.add(user) session.commit() session.refresh(user) return user def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User: """Любимая фракция — личный выбор игрока; None очищает выбор.""" if faction_id is not None and session.get(Faction, faction_id) is None: raise ValidationError("Такой фракции не существует.") user.favorite_faction_id = faction_id session.add(user) session.commit() session.refresh(user) return user def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]: """Прочитать загруженный файл с ограничением размера и убедиться, что это картинка. Читаем на байт больше лимита: так превышение видно, не загружая файл целиком. Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе правка лимита или списка форматов расходится по четырём роутерам.""" content = file.file.read(max_bytes + 1) if len(content) > max_bytes: raise ValidationError(limit_message) ext = sniff_image_ext(content) if ext is None: raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") return content, ext def sniff_image_ext(content: bytes) -> str | None: """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" if content.startswith(b"\x89PNG\r\n\x1a\n"): return "png" if content.startswith(b"\xff\xd8\xff"): return "jpg" if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP": return "webp" return None def avatar_media_type(ext: str) -> str: return _IMAGE_MEDIA.get(ext, "application/octet-stream") def avatar_abs_path(avatar_path: str | None) -> Path | None: """Абсолютный путь файла аватара (или None), для отдачи через FileResponse.""" if not avatar_path: return None return Path(settings.upload_dir) / avatar_path def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User: avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR avatars_dir.mkdir(parents=True, exist_ok=True) # Удаляем прежний файл (мог быть с другим расширением), затем пишем новый. old = avatar_abs_path(user.avatar_path) if old and old.exists(): try: old.unlink() except OSError: pass rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" (Path(settings.upload_dir) / rel).write_bytes(content) user.avatar_path = rel # Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path # не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа # кэш-бастер остаётся прежним, и браузер час показывает старую картинку. user.updated_at = utcnow() session.add(user) session.commit() session.refresh(user) return user def clear_avatar(session: Session, user: User) -> User: path = avatar_abs_path(user.avatar_path) if path and path.exists(): try: path.unlink() except OSError: pass user.avatar_path = None user.updated_at = utcnow() session.add(user) session.commit() session.refresh(user) return user def public_profile(session: Session, user_id: int) -> dict: """Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика.""" from app.services import stats_service # ленивый импорт (избегаем цикла) user = session.get(User, user_id) if user is None: raise NotFoundError("Пользователь не найден.") return { "user_id": user.id, "nickname": user.nickname, "bio": user.bio, "avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] "stats": stats_service.profile_stats(session, user_id, group_id=None), }