v0.4 - Расширено управление группой. Добавление игроков в группу в формате приглашения. Добавлено несколько способов пригласить игрока в группу. Улучшена вёрстка интерфейса админа.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""Приглашения в группу: таблица group_invitations.
|
||||
|
||||
Идемпотентна (как 0005): на свежей БД таблицу создаёт 0001 (create_all из актуальных
|
||||
моделей); на существующей — create_table здесь.
|
||||
|
||||
Revision ID: 0006_group_invitations
|
||||
Revises: 0005_achievements
|
||||
Create Date: 2026-06-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_group_invitations"
|
||||
down_revision: Union[str, None] = "0005_achievements"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
if "group_invitations" in insp.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"group_invitations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"group_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"invited_by_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
|
||||
)
|
||||
op.create_index("ix_group_invitations_group_id", "group_invitations", ["group_id"])
|
||||
op.create_index("ix_group_invitations_user_id", "group_invitations", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
if "group_invitations" in insp.get_table_names():
|
||||
op.drop_table("group_invitations")
|
||||
+14
-3
@@ -15,7 +15,17 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError, app_error_handler
|
||||
from app.routers import achievements, admin, auth, groups, matches, reference, stats, users
|
||||
from app.routers import (
|
||||
achievements,
|
||||
admin,
|
||||
auth,
|
||||
groups,
|
||||
invitations,
|
||||
matches,
|
||||
reference,
|
||||
stats,
|
||||
users,
|
||||
)
|
||||
|
||||
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
|
||||
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
|
||||
@@ -106,8 +116,9 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
|
||||
# API-роутеры под /api.
|
||||
api_routers = [auth.router, users.router, groups.router, matches.router,
|
||||
reference.router, stats.router, achievements.router, admin.router]
|
||||
api_routers = [auth.router, users.router, groups.router, invitations.router,
|
||||
matches.router, reference.router, stats.router, achievements.router,
|
||||
admin.router]
|
||||
for r in api_routers:
|
||||
app.include_router(r, prefix="/api")
|
||||
|
||||
|
||||
@@ -188,6 +188,34 @@ class GroupMember(SQLModel, table=True):
|
||||
joined_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class GroupInvitation(SQLModel, table=True):
|
||||
"""Ожидающее приглашение в группу. Хранятся только pending: при ответе строка
|
||||
удаляется (accept → создаётся GroupMember; decline → просто удаление)."""
|
||||
|
||||
__tablename__ = "group_invitations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "user_id", name="uq_group_invitation"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
group_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
)
|
||||
invited_by_id: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class GroupExpansion(SQLModel, table=True):
|
||||
__tablename__ = "group_expansions"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -5,12 +5,14 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import (
|
||||
audit_service,
|
||||
group_service,
|
||||
invitation_service,
|
||||
membership_service,
|
||||
stats_service,
|
||||
)
|
||||
@@ -128,29 +130,36 @@ def list_members(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{group_id}/members", response_model=s.MemberRead)
|
||||
def add_member(
|
||||
@router.post("/{group_id}/invitations", response_model=s.InvitationRead)
|
||||
def invite_member(
|
||||
group_id: int,
|
||||
body: s.MemberAdd,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.MemberRead:
|
||||
) -> s.InvitationRead:
|
||||
# Приглашать вправе ЛЮБОЙ участник группы (не только владелец) — assert_member.
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
member, added = membership_service.add_member_by_nickname(session, group, body.nickname)
|
||||
inv, invited = invitation_service.create_invitation(session, group, user, body.nickname)
|
||||
audit_service.record(
|
||||
session,
|
||||
actor_id=user.id,
|
||||
action="create",
|
||||
entity_type="group_member",
|
||||
entity_type="group_invitation",
|
||||
entity_id=group_id,
|
||||
payload={"added_user_id": added.id, "nickname": added.nickname},
|
||||
payload={"invited_user_id": invited.id, "nickname": invited.nickname},
|
||||
ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
return s.MemberRead(user_id=added.id, nickname=added.nickname, role=member.role)
|
||||
return s.InvitationRead(
|
||||
id=inv.id, # type: ignore[arg-type]
|
||||
group_id=group_id,
|
||||
group_name=group.name,
|
||||
invited_by_nickname=user.nickname,
|
||||
created_at=iso_utc(inv.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", response_model=s.OkResponse)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Входящие приглашения игрока: список, принятие, отклонение."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import invitation_service
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["invitations"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[s.InvitationRead])
|
||||
def my_invitations(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[dict]:
|
||||
return invitation_service.list_for_user(session, user.id) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.post("/{invitation_id}/accept", response_model=s.OkResponse)
|
||||
def accept_invitation(
|
||||
invitation_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
invitation_service.accept(session, user, invitation_id)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@router.post("/{invitation_id}/decline", response_model=s.OkResponse)
|
||||
def decline_invitation(
|
||||
invitation_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
invitation_service.decline(session, user, invitation_id)
|
||||
return s.OkResponse()
|
||||
@@ -149,6 +149,14 @@ class MemberRoleUpdate(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class InvitationRead(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
group_name: str
|
||||
invited_by_nickname: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
# ─── Партии ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class RandomizeRequest(BaseModel):
|
||||
@@ -304,6 +312,7 @@ class GroupStats(BaseModel):
|
||||
last_match_at: str | None = None
|
||||
leaderboard: list[LeaderboardEntry] = []
|
||||
provisional: list[LeaderboardEntry] = []
|
||||
inactive: list[LeaderboardEntry] = [] # участники без завершённых партий
|
||||
faction_meta: list[FactionMeta] = []
|
||||
min_games: int
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Приглашения в группу: создание (любым участником), список, принятие, отклонение.
|
||||
|
||||
Хранятся только ожидающие приглашения; ответ удаляет строку (accept → создаётся
|
||||
GroupMember). Лимит участников — общий с membership_service.MAX_GROUP_SIZE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.core.timeutil import iso_utc
|
||||
from app.models import Group, GroupInvitation, GroupMember, User
|
||||
from app.services.membership_service import MAX_GROUP_SIZE
|
||||
|
||||
|
||||
def _member_exists(session: Session, group_id: int, user_id: int) -> bool:
|
||||
return (
|
||||
session.exec(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _counts(session: Session, group_id: int) -> tuple[int, int]:
|
||||
members = len(
|
||||
session.exec(select(GroupMember.id).where(GroupMember.group_id == group_id)).all()
|
||||
)
|
||||
pending = len(
|
||||
session.exec(
|
||||
select(GroupInvitation.id).where(GroupInvitation.group_id == group_id)
|
||||
).all()
|
||||
)
|
||||
return members, pending
|
||||
|
||||
|
||||
def create_invitation(
|
||||
session: Session, group: Group, inviter: User, nickname: str
|
||||
) -> tuple[GroupInvitation, 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("Игрок с таким ником не найден.")
|
||||
if _member_exists(session, group.id, user.id): # type: ignore[arg-type]
|
||||
raise ConflictError("Игрок уже в группе.")
|
||||
|
||||
existing = session.exec(
|
||||
select(GroupInvitation).where(
|
||||
GroupInvitation.group_id == group.id, GroupInvitation.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
raise ConflictError("Игрок уже приглашён в эту группу.")
|
||||
|
||||
members, pending = _counts(session, group.id) # type: ignore[arg-type]
|
||||
if members + pending >= MAX_GROUP_SIZE:
|
||||
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
|
||||
|
||||
inv = GroupInvitation(
|
||||
group_id=group.id, user_id=user.id, invited_by_id=inviter.id # type: ignore[arg-type]
|
||||
)
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
session.refresh(inv)
|
||||
return inv, user
|
||||
|
||||
|
||||
def list_for_user(session: Session, user_id: int) -> list[dict]:
|
||||
rows = session.exec(
|
||||
select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id)
|
||||
.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
|
||||
out.append(
|
||||
{
|
||||
"id": inv.id,
|
||||
"group_id": inv.group_id,
|
||||
"group_name": gname,
|
||||
"invited_by_nickname": inviter_nick,
|
||||
"created_at": iso_utc(inv.created_at),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _own_invitation(session: Session, user: User, invitation_id: int) -> GroupInvitation:
|
||||
inv = session.get(GroupInvitation, invitation_id)
|
||||
if inv is None or inv.user_id != user.id:
|
||||
raise NotFoundError("Приглашение не найдено.")
|
||||
return inv
|
||||
|
||||
|
||||
def accept(session: Session, user: User, invitation_id: int) -> Group:
|
||||
inv = _own_invitation(session, user, invitation_id)
|
||||
group = session.get(Group, inv.group_id)
|
||||
if group is None:
|
||||
session.delete(inv)
|
||||
session.commit()
|
||||
raise NotFoundError("Группа не найдена.")
|
||||
|
||||
if not _member_exists(session, group.id, user.id): # type: ignore[arg-type]
|
||||
members, _ = _counts(session, group.id) # type: ignore[arg-type]
|
||||
if members >= MAX_GROUP_SIZE:
|
||||
raise ConflictError(f"В группе нет мест (максимум {MAX_GROUP_SIZE}).")
|
||||
session.add(
|
||||
GroupMember(group_id=group.id, user_id=user.id, role="member") # type: ignore[arg-type]
|
||||
)
|
||||
if user.active_group_id is None: # первая группа — делаем активной
|
||||
user.active_group_id = group.id
|
||||
session.add(user)
|
||||
|
||||
session.delete(inv)
|
||||
session.commit()
|
||||
return group
|
||||
|
||||
|
||||
def decline(session: Session, user: User, invitation_id: int) -> None:
|
||||
inv = _own_invitation(session, user, invitation_id)
|
||||
session.delete(inv)
|
||||
session.commit()
|
||||
@@ -8,7 +8,7 @@ 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 import group_service, membership_service, user_service
|
||||
from app.services.scoring import (
|
||||
FACTION_MIN_GAMES,
|
||||
MATCH_POINTS_SQL,
|
||||
@@ -240,12 +240,35 @@ def group_stats(session: Session, group_id: int) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
|
||||
played_ids = {e["user_id"] for e in board["entries"]} | {
|
||||
e["user_id"] for e in board["provisional"]
|
||||
}
|
||||
inactive = []
|
||||
for _m, u in membership_service.list_members(session, group_id):
|
||||
if u.id in played_ids:
|
||||
continue
|
||||
inactive.append(
|
||||
{
|
||||
"user_id": u.id,
|
||||
"nickname": u.nickname,
|
||||
"games": 0,
|
||||
"wins": 0,
|
||||
"win_rate": 0.0,
|
||||
"avg_place": None,
|
||||
"score": None,
|
||||
"rank": None,
|
||||
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"total_matches": len(total_matches),
|
||||
"last_match_at": last_at,
|
||||
"leaderboard": board["entries"],
|
||||
"provisional": board["provisional"],
|
||||
"inactive": inactive,
|
||||
"faction_meta": faction_meta,
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимаю
|
||||
import pytest # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||
from app.core.security import hash_password # noqa: E402
|
||||
from app.db.session import get_session # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import User # noqa: E402
|
||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||
from app.seed.reference_data import seed_reference_data # noqa: E402
|
||||
|
||||
|
||||
@@ -86,6 +86,32 @@ def login(client: TestClient, nickname: str) -> dict:
|
||||
return r.json()
|
||||
|
||||
|
||||
def add_group_member(engine, group_id: int, nickname: str, role: str = "member") -> int:
|
||||
"""Подготовка данных: добавить игрока в группу напрямую (в обход приглашений).
|
||||
|
||||
Создаёт пользователя при отсутствии. Возвращает user_id. Нужен, т.к. через HTTP
|
||||
прямого добавления больше нет — только приглашения (см. invitation_service)."""
|
||||
with Session(engine) as s:
|
||||
user = s.exec(select(User).where(User.nickname == nickname)).first()
|
||||
if user is None:
|
||||
user = User(nickname=nickname, role="player", auth_provider="stub")
|
||||
s.add(user)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
# AuthIdentity нужен, чтобы dev-вход (login) нашёл того же игрока, а не создал дубль.
|
||||
s.add(AuthIdentity(user_id=user.id, provider="stub", external_id=nickname))
|
||||
s.commit()
|
||||
exists = s.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if exists is None:
|
||||
s.add(GroupMember(group_id=group_id, user_id=user.id, role=role))
|
||||
s.commit()
|
||||
return user.id # type: ignore[return-value]
|
||||
|
||||
|
||||
def start_match(client: TestClient, group_id: int, roster: list[dict]):
|
||||
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
|
||||
return client.post(
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import create_finished_match, csrf_headers, login, start_match
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||
|
||||
|
||||
def _exp_id(client: TestClient, code: str) -> int:
|
||||
@@ -20,26 +20,25 @@ def _admin_login(client: TestClient, make_admin) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_group_size_limit(client: TestClient):
|
||||
def test_group_invite_capacity_limit(client: TestClient):
|
||||
login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
|
||||
for i in range(1, 11):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
|
||||
statuses = []
|
||||
for i in range(1, 11):
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
)
|
||||
statuses.append(r.status_code)
|
||||
# 9 успешных (итого 10 с владельцем), 10-й — отказ.
|
||||
# owner(1) + 9 приглашений = 10 (лимит мест); 10-е приглашение сверх лимита → 409.
|
||||
assert statuses[:9] == [200] * 9
|
||||
assert statuses[9] == 409
|
||||
|
||||
|
||||
def test_match_player_limit(client: TestClient):
|
||||
def test_match_player_limit(client: TestClient, engine):
|
||||
me = login(client, "Owner")
|
||||
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||
gid = client.post(
|
||||
@@ -47,12 +46,7 @@ def test_match_player_limit(client: TestClient):
|
||||
).json()["id"]
|
||||
ids = [me["id"]]
|
||||
for i in range(1, 7):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client))
|
||||
ids.append(
|
||||
client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"P{i}"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
)
|
||||
ids.append(add_group_member(engine, gid, f"P{i}"))
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
fids = [f["id"] for f in factions]
|
||||
# 7 игроков на старте → 422 (максимум 6).
|
||||
@@ -62,15 +56,12 @@ def test_match_player_limit(client: TestClient):
|
||||
assert "6" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin, engine):
|
||||
me = login(client, "Аня")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Боря")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
@@ -129,17 +120,14 @@ def test_admin_rename_account(client: TestClient, make_admin):
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
|
||||
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin, engine):
|
||||
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
||||
его созданные партии и группы переназначаются на админа."""
|
||||
me = login(client, "Аня") # владелец группы и создатель партии
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Боря")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
@@ -187,15 +175,12 @@ def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine):
|
||||
me = login(client, "Кто-то")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Друг"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Друг")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers, finish_match, login, start_match
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _expansion_id(client: TestClient, code: str) -> int:
|
||||
@@ -12,7 +12,7 @@ def _expansion_id(client: TestClient, code: str) -> int:
|
||||
return next(e["id"] for e in r.json() if e["code"] == code)
|
||||
|
||||
|
||||
def test_full_flow(client: TestClient, make_admin):
|
||||
def test_full_flow(client: TestClient, make_admin, engine):
|
||||
# 1) Вход игрока A — групп ещё нет.
|
||||
me = login(client, "Иван")
|
||||
assert me["groups"] == []
|
||||
@@ -44,18 +44,8 @@ def test_full_flow(client: TestClient, make_admin):
|
||||
me = client.get("/api/users/me").json()
|
||||
assert me["active_group_id"] == gid
|
||||
|
||||
# 4) Создать игрока B и добавить в группу по нику.
|
||||
rb = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Олег"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert rb.status_code == 200, rb.text
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members",
|
||||
json={"nickname": "Олег"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
b_id = r.json()["user_id"]
|
||||
# 4) Игрок B в группе (прямое добавление — подготовка данных; UX-путь теперь приглашения).
|
||||
b_id = add_group_member(engine, gid, "Олег")
|
||||
|
||||
# 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8.
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
@@ -150,15 +140,12 @@ def test_full_flow(client: TestClient, make_admin):
|
||||
assert {"Иван", "Олег"}.issubset(nicks)
|
||||
|
||||
|
||||
def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
def test_in_progress_excluded_and_win_reason_required(client: TestClient, engine):
|
||||
me = login(client, "Хост")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Гость2"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Гость2")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
@@ -202,16 +189,13 @@ def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
assert all(x["id"] != mid for x in client.get("/api/home").json()["in_progress"])
|
||||
|
||||
|
||||
def test_owner_can_finish_member_started_match(client: TestClient):
|
||||
def test_owner_can_finish_member_started_match(client: TestClient, engine):
|
||||
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
|
||||
a = login(client, "Хозяин")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Согрупник"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Согрупник")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
@@ -236,16 +220,14 @@ def test_owner_can_finish_member_started_match(client: TestClient):
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
|
||||
def test_any_member_can_finish_and_manage(client: TestClient):
|
||||
def test_any_member_can_finish_and_manage(client: TestClient, engine):
|
||||
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
|
||||
a = login(client, "Овнер")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
for nick in ("Бэ", "Цэ"):
|
||||
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "Бэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "Цэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Бэ")
|
||||
c = add_group_member(engine, gid, "Цэ")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
|
||||
# Владелец стартует партию A+B+C.
|
||||
@@ -305,18 +287,34 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
|
||||
assert guest_dev["is_active"] is False
|
||||
|
||||
|
||||
def test_tie_ranking_and_points(client: TestClient):
|
||||
def test_group_stats_includes_inactive_members(client: TestClient, engine):
|
||||
"""Участники без завершённых партий попадают в отдельный блок inactive (не в provisional)."""
|
||||
me = login(client, "Капитан")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Эскадра", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
rookie = add_group_member(engine, gid, "Новобранец")
|
||||
|
||||
stats = client.get(f"/api/groups/{gid}/stats").json()
|
||||
inactive_ids = {e["user_id"] for e in stats["inactive"]}
|
||||
prov_ids = {e["user_id"] for e in stats["provisional"]}
|
||||
lb_ids = {e["user_id"] for e in stats["leaderboard"]}
|
||||
|
||||
# Никто ещё не играл → и владелец, и новобранец в inactive, и нигде больше.
|
||||
assert {me["id"], rookie}.issubset(inactive_ids)
|
||||
assert not ({me["id"], rookie} & (prov_ids | lb_ids))
|
||||
entry = next(e for e in stats["inactive"] if e["user_id"] == rookie)
|
||||
assert entry["games"] == 0 and entry["rank"] is None
|
||||
|
||||
|
||||
def test_tie_ranking_and_points(client: TestClient, engine):
|
||||
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
|
||||
me = login(client, "A")
|
||||
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
|
||||
headers=csrf_headers(client))
|
||||
gid = r.json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client))
|
||||
client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "B"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "C"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "B")
|
||||
c = add_group_member(engine, gid, "C")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"])
|
||||
a = me["id"]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Приглашения в группу: создание, просмотр, принятие/отклонение, права участника."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, login
|
||||
|
||||
|
||||
def _group(client: TestClient, name: str = "Группа") -> int:
|
||||
return client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
|
||||
|
||||
def _dev_user(client: TestClient, nick: str) -> None:
|
||||
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
|
||||
|
||||
|
||||
def test_invite_accept_flow(client: TestClient):
|
||||
login(client, "Овнер")
|
||||
gid = _group(client)
|
||||
_dev_user(client, "Гость")
|
||||
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["invited_by_nickname"] == "Овнер"
|
||||
# Повторное приглашение → 409.
|
||||
dup = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert dup.status_code == 409, dup.text
|
||||
|
||||
# Приглашённый видит приглашение и принимает.
|
||||
login(client, "Гость")
|
||||
invs = client.get("/api/invitations").json()
|
||||
assert len(invs) == 1
|
||||
assert invs[0]["group_id"] == gid and invs[0]["group_name"] == "Группа"
|
||||
inv_id = invs[0]["id"]
|
||||
acc = client.post(f"/api/invitations/{inv_id}/accept", headers=csrf_headers(client))
|
||||
assert acc.status_code == 200, acc.text
|
||||
# Стал участником; первая группа стала активной; приглашение исчезло.
|
||||
me = client.get("/api/users/me").json()
|
||||
assert me["active_group_id"] == gid
|
||||
assert client.get("/api/invitations").json() == []
|
||||
|
||||
# Теперь он участник → приглашать его повторно нельзя.
|
||||
login(client, "Овнер")
|
||||
again = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert again.status_code == 409, again.text
|
||||
members = client.get(f"/api/groups/{gid}/members").json()
|
||||
assert any(m["nickname"] == "Гость" for m in members)
|
||||
|
||||
|
||||
def test_invite_decline(client: TestClient):
|
||||
login(client, "Овнер2")
|
||||
gid = _group(client)
|
||||
_dev_user(client, "Гость2")
|
||||
client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость2"}, headers=csrf_headers(client)
|
||||
)
|
||||
|
||||
login(client, "Гость2")
|
||||
inv_id = client.get("/api/invitations").json()[0]["id"]
|
||||
dec = client.post(f"/api/invitations/{inv_id}/decline", headers=csrf_headers(client))
|
||||
assert dec.status_code == 200, dec.text
|
||||
assert client.get("/api/invitations").json() == []
|
||||
|
||||
login(client, "Овнер2")
|
||||
members = client.get(f"/api/groups/{gid}/members").json()
|
||||
assert all(m["nickname"] != "Гость2" for m in members)
|
||||
|
||||
|
||||
def test_any_member_can_invite(client: TestClient, engine):
|
||||
login(client, "Овнер3")
|
||||
gid = _group(client)
|
||||
add_group_member(engine, gid, "Участник") # обычный member (не владелец)
|
||||
_dev_user(client, "Новичок")
|
||||
|
||||
# Обычный участник входит и приглашает — должно быть можно (assert_member, не owner).
|
||||
login(client, "Участник")
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Новичок"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_invite_unknown_nick_404(client: TestClient):
|
||||
login(client, "Овнер4")
|
||||
gid = _group(client)
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "НетТакого"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import create_finished_match, csrf_headers, login
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
@@ -15,16 +15,13 @@ def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
def _finished_match_for(client: TestClient, me: dict) -> int:
|
||||
def _finished_match_for(client: TestClient, engine, me: dict) -> int:
|
||||
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client))
|
||||
p2 = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
p2 = add_group_member(engine, gid, "Соперник")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
create_finished_match(
|
||||
client,
|
||||
@@ -110,10 +107,10 @@ def test_public_profile(client: TestClient):
|
||||
assert client.get("/api/users/999999/profile").status_code == 404
|
||||
|
||||
|
||||
def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path):
|
||||
def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch, tmp_path):
|
||||
_use_tmp_uploads(monkeypatch, tmp_path)
|
||||
me = login(client, "Топчик")
|
||||
_finished_match_for(client, me)
|
||||
_finished_match_for(client, engine, me)
|
||||
|
||||
def entry_for(uid: int) -> dict:
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
|
||||
@@ -15,6 +15,7 @@ export const qk = {
|
||||
groupStats: (id: number) => ["group", id, "stats"] as const,
|
||||
match: (id: number) => ["match", id] as const,
|
||||
authConfig: ["authConfig"] as const,
|
||||
invitations: ["invitations"] as const,
|
||||
devUsers: ["devUsers"] as const,
|
||||
adminUsers: ["adminUsers"] as const,
|
||||
adminGroups: ["adminGroups"] as const,
|
||||
|
||||
Vendored
+171
-4
@@ -305,8 +305,24 @@ export interface paths {
|
||||
/** List Members */
|
||||
get: operations["list_members_api_groups__group_id__members_get"];
|
||||
put?: never;
|
||||
/** Add Member */
|
||||
post: operations["add_member_api_groups__group_id__members_post"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/groups/{group_id}/invitations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Invite Member */
|
||||
post: operations["invite_member_api_groups__group_id__invitations_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -365,6 +381,57 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invitations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** My Invitations */
|
||||
get: operations["my_invitations_api_invitations_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invitations/{invitation_id}/accept": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Accept Invitation */
|
||||
post: operations["accept_invitation_api_invitations__invitation_id__accept_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invitations/{invitation_id}/decline": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Decline Invitation */
|
||||
post: operations["decline_invitation_api_invitations__invitation_id__decline_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/randomize-faction": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1219,6 +1286,11 @@ export interface components {
|
||||
* @default []
|
||||
*/
|
||||
provisional: components["schemas"]["LeaderboardEntry"][];
|
||||
/**
|
||||
* Inactive
|
||||
* @default []
|
||||
*/
|
||||
inactive: components["schemas"]["LeaderboardEntry"][];
|
||||
/**
|
||||
* Faction Meta
|
||||
* @default []
|
||||
@@ -1272,6 +1344,19 @@ export interface components {
|
||||
/** Min Games */
|
||||
min_games: number;
|
||||
};
|
||||
/** InvitationRead */
|
||||
InvitationRead: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Group Id */
|
||||
group_id: number;
|
||||
/** Group Name */
|
||||
group_name: string;
|
||||
/** Invited By Nickname */
|
||||
invited_by_nickname?: string | null;
|
||||
/** Created At */
|
||||
created_at: string;
|
||||
};
|
||||
/** Leaderboard */
|
||||
Leaderboard: {
|
||||
/**
|
||||
@@ -2317,7 +2402,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
add_member_api_groups__group_id__members_post: {
|
||||
invite_member_api_groups__group_id__invitations_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -2338,7 +2423,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MemberRead"];
|
||||
"application/json": components["schemas"]["InvitationRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
@@ -2485,6 +2570,88 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
my_invitations_api_invitations_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["InvitationRead"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
accept_invitation_api_invitations__invitation_id__accept_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
invitation_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OkResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
decline_invitation_api_invitations__invitation_id__decline_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
invitation_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OkResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
randomize_faction_api_matches_randomize_faction_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ApiError } from "../api/client";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useAcceptInvitation, useDeclineInvitation, useMyInvitations } from "../hooks/invitations";
|
||||
|
||||
export function GroupInvitations() {
|
||||
const { data: invitations } = useMyInvitations();
|
||||
const accept = useAcceptInvitation();
|
||||
const decline = useDeclineInvitation();
|
||||
const toast = useToast();
|
||||
|
||||
if (!invitations || invitations.length === 0) return null;
|
||||
|
||||
const onAccept = async (id: number) => {
|
||||
try {
|
||||
await accept.mutateAsync(id);
|
||||
toast.show("Вы вступили в группу");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const onDecline = async (id: number) => {
|
||||
try {
|
||||
await decline.mutateAsync(id);
|
||||
toast.show("Приглашение отклонено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const busy = accept.isPending || decline.isPending;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Приглашения в группы</h3>
|
||||
<div className="stack">
|
||||
{invitations.map((inv) => (
|
||||
<div key={inv.id} className="row-between">
|
||||
<div>
|
||||
<b>{inv.group_name}</b>
|
||||
{inv.invited_by_nickname && (
|
||||
<div className="muted small">пригласил: {inv.invited_by_nickname}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button className="btn btn-primary small" onClick={() => onAccept(inv.id)} disabled={busy}>
|
||||
Согласиться
|
||||
</button>
|
||||
<button className="btn btn-ghost small" onClick={() => onDecline(inv.id)} disabled={busy}>
|
||||
Отказаться
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useMe, useSetActiveGroup } from "../hooks/auth";
|
||||
import { CreateGroupForm } from "./CreateGroupForm";
|
||||
|
||||
export function GroupSwitcher() {
|
||||
const { data: me } = useMe();
|
||||
const setActive = useSetActiveGroup();
|
||||
const [creating, setCreating] = useState(false);
|
||||
if (!me || me.groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -17,11 +21,25 @@ export function GroupSwitcher() {
|
||||
className={"btn" + (active ? " btn-primary" : "")}
|
||||
onClick={() => !active && setActive.mutate(g.id)}
|
||||
>
|
||||
{g.name} {active && "✓"} <span className="muted small">({g.role})</span>
|
||||
{g.name} {active && "✓"} <span className="muted small"></span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Плашка создания новой группы — в одном ряду логики с выбором группы. */}
|
||||
<button
|
||||
className="btn"
|
||||
aria-label="Создать группу"
|
||||
onClick={() => setCreating((v) => !v)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<CreateGroupForm onCreated={() => setCreating(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,20 +9,20 @@ function pct(v: number | null | undefined): string {
|
||||
|
||||
function Row({ entry }: { entry: LeaderboardEntry }) {
|
||||
return (
|
||||
<div className="lb-row">
|
||||
// Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка
|
||||
// ведёт в общую статистику, а клик по строке игрока — в его профиль.
|
||||
<Link
|
||||
to={`/u/${entry.user_id}`}
|
||||
className="lb-row"
|
||||
style={{ color: "inherit" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
||||
{entry.rank ?? "—"}
|
||||
</div>
|
||||
<Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} />
|
||||
<div>
|
||||
<Link
|
||||
to={`/u/${entry.user_id}`}
|
||||
// stopPropagation: ник кликабелен даже когда вся карточка-обёртка кликабельна.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontWeight: 600, color: "inherit" }}
|
||||
>
|
||||
{entry.nickname}
|
||||
</Link>
|
||||
<div style={{ fontWeight: 600 }}>{entry.nickname}</div>
|
||||
<div className="lb-metrics">
|
||||
<span>Игр: {entry.games}</span>
|
||||
<span>Побед: {entry.wins}</span>
|
||||
@@ -30,18 +30,22 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="lb-score">{entry.score ?? "—"}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Leaderboard({
|
||||
entries,
|
||||
provisional,
|
||||
inactive,
|
||||
}: {
|
||||
entries: LeaderboardEntry[];
|
||||
provisional?: LeaderboardEntry[];
|
||||
inactive?: LeaderboardEntry[];
|
||||
}) {
|
||||
if (entries.length === 0 && (!provisional || provisional.length === 0)) {
|
||||
const empty =
|
||||
entries.length === 0 && !provisional?.length && !inactive?.length;
|
||||
if (empty) {
|
||||
return <div className="muted small">Пока нет сыгранных партий.</div>;
|
||||
}
|
||||
return (
|
||||
@@ -59,6 +63,16 @@ export function Leaderboard({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{inactive && inactive.length > 0 && (
|
||||
<>
|
||||
<div className="label" style={{ marginTop: 10 }}>
|
||||
Ещё не играли
|
||||
</div>
|
||||
{inactive.map((e) => (
|
||||
<Row key={e.user_id} entry={e} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type GroupDetail = S["GroupDetail"];
|
||||
export type ExpansionRead = S["ExpansionRead"];
|
||||
export type FactionRead = S["FactionRead"];
|
||||
export type MemberRead = S["MemberRead"];
|
||||
export type InvitationRead = S["InvitationRead"];
|
||||
export type MatchRead = S["MatchRead"];
|
||||
export type MatchList = S["MatchList"];
|
||||
export type MatchListItem = S["MatchListItem"];
|
||||
|
||||
@@ -92,17 +92,17 @@ export function useSetGroupExpansions(groupId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMember(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
// Приглашение игрока в группу по нику (вместо прямого добавления). groupId передаётся
|
||||
// при вызове — чтобы можно было приглашать в выбранную группу (со страницы чужого профиля).
|
||||
export function useInviteMember() {
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
mutationFn: async ({ groupId, nickname }: { groupId: number; nickname: string }) =>
|
||||
unwrap(
|
||||
await api.POST("/api/groups/{group_id}/members", {
|
||||
await api.POST("/api/groups/{group_id}/invitations", {
|
||||
params: { path: { group_id: groupId } },
|
||||
body: { nickname },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useMyInvitations() {
|
||||
return useQuery({
|
||||
queryKey: qk.invitations,
|
||||
queryFn: async () => unwrap(await api.GET("/api/invitations")),
|
||||
});
|
||||
}
|
||||
|
||||
// Принятие/отклонение меняет приглашения, состав групп и активную группу.
|
||||
function invalidateAll(qc: ReturnType<typeof useQueryClient>) {
|
||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
qc.invalidateQueries({ queryKey: qk.groups });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
}
|
||||
|
||||
export function useAcceptInvitation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (invitationId: number) =>
|
||||
unwrap(
|
||||
await api.POST("/api/invitations/{invitation_id}/accept", {
|
||||
params: { path: { invitation_id: invitationId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => invalidateAll(qc),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeclineInvitation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (invitationId: number) =>
|
||||
unwrap(
|
||||
await api.POST("/api/invitations/{invitation_id}/decline", {
|
||||
params: { path: { invitation_id: invitationId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => invalidateAll(qc),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { CreateGroupForm } from "../components/CreateGroupForm";
|
||||
import { GroupInvitations } from "../components/GroupInvitations";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { Leaderboard } from "../components/Leaderboard";
|
||||
import { MatchListView } from "../components/MatchList";
|
||||
@@ -25,9 +26,10 @@ export function GroupPage() {
|
||||
<div className="card">
|
||||
<h3>Активная группа не выбрана</h3>
|
||||
<p className="muted small">
|
||||
Создайте группу или выберите существующую, чтобы вести учёт партий.
|
||||
Создайте группу, выберите существующую или примите приглашение, чтобы вести учёт партий.
|
||||
</p>
|
||||
</div>
|
||||
<GroupInvitations />
|
||||
<GroupSwitcher />
|
||||
<CreateGroupForm />
|
||||
</div>
|
||||
@@ -51,9 +53,17 @@ export function GroupPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GroupInvitations />
|
||||
|
||||
<div className="card">
|
||||
<h3>Топ в группе</h3>
|
||||
{stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />}
|
||||
<h3>Статистика игроков</h3>
|
||||
{stats && (
|
||||
<Leaderboard
|
||||
entries={stats.leaderboard}
|
||||
provisional={stats.provisional}
|
||||
inactive={stats.inactive}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GroupSwitcher />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Spinner } from "../components/Spinner";
|
||||
import { MAX_GROUP_SIZE } from "../domain/constants";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useAddMember, useGroup, useGroupMembers, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
|
||||
import { useGroup, useGroupMembers, useInviteMember, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
|
||||
import { useExpansions } from "../hooks/reference";
|
||||
|
||||
export function GroupSettingsPage() {
|
||||
@@ -16,7 +16,7 @@ export function GroupSettingsPage() {
|
||||
const { data: expansions } = useExpansions();
|
||||
const { data: members } = useGroupMembers(groupId);
|
||||
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
||||
const addMember = useAddMember(groupId ?? 0);
|
||||
const inviteMember = useInviteMember();
|
||||
const removeMember = useRemoveMember(groupId ?? 0);
|
||||
const toast = useToast();
|
||||
|
||||
@@ -51,9 +51,9 @@ export function GroupSettingsPage() {
|
||||
const nick = newNick.trim();
|
||||
if (!nick) return;
|
||||
try {
|
||||
await addMember.mutateAsync(nick);
|
||||
await inviteMember.mutateAsync({ groupId: group.id, nickname: nick });
|
||||
setNewNick("");
|
||||
toast.show("Игрок добавлен");
|
||||
toast.show("Приглашение отправлено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||
}
|
||||
@@ -102,8 +102,12 @@ export function GroupSettingsPage() {
|
||||
)}
|
||||
{isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<input placeholder="Никнейм игрока" value={newNick} onChange={(e) => setNewNick(e.target.value)} />
|
||||
<button className="btn btn-primary" onClick={add}>
|
||||
<input
|
||||
placeholder="Ник игрока — отправить приглашение"
|
||||
value={newNick}
|
||||
onChange={(e) => setNewNick(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={add} disabled={inviteMember.isPending}>
|
||||
<UserPlus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useInviteMember } from "../hooks/groups";
|
||||
import { usePublicProfile } from "../hooks/stats";
|
||||
|
||||
/**
|
||||
@@ -13,10 +18,27 @@ export function PublicProfilePage() {
|
||||
const { userId } = useParams();
|
||||
const id = Number(userId);
|
||||
const { data, isLoading, isError } = usePublicProfile(id);
|
||||
const { data: me } = useMe();
|
||||
const invite = useInviteMember();
|
||||
const toast = useToast();
|
||||
const [pickOpen, setPickOpen] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
|
||||
|
||||
const groups = me?.groups ?? [];
|
||||
const isSelf = me?.id === data.user_id;
|
||||
|
||||
const doInvite = async (groupId: number) => {
|
||||
setPickOpen(false);
|
||||
try {
|
||||
await invite.mutateAsync({ groupId, nickname: data.nickname });
|
||||
toast.show("Приглашение отправлено");
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось пригласить");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
@@ -37,6 +59,46 @@ export function PublicProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Пригласить в группу (только чужой профиль и если у меня есть группы). */}
|
||||
{!isSelf && groups.length > 0 && (
|
||||
<div className="card">
|
||||
{groups.length === 1 ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => doInvite(groups[0].id)}
|
||||
disabled={invite.isPending}
|
||||
>
|
||||
Добавить в группу «{groups[0].name}»
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => setPickOpen((v) => !v)}
|
||||
>
|
||||
Добавить в группу
|
||||
</button>
|
||||
{pickOpen && (
|
||||
<div className="stack" style={{ marginTop: 8 }}>
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className="btn"
|
||||
onClick={() => doInvite(g.id)}
|
||||
disabled={invite.isPending}
|
||||
>
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileStatsCard stats={data.stats} title="Статистика" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AdminLayout() {
|
||||
Выйти
|
||||
</button>
|
||||
</header>
|
||||
<div className="row" style={{ padding: "10px 14px", gap: 8 }}>
|
||||
<div className="admin-tabs">
|
||||
<NavLink to="/admin/records" className={tab}>
|
||||
Записи
|
||||
</NavLink>
|
||||
|
||||
@@ -74,23 +74,17 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
};
|
||||
|
||||
return (
|
||||
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
zIndex: 200,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
background: "var(--bg)",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="app-shell"
|
||||
style={{ maxHeight: "92vh", overflowY: "auto", boxShadow: "none" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-shell" style={{ minHeight: "100%", boxShadow: "none" }}>
|
||||
<div className="app-main" style={{ paddingBottom: 24 }}>
|
||||
<div className="row-between">
|
||||
<h2 style={{ margin: 0 }}>Правка партии #{matchId}</h2>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
/* Убираем синий/серый прямоугольник подсветки при тапе на мобильных. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
|
||||
@@ -122,6 +122,19 @@
|
||||
.st-player { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 600; }
|
||||
.st-player .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Вкладки админки: горизонтальная карусель (не ломают вёрстку при переполнении) */
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.admin-tabs::-webkit-scrollbar { display: none; } /* WebKit */
|
||||
.admin-tabs > * { flex: 0 0 auto; white-space: nowrap; }
|
||||
|
||||
.participant-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user