v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
View File
+168
View File
@@ -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,
}
+33
View File
@@ -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,
)
)
+20
View File
@@ -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()
)
+138
View File
@@ -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]
+322
View File
@@ -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
+34
View File
@@ -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(),
)
+353
View File
@@ -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,
}
+94
View File
@@ -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