From 071eeb0bfa65a3dc56e53638638eb3cbfc5c4704 Mon Sep 17 00:00:00 2001 From: roai_linux Date: Sat, 28 Mar 2026 20:49:16 +0330 Subject: [PATCH] feat: refactor groups --- .../36ed70229177_add_owner_id_to_group.py | 57 ++++++++ Back/domains/admin/api.py | 11 +- Back/domains/groups/api.py | 133 ------------------ Back/domains/groups/api/__init__.py | 6 + Back/domains/groups/api/admin.py | 111 +++++++++++++++ Back/domains/groups/api/client.py | 90 ++++++++++++ Back/domains/groups/models.py | 6 + Back/domains/groups/repo.py | 8 ++ Back/domains/groups/schemas.py | 12 +- Back/domains/groups/service.py | 61 ++++++-- Back/domains/realtime/ws.py | 2 +- Back/main.py | 10 +- Back/tests/test_groups_service.py | 108 ++++++++++++++ 13 files changed, 456 insertions(+), 159 deletions(-) create mode 100644 Back/alembic/versions/36ed70229177_add_owner_id_to_group.py delete mode 100644 Back/domains/groups/api.py create mode 100644 Back/domains/groups/api/__init__.py create mode 100644 Back/domains/groups/api/admin.py create mode 100644 Back/domains/groups/api/client.py create mode 100644 Back/tests/test_groups_service.py diff --git a/Back/alembic/versions/36ed70229177_add_owner_id_to_group.py b/Back/alembic/versions/36ed70229177_add_owner_id_to_group.py new file mode 100644 index 0000000..1e239f0 --- /dev/null +++ b/Back/alembic/versions/36ed70229177_add_owner_id_to_group.py @@ -0,0 +1,57 @@ +"""add owner_id to group + +Revision ID: 36ed70229177 +Revises: b5ace31192c3 +Create Date: 2026-03-28 13:26:31.817104 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '36ed70229177' +down_revision: Union[str, Sequence[str], None] = 'b5ace31192c3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 1. Add column as nullable + op.add_column('groups', sa.Column('owner_id', sa.UUID(), nullable=True)) + op.create_index(op.f('ix_groups_owner_id'), 'groups', ['owner_id'], unique=False) + op.create_foreign_key(None, 'groups', 'users', ['owner_id'], ['id'], ondelete='CASCADE') + + # 2. Update existing groups with an owner (using the first manager or any member) + op.execute(""" + UPDATE groups g + SET owner_id = ( + SELECT user_id + FROM group_members + WHERE group_id = g.id + ORDER BY (CASE WHEN role::text = 'MANAGER' THEN 0 ELSE 1 END), user_id + LIMIT 1 + ) + """) + + # 3. For any group that still has NULL (unlikely), set to the first user in the system + op.execute(""" + UPDATE groups + SET owner_id = (SELECT id FROM users LIMIT 1) + WHERE owner_id IS NULL + """) + + # 4. Set NOT NULL constraint + op.alter_column('groups', 'owner_id', nullable=False) + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'groups', type_='foreignkey') + op.drop_index(op.f('ix_groups_owner_id'), table_name='groups') + op.drop_column('groups', 'owner_id') + # ### end Alembic commands ### diff --git a/Back/domains/admin/api.py b/Back/domains/admin/api.py index c182ddf..b06a3cf 100644 --- a/Back/domains/admin/api.py +++ b/Back/domains/admin/api.py @@ -11,9 +11,6 @@ from domains.admin.schemas import ( AdminUserResponse ) -from domains.groups.schemas import GroupResponse -from domains.groups.repo import get_all_groups - from domains.admin.service import ( admin_create_user, admin_reset_user_secret @@ -102,12 +99,8 @@ async def list_users( return await get_all_users(db, include_admin=True) -@router.get("/groups", response_model=list[GroupResponse]) -async def list_groups( - db: AsyncSession = Depends(get_db), - admin=Depends(get_current_admin) -): - return await get_all_groups(db) + return await get_all_users(db, include_admin=True) + @router.get("/notifications", response_model=list[NotificationResponse]) async def list_notifications( diff --git a/Back/domains/groups/api.py b/Back/domains/groups/api.py deleted file mode 100644 index 35f51d8..0000000 --- a/Back/domains/groups/api.py +++ /dev/null @@ -1,133 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from db.session import get_db -from core.deps import get_current_admin, get_current_user - -from domains.groups.schemas import ( - GroupCreate, - GroupResponse, - AddMemberRequest, - GroupMemberResponse -) - -from domains.groups.service import ( - create_new_group, - list_user_groups, - list_all_groups_admin, - list_group_members_api, - invite_member_to_group, - remove_member_from_group -) - - -router = APIRouter( - prefix="/groups", - tags=["groups"] -) - - -@router.post( - "/", - response_model=GroupResponse -) -async def create_group( - payload: GroupCreate, - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user) -): - """ - Admin creates Public groups, Regular users create Private groups. - """ - group = await create_new_group( - db, - payload.name, - user.id, - user.is_admin - ) - - return group - - -@router.get("/my", response_model=list[GroupResponse]) -async def my_groups( - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user) -): - """ - List groups the user is a member of. - """ - return await list_user_groups(db, user.id) - - -@router.get("/admin/all", response_model=list[GroupResponse]) -async def list_all_groups( - db: AsyncSession = Depends(get_db), - admin=Depends(get_current_admin) -): - """ - Admin can see all groups. - """ - return await list_all_groups_admin(db) - - -@router.get("/{group_id}/members", response_model=list[GroupMemberResponse]) -async def group_members( - group_id: str, - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user) -): - """ - List group members with username and online status. - """ - return await list_group_members_api(db, group_id) - - -@router.post("/{group_id}/invite") -async def invite_member( - group_id: str, - payload: AddMemberRequest, - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user) -): - """ - Invite a user by username. Sends a notification. - """ - try: - notification = await invite_member_to_group( - db, - group_id, - user.id, - payload.username - ) - return {"message": "دعوت ارسال شد", "notification_id": notification.id} - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - - -@router.delete("/{group_id}/members/{user_id}") -async def remove_member( - group_id: str, - user_id: str, - db: AsyncSession = Depends(get_db), - user=Depends(get_current_user) -): - """ - Admin or Group Manager can remove a member. - """ - try: - await remove_member_from_group( - db, - group_id, - user_id, - user - ) - return {"message": "عضو با موفقیت حذف شد"} - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(e) - ) \ No newline at end of file diff --git a/Back/domains/groups/api/__init__.py b/Back/domains/groups/api/__init__.py new file mode 100644 index 0000000..23bd794 --- /dev/null +++ b/Back/domains/groups/api/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +from domains.groups.api.admin import router as admin_router +from domains.groups.api.client import router as client_router + +# The API is split into two specialized routers +# This will be included in main.py diff --git a/Back/domains/groups/api/admin.py b/Back/domains/groups/api/admin.py new file mode 100644 index 0000000..60d6585 --- /dev/null +++ b/Back/domains/groups/api/admin.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +from db.session import get_db +from core.deps import get_current_admin +from domains.groups.schemas import ( + AdminGroupCreate, + GroupResponse, + AdminAddMemberRequest, + GroupMemberResponse +) +from domains.groups.service import ( + create_admin_group, + list_all_groups_admin, + add_member_to_group, + list_group_members_api, + remove_member_from_group, + delete_group_service +) + +router = APIRouter( + prefix="/admin/groups", + tags=["admin-groups"] +) + +@router.post("/", response_model=GroupResponse) +async def admin_create_group( + payload: AdminGroupCreate, + db: AsyncSession = Depends(get_db), + admin=Depends(get_current_admin) +): + """ + Admin always creates public groups and is not auto-added as a member. + """ + return await create_admin_group( + db, + name=payload.name, + owner_id=admin.id + ) + +@router.get("/", response_model=list[GroupResponse]) +async def list_all_groups( + db: AsyncSession = Depends(get_db), + admin=Depends(get_current_admin) +): + """ + List all groups in the system. + """ + return await list_all_groups_admin(db) + +@router.get("/{group_id}/members", response_model=list[GroupMemberResponse]) +async def admin_list_members( + group_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + admin=Depends(get_current_admin) +): + return await list_group_members_api(db, group_id) + +@router.post("/{group_id}/members", response_model=None) +async def force_add_member( + group_id: uuid.UUID, + payload: AdminAddMemberRequest, + db: AsyncSession = Depends(get_db), + admin=Depends(get_current_admin) +): + """ + Force add a user to a group with a specific role. Bypasses invitation. + """ + from domains.users.repo import get_user_by_username + + target_user = await get_user_by_username(db, payload.username) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + try: + await add_member_to_group(db, group_id, target_user.id, payload.role) + return {"message": f"User {payload.username} added successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.delete("/{group_id}/members/{user_id}") +async def admin_remove_member( + group_id: uuid.UUID, + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + admin=Depends(get_current_admin) +): + """ + Admin can remove any user from any group. + """ + try: + await remove_member_from_group(db, group_id, user_id, admin) + return {"message": "Member removed successfully"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.delete("/{group_id}") +async def admin_delete_group( + group_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + admin=Depends(get_current_admin) +): + """ + Admin can delete any group. + """ + try: + await delete_group_service(db, group_id, admin) + return {"message": "Group deleted successfully"} + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) diff --git a/Back/domains/groups/api/client.py b/Back/domains/groups/api/client.py new file mode 100644 index 0000000..dd41c8d --- /dev/null +++ b/Back/domains/groups/api/client.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +import uuid + +from db.session import get_db +from core.deps import get_current_user +from domains.groups.schemas import ( + GroupCreate, + GroupResponse, + AddMemberRequest, + GroupMemberResponse +) +from domains.groups.service import ( + create_user_group, + list_user_groups, + list_group_members_api, + invite_member_to_group, + delete_group_service +) + +router = APIRouter( + prefix="/groups", + tags=["groups"] +) + +@router.post("/", response_model=GroupResponse) +async def user_create_group( + payload: GroupCreate, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user) +): + """ + Regular users always create private groups and become the first manager. + """ + return await create_user_group( + db, + name=payload.name, + owner_id=user.id + ) + +@router.get("/my", response_model=list[GroupResponse]) +async def my_groups( + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user) +): + """ + List groups the current user is a member of. + """ + return await list_user_groups(db, user.id) + +@router.get("/{group_id}/members", response_model=list[GroupMemberResponse]) +async def list_members( + group_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user) +): + return await list_group_members_api(db, str(group_id)) + +@router.post("/{group_id}/invite") +async def invite_member( + group_id: uuid.UUID, + payload: AddMemberRequest, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user) +): + try: + notification = await invite_member_to_group( + db, + group_id, + user.id, + payload.username + ) + return {"message": "Invitation sent", "notification_id": str(notification.id)} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.delete("/{group_id}") +async def delete_my_group( + group_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user) +): + """ + Only the Group Owner (creator) can delete the group. + """ + try: + await delete_group_service(db, group_id, user) + return {"message": "Group deleted successfully"} + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) diff --git a/Back/domains/groups/models.py b/Back/domains/groups/models.py index dea0c26..afec058 100644 --- a/Back/domains/groups/models.py +++ b/Back/domains/groups/models.py @@ -38,6 +38,12 @@ class Group(Base): default=True, index=True ) + + owner_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True + ) # Relationship to members members: Mapped[list["GroupMember"]] = relationship(back_populates="group", cascade="all, delete-orphan") diff --git a/Back/domains/groups/repo.py b/Back/domains/groups/repo.py index a8c8414..ff80465 100644 --- a/Back/domains/groups/repo.py +++ b/Back/domains/groups/repo.py @@ -72,3 +72,11 @@ async def delete_group_member(db: AsyncSession, group_id: uuid.UUID, user_id: uu .where(GroupMember.user_id == user_id) ) await db.commit() + +async def delete_group(db: AsyncSession, group_id: uuid.UUID): + from sqlalchemy import delete + await db.execute( + delete(Group) + .where(Group.id == group_id) + ) + await db.commit() diff --git a/Back/domains/groups/schemas.py b/Back/domains/groups/schemas.py index ae14e36..3549eb3 100644 --- a/Back/domains/groups/schemas.py +++ b/Back/domains/groups/schemas.py @@ -6,10 +6,15 @@ from domains.groups.models import GroupType, GroupMemberRole class GroupCreate(BaseModel): name: str = Field(..., max_length=50, description='name of the group') + +class AdminGroupCreate(BaseModel): + name: str = Field(..., max_length=50, description='name of the group') + class GroupResponse(BaseModel): id: uuid.UUID = Field(...) name: str = Field(..., max_length=50, description='name of the group') type: GroupType = Field(..., description='type of the group') + owner_id: uuid.UUID = Field(...) is_active: bool = Field(..., description='is active') class Config: @@ -18,6 +23,11 @@ class GroupResponse(BaseModel): class AddMemberRequest(BaseModel): username: str = Field(..., max_length=20, description='username of the user') + +class AdminAddMemberRequest(BaseModel): + username: str = Field(..., max_length=20, description='username of the user') + role: GroupMemberRole = Field(GroupMemberRole.MEMBER, description='role of the user in the group') + class GroupMemberResponse(BaseModel): user_id: uuid.UUID = Field(...) username: str = Field(..., max_length=20, description='username of the user') @@ -25,4 +35,4 @@ class GroupMemberResponse(BaseModel): is_online: bool = Field(False, description='is online') class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/Back/domains/groups/service.py b/Back/domains/groups/service.py index 5562d45..e6fbd85 100644 --- a/Back/domains/groups/service.py +++ b/Back/domains/groups/service.py @@ -11,6 +11,7 @@ from domains.groups.repo import ( get_user_groups, get_group_members_with_details, delete_group_member, + delete_group, get_group_member, get_all_groups as repo_get_all_groups ) @@ -18,25 +19,24 @@ from domains.realtime.presence_service import list_online_users from domains.users.repo import get_user_by_username from domains.notifications.service import send_join_request -async def create_new_group( + +async def create_user_group( db: AsyncSession, name: str, - creator_id: uuid.UUID, - is_admin: bool + owner_id: uuid.UUID, ): - group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE - group = Group( name=name, - type=group_type + type=GroupType.PRIVATE, + owner_id=owner_id ) await create_group(db, group) - # Creator becomes Manager + # Owner becomes Manager membership = GroupMember( group_id=group.id, - user_id=creator_id, + user_id=owner_id, role=GroupMemberRole.MANAGER ) await add_group_member(db, membership) @@ -44,6 +44,21 @@ async def create_new_group( return group +async def create_admin_group( + db: AsyncSession, + name: str, + owner_id: uuid.UUID, +): + group = Group( + name=name, + type=GroupType.PUBLIC, + owner_id=owner_id + ) + + await create_group(db, group) + return group + + async def invite_member_to_group( db: AsyncSession, group_id: str | uuid.UUID, @@ -116,10 +131,10 @@ async def list_all_groups_admin(db: AsyncSession): return await repo_get_all_groups(db) -async def list_group_members_api(db: AsyncSession, group_id: str): - group_id_uuid = uuid.UUID(group_id) +async def list_group_members_api(db: AsyncSession, group_id: str | uuid.UUID): + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) members_data = await get_group_members_with_details(db, group_id_uuid) - online_users = await list_online_users(str(group_id)) + online_users = await list_online_users(str(group_id_uuid)) result = [] for member_info in members_data: @@ -148,4 +163,28 @@ async def remove_member_from_group( if not membership or membership.role != GroupMemberRole.MANAGER: raise ValueError("دسترسی لازم را ندارید") + group = await get_group_by_id(db, group_id_uuid) + if not group: + raise ValueError("گروهی یافت نشد") + if group.owner_id == target_user_id_uuid: + raise ValueError("حذف سازنده گروه مجاز نیست") + await delete_group_member(db, group_id_uuid, target_user_id_uuid) + + +async def delete_group_service( + db: AsyncSession, + group_id: str | uuid.UUID, + requesting_user +): + group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) + + group = await get_group_by_id(db, group_id_uuid) + if not group: + raise ValueError("گروهی یافت نشد") + + # Permission check: System Admin or Group Owner + if not requesting_user.is_admin and group.owner_id != requesting_user.id: + raise ValueError("شما دسترسی لازم برای حذف گروه را ندارید (فقط سازنده گروه یا ادمین سیستم)") + + await delete_group(db, group_id_uuid) diff --git a/Back/domains/realtime/ws.py b/Back/domains/realtime/ws.py index c610f74..a55b0b2 100644 --- a/Back/domains/realtime/ws.py +++ b/Back/domains/realtime/ws.py @@ -124,7 +124,7 @@ async def group_ws(websocket: WebSocket, group_id: str): # anti spam current_time = time.time() - if current_time - last_action_time < 0.5: + if current_time - last_action_time < 0.1: continue last_action_time = current_time diff --git a/Back/main.py b/Back/main.py index 64322e5..c28263c 100644 --- a/Back/main.py +++ b/Back/main.py @@ -6,7 +6,8 @@ from fastapi_swagger import patch_fastapi from domains.auth.api import router as auth_router from domains.users.api import router as users_router from domains.admin.api import router as admin_router -from domains.groups.api import router as groups_router +from domains.groups.api.admin import router as groups_admin_router +from domains.groups.api.client import router as groups_client_router from domains.realtime.ws import router as realtime_router from domains.notifications.api import router as notifications_router from integrations.livekit.client import close_livekit_api @@ -34,7 +35,7 @@ async def lifespan(app: FastAPI): await close_livekit_api() await redis_client.close() -global_limiter = RateLimiter(requests=30, window_seconds=60, scope="global") +global_limiter = RateLimiter(requests=120, window_seconds=60, scope="global") app = FastAPI( title="NEDA API", @@ -76,6 +77,7 @@ app.add_middleware( app.include_router(auth_router) app.include_router(users_router) app.include_router(admin_router) -app.include_router(groups_router) +app.include_router(groups_client_router) +app.include_router(groups_admin_router) app.include_router(realtime_router) -app.include_router(notifications_router) \ No newline at end of file +app.include_router(notifications_router) diff --git a/Back/tests/test_groups_service.py b/Back/tests/test_groups_service.py new file mode 100644 index 0000000..7f7b499 --- /dev/null +++ b/Back/tests/test_groups_service.py @@ -0,0 +1,108 @@ +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from domains.groups.models import GroupMemberRole, GroupType +from domains.groups import service + + +@pytest.mark.asyncio +async def test_create_user_group_creates_private_group_and_owner_membership(monkeypatch): + created_group = None + added_membership = None + + async def fake_create_group(db, group): + nonlocal created_group + created_group = group + group.id = uuid.uuid4() + return group + + async def fake_add_group_member(db, membership): + nonlocal added_membership + added_membership = membership + return membership + + monkeypatch.setattr(service, "create_group", fake_create_group) + monkeypatch.setattr(service, "add_group_member", fake_add_group_member) + + owner_id = uuid.uuid4() + group = await service.create_user_group(object(), "team-alpha", owner_id) + + assert group.type == GroupType.PRIVATE + assert group.owner_id == owner_id + assert created_group is group + assert added_membership is not None + assert added_membership.user_id == owner_id + assert added_membership.role == GroupMemberRole.MANAGER + + +@pytest.mark.asyncio +async def test_create_admin_group_creates_public_group_without_membership(monkeypatch): + create_group_mock = AsyncMock(side_effect=lambda db, group: setattr(group, "id", uuid.uuid4()) or group) + add_group_member_mock = AsyncMock() + + monkeypatch.setattr(service, "create_group", create_group_mock) + monkeypatch.setattr(service, "add_group_member", add_group_member_mock) + + owner_id = uuid.uuid4() + group = await service.create_admin_group(object(), "ops-room", owner_id) + + assert group.type == GroupType.PUBLIC + assert group.owner_id == owner_id + add_group_member_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_list_group_members_api_returns_only_real_members(monkeypatch): + group_id = uuid.uuid4() + member_id = uuid.uuid4() + members_data = [ + ( + SimpleNamespace( + user_id=member_id, + role=GroupMemberRole.MEMBER, + ), + "neda-user", + ) + ] + + monkeypatch.setattr( + service, + "get_group_members_with_details", + AsyncMock(return_value=members_data), + ) + monkeypatch.setattr( + service, + "list_online_users", + AsyncMock(return_value=[str(member_id)]), + ) + + response = await service.list_group_members_api(object(), group_id) + + assert response == [ + { + "user_id": member_id, + "username": "neda-user", + "role": GroupMemberRole.MEMBER, + "is_online": True, + } + ] + + +def test_group_routes_are_namespaced(): + from main import app + + route_map = { + (route.path, method) + for route in app.routes + if getattr(route, "methods", None) + for method in route.methods + } + + assert ("/groups/", "POST") in route_map + assert ("/admin/groups/", "POST") in route_map + assert ("/admin/groups/", "GET") in route_map + assert ("/admin/groups/{group_id}/members", "GET") in route_map + assert ("/admin/groups/{group_id}/members", "POST") in route_map