Compare commits

...

10 Commits

22 changed files with 710 additions and 172 deletions

View File

@ -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 ###

View File

@ -10,10 +10,12 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_DAYS: int = 1 ACCESS_TOKEN_EXPIRE_DAYS: int = 1
REFRESH_TOKEN_EXPIRE_WEEKS: int = 12 REFRESH_TOKEN_EXPIRE_WEEKS: int = 12
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
SECRET_PASS_LENGTH: int SECRET_PASS_LENGTH: int = 4
DATABASE_URL: str DATABASE_URL: str
REDIS_URL: str REDIS_URL: str
REDIS_USERNAME: str
REDIS_PASSWORD: str
LIVEKIT_API_KEY: str LIVEKIT_API_KEY: str
LIVEKIT_API_SECRET: str LIVEKIT_API_SECRET: str

38
Back/core/logger.py Normal file
View File

@ -0,0 +1,38 @@
import logging
import os
from typing import Final
DEFAULT_LOG_LEVEL: Final[str] = "INFO"
DEFAULT_LOG_FORMAT: Final[str] = (
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
_configured = False
def _resolve_log_level() -> int:
level_name = os.getenv("LOG_LEVEL", DEFAULT_LOG_LEVEL).upper()
return getattr(logging, level_name, logging.INFO)
def setup_logging() -> None:
global _configured
if _configured:
return
root_logger = logging.getLogger()
root_logger.setLevel(_resolve_log_level())
if not root_logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
root_logger.addHandler(handler)
_configured = True
def get_logger(name: str) -> logging.Logger:
setup_logging()
return logging.getLogger(name)

View File

@ -1,6 +1,7 @@
# core/rate_limit.py # core/rate_limit.py
from fastapi import Request, HTTPException, status from fastapi import Request, WebSocket, HTTPException, status
from starlette.requests import HTTPConnection
from db.redis import redis_client from db.redis import redis_client
class RateLimiter: class RateLimiter:
@ -14,37 +15,28 @@ class RateLimiter:
self.window_seconds = window_seconds self.window_seconds = window_seconds
self.scope = scope self.scope = scope
async def __call__(self, request: Request): async def __call__(self, connection: HTTPConnection):
# getting client ip client_ip = connection.client.host if connection.client else "127.0.0.1"
client_ip = request.client.host if request.client else "127.0.0.1"
# when project is in docker and behind nginx, the real ip is in the headers real_ip = connection.headers.get("x-real-ip", connection.headers.get("x-forwarded-for", client_ip))
real_ip = request.headers.get("x-real-ip", request.headers.get("x-forwarded-for", client_ip))
# if there are multiple ips, take the first ip (the real user ip)
real_ip = real_ip.split(",")[0].strip() real_ip = real_ip.split(",")[0].strip()
# creating redis key based on scope
if self.scope == "global": if self.scope == "global":
# key for global limit (e.g., rate_limit:global:192.168.1.5)
key = f"rate_limit:global:{real_ip}" key = f"rate_limit:global:{real_ip}"
else: else:
# key for endpoint limit (e.g., rate_limit:endpoint:192.168.1.5:/admin/login) path = connection.scope["path"]
path = request.scope["path"]
key = f"rate_limit:endpoint:{real_ip}:{path}" key = f"rate_limit:endpoint:{real_ip}:{path}"
# adding 1 to the number of requests for this ip in redis
current_count = await redis_client.incr(key) current_count = await redis_client.incr(key)
# if this is the first request in this time window, set the expiration time (TTL)
if current_count == 1: if current_count == 1:
await redis_client.expire(key, self.window_seconds) await redis_client.expire(key, self.window_seconds)
# if the number of requests exceeds the limit, access is blocked
if current_count > self.requests: if current_count > self.requests:
# penalty: if someone spams, the time they are blocked is extended from zero again
await redis_client.expire(key, self.window_seconds) await redis_client.expire(key, self.window_seconds)
# If it's a WebSocket connection, we might want to raise WebSocketException
# But Starlette's HTTPException is also handled by FastAPI for WebSockets by closing the connection.
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many requests. Please try again later." detail="تعداد درخواست‌های شما از حد مجاز گذشته است. لطفاً بعداً دوباره تلاش کنید."
) )

