Compare commits

..

26 Commits

Author SHA1 Message Date
78cc7e3f54 feat: добавлены новые функции и схемы в модуль auth
All checks were successful
Lint project / lint (push) Successful in 1m41s
- Добавлен класс UserVerifySchema для валидации данных пользователя, наследующий GetUserByID и GetUserByEmail.
- Обновлена модель AuthUser для регистрации пользователя.
- Добавлены роуты logout и get-user для работы с аутентификацией.
- Создан новый модуль utils с функцией get_token_from_cookies для извлечения токена из куки.
- Добавлена логика для выхода пользователя и получения информации об авторизованном пользователе в сервисах и роутах.
- Обновлены схемы и зависимости в модуле handlers и managers для работы с аутентификацией.
2025-04-29 22:51:00 +04:00
40d45e8379 docs: добавлены детали о библиотеке pyJWT
All checks were successful
Lint project / lint (push) Successful in 1m42s
- Добавлено описание библиотеки pyJWT в раздел "Установка" и обновлен список статей.
- Обновлен текст в разделе "Приложения", добавлена ссылка на новую статью про маршруты авторизации и JWT.
2025-04-10 12:51:01 +04:00
28acd7d04d refactor: переименован класс и добавлены новые методы в модулях auth
- Переименован класс `RegisterUser` в `AuthUser` для общей аутентификации.
- Добавлена новая схема `GetUserWithIDAndEmail`, которая наследует от `GetUserByID` и `CreateUser`.
- Обновлены импорты и используемые классы в модулях handlers, services и managers для использования нового класса `AuthUser`.
- Добавлен новый файл `named_tuples.py` с определением `CreateTokenTuple`.
- В модуле `handlers.py` добавлены новые методы для аутентификации и создания JWT токенов.
- Обновлены маршруты `/register` и `/login`, чтобы использовать новый класс `AuthUser` и обновленные сервисы.
2025-04-10 12:50:39 +04:00
e2d0669064 feat: добавлен класс для работы с Redis
- Создан новый файл `redis_dependency.py` в каталоге `lkeep/core/core_dependency`.
- Добавлен класс `RedisDependency` для управления соединениями с Redis и взаимодействия с базой данных.
- Реализован метод `_init_pool` для инициализации пула соединений.
- Создан асинхронный контекст менеджер `get_client` для получения клиентской сессии Redis.
2025-04-10 12:48:37 +04:00
c33e898218 feat: добавлены необходимые зависимости и конфигурации
- Добавлена зависимость pyjwt для работы с JWT-токенами.
- Обновлен файл .pre-commit-config.yaml, добавлен хук mypy для проверки типов.
- Внесены изменения в .env.example для поддержки нового параметра ACCESS_TOKEN_EXPIRE.
- Добавлен новый атрибут access_token_expire в класс Settings в lkeep/core/settings.py.
2025-04-10 12:48:10 +04:00
807dbee647 feat: добавлены библиотеки Celery и Redis
All checks were successful
Lint project / lint (push) Successful in 2m22s
- Добавлены зависимости Celery и Redis в `pyproject.toml`.
- Обновлена документация `README.md`, добавлены новые пункты о Celery, Redis, itsdangerous, smtplib и Jinja2.
- Добавлено описание переменных окружения в `.env.example` для Celery, Redis и электронной почты.
2025-03-20 13:44:12 +04:00
5a2491ec91 feat: добавлен шаблон электронной почты подтверждения регистрации
- Создан новый файл `templates/confirmation_email.html`.
- Добавлен HTML-код для отображения страницы подтверждения регистрации.
- Включена ссылка на подтверждение через переменную `{{ confirmation_url }}`.
2025-03-20 13:43:56 +04:00
7cfb2e734a **feat: добавлен модуль конфигурации Celery и расширены настройки приложения**
- Создан новый файл `celery_config.py` в пакете `lkeep.core`, содержащий класс для конфигурации Celery.
- В файле `settings.py` добавлены новые классы `EmailSettings` и `RedisSettings` с соответствующими настройками.
- Обновлён класс `Settings` для включения новых настроек: `email_settings`, `redis_settings`.
- Добавлен экземпляр Celery в модуль `__init__.py` для доступа из других частей приложения.
2025-03-20 13:43:40 +04:00
217af1cd06 feat: добавлены задачи для отправки email и обновлены сервисы аутентификации
- Созданы две задачи с использованием Celery: `send_text_confirmation_email` и `send_confirmation_email`.
- Обновлена модель бизнес-логики `UserService`: добавлен метод `confirm_user` для подтверждения пользователя.
- Обновлена модель менеджера `UserManager`: добавлен асинхронный метод `confirm_user` для обновления состояния пользователя в базе данных.
- Добавлена новая роутер-функция для подтверждения регистрации по ссылке `/register_confirm`.
2025-03-20 13:43:22 +04:00
84446d44ce fix: обновлен ответ модели в маршруте регистрации
All checks were successful
Lint project / lint (push) Successful in 47s
- Изменен тип возвращаемого значения функции `registration` на `UserReturnData`.
- Обновлена аннотация `response_model` для маршрута `/register`.
2025-01-12 13:12:50 +04:00
d3b2f4d71a feat: добавлен сервис управления пользователями
All checks were successful
Lint project / lint (push) Successful in 55s
- Создан файл services.py в директории lkeep/apps/auth.
- Определен класс UserService для управления пользователями.
- Реализован метод register_user для регистрации нового пользователя.
- Использованы зависимости UserManager и AuthHandler.
- Добавлены необходимые импорты из модулей schemas, managers и handlers.
2025-01-12 13:00:36 +04:00
aec3f41f4f fix: исправлен инициализатор класса UserManager
- Удалена передача модели пользователя в конструктор.
- Используется默认ная модель `User`.
2025-01-12 13:00:13 +04:00
c9de61e535 refactor: добавлены маршруты аутентификации в FastAPI проект
- Создан новый файл `routes.py` в директории `apps/auth`, содержащий маршруты для регистрации пользователя.
- В файле `__init__.py` в директории `apps` инициализирован роутер `apps_router` и подключен маршрут аутентификации.
- Обновлен файл `main.py` для включения роутера `apps_router`.
2025-01-12 13:00:04 +04:00
e421b6c0e1 docs: добавлены новые разделы README.md
All checks were successful
Lint project / lint (push) Successful in 47s
- Добавлены библиотеки pre-commit и CI Workflow.
- Обновлен список зависимостей в requirements.txt.
- Добавлены посты об улучшениях функциональности FastAPI.
- Обновлена секция Установки проекта.
2025-01-11 03:03:16 +04:00
c0ee89f175 feat: добавлен обработчик аутентификации
All checks were successful
Lint project / lint (push) Successful in 45s
- Создан новый модуль `handlers.py` для управления аутентификацией.
- Добавлена класс `AuthHandler` с методом для генерации хэшей паролей.
- Использование библиотеки `passlib` для обеспечения безопасного хранения паролей.
2025-01-11 02:30:55 +04:00
623e9ba325 feat: добавлен класс UserManager для управления пользователями
- Создан новый файл managers.py в директории lkeep/apps/auth.
- Внутри файла создан класс UserManager с методом create_user для создания пользователей.
- Метод create_user использует SQLAlchemy для взаимодействия с базой данных и обрабатывает исключения при добавлении существующего пользователя.
2025-01-11 02:30:39 +04:00
67f980d162 feat: добавлены схемы данных для авторизации в приложении
- Создан новый файл `schemas.py` в директории `lkeep/apps/auth`.
- Внутри файла определены несколько классов Pydantic для обработки различных сценариев работы с пользователями (получение по ID, электронной почте, регистрация и создание).
- Каждый класс имеет соответствующие поля и типы данных, а также документации в формате docstring.
- Добавлены комментарии к файлу для описания проекта, автора, года создания и специализации.
2025-01-11 02:30:26 +04:00
b71f6d2a81 docs: добавлены файлы инициализации для приложения и модуля аутентификации
- Созданы новые файлы `__init__.py` в директориях `lkeep/apps` и `lkeep/apps/auth`.
- Добавлены комментарии с метаданными о проекте, авторе, годе и цели.
2025-01-11 02:30:08 +04:00
2520d49a2b refactor: улучшенная структура и настройки приложения
- Добавлен атрибут `secret_key` для хранения секретного ключа.
- Обновлён класс `Settings` с использованием `SecretStr`.
- Изменены параметры конструктора в классе `DBDependency`, теперь они инициализируются через настройки.
2025-01-11 02:29:45 +04:00
b10286773a feat: добавлены зависимости для аутентификации и хеширования
- Добавлена зависимость `pydantic[email]` для поддержки Email в схемах модели данных.
- Добавлена зависимость `passlib` для упрощения процесса создания паролей.
- Добавлена зависимость `bcrypt` для безопасного хеширования паролей.
- Обновлен `.env.example` файл, добавлен параметр `SECRET_KEY` для настройки секретного ключа.
2025-01-11 02:29:25 +04:00
c6e82f292d docs: добавлены комментарии и документация в модули
All checks were successful
Lint project / lint (push) Successful in 53s
- Добавлены строки документации к модулю `main.py`.
- Добавлены строки документации к модулю `__init__.py`.
- Обновлена заголовочная информация для проекта.
2025-01-10 19:22:47 +04:00
e8de7e21f2 feat: добавлен модуль базы данных и модели пользователя
- Создан файл `__init__.py` в папке `lkeep/database/models` для инициализации модуля.
- Создан класс `Base` в `base.py` как базовый класс для всех ORM-моделей.
- Добавлена модель `User` в `user.py`, представляющая пользователя с необходимыми атрибутами.
2025-01-10 19:22:32 +04:00
a53ffb5213 feat: добавлены миксины для работы с идентификаторами и временными метками
- Создан файл `__init__.py` в пакете `lkeep.database.mixins`.
- Добавлен класс `IDMixin` для генерации уникальных идентификаторов.
- Создан файл `id_mixins.py` с реализацией миксина `IDMixin`.
- Добавлены классы `CreatedAtMixin`, `UpdatedAtMixin` и `TimestampsMixin` для управления временными метками в моделях SQLAlchemy.
2025-01-10 19:22:13 +04:00
ac19bb4397 docs: добавлены комментарии в core/settings.py и core/__init__.py
- Добавлены заголовочные комментарии в файлы settings.py и __init__.py.
- Обновлена метаинформация о проекте, авторе и году.
- Добавлена ссылка на специальный проект "Код на салфетке".
2025-01-10 19:21:47 +04:00
77e3066e6d feat: добавлены файлы для миграции базы данных Alembic
- Созданы новые файлы alembic/versions/2025_01_10_1706-ccf7560dd457_create_user_table.py, alembic/env.py, alembic/README.
- Добавлен новый конфигурационный файл alembic.ini для настройки Alembic.
- Создан шаблон для генерации миграционных скриптов в файле script.py.mako.
2025-01-10 19:21:25 +04:00
9b051edcf3 feat: добавлены зависимости архитектуры Alembic и форматирования Ruff
- Добавлена зависимость alembic версии 1.14.0 или выше, но ниже 2.0.0.
- Добавлена зависимость ruff версии 0.9.0 или выше, но ниже 0.10.0.
2025-01-10 19:21:04 +04:00
37 changed files with 2399 additions and 357 deletions

