moderation: add chat bans for groups/channels with web actions
All checks were successful
CI / test (push) Successful in 26s

This commit is contained in:
2026-03-08 14:29:21 +03:00
parent 76cc5e0f12
commit db700bcbcd
10 changed files with 224 additions and 3 deletions

View File

@@ -108,3 +108,14 @@ class ChatInviteLink(Base):
token: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class ChatBan(Base):
__tablename__ = "chat_bans"
__table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
banned_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select
from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
from app.chats.models import Chat, ChatBan, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting
from app.messages.models import Message, MessageHidden, MessageReceipt
@@ -401,3 +401,31 @@ async def get_active_chat_invite_by_token(db: AsyncSession, *, token: str) -> Ch
.limit(1)
)
return result.scalar_one_or_none()
async def get_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatBan | None:
result = await db.execute(select(ChatBan).where(ChatBan.chat_id == chat_id, ChatBan.user_id == user_id))
return result.scalar_one_or_none()
async def is_user_banned_in_chat(db: AsyncSession, *, chat_id: int, user_id: int) -> bool:
result = await db.execute(select(ChatBan.id).where(ChatBan.chat_id == chat_id, ChatBan.user_id == user_id).limit(1))
return result.scalar_one_or_none() is not None
async def upsert_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int, banned_by_user_id: int) -> ChatBan:
existing = await get_chat_ban(db, chat_id=chat_id, user_id=user_id)
if existing:
existing.banned_by_user_id = banned_by_user_id
await db.flush()
return existing
ban = ChatBan(chat_id=chat_id, user_id=user_id, banned_by_user_id=banned_by_user_id)
db.add(ban)
await db.flush()
return ban
async def remove_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> None:
ban = await get_chat_ban(db, chat_id=chat_id, user_id=user_id)
if ban:
await db.delete(ban)

View File

@@ -21,6 +21,7 @@ from app.chats.schemas import (
)
from app.chats.service import (
add_chat_member_for_user,
ban_chat_member_for_user,
create_chat_for_user,
create_chat_invite_link_for_user,
clear_chat_for_user,
@@ -39,6 +40,7 @@ from app.chats.service import (
serialize_chats_for_user,
set_chat_archived_for_user,
set_chat_pinned_for_user,
unban_chat_member_for_user,
update_chat_member_role_for_user,
update_chat_notification_settings_for_user,
update_chat_profile_for_user,
@@ -207,6 +209,29 @@ async def remove_chat_member(
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
@router.post("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def ban_chat_member(
chat_id: int,
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
await ban_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id)
realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=user_id)
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
@router.delete("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unban_chat_member(
chat_id: int,
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
await unban_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id)
await realtime_gateway.publish_chat_updated(chat_id=chat_id)
@router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
async def leave_chat(
chat_id: int,

View File

@@ -350,6 +350,8 @@ async def add_chat_member_for_user(
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id)
_ensure_group_or_channel(chat.type)
_ensure_manage_permission(membership.role)
if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=target_user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is banned from this chat")
if target_user_id == actor_user_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat")
target_user = await get_user_by_id(db, target_user_id)
@@ -427,6 +429,45 @@ async def remove_chat_member_for_user(
await db.commit()
async def ban_chat_member_for_user(
db: AsyncSession,
*,
chat_id: int,
actor_user_id: int,
target_user_id: int,
) -> None:
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")
if target_user_id == actor_user_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot ban yourself")
target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id)
if target_membership:
if target_membership.role == ChatMemberRole.OWNER:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be banned")
if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can ban only members")
await repository.delete_chat_member(db, target_membership)
await repository.upsert_chat_ban(db, chat_id=chat_id, user_id=target_user_id, banned_by_user_id=actor_user_id)
await db.commit()
async def unban_chat_member_for_user(
db: AsyncSession,
*,
chat_id: int,
actor_user_id: int,
target_user_id: int,
) -> None:
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")
await repository.remove_chat_ban(db, chat_id=chat_id, user_id=target_user_id)
await db.commit()
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)
@@ -514,6 +555,8 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat")
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if membership:
return chat
@@ -650,6 +693,8 @@ async def join_chat_by_invite_for_user(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat")
if await repository.is_user_banned_in_chat(db, chat_id=chat.id, user_id=user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat")
membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id)
if membership:
return chat