View File

@ -9,7 +9,10 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: always restart: always
ports:
- "5432:5432"
networks:
- internal
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ]
interval: 5s interval: 5s
@ -22,9 +25,13 @@ services:
volumes: volumes:
- redis_data:/data - redis_data:/data
restart: always restart: always
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- "6379:6379"
networks:
- internal
healthcheck: healthcheck:
test: [ "CMD", "redis-cli", "ping" ] test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
@ -32,33 +39,20 @@ services:
livekit: livekit:
image: livekit/livekit-server image: livekit/livekit-server
container_name: neda_livekit container_name: neda_livekit
ports: network_mode: "host"
- "7780:7880"
- "7781:7881"
- "50000-50100:50000-50100/udp"
env_file: env_file:
- .env - .env
volumes: volumes:
- ./livekit.yaml:/etc/livekit/livekit.yaml - ./livekit.yaml:/etc/livekit/livekit.yaml
- /etc/letsencrypt:/etc/letsencrypt:ro # uncomment when using letsencrypt
command: [ "--config", "/etc/livekit/livekit.yaml", "--keys", "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" ] command: [ "--config", "/etc/livekit/livekit.yaml", "--keys", "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" ]
restart: always restart: always
healthcheck:
pgadmin: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:7780/health" ]
image: dpage/pgadmin4:latest interval: 10s
container_name: neda_pgadmin timeout: 5s
restart: always retries: 3
ports: start_period: 10s
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
PGADMIN_CONFIG_SERVER_MODE: 'True'
PGADMIN_CONFIG_UPGRADE_CHECK_ENABLED: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
pg_backup: pg_backup:
image: prodrigestivill/postgres-backup-local image: prodrigestivill/postgres-backup-local
@ -75,13 +69,14 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
SCHEDULE: '@daily' SCHEDULE: '@daily'
BACKUP_KEEP_DAYS: 7 BACKUP_KEEP_DAYS: 7
networks:
- internal
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
api: api:
build: . image: back-api
container_name: neda_api container_name: neda_api
ports: ports:
- "8000:8000" - "8000:8000"
@ -89,6 +84,9 @@ services:
- "./:/app" - "./:/app"
env_file: env_file:
- .env - .env
networks:
- public
- internal
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -98,6 +96,13 @@ services:
condition: service_started condition: service_started
restart: always restart: always
networks:
public:
driver: bridge
internal:
driver: bridge
internal: true
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

View File

