diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 56c8811..ab2400d 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -243,6 +243,7 @@ def update_match( admin: User = Depends(get_current_admin), ) -> s.MatchRead: match = match_service.get_match(session, match_id) + rating_before = match_service.rating_inputs(session, match) match = match_service.update_match( session, match, @@ -265,7 +266,9 @@ def update_match( ip=client_ip(request), ) session.commit() - notify.match_changed(session, match) + notify.match_changed( + session, match, ratings=match_service.rating_inputs(session, match) != rating_before + ) return build_match_read(session, match, can_modify=True) @@ -356,7 +359,7 @@ def admin_add_attachment( att = attachment_service.add_photo( session, match, admin, content, ext, user_service.avatar_media_type(ext) ) - notify.match_changed(session, match) + notify.match_changed(session, match, ratings=False) return attachment_read(att, f"/api/admin/matches/{match_id}") @@ -369,7 +372,7 @@ def admin_delete_attachment( ) -> s.OkResponse: match = match_service.get_match(session, match_id) attachment_service.delete(session, match, attachment_id) - notify.match_changed(session, match) + notify.match_changed(session, match, ratings=False) return s.OkResponse() diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 010d43a..1300f49 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -93,6 +93,7 @@ def update_group( group_service.rename_group(session, group, body.name) if body.nine_rounds_rule is not None: group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule) + notify.group_changed(session, group_id) # остальным участникам — живое обновление настроек return _detail(session, group_id, user.id) # type: ignore[arg-type] diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index ea6f970..aa74a49 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -234,6 +234,7 @@ def update_match( ) -> s.MatchRead: match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) + rating_before = match_service.rating_inputs(session, match) match = match_service.update_match( session, match, @@ -257,7 +258,9 @@ def update_match( user_agent=request.headers.get("user-agent"), ) session.commit() - notify.match_changed(session, match) + notify.match_changed( + session, match, ratings=match_service.rating_inputs(session, match) != rating_before + ) return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) @@ -304,7 +307,7 @@ def add_attachment( att = attachment_service.add_photo( session, match, user, content, ext, user_service.avatar_media_type(ext) ) - notify.match_changed(session, match) + notify.match_changed(session, match, ratings=False) return attachment_read(att, f"/api/matches/{match_id}") @@ -318,7 +321,7 @@ def delete_attachment( match = match_service.get_match(session, match_id) _assert_can_attach(session, match, user) attachment_service.delete(session, match, attachment_id) - notify.match_changed(session, match) + notify.match_changed(session, match, ratings=False) return s.OkResponse() diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index d51953c..d2c0394 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -479,6 +479,27 @@ def assert_can_modify(session: Session, match: Match, user: User) -> None: raise ForbiddenError("Недостаточно прав для изменения партии.") +def rating_inputs(session: Session, match: Match) -> tuple: + """Снимок всего, что из партии читает рейтинг (stats_service.load_history): порядок + в истории, итоги и места. Сравнение снимков до и после правки показывает, сдвинула ли + она рейтинг, — комментарий или фото его не трогают.""" + seats = session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + return ( + match.status, + match.played_at, + match.finished_at, + match.win_reason, + match.end_round, + match.nine_rounds_rule, + sorted( + (p.user_id, p.faction_id, p.place, p.eliminated, p.objectives, p.worlds) + for p in seats + ), + ) + + def update_match( session: Session, match: Match, diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index d15f9f2..15fd443 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -32,12 +32,15 @@ def _ratings_changed(session: Session, notified: list[int]) -> None: hub.publish(ids, {"type": "ratings"}) -def match_changed(session: Session, match: Match) -> None: +def match_changed(session: Session, match: Match, *, ratings: bool = True) -> None: """Партия изменилась — уведомить всех участников её группы, а если она завершена — - и остальных игроков (_ratings_changed).""" + и остальных игроков (_ratings_changed). + + ratings=False — правка, которая рейтинг не двигает (фото, комментарий): тогда + остальным игрокам незачем перезапрашивать все рейтинговые витрины.""" members = _group_member_ids(session, match.group_id) hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id}) - if match.status == "finished": + if ratings and match.status == "finished": _ratings_changed(session, members) diff --git a/backend/tests/test_events_payload.py b/backend/tests/test_events_payload.py index 72a6198..50e72ca 100644 --- a/backend/tests/test_events_payload.py +++ b/backend/tests/test_events_payload.py @@ -81,3 +81,60 @@ def test_deleting_finished_match_reaches_players_outside_group( assert r.status_code == 200, r.text assert _recipients(published, "match") == {me["id"], p2} assert outsider in _recipients(published, "ratings") + + +def _finished_match(client: TestClient, me: dict, p2: int, gid: int, fids: list[int]) -> int: + mid = start_match( + client, gid, + [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], + ).json()["id"] + finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]) + return mid + + +def _patch_match(client: TestClient, mid: int, me: dict, p2: int, fids: list[int], **changes): + """PATCH как у формы правки: состав передаётся целиком, даже если меняется комментарий.""" + places = changes.pop("places", (1, 2)) + body = { + "expected_version": client.get(f"/api/matches/{mid}").json()["version"], + "participants": [ + {"user_id": me["id"], "faction_id": fids[0], "place": places[0]}, + {"user_id": p2, "faction_id": fids[1], "place": places[1]}, + ], + **changes, + } + r = client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + + +def test_comment_edit_does_not_broadcast_ratings(client: TestClient, engine, monkeypatch): + """Правка без влияния на рейтинг — событие только группе: остальным игрокам незачем + перезапрашивать все рейтинговые витрины.""" + me, gid, p2, outsider, fids = _two_groups(client, engine) + mid = _finished_match(client, me, p2, gid, fids) + + published = _capture_events(monkeypatch) + _patch_match(client, mid, me, p2, fids, overall_comment="Хорошая партия") + assert _recipients(published, "match") == {me["id"], p2} + assert _recipients(published, "ratings") == set() + + +def test_result_edit_broadcasts_ratings(client: TestClient, engine, monkeypatch): + me, gid, p2, outsider, fids = _two_groups(client, engine) + mid = _finished_match(client, me, p2, gid, fids) + + published = _capture_events(monkeypatch) + _patch_match(client, mid, me, p2, fids, places=(2, 1)) + assert outsider in _recipients(published, "ratings") + + +def test_group_settings_change_reaches_members(client: TestClient, engine, monkeypatch): + """Переключение домашнего правила видно остальным участникам без перезагрузки.""" + me, gid, p2, outsider, fids = _two_groups(client, engine) + + published = _capture_events(monkeypatch) + r = client.patch( + f"/api/groups/{gid}", json={"nine_rounds_rule": True}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + assert _recipients(published, "group") == {me["id"], p2}