From 90320ffd5d8a8fcf634bfb95b21805fbaf770db8 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 21:21:43 +0300 Subject: [PATCH] feat(moderation): add chat bans list endpoint with admin access checks --- app/chats/repository.py | 7 +++++ app/chats/router.py | 11 ++++++++ app/chats/schemas.py | 7 +++++ app/chats/service.py | 23 ++++++++++++++++ docs/api-reference.md | 16 +++++++++++ docs/core-checklist-status.md | 2 +- tests/test_chat_message_flow.py | 49 +++++++++++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 1 deletion(-) diff --git a/app/chats/repository.py b/app/chats/repository.py index 2e2c901..1678556 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -434,3 +434,10 @@ async def remove_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> No ban = await get_chat_ban(db, chat_id=chat_id, user_id=user_id) if ban: await db.delete(ban) + + +async def list_chat_bans(db: AsyncSession, *, chat_id: int) -> list[ChatBan]: + result = await db.execute( + select(ChatBan).where(ChatBan.chat_id == chat_id).order_by(ChatBan.created_at.desc(), ChatBan.id.desc()) + ) + return list(result.scalars().all()) diff --git a/app/chats/router.py b/app/chats/router.py index bed6d92..0261258 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.chats.schemas import ( + ChatBanRead, ChatCreateRequest, ChatDetailRead, ChatDiscoverRead, @@ -38,6 +39,7 @@ from app.chats.service import ( leave_chat_for_user, pin_chat_message_for_user, remove_chat_member_for_user, + list_chat_bans_for_user, serialize_chat_for_user, serialize_chats_for_user, set_chat_archived_for_user, @@ -226,6 +228,15 @@ async def ban_chat_member( await realtime_gateway.publish_chat_updated(chat_id=chat_id) +@router.get("/{chat_id}/bans", response_model=list[ChatBanRead]) +async def list_chat_bans( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[ChatBanRead]: + return await list_chat_bans_for_user(db, chat_id=chat_id, actor_user_id=current_user.id) + + @router.delete("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def unban_chat_member( chat_id: int, diff --git a/app/chats/schemas.py b/app/chats/schemas.py index afd0e0e..cc3f47f 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -111,3 +111,10 @@ class ChatInviteLinkRead(BaseModel): class ChatJoinByInviteRequest(BaseModel): token: str = Field(min_length=8, max_length=64) + + +class ChatBanRead(BaseModel): + chat_id: int + user_id: int + banned_by_user_id: int + created_at: datetime diff --git a/app/chats/service.py b/app/chats/service.py index 84d3d9f..8302828 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.chats import repository from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.schemas import ( + ChatBanRead, ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, @@ -471,6 +472,28 @@ async def unban_chat_member_for_user( await db.commit() +async def list_chat_bans_for_user( + db: AsyncSession, + *, + chat_id: int, + actor_user_id: int, +) -> list[ChatBanRead]: + chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) + _ensure_group_or_channel(chat.type) + if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + bans = await repository.list_chat_bans(db, chat_id=chat_id) + return [ + ChatBanRead( + chat_id=ban.chat_id, + user_id=ban.user_id, + banned_by_user_id=ban.banned_by_user_id, + created_at=ban.created_at, + ) + for ban in bans + ] + + async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None: chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) _ensure_group_or_channel(chat.type) diff --git a/docs/api-reference.md b/docs/api-reference.md index e490f8c..e5177d0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -811,6 +811,22 @@ Auth required (`owner/admin` in group/channel). Response: `204` Behavior: bans user from chat and removes membership if present. +### GET `/api/v1/chats/{chat_id}/bans` + +Auth required (`owner/admin` in group/channel). +Response: `200` + `ChatBanRead[]` + +Example item: + +```json +{ + "chat_id": 42, + "user_id": 101, + "banned_by_user_id": 5, + "created_at": "2026-03-10T00:00:00Z" +} +``` + ### DELETE `/api/v1/chats/{chat_id}/bans/{user_id}` Auth required (`owner/admin` in group/channel). diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 438a30c..11795fe 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -31,7 +31,7 @@ Legend: 22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving) 23. Groups - `PARTIAL` (create/add/remove/invite link; join-by-invite and invite permissions covered by integration tests; advanced moderation partial) 24. Roles - `DONE` (owner/admin/member) -25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban API for groups/channels; integration tests cover channel member read-only, channel admin full-delete, channel message delete-for-all permissions, group profile edit permissions, and owner-only role management rules; remaining UX moderation tools limited) +25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban APIs for groups/channels including ban list endpoint; integration tests cover channel member read-only, channel admin full-delete, channel message delete-for-all permissions, group profile edit permissions, owner-only role management rules, and admin-visible/member-forbidden ban-list access; remaining UX moderation tools limited) 26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; UX edge-cases still polishing) 27. Channel Types - `DONE` (public/private) 28. Notifications - `PARTIAL` (browser notifications + mute/settings; chat mute is propagated in chat list payload, honored by web realtime notifications with mention override, and mute toggle now syncs instantly in chat store; backend now emits `chat_updated` after notification mute/unmute for cross-tab consistency; no mobile push infra) diff --git a/tests/test_chat_message_flow.py b/tests/test_chat_message_flow.py index 981616e..0771fc1 100644 --- a/tests/test_chat_message_flow.py +++ b/tests/test_chat_message_flow.py @@ -492,6 +492,55 @@ async def test_group_ban_blocks_rejoin(client, db_session): assert rejoin_response.status_code == 403 +async def test_group_ban_list_visible_to_admin_and_hidden_from_member(client, db_session): + owner = await _create_verified_user(client, db_session, "ban_list_owner@example.com", "ban_list_owner", "strongpass123") + admin = await _create_verified_user(client, db_session, "ban_list_admin@example.com", "ban_list_admin", "strongpass123") + member = await _create_verified_user(client, db_session, "ban_list_member@example.com", "ban_list_member", "strongpass123") + target = await _create_verified_user(client, db_session, "ban_list_target@example.com", "ban_list_target", "strongpass123") + + me_admin = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {admin['access_token']}"}) + me_member = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {member['access_token']}"}) + me_target = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {target['access_token']}"}) + admin_id = me_admin.json()["id"] + member_id = me_member.json()["id"] + target_id = me_target.json()["id"] + + create_group = await client.post( + "/api/v1/chats", + headers={"Authorization": f"Bearer {owner['access_token']}"}, + json={"type": ChatType.GROUP.value, "title": "Ban list group", "member_ids": [admin_id, member_id, target_id]}, + ) + assert create_group.status_code == 200 + chat_id = create_group.json()["id"] + + promote_admin = await client.patch( + f"/api/v1/chats/{chat_id}/members/{admin_id}/role", + headers={"Authorization": f"Bearer {owner['access_token']}"}, + json={"role": "admin"}, + ) + assert promote_admin.status_code == 200 + + ban_target = await client.post( + f"/api/v1/chats/{chat_id}/bans/{target_id}", + headers={"Authorization": f"Bearer {admin['access_token']}"}, + ) + assert ban_target.status_code == 204 + + list_by_admin = await client.get( + f"/api/v1/chats/{chat_id}/bans", + headers={"Authorization": f"Bearer {admin['access_token']}"}, + ) + assert list_by_admin.status_code == 200 + bans = list_by_admin.json() + assert any(item["user_id"] == target_id and item["banned_by_user_id"] == admin_id for item in bans) + + list_by_member = await client.get( + f"/api/v1/chats/{chat_id}/bans", + headers={"Authorization": f"Bearer {member['access_token']}"}, + ) + assert list_by_member.status_code == 403 + + async def test_channel_member_delete_chat_behaves_as_leave(client, db_session): owner = await _create_verified_user(client, db_session, "channel_owner@example.com", "channel_owner", "strongpass123") member = await _create_verified_user(client, db_session, "channel_member@example.com", "channel_member", "strongpass123")