@ -11,9 +11,6 @@ from domains.admin.schemas import (
AdminUserResponse AdminUserResponse
) )
from domains.groups.schemas import GroupResponse
from domains.groups.repo import get_all_groups
from domains.admin.service import ( from domains.admin.service import (
admin_create_user, admin_create_user,
admin_reset_user_secret admin_reset_user_secret
@ -102,12 +99,8 @@ async def list_users(
return await get_all_users(db, include_admin=True) return await get_all_users(db, include_admin=True)
@router.get("/groups", response_model=list[GroupResponse]) return await get_all_users(db, include_admin=True)
async def list_groups(
db: AsyncSession = Depends(get_db),
admin=Depends(get_current_admin)
):
return await get_all_groups(db)
@router.get("/notifications", response_model=list[NotificationResponse]) @router.get("/notifications", response_model=list[NotificationResponse])
async def list_notifications( async def list_notifications(

View File

@ -11,12 +11,10 @@ from domains.users.repo import (
) )
from core.security import hash_password from core.security import hash_password
from core.config import settings
def generate_user_secret(): def generate_user_secret():
# return secrets.token_urlsafe(16) return secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
#for test
return "1234"
async def admin_create_user( async def admin_create_user(
db: AsyncSession, db: AsyncSession,

View File

@ -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

View File

@ -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))

View File

@ -1,98 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from db.session import get_db from db.session import get_db
from core.deps import get_current_admin, get_current_user from core.deps import get_current_user
from domains.groups.schemas import ( from domains.groups.schemas import (
GroupCreate, GroupCreate,
GroupResponse, GroupResponse,
AddMemberRequest, AddMemberRequest,
GroupMemberResponse GroupMemberResponse,
) )
from domains.groups.service import ( from domains.groups.service import (
create_new_group, create_user_group,
list_user_groups, list_user_groups,
list_all_groups_admin,
list_group_members_api, list_group_members_api,
invite_member_to_group, invite_member_to_group,
delete_group_service,
remove_member_from_group remove_member_from_group
) )
router = APIRouter( router = APIRouter(
prefix="/groups", prefix="/groups",
tags=["groups"] tags=["groups"]
) )
@router.post("/", response_model=GroupResponse)
@router.post( async def user_create_group(
"/",
response_model=GroupResponse
)
async def create_group(
payload: GroupCreate, payload: GroupCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
""" """
Admin creates Public groups, Regular users create Private groups. Regular users always create private groups and become the first manager.
""" """
group = await create_new_group( return await create_user_group(
db, db,
payload.name, name=payload.name,
user.id, owner_id=user.id
user.is_admin
) )
return group
@router.get("/my", response_model=list[GroupResponse]) @router.get("/my", response_model=list[GroupResponse])
async def my_groups( async def my_groups(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
""" """
List groups the user is a member of. List groups the current user is a member of.
""" """
return await list_user_groups(db, user.id) 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]) @router.get("/{group_id}/members", response_model=list[GroupMemberResponse])
async def group_members( async def list_members(
group_id: str, group_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
""" return await list_group_members_api(db, str(group_id))
List group members with username and online status.
"""
return await list_group_members_api(db, group_id)
@router.post("/{group_id}/invite") @router.post("/{group_id}/invite")
async def invite_member( async def invite_member(
group_id: str, group_id: uuid.UUID,
payload: AddMemberRequest, payload: AddMemberRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
"""
Invite a user by username. Sends a notification.
"""
try: try:
notification = await invite_member_to_group( notification = await invite_member_to_group(
db, db,
@ -100,34 +71,39 @@ async def invite_member(
user.id, user.id,
payload.username payload.username
) )
return {"message": "دعوت ارسال شد", "notification_id": notification.id} return {"message": "Invitation sent", "notification_id": str(notification.id)}
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(status_code=400, detail=str(e))
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{group_id}")
@router.delete("/{group_id}/members/{user_id}") async def delete_my_group(
async def remove_member( group_id: uuid.UUID,
group_id: str,
user_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
""" """
Admin or Group Manager can remove a member. Only the Group Owner (creator) can delete the group.
""" """
try: try:
await remove_member_from_group( await delete_group_service(db, group_id, user)
db, return {"message": "Group deleted successfully"}
group_id,
user_id,
user
)
return {"message": "عضو با موفقیت حذف شد"}
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(status_code=403, detail=str(e))
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e) @router.delete("/{group_id}/members/{user_id}")
) async def remove_member(
group_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user)
):
"""
A group manager can remove members from their own group.
"""
try:
await remove_member_from_group(db, group_id, user_id, user)
return {"message": "Member removed successfully"}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@ -38,6 +38,12 @@ class Group(Base):
default=True, default=True,
index=True index=True
) )
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
# Relationship to members # Relationship to members
members: Mapped[list["GroupMember"]] = relationship(back_populates="group", cascade="all, delete-orphan") members: Mapped[list["GroupMember"]] = relationship(back_populates="group", cascade="all, delete-orphan")

View File

@ -72,3 +72,11 @@ async def delete_group_member(db: AsyncSession, group_id: uuid.UUID, user_id: uu
.where(GroupMember.user_id == user_id) .where(GroupMember.user_id == user_id)
) )
await db.commit() 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()

View File

@ -6,10 +6,15 @@ from domains.groups.models import GroupType, GroupMemberRole
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name: str = Field(..., max_length=50, description='name of the group') 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): class GroupResponse(BaseModel):
id: uuid.UUID = Field(...) id: uuid.UUID = Field(...)
name: str = Field(..., max_length=50, description='name of the group') name: str = Field(..., max_length=50, description='name of the group')
type: GroupType = Field(..., description='type of the group') type: GroupType = Field(..., description='type of the group')
owner_id: uuid.UUID = Field(...)
is_active: bool = Field(..., description='is active') is_active: bool = Field(..., description='is active')
class Config: class Config:
@ -18,6 +23,11 @@ class GroupResponse(BaseModel):
class AddMemberRequest(BaseModel): class AddMemberRequest(BaseModel):
username: str = Field(..., max_length=20, description='username of the user') 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): class GroupMemberResponse(BaseModel):
user_id: uuid.UUID = Field(...) user_id: uuid.UUID = Field(...)
username: str = Field(..., max_length=20, description='username of the user') 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') is_online: bool = Field(False, description='is online')
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -11,6 +11,7 @@ from domains.groups.repo import (
get_user_groups, get_user_groups,
get_group_members_with_details, get_group_members_with_details,
delete_group_member, delete_group_member,
delete_group,
get_group_member, get_group_member,
get_all_groups as repo_get_all_groups 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.users.repo import get_user_by_username
from domains.notifications.service import send_join_request from domains.notifications.service import send_join_request
async def create_new_group(
async def create_user_group(
db: AsyncSession, db: AsyncSession,
name: str, name: str,
creator_id: uuid.UUID, owner_id: uuid.UUID,
is_admin: bool
): ):
group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE
group = Group( group = Group(
name=name, name=name,
type=group_type type=GroupType.PRIVATE,
owner_id=owner_id
) )
await create_group(db, group) await create_group(db, group)
# Creator becomes Manager # Owner becomes Manager
membership = GroupMember( membership = GroupMember(
group_id=group.id, group_id=group.id,
user_id=creator_id, user_id=owner_id,
role=GroupMemberRole.MANAGER role=GroupMemberRole.MANAGER
) )
await add_group_member(db, membership) await add_group_member(db, membership)
@ -44,6 +44,21 @@ async def create_new_group(
return 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( async def invite_member_to_group(
db: AsyncSession, db: AsyncSession,
group_id: str | uuid.UUID, group_id: str | uuid.UUID,
@ -116,10 +131,10 @@ async def list_all_groups_admin(db: AsyncSession):
return await repo_get_all_groups(db) return await repo_get_all_groups(db)
async def list_group_members_api(db: AsyncSession, group_id: str): async def list_group_members_api(db: AsyncSession, group_id: str | uuid.UUID):
group_id_uuid = uuid.UUID(group_id) 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) 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 = [] result = []
for member_info in members_data: for member_info in members_data:
@ -142,10 +157,42 @@ async def remove_member_from_group(
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id) group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
target_user_id_uuid = target_user_id if isinstance(target_user_id, uuid.UUID) else uuid.UUID(target_user_id) target_user_id_uuid = target_user_id if isinstance(target_user_id, uuid.UUID) else uuid.UUID(target_user_id)
group = await get_group_by_id(db, group_id_uuid)
if not group:
raise ValueError("گروهی یافت نشد")
# Admin can remove anyone # Admin can remove anyone
if not requesting_user.is_admin: if not requesting_user.is_admin:
membership = await get_group_member(db, group_id_uuid, requesting_user.id) membership = await get_group_member(db, group_id_uuid, requesting_user.id)
if not membership or membership.role != GroupMemberRole.MANAGER: if not membership or membership.role != GroupMemberRole.MANAGER:
raise ValueError("دسترسی لازم را ندارید") raise PermissionError("دسترسی لازم را ندارید")
if group.owner_id == target_user_id_uuid:
raise ValueError("حذف سازنده گروه مجاز نیست")
target_membership = await get_group_member(db, group_id_uuid, target_user_id_uuid)
if not target_membership:
raise ValueError("کاربر عضو این گروه نیست")
if not requesting_user.is_admin and target_membership.role != GroupMemberRole.MEMBER:
raise ValueError("حذف مدیر گروه مجاز نیست")
await delete_group_member(db, group_id_uuid, target_user_id_uuid) 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)

View File

@ -1,6 +1,8 @@
import uuid import uuid
from livekit import api from livekit import api
from livekit.api.twirp_client import TwirpError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.logger import get_logger
from integrations.livekit.client import get_livekit_api from integrations.livekit.client import get_livekit_api
from db.redis import ( from db.redis import (
acquire_speaker, acquire_speaker,
@ -8,7 +10,8 @@ from db.redis import (
get_active_speaker get_active_speaker
) )
from domains.groups.repo import get_group_by_id logger = get_logger(__name__)
async def request_speak( async def request_speak(
group_id: str | uuid.UUID, group_id: str | uuid.UUID,
@ -43,13 +46,21 @@ async def current_speaker(group_id: str | uuid.UUID):
async def grant_publish_permission(room_name: str, identity: str, can_publish: bool): async def grant_publish_permission(room_name: str, identity: str, can_publish: bool):
lk_api = get_livekit_api() lk_api = get_livekit_api()
await lk_api.room.update_participant( try:
api.UpdateParticipantRequest( await lk_api.room.update_participant(
room=room_name, api.UpdateParticipantRequest(
identity=identity, room=room_name,
permission=api.ParticipantPermission( identity=identity,
can_publish=can_publish, permission=api.ParticipantPermission(
can_subscribe=True can_publish=can_publish,
can_subscribe=True
)
) )
) )
) except TwirpError as e:
if "not_found" in str(e).lower() or "exist" in str(e).lower():
logger.warning(f"Participant {identity} already left the room.")
else:
logger.error(f"Error updating participant: {e}")
except Exception as e:
logger.error(f"Unexpected error in grant_permission: {e}")

View File

@ -124,7 +124,7 @@ async def group_ws(websocket: WebSocket, group_id: str):
# anti spam # anti spam
current_time = time.time() current_time = time.time()
if current_time - last_action_time < 0.5: if current_time - last_action_time < 0.1:
continue continue
last_action_time = current_time last_action_time = current_time

View File

@ -1,7 +1,7 @@
from livekit import api from livekit import api
from core.config import settings from core.config import settings
from datetime import timedelta
def generate_join_token( def generate_join_token(
user_id: str, user_id: str,
@ -15,6 +15,7 @@ def generate_join_token(
) )
token.with_identity(user_id) token.with_identity(user_id)
token.with_ttl(timedelta(hours=2))
token.with_grants( token.with_grants(
api.VideoGrants( api.VideoGrants(

View File

@ -1,11 +1,25 @@
port: 7880 port: 7780
rtc: rtc:
tcp_port: 7881 tcp_port: 7781
port_range_start: 50000 port_range_start: 51000
port_range_end: 50100 port_range_end: 52000
use_external_ip: false use_external_ip: false
# node_ip: "94.183.170.121" node_ip: "188.213.199.211" # uncomment when using server ip
# room:
# empty_timeout: 600
# departure_timeout: 600
##### uncomment when using letsencrypt in server #######
turn:
cert_file: "/etc/letsencrypt/live/pathfinder.wikm.ir/fullchain.pem"
key_file: "/etc/letsencrypt/live/pathfinder.wikm.ir/privkey.pem"
tls_port: 5349
udp_port: 3478
external_tls: false
domain: "pathfinder.wikm.ir"
#######################################################
logging: logging:
level: info level: info

View File

@ -1,12 +1,13 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi_swagger import patch_fastapi from fastapi_swagger import patch_fastapi
from domains.auth.api import router as auth_router from domains.auth.api import router as auth_router
from domains.users.api import router as users_router from domains.users.api import router as users_router
from domains.admin.api import router as admin_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.realtime.ws import router as realtime_router
from domains.notifications.api import router as notifications_router from domains.notifications.api import router as notifications_router
from integrations.livekit.client import close_livekit_api from integrations.livekit.client import close_livekit_api
@ -53,21 +54,21 @@ patch_fastapi(app,docs_url="/swagger")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[
"https://pathfinder.wikm.ir",
"http://localhost:8000",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "*"], # محدود کردن متدها
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"], # محدود کردن هدرها
) )
# app.add_middleware(
# CORSMiddleware, @app.middleware("http")
# allow_origins=[ async def add_security_headers(request: Request, call_next):
# "https://app.neda.com", response = await call_next(request)
# "http://localhost:3000" # فقط برای تست برنامه‌نویس فرانت‌اند response.headers["X-Content-Type-Options"] = "nosniff"
# ], response.headers["X-Frame-Options"] = "DENY"
# allow_credentials=True, return response
# allow_methods=["GET", "POST", "PUT", "DELETE"], # محدود کردن متدها
# allow_headers=["Authorization", "Content-Type"], # محدود کردن هدرها
# )
# ------------------------- # -------------------------
# Routers # Routers
@ -76,6 +77,7 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(admin_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(realtime_router)
app.include_router(notifications_router) app.include_router(notifications_router)

View File

@ -4,12 +4,12 @@ import secrets
from db.session import AsyncSessionLocal from db.session import AsyncSessionLocal
from domains.users.models import User from domains.users.models import User
from core.security import hash_password from core.security import hash_password
from core.config import settings
async def create_admin() -> None: async def create_admin() -> None:
username = input("Admin username: ").strip() username = input("Admin username: ").strip()
phone_number = input("Phone number (optional, 11 digits): ").strip() or None phone_number = input("Phone number (optional, 11 digits): ").strip() or None
# secret = secrets.token_urlsafe(16) secret = secrets.token_urlsafe(settings.SECRET_PASS_LENGTH)
secret = "1234"
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:

View File

@ -0,0 +1,246 @@
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 ("/groups/{group_id}/members/{user_id}", "DELETE") 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
@pytest.mark.asyncio
async def test_remove_member_from_group_allows_manager_to_remove_member(monkeypatch):
db = object()
group_id = uuid.uuid4()
manager_id = uuid.uuid4()
member_id = uuid.uuid4()
group = SimpleNamespace(owner_id=uuid.uuid4())
removed = AsyncMock()
async def fake_get_group_member(db, requested_group_id, requested_user_id):
if requested_user_id == manager_id:
return SimpleNamespace(role=GroupMemberRole.MANAGER)
if requested_user_id == member_id:
return SimpleNamespace(role=GroupMemberRole.MEMBER)
return None
monkeypatch.setattr(service, "get_group_by_id", AsyncMock(return_value=group))
monkeypatch.setattr(service, "get_group_member", fake_get_group_member)
monkeypatch.setattr(service, "delete_group_member", removed)
requester = SimpleNamespace(id=manager_id, is_admin=False)
await service.remove_member_from_group(db, group_id, member_id, requester)
removed.assert_awaited_once_with(db, group_id, member_id)
@pytest.mark.asyncio
async def test_remove_member_from_group_rejects_non_manager(monkeypatch):
group_id = uuid.uuid4()
requester_id = uuid.uuid4()
member_id = uuid.uuid4()
group = SimpleNamespace(owner_id=uuid.uuid4())
monkeypatch.setattr(service, "get_group_by_id", AsyncMock(return_value=group))
monkeypatch.setattr(
service,
"get_group_member",
AsyncMock(return_value=SimpleNamespace(role=GroupMemberRole.MEMBER)),
)
monkeypatch.setattr(service, "delete_group_member", AsyncMock())
requester = SimpleNamespace(id=requester_id, is_admin=False)
with pytest.raises(PermissionError, match="دسترسی لازم را ندارید"):
await service.remove_member_from_group(object(), group_id, member_id, requester)
@pytest.mark.asyncio
async def test_remove_member_from_group_rejects_owner_removal(monkeypatch):
group_id = uuid.uuid4()
manager_id = uuid.uuid4()
owner_id = uuid.uuid4()
group = SimpleNamespace(owner_id=owner_id)
async def fake_get_group_member(db, requested_group_id, requested_user_id):
if requested_user_id == manager_id:
return SimpleNamespace(role=GroupMemberRole.MANAGER)
return SimpleNamespace(role=GroupMemberRole.MEMBER)
monkeypatch.setattr(service, "get_group_by_id", AsyncMock(return_value=group))
monkeypatch.setattr(service, "get_group_member", fake_get_group_member)
monkeypatch.setattr(service, "delete_group_member", AsyncMock())
requester = SimpleNamespace(id=manager_id, is_admin=False)
with pytest.raises(ValueError, match="حذف سازنده گروه مجاز نیست"):
await service.remove_member_from_group(object(), group_id, owner_id, requester)
@pytest.mark.asyncio
async def test_remove_member_from_group_rejects_missing_target_membership(monkeypatch):
group_id = uuid.uuid4()
manager_id = uuid.uuid4()
member_id = uuid.uuid4()
group = SimpleNamespace(owner_id=uuid.uuid4())
async def fake_get_group_member(db, requested_group_id, requested_user_id):
if requested_user_id == manager_id:
return SimpleNamespace(role=GroupMemberRole.MANAGER)
return None
monkeypatch.setattr(service, "get_group_by_id", AsyncMock(return_value=group))
monkeypatch.setattr(service, "get_group_member", fake_get_group_member)
monkeypatch.setattr(service, "delete_group_member", AsyncMock())
requester = SimpleNamespace(id=manager_id, is_admin=False)
with pytest.raises(ValueError, match="کاربر عضو این گروه نیست"):
await service.remove_member_from_group(object(), group_id, member_id, requester)
@pytest.mark.asyncio
async def test_remove_member_from_group_rejects_manager_target_for_non_admin(monkeypatch):
group_id = uuid.uuid4()
manager_id = uuid.uuid4()
other_manager_id = uuid.uuid4()
group = SimpleNamespace(owner_id=uuid.uuid4())
async def fake_get_group_member(db, requested_group_id, requested_user_id):
if requested_user_id == manager_id:
return SimpleNamespace(role=GroupMemberRole.MANAGER)
if requested_user_id == other_manager_id:
return SimpleNamespace(role=GroupMemberRole.MANAGER)
return None
monkeypatch.setattr(service, "get_group_by_id", AsyncMock(return_value=group))
monkeypatch.setattr(service, "get_group_member", fake_get_group_member)
monkeypatch.setattr(service, "delete_group_member", AsyncMock())
requester = SimpleNamespace(id=manager_id, is_admin=False)
with pytest.raises(ValueError, match="حذف مدیر گروه مجاز نیست"):
await service.remove_member_from_group(object(), group_id, other_manager_id, requester)
@pytest.mark.asyncio
async def test_remove_member_from_group_allows_admin_to_remove_manager(monkeypatch):
db = object()
group_id = uuid.uuid4()
target_manager_id = uuid.uuid4()
group = SimpleNamespace(owner_id=uuid.uuid4())
removed = AsyncMock()
monkeypatch.setattr(service, "get_group_by_id", AsyncMock(return_value=group))
monkeypatch.setattr(
service,
"get_group_member",
AsyncMock(return_value=SimpleNamespace(role=GroupMemberRole.MANAGER)),
)
monkeypatch.setattr(service, "delete_group_member", removed)
requester = SimpleNamespace(id=uuid.uuid4(), is_admin=True)
await service.remove_member_from_group(db, group_id, target_manager_id, requester)
removed.assert_awaited_once_with(db, group_id, target_manager_id)

15
Back/tests/test_redis.py Normal file
View File

@ -0,0 +1,15 @@
import asyncio
import redis.asyncio as redis
from core.config import settings
async def test_redis():
print(f"Testing connection to: {settings.REDIS_URL}")
try:
client = redis.from_url(settings.REDIS_URL, decode_responses=True)
pong = await client.ping()
print(f"Ping successful: {pong}")
except Exception as e:
print(f"Connection failed: {e}")
if __name__ == "__main__":
asyncio.run(test_redis())