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:
16
.env.example
16
.env.example
@@ -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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
20
Makefile
Normal file
20
Makefile
Normal 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
90
backend/.dockerignore
Normal 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
9
backend/.env.example
Normal 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
|
||||
@@ -1,2 +1,2 @@
|
||||
[flake8]
|
||||
max-line-length = 101
|
||||
max-line-length=110
|
||||
|
||||
20
backend/.gitignore
vendored
20
backend/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
11
backend/Makefile
Normal 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
|
||||
@@ -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 с поддержкой аутентификации,
|
||||
авторизации и структурированной архитектуры, готовой к масштабированию.
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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
6
backend/app/auth/dao.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.dao.base import BaseDAO
|
||||
from app.auth.models import User
|
||||
|
||||
|
||||
class UsersDAO(BaseDAO):
|
||||
model = User
|
||||
2
backend/app/auth/models/__init__.py
Normal file
2
backend/app/auth/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# flake8: noqa
|
||||
from .user import User
|
||||
13
backend/app/auth/models/user.py
Normal file
13
backend/app/auth/models/user.py
Normal 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})"
|
||||
74
backend/app/auth/router.py
Normal file
74
backend/app/auth/router.py
Normal 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": "Токены успешно обновлены"}
|
||||
55
backend/app/auth/schemas.py
Normal file
55
backend/app/auth/schemas.py
Normal 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
70
backend/app/auth/utils.py
Normal 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)
|
||||
@@ -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
174
backend/app/dao/base.py
Normal 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
|
||||
68
backend/app/dao/database.py
Normal file
68
backend/app/dao/database.py
Normal 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})>"
|
||||
@@ -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()
|
||||
0
backend/app/dependencies/__init__.py
Normal file
0
backend/app/dependencies/__init__.py
Normal file
95
backend/app/dependencies/auth_dep.py
Normal file
95
backend/app/dependencies/auth_dep.py
Normal 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
|
||||
28
backend/app/dependencies/dao_dep.py
Normal file
28
backend/app/dependencies/dao_dep.py
Normal 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
58
backend/app/exceptions.py
Normal 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 <токен>'",
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
1
backend/app/migration/README
Normal file
1
backend/app/migration/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
77
backend/app/migration/env.py
Normal file
77
backend/app/migration/env.py
Normal 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()
|
||||
@@ -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"}
|
||||
40
backend/app/migration/versions/faa64c3fcd68_initial.py
Normal file
40
backend/app/migration/versions/faa64c3fcd68_initial.py
Normal 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 ###
|
||||
@@ -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()")
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
from .auth import router as auth_router
|
||||
from .users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "users_router"]
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
0
backend/app/static/.gitkeep
Normal file
0
backend/app/static/.gitkeep
Normal file
6
backend/entrypoint.sh
Executable file
6
backend/entrypoint.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
alembic upgrade head
|
||||
|
||||
exec "$@"
|
||||
1813
backend/poetry.lock
generated
1813
backend/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
58
backend/requirements.txt
Normal 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
|
||||
33
compose.yml
33
compose.yml
@@ -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:
|
||||
|
||||
1
student-perfomance-frontend/.nvmrc
Normal file
1
student-perfomance-frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v22.14.0
|
||||
70
student-perfomance-frontend/.vscode/settings.json
vendored
Normal file
70
student-perfomance-frontend/.vscode/settings.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtWelcome />
|
||||
</div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
5
student-perfomance-frontend/assets/scss/main.scss
Normal file
5
student-perfomance-frontend/assets/scss/main.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import 'bootstrap/scss/bootstrap';
|
||||
|
||||
html, body, #__nuxt {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -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=="],
|
||||
|
||||
5
student-perfomance-frontend/components/AppAlert.vue
Normal file
5
student-perfomance-frontend/components/AppAlert.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<span>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
9
student-perfomance-frontend/components/AppFooter.vue
Normal file
9
student-perfomance-frontend/components/AppFooter.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="container p-3">
|
||||
<span>© {{year}} Информационная система университета</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
76
student-perfomance-frontend/components/AppHeader.vue
Normal file
76
student-perfomance-frontend/components/AppHeader.vue
Normal 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>
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
9
student-perfomance-frontend/layouts/default.vue
Normal file
9
student-perfomance-frontend/layouts/default.vue
Normal 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>
|
||||
@@ -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
10698
student-perfomance-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
5
student-perfomance-frontend/pages/about.vue
Normal file
5
student-perfomance-frontend/pages/about.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<section>
|
||||
<p>This page will be displayed at the /about route.</p>
|
||||
</section>
|
||||
</template>
|
||||
8
student-perfomance-frontend/pages/index.vue
Normal file
8
student-perfomance-frontend/pages/index.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Welcome to the homepage</h1>
|
||||
<AppAlert>
|
||||
This is an auto-imported component
|
||||
</AppAlert>
|
||||
</div>
|
||||
</template>
|
||||
13
student-perfomance-frontend/pages/login.vue
Normal file
13
student-perfomance-frontend/pages/login.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
3
student-perfomance-frontend/plugins/bootstrap.client.ts
Normal file
3
student-perfomance-frontend/plugins/bootstrap.client.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||
|
||||
export default defineNuxtPlugin(() => {})
|
||||
Reference in New Issue
Block a user