diff --git a/backend/app/models.py b/backend/app/models.py index be047e8..29d7b5a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import date, datetime from sqlalchemy import ( JSON, @@ -23,9 +23,9 @@ from sqlalchemy import ( ) 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)) is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) 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): @@ -56,7 +56,7 @@ class Faction(SQLModel, table=True): ) ) 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 # (выдача ачивок игрокам — следующий этап). 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( - default_factory=_utcnow, - sa_column_kwargs={"onupdate": _utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) @@ -150,7 +150,7 @@ class UserAchievement(SQLModel, table=True): ) ) 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): @@ -167,7 +167,7 @@ class AuthIdentity(SQLModel, table=True): ) provider: str = Field(sa_column=Column(String(16), 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 ) ) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) updated_at: datetime = Field( - default_factory=_utcnow, - sa_column_kwargs={"onupdate": _utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, 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")) - joined_at: datetime = Field(default_factory=_utcnow, nullable=False) + joined_at: datetime = Field(default_factory=utcnow, nullable=False) class GroupInvitation(SQLModel, table=True): @@ -237,7 +237,7 @@ class GroupInvitation(SQLModel, table=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): @@ -257,7 +257,7 @@ class GroupExpansion(SQLModel, table=True): 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 ) ) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) updated_at: datetime = Field( - default_factory=_utcnow, - sa_column_kwargs={"onupdate": _utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) @@ -333,7 +333,7 @@ class MatchParticipant(SQLModel, table=True): 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")) 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): @@ -359,7 +359,7 @@ class MatchAttachment(SQLModel, table=True): storage_path: str = Field(sa_column=Column(String(255), nullable=False)) mime_type: str = Field(sa_column=Column(String(64), 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)) 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)) - 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)) ip: str | None = Field(sa_column=Column(String(45), 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-миграции, добавится отдельно): diff --git a/backend/app/services/attachment_service.py b/backend/app/services/attachment_service.py index eabfe2e..3847779 100644 --- a/backend/app/services/attachment_service.py +++ b/backend/app/services/attachment_service.py @@ -11,7 +11,6 @@ from sqlmodel import Session, select from app.core.config import settings from app.core.errors import ConflictError, NotFoundError -from app.core.timeutil import utcnow from app.models import Match, MatchAttachment, User MAX_ATTACHMENTS = 10 @@ -40,17 +39,11 @@ def file_path(att: MatchAttachment) -> 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( session: Session, match: Match, user: User, content: bytes, ext: str, mime: str ) -> MatchAttachment: + from app.services import match_service # избегаем цикла импорта + if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type] raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.") att = MatchAttachment( @@ -69,7 +62,7 @@ def add_photo( abs_path.write_bytes(content) att.storage_path = rel session.add(att) - _touch(session, match) + match_service.touch(session, match) session.commit() session.refresh(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: + from app.services import match_service # избегаем цикла импорта + att = get_for_match(session, match.id, att_id) # type: ignore[arg-type] path = file_path(att) if path.exists(): @@ -91,7 +86,7 @@ def delete(session: Session, match: Match, att_id: int) -> None: except OSError: pass session.delete(att) - _touch(session, match) + match_service.touch(session, match) session.commit() diff --git a/backend/app/services/invitation_service.py b/backend/app/services/invitation_service.py index 5796e97..3b77c86 100644 --- a/backend/app/services/invitation_service.py +++ b/backend/app/services/invitation_service.py @@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi """ from __future__ import annotations +from sqlalchemy.orm import aliased from sqlmodel import Session, select 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]: + inviter = aliased(User) 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) .order_by(GroupInvitation.created_at.desc()) ).all() out = [] - for inv, gname 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 + for inv, gname, inviter_nick in rows: out.append( { "id": inv.id, diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index e3d7ecb..6639686 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import random from dataclasses import dataclass -from datetime import date, datetime, timezone +from datetime import date from sqlmodel import Session, select @@ -16,7 +16,7 @@ from app.core.errors import ( NotFoundError, 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.services import group_service @@ -56,10 +56,6 @@ class ParticipantInput: 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) @@ -77,6 +73,17 @@ def match_version(match: Match) -> str: 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: """Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию).""" 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] ) - now = _utcnow() + now = utcnow() match = Match( group_id=group_id, status="in_progress", @@ -264,7 +271,7 @@ def finish_match( p.faction_id = f.faction_id session.add(p) - now = _utcnow() + now = utcnow() match.finished_at = now started = match.started_at if started is not None: @@ -351,11 +358,7 @@ def update_match( ) match.player_count = len(participants) - # Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE - # строки matches. Правка одних участников его не вызывает, и тогда оптимистичная - # блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно. - match.updated_at = _utcnow() - session.add(match) + touch(session, match) session.commit() session.refresh(match) return match diff --git a/backend/app/services/membership_service.py b/backend/app/services/membership_service.py index 12b4dd4..18b8fba 100644 --- a/backend/app/services/membership_service.py +++ b/backend/app/services/membership_service.py @@ -5,6 +5,7 @@ from sqlmodel import Session, select from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.models import Group, GroupMember, User +from app.services import group_service 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: raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).") - existing = session.exec( - select(GroupMember).where( - GroupMember.group_id == group.id, GroupMember.user_id == user.id - ) - ).first() + existing = group_service.get_membership(session, group.id, user.id) if existing is not None: raise ConflictError("Игрок уже в группе.") @@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup 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 +def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None: + """Группа без владельца неисправима: назначить нового становится некому.""" + if member.role != "owner": + 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: 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("Нельзя удалить последнего владельца группы.") + _assert_not_last_owner( + session, group, member, "Нельзя удалить последнего владельца группы." + ) # Сбросить активную группу у тех, для кого она была активной. 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: 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() + member = group_service.get_membership(session, group.id, user_id) if member is None: raise NotFoundError("Игрок не состоит в группе.") - if member.role == "owner" and role != "owner": - # Без этого единственный владелец мог разжаловать сам себя, и группа - # оставалась без владельца навсегда: назначить нового уже некому. - owners = session.exec( - select(GroupMember).where( - GroupMember.group_id == group.id, GroupMember.role == "owner" - ) - ).all() - if len(owners) <= 1: - raise ForbiddenError("Нельзя снять роль с последнего владельца группы.") + if role != "owner": + _assert_not_last_owner( + session, group, member, "Нельзя снять роль с последнего владельца группы." + ) member.role = role session.add(member) session.commit() diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 690f537..d90575b 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from sqlalchemy import text +from sqlalchemy import func, text from sqlmodel import Session, select 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: 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)) + # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. + games_count, last_played = session.exec( + select(func.count(), func.max(Match.played_at)).where( + Match.group_id == group_id, Match.status == "finished" + ) + ).one() + last_at = str(last_played) if last_played else None available_ids = group_service.available_faction_ids(session, group_id) faction_meta = [] @@ -290,7 +291,7 @@ def group_stats(session: Session, group_id: int) -> dict: return { "group_id": group_id, - "total_matches": len(total_matches), + "total_matches": games_count, "last_match_at": last_at, "leaderboard": board["entries"], "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]: """Элементы списка партий (общее для списка группы и истории игрока).""" - from app.services.match_service import participants_detail # избегаем цикла импорта + by_match = _participants_by_match(session, [m.id for m in matches]) 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, - "eliminated": p.eliminated, - "was_random": p.was_random, - "comment": p.comment, - } - ) + parts = by_match.get(m.id, []) items.append( { "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: - 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( select(Match) .where(Match.group_id == group_id) @@ -385,13 +406,12 @@ def user_match_list( } where = (MatchParticipant.user_id == user_id, Match.status == "finished") - total = len( - session.exec( - select(Match.id) - .join(MatchParticipant, MatchParticipant.match_id == Match.id) - .where(*where) - ).all() - ) + total = session.exec( + select(func.count()) + .select_from(Match) + .join(MatchParticipant, MatchParticipant.match_id == Match.id) + .where(*where) + ).one() matches = session.exec( select(Match) .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]: """Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" - from app.services.match_service import participants_detail # избегаем цикла импорта - group_ids = list( 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)) .order_by(Match.started_at.desc(), Match.id.desc()) ).all() + by_match = _participants_by_match(session, [m.id for m, _ in rows]) 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, - "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] - ] + parts = by_match.get(m.id, []) out.append( { "id": m.id,