├── .env ├── .gitignore ├── README.md ├── alembic.ini ├── amvera.yml ├── app ├── chat │ ├── dao.py │ ├── models.py │ ├── router.py │ └── schemas.py ├── config.py ├── dao │ └── base.py ├── database.py ├── exceptions.py ├── main.py ├── migration │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 6b2520ba62db_initial_revision.py │ │ └── __pycache__ │ │ └── 6b2520ba62db_initial_revision.cpython-312.pyc ├── static │ ├── js │ │ ├── auth.js │ │ └── chat.js │ └── styles │ │ ├── auth.css │ │ └── chat.css ├── templates │ ├── auth.html │ └── chat.html └── users │ ├── auth.py │ ├── dao.py │ ├── dependencies.py │ ├── models.py │ ├── router.py │ └── schemas.py ├── db.sqlite3 ├── demo.gif └── requirements.txt /.env: -------------------------------------------------------------------------------- 1 | SECRET_KEY=gV64m9aIzFG4qpgVphvQbPQrtAO0nM-7YwwOvu0XPt5KJOjAy4AfgLkqJXYEt 2 | ALGORITHM=HS256 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Игнорирование виртуальной среды Python 2 | venv/ 3 | .venv/ 4 | 5 | 6 | # Игнорирование файлов с окружением 7 | # .env 8 | 9 | 10 | # Игнорирование скомпилированных файлов Python 11 | __pycache__/ 12 | **/__pycache__/ 13 | 14 | # Игнорирование настроек проекта для PyCharm 15 | .idea/ 16 | **/.idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Проект: Мини-чат на FastAPI 2 | 3 | Данный проект представляет собой мини-чат с использованием **FastAPI** для создания API, **SQLAlchemy** для работы с базой данных и простого фронтенда для взаимодействия с пользователями. Приложение содержит две основные модели: модель пользователей и модель сообщений, что позволяет пользователям отправлять и получать сообщения в реальном времени. 4 | 5 | ## Стек технологий: 6 | - **Backend**: FastAPI 7 | - **Frontend**: HTML/JavaScript 8 | - **ORM**: SQLAlchemy 9 | - **База данных**: Любая поддерживаемая SQLAlchemy база данных (например, PostgreSQL или SQLite). В примере используется SQLite. 10 | 11 | ## Основные компоненты: 12 | 1. **Модель пользователя**: содержит информацию о пользователях, включая имя пользователя, email и пароль. 13 | 2. **Модель сообщений**: хранит сообщения, отправленные пользователями, включая время отправки, отправителя и текст сообщения. 14 | 15 | ## Функционал: 16 | Это веб-приложение для обмена сообщениями, в котором пользователи могут отправлять и получать сообщения в реальном времени. Основные возможности включают: 17 | 18 | 1. **Аутентификация**: Регистрация и авторизация пользователей через формы на HTML и JavaScript. Используются API для обработки и валидации данных. 19 | 2. **Чат**: Пользователи могут выбирать собеседников из списка и общаться с ними. Сообщения отображаются в окне чата. 20 | 3. **Отправка сообщений**: Пользователи могут отправлять текстовые сообщения. Для мгновенной доставки сообщений используется WebSocket. Старые сообщения подгружаются при помощи опроса сервера. 21 | 4. **Интерфейс**: Современный и адаптивный интерфейс с использованием CSS, обеспечивающий удобное взаимодействие на различных устройствах. 22 | 5. **Управление пользователями**: Возможность выбора собеседников, выхода из системы и работы с "Избранным" по типу "Избранного" с телеграмм.. 23 | 24 | ## Пример GIF-анимации 25 | 26 | Ниже представлена анимация, демонстрирующая процесс работы приложения: 27 | 28 | Демонстрация работы мини-чата 29 | 30 | --- 31 | 32 | ## Как запустить проект 33 | 34 | ### Установка зависимостей: 35 | 36 | ```bash 37 | pip install -r requirements.txt 38 | ``` 39 | 40 | ### Выполнение миграций Alembic: 41 | 42 | ```bash 43 | alembic upgrade head 44 | ``` 45 | ### Запуск проекта: 46 | ```bash 47 | uvicorn app.main:app 48 | ``` 49 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts. 5 | # Use forward slashes (/) also on windows to provide an os agnostic path 6 | script_location = app/migration 7 | 8 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 9 | # Uncomment the line below if you want the files to be prepended with date and time 10 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 11 | 12 | # sys.path path, will be prepended to sys.path if present. 13 | # defaults to the current working directory. 14 | prepend_sys_path = . 15 | 16 | # timezone to use when rendering the date within the migration file 17 | # as well as the filename. 18 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 19 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 20 | # string value is passed to ZoneInfo() 21 | # leave blank for localtime 22 | # timezone = 23 | 24 | # max length of characters to apply to the "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migration/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migration/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | # version_path_separator = newline 51 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 52 | 53 | # set to 'true' to search source files recursively 54 | # in each "version_locations" directory 55 | # new in Alembic version 1.10 56 | # recursive_version_locations = false 57 | 58 | # the output encoding used when revision files 59 | # are written from script.py.mako 60 | # output_encoding = utf-8 61 | 62 | sqlalchemy.url = driver://user:pass@localhost/dbname 63 | 64 | 65 | [post_write_hooks] 66 | # post_write_hooks defines scripts or Python functions that are run 67 | # on newly generated revision scripts. See the documentation for further 68 | # detail and examples 69 | 70 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 71 | # hooks = black 72 | # black.type = console_scripts 73 | # black.entrypoint = black 74 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 75 | 76 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 77 | # hooks = ruff 78 | # ruff.type = exec 79 | # ruff.executable = %(here)s/.venv/bin/ruff 80 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 81 | 82 | # Logging configuration 83 | [loggers] 84 | keys = root,sqlalchemy,alembic 85 | 86 | [handlers] 87 | keys = console 88 | 89 | [formatters] 90 | keys = generic 91 | 92 | [logger_root] 93 | level = WARN 94 | handlers = console 95 | qualname = 96 | 97 | [logger_sqlalchemy] 98 | level = WARN 99 | handlers = 100 | qualname = sqlalchemy.engine 101 | 102 | [logger_alembic] 103 | level = INFO 104 | handlers = 105 | qualname = alembic 106 | 107 | [handler_console] 108 | class = StreamHandler 109 | args = (sys.stderr,) 110 | level = NOTSET 111 | formatter = generic 112 | 113 | [formatter_generic] 114 | format = %(levelname)-5.5s [%(name)s] %(message)s 115 | datefmt = %H:%M:%S 116 | -------------------------------------------------------------------------------- /amvera.yml: -------------------------------------------------------------------------------- 1 | meta: 2 | environment: python 3 | toolchain: 4 | name: pip 5 | version: 3.12 6 | build: 7 | requirementsPath: requirements.txt 8 | run: 9 | persistenceMount: /data 10 | containerPort: 8000 11 | command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips='10.112.130.20' --proxy-headers -------------------------------------------------------------------------------- /app/chat/dao.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select, and_, or_ 2 | from app.dao.base import BaseDAO 3 | from app.chat.models import Message 4 | from app.database import async_session_maker 5 | 6 | 7 | class MessagesDAO(BaseDAO): 8 | model = Message 9 | 10 | @classmethod 11 | async def get_messages_between_users(cls, user_id_1: int, user_id_2: int): 12 | """ 13 | Асинхронно находит и возвращает все сообщения между двумя пользователями. 14 | 15 | Аргументы: 16 | user_id_1: ID первого пользователя. 17 | user_id_2: ID второго пользователя. 18 | 19 | Возвращает: 20 | Список сообщений между двумя пользователями. 21 | """ 22 | async with async_session_maker() as session: 23 | query = select(cls.model).filter( 24 | or_( 25 | and_(cls.model.sender_id == user_id_1, cls.model.recipient_id == user_id_2), 26 | and_(cls.model.sender_id == user_id_2, cls.model.recipient_id == user_id_1) 27 | ) 28 | ).order_by(cls.model.id) 29 | result = await session.execute(query) 30 | return result.scalars().all() -------------------------------------------------------------------------------- /app/chat/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Text, ForeignKey 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | from app.database import Base 4 | 5 | 6 | class Message(Base): 7 | __tablename__ = 'messages' 8 | 9 | id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 10 | sender_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id")) 11 | recipient_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id")) 12 | content: Mapped[str] = mapped_column(Text) -------------------------------------------------------------------------------- /app/chat/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Request, Depends 2 | from fastapi.responses import HTMLResponse 3 | from fastapi.templating import Jinja2Templates 4 | from typing import List, Dict 5 | from app.chat.dao import MessagesDAO 6 | from app.chat.schemas import MessageRead, MessageCreate 7 | from app.users.dao import UsersDAO 8 | from app.users.dependencies import get_current_user 9 | from app.users.models import User 10 | import asyncio 11 | import logging 12 | 13 | # Создаем экземпляр маршрутизатора с префиксом /chat и тегом "Chat" 14 | router = APIRouter(prefix='/chat', tags=['Chat']) 15 | # Настройка шаблонов Jinja2 16 | templates = Jinja2Templates(directory='app/templates') 17 | 18 | 19 | # Страница чата 20 | @router.get("/", response_class=HTMLResponse, summary="Chat Page") 21 | async def get_chat_page(request: Request, user_data: User = Depends(get_current_user)): 22 | # Получаем всех пользователей из базы данных 23 | users_all = await UsersDAO.find_all() 24 | # Возвращаем HTML-страницу с использованием шаблона Jinja2 25 | return templates.TemplateResponse("chat.html", 26 | {"request": request, "user": user_data, 'users_all': users_all}) 27 | 28 | 29 | # Активные WebSocket-подключения: {user_id: websocket} 30 | active_connections: Dict[int, WebSocket] = {} 31 | 32 | 33 | # Функция для отправки сообщения пользователю, если он подключен 34 | async def notify_user(user_id: int, message: dict): 35 | """Отправить сообщение пользователю, если он подключен.""" 36 | if user_id in active_connections: 37 | websocket = active_connections[user_id] 38 | # Отправляем сообщение в формате JSON 39 | await websocket.send_json(message) 40 | 41 | 42 | # WebSocket эндпоинт для соединений 43 | @router.websocket("/ws/{user_id}") 44 | async def websocket_endpoint(websocket: WebSocket, user_id: int): 45 | # Принимаем WebSocket-соединение 46 | await websocket.accept() 47 | # Сохраняем активное соединение для пользователя 48 | active_connections[user_id] = websocket 49 | try: 50 | while True: 51 | # Просто поддерживаем соединение активным (1 секунда паузы) 52 | await asyncio.sleep(1) 53 | except WebSocketDisconnect: 54 | # Удаляем пользователя из активных соединений при отключении 55 | active_connections.pop(user_id, None) 56 | 57 | 58 | # Получение сообщений между двумя пользователями 59 | @router.get("/messages/{user_id}", response_model=List[MessageRead]) 60 | async def get_messages(user_id: int, current_user: User = Depends(get_current_user)): 61 | # Возвращаем список сообщений между текущим пользователем и другим пользователем 62 | return await MessagesDAO.get_messages_between_users(user_id_1=user_id, user_id_2=current_user.id) or [] 63 | 64 | 65 | # Отправка сообщения от текущего пользователя 66 | @router.post("/messages", response_model=MessageCreate) 67 | async def send_message(message: MessageCreate, current_user: User = Depends(get_current_user)): 68 | # Добавляем новое сообщение в базу данных 69 | await MessagesDAO.add( 70 | sender_id=current_user.id, 71 | content=message.content, 72 | recipient_id=message.recipient_id 73 | ) 74 | # Подготавливаем данные для отправки сообщения 75 | message_data = { 76 | 'sender_id': current_user.id, 77 | 'recipient_id': message.recipient_id, 78 | 'content': message.content, 79 | } 80 | # Уведомляем получателя и отправителя через WebSocket 81 | await notify_user(message.recipient_id, message_data) 82 | await notify_user(current_user.id, message_data) 83 | 84 | # Возвращаем подтверждение сохранения сообщения 85 | return {'recipient_id': message.recipient_id, 'content': message.content, 'status': 'ok', 'msg': 'Message saved!'} 86 | -------------------------------------------------------------------------------- /app/chat/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class MessageRead(BaseModel): 5 | id: int = Field(..., description="Уникальный идентификатор сообщения") 6 | sender_id: int = Field(..., description="ID отправителя сообщения") 7 | recipient_id: int = Field(..., description="ID получателя сообщения") 8 | content: str = Field(..., description="Содержимое сообщения") 9 | 10 | 11 | class MessageCreate(BaseModel): 12 | recipient_id: int = Field(..., description="ID получателя сообщения") 13 | content: str = Field(..., description="Содержимое сообщения") -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pydantic_settings import BaseSettings, SettingsConfigDict 3 | 4 | 5 | class Settings(BaseSettings): 6 | SECRET_KEY: str 7 | ALGORITHM: str 8 | model_config = SettingsConfigDict( 9 | env_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") 10 | ) 11 | 12 | 13 | settings = Settings() 14 | 15 | 16 | def get_auth_data(): 17 | return {"secret_key": settings.SECRET_KEY, "algorithm": settings.ALGORITHM} 18 | -------------------------------------------------------------------------------- /app/dao/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.exc import SQLAlchemyError 2 | from sqlalchemy.future import select 3 | from sqlalchemy import update as sqlalchemy_update, delete as sqlalchemy_delete, func 4 | from app.database import async_session_maker 5 | 6 | 7 | class BaseDAO: 8 | model = None 9 | 10 | @classmethod 11 | async def find_one_or_none_by_id(cls, data_id: int): 12 | """ 13 | Асинхронно находит и возвращает один экземпляр модели по указанным критериям или None. 14 | 15 | Аргументы: 16 | data_id: Критерии фильтрации в виде идентификатора записи. 17 | 18 | Возвращает: 19 | Экземпляр модели или None, если ничего не найдено. 20 | """ 21 | async with async_session_maker() as session: 22 | query = select(cls.model).filter_by(id=data_id) 23 | result = await session.execute(query) 24 | return result.scalar_one_or_none() 25 | 26 | @classmethod 27 | async def find_one_or_none(cls, **filter_by): 28 | """ 29 | Асинхронно находит и возвращает один экземпляр модели по указанным критериям или None. 30 | 31 | Аргументы: 32 | **filter_by: Критерии фильтрации в виде именованных параметров. 33 | 34 | Возвращает: 35 | Экземпляр модели или None, если ничего не найдено. 36 | """ 37 | async with async_session_maker() as session: 38 | query = select(cls.model).filter_by(**filter_by) 39 | result = await session.execute(query) 40 | return result.scalar_one_or_none() 41 | 42 | @classmethod 43 | async def find_all(cls, **filter_by): 44 | """ 45 | Асинхронно находит и возвращает все экземпляры модели, удовлетворяющие указанным критериям. 46 | 47 | Аргументы: 48 | **filter_by: Критерии фильтрации в виде именованных параметров. 49 | 50 | Возвращает: 51 | Список экземпляров модели. 52 | """ 53 | async with async_session_maker() as session: 54 | query = select(cls.model).filter_by(**filter_by) 55 | result = await session.execute(query) 56 | return result.scalars().all() 57 | 58 | @classmethod 59 | async def add(cls, **values): 60 | """ 61 | Асинхронно создает новый экземпляр модели с указанными значениями. 62 | 63 | Аргументы: 64 | **values: Именованные параметры для создания нового экземпляра модели. 65 | 66 | Возвращает: 67 | Созданный экземпляр модели. 68 | """ 69 | async with async_session_maker() as session: 70 | async with session.begin(): 71 | new_instance = cls.model(**values) 72 | session.add(new_instance) 73 | try: 74 | await session.commit() 75 | except SQLAlchemyError as e: 76 | await session.rollback() 77 | raise e 78 | return new_instance 79 | 80 | @classmethod 81 | async def add_many(cls, instances: list[dict]): 82 | """ 83 | Асинхронно создает несколько новых экземпляров модели с указанными значениями. 84 | 85 | Аргументы: 86 | instances: Список словарей, где каждый словарь содержит именованные параметры для создания нового 87 | экземпляра модели. 88 | 89 | Возвращает: 90 | Список созданных экземпляров модели. 91 | """ 92 | async with async_session_maker() as session: 93 | async with session.begin(): 94 | new_instances = [cls.model(**values) for values in instances] 95 | session.add_all(new_instances) 96 | try: 97 | await session.commit() 98 | except SQLAlchemyError as e: 99 | await session.rollback() 100 | raise e 101 | return new_instances 102 | 103 | @classmethod 104 | async def update(cls, filter_by, **values): 105 | """ 106 | Асинхронно обновляет экземпляры модели, удовлетворяющие критериям фильтрации, указанным в filter_by, 107 | новыми значениями, указанными в values. 108 | 109 | Аргументы: 110 | filter_by: Критерии фильтрации в виде именованных параметров. 111 | **values: Именованные параметры для обновления значений экземпляров модели. 112 | 113 | Возвращает: 114 | Количество обновленных экземпляров модели. 115 | """ 116 | async with async_session_maker() as session: 117 | async with session.begin(): 118 | query = ( 119 | sqlalchemy_update(cls.model) 120 | .where(*[getattr(cls.model, k) == v for k, v in filter_by.items()]) 121 | .values(**values) 122 | .execution_options(synchronize_session="fetch") 123 | ) 124 | result = await session.execute(query) 125 | try: 126 | await session.commit() 127 | except SQLAlchemyError as e: 128 | await session.rollback() 129 | raise e 130 | return result.rowcount 131 | 132 | @classmethod 133 | async def delete(cls, delete_all: bool = False, **filter_by): 134 | """ 135 | Асинхронно удаляет экземпляры модели, удовлетворяющие критериям фильтрации, указанным в filter_by. 136 | 137 | Аргументы: 138 | delete_all: Если True, удаляет все экземпляры модели без фильтрации. 139 | **filter_by: Критерии фильтрации в виде именованных параметров. 140 | 141 | Возвращает: 142 | Количество удаленных экземпляров модели. 143 | """ 144 | if delete_all is False: 145 | if not filter_by: 146 | raise ValueError("Необходимо указать хотя бы один параметр для удаления.") 147 | 148 | async with async_session_maker() as session: 149 | async with session.begin(): 150 | query = sqlalchemy_delete(cls.model).filter_by(**filter_by) 151 | result = await session.execute(query) 152 | try: 153 | await session.commit() 154 | except SQLAlchemyError as e: 155 | await session.rollback() 156 | raise e 157 | return result.rowcount 158 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import func 2 | from datetime import datetime 3 | from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase 4 | from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine, AsyncSession 5 | 6 | database_url = 'sqlite+aiosqlite:///db.sqlite3' 7 | engine = create_async_engine(url=database_url) 8 | async_session_maker = async_sessionmaker(engine, class_=AsyncSession) 9 | 10 | 11 | class Base(AsyncAttrs, DeclarativeBase): 12 | created_at: Mapped[datetime] = mapped_column(server_default=func.now()) 13 | updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) 14 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import status, HTTPException 2 | 3 | 4 | class TokenExpiredException(HTTPException): 5 | def __init__(self): 6 | super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Токен истек") 7 | 8 | 9 | class TokenNoFoundException(HTTPException): 10 | def __init__(self): 11 | super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Токен не найден") 12 | 13 | 14 | UserAlreadyExistsException = HTTPException(status_code=status.HTTP_409_CONFLICT, 15 | detail='Пользователь уже существует') 16 | 17 | PasswordMismatchException = HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Пароли не совпадают!') 18 | 19 | IncorrectEmailOrPasswordException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 20 | detail='Неверная почта или пароль') 21 | 22 | NoJwtException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 23 | detail='Токен не валидный!') 24 | 25 | NoUserIdException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 26 | detail='Не найден ID пользователя') 27 | 28 | ForbiddenException = HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Недостаточно прав!') 29 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from fastapi.responses import RedirectResponse 3 | from fastapi.exceptions import HTTPException 4 | from fastapi.middleware.cors import CORSMiddleware 5 | from fastapi.staticfiles import StaticFiles 6 | from app.exceptions import TokenExpiredException, TokenNoFoundException 7 | from app.users.router import router as users_router 8 | from app.chat.router import router as chat_router 9 | 10 | app = FastAPI() 11 | app.mount('/static', StaticFiles(directory='app/static'), name='static') 12 | 13 | app.add_middleware( 14 | CORSMiddleware, 15 | allow_origins=["*"], # Разрешить запросы с любых источников. Можете ограничить список доменов 16 | allow_credentials=True, 17 | allow_methods=["*"], # Разрешить все методы (GET, POST, PUT, DELETE и т.д.) 18 | allow_headers=["*"], # Разрешить все заголовки 19 | ) 20 | 21 | app.include_router(users_router) 22 | app.include_router(chat_router) 23 | 24 | 25 | @app.get("/") 26 | async def redirect_to_auth(): 27 | return RedirectResponse(url="/auth") 28 | 29 | 30 | @app.exception_handler(TokenExpiredException) 31 | async def token_expired_exception_handler(request: Request, exc: HTTPException): 32 | # Возвращаем редирект на страницу /auth 33 | return RedirectResponse(url="/auth") 34 | 35 | 36 | # Обработчик для TokenNoFound 37 | @app.exception_handler(TokenNoFoundException) 38 | async def token_no_found_exception_handler(request: Request, exc: HTTPException): 39 | # Возвращаем редирект на страницу /auth 40 | return RedirectResponse(url="/auth") 41 | -------------------------------------------------------------------------------- /app/migration/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /app/migration/env.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os.path import dirname, abspath 3 | 4 | sys.path.insert(0, dirname(dirname(abspath(__file__)))) 5 | 6 | import asyncio 7 | from logging.config import fileConfig 8 | from sqlalchemy import pool 9 | from sqlalchemy.engine import Connection 10 | from sqlalchemy.ext.asyncio import async_engine_from_config 11 | from alembic import context 12 | from app.database import Base, database_url 13 | from app.users.models import User 14 | from app.chat.models import Message 15 | 16 | config = context.config 17 | config.set_main_option("sqlalchemy.url", database_url) 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 20 | 21 | target_metadata = Base.metadata 22 | 23 | 24 | def run_migrations_offline() -> None: 25 | """Run migrations in 'offline' mode. 26 | 27 | This configures the context with just a URL 28 | and not an Engine, though an Engine is acceptable 29 | here as well. By skipping the Engine creation 30 | we don't even need a DBAPI to be available. 31 | 32 | Calls to context.execute() here emit the given string to the 33 | script output. 34 | 35 | """ 36 | url = config.get_main_option("sqlalchemy.url") 37 | context.configure( 38 | url=url, 39 | target_metadata=target_metadata, 40 | literal_binds=True, 41 | dialect_opts={"paramstyle": "named"}, 42 | ) 43 | 44 | with context.begin_transaction(): 45 | context.run_migrations() 46 | 47 | 48 | def do_run_migrations(connection: Connection) -> None: 49 | context.configure(connection=connection, target_metadata=target_metadata) 50 | 51 | with context.begin_transaction(): 52 | context.run_migrations() 53 | 54 | 55 | async def run_async_migrations() -> None: 56 | """In this scenario we need to create an Engine 57 | and associate a connection with the context. 58 | 59 | """ 60 | 61 | connectable = async_engine_from_config( 62 | config.get_section(config.config_ini_section, {}), 63 | prefix="sqlalchemy.", 64 | poolclass=pool.NullPool, 65 | ) 66 | 67 | async with connectable.connect() as connection: 68 | await connection.run_sync(do_run_migrations) 69 | 70 | await connectable.dispose() 71 | 72 | 73 | def run_migrations_online() -> None: 74 | """Run migrations in 'online' mode.""" 75 | 76 | asyncio.run(run_async_migrations()) 77 | 78 | 79 | if context.is_offline_mode(): 80 | run_migrations_offline() 81 | else: 82 | run_migrations_online() 83 | -------------------------------------------------------------------------------- /app/migration/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /app/migration/versions/6b2520ba62db_initial_revision.py: -------------------------------------------------------------------------------- 1 | """Initial revision 2 | 3 | Revision ID: 6b2520ba62db 4 | Revises: 5 | Create Date: 2024-09-28 21:36:35.087751 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '6b2520ba62db' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('users', 24 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 25 | sa.Column('name', sa.String(), nullable=False), 26 | sa.Column('hashed_password', sa.String(), nullable=False), 27 | sa.Column('email', sa.String(), nullable=False), 28 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 29 | sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | op.create_table('messages', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('sender_id', sa.Integer(), nullable=False), 35 | sa.Column('recipient_id', sa.Integer(), nullable=False), 36 | sa.Column('content', sa.Text(), nullable=False), 37 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 38 | sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 39 | sa.ForeignKeyConstraint(['recipient_id'], ['users.id'], ), 40 | sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ), 41 | sa.PrimaryKeyConstraint('id') 42 | ) 43 | op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade() -> None: 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_index(op.f('ix_messages_id'), table_name='messages') 50 | op.drop_table('messages') 51 | op.drop_table('users') 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /app/migration/versions/__pycache__/6b2520ba62db_initial_revision.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakvenalex/FastApiChat/7c2663048c26242c10d18fb8a65231f2bfd6778b/app/migration/versions/__pycache__/6b2520ba62db_initial_revision.cpython-312.pyc -------------------------------------------------------------------------------- /app/static/js/auth.js: -------------------------------------------------------------------------------- 1 | // Обработка кликов по вкладкам 2 | document.querySelectorAll('.tab').forEach(tab => { 3 | tab.addEventListener('click', () => showTab(tab.dataset.tab)); 4 | }); 5 | 6 | // Функция отображения выбранной вкладки 7 | function showTab(tabName) { 8 | document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); 9 | document.querySelectorAll('.form').forEach(form => form.classList.remove('active')); 10 | 11 | document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active'); 12 | document.getElementById(`${tabName}Form`).classList.add('active'); 13 | } 14 | 15 | // Функция для валидации данных формы 16 | const validateForm = fields => fields.every(field => field.trim() !== ''); 17 | 18 | // Функция для отправки запросов 19 | const sendRequest = async (url, data) => { 20 | try { 21 | const response = await fetch(url, { 22 | method: "POST", 23 | headers: {"Content-Type": "application/json"}, 24 | body: JSON.stringify(data) 25 | }); 26 | 27 | const result = await response.json(); 28 | 29 | if (response.ok) { 30 | alert(result.message || 'Операция выполнена успешно!'); 31 | return result; 32 | } else { 33 | alert(result.message || 'Ошибка выполнения запроса!'); 34 | return null; 35 | } 36 | } catch (error) { 37 | console.error("Ошибка:", error); 38 | alert('Произошла ошибка на сервере'); 39 | } 40 | }; 41 | 42 | // Функция для обработки формы 43 | const handleFormSubmit = async (formType, url, fields) => { 44 | if (!validateForm(fields)) { 45 | alert('Пожалуйста, заполните все поля.'); 46 | return; 47 | } 48 | 49 | const data = await sendRequest(url, formType === 'login' 50 | ? {email: fields[0], password: fields[1]} 51 | : {email: fields[0], name: fields[1], password: fields[2], password_check: fields[3]}); 52 | 53 | if (data && formType === 'login') { 54 | window.location.href = '/chat'; 55 | } 56 | }; 57 | 58 | // Обработка формы входа 59 | document.getElementById('loginButton').addEventListener('click', async (event) => { 60 | event.preventDefault(); 61 | 62 | const email = document.querySelector('#loginForm input[type="email"]').value; 63 | const password = document.querySelector('#loginForm input[type="password"]').value; 64 | 65 | await handleFormSubmit('login', 'login/', [email, password]); 66 | }); 67 | 68 | // Обработка формы регистрации 69 | document.getElementById('registerButton').addEventListener('click', async (event) => { 70 | event.preventDefault(); 71 | 72 | const email = document.querySelector('#registerForm input[type="email"]').value; 73 | const name = document.querySelector('#registerForm input[type="text"]').value; 74 | const password = document.querySelectorAll('#registerForm input[type="password"]')[0].value; 75 | const password_check = document.querySelectorAll('#registerForm input[type="password"]')[1].value; 76 | 77 | if (password !== password_check) { 78 | alert('Пароли не совпадают.'); 79 | return; 80 | } 81 | 82 | await handleFormSubmit('register', 'register/', [email, name, password, password_check]); 83 | }); 84 | -------------------------------------------------------------------------------- /app/static/js/chat.js: -------------------------------------------------------------------------------- 1 | // Сохраняем текущий выбранный userId и WebSocket соединение 2 | let selectedUserId = null; 3 | let socket = null; 4 | let messagePollingInterval = null; 5 | 6 | // Функция выхода из аккаунта 7 | async function logout() { 8 | try { 9 | const response = await fetch('/auth/logout', { 10 | method: 'POST', 11 | credentials: 'include' 12 | }); 13 | 14 | if (response.ok) { 15 | window.location.href = '/auth'; 16 | } else { 17 | console.error('Ошибка при выходе'); 18 | } 19 | } catch (error) { 20 | console.error('Ошибка при выполнении запроса:', error); 21 | } 22 | } 23 | 24 | // Функция выбора пользователя 25 | async function selectUser(userId, userName, event) { 26 | selectedUserId = userId; 27 | document.getElementById('chatHeader').innerHTML = `Чат с ${userName}`; 28 | document.getElementById('messageInput').disabled = false; 29 | document.getElementById('sendButton').disabled = false; 30 | 31 | document.querySelectorAll('.user-item').forEach(item => item.classList.remove('active')); 32 | event.target.classList.add('active'); 33 | 34 | const messagesContainer = document.getElementById('messages'); 35 | messagesContainer.innerHTML = ''; 36 | messagesContainer.style.display = 'block'; 37 | 38 | document.getElementById('logoutButton').onclick = logout; 39 | 40 | await loadMessages(userId); 41 | connectWebSocket(); 42 | startMessagePolling(userId); 43 | } 44 | 45 | // Загрузка сообщений 46 | async function loadMessages(userId) { 47 | try { 48 | const response = await fetch(`/chat/messages/${userId}`); 49 | const messages = await response.json(); 50 | 51 | const messagesContainer = document.getElementById('messages'); 52 | messagesContainer.innerHTML = messages.map(message => 53 | createMessageElement(message.content, message.recipient_id) 54 | ).join(''); 55 | } catch (error) { 56 | console.error('Ошибка загрузки сообщений:', error); 57 | } 58 | } 59 | 60 | // Подключение WebSocket 61 | function connectWebSocket() { 62 | if (socket) socket.close(); 63 | 64 | socket = new WebSocket(`wss://${window.location.host}/chat/ws/${selectedUserId}`); 65 | 66 | socket.onopen = () => console.log('WebSocket соединение установлено'); 67 | 68 | socket.onmessage = (event) => { 69 | const incomingMessage = JSON.parse(event.data); 70 | if (incomingMessage.recipient_id === selectedUserId) { 71 | addMessage(incomingMessage.content, incomingMessage.recipient_id); 72 | } 73 | }; 74 | 75 | socket.onclose = () => console.log('WebSocket соединение закрыто'); 76 | } 77 | 78 | // Отправка сообщения 79 | async function sendMessage() { 80 | const messageInput = document.getElementById('messageInput'); 81 | const message = messageInput.value.trim(); 82 | 83 | if (message && selectedUserId) { 84 | const payload = {recipient_id: selectedUserId, content: message}; 85 | 86 | try { 87 | await fetch('/chat/messages', { 88 | method: 'POST', 89 | headers: {'Content-Type': 'application/json'}, 90 | body: JSON.stringify(payload) 91 | }); 92 | 93 | socket.send(JSON.stringify(payload)); 94 | addMessage(message, selectedUserId); 95 | messageInput.value = ''; 96 | } catch (error) { 97 | console.error('Ошибка при отправке сообщения:', error); 98 | } 99 | } 100 | } 101 | 102 | // Добавление сообщения в чат 103 | function addMessage(text, recipient_id) { 104 | const messagesContainer = document.getElementById('messages'); 105 | messagesContainer.insertAdjacentHTML('beforeend', createMessageElement(text, recipient_id)); 106 | messagesContainer.scrollTop = messagesContainer.scrollHeight; 107 | } 108 | 109 | // Создание HTML элемента сообщения 110 | function createMessageElement(text, recipient_id) { 111 | const userID = parseInt(selectedUserId, 10); 112 | const messageClass = userID === recipient_id ? 'my-message' : 'other-message'; 113 | return `
${text}
`; 114 | } 115 | 116 | // Запуск опроса новых сообщений 117 | function startMessagePolling(userId) { 118 | clearInterval(messagePollingInterval); 119 | messagePollingInterval = setInterval(() => loadMessages(userId), 1000); 120 | } 121 | 122 | // Обработка нажатий на пользователя 123 | function addUserClickListeners() { 124 | document.querySelectorAll('.user-item').forEach(item => { 125 | item.onclick = event => selectUser(item.getAttribute('data-user-id'), item.textContent, event); 126 | }); 127 | } 128 | 129 | // Первоначальная настройка событий нажатия на пользователей 130 | addUserClickListeners(); 131 | 132 | // Обновление списка пользователей 133 | async function fetchUsers() { 134 | try { 135 | const response = await fetch('/auth/users'); 136 | const users = await response.json(); 137 | const userList = document.getElementById('userList'); 138 | 139 | // Очищаем текущий список пользователей 140 | userList.innerHTML = ''; 141 | 142 | // Создаем элемент "Избранное" для текущего пользователя 143 | const favoriteElement = document.createElement('div'); 144 | favoriteElement.classList.add('user-item'); 145 | favoriteElement.setAttribute('data-user-id', currentUserId); 146 | favoriteElement.textContent = 'Избранное'; 147 | 148 | // Добавляем "Избранное" в начало списка 149 | userList.appendChild(favoriteElement); 150 | 151 | // Генерация списка остальных пользователей 152 | users.forEach(user => { 153 | if (user.id !== currentUserId) { 154 | const userElement = document.createElement('div'); 155 | userElement.classList.add('user-item'); 156 | userElement.setAttribute('data-user-id', user.id); 157 | userElement.textContent = user.name; 158 | userList.appendChild(userElement); 159 | } 160 | }); 161 | 162 | // Повторно добавляем обработчики событий для каждого пользователя 163 | addUserClickListeners(); 164 | } catch (error) { 165 | console.error('Ошибка при загрузке списка пользователей:', error); 166 | } 167 | } 168 | 169 | 170 | document.addEventListener('DOMContentLoaded', fetchUsers); 171 | setInterval(fetchUsers, 10000); // Обновление каждые 10 секунд 172 | 173 | // Обработчики для кнопки отправки и ввода сообщения 174 | document.getElementById('sendButton').onclick = sendMessage; 175 | 176 | document.getElementById('messageInput').onkeypress = async (e) => { 177 | if (e.key === 'Enter') { 178 | await sendMessage(); 179 | } 180 | }; 181 | -------------------------------------------------------------------------------- /app/static/styles/auth.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | background-color: #f0f0f0; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | height: 100vh; 8 | margin: 0; 9 | } 10 | 11 | .container { 12 | background-color: white; 13 | border-radius: 8px; 14 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 15 | overflow: hidden; 16 | width: 300px; 17 | } 18 | 19 | .tabs { 20 | display: flex; 21 | } 22 | 23 | .tab { 24 | flex: 1; 25 | text-align: center; 26 | padding: 10px; 27 | background-color: #f0f0f0; 28 | cursor: pointer; 29 | transition: background-color 0.3s; 30 | } 31 | 32 | .tab.active { 33 | background-color: white; 34 | font-weight: bold; 35 | } 36 | 37 | .content { 38 | padding: 20px; 39 | } 40 | 41 | .form { 42 | display: none; 43 | } 44 | 45 | .form.active { 46 | display: block; 47 | } 48 | 49 | input { 50 | width: 100%; 51 | padding: 10px; 52 | margin-bottom: 10px; 53 | border: 1px solid #ddd; 54 | border-radius: 4px; 55 | box-sizing: border-box; 56 | } 57 | 58 | button { 59 | width: 100%; 60 | padding: 10px; 61 | background-color: #007bff; 62 | color: white; 63 | border: none; 64 | border-radius: 4px; 65 | cursor: pointer; 66 | transition: background-color 0.3s; 67 | } 68 | 69 | button:hover { 70 | background-color: #0056b3; 71 | } 72 | -------------------------------------------------------------------------------- /app/static/styles/chat.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | font-family: Arial, sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | height: 100%; 6 | background-color: #f0f0f0; 7 | } 8 | 9 | .chat-container { 10 | display: flex; 11 | height: 100vh; 12 | max-width: 1200px; 13 | margin: 0 auto; 14 | background-color: white; 15 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 16 | } 17 | 18 | .user-list { 19 | width: 30%; 20 | background-color: #f8f8f8; 21 | border-right: 1px solid #ddd; 22 | overflow-y: auto; 23 | } 24 | 25 | .user-item { 26 | padding: 15px; 27 | border-bottom: 1px solid #eee; 28 | cursor: pointer; 29 | transition: background-color 0.3s; 30 | } 31 | 32 | .user-item:hover, .user-item.active { 33 | background-color: #e6e6e6; 34 | } 35 | 36 | .chat-area { 37 | flex: 1; 38 | display: flex; 39 | flex-direction: column; 40 | } 41 | 42 | .chat-header { 43 | padding: 15px; 44 | background-color: #007bff; 45 | color: white; 46 | font-weight: bold; 47 | display: flex; 48 | justify-content: space-between; 49 | align-items: center; 50 | } 51 | 52 | .logout-button { 53 | background-color: #dc3545; 54 | color: white; 55 | border: none; 56 | padding: 8px 15px; 57 | border-radius: 4px; 58 | cursor: pointer; 59 | transition: background-color 0.3s; 60 | } 61 | 62 | .logout-button:hover { 63 | background-color: #c82333; 64 | } 65 | 66 | .messages { 67 | flex: 1; 68 | overflow-y: auto; 69 | padding: 15px; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | } 74 | 75 | .message { 76 | margin-bottom: 10px; 77 | padding: 10px; 78 | border-radius: 5px; 79 | max-width: 70%; 80 | } 81 | 82 | .message.sent { 83 | background-color: #dcf8c6; 84 | align-self: flex-end; 85 | margin-left: auto; 86 | } 87 | 88 | .message.received { 89 | background-color: #f2f2f2; 90 | } 91 | 92 | .input-area { 93 | display: flex; 94 | padding: 15px; 95 | border-top: 1px solid #ddd; 96 | } 97 | 98 | .input-area input { 99 | flex: 1; 100 | padding: 10px; 101 | border: 1px solid #ddd; 102 | border-radius: 4px; 103 | margin-right: 10px; 104 | } 105 | 106 | .input-area button { 107 | padding: 10px 20px; 108 | background-color: #007bff; 109 | color: white; 110 | border: none; 111 | border-radius: 4px; 112 | cursor: pointer; 113 | transition: background-color 0.3s; 114 | } 115 | 116 | .input-area button:hover { 117 | background-color: #0056b3; 118 | } 119 | 120 | .welcome-message { 121 | font-size: 24px; 122 | color: #007bff; 123 | text-align: center; 124 | animation: bounce 2s infinite; 125 | } 126 | 127 | .my-message { 128 | background-color: #e0ffe0; /* Зеленый фон для ваших сообщений */ 129 | text-align: right; /* Выравнивание текста по правому краю */ 130 | } 131 | 132 | .other-message { 133 | background-color: #f0f0f0; /* Серый фон для сообщений других пользователей */ 134 | text-align: left; /* Выравнивание текста по левому краю */ 135 | } 136 | 137 | @keyframes bounce { 138 | 0%, 20%, 50%, 80%, 100% { 139 | transform: translateY(0); 140 | } 141 | 40% { 142 | transform: translateY(-30px); 143 | } 144 | 60% { 145 | transform: translateY(-15px); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /app/templates/auth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Мини-чат: Вход и Регистрация 6 | 7 | 8 | 9 | 10 |
11 |
12 |
Авторизация
13 |
Регистрация
14 |
15 |
16 |
17 | 18 | 19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/templates/chat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Мини-чат 7 | 8 | 9 | 10 |
11 |
12 | 13 |
14 | Избранное 15 |
16 | 17 | {% for chat in users_all %} 18 | {% if chat.id != user.id %} 19 |
20 | {{ chat.name }} 21 |
22 | {% endif %} 23 | {% endfor %} 24 |
25 |
26 |
27 | Мини-чат 28 | 29 |
30 |
31 |
Выберите чат для общения
32 |
33 |
34 | 35 | 36 |
37 |
38 |
39 | 40 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/users/auth.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | from pydantic import EmailStr 3 | from jose import jwt 4 | from datetime import datetime, timedelta, timezone 5 | from app.config import get_auth_data 6 | from app.users.dao import UsersDAO 7 | 8 | 9 | def create_access_token(data: dict) -> str: 10 | to_encode = data.copy() 11 | expire = datetime.now(timezone.utc) + timedelta(days=366) 12 | to_encode.update({"exp": expire}) 13 | auth_data = get_auth_data() 14 | encode_jwt = jwt.encode(to_encode, auth_data['secret_key'], algorithm=auth_data['algorithm']) 15 | return encode_jwt 16 | 17 | 18 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 19 | 20 | 21 | def get_password_hash(password: str) -> str: 22 | return pwd_context.hash(password) 23 | 24 | 25 | def verify_password(plain_password: str, hashed_password: str) -> bool: 26 | return pwd_context.verify(plain_password, hashed_password) 27 | 28 | 29 | async def authenticate_user(email: EmailStr, password: str): 30 | user = await UsersDAO.find_one_or_none(email=email) 31 | if not user or verify_password(plain_password=password, hashed_password=user.hashed_password) is False: 32 | return None 33 | return user 34 | -------------------------------------------------------------------------------- /app/users/dao.py: -------------------------------------------------------------------------------- 1 | from app.dao.base import BaseDAO 2 | from app.users.models import User 3 | 4 | 5 | class UsersDAO(BaseDAO): 6 | model = User 7 | -------------------------------------------------------------------------------- /app/users/dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request, HTTPException, status, Depends 2 | from jose import jwt, JWTError 3 | from datetime import datetime, timezone 4 | from app.config import get_auth_data 5 | from app.exceptions import TokenExpiredException, NoJwtException, NoUserIdException, TokenNoFoundException 6 | from app.users.dao import UsersDAO 7 | 8 | 9 | def get_token(request: Request): 10 | token = request.cookies.get('users_access_token') 11 | if not token: 12 | raise TokenNoFoundException 13 | return token 14 | 15 | 16 | async def get_current_user(token: str = Depends(get_token)): 17 | try: 18 | auth_data = get_auth_data() 19 | payload = jwt.decode(token, auth_data['secret_key'], algorithms=auth_data['algorithm']) 20 | except JWTError: 21 | raise NoJwtException 22 | 23 | expire: str = payload.get('exp') 24 | expire_time = datetime.fromtimestamp(int(expire), tz=timezone.utc) 25 | if (not expire) or (expire_time < datetime.now(timezone.utc)): 26 | raise TokenExpiredException 27 | 28 | user_id: str = payload.get('sub') 29 | if not user_id: 30 | raise NoUserIdException 31 | 32 | user = await UsersDAO.find_one_or_none_by_id(int(user_id)) 33 | if not user: 34 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='User not found') 35 | return user 36 | -------------------------------------------------------------------------------- /app/users/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String, Integer 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | from app.database import Base 4 | 5 | 6 | class User(Base): 7 | __tablename__ = 'users' 8 | 9 | id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 10 | name: Mapped[str] = mapped_column(String, nullable=False) 11 | hashed_password: Mapped[str] = mapped_column(String, nullable=False) 12 | email: Mapped[str] = mapped_column(String, nullable=False) 13 | -------------------------------------------------------------------------------- /app/users/router.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Response 4 | from fastapi.requests import Request 5 | from fastapi.responses import HTMLResponse 6 | from fastapi.templating import Jinja2Templates 7 | from app.exceptions import UserAlreadyExistsException, IncorrectEmailOrPasswordException, PasswordMismatchException 8 | from app.users.auth import get_password_hash, authenticate_user, create_access_token 9 | from app.users.dao import UsersDAO 10 | from app.users.schemas import SUserRegister, SUserAuth, SUserRead 11 | 12 | router = APIRouter(prefix='/auth', tags=['Auth']) 13 | 14 | templates = Jinja2Templates(directory='app/templates') 15 | 16 | 17 | @router.get("/users", response_model=List[SUserRead]) 18 | async def get_users(): 19 | users_all = await UsersDAO.find_all() 20 | # Используем генераторное выражение для создания списка 21 | return [{'id': user.id, 'name': user.name} for user in users_all] 22 | 23 | 24 | @router.get("/", response_class=HTMLResponse, summary="Страница авторизации") 25 | async def get_categories(request: Request): 26 | return templates.TemplateResponse("auth.html", {"request": request}) 27 | 28 | 29 | @router.post("/register/") 30 | async def register_user(user_data: SUserRegister) -> dict: 31 | user = await UsersDAO.find_one_or_none(email=user_data.email) 32 | if user: 33 | raise UserAlreadyExistsException 34 | 35 | if user_data.password != user_data.password_check: 36 | raise PasswordMismatchException("Пароли не совпадают") 37 | hashed_password = get_password_hash(user_data.password) 38 | await UsersDAO.add( 39 | name=user_data.name, 40 | email=user_data.email, 41 | hashed_password=hashed_password 42 | ) 43 | 44 | return {'message': 'Вы успешно зарегистрированы!'} 45 | 46 | 47 | @router.post("/login/") 48 | async def auth_user(response: Response, user_data: SUserAuth): 49 | check = await authenticate_user(email=user_data.email, password=user_data.password) 50 | if check is None: 51 | raise IncorrectEmailOrPasswordException 52 | access_token = create_access_token({"sub": str(check.id)}) 53 | response.set_cookie(key="users_access_token", value=access_token, httponly=True) 54 | return {'ok': True, 'access_token': access_token, 'refresh_token': None, 'message': 'Авторизация успешна!'} 55 | 56 | 57 | @router.post("/logout/") 58 | async def logout_user(response: Response): 59 | response.delete_cookie(key="users_access_token") 60 | return {'message': 'Пользователь успешно вышел из системы'} 61 | -------------------------------------------------------------------------------- /app/users/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr, Field 2 | 3 | 4 | class SUserRegister(BaseModel): 5 | email: EmailStr = Field(..., description="Электронная почта") 6 | password: str = Field(..., min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков") 7 | password_check: str = Field(..., min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков") 8 | name: str = Field(..., min_length=3, max_length=50, description="Имя, от 3 до 50 символов") 9 | 10 | 11 | class SUserAuth(BaseModel): 12 | email: EmailStr = Field(..., description="Электронная почта") 13 | password: str = Field(..., min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков") 14 | 15 | 16 | class SUserRead(BaseModel): 17 | id: int = Field(..., description="Идентификатор пользователя") 18 | name: str = Field(..., min_length=3, max_length=50, description="Имя, от 3 до 50 символов") 19 | -------------------------------------------------------------------------------- /db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakvenalex/FastApiChat/7c2663048c26242c10d18fb8a65231f2bfd6778b/db.sqlite3 -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakvenalex/FastApiChat/7c2663048c26242c10d18fb8a65231f2bfd6778b/demo.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.115.0 2 | uvicorn==0.31.0 3 | sqlalchemy==2.0.35 4 | alembic==1.13.3 5 | pydantic[email]==2.9.2 6 | bcrypt==4.0.1 7 | passlib==1.7.4 8 | python-jose==3.3.0 9 | websockets==13.1 10 | aiosqlite==0.20.0 11 | pydantic_settings==2.5.2 12 | jinja2==3.1.4 13 | --------------------------------------------------------------------------------