View File

@ -1,6 +1,23 @@
# Переменные для базы данных
DB_NAME=db_name
DB_USER=db_user
DB_PASSWORD=db_password
DB_HOST=localhost
DB_PORT=5432
DB_ECHO=True
# Системные переменные
SECRET_KEY=1234567890abcdefghigklmnopqrstuvwxyz
FRONTEND_URL=http://127.0.0.1:8000/api/v1
ACCESS_TOKEN_EXPIRE=3600
# Переменные для почты
EMAIL_HOST=smtp.yandex.ru
EMAIL_PORT=465
EMAIL_USERNAME=info@yandex.ru
EMAIL_PASSWORD=12345
# Переменные для Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DB=0

View File

@ -49,3 +49,8 @@ repos:
args: [ "--fix", "--line-length=120" ]
- id: ruff-format
args: [ "--line-length=120" ]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy

View File

@ -16,6 +16,24 @@ PostgreSQL, Poetry, Pydantic и других.
- **SQLAlchemy** — ORM для работы с базой данных.
- **Poetry** — инструмент для управления зависимостями и виртуальными окружениями.
- **Pydantic** — для валидации данных и работы с моделями.
- **pre-commit** — инструмент для автоматической проверки кода перед коммитом.
- **CI Workflow** — автоматизация тестирования приложения.
- **uvicorn** — высокопроизводительный ASGI-сервер для обработки HTTP-запросов.
- **pydantic-settings** — библиотека для работы с конфигурациями и переменными окружения с использованием Pydantic.
- **passlib** — библиотека для безопасного хеширования паролей и других данных.
- **celery** — распределённая система для выполнения фоновых задач и управления очередями, позволяющая выполнять задачи
асинхронно.
- **redis** — высокопроизводительное in-memory хранилище, используемое для кэширования данных и как брокер сообщений для
Celery.
- **itsdangerous** — библиотека для безопасного создания и проверки подписанных данных, что помогает защитить токены и
другую чувствительную информацию.
- **smtplib** — стандартный модуль Python для отправки электронной почты через протокол SMTP.
- **jinja2** — современный и гибкий шаблонизатор, который позволяет динамически генерировать HTML и другие текстовые
форматы.
- **pyJWT** — библиотека для создания, подписи и верификации JSON Web Tokens (JWT). Используется для генерации токенов
доступа, проверки их
целостности, срока действия и подписи, а также работы с закодированными данными (payload) в соответствии со
стандартами JWT.
## Репозитории
@ -29,6 +47,12 @@ PostgreSQL, Poetry, Pydantic и других.
1. [FastAPI 1. Инициализация проекта](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-1-inicializaciya-proekta/)
2. [FastAPI 2. Подготовка проекта](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-2-podgotovka-proekta/)
3. [FastAPI 3. Подключение к SQLAlchemy и генератор сессий](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-3-podklyuchenie-k-sqlalchemy-i-generator-s/)
4. [FastAPI 4. Модель пользователя, миксины и Alembic](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-4-model-polzovatelya-i-alembic/)
5. [FastAPI 5. Приложение аутентификации и Pydantic схемы](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-5-prilozhenie-autentifikacii-i-pydantic-sh/)
6. [FastAPI 6. Пользовательский сервис и маршруты регистрации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-6-polzovatelskij-servis-i-marshruty-regist/)
7. [FastAPI 7. Электронная почта, подтверждение регистрации, Celery и Redis](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-7-elektronnaya-pochta-podtverzhdenie-registracii-celery-i-redis/)
8. [FastAPI 8. Маршрут авторизации и JWT](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-8-marshrut-avtorizacii-i-jwt/)
## Установка

