feat: update Back Security
This commit is contained in:
parent
a99d920f2c
commit
f1f864d468
342
Back/README.md
342
Back/README.md
|
|
@ -1,142 +1,58 @@
|
||||||
# NEDA Backend
|
# NEDA Backend
|
||||||
|
|
||||||
NEDA is a real-time group voice communication backend designed for wearable devices (e.g., smartwatches).
|
NEDA is a FastAPI backend for real-time group voice communication.
|
||||||
It enables secure, low-latency push-to-talk audio communication within isolated groups.
|
It includes authentication, user/group management, WebSocket signaling, Redis for real-time state, and PostgreSQL for persistent data.
|
||||||
|
|
||||||
This repository contains the FastAPI backend, realtime control layer, and database schema.
|
## Tech Stack
|
||||||
|
|
||||||
---
|
- FastAPI + Uvicorn
|
||||||
|
- SQLAlchemy (Async) + asyncpg
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis
|
||||||
|
- Alembic
|
||||||
|
- LiveKit
|
||||||
|
|
||||||
# ✨ Features
|
## Project Structure
|
||||||
|
|
||||||
- Real-time push-to-talk voice groups
|
|
||||||
- Single active speaker per group
|
|
||||||
- Secure group isolation
|
|
||||||
- Role-based group permissions
|
|
||||||
- Admin-managed membership
|
|
||||||
- Redis-based realtime state
|
|
||||||
- LiveKit media integration
|
|
||||||
- Async PostgreSQL (SQLAlchemy)
|
|
||||||
- Alembic migrations
|
|
||||||
- WebSocket signaling layer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧱 Architecture
|
|
||||||
|
|
||||||
NEDA follows a **domain-oriented layered modular monolith** architecture.
|
|
||||||
|
|
||||||
|
```text
|
||||||
|
core/ config, security, deps
|
||||||
|
alembic/ database migrations
|
||||||
|
db/ database/redis setup
|
||||||
|
domains/ domain modules (auth, users, admin, groups, realtime, notifications)
|
||||||
|
integrations/ external integrations (LiveKit)
|
||||||
|
scripts/ utility scripts (create_admin)
|
||||||
|
main.py FastAPI app entrypoint
|
||||||
```
|
```
|
||||||
|
|
||||||
core/ shared infrastructure
|
## Active Routes
|
||||||
db/ database & redis
|
|
||||||
domains/ business domains
|
|
||||||
integrations/ external services
|
|
||||||
alembic/ migrations
|
|
||||||
|
|
||||||
```
|
- `POST /auth/login`
|
||||||
|
- `GET /users/`
|
||||||
|
- `POST /admin/users`
|
||||||
|
- `POST /admin/users/{user_id}/logout`
|
||||||
|
- `POST /admin/users/{user_id}/reset-secret`
|
||||||
|
- `GET /admin/users`
|
||||||
|
- `GET /admin/groups`
|
||||||
|
- `POST /groups/`
|
||||||
|
- `GET /groups/my`
|
||||||
|
- `GET /groups/admin/all`
|
||||||
|
- `GET /groups/{group_id}/members`
|
||||||
|
- `POST /groups/{group_id}/invite`
|
||||||
|
- `DELETE /groups/{group_id}/members/{user_id}`
|
||||||
|
- `WS /ws/groups/{group_id}`
|
||||||
|
|
||||||
Domains:
|
## Environment Variables
|
||||||
|
|
||||||
- users
|
Create a `.env` file in the project root:
|
||||||
- groups
|
|
||||||
- realtime
|
|
||||||
- auth
|
|
||||||
- admin
|
|
||||||
|
|
||||||
This design keeps domain logic isolated and allows future service extraction.
|
```env
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🎙️ Realtime Model
|
|
||||||
|
|
||||||
- Audio media → LiveKit
|
|
||||||
- Signaling → WebSocket (FastAPI)
|
|
||||||
- State → Redis
|
|
||||||
- Persistence → PostgreSQL
|
|
||||||
|
|
||||||
Active speaker is stored in Redis:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
speaker:{group_id} = user_id
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Presence:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
presence:{group_id} = set(user_ids)
|
|
||||||
|
|
||||||
````
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 👥 Roles
|
|
||||||
|
|
||||||
System role (User):
|
|
||||||
|
|
||||||
- `admin`
|
|
||||||
- `user`
|
|
||||||
|
|
||||||
Group role (GroupMember):
|
|
||||||
|
|
||||||
- `group_manager` (exactly one per group)
|
|
||||||
- `member`
|
|
||||||
|
|
||||||
Only admins can:
|
|
||||||
|
|
||||||
- create groups
|
|
||||||
- assign group manager
|
|
||||||
- add/remove members
|
|
||||||
|
|
||||||
Group managers have realtime authority only (speaker control).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🗄️ Database
|
|
||||||
|
|
||||||
Core entities:
|
|
||||||
|
|
||||||
- User
|
|
||||||
- Group
|
|
||||||
- GroupMember
|
|
||||||
- Session
|
|
||||||
- GroupVoiceSession
|
|
||||||
- SpeakerHistory
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- soft delete for main entities
|
|
||||||
- single active group_manager per group
|
|
||||||
- unique membership (user, group)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🚀 Running with Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build
|
|
||||||
````
|
|
||||||
|
|
||||||
Services:
|
|
||||||
|
|
||||||
* API → [http://localhost:8000](http://localhost:8000)
|
|
||||||
* Docs → [http://localhost:8000/docs](http://localhost:8000/docs)
|
|
||||||
* LiveKit → [http://localhost:7880](http://localhost:7880)
|
|
||||||
* Postgres → 5432
|
|
||||||
* Redis → 6379
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# ⚙️ Environment
|
|
||||||
|
|
||||||
`.env`
|
|
||||||
|
|
||||||
```
|
|
||||||
APP_NAME=NEDA
|
APP_NAME=NEDA
|
||||||
|
DEBUG=False
|
||||||
|
|
||||||
SECRET_KEY=change-me
|
SECRET_KEY=change-me
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
ALGORITHM=HS256
|
||||||
|
SECRET_PASS_LENGTH=32
|
||||||
|
|
||||||
POSTGRES_DB=neda
|
POSTGRES_DB=neda
|
||||||
POSTGRES_USER=neda_user
|
POSTGRES_USER=neda_user
|
||||||
|
|
@ -150,96 +66,114 @@ LIVEKIT_API_SECRET=neda_secret
|
||||||
LIVEKIT_HOST=http://livekit:7880
|
LIVEKIT_HOST=http://livekit:7880
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Run With Docker
|
||||||
|
|
||||||
# 🧪 Development Setup
|
|
||||||
|
|
||||||
Create venv and install:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
- API: `http://localhost:8000`
|
||||||
|
- Swagger Docs: `http://localhost:8000/docs`
|
||||||
|
- LiveKit: `http://localhost:7880`
|
||||||
|
- Postgres: `localhost:5432`
|
||||||
|
- Redis: `localhost:6379`
|
||||||
|
|
||||||
|
API logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create Admin (Inside Docker)
|
||||||
|
|
||||||
|
After services are up, create the initial admin user inside the API container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api python -m scripts.create_admin
|
||||||
|
```
|
||||||
|
|
||||||
|
The script asks for `username` and optional `phone_number`, creates the user with `is_admin=True`, and prints the initial `secret`.
|
||||||
|
|
||||||
|
## Admin API Hardening
|
||||||
|
|
||||||
|
- `POST /admin/users` creates only non-admin users.
|
||||||
|
- Sending `is_admin` in the payload is not allowed and returns `422`.
|
||||||
|
- Admin creation is only allowed through the server-side script: `scripts/create_admin.py`.
|
||||||
|
|
||||||
|
Invalid payload example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "new_user",
|
||||||
|
"phone_number": "09123456789",
|
||||||
|
"is_admin": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin login:
|
||||||
|
- Endpoint: `POST /auth/login`
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin_username",
|
||||||
|
"secret": "printed_secret"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migrations (Alembic)
|
||||||
|
|
||||||
|
This project uses Alembic, and `env.py` reads `DATABASE_URL` from `.env`.
|
||||||
|
|
||||||
|
Apply migrations (inside Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a new migration (autogenerate):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api alembic revision --autogenerate -m "your_message"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check current DB revision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api alembic current
|
||||||
|
```
|
||||||
|
|
||||||
|
Show migration history:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api alembic history
|
||||||
|
```
|
||||||
|
|
||||||
|
Important: run `alembic upgrade head` before starting the API in a new environment.
|
||||||
|
|
||||||
|
## Local Development (Without Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Run API:
|
Run migrations:
|
||||||
|
|
||||||
```bash
|
|
||||||
uvicorn neda.main:app --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 📜 Migrations (Alembic)
|
|
||||||
|
|
||||||
Init (first time):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
alembic init alembic
|
|
||||||
```
|
|
||||||
|
|
||||||
Create migration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
alembic revision --autogenerate -m "init"
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Start API:
|
||||||
|
|
||||||
# 🔌 Realtime Flow
|
```bash
|
||||||
|
uvicorn main:app --reload
|
||||||
Request to speak:
|
|
||||||
|
|
||||||
1. Client → WS `REQUEST_TALK`
|
|
||||||
2. Backend → Redis `SET NX speaker:{group}`
|
|
||||||
3. If granted → LiveKit publish token
|
|
||||||
4. Others → subscribers
|
|
||||||
5. Release → Redis delete
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧭 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
domains/
|
|
||||||
users/
|
|
||||||
groups/
|
|
||||||
realtime/
|
|
||||||
auth/
|
|
||||||
admin/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each domain contains:
|
## Realtime Notes
|
||||||
|
|
||||||
* models
|
|
||||||
* schemas
|
|
||||||
* repo
|
|
||||||
* service
|
|
||||||
* api
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧠 Design Principles
|
|
||||||
|
|
||||||
* realtime state outside DB
|
|
||||||
* single responsibility domains
|
|
||||||
* admin control plane
|
|
||||||
* Redis for locks/presence
|
|
||||||
* DB for long-term truth
|
|
||||||
* media separated from signaling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 📡 Future Scaling
|
|
||||||
|
|
||||||
The architecture supports:
|
|
||||||
|
|
||||||
* realtime service extraction
|
|
||||||
* horizontal scaling
|
|
||||||
* sharded groups
|
|
||||||
* multi-tenant deployments
|
|
||||||
|
|
||||||
|
- Presence is stored in Redis.
|
||||||
|
- Speaker lock is managed atomically in Redis.
|
||||||
|
- LiveKit tokens for listener/speaker roles are issued during the WebSocket flow.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from db.base import Base
|
||||||
|
|
||||||
import domains.users.models
|
import domains.users.models
|
||||||
import domains.groups.models
|
import domains.groups.models
|
||||||
|
import domains.notifications.models
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""feat: add unique constraint to GroupMember
|
||||||
|
|
||||||
|
Revision ID: 24e07dd1307e
|
||||||
|
Revises: 4080314c8f5a
|
||||||
|
Create Date: 2026-03-08 15:57:14.560371
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '24e07dd1307e'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '4080314c8f5a'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_unique_constraint('uq_group_member', 'group_members', ['user_id', 'group_id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint('uq_group_member', 'group_members', type_='unique')
|
||||||
|
# ### end Alembic commands ###
|
||||||
88
Back/alembic/versions/4080314c8f5a_add_notifications.py
Normal file
88
Back/alembic/versions/4080314c8f5a_add_notifications.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""add notifications
|
||||||
|
|
||||||
|
Revision ID: 4080314c8f5a
|
||||||
|
Revises: b1f09d977759
|
||||||
|
Create Date: 2026-03-07 16:16:30.792790
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4080314c8f5a'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'b1f09d977759'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
group_member_role_enum = postgresql.ENUM(
|
||||||
|
'MANAGER',
|
||||||
|
'MEMBER',
|
||||||
|
name='group_member_role'
|
||||||
|
)
|
||||||
|
group_member_role_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
op.create_table('notifications',
|
||||||
|
sa.Column('title', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('type', sa.Enum('PUBLIC', 'JOIN_REQUEST', name='notification_type'), nullable=False),
|
||||||
|
sa.Column('is_accepted', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('receiver_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('sender_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['receiver_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_notifications_receiver_id'), 'notifications', ['receiver_id'], unique=False)
|
||||||
|
op.add_column(
|
||||||
|
'group_members',
|
||||||
|
sa.Column(
|
||||||
|
'role',
|
||||||
|
sa.Enum('MANAGER', 'MEMBER', name='group_member_role'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='MEMBER'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.alter_column('group_members', 'role', server_default=None)
|
||||||
|
op.add_column('users', sa.Column('is_admin', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
op.add_column('users', sa.Column('phone_number', sa.String(length=11), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='1'))
|
||||||
|
op.alter_column('users', 'is_admin', server_default=None)
|
||||||
|
op.alter_column('users', 'token_version', server_default=None)
|
||||||
|
op.drop_index(op.f('ix_users_role'), table_name='users')
|
||||||
|
op.create_index(op.f('ix_users_is_admin'), 'users', ['is_admin'], unique=False)
|
||||||
|
op.create_index(op.f('ix_users_phone_number'), 'users', ['phone_number'], unique=True)
|
||||||
|
op.drop_column('users', 'role')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('users', sa.Column('role', postgresql.ENUM('ADMIN', 'GROUP_MANAGER', 'MEMBER', name='user_role'), autoincrement=False, nullable=False))
|
||||||
|
op.drop_index(op.f('ix_users_phone_number'), table_name='users')
|
||||||
|
op.drop_index(op.f('ix_users_is_admin'), table_name='users')
|
||||||
|
op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False)
|
||||||
|
op.drop_column('users', 'token_version')
|
||||||
|
op.drop_column('users', 'phone_number')
|
||||||
|
op.drop_column('users', 'is_admin')
|
||||||
|
op.drop_column('group_members', 'role')
|
||||||
|
postgresql.ENUM(
|
||||||
|
'MANAGER',
|
||||||
|
'MEMBER',
|
||||||
|
name='group_member_role'
|
||||||
|
).drop(op.get_bind(), checkfirst=True)
|
||||||
|
op.drop_index(op.f('ix_notifications_receiver_id'), table_name='notifications')
|
||||||
|
op.drop_table('notifications')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
pwd_context = CryptContext(
|
pwd_context = CryptContext(
|
||||||
schemes=["bcrypt"],
|
schemes=["argon2"],
|
||||||
deprecated="auto",
|
deprecated="auto",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,7 @@ async def create_user(
|
||||||
user, secret = await admin_create_user(
|
user, secret = await admin_create_user(
|
||||||
db,
|
db,
|
||||||
payload.username,
|
payload.username,
|
||||||
payload.phone_number,
|
payload.phone_number
|
||||||
payload.is_admin
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -97,7 +96,7 @@ async def list_users(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin=Depends(get_current_admin)
|
admin=Depends(get_current_admin)
|
||||||
):
|
):
|
||||||
return await get_all_users(db)
|
return await get_all_users(db, include_admin=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/groups", response_model=list[GroupResponse])
|
@router.get("/groups", response_model=list[GroupResponse])
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
class AdminCreateUser(BaseModel):
|
class AdminCreateUser(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
username: str
|
username: str
|
||||||
phone_number: str | None = None
|
phone_number: str | None = None
|
||||||
is_admin: bool = False
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
class AdminUserResponse(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import secrets
|
import secrets
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -10,18 +11,29 @@ 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(settings.SECRET_PASS_LENGTH)
|
return secrets.token_urlsafe(16)
|
||||||
return "1234"
|
|
||||||
|
|
||||||
async def admin_create_user(
|
async def admin_create_user(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
username: str,
|
username: str,
|
||||||
phone_number: str | None = None,
|
phone_number: str | None = None
|
||||||
is_admin: bool = False
|
):
|
||||||
|
return await _create_user_with_role(
|
||||||
|
db=db,
|
||||||
|
username=username,
|
||||||
|
phone_number=phone_number,
|
||||||
|
is_admin=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_user_with_role(
|
||||||
|
db: AsyncSession,
|
||||||
|
username: str,
|
||||||
|
phone_number: str | None,
|
||||||
|
is_admin: bool
|
||||||
):
|
):
|
||||||
|
|
||||||
existing = await get_user_by_username(db, username)
|
existing = await get_user_by_username(db, username)
|
||||||
|
|
@ -45,7 +57,7 @@ async def admin_create_user(
|
||||||
|
|
||||||
async def admin_logout_user(
|
async def admin_logout_user(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: str
|
user_id: str | uuid.UUID
|
||||||
):
|
):
|
||||||
user = await get_user_by_id(db, user_id)
|
user = await get_user_by_id(db, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -58,7 +70,7 @@ async def admin_logout_user(
|
||||||
|
|
||||||
async def admin_reset_user_secret(
|
async def admin_reset_user_secret(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id
|
user_id: str | uuid.UUID
|
||||||
):
|
):
|
||||||
|
|
||||||
user = await get_user_by_id(db, user_id)
|
user = await get_user_by_id(db, user_id)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from sqlalchemy import String, Boolean, ForeignKey, Enum as SQLEnum
|
from sqlalchemy import String, Boolean, ForeignKey, Enum as SQLEnum, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
@ -45,6 +45,9 @@ class Group(Base):
|
||||||
|
|
||||||
class GroupMember(Base):
|
class GroupMember(Base):
|
||||||
__tablename__ = "group_members" # type: ignore
|
__tablename__ = "group_members" # type: ignore
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "group_id", name="uq_group_member"),
|
||||||
|
)
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from sqlalchemy import select
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from domains.groups.models import Group, GroupMember
|
from domains.groups.models import Group, GroupMember
|
||||||
|
|
@ -11,7 +12,7 @@ async def create_group(db: AsyncSession, group: Group):
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
async def get_group_by_id(db: AsyncSession, group_id):
|
async def get_group_by_id(db: AsyncSession, group_id: uuid.UUID):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Group).where(Group.id == group_id)
|
select(Group).where(Group.id == group_id)
|
||||||
)
|
)
|
||||||
|
|
@ -23,7 +24,7 @@ async def add_group_member(db: AsyncSession, membership: GroupMember):
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|
||||||
async def get_user_groups(db: AsyncSession, user_id):
|
async def get_user_groups(db: AsyncSession, user_id: uuid.UUID):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Group)
|
select(Group)
|
||||||
.join(GroupMember)
|
.join(GroupMember)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from domains.users.repo import get_user_by_id
|
from domains.users.repo import get_user_by_id
|
||||||
from domains.groups.models import Group, GroupMember, GroupType, GroupMemberRole
|
from domains.groups.models import Group, GroupMember, GroupType, GroupMemberRole
|
||||||
from domains.groups.repo import (
|
from domains.groups.repo import (
|
||||||
|
|
@ -10,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,
|
||||||
|
get_group_member,
|
||||||
get_all_groups as repo_get_all_groups
|
get_all_groups as repo_get_all_groups
|
||||||
)
|
)
|
||||||
from domains.realtime.presence_service import list_online_users
|
from domains.realtime.presence_service import list_online_users
|
||||||
|
|
@ -18,7 +20,7 @@ from domains.realtime.presence_service import list_online_users
|
||||||
async def create_new_group(
|
async def create_new_group(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
name: str,
|
name: str,
|
||||||
creator_id,
|
creator_id: uuid.UUID,
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
):
|
):
|
||||||
group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE
|
group_type = GroupType.PUBLIC if is_admin else GroupType.PRIVATE
|
||||||
|
|
@ -43,18 +45,29 @@ async def create_new_group(
|
||||||
|
|
||||||
async def invite_member_to_group(
|
async def invite_member_to_group(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
group_id,
|
group_id: str | uuid.UUID,
|
||||||
sender_id,
|
sender_id: uuid.UUID,
|
||||||
target_username: str
|
target_username: str
|
||||||
):
|
):
|
||||||
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
|
||||||
|
|
||||||
|
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
|
||||||
|
|
||||||
# 1. Check if group exists
|
# 1. Check if group exists
|
||||||
group = await get_group_by_id(db, group_id)
|
group = await get_group_by_id(db, group_id_uuid)
|
||||||
if not group:
|
if not group:
|
||||||
raise ValueError("Group not found")
|
raise ValueError("Group not found")
|
||||||
|
|
||||||
|
sender = await get_user_by_id(db, sender_id)
|
||||||
|
if not sender:
|
||||||
|
raise ValueError("Sender not found")
|
||||||
|
|
||||||
|
if not sender.is_admin:
|
||||||
|
membership = await get_group_member(db, group_id_uuid, sender_id)
|
||||||
|
if not membership:
|
||||||
|
raise ValueError("Not a group member")
|
||||||
|
|
||||||
# 2. Check if target user exists
|
# 2. Check if target user exists
|
||||||
target_user = await get_user_by_username(db, target_username)
|
target_user = await get_user_by_username(db, target_username)
|
||||||
if not target_user:
|
if not target_user:
|
||||||
|
|
@ -63,9 +76,9 @@ async def invite_member_to_group(
|
||||||
# 3. Send notification (Req 12)
|
# 3. Send notification (Req 12)
|
||||||
return await send_join_request(
|
return await send_join_request(
|
||||||
db,
|
db,
|
||||||
sender_id=str(sender_id),
|
sender_id=sender_id,
|
||||||
receiver_id=str(target_user.id),
|
receiver_id=target_user.id,
|
||||||
group_id=str(group.id),
|
group_id=group.id,
|
||||||
title="Group Invitation",
|
title="Group Invitation",
|
||||||
description=f"You have been invited to join group {group.name}"
|
description=f"You have been invited to join group {group.name}"
|
||||||
)
|
)
|
||||||
|
|
@ -73,13 +86,20 @@ async def invite_member_to_group(
|
||||||
|
|
||||||
async def add_member_to_group(
|
async def add_member_to_group(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
group_id,
|
group_id: str | uuid.UUID,
|
||||||
user_id,
|
user_id: str | uuid.UUID,
|
||||||
role: GroupMemberRole = GroupMemberRole.MEMBER
|
role: GroupMemberRole = GroupMemberRole.MEMBER
|
||||||
):
|
):
|
||||||
|
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
|
||||||
|
user_id_uuid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(user_id)
|
||||||
|
|
||||||
|
existing = await get_group_member(db, group_id_uuid, user_id_uuid)
|
||||||
|
if existing:
|
||||||
|
raise ValueError("User already in group")
|
||||||
|
|
||||||
membership = GroupMember(
|
membership = GroupMember(
|
||||||
group_id=group_id,
|
group_id=group_id_uuid,
|
||||||
user_id=user_id,
|
user_id=user_id_uuid,
|
||||||
role=role
|
role=role
|
||||||
)
|
)
|
||||||
return await add_group_member(db, membership)
|
return await add_group_member(db, membership)
|
||||||
|
|
@ -87,7 +107,7 @@ async def add_member_to_group(
|
||||||
|
|
||||||
async def list_user_groups(
|
async def list_user_groups(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id
|
user_id: uuid.UUID
|
||||||
):
|
):
|
||||||
return await get_user_groups(db, user_id)
|
return await get_user_groups(db, user_id)
|
||||||
|
|
||||||
|
|
@ -115,20 +135,17 @@ async def list_group_members_api(db: AsyncSession, group_id: str):
|
||||||
|
|
||||||
async def remove_member_from_group(
|
async def remove_member_from_group(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
group_id,
|
group_id: str | uuid.UUID,
|
||||||
target_user_id,
|
target_user_id: str | uuid.UUID,
|
||||||
requesting_user
|
requesting_user
|
||||||
):
|
):
|
||||||
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)
|
||||||
|
|
||||||
# Req 13: Admin or Group Manager can remove
|
# Admin can remove anyone
|
||||||
if not requesting_user.is_admin:
|
if not requesting_user.is_admin:
|
||||||
# Check if requesting user is manager of this group
|
|
||||||
from domains.groups.repo import get_group_member
|
|
||||||
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("Permission denied. Only admin or group manager can remove members.")
|
raise ValueError("Permission denied")
|
||||||
|
|
||||||
# For now, let's assume we implement the check here or in repo.
|
|
||||||
await delete_group_member(db, group_id_uuid, target_user_id_uuid)
|
await delete_group_member(db, group_id_uuid, target_user_id_uuid)
|
||||||
|
|
@ -54,7 +54,7 @@ async def broadcast_notification(
|
||||||
admin=Depends(get_current_admin)
|
admin=Depends(get_current_admin)
|
||||||
):
|
):
|
||||||
users = await get_all_users(db)
|
users = await get_all_users(db)
|
||||||
user_ids = [str(u.id) for u in users]
|
user_ids = [u.id for u in users]
|
||||||
return await send_public_notification(
|
return await send_public_notification(
|
||||||
db,
|
db,
|
||||||
title,
|
title,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from domains.notifications.models import Notification
|
from domains.notifications.models import Notification
|
||||||
|
|
@ -8,13 +10,13 @@ async def create_notification(db: AsyncSession, notification: Notification):
|
||||||
await db.refresh(notification)
|
await db.refresh(notification)
|
||||||
return notification
|
return notification
|
||||||
|
|
||||||
async def get_notification_by_id(db: AsyncSession, notification_id):
|
async def get_notification_by_id(db: AsyncSession, notification_id: uuid.UUID):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Notification).where(Notification.id == notification_id)
|
select(Notification).where(Notification.id == notification_id)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def get_user_notifications(db: AsyncSession, user_id):
|
async def get_user_notifications(db: AsyncSession, user_id: uuid.UUID):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Notification)
|
select(Notification)
|
||||||
.where(Notification.receiver_id == user_id)
|
.where(Notification.receiver_id == user_id)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from domains.notifications.models import Notification, NotificationType
|
from domains.notifications.models import Notification, NotificationType
|
||||||
from domains.notifications.repo import (
|
from domains.notifications.repo import (
|
||||||
create_notification,
|
create_notification,
|
||||||
|
|
@ -11,8 +14,8 @@ async def send_public_notification(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
title: str,
|
title: str,
|
||||||
description: str,
|
description: str,
|
||||||
sender_id: str,
|
sender_id: uuid.UUID,
|
||||||
receiver_ids: list[str]
|
receiver_ids: list[uuid.UUID]
|
||||||
):
|
):
|
||||||
notifications = []
|
notifications = []
|
||||||
for receiver_id in receiver_ids:
|
for receiver_id in receiver_ids:
|
||||||
|
|
@ -31,9 +34,9 @@ async def send_public_notification(
|
||||||
|
|
||||||
async def send_join_request(
|
async def send_join_request(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
sender_id: str,
|
sender_id: uuid.UUID,
|
||||||
receiver_id: str,
|
receiver_id: uuid.UUID,
|
||||||
group_id: str,
|
group_id: uuid.UUID,
|
||||||
title: str,
|
title: str,
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
):
|
):
|
||||||
|
|
@ -47,16 +50,17 @@ async def send_join_request(
|
||||||
)
|
)
|
||||||
return await create_notification(db, notification)
|
return await create_notification(db, notification)
|
||||||
|
|
||||||
async def list_my_notifications(db: AsyncSession, user_id):
|
async def list_my_notifications(db: AsyncSession, user_id: uuid.UUID):
|
||||||
return await get_user_notifications(db, user_id)
|
return await get_user_notifications(db, user_id)
|
||||||
|
|
||||||
async def respond_to_notification(
|
async def respond_to_notification(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
notification_id: str,
|
notification_id: str | uuid.UUID,
|
||||||
user_id: str,
|
user_id: uuid.UUID,
|
||||||
is_accepted: bool
|
is_accepted: bool
|
||||||
):
|
):
|
||||||
notification = await get_notification_by_id(db, notification_id)
|
notification_id_uuid = notification_id if isinstance(notification_id, uuid.UUID) else uuid.UUID(notification_id)
|
||||||
|
notification = await get_notification_by_id(db, notification_id_uuid)
|
||||||
if not notification:
|
if not notification:
|
||||||
raise ValueError("Notification not found")
|
raise ValueError("Notification not found")
|
||||||
|
|
||||||
|
|
@ -69,6 +73,6 @@ async def respond_to_notification(
|
||||||
# If it's a join request and accepted, add user to group
|
# If it's a join request and accepted, add user to group
|
||||||
if notification.type == NotificationType.JOIN_REQUEST and is_accepted:
|
if notification.type == NotificationType.JOIN_REQUEST and is_accepted:
|
||||||
from domains.groups.service import add_member_to_group
|
from domains.groups.service import add_member_to_group
|
||||||
await add_member_to_group(db, notification.group_id, user_id)
|
await add_member_to_group(db, notification.group_id, user_id) # type: ignore
|
||||||
|
|
||||||
return notification
|
return notification
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from db.redis import (
|
from db.redis import (
|
||||||
add_presence,
|
add_presence,
|
||||||
remove_presence,
|
remove_presence,
|
||||||
|
|
@ -5,22 +7,22 @@ from db.redis import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def user_join_group(group_id: str, user_id: str):
|
async def user_join_group(group_id: str | uuid.UUID, user_id: str | uuid.UUID):
|
||||||
"""
|
"""
|
||||||
Called when websocket connects
|
Called when websocket connects
|
||||||
"""
|
"""
|
||||||
await add_presence(group_id, user_id)
|
await add_presence(str(group_id), str(user_id))
|
||||||
|
|
||||||
|
|
||||||
async def user_leave_group(group_id: str, user_id: str):
|
async def user_leave_group(group_id: str | uuid.UUID, user_id: str | uuid.UUID):
|
||||||
"""
|
"""
|
||||||
Called when websocket disconnects
|
Called when websocket disconnects
|
||||||
"""
|
"""
|
||||||
await remove_presence(group_id, user_id)
|
await remove_presence(str(group_id), str(user_id))
|
||||||
|
|
||||||
|
|
||||||
async def list_online_users(group_id: str):
|
async def list_online_users(group_id: str | uuid.UUID):
|
||||||
"""
|
"""
|
||||||
Returns online users in a group
|
Returns online users in a group
|
||||||
"""
|
"""
|
||||||
return await get_presence(group_id)
|
return await get_presence(str(group_id))
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from db.redis import (
|
from db.redis import (
|
||||||
|
|
@ -7,42 +9,44 @@ from db.redis import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from domains.groups.repo import get_group_by_id
|
from domains.groups.repo import get_group_by_id
|
||||||
from domains.groups.models import GroupType
|
|
||||||
|
|
||||||
from integrations.livekit.token_service import generate_join_token
|
from integrations.livekit.token_service import generate_join_token
|
||||||
|
|
||||||
|
|
||||||
async def request_speak(
|
async def request_speak(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
group_id: str,
|
group_id: str | uuid.UUID,
|
||||||
user_id: str
|
user_id: str | uuid.UUID
|
||||||
):
|
):
|
||||||
|
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
|
||||||
|
group_id_str = str(group_id_uuid)
|
||||||
|
user_id_str = str(user_id)
|
||||||
|
|
||||||
group = await get_group_by_id(db, group_id)
|
group = await get_group_by_id(db, group_id_uuid)
|
||||||
|
|
||||||
if not group:
|
if not group:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# direct chat → no speaker lock
|
# direct chat → no speaker lock
|
||||||
if group.type == GroupType.DIRECT:
|
if str(group.type) == "direct":
|
||||||
|
|
||||||
token = generate_join_token(
|
token = generate_join_token(
|
||||||
user_id=user_id,
|
user_id=user_id_str,
|
||||||
group_id=group_id,
|
group_id=group_id_str,
|
||||||
can_publish=True
|
can_publish=True
|
||||||
)
|
)
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
# group chat → push-to-talk
|
# group chat → push-to-talk
|
||||||
granted = await acquire_speaker(group_id, user_id)
|
granted = await acquire_speaker(group_id_str, user_id_str)
|
||||||
|
|
||||||
if not granted:
|
if not granted:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
token = generate_join_token(
|
token = generate_join_token(
|
||||||
user_id=user_id,
|
user_id=user_id_str,
|
||||||
group_id=group_id,
|
group_id=group_id_str,
|
||||||
can_publish=True
|
can_publish=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -51,33 +55,38 @@ async def request_speak(
|
||||||
|
|
||||||
async def stop_speaking(
|
async def stop_speaking(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
group_id: str,
|
group_id: str | uuid.UUID,
|
||||||
user_id: str
|
user_id: str | uuid.UUID
|
||||||
):
|
):
|
||||||
|
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
|
||||||
|
group_id_str = str(group_id_uuid)
|
||||||
|
user_id_str = str(user_id)
|
||||||
|
|
||||||
group = await get_group_by_id(db, group_id)
|
group = await get_group_by_id(db, group_id_uuid)
|
||||||
|
|
||||||
if not group:
|
if not group:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# direct chat → nothing to release
|
# direct chat → nothing to release
|
||||||
if group.type == GroupType.DIRECT:
|
if str(group.type) == "direct":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return await release_speaker(group_id, user_id)
|
return await release_speaker(group_id_str, user_id_str)
|
||||||
|
|
||||||
|
|
||||||
async def current_speaker(
|
async def current_speaker(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
group_id: str
|
group_id: str | uuid.UUID
|
||||||
):
|
):
|
||||||
|
group_id_uuid = group_id if isinstance(group_id, uuid.UUID) else uuid.UUID(group_id)
|
||||||
|
group_id_str = str(group_id_uuid)
|
||||||
|
|
||||||
group = await get_group_by_id(db, group_id)
|
group = await get_group_by_id(db, group_id_uuid)
|
||||||
|
|
||||||
if not group:
|
if not group:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if group.type == GroupType.DIRECT:
|
if str(group.type) == "direct":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await get_active_speaker(group_id)
|
return await get_active_speaker(group_id_str)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from core.websocket import get_ws_current_user
|
from core.websocket import get_ws_current_user
|
||||||
|
from db.session import AsyncSessionLocal
|
||||||
|
from domains.groups.repo import get_group_member
|
||||||
|
|
||||||
from domains.realtime.ws_manager import manager
|
from domains.realtime.ws_manager import manager
|
||||||
from domains.realtime.presence_service import (
|
from domains.realtime.presence_service import (
|
||||||
|
|
@ -16,7 +20,6 @@ from domains.realtime.speaker_service import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from integrations.livekit.token_service import generate_join_token
|
from integrations.livekit.token_service import generate_join_token
|
||||||
from db.session import AsyncSessionLocal
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -25,6 +28,18 @@ router = APIRouter()
|
||||||
async def group_ws(websocket: WebSocket, group_id: str):
|
async def group_ws(websocket: WebSocket, group_id: str):
|
||||||
|
|
||||||
user = await get_ws_current_user(websocket)
|
user = await get_ws_current_user(websocket)
|
||||||
|
try:
|
||||||
|
group_id_uuid = uuid.UUID(group_id)
|
||||||
|
except ValueError:
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
membership = await get_group_member(db, group_id_uuid, user.id)
|
||||||
|
if not membership:
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
user_id = str(user.id)
|
user_id = str(user.id)
|
||||||
|
|
||||||
# connect websocket
|
# connect websocket
|
||||||
|
|
@ -55,13 +70,12 @@ async def group_ws(websocket: WebSocket, group_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
event = data.get("type")
|
event = data.get("type")
|
||||||
# user wants to speak
|
# user wants to speak
|
||||||
if event == "request_speak":
|
if event == "request_speak":
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
token = await request_speak(
|
token = await request_speak(
|
||||||
db,
|
db,
|
||||||
group_id,
|
group_id,
|
||||||
|
|
@ -80,7 +94,6 @@ async def group_ws(websocket: WebSocket, group_id: str):
|
||||||
"token": token
|
"token": token
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
speaker = await current_speaker(db, group_id)
|
speaker = await current_speaker(db, group_id)
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
|
|
@ -90,7 +103,6 @@ async def group_ws(websocket: WebSocket, group_id: str):
|
||||||
|
|
||||||
# user stops speaking
|
# user stops speaking
|
||||||
elif event == "stop_speak":
|
elif event == "stop_speak":
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
await stop_speaking(db, group_id, user_id)
|
await stop_speaking(db, group_id, user_id)
|
||||||
await manager.broadcast(
|
await manager.broadcast(
|
||||||
group_id,
|
group_id,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from domains.users.models import User
|
from domains.users.models import User
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_id(db: AsyncSession, user_id):
|
async def get_user_by_id(db: AsyncSession, user_id: str | uuid.UUID):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.id == user_id)
|
select(User).where(User.id == user_id)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def get_user_by_username(db: AsyncSession, username):
|
async def get_user_by_username(db: AsyncSession, username: str):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.username == username)
|
select(User).where(User.username == username)
|
||||||
)
|
)
|
||||||
|
|
@ -21,6 +23,10 @@ async def create_user(db: AsyncSession, user: User):
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def get_all_users(db: AsyncSession):
|
async def get_all_users(db: AsyncSession, include_admin: bool = False):
|
||||||
result = await db.execute(select(User))
|
query = select(User)
|
||||||
|
if not include_admin:
|
||||||
|
query = query.where(User.is_admin.is_(False))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
@ -13,8 +12,8 @@ class UserCreate(BaseModel):
|
||||||
def validate_phone_number(cls, v: str | None) -> str | None:
|
def validate_phone_number(cls, v: str | None) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
if not re.match(r"^\d{11}$", v):
|
if not re.match(r"^09\d{9}$", v):
|
||||||
raise ValueError("Phone number must be exactly 11 digits")
|
raise ValueError("Phone number must start with 09 and be exactly 11 digits")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from domains.users.repo import get_user_by_id
|
from domains.users.repo import get_user_by_id
|
||||||
|
|
||||||
|
|
||||||
async def get_user(db: AsyncSession, user_id):
|
async def get_user(db: AsyncSession, user_id: str | uuid.UUID):
|
||||||
return await get_user_by_id(db, user_id)
|
return await get_user_by_id(db, user_id)
|
||||||
12
Back/main.py
12
Back/main.py
|
|
@ -17,7 +17,7 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# ---------- Startup ----------
|
# ---------- Startup ----------
|
||||||
try:
|
try:
|
||||||
await redis_client.ping()
|
await redis_client.ping() # type: ignore
|
||||||
print("Redis connected")
|
print("Redis connected")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -59,13 +59,3 @@ app.include_router(users_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(groups_router)
|
app.include_router(groups_router)
|
||||||
app.include_router(realtime_router)
|
app.include_router(realtime_router)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Health Check
|
|
||||||
# -------------------------
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
@ -1,33 +1,35 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from db.session import AsyncSessionLocal
|
from db.session import AsyncSessionLocal
|
||||||
|
from domains.users.models import User
|
||||||
from core.security import hash_password
|
from core.security import hash_password
|
||||||
|
|
||||||
from domains.users.models import User, UserRole
|
async def create_admin() -> None:
|
||||||
|
username = input("Admin username: ").strip()
|
||||||
|
phone_number = input("Phone number (optional, 11 digits): ").strip() or None
|
||||||
async def create_admin():
|
# secret = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
username = input("Admin username: ")
|
|
||||||
|
|
||||||
secret = "1234"
|
secret = "1234"
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
role=UserRole.ADMIN,
|
phone_number=phone_number,
|
||||||
|
is_admin=True,
|
||||||
secret_hash=hash_password(secret),
|
secret_hash=hash_password(secret),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"\nError: {exc}\n")
|
||||||
|
return
|
||||||
|
|
||||||
print("\nAdmin created successfully\n")
|
print("\nAdmin created successfully\n")
|
||||||
print("Username:", username)
|
print("Username:", user.username)
|
||||||
|
print("Phone number:", user.phone_number)
|
||||||
print("Secret:", secret)
|
print("Secret:", secret)
|
||||||
print("\nSave this secret!\n")
|
print("\nSave this secret!\n")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user