Группы: приглашения вместо авто-добавления (согласиться/отказаться)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:07:20 +03:00
co-authored by Claude Opus 4.8
parent 66370c7df4
commit a5c54512a5
20 changed files with 742 additions and 98 deletions
+14 -3
View File
@@ -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")
+28
View File
@@ -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__ = (
+16 -7
View File
@@ -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)
+41
View File
@@ -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()
+8
View File
@@ -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):
+130
View File
@@ -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()