From 9141aebc64e809a695c5cd771a0a32f9d6553d7e Mon Sep 17 00:00:00 2001 From: sinasezza Date: Sun, 28 Jul 2024 19:40:47 +0330 Subject: [PATCH] feat(models, routes): models changed and some routes for send message added. --- chatApp/config/auth.py | 40 +++++++--------------- chatApp/config/database.py | 50 +++++++++++++++++++++++---- chatApp/models/message.py | 21 ++++++++---- chatApp/models/room.py | 14 ++++++++ chatApp/models/user.py | 19 +++++++---- chatApp/routes/auth.py | 17 ++++++---- chatApp/routes/chat.py | 69 +++++++++++++++++++++++++++++++++++++- chatApp/routes/user.py | 23 +++++++------ chatApp/schemas/message.py | 12 ++----- chatApp/schemas/user.py | 12 ------- chatApp/utils/object_id.py | 30 +++++++++++++++++ mypy.ini | 1 + poetry.lock | 2 +- pyproject.toml | 3 +- 14 files changed, 225 insertions(+), 88 deletions(-) create mode 100644 chatApp/models/room.py create mode 100644 chatApp/utils/object_id.py diff --git a/chatApp/config/auth.py b/chatApp/config/auth.py index f84c1e1..b658cfc 100644 --- a/chatApp/config/auth.py +++ b/chatApp/config/auth.py @@ -1,7 +1,6 @@ -# auth.py from collections.abc import Mapping from datetime import UTC, datetime, timedelta -from typing import Any, Optional +from typing import Any from fastapi import Depends from fastapi.security import OAuth2PasswordBearer @@ -10,9 +9,9 @@ from passlib.context import CryptContext from chatApp.config.config import get_settings -from chatApp.config.database import mongo_db +from chatApp.config.database import get_users_collection from chatApp.config.logs import logger -from chatApp.models.user import User +from chatApp.models.user import UserInDB from chatApp.utils.exceptions import credentials_exception settings = get_settings() @@ -51,7 +50,7 @@ def get_password_hash(password: str) -> str: def create_access_token( - data: dict[str, Any], expires_delta: Optional[timedelta] = None + data: dict[str, Any], expires_delta: timedelta | None = None ) -> str: """ Create a JWT access token with a specified expiration. @@ -87,20 +86,7 @@ def parse_access_token(token: str) -> dict[str, Any]: raise credentials_exception -def get_users_collection() -> AsyncIOMotorCollection: - """ - Retrieve the users collection from the MongoDB database. - - :return: The users collection instance. - :raises RuntimeError: If the users collection is not initialized. - """ - users_collection = mongo_db.users_collection - if users_collection is None: - raise RuntimeError("Users collection is not initialized.") - return users_collection - - -async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: +async def get_current_user(token: str = Depends(oauth2_scheme)) -> UserInDB: """ Retrieve the current user from the database using the provided JWT token. @@ -110,17 +96,17 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: """ # Parse the token to get the payload payload = parse_access_token(token) - username: Optional[str] = payload.get("sub") + username: str | None = payload.get("sub") if username is None: logger.error("Username is missing in the token payload.") raise credentials_exception # Fetch the users_collection within the request scope - users_collection = get_users_collection() + users_collection: AsyncIOMotorCollection = get_users_collection() # Properly type the result of the find_one query - user: Optional[Mapping[str, Any]] = await users_collection.find_one( + user: Mapping[str, Any] | None = await users_collection.find_one( {"username": username} ) @@ -130,15 +116,15 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: raise credentials_exception # Construct and return a User instance from the found document - return User(**user) + return UserInDB(**user) -async def authenticate_user(username: str, password: str) -> Optional[User]: +async def authenticate_user(username: str, password: str) -> UserInDB | None: # Fetch the users_collection within the request scope - users_collection = get_users_collection() + users_collection: AsyncIOMotorCollection = get_users_collection() # Properly type the result of the find_one query - user: Optional[Mapping[str, Any]] = await users_collection.find_one( + user: Mapping[str, Any] | None = await users_collection.find_one( {"username": username} ) @@ -147,4 +133,4 @@ async def authenticate_user(username: str, password: str) -> Optional[User]: return None # Construct and return a User instance from the found document - return User(**user) + return UserInDB(**user) diff --git a/chatApp/config/database.py b/chatApp/config/database.py index 1a3307f..27a54c7 100644 --- a/chatApp/config/database.py +++ b/chatApp/config/database.py @@ -1,5 +1,4 @@ import logging -from typing import Optional from motor.motor_asyncio import ( AsyncIOMotorClient, @@ -15,11 +14,11 @@ class MongoDB: def __init__(self) -> None: - self.db_client: Optional[AsyncIOMotorClient] = None - self.db: Optional[AsyncIOMotorDatabase] = None - self.users_collection: Optional[AsyncIOMotorCollection] = None - self.messages_collection: Optional[AsyncIOMotorCollection] = None - self.rooms_collection: Optional[AsyncIOMotorCollection] = None + self.db_client: AsyncIOMotorClient | None = None + self.db: AsyncIOMotorDatabase | None = None + self.users_collection: AsyncIOMotorCollection | None = None + self.messages_collection: AsyncIOMotorCollection | None = None + self.rooms_collection: AsyncIOMotorCollection | None = None async def connect_to_mongodb(self) -> None: try: @@ -53,3 +52,42 @@ async def close_mongodb_connection(self) -> None: # Create a global instance of MongoDB mongo_db = MongoDB() + + +def get_users_collection() -> AsyncIOMotorCollection: + """ + Retrieve the users collection from the MongoDB database. + + :return: The users collection instance. + :raises RuntimeError: If the users collection is not initialized. + """ + users_collection = mongo_db.users_collection + if users_collection is None: + raise RuntimeError("Users collection is not initialized.") + return users_collection + + +def get_messages_collection() -> AsyncIOMotorCollection: + """ + Retrieve the messages collection from the MongoDB database. + + :return: The messages collection instance. + :raises RuntimeError: If the messages collection is not initialized. + """ + messages_collection = mongo_db.messages_collection + if messages_collection is None: + raise RuntimeError("messages collection is not initialized.") + return messages_collection + + +def get_rooms_collection() -> AsyncIOMotorCollection: + """ + Retrieve the 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 + if rooms_collection is None: + raise RuntimeError("rooms collection is not initialized.") + return rooms_collection diff --git a/chatApp/models/message.py b/chatApp/models/message.py index b68e7b1..f61729e 100644 --- a/chatApp/models/message.py +++ b/chatApp/models/message.py @@ -1,10 +1,17 @@ -from bson import ObjectId -from pydantic import BaseModel +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + +from chatApp.utils.object_id import PydanticObjectId class Message(BaseModel): - _id: ObjectId - user_id: ObjectId - room_id: str - content: str - # created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + user_id: PydanticObjectId + 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)) + + +class MessageInDB(Message): + id: PydanticObjectId = Field(alias="_id") diff --git a/chatApp/models/room.py b/chatApp/models/room.py new file mode 100644 index 0000000..26a56da --- /dev/null +++ b/chatApp/models/room.py @@ -0,0 +1,14 @@ +from datetime import datetime, timezone + +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 RoomInDB(Room): + id: PydanticObjectId = Field(alias="_id") diff --git a/chatApp/models/user.py b/chatApp/models/user.py index de9c78c..2ad8b82 100644 --- a/chatApp/models/user.py +++ b/chatApp/models/user.py @@ -1,15 +1,20 @@ -from bson import ObjectId -from pydantic import BaseModel +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + +from chatApp.utils.object_id import PydanticObjectId class User(BaseModel): - _id: ObjectId username: str email: str 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)) - # last_login: datetime = None - # last_logout: datetime = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + last_login: datetime | None = None + + +class UserInDB(User): + id: PydanticObjectId = Field(alias="_id") diff --git a/chatApp/routes/auth.py b/chatApp/routes/auth.py index 0d3e9a2..f703c35 100644 --- a/chatApp/routes/auth.py +++ b/chatApp/routes/auth.py @@ -1,13 +1,14 @@ # auth.py from collections.abc import Mapping from datetime import timedelta -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from chatApp.config import auth -from chatApp.models.user import User +from chatApp.config.database import get_users_collection +from chatApp.models.user import User, UserInDB from chatApp.schemas.user import UserCreateSchema from chatApp.utils.exceptions import credentials_exception @@ -15,12 +16,12 @@ @router.post("/register", response_model=User) -async def register_user(user: UserCreateSchema) -> User: +async def register_user(user: UserCreateSchema) -> UserInDB: # Fetch the users_collection within the request scope - users_collection = auth.get_users_collection() + users_collection = get_users_collection() # Check if the user already exists - existing_user: Optional[Mapping[str, Any]] = await users_collection.find_one( + existing_user: Mapping[str, Any] | None = await users_collection.find_one( {"username": user.username} ) if existing_user: @@ -38,7 +39,7 @@ async def register_user(user: UserCreateSchema) -> User: await users_collection.insert_one(user_dict) # Construct and return a User instance from the inserted document - return User(**user_dict) + return UserInDB(**user_dict) @router.post("/token", response_model=dict) @@ -63,5 +64,7 @@ async def login_for_access_token( @router.get("/users/me/", response_model=User) -async def read_users_me(current_user: User = Depends(auth.get_current_user)) -> User: +async def read_users_me( + current_user: UserInDB = Depends(auth.get_current_user), +) -> UserInDB: return current_user diff --git a/chatApp/routes/chat.py b/chatApp/routes/chat.py index af9233c..e5d07ec 100644 --- a/chatApp/routes/chat.py +++ b/chatApp/routes/chat.py @@ -1,3 +1,70 @@ -from fastapi import APIRouter +from collections.abc import Mapping +from typing import Any + +from bson import ObjectId +from fastapi import APIRouter, Depends, HTTPException +from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorCursor + +from chatApp.config import auth +from chatApp.config.database import get_messages_collection +from chatApp.models.message import MessageInDB +from chatApp.models.user import UserInDB +from chatApp.schemas.message import MessageCreateSchema router = APIRouter() + + +@router.get("/all-messages", response_model=list[MessageInDB]) +async def get_all_messages(): + messages_collection: AsyncIOMotorCollection = get_messages_collection() + + cursor: AsyncIOMotorCursor = messages_collection.find() + messages_dicts: list[Mapping[str, Any]] = await cursor.to_list(length=None) + messages: list[MessageInDB] = [ + MessageInDB(**message_dict) for message_dict in messages_dicts + ] + + return messages + + +@router.get("/messages", response_model=list[MessageInDB]) +async def get_messages(user: UserInDB = Depends(auth.get_current_user)): + messages_collection: AsyncIOMotorCollection = get_messages_collection() + + cursor: AsyncIOMotorCursor = messages_collection.find( + {"user_id": ObjectId(user.id)} + ) + messages_dicts: list[Mapping[str, Any]] = await cursor.to_list(length=None) + messages: list[MessageInDB] = [ + MessageInDB(**message_dict) for message_dict in messages_dicts + ] + + return messages + + +@router.get("/message/{message_id}", response_model=MessageInDB) +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( + {"_id": ObjectId(message_id), "user_id": ObjectId(user.id)} + ) + + if message is None: + raise HTTPException(status_code=404, detail="Message not found") + + return MessageInDB(**message) + + +@router.post("/message", response_model=MessageInDB) +async def create_message( + 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) + + return MessageInDB(**message_dict, _id=result.inserted_id) diff --git a/chatApp/routes/user.py b/chatApp/routes/user.py index 6b43309..a59fa86 100644 --- a/chatApp/routes/user.py +++ b/chatApp/routes/user.py @@ -4,20 +4,23 @@ from fastapi import APIRouter from motor.motor_asyncio import AsyncIOMotorCursor -from chatApp.config.auth import get_users_collection -from chatApp.schemas.user import users_serializer +from chatApp.config.database import get_users_collection +from chatApp.models.user import User router = APIRouter() -@router.get("/") -async def get_users() -> list[Mapping[str, Any]]: +@router.get("/", response_model=list[User]) +async def get_users() -> list[User]: users_collection = get_users_collection() + # Perform the query to get an async cursor cursor: AsyncIOMotorCursor = users_collection.find() - # Collect all users into a list - users: list[Mapping[str, Any]] = await cursor.to_list( - length=None - ) # length=None will retrieve all documents - # Serialize the list of users - return users_serializer(users) + + # Collect all users into a list of dictionaries + users_dicts: list[Mapping[str, Any]] = await cursor.to_list(length=None) + + # Convert each dictionary to a User object + users: list[User] = [User(**user_dict) for user_dict in users_dicts] + + return users diff --git a/chatApp/schemas/message.py b/chatApp/schemas/message.py index 6ee1793..f254a1d 100644 --- a/chatApp/schemas/message.py +++ b/chatApp/schemas/message.py @@ -1,11 +1,5 @@ -def message_serializer(message) -> dict: - return { - "id": str(message["_id"]), - "userId": str(message["user_id"]), - "roomId": message["room_id"], - "content": message["content"], - } +from pydantic import BaseModel -def messages_serializer(messages) -> list: - return [message_serializer(message) for message in messages] +class MessageCreateSchema(BaseModel): + content: str diff --git a/chatApp/schemas/user.py b/chatApp/schemas/user.py index 0d51d7d..1a9c0c8 100644 --- a/chatApp/schemas/user.py +++ b/chatApp/schemas/user.py @@ -1,18 +1,6 @@ from pydantic import BaseModel -def user_serializer(user) -> dict: - return { - "id": str(user["_id"]), - "userName": user["username"], - "email": user["email"], - } - - -def users_serializer(users) -> list: - return [user_serializer(user) for user in users] - - class UserCreateSchema(BaseModel): username: str email: str diff --git a/chatApp/utils/object_id.py b/chatApp/utils/object_id.py new file mode 100644 index 0000000..2fbed18 --- /dev/null +++ b/chatApp/utils/object_id.py @@ -0,0 +1,30 @@ +from collections.abc import Callable +from typing import Annotated, Any + +from bson import ObjectId +from pydantic_core import core_schema + + +class _ObjectIdPydanticAnnotation: + # Based on https://docs.pydantic.dev/latest/usage/types/custom/#handling-third-party-types. + + @classmethod + def __get_pydantic_core_schema__( + cls, + _source_type: Any, + _handler: Callable[[Any], core_schema.CoreSchema], + ) -> core_schema.CoreSchema: + def validate_from_str(input_value: str) -> ObjectId: + return ObjectId(input_value) + + return core_schema.union_schema( + [ + # 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), + ], + serialization=core_schema.to_string_ser_schema(), + ) + + +PydanticObjectId = Annotated[ObjectId, _ObjectIdPydanticAnnotation] diff --git a/mypy.ini b/mypy.ini index 976ba02..281dd2e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,3 @@ [mypy] ignore_missing_imports = True +check_untyped_defs = True diff --git a/poetry.lock b/poetry.lock index d3f8e25..d1d61e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2532,4 +2532,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "0cedbbf04904436f88d95c9c6527e3163beed0d96d47f392de821d0949c0026c" +content-hash = "8d0e8b9fbb178bd67780220b0807bf3115370092ad25ebc1300bcaf59504c01d" diff --git a/pyproject.toml b/pyproject.toml index a29eb41..ba22bbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" package-mode = false [tool.isort] -line_length = 88 +line_length = 92 profile = "black" remove_redundant_aliases = true @@ -35,6 +35,7 @@ pyupgrade = "^3.16.0" requests = "^2.32.3" pytest = "^8.3.2" pytest-asyncio = "^0.23.8" +pydantic = "^2.8.2" [build-system]