Compare commits
26 Commits
083b588289
...
main
Author | SHA1 | Date | |
---|---|---|---|
78cc7e3f54 | |||
40d45e8379 | |||
28acd7d04d | |||
e2d0669064 | |||
c33e898218 | |||
807dbee647 | |||
5a2491ec91 | |||
7cfb2e734a | |||
217af1cd06 | |||
84446d44ce | |||
d3b2f4d71a | |||
aec3f41f4f | |||
c9de61e535 | |||
e421b6c0e1 | |||
c0ee89f175 | |||
623e9ba325 | |||
67f980d162 | |||
b71f6d2a81 | |||
2520d49a2b | |||
b10286773a | |||
c6e82f292d | |||
e8de7e21f2 | |||
a53ffb5213 | |||
ac19bb4397 | |||
77e3066e6d | |||
9b051edcf3 |
17
.env.example
17
.env.example
@ -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
|
||||
|
@ -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
|
||||
|
24
README.md
24
README.md
@ -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
115
alembic.ini
Normal 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
|
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
15
lkeep/apps/__init__.py
Normal file
15
lkeep/apps/__init__.py
Normal 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)
|
7
lkeep/apps/auth/__init__.py
Normal file
7
lkeep/apps/auth/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
52
lkeep/apps/auth/depends.py
Normal file
52
lkeep/apps/auth/depends.py
Normal 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
|
93
lkeep/apps/auth/handlers.py
Normal file
93
lkeep/apps/auth/handlers.py
Normal 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
161
lkeep/apps/auth/managers.py
Normal 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}")
|
27
lkeep/apps/auth/named_tuples.py
Normal file
27
lkeep/apps/auth/named_tuples.py
Normal 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
100
lkeep/apps/auth/routes.py
Normal 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
105
lkeep/apps/auth/schemas.py
Normal 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
127
lkeep/apps/auth/services.py
Normal 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
75
lkeep/apps/auth/tasks.py
Normal 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
27
lkeep/apps/auth/utils.py
Normal 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
|
@ -0,0 +1,11 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from .celery_config import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
|
15
lkeep/core/celery_config.py
Normal file
15
lkeep/core/celery_config.py
Normal 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"])
|
@ -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
|
||||
|
55
lkeep/core/core_dependency/redis_dependency.py
Normal file
55
lkeep/core/core_dependency/redis_dependency.py
Normal 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()
|
@ -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()
|
||||
|
7
lkeep/database/__init__.py
Normal file
7
lkeep/database/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
1
lkeep/database/alembic/README
Normal file
1
lkeep/database/alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
92
lkeep/database/alembic/env.py
Normal file
92
lkeep/database/alembic/env.py
Normal 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()
|
26
lkeep/database/alembic/script.py.mako
Normal file
26
lkeep/database/alembic/script.py.mako
Normal 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"}
|
@ -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 ###
|
7
lkeep/database/mixins/__init__.py
Normal file
7
lkeep/database/mixins/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
23
lkeep/database/mixins/id_mixins.py
Normal file
23
lkeep/database/mixins/id_mixins.py
Normal 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)
|
53
lkeep/database/mixins/timestamp_mixins.py
Normal file
53
lkeep/database/mixins/timestamp_mixins.py
Normal 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
|
13
lkeep/database/models/__init__.py
Normal file
13
lkeep/database/models/__init__.py
Normal 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")
|
25
lkeep/database/models/base.py
Normal file
25
lkeep/database/models/base.py
Normal 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()
|
37
lkeep/database/models/user.py
Normal file
37
lkeep/database/models/user.py
Normal 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)
|
@ -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
1249
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
11
templates/confirmation_email.html
Normal file
11
templates/confirmation_email.html
Normal 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
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user