├── .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 `