diff --git a/backend/alembic/versions/0006_group_invitations.py b/backend/alembic/versions/0006_group_invitations.py new file mode 100644 index 0000000..eedd10c --- /dev/null +++ b/backend/alembic/versions/0006_group_invitations.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index f250ed1..0f33473 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 2b635c3..d57166e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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__ = ( diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index ba4693c..7813d16 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -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) diff --git a/backend/app/routers/invitations.py b/backend/app/routers/invitations.py new file mode 100644 index 0000000..e388562 --- /dev/null +++ b/backend/app/routers/invitations.py @@ -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() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 9e5f466..3a1e73d 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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): diff --git a/backend/app/services/invitation_service.py b/backend/app/services/invitation_service.py new file mode 100644 index 0000000..5796e97 --- /dev/null +++ b/backend/app/services/invitation_service.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8434995..dc9bbfa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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( diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 0e75643..604e142 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -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, diff --git a/backend/tests/test_core_flow.py b/backend/tests/test_core_flow.py index 6409c6c..cc98721 100644 --- a/backend/tests/test_core_flow.py +++ b/backend/tests/test_core_flow.py @@ -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,14 @@ 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_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"] diff --git a/backend/tests/test_invitations.py b/backend/tests/test_invitations.py new file mode 100644 index 0000000..872b517 --- /dev/null +++ b/backend/tests/test_invitations.py @@ -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 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 3615eba..72195ec 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -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() diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 21c0625..f54b4ad 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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, diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 6f6c422..974c4e9 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; @@ -1272,6 +1339,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 +2397,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 +2418,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MemberRead"]; + "application/json": components["schemas"]["InvitationRead"]; }; }; /** @description Validation Error */ @@ -2485,6 +2565,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; diff --git a/frontend/src/components/GroupInvitations.tsx b/frontend/src/components/GroupInvitations.tsx new file mode 100644 index 0000000..7b1fd7e --- /dev/null +++ b/frontend/src/components/GroupInvitations.tsx @@ -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 ( +
+

Приглашения в группы

+
+ {invitations.map((inv) => ( +
+
+ {inv.group_name} + {inv.invited_by_nickname && ( +
пригласил: {inv.invited_by_nickname}
+ )} +
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index 1372b52..1782d7f 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -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"]; diff --git a/frontend/src/hooks/groups.ts b/frontend/src/hooks/groups.ts index c894ebe..869291b 100644 --- a/frontend/src/hooks/groups.ts +++ b/frontend/src/hooks/groups.ts @@ -92,17 +92,16 @@ export function useSetGroupExpansions(groupId: number) { }); } -export function useAddMember(groupId: number) { - const qc = useQueryClient(); +// Приглашение игрока в группу по нику (вместо прямого добавления). +export function useInviteMember(groupId: number) { return useMutation({ mutationFn: async (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) }), }); } diff --git a/frontend/src/hooks/invitations.ts b/frontend/src/hooks/invitations.ts new file mode 100644 index 0000000..4558f13 --- /dev/null +++ b/frontend/src/hooks/invitations.ts @@ -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) { + 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), + }); +} diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index e95482b..150df8c 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -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() {

Активная группа не выбрана

- Создайте группу или выберите существующую, чтобы вести учёт партий. + Создайте группу, выберите существующую или примите приглашение, чтобы вести учёт партий.

+ @@ -51,6 +53,8 @@ export function GroupPage() {

+ +

Топ в группе

{stats && } diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index 1b0dfd2..acee949 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -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(groupId ?? 0); 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(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 && (
- setNewNick(e.target.value)} /> -