115
alembic.ini Normal file
View File

@ -0,0 +1,115 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = lkeep/database/alembic
# 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 lkeep/database/alembic/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:lkeep/database/alembic/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 = newline
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 = poetry
ruff.options = run ruff format REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,7 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""

15
lkeep/apps/__init__.py Normal file
View File

@ -0,0 +1,15 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from fastapi import APIRouter
from lkeep.apps.auth.routes import auth_router
apps_router = APIRouter(prefix="/api/v1")
apps_router.include_router(router=auth_router)

View File

@ -0,0 +1,7 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""

View File

@ -0,0 +1,52 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uuid
from typing import Annotated
from fastapi import Depends, HTTPException
from starlette import status
from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.managers import UserManager
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.auth.utils import get_token_from_cookies
async def get_current_user(
token: Annotated[str, Depends(get_token_from_cookies)],
handler: AuthHandler = Depends(AuthHandler),
manager: UserManager = Depends(UserManager),
) -> UserVerifySchema:
"""
Получает текущего пользователя из токена аутентификации.
:param token: Токен аутентификации, полученный из куки.
:type token: str
:param handler: Обработчик аутентификации, использующийся для декодирования токена.
:type handler: AuthHandler
:param manager: Менеджер пользователей, используется для проверки и получения данных о пользователе.
:type manager: UserManager
:returns: Схема с информацией о текущем пользователе.
:rtype: UserVerifySchema
:raises HTTPException: Если токен невалиден или пользователь не найден.
"""
decoded_token = await handler.decode_access_token(token=token)
user_id = str(decoded_token.get("user_id"))
session_id = str(decoded_token.get("session_id"))
if not await manager.get_access_token(user_id=user_id, session_id=session_id):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is invalid")
user = await manager.get_user_by_id(user_id=uuid.UUID(user_id))
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
user.session_id = session_id
return user

View File

