Ревью кода (#8) #34

Merged
NotBigGhost merged 7 commits from issue-8-code-review into dev 2026-09-09 19:06:54 +03:00
6 changed files with 129 additions and 132 deletions
Showing only changes of commit 8ac2757cd6 - Show all commits
+23 -23
View File
@@ -4,7 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timezone from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
@@ -23,9 +23,9 @@ from sqlalchemy import (
) )
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutil import utcnow
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
# ─── Справочники: дополнения и фракции ─────────────────────────────────────── # ─── Справочники: дополнения и фракции ───────────────────────────────────────
@@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True):
name_ru: str = Field(sa_column=Column(String(64), nullable=False)) name_ru: str = Field(sa_column=Column(String(64), nullable=False))
is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
sort_order: int = Field(default=0, nullable=False) sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
class Faction(SQLModel, table=True): class Faction(SQLModel, table=True):
@@ -56,7 +56,7 @@ class Faction(SQLModel, table=True):
) )
) )
sort_order: int = Field(default=0, nullable=False) sort_order: int = Field(default=0, nullable=False)
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Пользователи и идентичности ───────────────────────────────────────────── # ─── Пользователи и идентичности ─────────────────────────────────────────────
@@ -125,10 +125,10 @@ class User(SQLModel, table=True):
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап). # (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True)) title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": _utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
@@ -150,7 +150,7 @@ class UserAchievement(SQLModel, table=True):
) )
) )
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False)) achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=_utcnow, nullable=False) earned_at: datetime = Field(default_factory=utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True): class AuthIdentity(SQLModel, table=True):
@@ -167,7 +167,7 @@ class AuthIdentity(SQLModel, table=True):
) )
provider: str = Field(sa_column=Column(String(16), nullable=False)) provider: str = Field(sa_column=Column(String(16), nullable=False))
external_id: str = Field(sa_column=Column(String(64), nullable=False)) external_id: str = Field(sa_column=Column(String(64), nullable=False))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Группы и членство ─────────────────────────────────────────────────────── # ─── Группы и членство ───────────────────────────────────────────────────────
@@ -182,10 +182,10 @@ class Group(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": _utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
@@ -209,7 +209,7 @@ class GroupMember(SQLModel, table=True):
) )
) )
role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member")) role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member"))
joined_at: datetime = Field(default_factory=_utcnow, nullable=False) joined_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupInvitation(SQLModel, table=True): class GroupInvitation(SQLModel, table=True):
@@ -237,7 +237,7 @@ class GroupInvitation(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
class GroupExpansion(SQLModel, table=True): class GroupExpansion(SQLModel, table=True):
@@ -257,7 +257,7 @@ class GroupExpansion(SQLModel, table=True):
Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Партии и участники ────────────────────────────────────────────────────── # ─── Партии и участники ──────────────────────────────────────────────────────
@@ -295,10 +295,10 @@ class Match(SQLModel, table=True):
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True
) )
) )
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, default_factory=utcnow,
sa_column_kwargs={"onupdate": _utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
@@ -333,7 +333,7 @@ class MatchParticipant(SQLModel, table=True):
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
class MatchAttachment(SQLModel, table=True): class MatchAttachment(SQLModel, table=True):
@@ -359,7 +359,7 @@ class MatchAttachment(SQLModel, table=True):
storage_path: str = Field(sa_column=Column(String(255), nullable=False)) storage_path: str = Field(sa_column=Column(String(255), nullable=False))
mime_type: str = Field(sa_column=Column(String(64), nullable=False)) mime_type: str = Field(sa_column=Column(String(64), nullable=False))
size_bytes: int = Field(sa_column=Column(Integer, nullable=False)) size_bytes: int = Field(sa_column=Column(Integer, nullable=False))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Уведомления ───────────────────────────────────────────────────────────── # ─── Уведомления ─────────────────────────────────────────────────────────────
@@ -387,7 +387,7 @@ class Notification(SQLModel, table=True):
body: str | None = Field(default=None, sa_column=Column(Text, nullable=True)) body: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True)) link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False, index=True) created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -411,7 +411,7 @@ class AuditLog(SQLModel, table=True):
payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True)) payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
ip: str | None = Field(sa_column=Column(String(45), nullable=True)) ip: str | None = Field(sa_column=Column(String(45), nullable=True))
user_agent: str | None = Field(sa_column=Column(String(256), nullable=True)) user_agent: str | None = Field(sa_column=Column(String(256), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно): # Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно):
+6 -11
View File
@@ -11,7 +11,6 @@ from sqlmodel import Session, select
from app.core.config import settings from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError from app.core.errors import ConflictError, NotFoundError
from app.core.timeutil import utcnow
from app.models import Match, MatchAttachment, User from app.models import Match, MatchAttachment, User
MAX_ATTACHMENTS = 10 MAX_ATTACHMENTS = 10
@@ -40,17 +39,11 @@ def file_path(att: MatchAttachment) -> Path:
return Path(settings.upload_dir) / att.storage_path return Path(settings.upload_dir) / att.storage_path
def _touch(session: Session, match: Match) -> None:
"""Двинуть версию партии: набор вложений виден в MatchRead, а сама строка matches
при работе с ними не меняется — без этого оптимистичная блокировка проспала бы
конкурентную правку."""
match.updated_at = utcnow()
session.add(match)
def add_photo( def add_photo(
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
) -> MatchAttachment: ) -> MatchAttachment:
from app.services import match_service # избегаем цикла импорта
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type] if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.") raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
att = MatchAttachment( att = MatchAttachment(
@@ -69,7 +62,7 @@ def add_photo(
abs_path.write_bytes(content) abs_path.write_bytes(content)
att.storage_path = rel att.storage_path = rel
session.add(att) session.add(att)
_touch(session, match) match_service.touch(session, match)
session.commit() session.commit()
session.refresh(att) session.refresh(att)
return att return att
@@ -83,6 +76,8 @@ def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachme
def delete(session: Session, match: Match, att_id: int) -> None: def delete(session: Session, match: Match, att_id: int) -> None:
from app.services import match_service # избегаем цикла импорта
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type] att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
path = file_path(att) path = file_path(att)
if path.exists(): if path.exists():
@@ -91,7 +86,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
except OSError: except OSError:
pass pass
session.delete(att) session.delete(att)
_touch(session, match) match_service.touch(session, match)
session.commit() session.commit()
+7 -6
View File
@@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.errors import ConflictError, NotFoundError, ValidationError from app.core.errors import ConflictError, NotFoundError, ValidationError
@@ -70,17 +71,17 @@ def create_invitation(
def list_for_user(session: Session, user_id: int) -> list[dict]: def list_for_user(session: Session, user_id: int) -> list[dict]:
inviter = aliased(User)
rows = session.exec( rows = session.exec(
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id) select(GroupInvitation, Group.name, inviter.nickname)
.join(Group, Group.id == GroupInvitation.group_id)
# LEFT JOIN: пригласивший мог быть удалён (invited_by_id → SET NULL).
.join(inviter, inviter.id == GroupInvitation.invited_by_id, isouter=True)
.where(GroupInvitation.user_id == user_id) .where(GroupInvitation.user_id == user_id)
.order_by(GroupInvitation.created_at.desc()) .order_by(GroupInvitation.created_at.desc())
).all() ).all()
out = [] out = []
for inv, gname in rows: for inv, gname, inviter_nick in rows:
inviter_nick = None
if inv.invited_by_id is not None:
inviter = session.get(User, inv.invited_by_id)
inviter_nick = inviter.nickname if inviter else None
out.append( out.append(
{ {
"id": inv.id, "id": inv.id,
+16 -13
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import random import random
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -16,7 +16,7 @@ from app.core.errors import (
NotFoundError, NotFoundError,
ValidationError, ValidationError,
) )
from app.core.timeutil import app_today, iso_utc from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchParticipant, User from app.models import Faction, GroupMember, Match, MatchParticipant, User
from app.services import group_service from app.services import group_service
@@ -56,10 +56,6 @@ class ParticipantInput:
comment: str | None = None comment: str | None = None
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def round_to_30(minutes: float) -> int: def round_to_30(minutes: float) -> int:
"""Округление длительности до получаса, минимум 30 минут.""" """Округление длительности до получаса, минимум 30 минут."""
return max(30, int(round(minutes / 30.0)) * 30) return max(30, int(round(minutes / 30.0)) * 30)
@@ -77,6 +73,17 @@ def match_version(match: Match) -> str:
return iso_utc(match.updated_at) return iso_utc(match.updated_at)
def touch(session: Session, match: Match) -> None:
"""Пометить партию изменённой (без commit).
Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy
срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь
связанные сущности (участники, вложения), строку не трогают, поэтому каждая
такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча."""
match.updated_at = utcnow()
session.add(match)
def assert_version(match: Match, expected: str | None) -> None: def assert_version(match: Match, expected: str | None) -> None:
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию).""" """Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
if expected is not None and expected != match_version(match): if expected is not None and expected != match_version(match):
@@ -188,7 +195,7 @@ def create_match(
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster] session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
) )
now = _utcnow() now = utcnow()
match = Match( match = Match(
group_id=group_id, group_id=group_id,
status="in_progress", status="in_progress",
@@ -264,7 +271,7 @@ def finish_match(
p.faction_id = f.faction_id p.faction_id = f.faction_id
session.add(p) session.add(p)
now = _utcnow() now = utcnow()
match.finished_at = now match.finished_at = now
started = match.started_at started = match.started_at
if started is not None: if started is not None:
@@ -351,11 +358,7 @@ def update_match(
) )
match.player_count = len(participants) match.player_count = len(participants)
# Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE touch(session, match)
# строки matches. Правка одних участников его не вызывает, и тогда оптимистичная
# блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно.
match.updated_at = _utcnow()
session.add(match)
session.commit() session.commit()
session.refresh(match) session.refresh(match)
return match return match
+24 -33
View File
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.models import Group, GroupMember, User from app.models import Group, GroupMember, User
from app.services import group_service
MAX_GROUP_SIZE = 10 MAX_GROUP_SIZE = 10
@@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
if member_count >= MAX_GROUP_SIZE: if member_count >= MAX_GROUP_SIZE:
raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).") raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).")
existing = session.exec( existing = group_service.get_membership(session, group.id, user.id)
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user.id
)
).first()
if existing is not None: if existing is not None:
raise ConflictError("Игрок уже в группе.") raise ConflictError("Игрок уже в группе.")
@@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup
return member, user return member, user
def remove_member(session: Session, group: Group, user_id: int) -> None: def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None:
member = session.exec( """Группа без владельца неисправима: назначить нового становится некому."""
select(GroupMember).where( if member.role != "owner":
GroupMember.group_id == group.id, GroupMember.user_id == user_id return
owners = session.exec(
select(GroupMember.id).where(
GroupMember.group_id == group.id, GroupMember.role == "owner"
) )
).first() ).all()
if len(owners) <= 1:
raise ForbiddenError(message)
def remove_member(session: Session, group: Group, user_id: int) -> None:
member = group_service.get_membership(session, group.id, user_id)
if member is None: if member is None:
raise NotFoundError("Игрок не состоит в группе.") raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner": _assert_not_last_owner(
owners = session.exec( session, group, member, "Нельзя удалить последнего владельца группы."
select(GroupMember).where( )
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).all()
if len(owners) <= 1:
raise ForbiddenError("Нельзя удалить последнего владельца группы.")
# Сбросить активную группу у тех, для кого она была активной. # Сбросить активную группу у тех, для кого она была активной.
user = session.get(User, user_id) user = session.get(User, user_id)
@@ -78,23 +79,13 @@ def remove_member(session: Session, group: Group, user_id: int) -> None:
def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember: def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember:
if role not in ("owner", "member"): if role not in ("owner", "member"):
raise ValidationError("Недопустимая роль.") raise ValidationError("Недопустимая роль.")
member = session.exec( member = group_service.get_membership(session, group.id, user_id)
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.user_id == user_id
)
).first()
if member is None: if member is None:
raise NotFoundError("Игрок не состоит в группе.") raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner" and role != "owner": if role != "owner":
# Без этого единственный владелец мог разжаловать сам себя, и группа _assert_not_last_owner(
# оставалась без владельца навсегда: назначить нового уже некому. session, group, member, "Нельзя снять роль с последнего владельца группы."
owners = session.exec( )
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).all()
if len(owners) <= 1:
raise ForbiddenError("Нельзя снять роль с последнего владельца группы.")
member.role = role member.role = role
session.add(member) session.add(member)
session.commit() session.commit()
+53 -46
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import func, text
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
@@ -235,12 +235,13 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
def group_stats(session: Session, group_id: int) -> dict: def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id) board = leaderboard(session, group_id=group_id)
total_matches = session.exec( # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
select(Match).where(Match.group_id == group_id, Match.status == "finished") games_count, last_played = session.exec(
).all() select(func.count(), func.max(Match.played_at)).where(
last_at = None Match.group_id == group_id, Match.status == "finished"
if total_matches: )
last_at = str(max(m.played_at for m in total_matches)) ).one()
last_at = str(last_played) if last_played else None
available_ids = group_service.available_faction_ids(session, group_id) available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = [] faction_meta = []
@@ -290,7 +291,7 @@ def group_stats(session: Session, group_id: int) -> dict:
return { return {
"group_id": group_id, "group_id": group_id,
"total_matches": len(total_matches), "total_matches": games_count,
"last_match_at": last_at, "last_match_at": last_at,
"leaderboard": board["entries"], "leaderboard": board["entries"],
"provisional": board["provisional"], "provisional": board["provisional"],
@@ -300,26 +301,44 @@ def group_stats(session: Session, group_id: int) -> dict:
} }
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
return {
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]:
"""Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1)."""
if not match_ids:
return {}
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.in_(match_ids))
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
).all()
out: dict[int, list[dict]] = {}
for p, u, f in rows:
out.setdefault(p.match_id, []).append(_participant_row(p, u, f))
return out
def _match_items(session: Session, matches) -> list[dict]: def _match_items(session: Session, matches) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока).""" """Элементы списка партий (общее для списка группы и истории игрока)."""
from app.services.match_service import participants_detail # избегаем цикла импорта by_match = _participants_by_match(session, [m.id for m in matches])
items = [] items = []
for m in matches: for m in matches:
parts = [] parts = by_match.get(m.id, [])
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,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
)
items.append( items.append(
{ {
"id": m.id, "id": m.id,
@@ -339,7 +358,9 @@ def _match_items(session: Session, matches) -> list[dict]:
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) total = session.exec(
select(func.count()).select_from(Match).where(Match.group_id == group_id)
).one()
matches = session.exec( matches = session.exec(
select(Match) select(Match)
.where(Match.group_id == group_id) .where(Match.group_id == group_id)
@@ -385,13 +406,12 @@ def user_match_list(
} }
where = (MatchParticipant.user_id == user_id, Match.status == "finished") where = (MatchParticipant.user_id == user_id, Match.status == "finished")
total = len( total = session.exec(
session.exec( select(func.count())
select(Match.id) .select_from(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id) .join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where) .where(*where)
).all() ).one()
)
matches = session.exec( matches = session.exec(
select(Match) select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id) .join(MatchParticipant, MatchParticipant.match_id == Match.id)
@@ -410,8 +430,6 @@ def user_match_list(
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" """Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
group_ids = list( group_ids = list(
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all() session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
) )
@@ -423,21 +441,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
.where(Match.status == "in_progress", Match.group_id.in_(group_ids)) .where(Match.status == "in_progress", Match.group_id.in_(group_ids))
.order_by(Match.started_at.desc(), Match.id.desc()) .order_by(Match.started_at.desc(), Match.id.desc())
).all() ).all()
by_match = _participants_by_match(session, [m.id for m, _ in rows])
out = [] out = []
for m, gname in rows: for m, gname in rows:
parts = [ parts = by_match.get(m.id, [])
{
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type]
]
out.append( out.append(
{ {
"id": m.id, "id": m.id,