Compare commits
10 Commits
da29380087
...
6476233f29
| Author | SHA1 | Date | |
|---|---|---|---|
| 6476233f29 | |||
| 017046dafd | |||
| 135d7d0ff3 | |||
| ffd8d174c8 | |||
| aad6ab8c6f | |||
| 720f8e6b7c | |||
| 6d687e7361 | |||
| 071eeb0bfa | |||
| 230c2460c9 | |||
| 21e19ed6a9 |
57
Back/alembic/versions/36ed70229177_add_owner_id_to_group.py
Normal file
57
Back/alembic/versions/36ed70229177_add_owner_id_to_group.py
Normal 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 ###
|
||||||
|
|
@ -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
38
Back/core/logger.py
Normal 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)
|
||||||
|
|
@ -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="تعداد درخواستهای شما از حد مجاز گذشته است. لطفاً بعداً دوباره تلاش کنید."
|
||||||
)
|
)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
6
Back/domains/groups/api/__init__.py
Normal file
6
Back/domains/groups/api/__init__.py
Normal 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
|
||||||
111
Back/domains/groups/api/admin.py
Normal file
111
Back/domains/groups/api/admin.py
Normal 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))
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -39,6 +39,12 @@ class Group(Base):
|
||||||
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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,6 +46,7 @@ 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()
|
||||||
|
try:
|
||||||
await lk_api.room.update_participant(
|
await lk_api.room.update_participant(
|
||||||
api.UpdateParticipantRequest(
|
api.UpdateParticipantRequest(
|
||||||
room=room_name,
|
room=room_name,
|
||||||
|
|
@ -53,3 +57,10 @@ async def grant_publish_permission(room_name: str, identity: str, can_publish: b
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
34
Back/main.py
34
Back/main.py
|
|
@ -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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
246
Back/tests/test_groups_service.py
Normal file
246
Back/tests/test_groups_service.py
Normal 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
15
Back/tests/test_redis.py
Normal 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())
|
||||||
Loading…
Reference in New Issue
Block a user