Simplify: единый источник времени, версии партии и запросов
Проход /simplify по backend/app: - Три источника «текущего времени» (_utcnow в models.py и match_service.py при живом timeutil.utcnow) сведены к одному — это прямо инвариант из CLAUDE.md. - Бамп версии партии из двух независимых мест собран в match_service.touch: следующая точка мутации, не трогающая строку matches, теперь имеет очевидный способ сделать правильно. - membership_service переиспользует group_service.get_membership вместо трёх копий одного запроса; защита последнего владельца — один хелпер на удаление и смену роли вместо двух похожих блоков. - Убраны N+1: участники страницы партий и ники пригласивших берутся одним запросом вместо запроса на строку (20 партий = 20 лишних запросов с двумя join каждый). - Счётчики партий считает СУБД (COUNT/MAX) вместо выгрузки всех строк ради len() и max() в Python. #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
+23
-23
@@ -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-миграции, добавится отдельно):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user