From 67d27be9532631a29de326388a47a1ffdbb2ddc6 Mon Sep 17 00:00:00 2001 From: sinasezza Date: Mon, 29 Jul 2024 16:05:01 +0330 Subject: [PATCH] feat(chat): private and public rooms creation and join apis added to project. --- chatApp/config/auth.py | 4 +- chatApp/config/config.py | 4 +- chatApp/config/database.py | 28 +++- chatApp/main.py | 2 +- chatApp/middlewares/request_limit.py | 17 ++- chatApp/models/message.py | 6 +- chatApp/models/{room.py => private_room.py} | 11 +- chatApp/models/public_room.py | 43 ++++++ chatApp/models/user.py | 8 +- chatApp/routes/auth.py | 5 +- chatApp/routes/chat.py | 160 +++++++++++++++++++- chatApp/schemas/private_room.py | 8 + chatApp/schemas/public_room.py | 22 +++ chatApp/sockets.py | 4 +- chatApp/utils/object_id.py | 8 +- pyproject.toml | 5 +- tests/integration/test_chat.py | 2 +- 17 files changed, 301 insertions(+), 36 deletions(-) rename chatApp/models/{room.py => private_room.py} (55%) create mode 100644 chatApp/models/public_room.py create mode 100644 chatApp/schemas/private_room.py create mode 100644 chatApp/schemas/public_room.py diff --git a/chatApp/config/auth.py b/chatApp/config/auth.py index b658cfc..ccd6440 100644 --- a/chatApp/config/auth.py +++ b/chatApp/config/auth.py @@ -63,7 +63,9 @@ def create_access_token( if expires_delta: expire = datetime.now(UTC) + expires_delta else: - expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(UTC) + timedelta( + minutes=ACCESS_TOKEN_EXPIRE_MINUTES + ) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) diff --git a/chatApp/config/config.py b/chatApp/config/config.py index 143200c..069c30b 100644 --- a/chatApp/config/config.py +++ b/chatApp/config/config.py @@ -42,7 +42,9 @@ class Settings(BaseSettings): upload_dir: Path = Field(default=BASE_DIR / "uploads") max_upload_size: int = Field(default=(5 * 1024 * 1024)) - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8" + ) def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/chatApp/config/database.py b/chatApp/config/database.py index 27a54c7..c6b5e02 100644 --- a/chatApp/config/database.py +++ b/chatApp/config/database.py @@ -35,7 +35,12 @@ async def connect_to_mongodb(self) -> None: # Initialize collections self.users_collection = self.db.get_collection("users") self.messages_collection = self.db.get_collection("messages") - self.rooms_collection = self.db.get_collection("rooms") + self.public_rooms_collection = self.db.get_collection( + "public_rooms" + ) + self.private_rooms_collection = self.db.get_collection( + "private_rooms" + ) # Ping the server to validate the connection await self.db_client.admin.command("ismaster") @@ -80,14 +85,27 @@ def get_messages_collection() -> AsyncIOMotorCollection: return messages_collection -def get_rooms_collection() -> AsyncIOMotorCollection: +def get_public_rooms_collection() -> AsyncIOMotorCollection: + """ + Retrieve the public rooms collection from the MongoDB database. + + :return: The rooms collection instance. + :raises RuntimeError: If the rooms collection is not initialized. + """ + rooms_collection = mongo_db.public_rooms_collection + if rooms_collection is None: + raise RuntimeError("public rooms collection is not initialized.") + return rooms_collection + + +def get_private_rooms_collection() -> AsyncIOMotorCollection: """ - Retrieve the rooms collection from the MongoDB database. + Retrieve the private rooms collection from the MongoDB database. :return: The rooms collection instance. :raises RuntimeError: If the rooms collection is not initialized. """ - rooms_collection = mongo_db.rooms_collection + rooms_collection = mongo_db.private_rooms_collection if rooms_collection is None: - raise RuntimeError("rooms collection is not initialized.") + raise RuntimeError("private rooms collection is not initialized.") return rooms_collection diff --git a/chatApp/main.py b/chatApp/main.py index c0d5cd2..f116898 100644 --- a/chatApp/main.py +++ b/chatApp/main.py @@ -59,7 +59,7 @@ async def root() -> dict[str, str]: # Mount socket.io app -app.mount("/", app=sio_app) +app.mount("/socket.io/", app=sio_app) if __name__ == "__main__": diff --git a/chatApp/middlewares/request_limit.py b/chatApp/middlewares/request_limit.py index aedda19..3f8fbdc 100644 --- a/chatApp/middlewares/request_limit.py +++ b/chatApp/middlewares/request_limit.py @@ -2,14 +2,19 @@ from collections import defaultdict from fastapi import Request, Response -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.middleware.base import ( + BaseHTTPMiddleware, + RequestResponseEndpoint, +) from starlette.types import ASGIApp from chatApp.config.logs import logger # Import your custom logger class RequestLimitMiddleware(BaseHTTPMiddleware): - def __init__(self, app: ASGIApp, max_requests: int = 4, window_seconds: int = 1): + def __init__( + self, app: ASGIApp, max_requests: int = 4, window_seconds: int = 1 + ): super().__init__(app) self.max_requests = max_requests self.window_seconds = window_seconds @@ -41,7 +46,9 @@ async def dispatch( # If the count exceeds the limit, return a 429 Too Many Requests response if count > self.max_requests: - logger.warning(f"Too many requests from {client_ip} - Count: {count}") + logger.warning( + f"Too many requests from {client_ip} - Count: {count}" + ) return Response("Too many requests", status_code=429) # Measure start time of request processing @@ -54,7 +61,9 @@ async def dispatch( process_time = time.time() - start_time # Log the request processing time - logger.info(f"Processed request from {client_ip} in {process_time:.4f} seconds") + logger.info( + f"Processed request from {client_ip} in {process_time:.4f} seconds" + ) # Add X-Process-Time header to the response response.headers["X-Process-Time"] = str(process_time) diff --git a/chatApp/models/message.py b/chatApp/models/message.py index f61729e..88f7acc 100644 --- a/chatApp/models/message.py +++ b/chatApp/models/message.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime from pydantic import BaseModel, Field @@ -10,8 +10,8 @@ class Message(BaseModel): room_id: str | None = Field(default=None) content: str = Field(default=None) media: str = Field(default=None) - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=lambda: datetime.now()) class MessageInDB(Message): - id: PydanticObjectId = Field(alias="_id") + id: PydanticObjectId = Field(alias="_id", serialization_alias="id") diff --git a/chatApp/models/room.py b/chatApp/models/private_room.py similarity index 55% rename from chatApp/models/room.py rename to chatApp/models/private_room.py index 26a56da..d4d03cc 100644 --- a/chatApp/models/room.py +++ b/chatApp/models/private_room.py @@ -1,14 +1,15 @@ -from datetime import datetime, timezone +from datetime import datetime from pydantic import BaseModel, Field from chatApp.utils.object_id import PydanticObjectId -class Room(BaseModel): - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - members: list[PydanticObjectId] +class PrivateRoom(BaseModel): + member1: PydanticObjectId + member2: PydanticObjectId + created_at: datetime = Field(default_factory=lambda: datetime.now()) -class RoomInDB(Room): +class PrivateRoomInDB(PrivateRoom): id: PydanticObjectId = Field(alias="_id") diff --git a/chatApp/models/public_room.py b/chatApp/models/public_room.py new file mode 100644 index 0000000..34cdd22 --- /dev/null +++ b/chatApp/models/public_room.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from chatApp.utils.object_id import PydanticObjectId + + +class PublicRoom(BaseModel): + owner: PydanticObjectId + name: str + description: str | None = Field( + default=None, description="Description of the room" + ) + max_members: int | None = Field( + default=None, description="Maximum number of members allowed" + ) + welcome_message: str | None = Field( + default=None, description="Welcome message for the room" + ) + rules: str | None = Field(default=None, description="Rules for the room") + allow_file_sharing: bool = Field( + default=True, description="Allow file sharing in the room" + ) + members: list[PydanticObjectId] = Field( + default_factory=list, description="List of user IDs" + ) + ban_list: list[PydanticObjectId] = Field( + default_factory=list, description="List of IDs to be banned" + ) + moderators: list[PydanticObjectId] = Field( + default_factory=list, description="List of moderator IDs" + ) + allow_users_access_message_history: bool = Field( + True, description="Allow user to access message history" + ) + max_latest_messages_access: int | None = Field( + default=None, description="Maximum number of latest messages to access" + ) + created_at: datetime = Field(default_factory=lambda: datetime.now()) + + +class PublicRoomInDB(PublicRoom): + id: PydanticObjectId = Field(alias="_id", serialization_alias="id") diff --git a/chatApp/models/user.py b/chatApp/models/user.py index 2ad8b82..01cda67 100644 --- a/chatApp/models/user.py +++ b/chatApp/models/user.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime from pydantic import BaseModel, Field @@ -11,10 +11,10 @@ class User(BaseModel): hashed_password: str is_active: bool = True is_admin: bool = False - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=lambda: datetime.now()) + updated_at: datetime = Field(default_factory=lambda: datetime.now()) last_login: datetime | None = None class UserInDB(User): - id: PydanticObjectId = Field(alias="_id") + id: PydanticObjectId = Field(alias="_id", serialization_alias="id") diff --git a/chatApp/routes/auth.py b/chatApp/routes/auth.py index f703c35..b9dde51 100644 --- a/chatApp/routes/auth.py +++ b/chatApp/routes/auth.py @@ -1,6 +1,5 @@ -# auth.py from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from fastapi import APIRouter, Depends, HTTPException, status @@ -35,6 +34,8 @@ async def register_user(user: UserCreateSchema) -> UserInDB: user_dict = user.model_dump(exclude={"password"}) user_dict["hashed_password"] = hashed_password + user_dict["created_at"] = datetime.now() + # Insert user into the database await users_collection.insert_one(user_dict) diff --git a/chatApp/routes/chat.py b/chatApp/routes/chat.py index e5d07ec..44cc164 100644 --- a/chatApp/routes/chat.py +++ b/chatApp/routes/chat.py @@ -1,15 +1,25 @@ from collections.abc import Mapping +from datetime import datetime from typing import Any from bson import ObjectId -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Path from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorCursor from chatApp.config import auth -from chatApp.config.database import get_messages_collection +from chatApp.config.database import ( + get_messages_collection, + get_private_rooms_collection, + get_public_rooms_collection, +) from chatApp.models.message import MessageInDB +from chatApp.models.private_room import PrivateRoom, PrivateRoomInDB +from chatApp.models.public_room import PublicRoom, PublicRoomInDB from chatApp.models.user import UserInDB from chatApp.schemas.message import MessageCreateSchema +from chatApp.schemas.private_room import CreatePrivateRoom +from chatApp.schemas.public_room import CreatePublicRoom +from chatApp.utils.object_id import PydanticObjectId, is_valid_object_id router = APIRouter() @@ -43,7 +53,9 @@ async def get_messages(user: UserInDB = Depends(auth.get_current_user)): @router.get("/message/{message_id}", response_model=MessageInDB) -async def get_message(message_id: str, user: UserInDB = Depends(auth.get_current_user)): +async def get_message( + message_id: str, user: UserInDB = Depends(auth.get_current_user) +): messages_collection: AsyncIOMotorCollection = get_messages_collection() message = await messages_collection.find_one( @@ -58,13 +70,149 @@ async def get_message(message_id: str, user: UserInDB = Depends(auth.get_current @router.post("/message", response_model=MessageInDB) async def create_message( - message: MessageCreateSchema, user: UserInDB = Depends(auth.get_current_user) + message: MessageCreateSchema, + user: UserInDB = Depends(auth.get_current_user), ): messages_collection = get_messages_collection() message_dict = message.model_dump() message_dict["user_id"] = user.id - result = await messages_collection.insert_one(message_dict) + await messages_collection.insert_one(message_dict) + + return MessageInDB(**message_dict) + + +@router.post("/create-public-room", response_model=PublicRoomInDB) +async def create_public_room( + room_info: CreatePublicRoom, + user: UserInDB = Depends(auth.get_current_user), +): + rooms_collection: AsyncIOMotorCollection = get_public_rooms_collection() + + # Convert room_info to dictionary and add the owner + room_dict: dict[str, Any] = room_info.model_dump() + room_dict["owner"] = user.id + room_dict["members"] = [user.id] + + room = PublicRoom(**room_dict) + + # Insert the room into the database + result = await rooms_collection.insert_one(room.model_dump(by_alias=True)) + + return PublicRoomInDB( + **room.model_dump(by_alias=True), _id=result.inserted_id + ) + + +@router.get("/join-public-room/{room_id}") +async def join_public_room( + room_id: str = Path(..., description="ID of the public room to join"), + user: UserInDB = Depends(auth.get_current_user), +): + rooms_collection: AsyncIOMotorCollection = get_public_rooms_collection() + + if not is_valid_object_id(room_id): + raise HTTPException(status_code=400, detail="Invalid room ID format") + + # Check if the room exists + room = await rooms_collection.find_one({"_id": PydanticObjectId(room_id)}) + if room is None: + raise HTTPException(status_code=404, detail="Room not found") + + if user.id not in room["ban_list"]: + if user.id not in room["members"]: + room["members"].append(user.id) + + # Update the room in the database + await rooms_collection.update_one( + {"_id": room["_id"]}, {"$set": room} + ) + else: + raise HTTPException( + status_code=403, detail="You are banned from this room" + ) + + return PublicRoomInDB(**room) + + +@router.post( + "/create-private-room/{person_id}", response_model=PrivateRoomInDB +) +async def create_private_room( + person_id: str = Path(..., description="other person's id"), + user: UserInDB = Depends(auth.get_current_user), +): + rooms_collection: AsyncIOMotorCollection = get_private_rooms_collection() + + if not is_valid_object_id(person_id): + raise HTTPException(status_code=400, detail="Invalid person ID format") + + # Create a PrivateRoomInDB instance with current time + room_schema = CreatePrivateRoom( + member1=user.id, member2=PydanticObjectId(person_id) + ) + room_dict = room_schema.model_dump() + room_dict["created_at"] = datetime.now() + + # Ensure member1 and member2 are not the same + if room_dict["member1"] == room_dict["member2"]: + raise HTTPException( + status_code=400, detail="Members must be different" + ) + + recently_created = await rooms_collection.find_one( + { + "$or": [ + { + "member1": room_dict["member1"], + "member2": room_dict["member2"], + }, + { + "member1": room_dict["member2"], + "member2": room_dict["member1"], + }, + ] + } + ) + if recently_created is not None: + raise HTTPException( + status_code=400, + detail="A private room already exists between you and this user", + ) + + # Create the PrivateRoom instance + room = PrivateRoom(**room_dict) + + # Insert the room into the database + result = await rooms_collection.insert_one(room.model_dump(by_alias=True)) + + return PrivateRoomInDB( + **room.model_dump(by_alias=True), _id=result.inserted_id + ) + + +@router.get("/private-room/{room_id}", response_model=PrivateRoomInDB) +async def get_private_room( + room_id: str = Path(..., description="ID of the private room to retrieve"), + user: UserInDB = Depends(auth.get_current_user), +): + rooms_collection: AsyncIOMotorCollection = get_private_rooms_collection() + + if not is_valid_object_id(room_id): + raise HTTPException(status_code=400, detail="Invalid room ID format") + + # Fetch the room from the database + room_data = await rooms_collection.find_one( + {"_id": PydanticObjectId(room_id)} + ) + + if room_data is None: + raise HTTPException(status_code=404, detail="Private room not found") + + if user.id not in [room_data.get("member1"), room_data.get("member2")]: + raise HTTPException( + status_code=403, detail="You are not a member of this room" + ) - return MessageInDB(**message_dict, _id=result.inserted_id) + return PrivateRoomInDB(**room_data) diff --git a/chatApp/schemas/private_room.py b/chatApp/schemas/private_room.py new file mode 100644 index 0000000..90127b1 --- /dev/null +++ b/chatApp/schemas/private_room.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + +from chatApp.utils.object_id import PydanticObjectId + + +class CreatePrivateRoom(BaseModel): + member1: PydanticObjectId | None = None + member2: PydanticObjectId | None = None diff --git a/chatApp/schemas/public_room.py b/chatApp/schemas/public_room.py new file mode 100644 index 0000000..13682ae --- /dev/null +++ b/chatApp/schemas/public_room.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, Field + + +class CreatePublicRoom(BaseModel): + name: str = Field(..., description="Name of the public room") + description: str = Field(..., description="Description of the public room") + max_members: int = Field( + 10, description="Maximum number of members allowed" + ) + welcome_message: str | None = Field( + None, description="Welcome message for the room" + ) + rules: str | None = Field(None, description="Rules for the room") + allow_file_sharing: bool = Field( + True, description="Allow file sharing in the room" + ) + allow_users_access_message_history: bool = Field( + True, description="Allow users to access message history" + ) + max_latest_messages_access: int | None = Field( + None, description="Maximum number of latest messages to access" + ) diff --git a/chatApp/sockets.py b/chatApp/sockets.py index 08b8a3e..4388915 100644 --- a/chatApp/sockets.py +++ b/chatApp/sockets.py @@ -12,7 +12,9 @@ # Create the ASGI app using the defined server sio_app = socketio.ASGIApp( - socketio_server=sio_server, socketio_path="/", other_asgi_app="main:app" + socketio_server=sio_server, + socketio_path="/socket.io/", + other_asgi_app="main:app", ) diff --git a/chatApp/utils/object_id.py b/chatApp/utils/object_id.py index 2fbed18..69a2037 100644 --- a/chatApp/utils/object_id.py +++ b/chatApp/utils/object_id.py @@ -21,10 +21,16 @@ def validate_from_str(input_value: str) -> ObjectId: [ # check if it's an instance first before doing any further work core_schema.is_instance_schema(ObjectId), - core_schema.no_info_plain_validator_function(validate_from_str), + core_schema.no_info_plain_validator_function( + validate_from_str + ), ], serialization=core_schema.to_string_ser_schema(), ) PydanticObjectId = Annotated[ObjectId, _ObjectIdPydanticAnnotation] + + +def is_valid_object_id(id_str: str) -> bool: + return ObjectId.is_valid(id_str) diff --git a/pyproject.toml b/pyproject.toml index ba22bbb..acc96d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,13 @@ readme = "README.md" package-mode = false [tool.isort] -line_length = 92 +line_length = 79 profile = "black" remove_redundant_aliases = true +[tool.ruff] +line-length = 79 + [tool.poetry.dependencies] python = "^3.10" fastapi = {extras = ["all"], version = "^0.111.1"} diff --git a/tests/integration/test_chat.py b/tests/integration/test_chat.py index a6f083f..6320b2e 100644 --- a/tests/integration/test_chat.py +++ b/tests/integration/test_chat.py @@ -32,7 +32,7 @@ async def connect_error(data): try: # Connect to the server await sio.connect( - "ws://127.0.0.1:8000/", wait_timeout=10 + "ws://127.0.0.1:8000/socket.io/", wait_timeout=10 ) # Ensure correct path # Wait for the connect event to be triggered