@ -0,0 +1,93 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import datetime
import uuid
import jwt
from fastapi import HTTPException
from passlib.context import CryptContext
from starlette import status
from lkeep.apps.auth.named_tuples import CreateTokenTuple
from lkeep.core.settings import settings
class AuthHandler:
"""
Обрабатывает аутентификационные запросы и обеспечивает безопасность пользовательских данных.
:ivar secret: Секретный ключ, используемый для дополнительной безопасности при генерации хешей.
:type secret: str
:ivar pwd_context: Контекст для использования bcrypt-алгоритма хеширования паролей.
:type pwd_context: CryptContext
"""
secret = settings.secret_key.get_secret_value()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def get_password_hash(self, password: str) -> str:
"""
Генерирует хэш-значение пароля для безопасного сохранения и сравнения.
:param password: Пароль пользователя, который нужно зашифровать.
:type password: str
:returns: Хешированный вариант пароля.
:rtype: str
"""
return self.pwd_context.hash(password)
async def verify_password(self, raw_password: str, hashed_password: str) -> bool:
"""
Проверяет соответствие введенного пароля захэшированному паролю.
:param raw_password: Введенный пользователем пароль.
:type raw_password: str
:param hashed_password: Хэш, с которым сравнивается введенный пароль.
:type hashed_password: str
:returns: Логическое значение, указывающее на успешность проверки.
:rtype: bool
"""
return self.pwd_context.verify(raw_password, hashed_password)
async def create_access_token(self, user_id: uuid.UUID | str) -> CreateTokenTuple:
"""
Создаёт JWT-токен доступа для пользователя.
:param user_id: Уникальный идентификатор пользователя (UUID).
:type user_id: uuid.UUID
:returns: Кортеж, содержащий закодированный JWT-токен и уникальный session_id.
:rtype: CreateTokenTuple
"""
expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=settings.access_token_expire)
session_id = str(uuid.uuid4())
data = {"exp": expire, "session_id": session_id, "user_id": str(user_id)}
encoded_jwt = jwt.encode(payload=data, key=self.secret, algorithm="HS256")
return CreateTokenTuple(encoded_jwt=encoded_jwt, session_id=session_id)
async def decode_access_token(self, token: str) -> dict:
"""
Декодирует JWT-токен и возвращает его содержимое.
:param token: Строка с JWT-токеном, который нужно декодировать.
:type token: str
:returns: Данные, содержащиеся в декодированном токене.
:rtype: dict
:raises HTTPException: При ошибке декодирования токена (например, токен просрочен или невалиден).
Статус-код ответа 401 UNAUTHORIZED, детализация "Token has expired" при просрочке,
и "Invalid token" при недопустимости токена.
"""
try:
return jwt.decode(jwt=token, key=self.secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")

161
lkeep/apps/auth/managers.py Normal file
View File

@ -0,0 +1,161 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uuid
from fastapi import Depends, HTTPException
from sqlalchemy import insert, select, update
from sqlalchemy.exc import IntegrityError
from lkeep.apps.auth.schemas import (
CreateUser,
GetUserWithIDAndEmail,
UserReturnData,
UserVerifySchema,
)
from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.core.core_dependency.redis_dependency import RedisDependency
from lkeep.database.models import User
class UserManager:
"""
Класс для управления пользователями.
"""
def __init__(
self, db: DBDependency = Depends(DBDependency), redis: RedisDependency = Depends(RedisDependency)
) -> None:
"""
Инициализирует экземпляр класса.
:param db: Зависимость для базы данных. По умолчанию используется Depends(DBDependency).
:type db: DBDependency
:param redis: Зависимость для Redis. По умолчанию используется Depends(RedisDependency).
:type redis: RedisDependency
"""
self.db = db
self.model = User
self.redis = redis
async def create_user(self, user: CreateUser) -> UserReturnData:
"""
Создает нового пользователя в базе данных.
:param user: Объект с данными для создания пользователя.
:type user: CreateUser
:returns: Данные созданного пользователя.
:rtype: UserReturnData
:raises HTTPException: Если пользователь уже существует.
"""
async with self.db.db_session() as session:
query = insert(self.model).values(**user.model_dump()).returning(self.model)
try:
result = await session.execute(query)
except IntegrityError:
raise HTTPException(status_code=400, detail="User already exists.")
await session.commit()
user_data = result.scalar_one()
return UserReturnData(**user_data.__dict__)
async def confirm_user(self, email: str) -> None:
"""
Асинхронный метод для подтверждения пользователя по электронной почте.
:param email: Электронная почта пользователя, которого нужно подтвердить.
:type email: str
"""
async with self.db.db_session() as session:
query = update(self.model).where(self.model.email == email).values(is_verified=True, is_active=True)
await session.execute(query)
await session.commit()
async def get_user_by_email(self, email: str) -> GetUserWithIDAndEmail | None:
"""
Возвращает пользователя по указанному адресу электронной почты.
:param email: Адрес электронной почты пользователя для поиска.
:type email: str
:return: Объект пользователя с полями id и email, если пользователь найден; None в противном случае.
:rtype: GetUserWithIDAndEmail | None
"""
async with self.db.db_session() as session:
query = select(self.model.id, self.model.email, self.model.hashed_password).where(self.model.email == email)
result = await session.execute(query)
user = result.mappings().first()
if user:
return GetUserWithIDAndEmail(**user)
return None
async def get_user_by_id(self, user_id: uuid.UUID | str) -> UserVerifySchema | None:
"""
Возвращает информацию о пользователе по его идентификатору.
:param user_id: Идентификатор пользователя, для которого нужно получить информацию.
Может быть представлен в виде UUID или строки.
:type user_id: uuid.UUID | str
:returns: Схема данных пользователя, если пользователь найден; None, если пользователь не найден.
:rtype: UserVerifySchema | None
"""
async with self.db.db_session() as session:
query = select(self.model.id, self.model.email).where(self.model.id == user_id)
result = await session.execute(query)
user = result.mappings().one_or_none()
if user:
return UserVerifySchema(**user)
return None
async def store_access_token(self, token: str, user_id: uuid.UUID | str, session_id: str) -> None:
"""
Сохраняет токен доступа в хранилище (Redis).
:param token: Токен доступа для сохранения.
:type token: str
:param user_id: Идентификатор пользователя, которому принадлежит токен.
:type user_id: uuid.UUID
:param session_id: Идентификатор сессии, связанной с токеном.
:type session_id: str
"""
async with self.redis.get_client() as client:
await client.set(f"{user_id}:{session_id}", token)
async def get_access_token(self, user_id: uuid.UUID | str, session_id: str) -> str | None:
"""
Получает токен доступа из кэша по идентификаторам пользователя и сессии.
:param user_id: Идентификатор пользователя, может быть в формате UUID или строка.
:type user_id: uuid.UUID | str
:param session_id: Идентификатор сессии, строка.
:type session_id: str
:returns: Токен доступа из кэша, если он существует; иначе None.
:rtype: str | None
"""
async with self.redis.get_client() as client:
return await client.get(f"{user_id}:{session_id}")
async def revoke_access_token(self, user_id: uuid.UUID | str, session_id: str | uuid.UUID | None) -> None:
"""
Отзывает доступный токен доступа пользователя.
:param user_id: Идентификатор пользователя, которому принадлежит токен.
:type user_id: uuid.UUID | str
:param session_id: Идентификатор сессии, для которой должен быть отозван токен.
Если None, то все сессии пользователя будут отозваны.
:type session_id: str | uuid.UUID | None
"""
async with self.redis.get_client() as client:
await client.delete(f"{user_id}:{session_id}")

View File

@ -0,0 +1,27 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from typing import NamedTuple
class CreateTokenTuple(NamedTuple):
"""
Класс для создания кортежа токенов, содержащего закодированный JWT и идентификатор сессии.
Класс наследует от `NamedTuple` и представляет собой неизменяемый контейнер для хранения двух значений:
- закодированного JSON Web Token (JWT)
- уникального идентификатора сессии.
:ivar encoded_jwt: Закодированный JWT-токен.
:type encoded_jwt: str
:ivar session_id: Уникальный идентификатор сессии.
:type session_id: str
"""
encoded_jwt: str
session_id: str

100
lkeep/apps/auth/routes.py Normal file
View File

@ -0,0 +1,100 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from starlette import status
from starlette.responses import JSONResponse
from lkeep.apps.auth.depends import get_current_user
from lkeep.apps.auth.schemas import AuthUser, UserReturnData, UserVerifySchema
from lkeep.apps.auth.services import UserService
auth_router = APIRouter(prefix="/auth", tags=["auth"])
@auth_router.post(path="/register", response_model=UserReturnData, status_code=status.HTTP_201_CREATED)
async def registration(user: AuthUser, service: UserService = Depends(UserService)) -> UserReturnData:
"""
Регистрация нового пользователя.
:param user: Данные нового пользователя, который нужно зарегистрировать.
:type user: AuthUser
:param service: Сервис для взаимодействия с пользователями.
:type service: UserService
:returns: Данные зарегистрированного пользователя.
:rtype: UserReturnData
:raises HTTPException 400: Если данные пользователя некорректны.
"""
return await service.register_user(user=user)
@auth_router.get(path="/register_confirm", status_code=status.HTTP_200_OK)
async def confirm_registration(token: str, service: UserService = Depends(UserService)) -> dict[str, str]:
"""
Подтверждает регистрацию пользователя по ссылке.
:param token: Токен подтверждения регистрации, полученный после отправки на электронную почту.
:type token: str
:param service: Сервис для взаимодействия с пользователями.
:raises HTTPException: Если токен недействителен или срок действия истек.
:return: Словарь с сообщением о успешной подтверждении электронной почты.
:rtype: dict[str, str]
"""
await service.confirm_user(token=token)
return {"message": "Электронная почта подтверждена"}
@auth_router.post(path="/login", status_code=status.HTTP_200_OK)
async def login(user: AuthUser, service: UserService = Depends(UserService)) -> JSONResponse:
"""
Вход пользователя в систему.
:param user: Объект данных пользователя для входа.
:type user: AuthUser
:param service: Сервисный объект для управления пользователями.
:type service: UserService
:returns: JSON-ответ с токеном доступа в Cookies, если вход выполнен успешно.
:rtype: JSONResponse
:raises HTTPException: Если учетные данные не верны или произошла другая ошибка при входе.
"""
return await service.login_user(user=user)
@auth_router.get(path="/logout", status_code=status.HTTP_200_OK)
async def logout(
user: Annotated[UserVerifySchema, Depends(get_current_user)], service: UserService = Depends(UserService)
) -> JSONResponse:
"""
Описание функции logout.
:param user: Текущий авторизованный пользователь.
:type user: UserVerifySchema
:param service: Сервис для управления пользователями.
:type service: UserService
:returns: JSON-ответ, содержащий результат логаута.
:rtype: JSONResponse
"""
return await service.logout_user(user=user)
@auth_router.get(path="/get-user", status_code=status.HTTP_200_OK, response_model=UserVerifySchema)
async def get_auth_user(user: Annotated[UserVerifySchema, Depends(get_current_user)]) -> UserVerifySchema:
"""
Возвращает информацию об авторизованном пользователе.
:param user: Информация о пользователе, полученная с помощью механизма аутентификации.
:type user: UserVerifySchema
:return: Схема данных пользователя, содержащая необходимую информацию для работы системы.
:rtype: UserVerifySchema
:raises HTTPException 401: Если пользователь не авторизован и попытка получить доступ к защищенному ресурсу.
"""
return user

105
lkeep/apps/auth/schemas.py Normal file
View File

@ -0,0 +1,105 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import datetime
import uuid
from pydantic import BaseModel, EmailStr
class GetUserByID(BaseModel):
"""
Класс для получения пользователя по его уникальному идентификатору (ID).
:ivar id: Уникальный идентификатор пользователя, может быть представлен как объект типа uuid.UUID или строкой.
:type id: uuid.UUID | str
"""
id: uuid.UUID | str
class GetUserByEmail(BaseModel):
"""
Класс для поиска пользователя по электронной почте.
:ivar email: Электронная почта пользователя.
:type email: EmailStr
"""
email: EmailStr
class UserVerifySchema(GetUserByID, GetUserByEmail):
"""
Класс для валидации данных пользователя.
Данный класс наследует методы из классов GetUserByID и GetUserByEmail,
что позволяет использовать их функциональность для проверки данных пользователя.
"""
session_id: uuid.UUID | str | None = None
class AuthUser(GetUserByEmail):
"""
Класс для регистрации пользователя, наследующий класс GetUserByEmail.
:ivar password: Пароль пользователя.
:type password: str
"""
password: str
class CreateUser(GetUserByEmail):
"""
Класс для создания пользователя.
:ivar hashed_password: Хэшированный пароль пользователя.
:type hashed_password: str
"""
hashed_password: str
class GetUserWithIDAndEmail(GetUserByID, CreateUser):
"""
Класс для получения пользователя по его ID и email.
:ivar id: Уникальный идентификатор пользователя.
:type id: int
:ivar email: Адрес электронной почты пользователя.
:type email: str
:ivar hashed_password: Хэшированный пароль пользователя.
:type hashed_password: str
"""
pass
class UserReturnData(GetUserByID, GetUserByEmail):
"""
Класс для представления данных пользователя, возвращаемых из API.
:ivar is_active: Статус активности пользователя.
:type is_active: bool
:ivar is_verified: Статус верификации пользователя.
:type is_verified: bool
:ivar is_superuser: Флаг, указывающий на наличие привилегий суперпользователя.
:type is_superuser: bool
:ivar created_at: Временная метка создания записи о пользователе.
:type created_at: datetime.datetime
:ivar updated_at: Временная метка последнего обновления записи о пользователе.
:type updated_at: datetime.datetime
"""
is_active: bool
is_verified: bool
is_superuser: bool
created_at: datetime.datetime
updated_at: datetime.datetime

127
lkeep/apps/auth/services.py Normal file
View File

@ -0,0 +1,127 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from fastapi import Depends, HTTPException
from itsdangerous import BadSignature, URLSafeTimedSerializer
from starlette import status
from starlette.responses import JSONResponse
from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.managers import UserManager
from lkeep.apps.auth.schemas import (
AuthUser,
CreateUser,
UserReturnData,
UserVerifySchema,
)
from lkeep.apps.auth.tasks import send_confirmation_email
from lkeep.core.settings import settings
class UserService:
"""
Класс для управления пользователями.
"""
def __init__(
self, manager: UserManager = Depends(UserManager), handler: AuthHandler = Depends(AuthHandler)
) -> None:
"""
Инициализирует экземпляр класса, используя зависимости для управления пользователями и авторизации.
:param manager: Управитель пользователей, отвечающий за CRUD-операции над пользователями.
:type manager: UserManager
:param handler: Обработчик аутентификации и авторизации, который используется для проверки доступа к ресурсам.
:type handler: AuthHandler
"""
self.manager = manager
self.handler = handler
self.serializer = URLSafeTimedSerializer(secret_key=settings.secret_key.get_secret_value())
async def register_user(self, user: AuthUser) -> UserReturnData:
"""
Регистрирует нового пользователя в системе.
:param user: Информация о пользователе, который нужно зарегистрировать.
:type user: AuthUser
:returns: Данные о созданном пользователе.
:rtype: UserReturnData
"""
hashed_password = await self.handler.get_password_hash(user.password)
new_user = CreateUser(email=user.email, hashed_password=hashed_password)
user_data = await self.manager.create_user(user=new_user)
confirmation_token = self.serializer.dumps(user_data.email)
send_confirmation_email.delay(to_email=user_data.email, token=confirmation_token)
return user_data
async def confirm_user(self, token: str) -> None:
"""
Подтверждает пользователя по переданному токену.
:param token: Токен для подтверждения пользователя.
:type token: str
:raises HTTPException: Если токен неверный или просроченный.
"""
try:
email = self.serializer.loads(token, max_age=3600)
except BadSignature:
raise HTTPException(status_code=400, detail="Неверный или просроченный токен")
await self.manager.confirm_user(email=email)
async def login_user(self, user: AuthUser) -> JSONResponse:
"""
Вход пользователя в систему.
:param user: Объект пользователя с входными данными для аутентификации.
:type user: AuthUser
:returns: Ответ сервера, указывающий на успешность или неудачу входа.
:rtype: JSONResponse
:raises HTTPException: Если предоставленные учетные данные неверны (HTTP 401 Unauthorized).
"""
exist_user = await self.manager.get_user_by_email(email=user.email)
if exist_user is None or not await self.handler.verify_password(
hashed_password=exist_user.hashed_password, raw_password=user.password
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong email or password")
token, session_id = await self.handler.create_access_token(user_id=exist_user.id)
await self.manager.store_access_token(token=token, user_id=exist_user.id, session_id=session_id)
response = JSONResponse(content={"message": "Вход успешен"})
response.set_cookie(
key="Authorization",
value=token,
httponly=True,
max_age=settings.access_token_expire,
)
return response
async def logout_user(self, user: UserVerifySchema) -> JSONResponse:
"""
Отправляет запрос на выход пользователя из системы.
:param user: Схема, содержащая информацию о пользователе для аутентификации.
:type user: UserVerifySchema
:returns: Ответ сервера с сообщением об успешном выходе пользователя.
:rtype: JSONResponse
:raises Exception: Если произошла ошибка при отмене токена доступа.
"""
await self.manager.revoke_access_token(user_id=user.id, session_id=user.session_id)
response = JSONResponse(content={"message": "Logged out"})
response.delete_cookie(key="Authorization")
return response

75
lkeep/apps/auth/tasks.py Normal file
View File

@ -0,0 +1,75 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import smtplib
from email.message import EmailMessage
from celery import shared_task
from starlette.templating import Jinja2Templates
from lkeep.core.settings import settings
@shared_task
def send_text_confirmation_email(to_email: str, token: str) -> None:
"""
Отправляет текстовое подтверждение регистрации по электронной почте.
:param to_email: Адрес электронной почты получателя подтверждения.
:type to_email: str
:param token: Токен для подтверждения регистрации.
:type token: str
"""
confirmation_url = f"{settings.frontend_url}/auth/register_confirm?token={token}"
text = f"""Спасибо за регистрацию!
Для подтверждения регистрации перейдите по ссылке: {confirmation_url}
"""
message = EmailMessage()
message.set_content(text)
message["From"] = settings.email_settings.email_username
message["To"] = to_email
message["Subject"] = "Подтверждение регистрации"
with smtplib.SMTP_SSL(host=settings.email_settings.email_host, port=settings.email_settings.email_port) as smtp:
smtp.login(
user=settings.email_settings.email_username,
password=settings.email_settings.email_password.get_secret_value(),
)
smtp.send_message(msg=message)
@shared_task
def send_confirmation_email(to_email: str, token: str) -> None:
"""
Отправляет подтверждение регистрации по электронной почте.
:param to_email: Адрес электронной почты получателя сообщения.
:type to_email: str
:param token: Токен для подтверждения регистрации, передаваемый в URL.
:type token: str
"""
confirmation_url = f"{settings.frontend_url}/auth/register_confirm?token={token}"
templates = Jinja2Templates(directory=settings.templates_dir)
template = templates.get_template(name="confirmation_email.html")
html_content = template.render(confirmation_url=confirmation_url)
message = EmailMessage()
message.add_alternative(html_content, subtype="html")
message["From"] = settings.email_settings.email_username
message["To"] = to_email
message["Subject"] = "Подтверждение регистрации"
with smtplib.SMTP_SSL(host=settings.email_settings.email_host, port=settings.email_settings.email_port) as smtp:
smtp.login(
user=settings.email_settings.email_username,
password=settings.email_settings.email_password.get_secret_value(),
)
smtp.send_message(msg=message)

27
lkeep/apps/auth/utils.py Normal file
View File

@ -0,0 +1,27 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from fastapi import HTTPException
from starlette import status
from starlette.requests import Request
async def get_token_from_cookies(request: Request) -> str:
"""
Получает токен из куки запроса.
:param request: Объект HTTP-запроса.
:type request: Request
:return: Токен из cookies.
:rtype: str
:raises HTTPException: Если в запросе отсутствует cookie с ключом "Authorization".
"""
token = request.cookies.get("Authorization")
if token is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is missing")
return token

View File

@ -0,0 +1,11 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from .celery_config import celery_app
__all__ = ["celery_app"]

View File

@ -0,0 +1,15 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from celery import Celery
from lkeep.core.settings import settings
celery_app = Celery(main="lkeep", broker=settings.redis_settings.redis_url, backend=settings.redis_settings.redis_url)
celery_app.autodiscover_tasks(packages=["lkeep.apps"])

View File

@ -1,21 +1,18 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from lkeep.core.settings import settings
class DBDependency:
"""
Класс для управления зависимостями базы данных, используя SQLAlchemy.
"""
def __init__(self, db_url: str, db_echo: bool) -> None:
def __init__(self) -> None:
"""
Инициализирует экземпляр класса, отвечающего за взаимодействие с асинхронной базой данных.
:param db_url: URL для подключения к базе данных.
:type db_url: str
:param db_echo: Флаг, определяющий вывод подробных логов при взаимодействии с базой данных.
:type db_echo: bool
"""
self._engine = create_async_engine(url=db_url, echo=db_echo)
self._engine = create_async_engine(url=settings.db_settings.db_url, echo=settings.db_settings.db_echo)
self._session_factory = async_sessionmaker(bind=self._engine, expire_on_commit=False, autocommit=False)
@property

View File

@ -0,0 +1,55 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from redis.asyncio import ConnectionPool, Redis
from lkeep.core.settings import settings
class RedisDependency:
"""
Класс, предоставляющий инструменты для работы с Redis через асинхронный клиент.
:ivar _url: URL подключения к Redis серверу.
:type _url: str
:ivar _pool: Пул соединений для управления соединениями с Redis.
:type _pool: ConnectionPool
"""
def __init__(self) -> None:
"""
Инициализирует экземпляр класса для работы с Redis.
"""
self._url = settings.redis_settings.redis_url
self._pool: ConnectionPool = self._init_pool()
def _init_pool(self) -> ConnectionPool:
"""
Инициализирует пул соединений Redis.
:returns: Пул соединений для работы с Redis.
:rtype: ConnectionPool
"""
return ConnectionPool.from_url(url=self._url, encoding="utf-8", decode_responses=True)
@asynccontextmanager
async def get_client(self) -> AsyncGenerator[Redis, None]:
"""
Получает клиентскую сессию Redis для взаимодействия с базой данных.
:returns: Асинхронный генератор клиента Redis.
:rtype: AsyncGenerator[Redis, None]
"""
redis_client = Redis(connection_pool=self._pool)
try:
yield redis_client
finally:
await redis_client.aclose()

View File

@ -1,3 +1,11 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -40,15 +48,88 @@ class DBSettings(BaseSettings):
return f"postgresql+asyncpg://{self.db_user}:{self.db_password.get_secret_value()}@{self.db_host}:{self.db_port}/{self.db_name}"
class EmailSettings(BaseSettings):
"""
Настройки для электронной почты.
:ivar email_host: Адрес SMTP-сервера.
:type email_host: str
:ivar email_port: Порт, используемый для подключения к SMTP-серверу.
:type email_port: int
:ivar email_username: Имя пользователя для аутентификации на электронной почтовом сервере.
:type email_username: str
:ivar email_password: Пароль пользователя, скрытый через `SecretStr` для обеспечения безопасности.
:type email_password: SecretStr
:model_config: Конфигурация settings, которая указывает на файл окружения и его кодировку.
:type model_config: SettingsConfigDict
"""
email_host: str
email_port: int
email_username: str
email_password: SecretStr
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
class RedisSettings(BaseSettings):
"""
Класс для настройки соединения с Redis.
:ivar redis_host: Хост, на котором размещается Redis-сервер.
:type redis_host: str
:ivar redis_port: Порт, через который происходит соединение с Redis-сервером.
:type redis_port: int
:ivar redis_db: Номер базы данных для использования в Redis.
:type redis_db: int
"""
redis_host: str
redis_port: int
redis_db: int
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
@property
def redis_url(self):
"""
Получает URL для подключения к Redis.
:returns: Строка с URL для подключения к Redis в формате `redis://<хост>:<порт>/<база данных>`.
:rtype: str
"""
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
class Settings(BaseSettings):
"""
Класс Settings используется для хранения настроек приложения.
Класс для хранения настроек приложения.
:ivar db_settings: Экземпляр класса DBSettings, содержащий настройки базы данных.
:ivar db_settings: Настройки для работы с базой данных.
:type db_settings: DBSettings
:ivar email_settings: Настройки для отправки электронной почты.
:type email_settings: EmailSettings
:ivar redis_settings: Настройки для работы с Redis.
:type redis_settings: RedisSettings
:ivar secret_key: Секретный ключ приложения.
:type secret_key: SecretStr
:ivar templates_dir: Путь к директории шаблонов.
:type templates_dir: str
:ivar frontend_url: Адрес фронтенд-приложения.
:type frontend_url: str
:ivar access_token_expire: Срок жизни JWT-токена
:type access_token_expire: int
"""
db_settings: DBSettings = DBSettings()
email_settings: EmailSettings = EmailSettings()
redis_settings: RedisSettings = RedisSettings()
secret_key: SecretStr
templates_dir: str = "templates"
frontend_url: str
access_token_expire: int
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
settings = Settings()

View File

@ -0,0 +1,7 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""

View File

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

View File

@ -0,0 +1,92 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from lkeep.core.settings import settings
from lkeep.database.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
config.set_main_option("sqlalchemy.url", settings.db_settings.db_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
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

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,52 @@
"""Create User Table
Revision ID: ccf7560dd457
Revises:
Create Date: 2025-01-10 17:06:05.585011
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ccf7560dd457"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user",
sa.Column("email", sa.String(length=100), nullable=False),
sa.Column("hashed_password", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column("is_verified", sa.Boolean(), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
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("user")
# ### end Alembic commands ###

View File

@ -0,0 +1,7 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""

View File

@ -0,0 +1,23 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uuid
from sqlalchemy import UUID
from sqlalchemy.orm import Mapped, mapped_column
class IDMixin:
"""
Класс-миксин для добавления уникального идентификатора к объектам.
:ivar id: Уникальный идентификатор объекта.
:type id: uuid.UUID
"""
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)

View File

@ -0,0 +1,53 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class CreatedAtMixin:
"""
Класс-миксин, добавляющий атрибут created_at для отслеживания времени создания объектов.
:ivar created_at: Время создания объекта.
:type created_at: datetime.datetime
"""
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
default=datetime.datetime.now,
)
class UpdatedAtMixin:
"""
Класс-миксин, добавляющий атрибут `updated_at`, который автоматически обновляется при каждом изменении записи.
:ivar updated_at: Время последнего обновления записи.
:type updated_at: Mapped[datetime.datetime]
"""
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class TimestampsMixin(CreatedAtMixin, UpdatedAtMixin):
"""
Класс-миксин для добавления временных меток создания и обновления в классы.
:ivar created_at: Время создания записи.
:type created_at: datetime.datetime
:ivar updated_at: Время последнего обновления записи.
:type updated_at: datetime.datetime
"""
pass

View File

@ -0,0 +1,13 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from lkeep.database.models.base import Base
from lkeep.database.models.user import User
__all__ = ("Base", "User")

View File

@ -0,0 +1,25 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from sqlalchemy.orm import DeclarativeBase, declared_attr
class Base(DeclarativeBase):
"""
Базовый класс для моделей ORM, использующих SQLAlchemy.
"""
@declared_attr.directive
def __tablename__(cls) -> str:
"""
Определяет название таблицы в базе данных на основе имени класса.
:return: Название таблицы в формате snake_case.
:rtype: str
"""
return cls.__name__.lower()

View File

@ -0,0 +1,37 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from lkeep.database.mixins.id_mixins import IDMixin
from lkeep.database.mixins.timestamp_mixins import TimestampsMixin
from lkeep.database.models.base import Base
class User(IDMixin, TimestampsMixin, Base):
"""
Класс User представляет пользователя в системе.
:ivar email: Email адрес пользователя.
:type email: str
:ivar hashed_password: Хэшированный пароль пользователя.
:type hashed_password: str
:ivar is_active: Флаг активности пользователя (True или False).
:type is_active: bool
:ivar is_superuser: Флаг суперпользователя (True или False).
:type is_superuser: bool
:ivar is_verified: Флаг подтверждения аккаунта (True или False).
:type is_verified: bool
"""
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(Text, unique=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@ -1,8 +1,20 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uvicorn
from fastapi import FastAPI
from lkeep.apps import apps_router
app = FastAPI()
app.include_router(router=apps_router)
def start():
uvicorn.run(app="lkeep.main:app", reload=True)

1249
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,16 @@ dependencies = [
"pre-commit (>=4.0.1,<5.0.0)",
"sqlalchemy (>=2.0.37,<3.0.0)",
"asyncpg (>=0.30.0,<0.31.0)",
"pydantic-settings (>=2.7.1,<3.0.0)"
"pydantic-settings (>=2.7.1,<3.0.0)",
"alembic (>=1.14.0,<2.0.0)",
"ruff (>=0.9.0,<0.10.0)",
"pydantic[email] (>=2.10.5,<3.0.0)",
"passlib (>=1.7.4,<2.0.0)",
"bcrypt (==4.0.1)",
"celery (>=5.4.0,<6.0.0)",
"redis (>=5.2.1,<6.0.0)",
"itsdangerous (>=2.2.0,<3.0.0)",
"pyjwt (>=2.10.1,<3.0.0)",
]
[build-system]
@ -22,3 +31,6 @@ build-backend = "poetry.core.masonry.api"
[project.scripts]
app = "lkeep.main:start"
[tool.poetry.group.dev.dependencies]
black = "^25.1.0"

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<title>Подтверждение регистрации</title>
</head>
<body>
<h1>Подтверждение регистрации</h1>
<p>Для подтверждения регистрации перейдите по ссылке:</p>
<a href="{{ confirmation_url }}">Подтвердить регистрацию</a>
</body>
</html>

0
tests/__init__.py Normal file
View File