feat(moderation): add chat bans list endpoint with admin access checks
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -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)
|
ban = await get_chat_ban(db, chat_id=chat_id, user_id=user_id)
|
||||||
if ban:
|
if ban:
|
||||||
await db.delete(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())
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.auth.service import get_current_user
|
from app.auth.service import get_current_user
|
||||||
from app.chats.schemas import (
|
from app.chats.schemas import (
|
||||||
|
ChatBanRead,
|
||||||
ChatCreateRequest,
|
ChatCreateRequest,
|
||||||
ChatDetailRead,
|
ChatDetailRead,
|
||||||
ChatDiscoverRead,
|
ChatDiscoverRead,
|
||||||
@@ -38,6 +39,7 @@ from app.chats.service import (
|
|||||||
leave_chat_for_user,
|
leave_chat_for_user,
|
||||||
pin_chat_message_for_user,
|
pin_chat_message_for_user,
|
||||||
remove_chat_member_for_user,
|
remove_chat_member_for_user,
|
||||||
|
list_chat_bans_for_user,
|
||||||
serialize_chat_for_user,
|
serialize_chat_for_user,
|
||||||
serialize_chats_for_user,
|
serialize_chats_for_user,
|
||||||
set_chat_archived_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)
|
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)
|
@router.delete("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def unban_chat_member(
|
async def unban_chat_member(
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
|
|||||||
@@ -111,3 +111,10 @@ class ChatInviteLinkRead(BaseModel):
|
|||||||
|
|
||||||
class ChatJoinByInviteRequest(BaseModel):
|
class ChatJoinByInviteRequest(BaseModel):
|
||||||
token: str = Field(min_length=8, max_length=64)
|
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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.chats import repository
|
from app.chats import repository
|
||||||
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
|
||||||
from app.chats.schemas import (
|
from app.chats.schemas import (
|
||||||
|
ChatBanRead,
|
||||||
ChatCreateRequest,
|
ChatCreateRequest,
|
||||||
ChatDeleteRequest,
|
ChatDeleteRequest,
|
||||||
ChatDiscoverRead,
|
ChatDiscoverRead,
|
||||||
@@ -471,6 +472,28 @@ async def unban_chat_member_for_user(
|
|||||||
await db.commit()
|
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:
|
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)
|
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
|
||||||
_ensure_group_or_channel(chat.type)
|
_ensure_group_or_channel(chat.type)
|
||||||
|
|||||||
@@ -811,6 +811,22 @@ Auth required (`owner/admin` in group/channel).
|
|||||||
Response: `204`
|
Response: `204`
|
||||||
Behavior: bans user from chat and removes membership if present.
|
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}`
|
### DELETE `/api/v1/chats/{chat_id}/bans/{user_id}`
|
||||||
|
|
||||||
Auth required (`owner/admin` in group/channel).
|
Auth required (`owner/admin` in group/channel).
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Legend:
|
|||||||
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving)
|
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)
|
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)
|
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)
|
26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; UX edge-cases still polishing)
|
||||||
27. Channel Types - `DONE` (public/private)
|
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)
|
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)
|
||||||
|
|||||||
@@ -492,6 +492,55 @@ async def test_group_ban_blocks_rejoin(client, db_session):
|
|||||||
assert rejoin_response.status_code == 403
|
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):
|
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")
|
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")
|
member = await _create_verified_user(client, db_session, "channel_member@example.com", "channel_member", "strongpass123")
|
||||||
|
|||||||
Reference in New Issue
Block a user