diff --git a/core/api/chat/handlers.py b/core/api/chat/handlers.py index aa5e3a3..5d03874 100644 --- a/core/api/chat/handlers.py +++ b/core/api/chat/handlers.py @@ -1,9 +1,7 @@ -from fastapi import APIRouter, Depends, Response - +from fastapi import APIRouter, Depends, Response, Path from core.helpers.auth.helpers import get_current_user from core.models.message.db import MPProfile from core.services import chat_service - from core.models.message.requests import CreateChatRequest router = APIRouter(prefix='/chat', tags=['chat']) @@ -13,3 +11,13 @@ router = APIRouter(prefix='/chat', tags=['chat']) async def create_chat(response: Response, chat: CreateChatRequest, user: MPProfile = Depends(get_current_user)): response.status_code = 201 return await chat_service.create_chat(chat.name, user.id) + + +@router.get("/{chat_id}") +async def chat_info(chat_id: int = Path(alias='chat_id'), user: MPProfile = Depends(get_current_user)): + return await chat_service.get_chat(chat_id, user.id) + + +@router.get("") +async def available_chats(user: MPProfile = Depends(get_current_user)): + return await chat_service.get_chats(user.id) diff --git a/core/api/message/handlers.py b/core/api/message/handlers.py index 207aeb6..5bb3902 100644 --- a/core/api/message/handlers.py +++ b/core/api/message/handlers.py @@ -1,5 +1,26 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, Response + +from core.helpers.auth.helpers import get_current_user +from core.models.message.db import MPProfile +from core.models.message.requests import SendMessageRequest +from core.services import message_service router = APIRouter(prefix="/message", tags=["message"]) +@router.post("") +async def send_message(response: Response, message: SendMessageRequest, user: MPProfile = Depends(get_current_user)): + response.status_code = 201 + return (await message_service.send_message(user, message)).model_dump(exclude_none=True, by_alias=True) + + +async def list_messages(): + pass + + +async def delete_message(): + pass + + +async def edit_message(): + pass diff --git a/core/errors/errors.py b/core/errors/errors.py index d691e4c..5063ec4 100644 --- a/core/errors/errors.py +++ b/core/errors/errors.py @@ -25,3 +25,16 @@ def incorrect_login(): message="Incorrect login") raise CustomExceptionHandler(status_code=401, response=response) + +def chat_not_found(): + response = Response(status=404, + error=True, + message="Chat not found") + raise CustomExceptionHandler(status_code=404, response=response) + + +def not_a_member_of_chat(): + response = Response(status=403, + error=True, + message="You are not a member of this chat") + raise CustomExceptionHandler(status_code=403, response=response) \ No newline at end of file diff --git a/core/models/message/db.py b/core/models/message/db.py index 3ce92db..f10cc60 100644 --- a/core/models/message/db.py +++ b/core/models/message/db.py @@ -10,6 +10,7 @@ class MPProfile(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) external_id: Mapped[int] = mapped_column(unique=True) chats: Mapped[list['MPChat']] = relationship('MPChat', secondary='mp_chat_user') + messages: Mapped[list['MPMessage']] = relationship('MPMessage', back_populates='sender') class MPChat(Base): @@ -19,6 +20,7 @@ class MPChat(Base): admin_id: Mapped[int] = mapped_column(ForeignKey('mp_profile.id')) admin: Mapped[MPProfile] = relationship() users: Mapped[list[MPProfile]] = relationship('MPProfile', secondary='mp_chat_user') + messages: Mapped[list['MPMessage']] = relationship('MPMessage', back_populates='chat') class MPMessage(Base): @@ -27,8 +29,8 @@ class MPMessage(Base): sender_id: Mapped[int] = mapped_column(ForeignKey('mp_profile.id')) chat_id: Mapped[int] = mapped_column(ForeignKey('mp_chat.id')) content: Mapped[str] - chat: Mapped[MPChat] = relationship() - sender: Mapped[MPProfile] = relationship() + chat: Mapped[MPChat] = relationship("MPChat", back_populates="messages") + sender: Mapped[MPProfile] = relationship("MPProfile", back_populates="messages") class MPChatUser(Base): diff --git a/core/models/message/requests.py b/core/models/message/requests.py index 6c16359..7d8dcb9 100644 --- a/core/models/message/requests.py +++ b/core/models/message/requests.py @@ -7,5 +7,4 @@ class CreateChatRequest(BaseModel): class SendMessageRequest(BaseModel): chat_id: int - sender_id: int content: str diff --git a/core/models/message/responses.py b/core/models/message/responses.py index 3d52d8e..68657b4 100644 --- a/core/models/message/responses.py +++ b/core/models/message/responses.py @@ -1,3 +1,34 @@ -from pydantic import BaseModel +import datetime + +from pydantic import BaseModel, ConfigDict, AliasGenerator +from pydantic.alias_generators import to_camel, to_snake +class ProfileResponse(BaseModel): + model_config = ConfigDict(alias_generator=AliasGenerator(serialization_alias=to_camel)) + id: int + external_id: int + created_at: datetime.datetime + modified_at: datetime.datetime + + +class ChatResponse(BaseModel): + model_config = ConfigDict(alias_generator=AliasGenerator(validation_alias=to_snake, + serialization_alias=to_camel)) + id: int + name: str | None + admin: ProfileResponse + users: list[ProfileResponse] | None = None + created_at: datetime.datetime + modified_at: datetime.datetime + + +class MessageResponse(BaseModel): + model_config = ConfigDict(alias_generator=AliasGenerator(validation_alias=to_snake, + serialization_alias=to_camel)) + id: int + sender: ProfileResponse + content: str + chat: ChatResponse + created_at: datetime.datetime + modified_at: datetime.datetime diff --git a/core/services/__init__.py b/core/services/__init__.py index 712ff9b..cbd2eab 100644 --- a/core/services/__init__.py +++ b/core/services/__init__.py @@ -1,7 +1,8 @@ from .auth.services import Service as AuthService from .chat.services import Service as ChatService - +from .message.service import Service as MessageService # Register services auth_service = AuthService() chat_service = ChatService() +message_service = MessageService() diff --git a/core/services/chat/services.py b/core/services/chat/services.py index b0505f9..ee721be 100644 --- a/core/services/chat/services.py +++ b/core/services/chat/services.py @@ -1,9 +1,36 @@ +from core.errors.errors import not_a_member_of_chat from core.models.message.db import MPChat +from core.models.message.responses import ChatResponse, ProfileResponse from core.storage import chat_storage class Service: - async def create_chat(self, name: str, admin_id: int) -> MPChat: + + async def build_chat_response(self, chat: MPChat) -> ChatResponse: + return ChatResponse(id=chat.id, + name=chat.name, + admin=ProfileResponse(id=chat.admin.id, + external_id=chat.admin.external_id, + created_at=chat.admin.created_at, + modified_at=chat.admin.modified_at), + users=[ProfileResponse(id=user.id, + external_id=user.external_id, + created_at=user.created_at, + modified_at=user.modified_at) for user in chat.users], + created_at=chat.created_at, + modified_at=chat.modified_at) + + async def create_chat(self, name: str, admin_id: int) -> ChatResponse: chat = await chat_storage.create_chat(name=name, admin_id=admin_id) - await chat_storage.add_member(chat.id, admin_id) - return chat + chat = await chat_storage.add_member(chat.id, admin_id) + return await self.build_chat_response(chat) + + async def get_chat(self, chat_id: int, user_id: int) -> ChatResponse: + chat = await chat_storage.get_chat(chat_id) + if user_id not in [user.id for user in chat.users]: + not_a_member_of_chat() + return await self.build_chat_response(chat) + + async def get_chats(self, user_id: int) -> list[ChatResponse]: + chats = await chat_storage.get_chats(user_id) + return [await self.build_chat_response(chat) for chat in chats] diff --git a/core/services/message/__init__.py b/core/services/message/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/services/message/service.py b/core/services/message/service.py new file mode 100644 index 0000000..1ad7d88 --- /dev/null +++ b/core/services/message/service.py @@ -0,0 +1,46 @@ +from core.errors.errors import not_a_member_of_chat +from core.models.message.db import MPProfile, MPMessage +from core.models.message.requests import SendMessageRequest +from core.models.message.responses import MessageResponse, ProfileResponse, ChatResponse +from core.storage import message_storage, auth_storage, chat_storage + + +class Service: + + async def build_message_response(self, msg: MPMessage) -> MessageResponse: + """ + Build a message response + """ + return MessageResponse( + id=msg.id, + sender=ProfileResponse(id=msg.sender.id, external_id=msg.sender.external_id, + created_at=msg.sender.created_at, modified_at=msg.sender.modified_at), + content=msg.content, + chat=ChatResponse(id=msg.chat.id, + name=msg.chat.name, + admin=ProfileResponse(id=msg.chat.admin.id, external_id=msg.chat.admin.external_id, + created_at=msg.chat.admin.created_at, + modified_at=msg.chat.admin.modified_at), + users=None, + created_at=msg.chat.created_at, + modified_at=msg.chat.modified_at), + created_at=msg.created_at, + modified_at=msg.modified_at + ) + + async def send_message(self, user: MPProfile, message: SendMessageRequest) -> MessageResponse: + """ + Send message to chat + """ + + # Check chat exists + chat = await chat_storage.get_chat(message.chat_id) + + # Check user is in chat + if user.id not in [x.id for x in chat.users]: + not_a_member_of_chat() + + # Add message to Database + msg = await message_storage.insert_message(user.id, message.chat_id, message.content) + + return await self.build_message_response(msg) diff --git a/core/storage/__init__.py b/core/storage/__init__.py index f66ffde..50a343a 100644 --- a/core/storage/__init__.py +++ b/core/storage/__init__.py @@ -5,6 +5,8 @@ Register our storages from database import Session from .chat.storage import Storage as ChatStorage from .auth.storage import Storage as AuthStorage +from .message.storage import Storage as MessageStorage chat_storage = ChatStorage(Session) -auth_storage = AuthStorage(Session) \ No newline at end of file +auth_storage = AuthStorage(Session) +message_storage = MessageStorage(Session) \ No newline at end of file diff --git a/core/storage/auth/storage.py b/core/storage/auth/storage.py index 2960641..3fc6be0 100644 --- a/core/storage/auth/storage.py +++ b/core/storage/auth/storage.py @@ -34,4 +34,5 @@ class Storage(BaseStorage): async with self.get_session() as session: session: AsyncSession user = await session.get(MPProfile, id) + await session.refresh(user, attribute_names=["chats"]) return user diff --git a/core/storage/chat/storage.py b/core/storage/chat/storage.py index 22423f4..02732e9 100644 --- a/core/storage/chat/storage.py +++ b/core/storage/chat/storage.py @@ -1,7 +1,9 @@ +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..base import BaseStorage from core.models.message.db import MPChat, MPProfile +from ...errors.errors import chat_not_found class Storage(BaseStorage): @@ -16,7 +18,7 @@ class Storage(BaseStorage): await session.refresh(chat) return chat - async def add_member(self, chat_id: int, user_id: int): + async def add_member(self, chat_id: int, user_id: int) -> MPChat: """ Storage handler for adding a member to a chat """ @@ -28,12 +30,25 @@ class Storage(BaseStorage): chat.users.append(user) await session.commit() await session.refresh(chat, attribute_names=["users", "admin"]) + return chat - - async def get_chat(self, id: int): + async def get_chat(self, id: int) -> MPChat: """ Storage handler for getting a chat """ async with self.get_session() as session: chat = await session.get(MPChat, id) + if chat is None: + chat_not_found() + await session.refresh(chat, attribute_names=["users", "admin"]) return chat + + async def get_chats(self, user_id: int) -> list[MPChat]: + async with self.get_session() as session: + session: AsyncSession + stmt = select(MPChat).where(MPChat.users.any(MPProfile.id == user_id)) + result = await session.execute(stmt) + chats: list[MPChat] = result.scalars().all() + for chat in chats: + await session.refresh(chat, attribute_names=["users", "admin"]) + return chats diff --git a/core/storage/message/__init__.py b/core/storage/message/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/storage/message/storage.py b/core/storage/message/storage.py new file mode 100644 index 0000000..2dc3a4f --- /dev/null +++ b/core/storage/message/storage.py @@ -0,0 +1,22 @@ +from sqlalchemy import insert, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, subqueryload + +from ..base import BaseStorage +from ...models.message.db import MPMessage, MPProfile, MPChat + + +class Storage(BaseStorage): + async def insert_message(self, sender_id: int, chat_id: int, content: str) -> MPMessage: + async with self.get_session() as session: + session: AsyncSession + msg = MPMessage(sender_id=sender_id, chat_id=chat_id, content=content) + session.add(msg) + await session.commit() + await session.refresh(msg) + + stmt = select(MPMessage).options(joinedload(MPMessage.chat).joinedload(MPChat.admin), + joinedload(MPMessage.sender)).where(MPMessage.id == msg.id) + result = await session.execute(stmt) + data = result.scalar_one_or_none() + return data diff --git a/tests/test_chat.py b/tests/test_chat.py index 6e211b5..39690da 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -12,7 +12,7 @@ server_thread = threading.Thread(target=uvicorn.run, args=(app,), daemon=True, server_thread.start() -client = Client(base_url="http://127.0.0.1:8000") +client = Client(base_url="http://127.0.0.1:8000", timeout=60*60*60) def test_create_chat(): @@ -29,3 +29,50 @@ def test_create_chat(): assert 'name' in resp.json() assert resp.json()['name'] == 'test_chat' + +def test_user_chats(): + resp = client.post('/api/v1/auth', json={'username': 1}) + token = resp.json()['access_token'] + resp = client.get('/api/v1/chat', headers={'Authorization': f'Bearer {token}'}) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +def test_user_forbidden_chat(): + resp = client.post('/api/v1/auth', json={'username': 1}) + token = resp.json()['access_token'] + resp = client.get('/api/v1/chat/1', headers={'Authorization': f'Bearer {token}'}) + assert resp.status_code == 403 + + +def test_user_retrieve_chat(): + resp = client.post('/api/v1/auth', json={'username': 1}) + token = resp.json()['access_token'] + resp = client.get('/api/v1/chat', headers={'Authorization': f'Bearer {token}'}) + resp = client.get(f"/api/v1/chat/{resp.json()[0]['id']}", headers={'Authorization': f'Bearer {token}'}) + assert resp.status_code == 200 + + +def test_not_found_chat(): + resp = client.post('/api/v1/auth', json={'username': 1}) + token = resp.json()['access_token'] + resp = client.get(f"/api/v1/chat/-1", headers={'Authorization': f'Bearer {token}'}) + assert resp.status_code == 404 + + +# Testing auth tokens + +def test_create_chat_no_token(): + resp = client.post(f"/api/v1/chat", json={'name': 'chat no token'}) + assert resp.status_code == 422 + + +def test_create_chat_invalid_token(): + resp = client.post(f"/api/v1/chat", json={'name': 'chat invalid token'}, headers={'Authorization': 'Bearer 123'}) + assert resp.status_code == 401 + + +def test_create_chat_expired_token(): + token = jwt.encode({'user_id': 1, "exp": 1715940028}, Config.secret, algorithm='HS256') + resp = client.post(f"/api/v1/chat", json={'name': 'chat expired token'}, headers={f"Authorization": f"Bearer {token}"}) + assert resp.status_code == 401 \ No newline at end of file diff --git a/tests/test_message.py b/tests/test_message.py new file mode 100644 index 0000000..23d4fb1 --- /dev/null +++ b/tests/test_message.py @@ -0,0 +1,44 @@ +import threading +import uvicorn +from main import app +from httpx import Client + +server_thread = threading.Thread(target=uvicorn.run, args=(app,), daemon=True, + kwargs={"host": "127.0.0.1", "port": 8000, "reload": False}) +server_thread.start() + +client = Client(base_url="http://127.0.0.1:8000", timeout=60 * 60 * 60) + + +def test__message_send(): + # Complete authorization + resp = client.post('/api/v1/auth', json={'username': 1}) + assert resp.status_code == 200 + token = resp.json()['access_token'] + chat_id = client.get('/api/v1/chat', headers={'Authorization': f'Bearer {token}'}).json()[0]["id"] + + resp = client.post('/api/v1/message', json={ + "chat_id": chat_id, + "content": "test__message_send"}, headers={'Authorization': f'Bearer {token}'}) + assert resp.status_code == 201 + assert resp.json()["content"] == "test__message_send" + assert resp.json()["id"] > 0 + + +def test__message_send_without_auth(): + resp = client.post('/api/v1/message', json={ + "chat_id": 70, + "content": "test__message_send_without_auth"}, headers={'Authorization': f'Bearer 123'}) + assert resp.status_code == 401 + + +def test__message_send_without_chat(): + # Complete authorization + resp = client.post('/api/v1/auth', json={'username': 1}) + assert resp.status_code == 200 + token = resp.json()['access_token'] + + resp = client.post('/api/v1/message', json={ + "chat_id": -1, + "content": "test__message_send_without_chat"}, headers={'Authorization': f'Bearer {token}'}) + assert resp.status_code == 404