Add edit message

This commit is contained in:
2026-01-24 13:20:45 +03:00
parent a690116399
commit 8c0c063bac
17 changed files with 1237 additions and 112 deletions
-53
View File
@@ -1,53 +0,0 @@
COMPANY_NAME=AETHER
MODE=DEV
LOG_LEVEL=DEBUG
BACKEND_HOST=localhost
BACKEND_PORT=8080
WORKERS=4
FRONTEND_URL=http://localhost:5173
VITE_API_URL=/api/v1
FRONTEND_PORT=3056
FIRST_SUPER_USER_EMAIL=admin@example.com
FIRST_SUPER_USER_PASS=admin
FIRST_SUPER_USER_USERNAME=admin
DB_HOST=localhost
DB_PORT=5432
DB_PASS=postgres
DB_USER=postgres
DB_NAME=Aether
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_PASS=
# REDIS_DB=
#CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"]
#CORS_ORIGINS=["http://localhost:3000"]
#CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"]
CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"]
CORS_ORIGINS=["http://localhost:5500", "http://localhost:5173", "http://localhost:8080", "http://127.0.0.1:8080", "null"]
CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"]
SECRET_KEY=sercretKey
ALGORITHM=HS256
SMTP_SERVER=localhost
SMTP_PORT=1025
SMTP_EMAIL=noreply@cityvibe.ru
SMTP_PASS=test
RMQ_HOST=localhost
RMQ_USER=guest
RMQ_PASS=guest
RMQ_PORT=5672
S3_URL=http://192.168.31.190:9002
S3_ACCESS_KEY_ID=lorsan
S3_SECRET_ACCESS_KEY=Lorser2009!
S3_BUCKET_NAME=aether
+2 -2
View File
@@ -1,4 +1,4 @@
__pycache__ __pycache__
.env .env.prod
test.py test.py
.env.old .env
+1 -1
View File
@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.13 (aetherbackend-6Zf3gKAD-py3.13)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.14 (aetherbackend-6Zf3gKAD-py3.14)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
<component name="PyDocumentationSettings"> <component name="PyDocumentationSettings">
+1 -1
View File
@@ -3,5 +3,5 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.13" /> <option name="sdkName" value="Python 3.13" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (aetherbackend-6Zf3gKAD-py3.13)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (aetherbackend-6Zf3gKAD-py3.14)" project-jdk-type="Python SDK" />
</project> </project>
+5 -5
View File
@@ -97,8 +97,8 @@ pip install -e .
# Или используя poetry # Или используя poetry
poetry install poetry install
# Создайте .env файл # Создайте .env.prod файл
cat > .env << EOF cat > .env.prod << EOF
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/aether DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/aether
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
SECRET_KEY=your-secret-key-here SECRET_KEY=your-secret-key-here
@@ -130,8 +130,8 @@ cd frontend
# Установите зависимости # Установите зависимости
npm install npm install
# Создайте .env файл # Создайте .env.prod файл
echo "VITE_API_URL=http://localhost:8000" > .env echo "VITE_API_URL=http://localhost:8000" > .env.prod
# Запустите dev сервер # Запустите dev сервер
npm run dev npm run dev
@@ -279,7 +279,7 @@ pytest tests/ -v
docker build -t aether-backend . docker build -t aether-backend .
# Запуск контейнера # Запуск контейнера
docker run -p 8000:8000 --env-file .env aether-backend docker run -p 8000:8000 --env-file .env.prod aether-backend
``` ```
### Frontend ### Frontend
+2 -2
View File
@@ -77,7 +77,7 @@ class MessageDAO(BaseDAO[MessageModel, MessageCreateDB, MessageUpdateDB]):
model = MessageModel model = MessageModel
@classmethod @classmethod
async def find_all_asc( async def find_all_desc(
cls, cls,
session: AsyncSession, session: AsyncSession,
offset: Optional[int], offset: Optional[int],
@@ -85,7 +85,7 @@ class MessageDAO(BaseDAO[MessageModel, MessageCreateDB, MessageUpdateDB]):
*filter, *filter,
**filter_by **filter_by
) -> List[MessageModel]: ) -> List[MessageModel]:
stmt = select(MessageModel).filter(*filter).filter_by(**filter_by).order_by(MessageModel.created_at.asc()) stmt = select(MessageModel).filter(*filter).filter_by(**filter_by).order_by(MessageModel.created_at.desc())
if offset is not None: if offset is not None:
stmt = stmt.offset(offset) stmt = stmt.offset(offset)
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ForeignKey, UUID, UniqueConstraint from sqlalchemy import ForeignKey, UUID, UniqueConstraint, text
from app.core.database import Base from app.core.database import Base
@@ -14,6 +14,7 @@ class MessageModel(Base):
chat_id: Mapped[uuid.UUID] = mapped_column(UUID, ForeignKey("chat.id", ondelete="CASCADE"), index=True) chat_id: Mapped[uuid.UUID] = mapped_column(UUID, ForeignKey("chat.id", ondelete="CASCADE"), index=True)
content: Mapped[str] = mapped_column() content: Mapped[str] = mapped_column()
is_read: Mapped[bool] = mapped_column(default=False) is_read: Mapped[bool] = mapped_column(default=False)
is_edited: Mapped[bool] = mapped_column(default=False, server_default=text("false"))
class ChatModel(Base): class ChatModel(Base):
+6 -2
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from app.chats.service import ChatService from app.chats.service import ChatService
from app.auth.dependencies import get_current_verified_user from app.auth.dependencies import get_current_verified_user
from app.users.models import UserModel from app.users.models import UserModel
from app.chats.schemas import Chat, MessageCreate, Message from app.chats.schemas import Chat, MessageCreate, Message, MessageUpdate
router = APIRouter(prefix="/chats", tags=["chats"]) router = APIRouter(prefix="/chats", tags=["chats"])
@@ -19,7 +19,7 @@ async def get_chats(
return await ChatService.get_chats(user, offset, limit) return await ChatService.get_chats(user, offset, limit)
@router.get("/{chat_id}") @router.get("/{chat_id}")
async def get_chat( async def get_messages(
chat_id: uuid.UUID, chat_id: uuid.UUID,
offset: int = 0, offset: int = 0,
limit: int = 10, limit: int = 10,
@@ -31,6 +31,10 @@ async def get_chat(
async def send_message(message: MessageCreate, user: UserModel = Depends(get_current_verified_user)) -> Message: async def send_message(message: MessageCreate, user: UserModel = Depends(get_current_verified_user)) -> Message:
return await ChatService.send_message(user, message) return await ChatService.send_message(user, message)
@router.put("/message")
async def edit_message(message_update: MessageUpdate, user: UserModel = Depends(get_current_verified_user)) -> Message:
return await ChatService.update_message(user, message_update)
@router.websocket("/ws") @router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket, user: UserModel = Depends(get_current_verified_user)): async def websocket_endpoint(ws: WebSocket, user: UserModel = Depends(get_current_verified_user)):
+7 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
import uuid import uuid
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
class MessageCreate(BaseModel): class MessageCreate(BaseModel):
@@ -21,10 +21,13 @@ class MessageCreateDB(BaseModel):
chat_id: Optional[uuid.UUID] chat_id: Optional[uuid.UUID]
content: Optional[str] content: Optional[str]
is_read: Optional[bool] = False is_read: Optional[bool] = False
is_edited: Optional[bool] = False
class MessageUpdateDB(BaseModel): class MessageUpdateDB(BaseModel):
content: Optional[str] content: Optional[str]
is_edited: Optional[bool] = False
class Message(BaseModel): class Message(BaseModel):
@@ -32,9 +35,12 @@ class Message(BaseModel):
sender_id: int sender_id: int
chat_id: uuid.UUID chat_id: uuid.UUID
content: str content: str
is_edited: Optional[bool] = False
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChatBase(BaseModel): class ChatBase(BaseModel):
is_group: Optional[bool] = False is_group: Optional[bool] = False
+44 -10
View File
@@ -9,7 +9,7 @@ from sqlalchemy import and_
from app.core.database import async_session_maker from app.core.database import async_session_maker
from app.chats.dao import ChatDAO, MessageDAO, ParticipantDAO from app.chats.dao import ChatDAO, MessageDAO, ParticipantDAO
from app.chats.models import ChatModel, MessageModel, ParticipantModel from app.chats.models import ChatModel, MessageModel, ParticipantModel
from app.chats.schemas import Chat, MessageCreate, MessageCreateDB, ChatCreateDB, ParticipantCreateDB, Message from app.chats.schemas import Chat, MessageCreate, MessageCreateDB, ChatCreateDB, ParticipantCreateDB, Message, MessageUpdateDB, MessageUpdate
from app.users.models import UserModel from app.users.models import UserModel
from app.core.redis import get_redis from app.core.redis import get_redis
@@ -92,14 +92,7 @@ class ChatService:
) )
) )
await cls._send_ws_message(members_ids, Message( await cls._send_ws_message(members_ids, Message.model_validate(message_db))
id=message_db.id,
sender_id=message_db.sender_id,
chat_id=message_db.chat_id,
content=message_db.content,
created_at=message_db.created_at,
updated_at=message_db.updated_at
))
await ChatDAO.update( await ChatDAO.update(
session, session,
@@ -124,7 +117,7 @@ class ChatService:
log.warning("Access denied to chat", extra={"user_id": user.id, "chat_id": chat_id}) log.warning("Access denied to chat", extra={"user_id": user.id, "chat_id": chat_id})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Access denied") raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Access denied")
messages = await MessageDAO.find_all_asc( messages = await MessageDAO.find_all_desc(
session, session,
offset, offset,
limit, limit,
@@ -181,3 +174,44 @@ class ChatService:
} }
await redis_client.publish("messenger_updates", json.dumps(payload)) await redis_client.publish("messenger_updates", json.dumps(payload))
log.debug(f"Published message for user_id: {user_id}") log.debug(f"Published message for user_id: {user_id}")
@classmethod
async def update_message(cls, user: UserModel, message_update: MessageUpdate) -> Message:
async with async_session_maker() as session:
message_exist = await MessageDAO.find_one_or_none(
session,
and_(
MessageModel.id==message_update.id,
MessageModel.sender_id==user.id
)
)
if message_exist is None:
log.warning("Message not found", extra={"user_id": user.id, "message_id": message_update.id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Message not found")
message_update_db = await MessageDAO.update(
session,
MessageModel.id==message_update.id,
obj_in=MessageUpdateDB(
content=message_update.content,
is_edited=True
)
)
members = await ParticipantDAO.find_all(
session,
None,
None,
ParticipantModel.chat_id==message_exist.chat_id
)
member_ids = [member.user_id for member in members]
await cls._send_ws_message(member_ids, Message.model_validate(message_update_db))
await session.commit()
log.info("Message update successfully", extra={"user_id": user.id, "message_id": message_update.id})
return message_update_db
@@ -0,0 +1,32 @@
"""Edit message table: adding is_edited column
Revision ID: 76159faa56c8
Revises: 0d3f7039ba77
Create Date: 2026-01-20 17:22:23.965106
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '76159faa56c8'
down_revision: Union[str, Sequence[str], None] = '0d3f7039ba77'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('message', sa.Column('is_edited', sa.Boolean(), nullable=False, server_default=sa.text("false")))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('message', 'is_edited')
# ### end Alembic commands ###
+3 -3
View File
@@ -56,7 +56,7 @@ services:
networks: networks:
- aether - aether
env_file: env_file:
- .env - .env.prod
celery: celery:
build: build:
@@ -70,7 +70,7 @@ services:
networks: networks:
- aether - aether
env_file: env_file:
- .env - .env.prod
restart: unless-stopped restart: unless-stopped
backend: backend:
@@ -95,7 +95,7 @@ services:
networks: networks:
- aether - aether
env_file: env_file:
- .env - .env.prod
restart: unless-stopped restart: unless-stopped
frontend: frontend:
+8
View File
@@ -66,6 +66,14 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/chat/:chatId"
element={
<PrivateRoute>
<ChatPage />
</PrivateRoute>
}
/>
<Route <Route
path="/profile" path="/profile"
element={ element={
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import apiClient from './api';
export type Chat = {
chat_id: string;
user_id: number;
last_message: string | null;
avatar_url: string | null;
display_name: string;
}
export type Message = {
id: string;
sender_id: number;
chat_id: string;
content: string;
is_edited?: boolean;
created_at: string;
updated_at: string;
}
export type MessageCreate = {
content: string;
chat_id?: string;
recipient_id?: number;
}
export type MessageUpdate = {
id: string;
content: string;
}
const chatService = {
async getChats(offset: number = 0, limit: number = 10): Promise<Chat[]> {
const response = await apiClient.get('/chats/', {
params: { offset, limit }
});
return response.data;
},
async getChatMessages(chatId: string, offset: number = 0, limit: number = 50): Promise<Message[]> {
const response = await apiClient.get(`/chats/${chatId}`, {
params: { offset, limit }
});
return response.data;
},
async sendMessage(data: MessageCreate): Promise<Message> {
const response = await apiClient.post('/chats/message', data);
return response.data;
},
async updateMessage(data: MessageUpdate): Promise<Message> {
const response = await apiClient.put('/chats/message', data);
return response.data;
}
};
export default chatService;
+5
View File
@@ -26,6 +26,11 @@ export const userService = {
return response.data; return response.data;
}, },
getUserById: async (userId: number): Promise<User> => {
const response = await apiClient.get(`/users/${userId}`);
return response.data;
},
updateProfile: async (data: UserUpdate): Promise<User> => { updateProfile: async (data: UserUpdate): Promise<User> => {
const response = await apiClient.put('/users/me', data); const response = await apiClient.put('/users/me', data);
return response.data; return response.data;
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Chat } from '../services/chatService';
interface ChatStore {
chats: Chat[];
setChats: (chats: Chat[]) => void;
updateChat: (chatId: string, updates: Partial<Chat>) => void;
clearChats: () => void;
}
export const useChatStore = create<ChatStore>()(
persist(
(set) => ({
chats: [],
setChats: (chats) => set({ chats }),
updateChat: (chatId, updates) =>
set((state) => ({
chats: state.chats.map(chat =>
chat.chat_id === chatId ? { ...chat, ...updates } : chat
)
})),
clearChats: () => set({ chats: [] }),
}),
{
name: 'aether-chats',
partialize: (state) => ({ chats: state.chats }),
}
)
);