"""Пользователи: создание из внешней личности, ник, активная группа.""" from __future__ import annotations import re from sqlmodel import Session, select from app.auth.provider import ExternalIdentity from app.core.errors import NicknameTakenError, NotFoundError, ValidationError from app.models import AuthIdentity, GroupMember, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) 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 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 _NICK_RE.match(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: member = session.exec( select(GroupMember).where( GroupMember.group_id == group_id, GroupMember.user_id == user.id ) ).first() if member is None: raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.") user.active_group_id = group_id session.add(user) session.commit() session.refresh(user) return user