Spaces:
Sleeping
Sleeping
initial commit
Browse files- .gitignore +4 -0
- Dockerfile +16 -0
- app/api/v1/api_router.py +11 -0
- app/api/v1/endpoints/admin.py +414 -0
- app/api/v1/endpoints/auth.py +111 -0
- app/api/v1/endpoints/chat.py +250 -0
- app/api/v1/endpoints/messages.py +87 -0
- app/api/v1/endpoints/users.py +159 -0
- app/cache/group_members.py +40 -0
- app/cache/tenant_members.py +33 -0
- app/core/config.py +38 -0
- app/db/session.py +52 -0
- app/models/__init__.py +11 -0
- app/models/admin.py +16 -0
- app/models/base.py +5 -0
- app/models/group.py +17 -0
- app/models/group_member.py +14 -0
- app/models/super_admin.py +10 -0
- app/models/user.py +17 -0
- app/schemas/admin.py +28 -0
- app/schemas/group.py +31 -0
- app/schemas/message.py +47 -0
- app/schemas/token.py +15 -0
- app/schemas/user.py +60 -0
- app/security/dependencies.py +40 -0
- app/security/hashing.py +15 -0
- app/security/jwt.py +40 -0
- app/websocket/connection_manager.py +36 -0
- main.py +48 -0
- requirements.txt +11 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
myenv/
|
| 2 |
+
scripts/
|
| 3 |
+
.env
|
| 4 |
+
__pycache__/
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12.7
|
| 2 |
+
WORKDIR /code
|
| 3 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 4 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 5 |
+
|
| 6 |
+
RUN useradd user
|
| 7 |
+
USER user
|
| 8 |
+
|
| 9 |
+
ENV HOME=/home/user \
|
| 10 |
+
PATH=/home/user/.local/bin:$PATH
|
| 11 |
+
|
| 12 |
+
WORKDIR $HOME/app
|
| 13 |
+
|
| 14 |
+
COPY --chown=user . $HOME/app
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/api/v1/api_router.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from app.api.v1.endpoints import auth, users, admin, chat, messages
|
| 3 |
+
|
| 4 |
+
api_router = APIRouter()
|
| 5 |
+
|
| 6 |
+
# Add the routers to the main API router
|
| 7 |
+
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
| 8 |
+
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
| 9 |
+
api_router.include_router(admin.router, prefix="/admin", tags=["Admin Management"])
|
| 10 |
+
api_router.include_router(messages.router, prefix="/messages", tags=["Message History"])
|
| 11 |
+
api_router.include_router(chat.router, prefix="/chat", tags=["Real-Time Chat"])
|
app/api/v1/endpoints/admin.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 3 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 4 |
+
from sqlalchemy.orm import Session, joinedload
|
| 5 |
+
from typing import List, Union, Optional
|
| 6 |
+
import datetime
|
| 7 |
+
|
| 8 |
+
from app.db.session import get_db, get_mongo_db
|
| 9 |
+
from app.security.dependencies import get_current_user_from_cookie
|
| 10 |
+
from app.security.hashing import Hasher
|
| 11 |
+
from app.models import Admin, User, Group, GroupMember
|
| 12 |
+
from app.schemas.user import UserOut, UserCreate, UserPasswordReset
|
| 13 |
+
from app.schemas.group import GroupCreateWithMembers, GroupWithMembers, GroupOut
|
| 14 |
+
from app.schemas.admin import ConversationSummary
|
| 15 |
+
from app.schemas.message import MessageHistory
|
| 16 |
+
from app.websocket.connection_manager import manager
|
| 17 |
+
from app.cache.group_members import remove_group_from_cache, add_member_to_cache, remove_member_from_cache
|
| 18 |
+
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
def get_admin_from_dependency(current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie)) -> Admin:
|
| 22 |
+
"""
|
| 23 |
+
A helper dependency to ensure the user is an Admin.
|
| 24 |
+
This replaces the old get_current_active_admin.
|
| 25 |
+
"""
|
| 26 |
+
if not isinstance(current_entity, Admin):
|
| 27 |
+
raise HTTPException(status_code=403, detail="Operation not permitted. Requires admin privileges.")
|
| 28 |
+
if not current_entity.is_active:
|
| 29 |
+
raise HTTPException(status_code=400, detail="Inactive admin.")
|
| 30 |
+
return current_entity
|
| 31 |
+
|
| 32 |
+
# --- User Management by Admin ---
|
| 33 |
+
|
| 34 |
+
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
| 35 |
+
def create_user_for_admin(
|
| 36 |
+
user_in: UserCreate,
|
| 37 |
+
db: Session = Depends(get_db),
|
| 38 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 39 |
+
):
|
| 40 |
+
# ... (logic remains the same)
|
| 41 |
+
full_username = f"{user_in.username}{current_admin.admin_key}"
|
| 42 |
+
db_user = db.query(User).filter(User.username == full_username).first()
|
| 43 |
+
if db_user:
|
| 44 |
+
raise HTTPException(status_code=400, detail=f"Username '{user_in.username}' already exists in your tenant.")
|
| 45 |
+
hashed_password = Hasher.get_password_hash(user_in.password)
|
| 46 |
+
new_user = User(username=full_username, password_hash=hashed_password, admin_id=current_admin.id)
|
| 47 |
+
db.add(new_user)
|
| 48 |
+
db.commit()
|
| 49 |
+
db.refresh(new_user)
|
| 50 |
+
return new_user
|
| 51 |
+
|
| 52 |
+
@router.get("/users/all", response_model=List[UserOut])
|
| 53 |
+
def list_users_for_admin(
|
| 54 |
+
db: Session = Depends(get_db),
|
| 55 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 56 |
+
):
|
| 57 |
+
return db.query(User).filter(User.admin_id == current_admin.id).all()
|
| 58 |
+
|
| 59 |
+
@router.get("/users/active", response_model=List[UserOut])
|
| 60 |
+
def list_active_users_for_admin(
|
| 61 |
+
db: Session = Depends(get_db),
|
| 62 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 63 |
+
):
|
| 64 |
+
"""Lists all active users in the admin's tenant."""
|
| 65 |
+
return db.query(User).filter(User.admin_id == current_admin.id, User.is_active == True).all()
|
| 66 |
+
|
| 67 |
+
@router.get("/users/deactivated", response_model=List[UserOut])
|
| 68 |
+
def list_deactivated_users_for_admin(
|
| 69 |
+
db: Session = Depends(get_db),
|
| 70 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 71 |
+
):
|
| 72 |
+
"""Lists all deactivated users in the admin's tenant."""
|
| 73 |
+
return db.query(User).filter(User.admin_id == current_admin.id, User.is_active == False).all()
|
| 74 |
+
|
| 75 |
+
@router.get("/users/online", response_model=List[UserOut])
|
| 76 |
+
def get_online_users(
|
| 77 |
+
db: Session = Depends(get_db),
|
| 78 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 79 |
+
):
|
| 80 |
+
"""Gets a list of all online users within the current admin's tenant."""
|
| 81 |
+
online_connection_ids = manager.get_all_connection_ids()
|
| 82 |
+
online_user_ids = [int(cid.split('-')[1]) for cid in online_connection_ids if cid.startswith('user-')]
|
| 83 |
+
|
| 84 |
+
# Fetch details only for online users that belong to this admin's tenant
|
| 85 |
+
online_users_in_tenant = db.query(User).filter(
|
| 86 |
+
User.id.in_(online_user_ids),
|
| 87 |
+
User.admin_id == current_admin.id
|
| 88 |
+
).all()
|
| 89 |
+
return online_users_in_tenant
|
| 90 |
+
|
| 91 |
+
@router.patch("/users/{user_id}/deactivate", status_code=status.HTTP_204_NO_CONTENT)
|
| 92 |
+
async def deactivate_user(
|
| 93 |
+
user_id: int,
|
| 94 |
+
db: Session = Depends(get_db),
|
| 95 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 96 |
+
):
|
| 97 |
+
# ... (logic remains the same)
|
| 98 |
+
user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
|
| 99 |
+
if not user:
|
| 100 |
+
raise HTTPException(status_code=404, detail="User not found in your tenant.")
|
| 101 |
+
user.is_active = False
|
| 102 |
+
db.commit()
|
| 103 |
+
user_connection_id = f"user-{user.id}"
|
| 104 |
+
if user_connection_id in manager.active_connections:
|
| 105 |
+
logout_command = json.dumps({"event": "force_logout", "reason": "Your account has been deactivated by the administrator."})
|
| 106 |
+
await manager.send_personal_message(logout_command, user_connection_id)
|
| 107 |
+
manager.disconnect(user_connection_id)
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
@router.patch("/users/{user_id}/reactivate", status_code=status.HTTP_204_NO_CONTENT)
|
| 111 |
+
def reactivate_user(
|
| 112 |
+
user_id: int,
|
| 113 |
+
db: Session = Depends(get_db),
|
| 114 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 115 |
+
):
|
| 116 |
+
# ... (logic remains the same)
|
| 117 |
+
user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
|
| 118 |
+
if not user:
|
| 119 |
+
raise HTTPException(status_code=404, detail="User not found in your tenant.")
|
| 120 |
+
user.is_active = True
|
| 121 |
+
db.commit()
|
| 122 |
+
return
|
| 123 |
+
|
| 124 |
+
@router.patch("/users/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT)
|
| 125 |
+
async def reset_user_password(
|
| 126 |
+
user_id: int,
|
| 127 |
+
password_data: UserPasswordReset,
|
| 128 |
+
db: Session = Depends(get_db),
|
| 129 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 130 |
+
):
|
| 131 |
+
"""
|
| 132 |
+
Resets the password for a specific user within the admin's tenant.
|
| 133 |
+
"""
|
| 134 |
+
# Find the user and verify they belong to the admin's tenant
|
| 135 |
+
user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
|
| 136 |
+
if not user:
|
| 137 |
+
raise HTTPException(status_code=404, detail="User not found in your tenant.")
|
| 138 |
+
|
| 139 |
+
# Hash the new password and update the user's record
|
| 140 |
+
hashed_password = Hasher.get_password_hash(password_data.new_password)
|
| 141 |
+
user.password_hash = hashed_password
|
| 142 |
+
|
| 143 |
+
db.commit()
|
| 144 |
+
user_connection_id = f"user-{user.id}"
|
| 145 |
+
if user_connection_id in manager.active_connections:
|
| 146 |
+
logout_command = json.dumps({"event": "force_logout", "reason": "Please re-authenticate yourself as admin has reset your password."})
|
| 147 |
+
await manager.send_personal_message(logout_command, user_connection_id)
|
| 148 |
+
manager.disconnect(user_connection_id)
|
| 149 |
+
return
|
| 150 |
+
|
| 151 |
+
# --- Group Management by Admin ---
|
| 152 |
+
|
| 153 |
+
@router.post("/groups", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
| 154 |
+
def create_group_for_admin(
|
| 155 |
+
group_in: GroupCreateWithMembers,
|
| 156 |
+
db: Session = Depends(get_db),
|
| 157 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 158 |
+
):
|
| 159 |
+
# ... (logic remains the same)
|
| 160 |
+
db_group = db.query(Group).filter(Group.name == group_in.name, Group.admin_id == current_admin.id).first()
|
| 161 |
+
if db_group:
|
| 162 |
+
raise HTTPException(status_code=400, detail=f"Group name '{group_in.name}' already exists in your tenant.")
|
| 163 |
+
new_group = Group(name=group_in.name, admin_id=current_admin.id)
|
| 164 |
+
db.add(new_group)
|
| 165 |
+
db.flush()
|
| 166 |
+
if group_in.members:
|
| 167 |
+
for user_id in group_in.members:
|
| 168 |
+
user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
|
| 169 |
+
if user:
|
| 170 |
+
new_member = GroupMember(group_id=new_group.id, user_id=user.id)
|
| 171 |
+
db.add(new_member)
|
| 172 |
+
add_member_to_cache(new_group.id, f"user-{user.id}")
|
| 173 |
+
db.commit()
|
| 174 |
+
db.refresh(new_group)
|
| 175 |
+
return new_group
|
| 176 |
+
|
| 177 |
+
@router.get("/groups/all", response_model=List[GroupWithMembers])
|
| 178 |
+
def list_groups_with_members_for_admin(
|
| 179 |
+
db: Session = Depends(get_db),
|
| 180 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 181 |
+
):
|
| 182 |
+
"""Lists all groups in the admin's tenant, including their members."""
|
| 183 |
+
groups = (
|
| 184 |
+
db.query(Group)
|
| 185 |
+
.options(joinedload(Group.members).joinedload(GroupMember.user))
|
| 186 |
+
.filter(Group.admin_id == current_admin.id)
|
| 187 |
+
.all()
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
result = []
|
| 191 |
+
for group in groups:
|
| 192 |
+
members_info = [
|
| 193 |
+
{"user_id": member.user.id, "username": member.user.username}
|
| 194 |
+
for member in group.members
|
| 195 |
+
]
|
| 196 |
+
result.append({
|
| 197 |
+
"id": group.id,
|
| 198 |
+
"name": group.name,
|
| 199 |
+
"admin_id": group.admin_id,
|
| 200 |
+
"is_active": group.is_active,
|
| 201 |
+
"members": members_info
|
| 202 |
+
})
|
| 203 |
+
return result
|
| 204 |
+
|
| 205 |
+
@router.get("/groups/active", response_model=List[GroupWithMembers])
|
| 206 |
+
def list_active_groups_with_members(
|
| 207 |
+
db: Session = Depends(get_db),
|
| 208 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 209 |
+
):
|
| 210 |
+
"""Lists all active groups in the admin's tenant, including their members."""
|
| 211 |
+
groups = (
|
| 212 |
+
db.query(Group)
|
| 213 |
+
.options(joinedload(Group.members).joinedload(GroupMember.user))
|
| 214 |
+
.filter(Group.admin_id == current_admin.id, Group.is_active == True)
|
| 215 |
+
.all()
|
| 216 |
+
)
|
| 217 |
+
# ... (response formatting logic remains the same)
|
| 218 |
+
result = []
|
| 219 |
+
for group in groups:
|
| 220 |
+
members_info = [{"user_id": member.user.id, "username": member.user.username} for member in group.members]
|
| 221 |
+
result.append({"id": group.id, "name": group.name, "admin_id": group.admin_id, "is_active": group.is_active, "members": members_info})
|
| 222 |
+
return result
|
| 223 |
+
|
| 224 |
+
@router.get("/groups/deactivated", response_model=List[GroupWithMembers])
|
| 225 |
+
def list_deactivated_groups_with_members(
|
| 226 |
+
db: Session = Depends(get_db),
|
| 227 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 228 |
+
):
|
| 229 |
+
"""Lists all deactivated groups in the admin's tenant, including their members."""
|
| 230 |
+
groups = (
|
| 231 |
+
db.query(Group)
|
| 232 |
+
.options(joinedload(Group.members).joinedload(GroupMember.user))
|
| 233 |
+
.filter(Group.admin_id == current_admin.id, Group.is_active == False)
|
| 234 |
+
.all()
|
| 235 |
+
)
|
| 236 |
+
# ... (response formatting logic remains the same)
|
| 237 |
+
result = []
|
| 238 |
+
for group in groups:
|
| 239 |
+
members_info = [{"user_id": member.user.id, "username": member.user.username} for member in group.members]
|
| 240 |
+
result.append({"id": group.id, "name": group.name, "admin_id": group.admin_id, "is_active": group.is_active, "members": members_info})
|
| 241 |
+
return result
|
| 242 |
+
|
| 243 |
+
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 244 |
+
def deactivate_group(
|
| 245 |
+
group_id: int,
|
| 246 |
+
db: Session = Depends(get_db),
|
| 247 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 248 |
+
):
|
| 249 |
+
group = db.query(Group).filter(Group.id == group_id, Group.admin_id == current_admin.id).first()
|
| 250 |
+
if not group:
|
| 251 |
+
raise HTTPException(status_code=404, detail="Group not found in your tenant.")
|
| 252 |
+
group.is_active = False
|
| 253 |
+
db.commit()
|
| 254 |
+
remove_group_from_cache(group_id)
|
| 255 |
+
return
|
| 256 |
+
|
| 257 |
+
@router.post("/groups/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 258 |
+
def add_user_to_group(
|
| 259 |
+
group_id: int,
|
| 260 |
+
user_id: int,
|
| 261 |
+
db: Session = Depends(get_db),
|
| 262 |
+
current_admin: Admin = Depends(get_current_user_from_cookie)
|
| 263 |
+
):
|
| 264 |
+
"""
|
| 265 |
+
Add a user from the admin's tenant to a group in the same tenant.
|
| 266 |
+
"""
|
| 267 |
+
# Verify the group belongs to the admin
|
| 268 |
+
group = db.query(Group).filter(Group.id == group_id, Group.admin_id == current_admin.id).first()
|
| 269 |
+
if not group:
|
| 270 |
+
raise HTTPException(status_code=404, detail="Group not found in your tenant.")
|
| 271 |
+
|
| 272 |
+
# Verify the user belongs to the admin
|
| 273 |
+
user = db.query(User).filter(User.id == user_id, User.admin_id == current_admin.id).first()
|
| 274 |
+
if not user:
|
| 275 |
+
raise HTTPException(status_code=404, detail="User not found in your tenant.")
|
| 276 |
+
|
| 277 |
+
# Check if the user is already a member
|
| 278 |
+
membership = db.query(GroupMember).filter(GroupMember.group_id == group_id, GroupMember.user_id == user_id).first()
|
| 279 |
+
if membership:
|
| 280 |
+
raise HTTPException(status_code=400, detail="User is already a member of this group.")
|
| 281 |
+
|
| 282 |
+
new_member = GroupMember(group_id=group_id, user_id=user_id)
|
| 283 |
+
db.add(new_member)
|
| 284 |
+
db.commit()
|
| 285 |
+
add_member_to_cache(group_id, f"user-{user_id}")
|
| 286 |
+
return
|
| 287 |
+
|
| 288 |
+
@router.delete("/groups/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 289 |
+
def remove_user_from_group(
|
| 290 |
+
group_id: int,
|
| 291 |
+
user_id: int,
|
| 292 |
+
db: Session = Depends(get_db),
|
| 293 |
+
current_admin: Admin = Depends(get_current_user_from_cookie)
|
| 294 |
+
):
|
| 295 |
+
"""
|
| 296 |
+
Remove a user from a group.
|
| 297 |
+
"""
|
| 298 |
+
# Verify the group belongs to the admin to allow this operation
|
| 299 |
+
group = db.query(Group).filter(Group.id == group_id, Group.admin_id == current_admin.id).first()
|
| 300 |
+
if not group:
|
| 301 |
+
raise HTTPException(status_code=404, detail="Group not found in your tenant.")
|
| 302 |
+
|
| 303 |
+
membership = db.query(GroupMember).filter(GroupMember.group_id == group_id, GroupMember.user_id == user_id).first()
|
| 304 |
+
if not membership:
|
| 305 |
+
raise HTTPException(status_code=404, detail="User is not a member of this group.")
|
| 306 |
+
|
| 307 |
+
db.delete(membership)
|
| 308 |
+
db.commit()
|
| 309 |
+
remove_member_from_cache(group_id, f"user-{user_id}")
|
| 310 |
+
return
|
| 311 |
+
|
| 312 |
+
@router.get("/conversations/users", response_model=List[ConversationSummary])
|
| 313 |
+
async def list_user_to_user_conversations(
|
| 314 |
+
db: Session = Depends(get_db),
|
| 315 |
+
mongo_db: AsyncIOMotorClient = Depends(get_mongo_db),
|
| 316 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 317 |
+
):
|
| 318 |
+
"""
|
| 319 |
+
Lists all private user-to-user conversations within the admin's tenant,
|
| 320 |
+
sorted by the most recent message.
|
| 321 |
+
"""
|
| 322 |
+
# 1. Get all user IDs for the current admin's tenant
|
| 323 |
+
tenant_user_ids = [user.id for user in db.query(User.id).filter(User.admin_id == current_admin.id).all()]
|
| 324 |
+
if not tenant_user_ids:
|
| 325 |
+
return []
|
| 326 |
+
|
| 327 |
+
messages_collection = mongo_db["messages"]
|
| 328 |
+
|
| 329 |
+
# 2. Run the aggregation pipeline in MongoDB
|
| 330 |
+
pipeline = [
|
| 331 |
+
# Match only private messages between users in this tenant
|
| 332 |
+
{"$match": {
|
| 333 |
+
"type": "private",
|
| 334 |
+
"sender.role": "user",
|
| 335 |
+
"receiver.role": "user",
|
| 336 |
+
"sender.id": {"$in": tenant_user_ids},
|
| 337 |
+
"receiver.id": {"$in": tenant_user_ids}
|
| 338 |
+
}},
|
| 339 |
+
# Group by a canonical key (sorted participants)
|
| 340 |
+
{"$group": {
|
| 341 |
+
"_id": {
|
| 342 |
+
"participants": {
|
| 343 |
+
"$sortArray": {"input": ["$sender", "$receiver"], "sortBy": {"id": 1}}
|
| 344 |
+
}
|
| 345 |
+
},
|
| 346 |
+
"last_message_timestamp": {"$max": "$timestamp"},
|
| 347 |
+
"message_count": {"$sum": 1}
|
| 348 |
+
}},
|
| 349 |
+
# Sort conversations by the most recent activity
|
| 350 |
+
{"$sort": {"last_message_timestamp": -1}}
|
| 351 |
+
]
|
| 352 |
+
|
| 353 |
+
aggregation_result = await messages_collection.aggregate(pipeline).to_list(length=None)
|
| 354 |
+
|
| 355 |
+
# 3. Format the response
|
| 356 |
+
response = []
|
| 357 |
+
for item in aggregation_result:
|
| 358 |
+
participants = item["_id"]["participants"]
|
| 359 |
+
response.append({
|
| 360 |
+
"user_one": {"id": participants[0]["id"], "username": participants[0]["username"]},
|
| 361 |
+
"user_two": {"id": participants[1]["id"], "username": participants[1]["username"]},
|
| 362 |
+
"last_message_timestamp": item["last_message_timestamp"],
|
| 363 |
+
"message_count": item["message_count"]
|
| 364 |
+
})
|
| 365 |
+
|
| 366 |
+
return response
|
| 367 |
+
|
| 368 |
+
@router.get("/messages/users/{user1_id}/{user2_id}", response_model=MessageHistory)
|
| 369 |
+
async def get_user_to_user_message_history(
|
| 370 |
+
user1_id: int,
|
| 371 |
+
user2_id: int,
|
| 372 |
+
before: Optional[str] = Query(None, description="ISO timestamp cursor for pagination"),
|
| 373 |
+
limit: int = Query(50, gt=0, le=100),
|
| 374 |
+
db: Session = Depends(get_db),
|
| 375 |
+
mongo_db: AsyncIOMotorClient = Depends(get_mongo_db),
|
| 376 |
+
current_admin: Admin = Depends(get_admin_from_dependency)
|
| 377 |
+
):
|
| 378 |
+
"""
|
| 379 |
+
Fetches the detailed message history for a specific user-to-user conversation.
|
| 380 |
+
"""
|
| 381 |
+
# 1. Security Check: Verify both users belong to the admin's tenant
|
| 382 |
+
users = db.query(User).filter(User.id.in_([user1_id, user2_id]), User.admin_id == current_admin.id).all()
|
| 383 |
+
if len(users) != 2:
|
| 384 |
+
raise HTTPException(status_code=404, detail="One or both users not found in your tenant.")
|
| 385 |
+
|
| 386 |
+
# 2. Build the MongoDB query
|
| 387 |
+
messages_collection = mongo_db["messages"]
|
| 388 |
+
query = {
|
| 389 |
+
"$or": [
|
| 390 |
+
{"sender.id": user1_id, "receiver.id": user2_id},
|
| 391 |
+
{"sender.id": user2_id, "receiver.id": user1_id}
|
| 392 |
+
],
|
| 393 |
+
"type": "private"
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
if before:
|
| 397 |
+
try:
|
| 398 |
+
cursor_time = datetime.datetime.fromisoformat(before.replace("Z", "+00:00"))
|
| 399 |
+
query["timestamp"] = {"$lt": cursor_time}
|
| 400 |
+
except ValueError:
|
| 401 |
+
raise HTTPException(status_code=400, detail="Invalid 'before' timestamp format.")
|
| 402 |
+
|
| 403 |
+
# 3. Fetch messages
|
| 404 |
+
messages_cursor = messages_collection.find(query).sort("timestamp", -1).limit(limit)
|
| 405 |
+
messages = await messages_cursor.to_list(length=limit)
|
| 406 |
+
|
| 407 |
+
for msg in messages:
|
| 408 |
+
msg["_id"] = str(msg["_id"])
|
| 409 |
+
|
| 410 |
+
next_cursor = None
|
| 411 |
+
if len(messages) == limit:
|
| 412 |
+
next_cursor = messages[-1]['timestamp'].isoformat() + "Z"
|
| 413 |
+
|
| 414 |
+
return {"messages": messages, "next_cursor": next_cursor}
|
app/api/v1/endpoints/auth.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.db.session import get_db
|
| 5 |
+
from app.schemas.user import UserLoginSchema
|
| 6 |
+
from app.security.hashing import Hasher
|
| 7 |
+
from app.security.jwt import create_access_token, create_refresh_token, verify_token
|
| 8 |
+
from app.models import User, Admin, SuperAdmin
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
| 13 |
+
"""Utility function to set auth cookies on a response."""
|
| 14 |
+
response.set_cookie(
|
| 15 |
+
key="access_token", value=access_token, httponly=True, samesite="lax", secure=True
|
| 16 |
+
)
|
| 17 |
+
response.set_cookie(
|
| 18 |
+
key="refresh_token", value=refresh_token, httponly=True, samesite="lax", secure=True
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
def delete_auth_cookies(response: Response):
|
| 22 |
+
"""Utility function to delete auth cookies."""
|
| 23 |
+
response.delete_cookie(key="access_token")
|
| 24 |
+
response.delete_cookie(key="refresh_token")
|
| 25 |
+
|
| 26 |
+
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
| 27 |
+
def login(response: Response, user_credentials: UserLoginSchema, db: Session = Depends(get_db)):
|
| 28 |
+
"""
|
| 29 |
+
Handles login and sets HttpOnly cookies for access and refresh tokens.
|
| 30 |
+
Now includes a check to ensure the user/admin is active.
|
| 31 |
+
"""
|
| 32 |
+
entity = None
|
| 33 |
+
role = None
|
| 34 |
+
tenant_id = None
|
| 35 |
+
|
| 36 |
+
inactive_exception = HTTPException(
|
| 37 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 38 |
+
detail="Your account has been deactivated. Please contact your administrator."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Check across user, admin, and super_admin tables
|
| 42 |
+
user = db.query(User).filter(User.username == user_credentials.username).first()
|
| 43 |
+
if user:
|
| 44 |
+
if not user.is_active:
|
| 45 |
+
raise inactive_exception
|
| 46 |
+
if Hasher.verify_password(user_credentials.password, user.password_hash):
|
| 47 |
+
entity, role, tenant_id = user, "user", user.admin_id
|
| 48 |
+
|
| 49 |
+
if not entity:
|
| 50 |
+
admin = db.query(Admin).filter(Admin.username == user_credentials.username).first()
|
| 51 |
+
if admin:
|
| 52 |
+
if not admin.is_active:
|
| 53 |
+
raise inactive_exception
|
| 54 |
+
if Hasher.verify_password(user_credentials.password, admin.password_hash):
|
| 55 |
+
entity, role, tenant_id = admin, "admin", admin.id
|
| 56 |
+
|
| 57 |
+
if not entity:
|
| 58 |
+
super_admin = db.query(SuperAdmin).filter(SuperAdmin.username == user_credentials.username).first()
|
| 59 |
+
if super_admin and Hasher.verify_password(user_credentials.password, super_admin.password_hash):
|
| 60 |
+
entity, role, tenant_id = super_admin, "super_admin", None
|
| 61 |
+
|
| 62 |
+
if not entity:
|
| 63 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password")
|
| 64 |
+
|
| 65 |
+
token_data = {"sub": entity.username, "role": role, "tenant_id": tenant_id}
|
| 66 |
+
access_token = create_access_token(data=token_data)
|
| 67 |
+
refresh_token = create_refresh_token(data=token_data)
|
| 68 |
+
|
| 69 |
+
set_auth_cookies(response, access_token, refresh_token)
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
@router.post("/refresh", status_code=status.HTTP_204_NO_CONTENT)
|
| 73 |
+
def refresh_token(request: Request, response: Response, db: Session = Depends(get_db)):
|
| 74 |
+
"""
|
| 75 |
+
Uses the refresh_token from cookies to issue a new access_token.
|
| 76 |
+
Now includes a check to ensure the user/admin is still active.
|
| 77 |
+
"""
|
| 78 |
+
refresh_token = request.cookies.get("refresh_token")
|
| 79 |
+
if not refresh_token:
|
| 80 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token found")
|
| 81 |
+
|
| 82 |
+
credentials_exception = HTTPException(status_code=401, detail="Could not validate refresh token")
|
| 83 |
+
token_data = verify_token(refresh_token, credentials_exception)
|
| 84 |
+
|
| 85 |
+
# *** FIX IS HERE: Verify the user from the token is still active ***
|
| 86 |
+
entity_to_check = None
|
| 87 |
+
if token_data.role == "user":
|
| 88 |
+
entity_to_check = db.query(User).filter(User.username == token_data.username).first()
|
| 89 |
+
elif token_data.role == "admin":
|
| 90 |
+
entity_to_check = db.query(Admin).filter(Admin.username == token_data.username).first()
|
| 91 |
+
|
| 92 |
+
if entity_to_check and not entity_to_check.is_active:
|
| 93 |
+
# If the user has been deactivated, invalidate their session by deleting cookies
|
| 94 |
+
delete_auth_cookies(response)
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 97 |
+
detail="Your account has been deactivated. Session terminated."
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Re-create the payload for the new access token
|
| 101 |
+
new_token_data = {"sub": token_data.username, "role": token_data.role, "tenant_id": token_data.tenant_id}
|
| 102 |
+
new_access_token = create_access_token(data=new_token_data)
|
| 103 |
+
|
| 104 |
+
response.set_cookie(key="access_token", value=new_access_token, httponly=True, samesite="lax", secure=True)
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
| 108 |
+
def logout(response: Response):
|
| 109 |
+
"""Logs the user out by deleting the auth cookies."""
|
| 110 |
+
delete_auth_cookies(response)
|
| 111 |
+
return
|
app/api/v1/endpoints/chat.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
import pytz
|
| 4 |
+
from typing import Union
|
| 5 |
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException, BackgroundTasks
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 8 |
+
|
| 9 |
+
from app.db.session import get_db, get_mongo_db
|
| 10 |
+
from app.security.jwt import verify_token
|
| 11 |
+
from app.models import User, Admin
|
| 12 |
+
from app.websocket.connection_manager import manager
|
| 13 |
+
from app.cache.group_members import get_group_members
|
| 14 |
+
from app.cache.tenant_members import get_tenant_connection_ids
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
async def broadcast_presence_update(tenant_id: int, user_id: int, role: str, status: str, db: Session):
|
| 19 |
+
"""Broadcasts a user's online/offline status to all users in the same tenant using the cache."""
|
| 20 |
+
# Use the cache to get the list of who to notify
|
| 21 |
+
broadcast_list = get_tenant_connection_ids(tenant_id, db)
|
| 22 |
+
|
| 23 |
+
payload = json.dumps({
|
| 24 |
+
"event": "presence_update",
|
| 25 |
+
"user": {"id": user_id, "role": role},
|
| 26 |
+
"status": status,
|
| 27 |
+
"timestamp": datetime.now(pytz.timezone('Asia/Kolkata')).isoformat()
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
await manager.broadcast_to_users(payload, list(broadcast_list))
|
| 31 |
+
|
| 32 |
+
# --- Background task for database updates on disconnect ---
|
| 33 |
+
def update_last_seen(user_id: int, db: Session):
|
| 34 |
+
try:
|
| 35 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 36 |
+
if user:
|
| 37 |
+
user.last_seen = datetime.utcnow()
|
| 38 |
+
db.commit()
|
| 39 |
+
finally:
|
| 40 |
+
db.close()
|
| 41 |
+
|
| 42 |
+
async def mark_messages_as_received(user_id: int, user_role: str, db: AsyncIOMotorClient):
|
| 43 |
+
"""Background task to update 'sent' messages to 'received'."""
|
| 44 |
+
messages_collection = db["messages"]
|
| 45 |
+
sent_messages_cursor = messages_collection.find({
|
| 46 |
+
"receiver.id": user_id,
|
| 47 |
+
"receiver.role": user_role,
|
| 48 |
+
"status": "sent"
|
| 49 |
+
})
|
| 50 |
+
sent_messages = await sent_messages_cursor.to_list(length=None)
|
| 51 |
+
if not sent_messages: return
|
| 52 |
+
|
| 53 |
+
message_ids_to_update = [msg["_id"] for msg in sent_messages]
|
| 54 |
+
|
| 55 |
+
await messages_collection.update_many(
|
| 56 |
+
{"_id": {"$in": message_ids_to_update}},
|
| 57 |
+
{"$set": {"status": "received"}}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
for msg in sent_messages:
|
| 61 |
+
sender_connection_id = f"{msg['sender']['role']}-{msg['sender']['id']}"
|
| 62 |
+
status_update_payload = json.dumps({
|
| 63 |
+
"event": "status_update",
|
| 64 |
+
"message_id": str(msg["_id"]),
|
| 65 |
+
"status": "received"
|
| 66 |
+
})
|
| 67 |
+
await manager.send_personal_message(status_update_payload, sender_connection_id)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.websocket("/ws")
|
| 71 |
+
async def websocket_endpoint(
|
| 72 |
+
websocket: WebSocket,
|
| 73 |
+
background_tasks: BackgroundTasks,
|
| 74 |
+
db: Session = Depends(get_db),
|
| 75 |
+
mongo_db: AsyncIOMotorClient = Depends(get_mongo_db)
|
| 76 |
+
):
|
| 77 |
+
try:
|
| 78 |
+
# 1. Extract the access_token from the WebSocket's cookies
|
| 79 |
+
token = websocket.cookies.get("access_token")
|
| 80 |
+
if not token:
|
| 81 |
+
await websocket.close(code=1008)
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
# 2. Verify the token
|
| 85 |
+
credentials_exception = HTTPException(status_code=403)
|
| 86 |
+
token_data = verify_token(token, credentials_exception)
|
| 87 |
+
|
| 88 |
+
# 3. Fetch the user/admin from the database
|
| 89 |
+
entity: Union[User, Admin] = None
|
| 90 |
+
if token_data.role == 'user':
|
| 91 |
+
entity = db.query(User).filter(User.username == token_data.username).first()
|
| 92 |
+
elif token_data.role == 'admin':
|
| 93 |
+
entity = db.query(Admin).filter(Admin.username == token_data.username).first()
|
| 94 |
+
|
| 95 |
+
if not entity:
|
| 96 |
+
await websocket.close(code=1008)
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
except Exception:
|
| 100 |
+
# If any part of auth fails, close the connection
|
| 101 |
+
await websocket.close(code=1008)
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
connection_id_str = f"{token_data.role}-{entity.id}"
|
| 105 |
+
await manager.connect(connection_id_str, websocket)
|
| 106 |
+
|
| 107 |
+
tenant_id = entity.id if isinstance(entity, Admin) else entity.admin_id
|
| 108 |
+
|
| 109 |
+
online_connection_ids = manager.get_all_connection_ids()
|
| 110 |
+
all_tenant_members = get_tenant_connection_ids(tenant_id, db)
|
| 111 |
+
|
| 112 |
+
# 1. Identify which users in the tenant are offline
|
| 113 |
+
offline_user_ids = []
|
| 114 |
+
for member_cid in all_tenant_members:
|
| 115 |
+
if member_cid.startswith('user-') and member_cid not in online_connection_ids:
|
| 116 |
+
offline_user_ids.append(int(member_cid.split('-')[1]))
|
| 117 |
+
|
| 118 |
+
# 2. Query the database for their last_seen timestamps in one go
|
| 119 |
+
offline_users_info = {
|
| 120 |
+
user.id: user.last_seen
|
| 121 |
+
for user in db.query(User.id, User.last_seen).filter(User.id.in_(offline_user_ids)).all()
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# 3. Build the initial state map with the correct data
|
| 125 |
+
initial_state = {}
|
| 126 |
+
for member_cid in all_tenant_members:
|
| 127 |
+
role, member_id_str = member_cid.split('-')
|
| 128 |
+
member_id = int(member_id_str)
|
| 129 |
+
|
| 130 |
+
if member_id == entity.id and role == token_data.role:
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
if member_cid in online_connection_ids:
|
| 134 |
+
status = "online"
|
| 135 |
+
last_seen = None
|
| 136 |
+
else:
|
| 137 |
+
status = "offline"
|
| 138 |
+
# Use the fetched timestamp if available
|
| 139 |
+
last_seen = offline_users_info.get(member_id)
|
| 140 |
+
|
| 141 |
+
# Ensure timestamp is in ISO format if it exists
|
| 142 |
+
last_seen_iso = last_seen.isoformat() + "Z" if last_seen else None
|
| 143 |
+
initial_state[member_cid] = {"status": status, "lastSeen": last_seen_iso}
|
| 144 |
+
|
| 145 |
+
await manager.send_personal_message(json.dumps({
|
| 146 |
+
"event": "initial_presence_state",
|
| 147 |
+
"users": initial_state
|
| 148 |
+
}), connection_id_str)
|
| 149 |
+
|
| 150 |
+
# Announce the new user's arrival to everyone else
|
| 151 |
+
await broadcast_presence_update(tenant_id, entity.id, token_data.role, "online", db)
|
| 152 |
+
|
| 153 |
+
background_tasks.add_task(mark_messages_as_received, entity.id, token_data.role, mongo_db)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
while True:
|
| 157 |
+
data = await websocket.receive_text()
|
| 158 |
+
message_data = json.loads(data)
|
| 159 |
+
event_type = message_data.get("event", "new_message")
|
| 160 |
+
messages_collection = mongo_db["messages"]
|
| 161 |
+
|
| 162 |
+
if event_type == "messages_read":
|
| 163 |
+
partner = message_data.get("partner")
|
| 164 |
+
if not partner: continue
|
| 165 |
+
|
| 166 |
+
messages_to_update = await messages_collection.find({
|
| 167 |
+
"receiver.id": entity.id, "receiver.role": token_data.role,
|
| 168 |
+
"sender.id": partner["id"], "sender.role": partner["role"],
|
| 169 |
+
"status": {"$in": ["sent", "received"]}
|
| 170 |
+
}).to_list(length=None)
|
| 171 |
+
|
| 172 |
+
if not messages_to_update: continue
|
| 173 |
+
|
| 174 |
+
msg_ids = [msg["_id"] for msg in messages_to_update]
|
| 175 |
+
await messages_collection.update_many(
|
| 176 |
+
{"_id": {"$in": msg_ids}},
|
| 177 |
+
{"$set": {"status": "read"}}
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
partner_connection_id = f"{partner['role']}-{partner['id']}"
|
| 181 |
+
await manager.send_personal_message(json.dumps({
|
| 182 |
+
"event": "status_update",
|
| 183 |
+
"message_ids": [str(mid) for mid in msg_ids],
|
| 184 |
+
"status": "read"
|
| 185 |
+
}), partner_connection_id)
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
if event_type == "new_message":
|
| 189 |
+
raw_content = message_data.get("content")
|
| 190 |
+
if isinstance(raw_content, dict):
|
| 191 |
+
content_obj = raw_content
|
| 192 |
+
else:
|
| 193 |
+
continue
|
| 194 |
+
mongo_message = {
|
| 195 |
+
"type": message_data.get("type"),
|
| 196 |
+
"sender": {"id": entity.id, "role": token_data.role, "username": entity.username},
|
| 197 |
+
"content": content_obj,
|
| 198 |
+
"timestamp": datetime.now(pytz.utc),
|
| 199 |
+
"status": "sent",
|
| 200 |
+
"is_deleted": False
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
if mongo_message["type"] == "private":
|
| 204 |
+
receiver_info = message_data.get("receiver")
|
| 205 |
+
if not receiver_info: continue
|
| 206 |
+
|
| 207 |
+
mongo_message["receiver"] = {
|
| 208 |
+
"id": receiver_info['id'],
|
| 209 |
+
"role": receiver_info['role'],
|
| 210 |
+
"username": receiver_info['username']
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
result = await messages_collection.insert_one(mongo_message)
|
| 214 |
+
mongo_message["_id"] = str(result.inserted_id)
|
| 215 |
+
|
| 216 |
+
receiver_connection_id = f"{receiver_info['role']}-{receiver_info['id']}"
|
| 217 |
+
await manager.broadcast_to_users(json.dumps(mongo_message, default=str), [receiver_connection_id])
|
| 218 |
+
|
| 219 |
+
elif mongo_message["type"] == "group":
|
| 220 |
+
group_info = message_data.get("group")
|
| 221 |
+
if not group_info: continue
|
| 222 |
+
|
| 223 |
+
mongo_message["group"] = {
|
| 224 |
+
"id": group_info["id"],
|
| 225 |
+
"name": group_info["name"]
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
result = await messages_collection.insert_one(mongo_message)
|
| 229 |
+
mongo_message["_id"] = str(result.inserted_id)
|
| 230 |
+
|
| 231 |
+
member_ids = get_group_members(group_info["id"], db=db)
|
| 232 |
+
connections = set(member_ids)
|
| 233 |
+
connections.discard(connection_id_str)
|
| 234 |
+
await manager.broadcast_to_users(json.dumps(mongo_message, default=str), list(connections))
|
| 235 |
+
|
| 236 |
+
except WebSocketDisconnect:
|
| 237 |
+
manager.disconnect(connection_id_str)
|
| 238 |
+
await broadcast_presence_update(tenant_id, entity.id, token_data.role, "offline", db)
|
| 239 |
+
if isinstance(entity, User):
|
| 240 |
+
# Create a new session for the background task
|
| 241 |
+
db_session_for_task = next(get_db())
|
| 242 |
+
background_tasks.add_task(update_last_seen, entity.id, db_session_for_task)
|
| 243 |
+
except Exception as e:
|
| 244 |
+
print(f"Error in WebSocket: {e}")
|
| 245 |
+
manager.disconnect(connection_id_str)
|
| 246 |
+
await broadcast_presence_update(tenant_id, entity.id, token_data.role, "offline", db)
|
| 247 |
+
if isinstance(entity, User):
|
| 248 |
+
db_session_for_task = next(get_db())
|
| 249 |
+
background_tasks.add_task(update_last_seen, entity.id, db_session_for_task)
|
| 250 |
+
|
app/api/v1/endpoints/messages.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 4 |
+
from typing import Union, Optional
|
| 5 |
+
import datetime
|
| 6 |
+
|
| 7 |
+
from app.db.session import get_db, get_mongo_db
|
| 8 |
+
from app.security.dependencies import get_current_user_from_cookie
|
| 9 |
+
from app.models import User, Admin, Group, GroupMember
|
| 10 |
+
from app.schemas.message import MessageHistory
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
@router.get("/{conversation_type}/{partner_id}", response_model=MessageHistory)
|
| 15 |
+
async def get_message_history(
|
| 16 |
+
conversation_type: str = Path(..., description="Type of conversation: 'private' or 'group'"),
|
| 17 |
+
partner_id: int = Path(..., description="ID of the user, admin, or group"),
|
| 18 |
+
partner_role: Optional[str] = Query(None, description="Role of the partner if private: 'user' or 'admin'"),
|
| 19 |
+
before: Optional[str] = Query(None, description="ISO timestamp cursor for pagination"),
|
| 20 |
+
limit: int = Query(50, gt=0, le=100),
|
| 21 |
+
current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie),
|
| 22 |
+
db: Session = Depends(get_db),
|
| 23 |
+
mongo_db: AsyncIOMotorClient = Depends(get_mongo_db)
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Fetch the message history for a specific conversation with pagination.
|
| 27 |
+
"""
|
| 28 |
+
messages_collection = mongo_db["messages"]
|
| 29 |
+
|
| 30 |
+
entity_id = current_entity.id
|
| 31 |
+
entity_role = "admin" if isinstance(current_entity, Admin) else "user"
|
| 32 |
+
|
| 33 |
+
query = {"type": conversation_type}
|
| 34 |
+
|
| 35 |
+
# --- Authorization and Query Building ---
|
| 36 |
+
if conversation_type == "private":
|
| 37 |
+
if not partner_role:
|
| 38 |
+
raise HTTPException(status_code=400, detail="Partner role is required for private chats.")
|
| 39 |
+
|
| 40 |
+
# Build a query that finds messages between the two parties, regardless of who is sender/receiver
|
| 41 |
+
query["$or"] = [
|
| 42 |
+
{"sender.id": entity_id, "sender.role": entity_role, "receiver.id": partner_id, "receiver.role": partner_role},
|
| 43 |
+
{"sender.id": partner_id, "sender.role": partner_role, "receiver.id": entity_id, "receiver.role": entity_role}
|
| 44 |
+
]
|
| 45 |
+
elif conversation_type == "group":
|
| 46 |
+
# Verify the current entity is a member of the group
|
| 47 |
+
group = db.query(Group).filter(Group.id == partner_id).first()
|
| 48 |
+
if not group: raise HTTPException(status_code=404, detail="Group not found.")
|
| 49 |
+
|
| 50 |
+
is_member = False
|
| 51 |
+
if isinstance(current_entity, Admin) and current_entity.id == group.admin_id:
|
| 52 |
+
is_member = True
|
| 53 |
+
elif isinstance(current_entity, User):
|
| 54 |
+
membership = db.query(GroupMember).filter(GroupMember.group_id == partner_id, GroupMember.user_id == entity_id).first()
|
| 55 |
+
if membership: is_member = True
|
| 56 |
+
|
| 57 |
+
if not is_member:
|
| 58 |
+
raise HTTPException(status_code=403, detail="You are not a member of this group.")
|
| 59 |
+
|
| 60 |
+
query["group.id"] = partner_id
|
| 61 |
+
else:
|
| 62 |
+
raise HTTPException(status_code=400, detail="Invalid conversation type.")
|
| 63 |
+
|
| 64 |
+
# --- Pagination ---
|
| 65 |
+
if before:
|
| 66 |
+
try:
|
| 67 |
+
# Using timestamp for cursor-based pagination
|
| 68 |
+
cursor_time = datetime.datetime.fromisoformat(before.replace("Z", "+00:00"))
|
| 69 |
+
query["timestamp"] = {"$lt": cursor_time}
|
| 70 |
+
except ValueError:
|
| 71 |
+
raise HTTPException(status_code=400, detail="Invalid 'before' timestamp format.")
|
| 72 |
+
|
| 73 |
+
# --- Fetching Messages ---
|
| 74 |
+
messages_cursor = messages_collection.find(query).sort("timestamp", -1).limit(limit)
|
| 75 |
+
messages = await messages_cursor.to_list(length=limit)
|
| 76 |
+
|
| 77 |
+
# Convert MongoDB's _id to a string for Pydantic
|
| 78 |
+
for msg in messages:
|
| 79 |
+
msg["_id"] = str(msg["_id"])
|
| 80 |
+
|
| 81 |
+
# --- Determine the next cursor ---
|
| 82 |
+
next_cursor = None
|
| 83 |
+
if len(messages) == limit:
|
| 84 |
+
# The timestamp of the last message fetched is the cursor for the next page
|
| 85 |
+
next_cursor = messages[-1]['timestamp'].isoformat() + "Z"
|
| 86 |
+
|
| 87 |
+
return {"messages": messages, "next_cursor": next_cursor}
|
app/api/v1/endpoints/users.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 4 |
+
from typing import Union
|
| 5 |
+
|
| 6 |
+
from app.db.session import get_db, get_mongo_db
|
| 7 |
+
from app.security.dependencies import get_current_user_from_cookie
|
| 8 |
+
from app.models import User, Admin, Group, GroupMember
|
| 9 |
+
from app.websocket.connection_manager import manager
|
| 10 |
+
from typing import List
|
| 11 |
+
from app.schemas.user import UserOut, AdminOut, SearchResult, ConversationList, ConversationPartner, MeOut, MeProfileOut
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
@router.get("/me", response_model=MeProfileOut)
|
| 16 |
+
def read_users_me(
|
| 17 |
+
# Use the new cookie-based dependency here
|
| 18 |
+
current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie)
|
| 19 |
+
):
|
| 20 |
+
"""
|
| 21 |
+
Get the profile of the currently authenticated user or admin.
|
| 22 |
+
Authentication is now handled via HttpOnly cookies.
|
| 23 |
+
"""
|
| 24 |
+
if isinstance(current_entity, User):
|
| 25 |
+
creator_name = current_entity.owner_admin.username
|
| 26 |
+
entity_type = "user"
|
| 27 |
+
elif isinstance(current_entity, Admin):
|
| 28 |
+
creator_name = "Super Admin"
|
| 29 |
+
entity_type = "admin"
|
| 30 |
+
else:
|
| 31 |
+
# This case handles if a Super Admin somehow hits this endpoint
|
| 32 |
+
creator_name = "System"
|
| 33 |
+
entity_type = "super_admin"
|
| 34 |
+
|
| 35 |
+
return MeProfileOut(
|
| 36 |
+
id=current_entity.id,
|
| 37 |
+
username=current_entity.username,
|
| 38 |
+
type=entity_type,
|
| 39 |
+
created_by=creator_name,
|
| 40 |
+
created_at=current_entity.created_at
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
@router.get("/search", response_model=SearchResult)
|
| 44 |
+
def search_for_entities(
|
| 45 |
+
query: str = Query(..., min_length=1, description="Search query for users, admins, or groups"),
|
| 46 |
+
# current_entity: Union[User, Admin] = Depends(get_current_active_user_or_admin),
|
| 47 |
+
current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie),
|
| 48 |
+
db: Session = Depends(get_db)
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
Search for users, the tenant admin, and groups within the same tenant.
|
| 52 |
+
- For Users: Shows other users in their tenant.
|
| 53 |
+
- For Admins: Shows all users and groups in their tenant.
|
| 54 |
+
"""
|
| 55 |
+
if isinstance(current_entity, Admin):
|
| 56 |
+
tenant_id = current_entity.id
|
| 57 |
+
current_id = None
|
| 58 |
+
else: # It's a User
|
| 59 |
+
tenant_id = current_entity.admin_id
|
| 60 |
+
current_id = current_entity.id
|
| 61 |
+
|
| 62 |
+
# --- Search for users ---
|
| 63 |
+
user_query = db.query(User).filter(
|
| 64 |
+
User.username.ilike(f"%{query}%"),
|
| 65 |
+
User.admin_id == tenant_id
|
| 66 |
+
)
|
| 67 |
+
if current_id: # Exclude self from search if the searcher is a user
|
| 68 |
+
user_query = user_query.filter(User.id != current_id)
|
| 69 |
+
found_users = user_query.all()
|
| 70 |
+
|
| 71 |
+
# --- Search for the admin ---
|
| 72 |
+
found_admins = db.query(Admin).filter(
|
| 73 |
+
Admin.username.ilike(f"%{query}%"),
|
| 74 |
+
Admin.id == tenant_id
|
| 75 |
+
).all()
|
| 76 |
+
|
| 77 |
+
# --- Search for groups ---
|
| 78 |
+
group_query = db.query(Group).filter(
|
| 79 |
+
Group.name.ilike(f"%{query}%"),
|
| 80 |
+
Group.admin_id == tenant_id
|
| 81 |
+
)
|
| 82 |
+
# If the searcher is a standard user, only show groups they are a member of.
|
| 83 |
+
if isinstance(current_entity, User):
|
| 84 |
+
group_query = group_query.join(Group.members).filter(GroupMember.user_id == current_entity.id)
|
| 85 |
+
|
| 86 |
+
found_groups = group_query.all()
|
| 87 |
+
|
| 88 |
+
return {"users": found_users, "admins": found_admins, "groups": found_groups}
|
| 89 |
+
|
| 90 |
+
@router.get("/conversations", response_model=ConversationList)
|
| 91 |
+
async def get_user_conversations(
|
| 92 |
+
# current_entity: Union[User, Admin] = Depends(get_current_active_user_or_admin),
|
| 93 |
+
current_entity: Union[User, Admin] = Depends(get_current_user_from_cookie),
|
| 94 |
+
db: Session = Depends(get_db),
|
| 95 |
+
mongo_db: AsyncIOMotorClient = Depends(get_mongo_db)
|
| 96 |
+
):
|
| 97 |
+
messages_collection = mongo_db["messages"]
|
| 98 |
+
|
| 99 |
+
entity_id = current_entity.id
|
| 100 |
+
entity_role = "admin" if isinstance(current_entity, Admin) else "user"
|
| 101 |
+
tenant_id = current_entity.id if isinstance(current_entity, Admin) else current_entity.admin_id
|
| 102 |
+
|
| 103 |
+
user_group_ids = []
|
| 104 |
+
if isinstance(current_entity, User):
|
| 105 |
+
memberships = db.query(GroupMember.group_id).filter_by(user_id=entity_id).all()
|
| 106 |
+
user_group_ids = [row.group_id for row in memberships]
|
| 107 |
+
else: # An admin is part of all groups in their tenant
|
| 108 |
+
groups = db.query(Group.id).filter_by(admin_id=tenant_id).all()
|
| 109 |
+
user_group_ids = [row.id for row in groups]
|
| 110 |
+
|
| 111 |
+
pipeline = [
|
| 112 |
+
{"$match": {
|
| 113 |
+
"$or": [
|
| 114 |
+
# Private messages sent to or from the user
|
| 115 |
+
{"sender.id": entity_id, "sender.role": entity_role},
|
| 116 |
+
{"receiver.id": entity_id, "receiver.role": entity_role},
|
| 117 |
+
# Messages from any group the user is a member of
|
| 118 |
+
{"group.id": {"$in": user_group_ids}}
|
| 119 |
+
]
|
| 120 |
+
}},
|
| 121 |
+
{"$sort": {"timestamp": -1}},
|
| 122 |
+
{"$group": {
|
| 123 |
+
"_id": {
|
| 124 |
+
"$cond": {
|
| 125 |
+
"if": {"$eq": ["$type", "private"]},
|
| 126 |
+
"then": {
|
| 127 |
+
"participants": {
|
| 128 |
+
"$sortArray": { "input": [ "$sender", "$receiver" ], "sortBy": { "id": 1 } }
|
| 129 |
+
}
|
| 130 |
+
},
|
| 131 |
+
"else": {"$concat": ["group-", {"$toString": "$group.id"}]}
|
| 132 |
+
}
|
| 133 |
+
},
|
| 134 |
+
"last_message_doc": {"$first": "$$ROOT"}
|
| 135 |
+
}},
|
| 136 |
+
{"$replaceRoot": {"newRoot": "$last_message_doc"}}
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
latest_messages = await messages_collection.aggregate(pipeline).to_list(length=None)
|
| 140 |
+
|
| 141 |
+
conversations = []
|
| 142 |
+
# The post-aggregation filtering is no longer needed, as the DB query is now correct.
|
| 143 |
+
for msg in sorted(latest_messages, key=lambda x: x['timestamp'], reverse=True):
|
| 144 |
+
last_message_text = msg.get("content", {}).get("text", "[attachment]")
|
| 145 |
+
|
| 146 |
+
if msg['type'] == 'private':
|
| 147 |
+
partner = msg['receiver'] if msg['sender']['id'] == entity_id and msg['sender']['role'] == entity_role else msg['sender']
|
| 148 |
+
conversations.append(ConversationPartner(
|
| 149 |
+
id=partner['id'], name=partner['username'], type=partner['role'],
|
| 150 |
+
last_message=last_message_text, timestamp=msg['timestamp']
|
| 151 |
+
))
|
| 152 |
+
elif msg['type'] == 'group':
|
| 153 |
+
group = msg['group']
|
| 154 |
+
conversations.append(ConversationPartner(
|
| 155 |
+
id=group['id'], name=group['name'], type='group',
|
| 156 |
+
last_message=last_message_text, timestamp=msg['timestamp']
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
return {"conversations": conversations}
|
app/cache/group_members.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Set
|
| 2 |
+
from threading import Lock
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.models import GroupMember, Group
|
| 5 |
+
|
| 6 |
+
group_members_cache: Dict[int, Set[str]] = {}
|
| 7 |
+
lock = Lock()
|
| 8 |
+
|
| 9 |
+
def get_group_members(group_id: int, db: Session) -> Set[str]:
|
| 10 |
+
with lock:
|
| 11 |
+
if group_id in group_members_cache:
|
| 12 |
+
return group_members_cache[group_id]
|
| 13 |
+
|
| 14 |
+
group = db.query(Group).get(group_id)
|
| 15 |
+
if not group: return set()
|
| 16 |
+
|
| 17 |
+
members = db.query(GroupMember).filter_by(group_id=group.id).all()
|
| 18 |
+
connection_ids = {f"user-{m.user_id}" for m in members}
|
| 19 |
+
connection_ids.add(f"admin-{group.admin_id}")
|
| 20 |
+
|
| 21 |
+
with lock:
|
| 22 |
+
group_members_cache[group_id] = connection_ids
|
| 23 |
+
return connection_ids
|
| 24 |
+
|
| 25 |
+
def add_member_to_cache(group_id: int, connection_id: str):
|
| 26 |
+
with lock:
|
| 27 |
+
if group_id not in group_members_cache:
|
| 28 |
+
return
|
| 29 |
+
group_members_cache[group_id].add(connection_id)
|
| 30 |
+
|
| 31 |
+
def remove_member_from_cache(group_id: int, connection_id: str):
|
| 32 |
+
with lock:
|
| 33 |
+
if group_id in group_members_cache:
|
| 34 |
+
group_members_cache[group_id].discard(connection_id)
|
| 35 |
+
|
| 36 |
+
def remove_group_from_cache(group_id: int):
|
| 37 |
+
"""Removes an entire group from the cache, e.g., when it's deactivated."""
|
| 38 |
+
with lock:
|
| 39 |
+
if group_id in group_members_cache:
|
| 40 |
+
del group_members_cache[group_id]
|
app/cache/tenant_members.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/cache/tenant_members.py
|
| 2 |
+
from typing import Dict, List, Set
|
| 3 |
+
from threading import Lock
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from app.models import User, Admin
|
| 6 |
+
|
| 7 |
+
# Cache format: { tenant_id: {"user-1", "user-2", "admin-10"} }
|
| 8 |
+
tenant_members_cache: Dict[int, Set[str]] = {}
|
| 9 |
+
lock = Lock()
|
| 10 |
+
|
| 11 |
+
def get_tenant_connection_ids(tenant_id: int, db: Session) -> Set[str]:
|
| 12 |
+
"""
|
| 13 |
+
Returns cached connection IDs for a tenant.
|
| 14 |
+
If not cached, it queries the DB, populates the cache, and returns the data.
|
| 15 |
+
"""
|
| 16 |
+
with lock:
|
| 17 |
+
if tenant_id in tenant_members_cache:
|
| 18 |
+
return tenant_members_cache[tenant_id]
|
| 19 |
+
|
| 20 |
+
# --- Not in cache, so load from PostgreSQL ---
|
| 21 |
+
tenant_users = db.query(User.id).filter(User.admin_id == tenant_id).all()
|
| 22 |
+
# The admin is also a member of their own tenant
|
| 23 |
+
tenant_admin = db.query(Admin.id).filter(Admin.id == tenant_id).first()
|
| 24 |
+
|
| 25 |
+
connection_ids = {f"user-{uid}" for uid, in tenant_users}
|
| 26 |
+
if tenant_admin:
|
| 27 |
+
connection_ids.add(f"admin-{tenant_admin.id}")
|
| 28 |
+
|
| 29 |
+
# Populate the cache
|
| 30 |
+
with lock:
|
| 31 |
+
tenant_members_cache[tenant_id] = connection_ids
|
| 32 |
+
|
| 33 |
+
return connection_ids
|
app/core/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/core/config.py
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
# Load environment variables from .env file
|
| 7 |
+
env_path = Path('.') / '.env'
|
| 8 |
+
load_dotenv(dotenv_path=env_path)
|
| 9 |
+
|
| 10 |
+
class Settings:
|
| 11 |
+
"""
|
| 12 |
+
Application settings loaded from environment variables.
|
| 13 |
+
"""
|
| 14 |
+
PROJECT_NAME: str = "Multi-Tenant Chat App"
|
| 15 |
+
PROJECT_VERSION: str = "1.0.0"
|
| 16 |
+
|
| 17 |
+
# PostgreSQL (Neon) settings
|
| 18 |
+
POSTGRES_USER: str = os.getenv("POSTGRES_USER", "postgres")
|
| 19 |
+
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password")
|
| 20 |
+
POSTGRES_SERVER: str = os.getenv("POSTGRES_SERVER", "localhost")
|
| 21 |
+
POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", "5432")
|
| 22 |
+
POSTGRES_DB: str = os.getenv("POSTGRES_DB", "chat_app")
|
| 23 |
+
DATABASE_URL: str = os.getenv("DATABASE_URL")
|
| 24 |
+
|
| 25 |
+
# MongoDB (Atlas) settings
|
| 26 |
+
MONGO_DATABASE_URL: str = os.getenv("MONGO_DATABASE_URL")
|
| 27 |
+
MONGO_DB_NAME: str = os.getenv("MONGO_DB_NAME", "chat_app")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# JWT settings
|
| 31 |
+
SECRET_KEY: str = os.getenv("SECRET_KEY", "a_very_secret_key")
|
| 32 |
+
ALGORITHM: str = os.getenv("ALGORITHM", "HS256")
|
| 33 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 30))
|
| 34 |
+
|
| 35 |
+
# API settings
|
| 36 |
+
API_V1_STR: str = "/api/v1"
|
| 37 |
+
|
| 38 |
+
settings = Settings()
|
app/db/session.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/db/session.py
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
|
| 7 |
+
# --- PostgreSQL (SQLAlchemy) Setup ---
|
| 8 |
+
engine = create_engine(
|
| 9 |
+
settings.DATABASE_URL,
|
| 10 |
+
pool_pre_ping=True,
|
| 11 |
+
pool_recycle=1800
|
| 12 |
+
)
|
| 13 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 14 |
+
|
| 15 |
+
def get_db():
|
| 16 |
+
"""
|
| 17 |
+
Dependency to get a DB session.
|
| 18 |
+
Ensures the session is always closed after the request.
|
| 19 |
+
"""
|
| 20 |
+
db = SessionLocal()
|
| 21 |
+
try:
|
| 22 |
+
yield db
|
| 23 |
+
finally:
|
| 24 |
+
db.close()
|
| 25 |
+
|
| 26 |
+
# --- MongoDB (Motor) Setup ---
|
| 27 |
+
class DataBase:
|
| 28 |
+
client: AsyncIOMotorClient = None
|
| 29 |
+
|
| 30 |
+
db = DataBase()
|
| 31 |
+
|
| 32 |
+
async def get_mongo_db():
|
| 33 |
+
"""
|
| 34 |
+
Dependency to get the MongoDB database instance.
|
| 35 |
+
"""
|
| 36 |
+
return db.client[settings.MONGO_DB_NAME]
|
| 37 |
+
|
| 38 |
+
async def connect_to_mongo():
|
| 39 |
+
"""
|
| 40 |
+
Connect to the MongoDB instance.
|
| 41 |
+
"""
|
| 42 |
+
print("Connecting to MongoDB...")
|
| 43 |
+
db.client = AsyncIOMotorClient(settings.MONGO_DATABASE_URL)
|
| 44 |
+
print("Successfully connected to MongoDB.")
|
| 45 |
+
|
| 46 |
+
async def close_mongo_connection():
|
| 47 |
+
"""
|
| 48 |
+
Close the MongoDB connection.
|
| 49 |
+
"""
|
| 50 |
+
print("Closing MongoDB connection...")
|
| 51 |
+
db.client.close()
|
| 52 |
+
print("MongoDB connection closed.")
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/models/__init__.py
|
| 2 |
+
|
| 3 |
+
# This makes it easier to import models from other parts of the application.
|
| 4 |
+
# For example: from app.models import User, Admin
|
| 5 |
+
|
| 6 |
+
from .base import Base
|
| 7 |
+
from .super_admin import SuperAdmin
|
| 8 |
+
from .admin import Admin
|
| 9 |
+
from .user import User
|
| 10 |
+
from .group import Group
|
| 11 |
+
from .group_member import GroupMember
|
app/models/admin.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from .base import Base
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
class Admin(Base):
|
| 7 |
+
__tablename__ = 'admins'
|
| 8 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 9 |
+
username = Column(String(50), unique=True, nullable=False)
|
| 10 |
+
password_hash = Column(Text, nullable=False)
|
| 11 |
+
admin_key = Column(Text, unique=True, nullable=False)
|
| 12 |
+
is_active = Column(Boolean, default=True)
|
| 13 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 14 |
+
|
| 15 |
+
users = relationship("User", back_populates="owner_admin", cascade="all, delete-orphan")
|
| 16 |
+
groups = relationship("Group", back_populates="owner_admin", cascade="all, delete-orphan")
|
app/models/base.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import declarative_base
|
| 2 |
+
|
| 3 |
+
# Create a Base class for all ORM models to inherit from.
|
| 4 |
+
# This allows SQLAlchemy to discover all the models.
|
| 5 |
+
Base = declarative_base()
|
app/models/group.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Boolean, Column, Integer, Text, ForeignKey, DateTime, UniqueConstraint
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from .base import Base
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
class Group(Base):
|
| 7 |
+
__tablename__ = 'groups'
|
| 8 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 9 |
+
name = Column(Text, nullable=False)
|
| 10 |
+
admin_id = Column(Integer, ForeignKey('admins.id', ondelete="CASCADE"), nullable=False)
|
| 11 |
+
is_active = Column(Boolean, default=True)
|
| 12 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 13 |
+
|
| 14 |
+
owner_admin = relationship("Admin", back_populates="groups")
|
| 15 |
+
members = relationship("GroupMember", back_populates="group", cascade="all, delete-orphan")
|
| 16 |
+
|
| 17 |
+
__table_args__ = (UniqueConstraint('name', 'admin_id', name='_group_name_admin_uc'),)
|
app/models/group_member.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Boolean, Column, Integer, ForeignKey, DateTime
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from .base import Base
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
class GroupMember(Base):
|
| 7 |
+
__tablename__ = 'group_members'
|
| 8 |
+
group_id = Column(Integer, ForeignKey('groups.id', ondelete="CASCADE"), primary_key=True)
|
| 9 |
+
user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), primary_key=True)
|
| 10 |
+
is_admin = Column(Boolean, default=False)
|
| 11 |
+
joined_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 12 |
+
|
| 13 |
+
group = relationship("Group", back_populates="members")
|
| 14 |
+
user = relationship("User", back_populates="groups")
|
app/models/super_admin.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime
|
| 2 |
+
from .base import Base
|
| 3 |
+
import datetime
|
| 4 |
+
|
| 5 |
+
class SuperAdmin(Base):
|
| 6 |
+
__tablename__ = 'super_admin'
|
| 7 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 8 |
+
username = Column(String(50), unique=True, nullable=False)
|
| 9 |
+
password_hash = Column(Text, nullable=False)
|
| 10 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
app/models/user.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Boolean, Column, Integer, Text, ForeignKey, DateTime
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from .base import Base
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
class User(Base):
|
| 7 |
+
__tablename__ = 'users'
|
| 8 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 9 |
+
username = Column(Text, unique=True, nullable=False)
|
| 10 |
+
password_hash = Column(Text, nullable=False)
|
| 11 |
+
admin_id = Column(Integer, ForeignKey('admins.id', ondelete="CASCADE"), nullable=False)
|
| 12 |
+
is_active = Column(Boolean, default=True)
|
| 13 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 14 |
+
last_seen = Column(DateTime, default=datetime.datetime.utcnow)
|
| 15 |
+
|
| 16 |
+
owner_admin = relationship("Admin", back_populates="users")
|
| 17 |
+
groups = relationship("GroupMember", back_populates="user", cascade="all, delete-orphan")
|
app/schemas/admin.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List
|
| 3 |
+
import datetime
|
| 4 |
+
|
| 5 |
+
class AdminUserCreate(BaseModel):
|
| 6 |
+
username: str
|
| 7 |
+
password: str
|
| 8 |
+
|
| 9 |
+
class AdminUserUpdate(BaseModel):
|
| 10 |
+
is_active: bool
|
| 11 |
+
|
| 12 |
+
class OnlineUser(BaseModel):
|
| 13 |
+
id: int
|
| 14 |
+
username: str
|
| 15 |
+
role: str
|
| 16 |
+
|
| 17 |
+
class AllOnlineUsers(BaseModel):
|
| 18 |
+
users: List[OnlineUser]
|
| 19 |
+
|
| 20 |
+
class UserPairInfo(BaseModel):
|
| 21 |
+
id: int
|
| 22 |
+
username: str
|
| 23 |
+
|
| 24 |
+
class ConversationSummary(BaseModel):
|
| 25 |
+
user_one: UserPairInfo
|
| 26 |
+
user_two: UserPairInfo
|
| 27 |
+
last_message_timestamp: datetime.datetime
|
| 28 |
+
message_count: int
|
app/schemas/group.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
class GroupMemberInfo(BaseModel):
|
| 5 |
+
user_id: int
|
| 6 |
+
username: str
|
| 7 |
+
|
| 8 |
+
class GroupWithMembers(BaseModel):
|
| 9 |
+
id: int
|
| 10 |
+
name: str
|
| 11 |
+
admin_id: int
|
| 12 |
+
is_active: bool
|
| 13 |
+
members: List[GroupMemberInfo]
|
| 14 |
+
|
| 15 |
+
class Config:
|
| 16 |
+
orm_mode = True
|
| 17 |
+
|
| 18 |
+
class GroupCreateWithMembers(BaseModel):
|
| 19 |
+
name: str
|
| 20 |
+
members: Optional[List[int]] = []
|
| 21 |
+
|
| 22 |
+
class GroupCreate(BaseModel):
|
| 23 |
+
name: str
|
| 24 |
+
|
| 25 |
+
class GroupOut(BaseModel):
|
| 26 |
+
id: int
|
| 27 |
+
name: str
|
| 28 |
+
admin_id: int
|
| 29 |
+
|
| 30 |
+
class Config:
|
| 31 |
+
orm_mode = True
|
app/schemas/message.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/schemas/message.py
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
# --- Nested objects for the main Message schema ---
|
| 7 |
+
class MessageSender(BaseModel):
|
| 8 |
+
id: int
|
| 9 |
+
username: str
|
| 10 |
+
role: str
|
| 11 |
+
|
| 12 |
+
class MessageReceiver(BaseModel):
|
| 13 |
+
id: int
|
| 14 |
+
username: str
|
| 15 |
+
role: str
|
| 16 |
+
|
| 17 |
+
class MessageGroup(BaseModel):
|
| 18 |
+
id: int
|
| 19 |
+
name: str
|
| 20 |
+
|
| 21 |
+
class MessageContent(BaseModel):
|
| 22 |
+
text: Optional[str] = None
|
| 23 |
+
image: Optional[str] = None
|
| 24 |
+
file: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
# --- Main Message Schema for API output ---
|
| 27 |
+
class MessageOut(BaseModel):
|
| 28 |
+
id: str = Field(..., alias="_id") # Use MongoDB's _id
|
| 29 |
+
type: str
|
| 30 |
+
sender: MessageSender
|
| 31 |
+
receiver: Optional[MessageReceiver] = None
|
| 32 |
+
group: Optional[MessageGroup] = None
|
| 33 |
+
content: MessageContent
|
| 34 |
+
timestamp: datetime.datetime
|
| 35 |
+
status: str
|
| 36 |
+
|
| 37 |
+
class Config:
|
| 38 |
+
orm_mode = True
|
| 39 |
+
allow_population_by_field_name = True
|
| 40 |
+
json_encoders = {
|
| 41 |
+
datetime.datetime: lambda dt: dt.isoformat(),
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# --- Schema for the new message history endpoint response ---
|
| 45 |
+
class MessageHistory(BaseModel):
|
| 46 |
+
messages: list[MessageOut]
|
| 47 |
+
next_cursor: Optional[str] = None
|
app/schemas/token.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
class Token(BaseModel):
|
| 5 |
+
access_token: str
|
| 6 |
+
token_type: str
|
| 7 |
+
|
| 8 |
+
class TokenData(BaseModel):
|
| 9 |
+
username: Optional[str] = None
|
| 10 |
+
role: Optional[str] = None
|
| 11 |
+
tenant_id: Optional[int] = None
|
| 12 |
+
|
| 13 |
+
class TokenRequest(BaseModel):
|
| 14 |
+
username: str
|
| 15 |
+
password: str
|
app/schemas/user.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/schemas/user.py
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from typing import List, Union
|
| 4 |
+
import datetime
|
| 5 |
+
from .group import GroupOut
|
| 6 |
+
|
| 7 |
+
class UserCreate(BaseModel):
|
| 8 |
+
username: str
|
| 9 |
+
password: str
|
| 10 |
+
|
| 11 |
+
class MeProfileOut(BaseModel):
|
| 12 |
+
id: int
|
| 13 |
+
username: str
|
| 14 |
+
type: str
|
| 15 |
+
created_by: str
|
| 16 |
+
created_at: datetime.datetime
|
| 17 |
+
|
| 18 |
+
class UserOut(BaseModel):
|
| 19 |
+
id: int
|
| 20 |
+
username: str
|
| 21 |
+
type: str = "User"
|
| 22 |
+
|
| 23 |
+
class Config:
|
| 24 |
+
orm_mode = True
|
| 25 |
+
|
| 26 |
+
class AdminOut(BaseModel):
|
| 27 |
+
id: int
|
| 28 |
+
username: str
|
| 29 |
+
type: str = "Admin"
|
| 30 |
+
admin_key: str
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
orm_mode = True
|
| 34 |
+
|
| 35 |
+
# A flexible response model for the /me endpoint
|
| 36 |
+
MeOut = Union[UserOut, AdminOut]
|
| 37 |
+
|
| 38 |
+
# A schema for the search result
|
| 39 |
+
class SearchResult(BaseModel):
|
| 40 |
+
users: List[UserOut]
|
| 41 |
+
admins: List[AdminOut]
|
| 42 |
+
groups: List[GroupOut]
|
| 43 |
+
|
| 44 |
+
# Schemas for the conversation list
|
| 45 |
+
class ConversationPartner(BaseModel):
|
| 46 |
+
id: int
|
| 47 |
+
name: str
|
| 48 |
+
type: str # "user", "group", or "admin"
|
| 49 |
+
last_message: str
|
| 50 |
+
timestamp: datetime.datetime
|
| 51 |
+
|
| 52 |
+
class ConversationList(BaseModel):
|
| 53 |
+
conversations: List[ConversationPartner]
|
| 54 |
+
|
| 55 |
+
class UserLoginSchema(BaseModel):
|
| 56 |
+
username: str
|
| 57 |
+
password: str
|
| 58 |
+
|
| 59 |
+
class UserPasswordReset(BaseModel):
|
| 60 |
+
new_password: str
|
app/security/dependencies.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import Union
|
| 4 |
+
|
| 5 |
+
from app.db.session import get_db
|
| 6 |
+
from app.security.jwt import verify_token
|
| 7 |
+
from app.models import User, Admin, SuperAdmin
|
| 8 |
+
|
| 9 |
+
def get_current_user_from_cookie(request: Request, db: Session = Depends(get_db)) -> Union[User, Admin, SuperAdmin]:
|
| 10 |
+
"""
|
| 11 |
+
New primary dependency to get the current user from the access_token cookie.
|
| 12 |
+
"""
|
| 13 |
+
credentials_exception = HTTPException(
|
| 14 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 15 |
+
detail="Could not validate credentials",
|
| 16 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
token = request.cookies.get("access_token")
|
| 20 |
+
if token is None:
|
| 21 |
+
raise credentials_exception
|
| 22 |
+
|
| 23 |
+
token_data = verify_token(token, credentials_exception)
|
| 24 |
+
|
| 25 |
+
# Based on the role in the token, fetch from the correct table
|
| 26 |
+
role = token_data.role
|
| 27 |
+
username = token_data.username
|
| 28 |
+
|
| 29 |
+
user = None
|
| 30 |
+
if role == "user":
|
| 31 |
+
user = db.query(User).filter(User.username == username).first()
|
| 32 |
+
elif role == "admin":
|
| 33 |
+
user = db.query(Admin).filter(Admin.username == username).first()
|
| 34 |
+
elif role == "super_admin":
|
| 35 |
+
user = db.query(SuperAdmin).filter(SuperAdmin.username == username).first()
|
| 36 |
+
|
| 37 |
+
if user is None:
|
| 38 |
+
raise credentials_exception
|
| 39 |
+
|
| 40 |
+
return user
|
app/security/hashing.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from passlib.context import CryptContext
|
| 2 |
+
|
| 3 |
+
# Use bcrypt for password hashing
|
| 4 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 5 |
+
|
| 6 |
+
class Hasher:
|
| 7 |
+
@staticmethod
|
| 8 |
+
def verify_password(plain_password, hashed_password):
|
| 9 |
+
"""Verifies a plain password against a hashed one."""
|
| 10 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
def get_password_hash(password):
|
| 14 |
+
"""Hashes a plain password."""
|
| 15 |
+
return pwd_context.hash(password)
|
app/security/jwt.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
from app.schemas.token import TokenData
|
| 6 |
+
|
| 7 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = 15 # 15 minutes
|
| 8 |
+
REFRESH_TOKEN_EXPIRE_DAYS = 7 # 7 days
|
| 9 |
+
|
| 10 |
+
def create_access_token(data: dict):
|
| 11 |
+
"""Creates a short-lived access token."""
|
| 12 |
+
to_encode = data.copy()
|
| 13 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 14 |
+
to_encode.update({"exp": expire})
|
| 15 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 16 |
+
return encoded_jwt
|
| 17 |
+
|
| 18 |
+
def create_refresh_token(data: dict):
|
| 19 |
+
"""Creates a long-lived refresh token."""
|
| 20 |
+
to_encode = data.copy()
|
| 21 |
+
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
| 22 |
+
to_encode.update({"exp": expire})
|
| 23 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 24 |
+
return encoded_jwt
|
| 25 |
+
|
| 26 |
+
def verify_token(token: str, credentials_exception) -> TokenData:
|
| 27 |
+
"""Verifies any JWT token and returns its payload."""
|
| 28 |
+
try:
|
| 29 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 30 |
+
username: str = payload.get("sub")
|
| 31 |
+
role: str = payload.get("role")
|
| 32 |
+
tenant_id: int = payload.get("tenant_id")
|
| 33 |
+
|
| 34 |
+
if username is None or role is None:
|
| 35 |
+
raise credentials_exception
|
| 36 |
+
|
| 37 |
+
token_data = TokenData(username=username, role=role, tenant_id=tenant_id)
|
| 38 |
+
return token_data
|
| 39 |
+
except JWTError:
|
| 40 |
+
raise credentials_exception
|
app/websocket/connection_manager.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/websocket/connection_manager.py
|
| 2 |
+
from fastapi import WebSocket
|
| 3 |
+
from typing import Dict, List, Set
|
| 4 |
+
|
| 5 |
+
class ConnectionManager:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
# Maps user_id to their active WebSocket connection
|
| 8 |
+
self.active_connections: Dict[int, WebSocket] = {}
|
| 9 |
+
|
| 10 |
+
async def connect(self, user_id: int, websocket: WebSocket):
|
| 11 |
+
"""Accept a new WebSocket connection."""
|
| 12 |
+
await websocket.accept()
|
| 13 |
+
self.active_connections[user_id] = websocket
|
| 14 |
+
|
| 15 |
+
def disconnect(self, user_id: int):
|
| 16 |
+
"""Disconnect a WebSocket."""
|
| 17 |
+
if user_id in self.active_connections:
|
| 18 |
+
del self.active_connections[user_id]
|
| 19 |
+
|
| 20 |
+
async def send_personal_message(self, message: str, user_id: int):
|
| 21 |
+
"""Send a message to a specific user."""
|
| 22 |
+
if user_id in self.active_connections:
|
| 23 |
+
websocket = self.active_connections[user_id]
|
| 24 |
+
await websocket.send_text(message)
|
| 25 |
+
|
| 26 |
+
async def broadcast_to_users(self, message: str, user_ids: List[int]):
|
| 27 |
+
"""Send a message to a list of specific users."""
|
| 28 |
+
for user_id in user_ids:
|
| 29 |
+
if user_id in self.active_connections:
|
| 30 |
+
await self.send_personal_message(message, user_id)
|
| 31 |
+
|
| 32 |
+
def get_all_connection_ids(self) -> Set[str]:
|
| 33 |
+
"""Returns a set of all active connection IDs."""
|
| 34 |
+
return set(self.active_connections.keys())
|
| 35 |
+
|
| 36 |
+
manager = ConnectionManager()
|
main.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# main.py
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
from app.api.v1.api_router import api_router
|
| 6 |
+
from app.db.session import close_mongo_connection, connect_to_mongo
|
| 7 |
+
|
| 8 |
+
app = FastAPI(
|
| 9 |
+
title="Multi-Tenant Chat API",
|
| 10 |
+
openapi_url=f"{settings.API_V1_STR}/openapi.json"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
origins = [
|
| 14 |
+
"http://localhost:3000"
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=origins,
|
| 20 |
+
allow_credentials=True,
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
@app.on_event("startup")
|
| 26 |
+
async def startup_event():
|
| 27 |
+
"""
|
| 28 |
+
Connect to MongoDB on startup.
|
| 29 |
+
"""
|
| 30 |
+
await connect_to_mongo()
|
| 31 |
+
|
| 32 |
+
@app.on_event("shutdown")
|
| 33 |
+
async def shutdown_event():
|
| 34 |
+
"""
|
| 35 |
+
Close MongoDB connection on shutdown.
|
| 36 |
+
"""
|
| 37 |
+
await close_mongo_connection()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# Include the API router
|
| 41 |
+
app.include_router(api_router, prefix=settings.API_V1_STR)
|
| 42 |
+
|
| 43 |
+
@app.get("/")
|
| 44 |
+
def read_root():
|
| 45 |
+
"""
|
| 46 |
+
Root endpoint for basic health check.
|
| 47 |
+
"""
|
| 48 |
+
return {"message": "Welcome to the Multi-Tenant Chat API"}
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
psycopg2-binary
|
| 4 |
+
SQLAlchemy
|
| 5 |
+
motor
|
| 6 |
+
pydantic[email]
|
| 7 |
+
python-dotenv
|
| 8 |
+
passlib[bcrypt]
|
| 9 |
+
python-jose[cryptography]
|
| 10 |
+
python-multipart
|
| 11 |
+
pytz
|