diff --git a/backend/app/api/endpoints/chat.py b/backend/app/api/endpoints/chat.py new file mode 100644 index 0000000..4e2c155 --- /dev/null +++ b/backend/app/api/endpoints/chat.py @@ -0,0 +1,141 @@ +from fastapi import APIRouter, Depends, HTTPException, WebSocket, UploadFile, File +from sqlalchemy.orm import Session +from typing import List +import os +import aiofiles +import uuid + +from app.database import get_db +from app.models.user import User +from app.core.auth import get_current_user +from app.schemas.chat import Chat, Message, ChatFile +from app.websockets.chat import handle_chat_connection + +router = APIRouter() + +# Путь для сохранения файлов +UPLOAD_DIR = "uploads/chat_files" +os.makedirs(UPLOAD_DIR, exist_ok=True) + +@router.websocket("/ws/chat") +async def websocket_endpoint(websocket: WebSocket, db: Session = Depends(get_db)): + await handle_chat_connection(websocket, db) + +@router.post("/files/") +async def upload_file( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + try: + # Генерируем уникальное имя файла + file_extension = os.path.splitext(file.filename)[1] + unique_filename = f"{uuid.uuid4()}{file_extension}" + file_path = os.path.join(UPLOAD_DIR, unique_filename) + + # Сохраняем файл + async with aiofiles.open(file_path, 'wb') as out_file: + content = await file.read() + await out_file.write(content) + + return { + "filename": file.filename, + "saved_path": file_path, + "size": len(content) + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/messages/", response_model=List[Message]) +def get_messages( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Получаем чат пользователя + chat = db.query(Chat).filter( + (Chat.employee_id == current_user.id) | + (Chat.admin_id == current_user.id) + ).first() + + if not chat: + return [] + + # Получаем сообщения + messages = db.query(Message).filter(Message.chat_id == chat.id).all() + return messages + +@router.get("/unread-count/") +def get_unread_count( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Получаем чат пользователя + chat = db.query(Chat).filter( + (Chat.employee_id == current_user.id) | + (Chat.admin_id == current_user.id) + ).first() + + if not chat: + return {"unread_count": 0} + + # Считаем непрочитанные сообщения + unread_count = db.query(Message).filter( + Message.chat_id == chat.id, + Message.sender_id != current_user.id, + Message.is_read == False + ).count() + + return {"unread_count": unread_count} + +@router.get("/admin/chats/", response_model=List[Chat]) +def get_admin_chats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + # Получаем все чаты с последними сообщениями и количеством непрочитанных + chats = db.query(Chat).all() + + # Для каждого чата добавляем дополнительную информацию + for chat in chats: + # Последнее сообщение + last_message = db.query(Message)\ + .filter(Message.chat_id == chat.id)\ + .order_by(Message.created_at.desc())\ + .first() + chat.last_message = last_message + + # Количество непрочитанных сообщений + unread_count = db.query(Message)\ + .filter( + Message.chat_id == chat.id, + Message.sender_id != current_user.id, + Message.is_read == False + ).count() + chat.unread_count = unread_count + + return chats + +@router.get("/messages/{chat_id}/", response_model=List[Message]) +def get_chat_messages( + chat_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Проверяем доступ к чату + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + raise HTTPException(status_code=404, detail="Chat not found") + + if not current_user.is_admin and chat.employee_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized") + + # Получаем сообщения чата + messages = db.query(Message)\ + .filter(Message.chat_id == chat_id)\ + .order_by(Message.created_at.asc())\ + .all() + + return messages \ No newline at end of file diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..a3a097f --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,57 @@ +from fastapi import Depends, HTTPException, status, WebSocket +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session +from typing import Optional + +from app.core.config import settings +from app.database import get_db +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.email == email).first() + if user is None: + raise credentials_exception + return user + +async def get_current_user_ws(websocket: WebSocket, db: Session = Depends(get_db)) -> Optional[User]: + try: + # Получаем токен из параметров запроса + token = websocket.query_params.get("token") + if not token: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None + + # Проверяем токен + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + email: str = payload.get("sub") + if email is None: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None + + # Получаем пользователя + user = db.query(User).filter(User.email == email).first() + if user is None: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None + + return user + + except JWTError: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return None \ No newline at end of file diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py new file mode 100644 index 0000000..c6e18d7 --- /dev/null +++ b/backend/app/core/scheduler.py @@ -0,0 +1,19 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from app.tasks.cleanup import cleanup_old_messages + +scheduler = AsyncIOScheduler() + +def setup_scheduler(): + """Настраивает планировщик задач""" + + # Запускаем очистку старых сообщений каждый день в полночь + scheduler.add_job( + cleanup_old_messages, + trigger=CronTrigger(hour=0, minute=0), + id='cleanup_old_messages', + name='Cleanup old messages and files', + replace_existing=True + ) + + scheduler.start() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index a7de79c..edc9860 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,40 +1,38 @@ """Main application module""" from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from . import models -from .routers import admin, employees, requests, auth, statistics +from app.api.api import api_router +from app.core.config import settings +from app.core.scheduler import setup_scheduler +from app.websockets.chat import handle_chat_connection app = FastAPI( - # Включаем автоматическое перенаправление со слэшем - redirect_slashes=True, - # Добавляем описание API - title="Support System API", - description="API для системы поддержки", - version="1.0.0" + title=settings.project_name, + openapi_url=f"{settings.api_v1_str}/openapi.json" ) -# CORS configuration -origins = [ - "http://localhost", - "http://localhost:8080", - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://127.0.0.1:8080", - "http://185.139.70.62", # Добавляем ваш production домен -] - +# Настройка CORS app.add_middleware( CORSMiddleware, - allow_origins=origins, + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], - expose_headers=["*"] ) -# Include routers -app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) -app.include_router(employees.router, prefix="/api/employees", tags=["employees"]) -app.include_router(requests.router, prefix="/api/requests", tags=["requests"]) -app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) -app.include_router(statistics.router, prefix="/api/statistics", tags=["statistics"]) \ No newline at end of file +# Подключаем роутеры +app.include_router(api_router, prefix=settings.api_v1_str) + +# WebSocket для чата +app.add_api_websocket_route("/ws/chat", handle_chat_connection) + +@app.on_event("startup") +async def startup_event(): + """Действия при запуске приложения""" + setup_scheduler() + +@app.on_event("shutdown") +async def shutdown_event(): + """Действия при остановке приложения""" + from app.core.scheduler import scheduler + scheduler.shutdown() \ No newline at end of file diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py new file mode 100644 index 0000000..9bd7251 --- /dev/null +++ b/backend/app/models/chat.py @@ -0,0 +1,46 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from app.database import Base + +class Chat(Base): + __tablename__ = "chats" + + id = Column(Integer, primary_key=True, index=True) + employee_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) + admin_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Отношения + employee = relationship("User", foreign_keys=[employee_id], back_populates="employee_chats") + admin = relationship("User", foreign_keys=[admin_id], back_populates="admin_chats") + messages = relationship("Message", back_populates="chat", cascade="all, delete-orphan") + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + chat_id = Column(Integer, ForeignKey("chats.id", ondelete="CASCADE"), nullable=False) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + content = Column(Text, nullable=False) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Отношения + chat = relationship("Chat", back_populates="messages") + sender = relationship("User", back_populates="sent_messages") + files = relationship("ChatFile", back_populates="message", cascade="all, delete-orphan") + +class ChatFile(Base): + __tablename__ = "chat_files" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column(Integer, ForeignKey("messages.id", ondelete="CASCADE"), nullable=False) + file_name = Column(String(255), nullable=False) + file_path = Column(String(255), nullable=False) + file_size = Column(Integer, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Отношения + message = relationship("Message", back_populates="files") \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..d963cd3 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +from sqlalchemy import Boolean, Column, Integer, String +from sqlalchemy.orm import relationship + +from app.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True) + full_name = Column(String) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + # Отношения для чата + employee_chats = relationship("Chat", foreign_keys="[Chat.employee_id]", back_populates="employee") + admin_chats = relationship("Chat", foreign_keys="[Chat.admin_id]", back_populates="admin") + sent_messages = relationship("Message", back_populates="sender") \ No newline at end of file diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py new file mode 100644 index 0000000..327fa6c --- /dev/null +++ b/backend/app/schemas/chat.py @@ -0,0 +1,73 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import List, Optional +from app.schemas.user import User + +class ChatFileBase(BaseModel): + file_name: str + file_size: int + +class ChatFileCreate(ChatFileBase): + pass + +class ChatFile(ChatFileBase): + id: int + message_id: int + file_path: str + created_at: datetime + + class Config: + from_attributes = True + +class MessageBase(BaseModel): + content: str + +class MessageCreate(MessageBase): + pass + +class Message(MessageBase): + id: int + chat_id: int + sender_id: int + is_read: bool + created_at: datetime + files: List[ChatFile] = [] + + class Config: + from_attributes = True + +class ChatBase(BaseModel): + employee_id: int + admin_id: int + +class ChatCreate(ChatBase): + pass + +class Chat(ChatBase): + id: int + created_at: datetime + employee: User + admin: User + last_message: Optional[Message] = None + unread_count: Optional[int] = 0 + + class Config: + from_attributes = True + +# Схемы для WebSocket сообщений +class WSMessage(BaseModel): + type: str + content: Optional[str] = None + message_ids: Optional[List[int]] = None + files: Optional[List[dict]] = None + +class WSResponse(BaseModel): + type: str + id: Optional[int] = None + sender_id: Optional[int] = None + content: Optional[str] = None + created_at: Optional[datetime] = None + is_read: Optional[bool] = None + message_ids: Optional[List[int]] = None + files: Optional[List[ChatFile]] = None + error: Optional[str] = None \ No newline at end of file diff --git a/backend/app/tasks/cleanup.py b/backend/app/tasks/cleanup.py new file mode 100644 index 0000000..f23be01 --- /dev/null +++ b/backend/app/tasks/cleanup.py @@ -0,0 +1,38 @@ +import os +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from app.database import SessionLocal +from app.models.chat import Message, ChatFile + +def cleanup_old_messages(): + """Удаляет сообщения и файлы старше 1 месяца""" + db = SessionLocal() + try: + # Определяем дату, до которой нужно удалить сообщения + cutoff_date = datetime.utcnow() - timedelta(days=30) + + # Получаем файлы, которые нужно удалить + files_to_delete = db.query(ChatFile)\ + .join(Message)\ + .filter(Message.created_at < cutoff_date)\ + .all() + + # Удаляем физические файлы + for file in files_to_delete: + try: + if os.path.exists(file.file_path): + os.remove(file.file_path) + except Exception as e: + print(f"Error deleting file {file.file_path}: {e}") + + # Удаляем старые сообщения (каскадно удалятся и записи о файлах) + db.query(Message)\ + .filter(Message.created_at < cutoff_date)\ + .delete(synchronize_session=False) + + db.commit() + except Exception as e: + print(f"Error during cleanup: {e}") + db.rollback() + finally: + db.close() \ No newline at end of file diff --git a/backend/app/websockets/chat.py b/backend/app/websockets/chat.py new file mode 100644 index 0000000..aa85468 --- /dev/null +++ b/backend/app/websockets/chat.py @@ -0,0 +1,112 @@ +from fastapi import WebSocket, WebSocketDisconnect, Depends +from typing import Dict, List, Optional +from datetime import datetime +import json + +from app.core.auth import get_current_user_ws +from app.models.user import User +from app.models.chat import Chat, Message, ChatFile +from app.database import get_db +from sqlalchemy.orm import Session + +class ConnectionManager: + def __init__(self): + # Хранение активных соединений: {user_id: WebSocket} + self.active_connections: Dict[int, WebSocket] = {} + + async def connect(self, websocket: WebSocket, user_id: int): + await websocket.accept() + self.active_connections[user_id] = websocket + + def disconnect(self, user_id: int): + if user_id in self.active_connections: + del self.active_connections[user_id] + + async def send_personal_message(self, message: dict, user_id: int): + if user_id in self.active_connections: + await self.active_connections[user_id].send_json(message) + + def is_connected(self, user_id: int) -> bool: + return user_id in self.active_connections + +manager = ConnectionManager() + +async def handle_chat_connection( + websocket: WebSocket, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_ws) +): + await manager.connect(websocket, current_user.id) + try: + while True: + data = await websocket.receive_json() + + # Обработка различных типов сообщений + message_type = data.get('type') + + if message_type == 'message': + # Создание нового сообщения + chat = db.query(Chat).filter( + (Chat.employee_id == current_user.id) | + (Chat.admin_id == current_user.id) + ).first() + + if not chat: + # Создаем новый чат для сотрудника + admin = db.query(User).filter(User.is_admin == True).first() + chat = Chat(employee_id=current_user.id, admin_id=admin.id) + db.add(chat) + db.commit() + + message = Message( + chat_id=chat.id, + sender_id=current_user.id, + content=data['content'] + ) + db.add(message) + db.commit() + + # Определяем получателя + recipient_id = chat.admin_id if current_user.id == chat.employee_id else chat.employee_id + + # Отправляем сообщение получателю + message_data = { + 'type': 'message', + 'id': message.id, + 'sender_id': current_user.id, + 'content': message.content, + 'created_at': message.created_at.isoformat(), + 'is_read': False + } + + if manager.is_connected(recipient_id): + await manager.send_personal_message(message_data, recipient_id) + + elif message_type == 'read': + # Отмечаем сообщения как прочитанные + message_ids = data.get('message_ids', []) + db.query(Message).filter(Message.id.in_(message_ids)).update( + {Message.is_read: True}, + synchronize_session=False + ) + db.commit() + + # Отправляем подтверждение прочтения + chat = db.query(Chat).filter( + (Chat.employee_id == current_user.id) | + (Chat.admin_id == current_user.id) + ).first() + + if chat: + recipient_id = chat.admin_id if current_user.id == chat.employee_id else chat.employee_id + if manager.is_connected(recipient_id): + await manager.send_personal_message({ + 'type': 'read_confirmation', + 'message_ids': message_ids + }, recipient_id) + + except WebSocketDisconnect: + manager.disconnect(current_user.id) + except Exception as e: + print(f"Error in WebSocket connection: {e}") + manager.disconnect(current_user.id) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index b0104ac..0118e36 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ uvicorn==0.27.1 sqlalchemy==2.0.27 pydantic==2.5.2 pydantic-settings==2.2.1 -python-multipart==0.0.9 +python-multipart==0.0.6 python-jose[cryptography]==3.3.0 passlib[bcrypt]>=1.7.4 bcrypt>=4.0.1 @@ -15,3 +15,6 @@ pytest==8.0.0 httpx==0.26.0 requests>=2.26.0 aiogram==3.4.1 +websockets==12.0 +aiofiles==23.2.1 +APScheduler==3.10.4 diff --git a/frontend/src/components/ChatModal.vue b/frontend/src/components/ChatModal.vue new file mode 100644 index 0000000..e74e833 --- /dev/null +++ b/frontend/src/components/ChatModal.vue @@ -0,0 +1,367 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ChatNotification.vue b/frontend/src/components/ChatNotification.vue new file mode 100644 index 0000000..e6fb954 --- /dev/null +++ b/frontend/src/components/ChatNotification.vue @@ -0,0 +1,78 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/AdminChatList.vue b/frontend/src/components/admin/AdminChatList.vue new file mode 100644 index 0000000..a5eb451 --- /dev/null +++ b/frontend/src/components/admin/AdminChatList.vue @@ -0,0 +1,117 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/admin/AdminChatView.vue b/frontend/src/components/admin/AdminChatView.vue new file mode 100644 index 0000000..1cafe75 --- /dev/null +++ b/frontend/src/components/admin/AdminChatView.vue @@ -0,0 +1,405 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..3f3e543 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,63 @@ +import AdminChatList from '@/components/admin/AdminChatList.vue' +import AdminChatView from '@/components/admin/AdminChatView.vue' +import ChatNotification from '@/components/ChatNotification.vue' + +const chats = ref([]) +const selectedChat = ref(null) +const showChatNotification = ref(false) +const chatNotificationMessage = ref('') + +const loadChats = async () => { + try { + const response = await fetch('/api/chat/admin/chats/', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }) + if (response.ok) { + chats.value = await response.json() + } + } catch (error) { + console.error('Error loading chats:', error) + } +} + +const handleChatSelect = (chat) => { + selectedChat.value = chat +} + +onMounted(() => { + loadChats() + const interval = setInterval(loadChats, 30000) + + onUnmounted(() => { + clearInterval(interval) + }) +}) + +
+ +
+ +
+ + +
+ +
+
+ + + \ No newline at end of file diff --git a/frontend/src/views/EmployeeView.vue b/frontend/src/views/EmployeeView.vue index 0fd7a7f..09202c4 100644 --- a/frontend/src/views/EmployeeView.vue +++ b/frontend/src/views/EmployeeView.vue @@ -61,12 +61,20 @@

Чат поддержки

@@ -323,12 +331,29 @@ @close="showNotification = false" /> + + + + +