nspotapov/add-frontend (#2)

Reviewed-on: /ns.potapov/piaps-course-work-university/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2025-06-10 10:44:51 +04:00
parent 8a90e953e0
commit 57ebddee03
63 changed files with 12360 additions and 2211 deletions

View File

@@ -0,0 +1,16 @@
# .env
MYSQL_DATABASE=app
MYSQL_USER=appuser
MYSQL_PASSWORD=secret
MYSQL_ROOT_PASSWORD=supersecret
DB_HOST=db
DB_PORT=3306
DB_DRIVER=aiomysql
DB_NAME=app
DB_USER=appuser
DB_PASSWORD=secret
SECRET_KEY=supersecretkey
ALGORITHM=HS256

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

20
Makefile Normal file
View File

@@ -0,0 +1,20 @@
up:
docker compose up -d
down:
docker compose down
remove:
docker compose down -v
restart:
docker compose restart
logs:
docker compose logs
monitor:
docker compose logs -f backend
build:
docker compose build

90
backend/.dockerignore Normal file
View File

@@ -0,0 +1,90 @@
# Git
.git
.gitignore
.gitattributes
# CI
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
docker-compose.y[a]ml
compose.y[a]ml
Dockerfile
.docker
.dockerignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
.env
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/

9
backend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
DB_HOST=db
DB_PORT=3306
DB_DRIVER=mysql+aiomysql
DB_NAME=app
DB_USER=appuser
DB_PASSWORD=secret
SECRET_KEY=gV64m9aIzFG4qpgVphvQbPQrtAO0nM-7YwwOvu0XPt5KJOjAy4AfgLkqJXYEt
ALGORITHM=HS256

View File

@@ -1,2 +1,2 @@
[flake8]
max-line-length = 101
max-line-length=110

20
backend/.gitignore vendored
View File

@@ -106,6 +106,7 @@ ipython_config.py
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
@@ -167,15 +168,28 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
.cursorindexingignore

View File

@@ -1,24 +1,8 @@
FROM python:3.12-slim
FROM python:3.12.3-slim
WORKDIR /app
# Установка зависимостей для MySQL
RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Установка Poetry
RUN pip install poetry
# Копируем зависимости
COPY pyproject.toml poetry.lock* ./
# Устанавливаем зависимости
RUN poetry config virtualenvs.create false && \
poetry install --no-root --no-interaction --no-ansi
# Копируем остальные файлы
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ENTRYPOINT ["/app/entrypoint.sh"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

11
backend/Makefile Normal file
View File

@@ -0,0 +1,11 @@
up:
docker compose up -d
down:
docker compose down
remove:
docker compose down -v
dev:
uvicorn app.main:app --reload

View File

@@ -1 +1,216 @@
# Бекенд
# Шаблон приложения FastAPI с аутентификацией и авторизацией
Этот проект представляет собой готовый шаблон для разработки масштабируемых веб-приложений на основе **FastAPI** с
полноценной системой аутентификации и авторизации. Проект включает модульную архитектуру, поддерживает гибкое
логирование с **loguru**, и взаимодействие с базой данных через **SQLAlchemy** с асинхронной поддержкой. Система
миграций **Alembic** упрощает работу со схемой базы данных.
## Стек технологий
- **Веб-фреймворк**: FastAPI
- **ORM**: SQLAlchemy с асинхронной поддержкой через aiosqlite
- **База данных**: SQLite (легко заменяемая на другую SQL-СУБД)
- **Система миграций**: Alembic
- **Авторизация/Аутентификация**: bcrypt для хеширования паролей, python-jose для защиты данных с использованием JWT
## Зависимости проекта
- `fastapi[all]==0.115.0` - высокопроизводительный веб-фреймворк
- `pydantic==2.9.2` - валидация данных
- `uvicorn==0.31.0` - ASGI-сервер
- `jinja2==3.1.4` - шаблонизатор
- `SQLAlchemy==2.0.35` - ORM для работы с базами данных
- `aiosqlite==0.20.0` - асинхронная поддержка SQLite
- `alembic==1.13.3` - управление миграциями базы данных
- `bcrypt==4.0.1` и `passlib[bcrypt]==1.7.4` - хеширование паролей
- `python-jose==3.3.0` - работа с JWT токенами
- `loguru==0.7.2` - красивое и удобное логирование
## Структура проекта
Проект построен с учётом модульной архитектуры, что позволяет легко расширять приложение и упрощает его поддержку.
Каждый модуль отвечает за отдельные задачи, такие как авторизация или управление данными.
### Основная структура проекта
```
├── app/
│ ├── auth/ # Модуль авторизации и аутентификации
│ │ ├── dao.py # Data Access Object для работы с БД
│ │ ├── models.py # Модели данных для авторизации
│ │ ├── router.py # Роутеры FastAPI для маршрутизации
│ │ ├── schemas.py # Схемы для валидации данных
│ │ └── utils.py # Вспомогательные функции для авторизации
│ ├── dao/ # Общие DAO для приложения
│ │ ├── database.py # Подключение к базе данных и управление сессиями
│ │ └── base.py # Базовый класс DAO для работы с БД
│ ├── dependencies # Зависимости в проекте
│ │ ├── auth_dep.py # Зависимости для авторизации
│ │ └── dao_dep.py # Зависимости для сессий SQLAlchemy
│ ├── migration/ # Миграции базы данных
│ │ ├── versions/ # Файлы миграций
│ │ ├── env.py # Настройки среды для Alembic
│ │ ├── README # Документация по миграциям
│ │ └── script.py.mako # Шаблон для генерации миграций
│ ├── static/ # Статические файлы приложения
│ │ └── .gitkeep # Пустой файл для сохранения папки в Git
│ ├── config.py # Конфигурация приложения
│ ├── exceptions.py # Исключения для обработки ошибок
│ ├── main.py # Основной файл для запуска приложения
├── data/ # Папка для хранения файла БД
│ └── db.sqlite3 # Файл базы данных SQLite
├── .env # Конфигурация окружения
├── alembic.ini # Конфигурация Alembic
├── README.md # Документация проекта
└── requirements.txt # Зависимости проекта
```
Обновленный раздел с подробным описанием основных модулей:
---
### Основные модули
#### **app/auth** - Модуль для аутентификации и авторизации
Модуль отвечает за управление процессами аутентификации (входа пользователей) и авторизации (проверки доступа).
Основные файлы:
- **`dao.py`**: Объект доступа к данным пользователей. Содержит методы для работы с базой данных (создание, обновление,
поиск пользователей и т. д.).
- **`dependencies.py`**: Внедрение зависимостей, таких как проверка токенов и авторизация для защищённых маршрутов.
- **`models.py`**: Определяет ORM-модели данных для пользователей (например, таблица Users в базе данных).
- **`router.py`**: Роутер для маршрутизации запросов, связанных с аутентификацией. Определяет эндпоинты для входа,
регистрации и проверки доступа.
- **`schemas.py`**: Определяет Pydantic-схемы для валидации входных данных и структуры ответов (например, формат данных
для регистрации пользователей).
- **`utils.py`**: Вспомогательные функции для работы с токенами (создание, проверка JWT) и шифрование паролей.
---
#### **app/dao** - Базовый слой доступа к данным (Data Access Layer)
Модуль содержит абстракции для работы с базой данных. Используется для управления подключениями и реализации
CRUD-операций.
- **`base.py`**: Базовый класс DAO, предоставляющий общие методы для работы с базой данных, такие как добавление,
обновление, удаление и поиск записей.
- **`database.py`**: Отвечает за подключение к базе данных, управление сессиями SQLAlchemy, а также создание
асинхронного подключения (например, через `aiosqlite`).
---
#### **app/migration** - Управление миграциями базы данных с Alembic
Модуль упрощает управление схемой базы данных и позволяет безопасно вносить изменения.
- **`versions/`**: Хранятся файлы миграций, автоматически создаваемые Alembic.
- **`env.py`**: Основной файл конфигурации для Alembic. Определяет подключение к базе данных и взаимодействие с ORM.
- **`script.py.mako`**: Шаблон для генерации новых файлов миграций.
---
#### **app/dependencies** - Управление миграциями базы данных с Alembic
Модуль содержит зависимости, которые используются в проекте.
- **`auth_dep.py`**: Зависимости, связанные с авторизацией пользователя в системе
- **`dao_dep.py`**: Зависимости, связанные с управллением сессией SQLAlchemy и с работой с дочерними классами BaseDao
---
#### **config.py** - Настройки и конфигурация приложения
- Определяет параметры приложения, загружаемые из файла `.env`. Например:
- `SECRET_KEY`: Секретный ключ для подписания JWT.
- `ALGORITHM`: Алгоритм хеширования токенов.
- `DATABASE_URL`: URL для подключения к базе данных.
- Обеспечивает удобное управление конфигурацией для разных окружений (локальное, тестовое, продакшн).
---
#### **main.py** - Основной файл для запуска приложения
- **Инициализация приложения**: Настраивает FastAPI-приложение, включая параметры, такие как название, версия, и
описание.
- **Подключение роутеров**: Регистрирует маршруты, определённые в модулях приложения, например:
- `app.auth.router` для маршрутов авторизации.
- Любые дополнительные модули (например, `app.users.router`).
- **Настройка зависимостей**: Внедряет глобальные зависимости, такие как подключение к базе данных или параметры
конфигурации.
- **Настройка middleware**: Добавляет промежуточные слои для обработки запросов (например, CORS, сжатие, обработка
ошибок).
- **Обработка ошибок**: Определяет глобальные обработчики исключений, чтобы возвращать понятные ответы при возникновении
ошибок (например, 401 Unauthorized или 500 Internal Server Error).
- **Запуск сервера**: Используется для старта приложения с помощью ASGI-сервера (Uvicorn).
## Настройка аутентификации и авторизации
Для аутентификации используется JSON Web Token (JWT) с bcrypt для хеширования паролей и python-jose для генерации и
проверки токенов. Это обеспечивает безопасное хранение данных и защищает API-эндпоинты.
## Запуск приложения
1. Клонируйте репозиторий:
```bash
git clone https://github.com/Yakvenalex/FastApiWithAuthSample.git .
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Создайте и настройте `.env` файл:
```env
SECRET_KEY=supersecretkey
ALGORITHM=HS256
```
4. Запустите приложение с Uvicorn:
```bash
uvicorn app.main:app --reload --port 8005
```
При необходимости замените port на нужный.
## Миграции базы данных
1. Инициализируйте Alembic:
```bash
cd app
alembic init -t async migration
```
Затем переместите `alembic.ini` в корень проекта.
2. В `alembic.ini` установите `script_location` как `app/migration`.
3. Создайте миграцию:
```bash
alembic revision --autogenerate -m "Initial migration"
```
4. Примените миграции:
```bash
alembic upgrade head
```
## Лучшие практики
- Разделяйте функциональность приложения на модули для удобства тестирования и поддержки.
- Обрабатывайте ошибки с четкими ответами и HTTP-кодами.
- Проводите миграции с Alembic для управления схемой базы данных.
- Используйте переменные окружения для безопасного хранения конфиденциальных данных.
---
Этот шаблон является мощной и удобной основой для разработки приложений на FastAPI с поддержкой аутентификации,
авторизации и структурированной архитектуры, готовой к масштабированию.

View File

@@ -1,8 +1,84 @@
[alembic]
script_location = alembic
; sqlalchemy.url = mysql+pymysql://myappuser:mypassword@db/myapp
sqlalchemy.url = mysql+pymysql://myappuser:mypassword@localhost/myapp
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = app/migration
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migration/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migration/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
@@ -35,4 +111,4 @@ formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
datefmt = %H:%M:%S

View File

@@ -1 +0,0 @@
Generic single-database configuration.

View File

@@ -1,52 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Добавляем путь к проекту
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.models import Base
from app.config import settings
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,46 +0,0 @@
"""create users
Revision ID: 40906c8e083f
Revises:
Create Date: 2025-05-18 18:11:23.585211
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '40906c8e083f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('username', sa.String(length=50), nullable=True),
sa.Column('hashed_password', sa.String(length=255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

6
backend/app/auth/dao.py Normal file
View File

@@ -0,0 +1,6 @@
from app.dao.base import BaseDAO
from app.auth.models import User
class UsersDAO(BaseDAO):
model = User

View File

@@ -0,0 +1,2 @@
# flake8: noqa
from .user import User

View File

@@ -0,0 +1,13 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.dao.database import Base
class User(Base):
first_name: Mapped[str] = mapped_column(String(50))
last_name: Mapped[str] = mapped_column(String(50))
email: Mapped[str] = mapped_column(String(50), unique=True)
password: Mapped[str] = mapped_column(String(256))
def __repr__(self):
return f"{self.__class__.__name__}(id={self.id})"

View File

@@ -0,0 +1,74 @@
from typing import List
from fastapi import APIRouter, Response, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.models import User
from app.auth.utils import authenticate_user, set_tokens
from app.dependencies.auth_dep import (
get_current_user,
check_refresh_token,
)
from app.dependencies.dao_dep import get_session_with_commit, get_session_without_commit
from app.exceptions import UserAlreadyExistsException, IncorrectEmailOrPasswordException
from app.auth.dao import UsersDAO
from app.auth.schemas import SUserRegister, SUserAuth, EmailModel, SUserAddDB, SUserInfo
router = APIRouter()
@router.post("/register/")
async def register_user(
user_data: SUserRegister, session: AsyncSession = Depends(get_session_with_commit)
) -> dict:
# Проверка существования пользователя
user_dao = UsersDAO(session)
existing_user = await user_dao.find_one_or_none(
filters=EmailModel(email=user_data.email)
)
if existing_user:
raise UserAlreadyExistsException
# Подготовка данных для добавления
user_data_dict = user_data.model_dump()
user_data_dict.pop("confirm_password", None)
# Добавление пользователя
await user_dao.add(values=SUserAddDB(**user_data_dict))
return {"message": "Вы успешно зарегистрированы!"}
@router.post("/login/")
async def auth_user(
response: Response,
user_data: SUserAuth,
session: AsyncSession = Depends(get_session_without_commit),
) -> dict:
users_dao = UsersDAO(session)
user = await users_dao.find_one_or_none(filters=EmailModel(email=user_data.email))
if not (user and await authenticate_user(user=user, password=user_data.password)):
raise IncorrectEmailOrPasswordException
set_tokens(response, user.id)
return {"ok": True, "message": "Авторизация успешна!"}
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie("user_access_token")
response.delete_cookie("user_refresh_token")
return {"message": "Пользователь успешно вышел из системы"}
@router.get("/me/")
async def get_me(user_data: User = Depends(get_current_user)) -> SUserInfo:
return SUserInfo.model_validate(user_data)
@router.post("/refresh")
async def process_refresh_token(
response: Response, user: User = Depends(check_refresh_token)
):
set_tokens(response, user.id)
return {"message": "Токены успешно обновлены"}

View File

@@ -0,0 +1,55 @@
from typing import Self
from pydantic import (
BaseModel,
ConfigDict,
EmailStr,
Field,
model_validator,
)
from app.auth.utils import get_password_hash
class EmailModel(BaseModel):
email: EmailStr = Field(description="Электронная почта")
model_config = ConfigDict(from_attributes=True)
class UserBase(EmailModel):
first_name: str = Field(
min_length=3, max_length=50, description="Имя, от 3 до 50 символов"
)
last_name: str = Field(
min_length=3, max_length=50, description="Фамилия, от 3 до 50 символов"
)
class SUserRegister(UserBase):
password: str = Field(
min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков"
)
confirm_password: str = Field(
min_length=5, max_length=50, description="Повторите пароль"
)
@model_validator(mode="after")
def check_password(self) -> Self:
if self.password != self.confirm_password:
raise ValueError("Пароли не совпадают")
self.password = get_password_hash(
self.password
) # хешируем пароль до сохранения в базе данных
return self
class SUserAddDB(UserBase):
password: str = Field(min_length=5, description="Пароль в формате HASH-строки")
class SUserAuth(EmailModel):
password: str = Field(
min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков"
)
class SUserInfo(UserBase):
id: int = Field(description="Идентификатор пользователя")

70
backend/app/auth/utils.py Normal file
View File

@@ -0,0 +1,70 @@
from passlib.context import CryptContext
from jose import jwt
from datetime import datetime, timedelta, timezone
from fastapi.responses import Response
from app.config import settings
def create_tokens(data: dict) -> dict:
# Текущее время в UTC
now = datetime.now(timezone.utc)
# AccessToken - 30 минут
access_expire = now + timedelta(minutes=30)
access_payload = data.copy()
access_payload.update({"exp": int(access_expire.timestamp()), "type": "access"})
access_token = jwt.encode(
access_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
# RefreshToken - 7 дней
refresh_expire = now + timedelta(days=7)
refresh_payload = data.copy()
refresh_payload.update({"exp": int(refresh_expire.timestamp()), "type": "refresh"})
refresh_token = jwt.encode(
refresh_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return {"access_token": access_token, "refresh_token": refresh_token}
async def authenticate_user(user, password):
if (
not user
or verify_password(plain_password=password, hashed_password=user.password)
is False
):
return None
return user
def set_tokens(response: Response, user_id: int):
new_tokens = create_tokens(data={"sub": str(user_id)})
access_token = new_tokens.get("access_token")
refresh_token = new_tokens.get("refresh_token")
response.set_cookie(
key="user_access_token",
value=access_token,
httponly=True,
secure=True,
samesite="lax",
)
response.set_cookie(
key="user_refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="lax",
)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)

View File

@@ -1,14 +1,19 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "mysql+pymysql://myappuser:mypassword@localhost/myapp"
secret_key: str = "your-secret-key"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
class Config:
env_file = ".env"
BASE_DIR: str = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
DB_HOST: str = os.getenv("DB_HOST")
DB_PORT: str = os.getenv("DB_PORT")
DB_DRIVER: str = os.getenv("DB_DRIVER")
DB_NAME: str = os.getenv("DB_NAME")
DB_USER: str = os.getenv("DB_USER")
DB_PASSWORD: str = os.getenv("DB_PASSWORD")
SECRET_KEY: str = os.getenv("SECRET_KEY")
ALGORITHM: str = os.getenv("ALGORITHM")
# Получаем параметры для загрузки переменных среды
settings = Settings()
database_url = f"{settings.DB_DRIVER}://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}/{settings.DB_NAME}"

174
backend/app/dao/base.py Normal file
View File

@@ -0,0 +1,174 @@
from typing import List, TypeVar, Generic, Type
from pydantic import BaseModel
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.future import select
from sqlalchemy import update as sqlalchemy_update, delete as sqlalchemy_delete, func
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession
from .database import Base
T = TypeVar("T", bound=Base)
class BaseDAO(Generic[T]):
model: Type[T] = None
def __init__(self, session: AsyncSession):
self._session = session
if self.model is None:
raise ValueError("Модель должна быть указана в дочернем классе")
async def find_one_or_none_by_id(self, data_id: int):
try:
query = select(self.model).filter_by(id=data_id)
result = await self._session.execute(query)
record = result.scalar_one_or_none()
log_message = (
f"Запись {self.model.__name__} с ID {data_id} "
+ "{'найдена' if record else 'не найдена'}."
)
logger.info(log_message)
return record
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске записи с ID {data_id}: {e}")
raise
async def find_one_or_none(self, filters: BaseModel):
filter_dict = filters.model_dump(exclude_unset=True)
logger.info(
f"Поиск одной записи {self.model.__name__} по фильтрам: {filter_dict}"
)
try:
query = select(self.model).filter_by(**filter_dict)
result = await self._session.execute(query)
record = result.scalar_one_or_none()
log_message = f"Запись {'найдена' if record else 'не найдена'} по фильтрам: {filter_dict}"
logger.info(log_message)
return record
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске записи по фильтрам {filter_dict}: {e}")
raise
async def find_all(self, filters: BaseModel | None = None):
filter_dict = filters.model_dump(exclude_unset=True) if filters else {}
logger.info(
f"Поиск всех записей {self.model.__name__} по фильтрам: {filter_dict}"
)
try:
query = select(self.model).filter_by(**filter_dict)
result = await self._session.execute(query)
records = result.scalars().all()
logger.info(f"Найдено {len(records)} записей.")
return records
except SQLAlchemyError as e:
logger.error(
f"Ошибка при поиске всех записей по фильтрам {filter_dict}: {e}"
)
raise
async def add(self, values: BaseModel):
values_dict = values.model_dump(exclude_unset=True)
logger.info(
f"Добавление записи {self.model.__name__} с параметрами: {values_dict}"
)
try:
new_instance = self.model(**values_dict)
self._session.add(new_instance)
logger.info(f"Запись {self.model.__name__} успешно добавлена.")
await self._session.flush()
return new_instance
except SQLAlchemyError as e:
logger.error(f"Ошибка при добавлении записи: {e}")
raise
async def add_many(self, instances: List[BaseModel]):
values_list = [item.model_dump(exclude_unset=True) for item in instances]
logger.info(
f"Добавление нескольких записей {self.model.__name__}. Количество: {len(values_list)}"
)
try:
new_instances = [self.model(**values) for values in values_list]
self._session.add_all(new_instances)
logger.info(f"Успешно добавлено {len(new_instances)} записей.")
await self._session.flush()
return new_instances
except SQLAlchemyError as e:
logger.error(f"Ошибка при добавлении нескольких записей: {e}")
raise
async def update(self, filters: BaseModel, values: BaseModel):
filter_dict = filters.model_dump(exclude_unset=True)
values_dict = values.model_dump(exclude_unset=True)
logger.info(
f"Обновление записей {self.model.__name__} по фильтру: {filter_dict} с параметрами: {values_dict}"
)
try:
query = (
sqlalchemy_update(self.model)
.where(*[getattr(self.model, k) == v for k, v in filter_dict.items()])
.values(**values_dict)
.execution_options(synchronize_session="fetch")
)
result = await self._session.execute(query)
logger.info(f"Обновлено {result.rowcount} записей.")
await self._session.flush()
return result.rowcount
except SQLAlchemyError as e:
logger.error(f"Ошибка при обновлении записей: {e}")
raise
async def delete(self, filters: BaseModel):
filter_dict = filters.model_dump(exclude_unset=True)
logger.info(f"Удаление записей {self.model.__name__} по фильтру: {filter_dict}")
if not filter_dict:
logger.error("Нужен хотя бы один фильтр для удаления.")
raise ValueError("Нужен хотя бы один фильтр для удаления.")
try:
query = sqlalchemy_delete(self.model).filter_by(**filter_dict)
result = await self._session.execute(query)
logger.info(f"Удалено {result.rowcount} записей.")
await self._session.flush()
return result.rowcount
except SQLAlchemyError as e:
logger.error(f"Ошибка при удалении записей: {e}")
raise
async def count(self, filters: BaseModel | None = None):
filter_dict = filters.model_dump(exclude_unset=True) if filters else {}
logger.info(
f"Подсчет количества записей {self.model.__name__} по фильтру: {filter_dict}"
)
try:
query = select(func.count(self.model.id)).filter_by(**filter_dict)
result = await self._session.execute(query)
count = result.scalar()
logger.info(f"Найдено {count} записей.")
return count
except SQLAlchemyError as e:
logger.error(f"Ошибка при подсчете записей: {e}")
raise
async def bulk_update(self, records: List[BaseModel]):
logger.info(f"Массовое обновление записей {self.model.__name__}")
try:
updated_count = 0
for record in records:
record_dict = record.model_dump(exclude_unset=True)
if "id" not in record_dict:
continue
update_data = {k: v for k, v in record_dict.items() if k != "id"}
stmt = (
sqlalchemy_update(self.model)
.filter_by(id=record_dict["id"])
.values(**update_data)
)
result = await self._session.execute(stmt)
updated_count += result.rowcount
logger.info(f"Обновлено {updated_count} записей")
await self._session.flush()
return updated_count
except SQLAlchemyError as e:
logger.error(f"Ошибка при массовом обновлении: {e}")
raise

View File

@@ -0,0 +1,68 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Annotated
from sqlalchemy import func, TIMESTAMP, Integer, inspect
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, declared_attr
from sqlalchemy.ext.asyncio import (
AsyncAttrs,
async_sessionmaker,
create_async_engine,
AsyncSession,
)
from app.config import database_url
engine = create_async_engine(url=database_url)
async_session_maker = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
str_uniq = Annotated[str, mapped_column(unique=True, nullable=False)]
class Base(AsyncAttrs, DeclarativeBase):
__abstract__ = True
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP, server_default=func.now(), onupdate=func.now()
)
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"
def to_dict(self, exclude_none: bool = False):
"""
Преобразует объект модели в словарь.
Args:
exclude_none (bool): Исключать ли None значения из результата
Returns:
dict: Словарь с данными объекта
"""
result = {}
for column in inspect(self.__class__).columns:
value = getattr(self, column.key)
# Преобразование специальных типов данных
if isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, Decimal):
value = float(value)
elif isinstance(value, uuid.UUID):
value = str(value)
# Добавляем значение в результат
if not exclude_none or value is not None:
result[column.key] = value
return result
def __repr__(self) -> str:
"""Строковое представление объекта для удобства отладки."""
return f"<{self.__class__.__name__}(id={self.id}, "
"created_at={self.created_at}, updated_at={self.updated_at})>"

View File

@@ -1,26 +0,0 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from .config import settings
SQLALCHEMY_DATABASE_URL = settings.database_url
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
pool_size=10,
max_overflow=20,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

View File

@@ -0,0 +1,95 @@
from datetime import datetime, timezone
from fastapi import Request, Depends
from jose import jwt, JWTError, ExpiredSignatureError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dao import UsersDAO
from app.auth.models import User
from app.config import settings
from app.dependencies.dao_dep import get_session_without_commit
from app.exceptions import (
TokenNoFound,
NoJwtException,
TokenExpiredException,
NoUserIdException,
ForbiddenException,
UserNotFoundException,
)
def get_access_token(request: Request) -> str:
"""Извлекаем access_token из кук."""
token = request.cookies.get("user_access_token")
if not token:
raise TokenNoFound
return token
def get_refresh_token(request: Request) -> str:
"""Извлекаем refresh_token из кук."""
token = request.cookies.get("user_refresh_token")
if not token:
raise TokenNoFound
return token
async def check_refresh_token(
token: str = Depends(get_refresh_token),
session: AsyncSession = Depends(get_session_without_commit),
) -> User:
"""Проверяем refresh_token и возвращаем пользователя."""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
user_id = payload.get("sub")
if not user_id:
raise NoJwtException
user = await UsersDAO(session).find_one_or_none_by_id(data_id=int(user_id))
if not user:
raise NoJwtException
return user
except JWTError:
raise NoJwtException
async def get_current_user(
token: str = Depends(get_access_token),
session: AsyncSession = Depends(get_session_without_commit),
) -> User:
"""Проверяем access_token и возвращаем пользователя."""
try:
# Декодируем токен
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
except ExpiredSignatureError:
raise TokenExpiredException
except JWTError:
# Общая ошибка для токенов
raise NoJwtException
expire: str = payload.get("exp")
expire_time = datetime.fromtimestamp(int(expire), tz=timezone.utc)
if (not expire) or (expire_time < datetime.now(timezone.utc)):
raise TokenExpiredException
user_id: str = payload.get("sub")
if not user_id:
raise NoUserIdException
user = await UsersDAO(session).find_one_or_none_by_id(data_id=int(user_id))
if not user:
raise UserNotFoundException
return user
async def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Проверяем права пользователя как администратора."""
if current_user.role.id in [3, 4]:
return current_user
raise ForbiddenException

View File

@@ -0,0 +1,28 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from app.dao.database import async_session_maker
async def get_session_with_commit() -> AsyncGenerator[AsyncSession, None]:
"""Асинхронная сессия с автоматическим коммитом."""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_session_without_commit() -> AsyncGenerator[AsyncSession, None]:
"""Асинхронная сессия без автоматического коммита."""
async with async_session_maker() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

58
backend/app/exceptions.py Normal file
View File

@@ -0,0 +1,58 @@
from fastapi import status, HTTPException
# Пользователь уже существует
UserAlreadyExistsException = HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Пользователь уже существует"
)
# Пользователь не найден
UserNotFoundException = HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
# Отсутствует идентификатор пользователя
UserIdNotFoundException = HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Отсутствует идентификатор пользователя",
)
# Неверная почта или пароль
IncorrectEmailOrPasswordException = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Неверная почта или пароль"
)
# Токен истек
TokenExpiredException = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Токен истек"
)
# Некорректный формат токена
InvalidTokenFormatException = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Некорректный формат токена"
)
# Токен отсутствует в заголовке
TokenNoFound = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Токен отсутствует в заголовке"
)
# Невалидный JWT токен
NoJwtException = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Токен не валидный"
)
# Не найден ID пользователя
NoUserIdException = HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Не найден ID пользователя"
)
# Недостаточно прав
ForbiddenException = HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав"
)
TokenInvalidFormatException = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный формат токена. Ожидается 'Bearer <токен>'",
)

View File

@@ -1,16 +1,69 @@
from fastapi import FastAPI
from . import models
from .database import engine
from app.routers import users_router, auth_router
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from loguru import logger
models.Base.metadata.create_all(bind=engine)
app = FastAPI()
app.include_router(users_router, prefix="/api/users", tags=["users"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
from app.auth.router import router as router_auth
@app.get("/")
def read_root():
return {"message": "Hello from FastAPI!"}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[dict, None]:
"""Управление жизненным циклом приложения."""
logger.info("Инициализация приложения...")
yield
logger.info("Завершение работы приложения...")
def create_app() -> FastAPI:
"""
Создание и конфигурация FastAPI приложения.
Returns:
Сконфигурированное приложение FastAPI
"""
app = FastAPI(
title="Стартовая сборка FastAPI",
description=(
"Стартовая сборка с интегрированной SQLAlchemy 2 для разработки FastAPI приложений с продвинутой "
"архитектурой, включающей авторизацию, аутентификацию и управление ролями пользователей."
),
version="1.0.0",
lifespan=lifespan,
)
# Настройка CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Монтирование статических файлов
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# Регистрация роутеров
register_routers(app)
return app
def register_routers(app: FastAPI) -> None:
"""Регистрация роутеров приложения."""
# Корневой роутер
root_router = APIRouter()
@root_router.get("/", tags=["root"])
def home_page():
return "Hello world"
# Подключение роутеров
app.include_router(root_router, tags=["root"])
app.include_router(router_auth, prefix="/auth", tags=["Auth"])
# Создание экземпляра приложения
app = create_app()

View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

View File

@@ -0,0 +1,77 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import database_url
from app.dao.database import Base
from app.auth.models import *
config = context.config
config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -19,10 +19,8 @@ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,40 @@
"""Initial
Revision ID: faa64c3fcd68
Revises:
Create Date: 2025-06-09 22:57:38.958126
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'faa64c3fcd68'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('first_name', sa.String(length=50), nullable=False),
sa.Column('last_name', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=50), nullable=False),
sa.Column('password', sa.String(length=256), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -1,17 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.sql.sqltypes import TIMESTAMP
from sqlalchemy.sql.expression import text
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True)
username = Column(String(50), unique=True, index=True)
hashed_password = Column(String(255))
is_active = Column(Boolean, default=True)
created_at = Column(
TIMESTAMP(timezone=True), nullable=False, server_default=text("now()")
)

View File

@@ -1,4 +0,0 @@
from .auth import router as auth_router
from .users import router as users_router
__all__ = ["auth_router", "users_router"]

View File

@@ -1,79 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from .. import schemas, models, database
from ..config import settings
router = APIRouter(tags=["auth"])
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password: str, hashed_password: str):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, settings.secret_key, algorithm=settings.algorithm
)
return encoded_jwt
async def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(database.get_db)
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.secret_key, algorithms=[settings.algorithm]
)
email: str = payload.get("sub")
if email is None:
raise credentials_exception
token_data = schemas.TokenData(email=email)
except JWTError:
raise credentials_exception
user = db.query(models.User).filter(models.User.email == token_data.email).first()
if user is None:
raise credentials_exception
return user
@router.post("/token", response_model=schemas.Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(database.get_db),
):
user = db.query(models.User).filter(models.User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@@ -1,39 +0,0 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from . import auth
from .. import schemas, models, database
router = APIRouter()
@router.post("/", response_model=schemas.User)
def create_user(user: schemas.UserCreate, db: Session = Depends(database.get_db)):
db_user = db.query(models.User).filter(models.User.email == user.email).first()
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = auth.get_password_hash(user.password)
db_user = models.User(email=user.email, hashed_password=hashed_password)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/token", response_model=schemas.Token)
def login_for_access_token(
user: schemas.UserCreate, db: Session = Depends(database.get_db)
):
db_user = db.query(models.User).filter(models.User.email == user.email).first()
if not db_user or not auth.verify_password(user.password, db_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = auth.create_access_token(
data={"sub": db_user.email},
expires_delta=timedelta(minutes=auth.ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@@ -1,26 +0,0 @@
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str
class User(UserBase):
id: int
is_active: bool
class Config:
orm_mode = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: str | None = None

View File

6
backend/entrypoint.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -e
alembic upgrade head
exec "$@"

1813
backend/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
[project]
name = "backend"
version = "0.1.0"
description = ""
authors = [
{name = "Your Name",email = "you@example.com"}
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi[all] (>=0.115.12,<0.116.0)",
"sqlalchemy (>=2.0.41,<3.0.0)",
"pymysql (>=1.1.1,<2.0.0)",
"passlib (>=1.7.4,<2.0.0)",
"python-jose (>=3.4.0,<4.0.0)",
"pydantic (>=2.11.4,<3.0.0)",
"alembic (>=1.15.2,<2.0.0)",
"dotenv (>=0.9.9,<0.10.0)",
"pydantic-settings (>=2.9.1,<3.0.0)",
"cryptography (>=45.0.2,<46.0.0)",
"bcrypt (>=4.3.0,<5.0.0)"
]
[tool.poetry]
package-mode = false
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

58
backend/requirements.txt Normal file
View File

@@ -0,0 +1,58 @@
aiomysql==0.2.0
aiosqlite==0.20.0
alembic==1.13.3
annotated-types==0.7.0
anyio==4.9.0
bcrypt==4.0.1
certifi==2025.4.26
cffi==1.17.1
click==8.2.1
cryptography==45.0.3
dnspython==2.7.0
ecdsa==0.19.1
email_validator==2.2.0
fastapi==0.115.0
fastapi-cli==0.0.7
greenlet==3.2.3
h11==0.16.0
httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.4
loguru==0.7.2
Mako==1.3.10
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
mysql-connector-python==9.3.0
orjson==3.10.18
passlib==1.7.4
pyasn1==0.6.1
pycparser==2.22
pydantic==2.9.2
pydantic-extra-types==2.10.5
pydantic-settings==2.5.2
pydantic_core==2.23.4
Pygments==2.19.1
PyMySQL==1.1.1
python-dotenv==1.1.0
python-jose==3.3.0
python-multipart==0.0.20
PyYAML==6.0.2
rich==14.0.0
rich-toolkit==0.14.7
rsa==4.9.1
shellingham==1.5.4
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.35
starlette==0.38.6
typer==0.16.0
typing_extensions==4.14.0
ujson==5.10.0
uvicorn==0.31.0
uvloop==0.21.0
watchfiles==1.0.5
websockets==15.0.1

View File

@@ -2,28 +2,35 @@ services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: myapp
MYSQL_USER: myappuser
MYSQL_PASSWORD: mypassword
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
MYSQL_USER: ${MYSQL_USER:-appuser}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-secret}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-supersecret}
ports:
- "127.0.0.1:3306:3306"
- 127.0.0.1:3306:3306
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 5s
retries: 10
backend:
build: ./backend
ports:
- "127.0.0.1:8000:8000"
- 127.0.0.1:8000:8000
environment:
DATABASE_URL: mysql+pymysql://myappuser:mypassword@db/myapp
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-3306}
DB_DRIVER: ${DB_DRIVER:-mysql}
DB_NAME: ${DB_NAME:-app}
DB_USER: ${DB_USER:-appuser}
DB_PASSWORD: ${DB_PASSWORD:-secret}
SECRET_KEY: ${SECRET_KEY:-supersecretkey}
ALGORITHM: ${ALGORITHM:-HS256}
depends_on:
db:
condition: service_healthy
- db
adminer:
image: adminer:latest
ports:
- 127.0.0.1:8080:8080
volumes:
mysql_data:

View File

@@ -0,0 +1 @@
v22.14.0

View File

@@ -0,0 +1,70 @@
{
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
// Enable the ESlint flat config support
"eslint.useFlatConfig": true,
"editor.formatOnSave": true,
"eslint.format.enable": true,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{
"rule": "style/*",
"severity": "off"
},
{
"rule": "format/*",
"severity": "off"
},
{
"rule": "*-indent",
"severity": "off"
},
{
"rule": "*-spacing",
"severity": "off"
},
{
"rule": "*-spaces",
"severity": "off"
},
{
"rule": "*-order",
"severity": "off"
},
{
"rule": "*-dangle",
"severity": "off"
},
{
"rule": "*-newline",
"severity": "off"
},
{
"rule": "*quotes",
"severity": "off"
},
{
"rule": "*semi",
"severity": "off"
}
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"astro"
]
}

View File

@@ -1,6 +1,5 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,5 @@
@import 'bootstrap/scss/bootstrap';
html, body, #__nuxt {
min-height: 100vh;
}

View File

@@ -5,11 +5,18 @@
"name": "nuxt-app",
"dependencies": {
"@nuxt/eslint": "1.4.0",
"eslint": "^9.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.6",
"eslint": "^9.27.0",
"nuxt": "^3.17.3",
"sass": "^1.89.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
},
"devDependencies": {
"@stylistic/eslint-plugin": "^4.2.0",
"@types/bootstrap": "^5.2.10",
},
},
},
"packages": {
@@ -307,6 +314,8 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@poppinss/colors": ["@poppinss/colors@4.1.4", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FA+nTU8p6OcSH4tLDY5JilGYr1bVWHpNmcLr7xmMEdbWmKHa+3QZ+DqefrXKmdjO/brHTnQZo20lLSjaO7ydog=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.3", "", { "dependencies": { "@poppinss/colors": "^4.1.4", "@sindresorhus/is": "^7.0.1", "supports-color": "^10.0.0" } }, "sha512-iombbn8ckOixMtuV1p3f8jN6vqhXefNjJttoPaJDMeIk/yIGhkkL3OrHkEjE9SRsgoAx1vBUU2GtgggjvA5hCA=="],
@@ -381,6 +390,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
"@types/bootstrap": ["@types/bootstrap@5.2.10", "", { "dependencies": { "@popperjs/core": "^2.9.2" } }, "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
@@ -565,6 +576,8 @@
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bootstrap": ["bootstrap@5.3.6", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -997,6 +1010,8 @@
"image-meta": ["image-meta@0.2.1", "", {}, "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw=="],
"immutable": ["immutable@5.1.2", "", {}, "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"impound": ["impound@1.0.0", "", { "dependencies": { "exsolve": "^1.0.5", "mocked-exports": "^0.1.1", "pathe": "^2.0.3", "unplugin": "^2.3.2", "unplugin-utils": "^0.2.4" } }, "sha512-8lAJ+1Arw2sMaZ9HE2ZmL5zOcMnt18s6+7Xqgq2aUVy4P1nlzAyPtzCDxsk51KVFwHEEdc6OWvUyqwHwhRYaug=="],
@@ -1481,6 +1496,8 @@
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"sass": ["sass@1.89.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ=="],
"scslre": ["scslre@0.3.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.0", "regexp-ast-analysis": "^0.7.0" } }, "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ=="],
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],

View File

@@ -0,0 +1,5 @@
<template>
<span>
<slot />
</span>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<div class="container p-3">
<span>&copy; {{year}} Информационная система университета</span>
</div>
</template>
<script lang="ts" setup>
const year = new Date().getFullYear()
</script>

View File

@@ -0,0 +1,76 @@
<template>
<nav
class="navbar navbar-expand-sm navbar-light bg-light"
>
<div class="container">
<a class="navbar-brand" href="#">Navbar</a>
<button
class="navbar-toggler d-lg-none"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapsibleNavId"
aria-controls="collapsibleNavId"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"/>
</button>
<div id="collapsibleNavId" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mt-2 mt-lg-0">
<li class="nav-item">
<a class="nav-link active" href="#" aria-current="page"
>Home
<span class="visually-hidden">(current)</span></a
>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a
id="dropdownId"
class="nav-link dropdown-toggle"
href="#"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>Dropdown</a
>
<div
class="dropdown-menu"
aria-labelledby="dropdownId"
>
<a class="dropdown-item" href="#"
>Action 1</a
>
<a class="dropdown-item" href="#"
>Action 2</a
>
</div>
</li>
</ul>
<!-- <form class="d-flex my-2 my-lg-0">
<input
class="form-control me-sm-2"
type="text"
placeholder="Search"
>
<button
class="btn btn-outline-success my-2 my-sm-0"
type="submit"
>
Search
</button>
</form> -->
</div>
</div>
</nav>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

View File

@@ -1,6 +1,12 @@
// @ts-check
import stylistic from '@stylistic/eslint-plugin'
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
export default withNuxt({
plugins: { '@stylistic': stylistic },
...stylistic.configs.recommended,
rules: {
...stylistic.configs.recommended.rules,
'@stylistic/quotes': ['warn', 'single'],
},
})

View File

@@ -0,0 +1,9 @@
<template>
<div class="d-flex flex-column min-vh-100">
<AppHeader />
<div class="container flex-fill">
<slot />
</div>
<AppFooter />
</div>
</template>

View File

@@ -2,5 +2,29 @@
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint']
})
modules: ['@nuxt/eslint'],
plugins: ['~/plugins/bootstrap.client.ts'],
css: [
'~/assets/scss/main.scss',
],
vite: {
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ['mixed-decls', 'color-functions', 'global-builtin', 'import'],
additionalData: '@use "~/assets/scss/_variables.scss" as *;',
},
},
},
},
runtimeConfig: {
public: {
apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8000',
},
},
})

10698
student-perfomance-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,22 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"@nuxt/eslint": "1.4.0",
"eslint": "^9.0.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.6",
"eslint": "^9.27.0",
"nuxt": "^3.17.3",
"sass": "^1.89.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/bootstrap": "^5.2.10",
"@stylistic/eslint-plugin": "^4.2.0"
}
}

View File

@@ -0,0 +1,5 @@
<template>
<section>
<p>This page will be displayed at the /about route.</p>
</section>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<div>
<h1>Welcome to the homepage</h1>
<AppAlert>
This is an auto-imported component
</AppAlert>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

View File

@@ -0,0 +1,3 @@
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
export default defineNuxtPlugin(() => {})