v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Админ: аутентификация (логин=ник + пароль) и управление сущностями."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError
|
||||
from app.core.security import verify_password
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
||||
|
||||
|
||||
def authenticate_admin(session: Session, username: str, password: str) -> User:
|
||||
user = session.exec(
|
||||
select(User).where(
|
||||
User.nickname == username,
|
||||
User.role == "admin",
|
||||
User.auth_provider == "local",
|
||||
User.is_active == True, # noqa: E712
|
||||
)
|
||||
).first()
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
raise InvalidCredentialsError()
|
||||
return user
|
||||
|
||||
|
||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||
|
||||
def list_users(session: Session, query: str | None = None) -> list[User]:
|
||||
stmt = select(User).order_by(User.created_at.desc())
|
||||
if query:
|
||||
stmt = stmt.where(User.nickname.contains(query))
|
||||
return list(session.exec(stmt).all())
|
||||
|
||||
|
||||
def update_user(session: Session, user_id: int, *, nickname: str | None = None, is_active: bool | None = None) -> User:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
if nickname is not None:
|
||||
user.nickname = nickname.strip()
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def delete_user(session: Session, user_id: int) -> None:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("Пользователь не найден.")
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_groups(session: Session) -> list[Group]:
|
||||
return list(session.exec(select(Group).order_by(Group.created_at.desc())).all())
|
||||
|
||||
|
||||
def delete_group(session: Session, group_id: int) -> None:
|
||||
group = session.get(Group, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError("Группа не найдена.")
|
||||
session.delete(group)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_matches(
|
||||
session: Session,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
group_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
faction_id: int | None = None,
|
||||
) -> list[tuple[Match, str]]:
|
||||
"""Возвращает партии вместе с названием группы (для группировки в админке).
|
||||
|
||||
Фильтры: по группе, по игроку-участнику, по фракции участника.
|
||||
"""
|
||||
stmt = (
|
||||
select(Match, Group.name)
|
||||
.join(Group, Group.id == Match.group_id)
|
||||
.order_by(Match.group_id, Match.played_at.desc(), Match.id.desc())
|
||||
)
|
||||
if group_id is not None:
|
||||
stmt = stmt.where(Match.group_id == group_id)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(
|
||||
Match.id.in_(
|
||||
select(MatchParticipant.match_id).where(MatchParticipant.user_id == user_id)
|
||||
)
|
||||
)
|
||||
if faction_id is not None:
|
||||
stmt = stmt.where(
|
||||
Match.id.in_(
|
||||
select(MatchParticipant.match_id).where(
|
||||
MatchParticipant.faction_id == faction_id
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
||||
return [(mt, gname) for mt, gname in rows]
|
||||
|
||||
|
||||
def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction:
|
||||
"""Переименовывает фракцию во всей системе (имя хранится один раз)."""
|
||||
name_ru = (name_ru or "").strip()
|
||||
if not (1 <= len(name_ru) <= 64):
|
||||
raise ValidationError("Название фракции: 1–64 символа.")
|
||||
faction = session.get(Faction, faction_id)
|
||||
if faction is None:
|
||||
raise NotFoundError("Фракция не найдена.")
|
||||
faction.name_ru = name_ru
|
||||
session.add(faction)
|
||||
session.commit()
|
||||
session.refresh(faction)
|
||||
return faction
|
||||
|
||||
|
||||
def delete_match(session: Session, match_id: int) -> None:
|
||||
match = session.get(Match, match_id)
|
||||
if match is None:
|
||||
raise NotFoundError("Партия не найдена.")
|
||||
session.delete(match)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ─── Журнал аудита ───────────────────────────────────────────────────────────
|
||||
|
||||
def list_audit_logs(
|
||||
session: Session,
|
||||
*,
|
||||
action: str | None = None,
|
||||
entity_type: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||
if action:
|
||||
stmt = stmt.where(AuditLog.action == action)
|
||||
if entity_type:
|
||||
stmt = stmt.where(AuditLog.entity_type == entity_type)
|
||||
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"actor_id": r.actor_id,
|
||||
"action": r.action,
|
||||
"entity_type": r.entity_type,
|
||||
"entity_id": r.entity_id,
|
||||
"payload": r.payload,
|
||||
"ip": r.ip,
|
||||
"user_agent": r.user_agent,
|
||||
"created_at": iso_utc(r.created_at),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Журнал аудита: запись действий, изменяющих состояние."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import AuditLog
|
||||
|
||||
|
||||
def record(
|
||||
session: Session,
|
||||
*,
|
||||
actor_id: int | None,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Добавляет запись аудита в сессию (commit — на стороне вызывающего)."""
|
||||
session.add(
|
||||
AuditLog(
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
payload=payload,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Справочники: дополнения и фракции."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Expansion, Faction
|
||||
|
||||
|
||||
def list_expansions(session: Session) -> list[Expansion]:
|
||||
return list(
|
||||
session.exec(select(Expansion).order_by(Expansion.sort_order)).all()
|
||||
)
|
||||
|
||||
|
||||
def list_factions(session: Session) -> list[Faction]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
|
||||
).all()
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Группы: создание, дополнения, доступные фракции, проверки доступа."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import NotFoundError, NotGroupMemberError, ForbiddenError, ValidationError
|
||||
from app.models import (
|
||||
Expansion,
|
||||
Faction,
|
||||
Group,
|
||||
GroupExpansion,
|
||||
GroupMember,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def get_group(session: Session, group_id: int) -> Group:
|
||||
group = session.get(Group, group_id)
|
||||
if group is None:
|
||||
raise NotFoundError("Группа не найдена.")
|
||||
return group
|
||||
|
||||
|
||||
def get_membership(session: Session, group_id: int, user_id: int) -> GroupMember | None:
|
||||
return session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
def assert_member(session: Session, group_id: int, user_id: int) -> GroupMember:
|
||||
member = get_membership(session, group_id, user_id)
|
||||
if member is None:
|
||||
raise NotGroupMemberError()
|
||||
return member
|
||||
|
||||
|
||||
def assert_owner(session: Session, group_id: int, user_id: int) -> GroupMember:
|
||||
member = assert_member(session, group_id, user_id)
|
||||
if member.role != "owner":
|
||||
raise ForbiddenError("Действие доступно только владельцу группы.")
|
||||
return member
|
||||
|
||||
|
||||
def list_user_groups(session: Session, user_id: int) -> list[tuple[Group, str]]:
|
||||
rows = session.exec(
|
||||
select(Group, GroupMember.role)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.where(GroupMember.user_id == user_id)
|
||||
.order_by(Group.name)
|
||||
).all()
|
||||
return [(g, role) for g, role in rows]
|
||||
|
||||
|
||||
def _valid_non_base_expansion_ids(session: Session, expansion_ids: list[int]) -> list[int]:
|
||||
if not expansion_ids:
|
||||
return []
|
||||
found = session.exec(
|
||||
select(Expansion.id).where(
|
||||
Expansion.id.in_(expansion_ids), Expansion.is_base == False # noqa: E712
|
||||
)
|
||||
).all()
|
||||
return list(found)
|
||||
|
||||
|
||||
def create_group(session: Session, owner: User, name: str, expansion_ids: list[int]) -> Group:
|
||||
name = (name or "").strip()
|
||||
if not (2 <= len(name) <= 64):
|
||||
raise ValidationError("Название группы: 2–64 символа.")
|
||||
|
||||
group = Group(name=name, owner_id=owner.id) # type: ignore[arg-type]
|
||||
session.add(group)
|
||||
session.flush()
|
||||
|
||||
session.add(GroupMember(group_id=group.id, user_id=owner.id, role="owner")) # type: ignore[arg-type]
|
||||
|
||||
for exp_id in _valid_non_base_expansion_ids(session, expansion_ids):
|
||||
session.add(GroupExpansion(group_id=group.id, expansion_id=exp_id)) # type: ignore[arg-type]
|
||||
|
||||
owner.active_group_id = group.id
|
||||
session.add(owner)
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def rename_group(session: Session, group: Group, name: str) -> Group:
|
||||
name = (name or "").strip()
|
||||
if not (2 <= len(name) <= 64):
|
||||
raise ValidationError("Название группы: 2–64 символа.")
|
||||
group.name = name
|
||||
session.add(group)
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
|
||||
valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
|
||||
current = session.exec(
|
||||
select(GroupExpansion).where(GroupExpansion.group_id == group.id)
|
||||
).all()
|
||||
current_ids = {ge.expansion_id for ge in current}
|
||||
|
||||
for ge in current:
|
||||
if ge.expansion_id not in valid:
|
||||
session.delete(ge)
|
||||
for exp_id in valid - current_ids:
|
||||
session.add(GroupExpansion(group_id=group.id, expansion_id=exp_id)) # type: ignore[arg-type]
|
||||
|
||||
session.commit()
|
||||
session.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
def group_expansion_ids(session: Session, group_id: int) -> list[int]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(GroupExpansion.expansion_id).where(GroupExpansion.group_id == group_id)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def available_factions(session: Session, group_id: int) -> list[Faction]:
|
||||
owned = select(GroupExpansion.expansion_id).where(GroupExpansion.group_id == group_id)
|
||||
stmt = (
|
||||
select(Faction)
|
||||
.join(Expansion, Expansion.id == Faction.expansion_id)
|
||||
.where(or_(Expansion.is_base == True, Expansion.id.in_(owned))) # noqa: E712
|
||||
.order_by(Expansion.sort_order, Faction.sort_order)
|
||||
)
|
||||
return list(session.exec(stmt).all())
|
||||
|
||||
|
||||
def available_faction_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {f.id for f in available_factions(session, group_id)} # type: ignore[misc]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Партии: рандом фракций, двухэтапный поток (старт → завершение), правка/удаление."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import (
|
||||
ConflictError,
|
||||
DuplicateParticipantError,
|
||||
FactionNotAvailableError,
|
||||
ForbiddenError,
|
||||
InvalidRankingError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.core.timeutil import app_today
|
||||
from app.models import Faction, GroupMember, Match, MatchParticipant, User
|
||||
from app.services import group_service
|
||||
|
||||
MAX_MATCH_PLAYERS = 6
|
||||
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RosterInput:
|
||||
"""Участник на этапе старта (мест ещё нет)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
was_random: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FinishInput:
|
||||
"""Результат участника на этапе завершения."""
|
||||
|
||||
user_id: int
|
||||
place: int
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticipantInput:
|
||||
"""Полный участник (для правки завершённой партии админом)."""
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def round_to_30(minutes: float) -> int:
|
||||
"""Округление длительности до получаса, минимум 30 минут."""
|
||||
return max(30, int(round(minutes / 30.0)) * 30)
|
||||
|
||||
|
||||
def get_match(session: Session, match_id: int) -> Match:
|
||||
match = session.get(Match, match_id)
|
||||
if match is None:
|
||||
raise NotFoundError("Партия не найдена.")
|
||||
return match
|
||||
|
||||
|
||||
def participants_detail(
|
||||
session: Session, match_id: int
|
||||
) -> list[tuple[MatchParticipant, User, Faction]]:
|
||||
rows = session.exec(
|
||||
select(MatchParticipant, User, Faction)
|
||||
.join(User, User.id == MatchParticipant.user_id)
|
||||
.join(Faction, Faction.id == MatchParticipant.faction_id)
|
||||
.where(MatchParticipant.match_id == match_id)
|
||||
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
|
||||
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
|
||||
).all()
|
||||
return [(p, u, f) for p, u, f in rows]
|
||||
|
||||
|
||||
def randomize_faction(
|
||||
session: Session, group_id: int, exclude_faction_ids: list[int] | None = None
|
||||
) -> Faction:
|
||||
exclude = set(exclude_faction_ids or [])
|
||||
pool = [f for f in group_service.available_factions(session, group_id) if f.id not in exclude]
|
||||
if not pool:
|
||||
raise ValidationError("Нет доступных фракций для рандома.")
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
def _validate_ranking(places: list[int]) -> None:
|
||||
"""Проверяет competition ranking (1,2,2,4) с допуском ничьих."""
|
||||
if any(p < 1 for p in places):
|
||||
raise InvalidRankingError("Место должно быть ≥ 1.")
|
||||
ordered = sorted(places)
|
||||
expected = 1
|
||||
i = 0
|
||||
n = len(ordered)
|
||||
while i < n:
|
||||
current = ordered[i]
|
||||
if current != expected:
|
||||
raise InvalidRankingError(
|
||||
"Места должны идти по правилу 1,2,2,4 (без пропусков перед группой ничьих)."
|
||||
)
|
||||
tie = 0
|
||||
while i < n and ordered[i] == current:
|
||||
tie += 1
|
||||
i += 1
|
||||
expected = current + tie
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {
|
||||
m.user_id
|
||||
for m in session.exec(
|
||||
select(GroupMember).where(GroupMember.group_id == group_id)
|
||||
).all()
|
||||
}
|
||||
|
||||
|
||||
def _validate_roster_basics(
|
||||
session: Session,
|
||||
group_id: int,
|
||||
user_ids: list[int],
|
||||
faction_ids: list[int],
|
||||
) -> None:
|
||||
if len(user_ids) < 2:
|
||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
|
||||
raise ValidationError("Все участники должны состоять в группе.")
|
||||
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
|
||||
# ─── Этап 1: старт партии ─────────────────────────────────────────────────────
|
||||
|
||||
def create_match(
|
||||
session: Session,
|
||||
creator: User,
|
||||
*,
|
||||
group_id: int,
|
||||
roster: list[RosterInput],
|
||||
) -> Match:
|
||||
group_service.assert_member(session, group_id, creator.id) # type: ignore[arg-type]
|
||||
_validate_roster_basics(
|
||||
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
|
||||
)
|
||||
|
||||
now = _utcnow()
|
||||
match = Match(
|
||||
group_id=group_id,
|
||||
status="in_progress",
|
||||
played_at=app_today(), # дата игры — в поясе приложения (+3)
|
||||
started_at=now,
|
||||
player_count=len(roster),
|
||||
created_by=creator.id, # type: ignore[arg-type]
|
||||
)
|
||||
session.add(match)
|
||||
session.flush()
|
||||
|
||||
for r in roster:
|
||||
session.add(
|
||||
MatchParticipant(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=r.user_id,
|
||||
faction_id=r.faction_id,
|
||||
place=None,
|
||||
was_random=r.was_random,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
# ─── Этап 2: завершение партии ────────────────────────────────────────────────
|
||||
|
||||
def finish_match(
|
||||
session: Session,
|
||||
match: Match,
|
||||
*,
|
||||
finish: list[FinishInput],
|
||||
win_reason: str,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
) -> Match:
|
||||
if match.status != "in_progress":
|
||||
raise ConflictError("Партия уже завершена.")
|
||||
if win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Укажите корректную причину победы.")
|
||||
|
||||
existing = {
|
||||
p.user_id: p
|
||||
for p in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all()
|
||||
}
|
||||
if {f.user_id for f in finish} != set(existing.keys()):
|
||||
raise ValidationError("Нужно указать результат по всем участникам партии.")
|
||||
|
||||
# Эффективные фракции (с учётом возможной замены при завершении).
|
||||
eff_factions = {
|
||||
f.user_id: (f.faction_id if f.faction_id is not None else existing[f.user_id].faction_id)
|
||||
for f in finish
|
||||
}
|
||||
fids = list(eff_factions.values())
|
||||
if len(set(fids)) != len(fids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
_validate_ranking([f.place for f in finish])
|
||||
|
||||
for f in finish:
|
||||
p = existing[f.user_id]
|
||||
p.place = f.place
|
||||
p.comment = f.comment or None
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
session.add(p)
|
||||
|
||||
now = _utcnow()
|
||||
match.finished_at = now
|
||||
started = match.started_at
|
||||
if started is not None:
|
||||
if started.tzinfo is not None:
|
||||
started = started.replace(tzinfo=None)
|
||||
# max(0, ...) на случай перекоса часов — не уходим в отрицательную длительность.
|
||||
elapsed_min = max(0.0, (now.replace(tzinfo=None) - started).total_seconds() / 60.0)
|
||||
match.duration_minutes = round_to_30(elapsed_min)
|
||||
match.status = "finished"
|
||||
match.win_reason = win_reason
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
|
||||
session.add(match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
# ─── Права / правка / удаление ────────────────────────────────────────────────
|
||||
|
||||
def can_modify(session: Session, match: Match, user: User) -> bool:
|
||||
# Управление партиями доступно любому участнику группы (а также админу).
|
||||
if user.role == "admin":
|
||||
return True
|
||||
member = group_service.get_membership(session, match.group_id, user.id) # type: ignore[arg-type]
|
||||
return member is not None
|
||||
|
||||
|
||||
def assert_can_modify(session: Session, match: Match, user: User) -> None:
|
||||
if not can_modify(session, match, user):
|
||||
raise ForbiddenError("Недостаточно прав для изменения партии.")
|
||||
|
||||
|
||||
def update_match(
|
||||
session: Session,
|
||||
match: Match,
|
||||
*,
|
||||
played_at: date | None = None,
|
||||
overall_comment: str | None = None,
|
||||
overall_comment_set: bool = False,
|
||||
win_reason: str | None = None,
|
||||
win_reason_set: bool = False,
|
||||
participants: list[ParticipantInput] | None = None,
|
||||
) -> Match:
|
||||
"""Правка завершённой партии (админ): полный список участников с местами."""
|
||||
if played_at is not None:
|
||||
match.played_at = played_at
|
||||
if overall_comment_set:
|
||||
match.overall_comment = overall_comment or None
|
||||
if win_reason_set:
|
||||
if win_reason is not None and win_reason not in WIN_REASONS:
|
||||
raise ValidationError("Некорректная причина победы.")
|
||||
match.win_reason = win_reason
|
||||
|
||||
if participants is not None:
|
||||
_validate_roster_basics(
|
||||
session,
|
||||
match.group_id,
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
)
|
||||
_validate_ranking([p.place for p in participants])
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
for p in participants:
|
||||
session.add(
|
||||
MatchParticipant(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
)
|
||||
)
|
||||
match.player_count = len(participants)
|
||||
|
||||
session.add(match)
|
||||
session.commit()
|
||||
session.refresh(match)
|
||||
return match
|
||||
|
||||
|
||||
def delete_match(session: Session, match: Match) -> None:
|
||||
session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE)
|
||||
session.commit()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Членство в группе: список, добавление по нику, удаление, смена роли."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
from app.models import Group, GroupMember, User
|
||||
|
||||
MAX_GROUP_SIZE = 10
|
||||
|
||||
|
||||
def list_members(session: Session, group_id: int) -> list[tuple[GroupMember, User]]:
|
||||
rows = session.exec(
|
||||
select(GroupMember, User)
|
||||
.join(User, User.id == GroupMember.user_id)
|
||||
.where(GroupMember.group_id == group_id)
|
||||
.order_by(GroupMember.role.desc(), User.nickname)
|
||||
).all()
|
||||
return [(m, u) for m, u in rows]
|
||||
|
||||
|
||||
def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tuple[GroupMember, User]:
|
||||
nickname = (nickname or "").strip()
|
||||
if not nickname:
|
||||
raise ValidationError("Укажите никнейм игрока.")
|
||||
user = session.exec(select(User).where(User.nickname == nickname)).first()
|
||||
if user is None:
|
||||
raise NotFoundError("Игрок с таким ником не найден.")
|
||||
|
||||
member_count = len(
|
||||
session.exec(select(GroupMember.id).where(GroupMember.group_id == group.id)).all()
|
||||
)
|
||||
if member_count >= MAX_GROUP_SIZE:
|
||||
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
|
||||
|
||||
existing = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
raise ConflictError("Игрок уже в группе.")
|
||||
|
||||
member = GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
return member, user
|
||||
|
||||
|
||||
def remove_member(session: Session, group: Group, user_id: int) -> None:
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
if member.role == "owner":
|
||||
owners = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.role == "owner"
|
||||
)
|
||||
).all()
|
||||
if len(owners) <= 1:
|
||||
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
|
||||
|
||||
# Сбросить активную группу у тех, для кого она была активной.
|
||||
user = session.get(User, user_id)
|
||||
if user is not None and user.active_group_id == group.id:
|
||||
user.active_group_id = None
|
||||
session.add(user)
|
||||
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
|
||||
|
||||
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
|
||||
if role not in ("owner", "member"):
|
||||
raise ValidationError("Недопустимая роль.")
|
||||
member = session.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if member is None:
|
||||
raise NotFoundError("Игрок не состоит в группе.")
|
||||
member.role = role
|
||||
session.add(member)
|
||||
session.commit()
|
||||
session.refresh(member)
|
||||
return member
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Метрика рейтинга. Вынесена отдельно — легко заменить.
|
||||
|
||||
По умолчанию: League Points — нормированные очки за место с учётом размера стола
|
||||
и ничьих (competition ranking). За партию из N игроков:
|
||||
points = (N - place - (tie_size - 1)/2) / (N - 1)
|
||||
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
|
||||
Рейтинговый счёт игрока = AVG(points) * 100.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
||||
MIN_GAMES = 3
|
||||
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
||||
FACTION_MIN_GAMES = 2
|
||||
|
||||
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count,
|
||||
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
|
||||
MATCH_POINTS_SQL = (
|
||||
"CASE WHEN m.player_count > 1 "
|
||||
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
|
||||
"/ (m.player_count - 1) "
|
||||
"ELSE 1.0 END"
|
||||
)
|
||||
|
||||
|
||||
def leaderboard_sort_key(row: dict) -> tuple:
|
||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||
return (
|
||||
-(row["score"] or 0.0),
|
||||
-(row["win_rate"] or 0.0),
|
||||
-(row["games"] or 0),
|
||||
(row["avg_place"] or 0.0),
|
||||
row["nickname"].lower(),
|
||||
)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import Group, GroupMember, Match
|
||||
from app.services import group_service
|
||||
from app.services.scoring import (
|
||||
FACTION_MIN_GAMES,
|
||||
MATCH_POINTS_SQL,
|
||||
MIN_GAMES,
|
||||
leaderboard_sort_key,
|
||||
)
|
||||
|
||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
|
||||
SCORED_CTE = f"""
|
||||
WITH tie AS (
|
||||
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
WHERE m.status = 'finished' AND mp.place IS NOT NULL
|
||||
GROUP BY mp.match_id, mp.place
|
||||
),
|
||||
scored AS (
|
||||
SELECT mp.user_id AS user_id,
|
||||
mp.faction_id AS faction_id,
|
||||
m.id AS match_id,
|
||||
m.group_id AS group_id,
|
||||
m.played_at AS played_at,
|
||||
mp.place AS place,
|
||||
m.player_count AS player_count,
|
||||
({MATCH_POINTS_SQL}) AS points,
|
||||
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
|
||||
FROM match_participants mp
|
||||
JOIN matches m ON m.id = mp.match_id
|
||||
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
|
||||
WHERE m.status = 'finished'
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _round(value: Any, ndigits: int) -> float | None:
|
||||
return None if value is None else round(float(value), ndigits)
|
||||
|
||||
|
||||
def _normalize(row: dict) -> dict:
|
||||
return {
|
||||
"user_id": row["user_id"],
|
||||
"nickname": row["nickname"],
|
||||
"games": int(row["games"] or 0),
|
||||
"wins": int(row["wins"] or 0),
|
||||
"win_rate": _round(row["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(row["avg_place"], 2),
|
||||
"score": _round(row["score"], 1),
|
||||
}
|
||||
|
||||
|
||||
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
|
||||
where = "WHERE s.group_id = :gid" if group_id is not None else ""
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT u.id AS user_id, u.nickname AS nickname,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
{where}
|
||||
GROUP BY u.id, u.nickname
|
||||
"""
|
||||
params = {"gid": group_id} if group_id is not None else {}
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
return [_normalize(dict(r)) for r in result]
|
||||
|
||||
|
||||
def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
||||
rows = _leaderboard_rows(session, group_id)
|
||||
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
|
||||
provisional = [r for r in rows if r["games"] < MIN_GAMES]
|
||||
qualified.sort(key=leaderboard_sort_key)
|
||||
provisional.sort(key=leaderboard_sort_key)
|
||||
for i, r in enumerate(qualified, start=1):
|
||||
r["rank"] = i
|
||||
for r in provisional:
|
||||
r["rank"] = None
|
||||
return {
|
||||
"entries": qualified,
|
||||
"provisional": provisional,
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
{cond}
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
r = session.execute(text(sql), params).mappings().first() or {}
|
||||
return {
|
||||
"games": int(r.get("games") or 0),
|
||||
"wins": int(r.get("wins") or 0),
|
||||
"win_rate": _round(r.get("win_rate") or 0.0, 4),
|
||||
"avg_place": _round(r.get("avg_place"), 2),
|
||||
"score": _round(r.get("score"), 1),
|
||||
}
|
||||
|
||||
|
||||
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
e.code AS expansion_code,
|
||||
COUNT(*) AS games,
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score
|
||||
FROM scored s
|
||||
JOIN factions f ON f.id = s.faction_id
|
||||
JOIN expansions e ON e.id = f.expansion_id
|
||||
{cond}
|
||||
GROUP BY f.id, f.code, f.name_ru, e.code
|
||||
ORDER BY games DESC, score DESC
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
out = []
|
||||
for r in result:
|
||||
out.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"expansion_code": r["expansion_code"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(r["avg_place"], 2),
|
||||
"score": _round(r["score"], 1),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
|
||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
|
||||
FROM scored s
|
||||
{cond}
|
||||
ORDER BY s.played_at DESC, s.match_id DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
params: dict[str, Any] = {"uid": user_id, "lim": limit}
|
||||
if group_id is not None:
|
||||
params["gid"] = group_id
|
||||
result = session.execute(text(sql), params).mappings().all()
|
||||
return [
|
||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
||||
for r in result
|
||||
]
|
||||
|
||||
|
||||
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
||||
overall = _overall_for_user(session, user_id, group_id)
|
||||
factions = _faction_breakdown(session, user_id, group_id)
|
||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
||||
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
most_played = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall": overall,
|
||||
"factions": factions,
|
||||
"best_faction": best,
|
||||
"worst_faction": worst,
|
||||
"most_played_faction": most_played,
|
||||
"recent_form": _recent_form(session, user_id, group_id),
|
||||
}
|
||||
|
||||
|
||||
def group_stats(session: Session, group_id: int) -> dict:
|
||||
board = leaderboard(session, group_id=group_id)
|
||||
total_matches = session.exec(
|
||||
select(Match).where(Match.group_id == group_id, Match.status == "finished")
|
||||
).all()
|
||||
last_at = None
|
||||
if total_matches:
|
||||
last_at = str(max(m.played_at for m in total_matches))
|
||||
|
||||
available_ids = group_service.available_faction_ids(session, group_id)
|
||||
faction_meta = []
|
||||
sql = f"""
|
||||
{SCORED_CTE}
|
||||
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
|
||||
FROM factions f
|
||||
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
|
||||
GROUP BY f.id, f.code, f.name_ru
|
||||
ORDER BY games DESC, f.sort_order
|
||||
"""
|
||||
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
|
||||
for r in rows:
|
||||
faction_meta.append(
|
||||
{
|
||||
"faction_id": r["faction_id"],
|
||||
"code": r["code"],
|
||||
"name_ru": r["name_ru"],
|
||||
"games": int(r["games"] or 0),
|
||||
"wins": int(r["wins"] or 0),
|
||||
"available": r["faction_id"] in available_ids,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(total_matches),
|
||||
"last_match_at": last_at,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
"faction_meta": faction_meta,
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
|
||||
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
|
||||
matches = session.exec(
|
||||
select(Match)
|
||||
.where(Match.group_id == group_id)
|
||||
.order_by(Match.played_at.desc(), Match.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
items = []
|
||||
for m in matches:
|
||||
parts = []
|
||||
for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type]
|
||||
parts.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"status": m.status,
|
||||
"played_at": str(m.played_at),
|
||||
"started_at": iso_utc(m.started_at),
|
||||
"finished_at": iso_utc(m.finished_at),
|
||||
"duration_minutes": m.duration_minutes,
|
||||
"win_reason": m.win_reason,
|
||||
"player_count": m.player_count,
|
||||
"overall_comment": m.overall_comment,
|
||||
"created_by": m.created_by,
|
||||
"participants": parts,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
|
||||
from app.services.match_service import participants_detail # избегаем цикла импорта
|
||||
|
||||
group_ids = list(
|
||||
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
|
||||
)
|
||||
if not group_ids:
|
||||
return []
|
||||
rows = session.exec(
|
||||
select(Match, Group.name)
|
||||
.join(Group, Group.id == Match.group_id)
|
||||
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
|
||||
.order_by(Match.started_at.desc(), Match.id.desc())
|
||||
).all()
|
||||
out = []
|
||||
for m, gname in rows:
|
||||
parts = [
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
|
||||
]
|
||||
out.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"group_id": m.group_id,
|
||||
"group_name": gname,
|
||||
"started_at": iso_utc(m.started_at),
|
||||
"player_count": m.player_count,
|
||||
"participants": parts,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||
board = leaderboard(session, group_id=None)
|
||||
profile = profile_stats(session, user_id, group_id=None)
|
||||
active_group_brief = None
|
||||
if active_group_id is not None:
|
||||
group = session.get(Group, active_group_id)
|
||||
if group is not None:
|
||||
active_group_brief = {
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
**_overall_for_user(session, user_id, active_group_id),
|
||||
}
|
||||
return {
|
||||
"leaderboard": board["entries"][:leaderboard_limit],
|
||||
"provisional": board["provisional"][:leaderboard_limit],
|
||||
"profile": profile,
|
||||
"active_group": active_group_brief,
|
||||
"in_progress": user_in_progress_matches(session, user_id),
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Пользователи: создание из внешней личности, ник, активная группа."""
|
||||
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 _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 get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User:
|
||||
link = session.exec(
|
||||
select(AuthIdentity).where(
|
||||
AuthIdentity.provider == identity.provider,
|
||||
AuthIdentity.external_id == identity.external_id,
|
||||
)
|
||||
).first()
|
||||
if link is not None:
|
||||
return get_user(session, link.user_id)
|
||||
|
||||
user = User(
|
||||
nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id),
|
||||
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 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
|
||||
Reference in New Issue
Block a user