Compare commits

..

7 Commits

Author SHA1 Message Date
8e80b4ab99 FastAPI 12. Интеграция Starlette Admin
All checks were successful
Lint project / lint (push) Successful in 21m11s
2025-10-16 14:59:08 +04:00
c74c8bffff fix: workflow python version
Some checks failed
Lint project / lint (push) Has been cancelled
2025-09-25 12:02:55 +04:00
be4e939f39 FastAPI 11. Хранение и сокращение ссылок
Some checks failed
Lint project / lint (push) Has been cancelled
2025-09-25 12:00:27 +04:00
5a299cf47d feat: приложение профиля
Some checks failed
Lint project / lint (push) Has been cancelled
- Добавлен новый маршрут смены почты и связанная с ним бизнес-логика
- Добавлен новый маршрут смены пароля и связанная с ним бизнес логика
- В схеме `AuthUser` изменена аннотация типа с `str` на `StringConstraints` с учётом минимальной и максимальной длинны пароля
- В `alembic.ini` исправлен путь до директории с миграциями после последнего обновления архитектуры под `Poetry 2.1.3`
- Добавлен `docker-compose.dev.yaml` для запуска БД и Redis в окружении для разработки
- Добавлен `Makefile` с описанием основных команд
- Обновлён `README.md`
2025-07-23 02:03:16 +04:00
170492a5c2 chore: обновление Poetry до актуальной версии (2.1.3) и правка структуры проекта.
Some checks failed
Lint project / lint (push) Has been cancelled
2025-07-18 16:57:50 +04:00
ae14c51b0e chore: обновление Poetry до актуальной версии (2.1.3) и правка структуры проекта.
Some checks failed
Lint project / lint (push) Has been cancelled
2025-07-18 16:54:59 +04:00
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
61 changed files with 1830 additions and 303 deletions

View File

@@ -19,7 +19,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.13"
cache: 'pip' cache: 'pip'
env: env:
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
@@ -36,7 +36,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install poetry==2.0.0 pip install poetry==2.1.3
poetry install poetry install
poetry run pre-commit install poetry run pre-commit install

View File

@@ -14,7 +14,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.13"
cache: 'pip' cache: 'pip'
- name: Cache pre-commit hooks - name: Cache pre-commit hooks
@@ -29,7 +29,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install poetry==2.0.0 pip install poetry==2.1.3
poetry install poetry install
poetry run pre-commit install poetry run pre-commit install

View File

@@ -19,7 +19,7 @@ repos:
rev: v3.19.0 rev: v3.19.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [ --py312-plus ] args: [ --py313-plus ]
- repo: https://github.com/hhatto/autopep8 - repo: https://github.com/hhatto/autopep8
rev: v2.3.1 rev: v2.3.1
@@ -39,7 +39,7 @@ repos:
rev: 24.10.0 rev: 24.10.0
hooks: hooks:
- id: black - id: black
language_version: python3.12 language_version: python3.13
args: [ --line-length=120 ] args: [ --line-length=120 ]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: install create_migration_dev install_dev lint migrate run_dev run_prod
install:
poetry install
install_dev: install
poetry run pre-commit install
run_dev:
poetry run app
run_prod: install migrate
poetry run app
run_all:
docker compose up -d
$(MAKE) run_prod
migrate:
poetry run alembic upgrade head
create_migration_dev:
@read -p "Введите описание ревизии: " msg; \
poetry run alembic revision --autogenerate -m "$$msg"
lint:
poetry run pre-commit run --all

View File

@@ -1,5 +1,10 @@
# Napkin Tools: Lkeep (Links Keeper) # Napkin Tools: Lkeep (Links Keeper)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/proDreams/lkeep/lint.yaml)
[![Код на салфетке](https://img.shields.io/badge/Telegram-Код_на_салфетке-blue)](https://t.me/press_any_button)
[![Заметки на салфетке](https://img.shields.io/badge/Telegram-Заметки_на_салфетке-blue)](https://t.me/writeanynotes)
[![Кот на салфетке](https://img.shields.io/badge/Telegram-Кот_на_салфетке-blue)](https://t.me/+Li2vbxfWo0Q4ZDk6)
Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI, Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI,
PostgreSQL, Poetry, Pydantic и других. PostgreSQL, Poetry, Pydantic и других.
@@ -53,6 +58,10 @@ PostgreSQL, Poetry, Pydantic и других.
6. [FastAPI 6. Пользовательский сервис и маршруты регистрации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-6-polzovatelskij-servis-i-marshruty-regist/) 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/) 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/) 8. [FastAPI 8. Маршрут авторизации и JWT](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-8-marshrut-avtorizacii-i-jwt/)
9. [FastAPI 9. Logout и проверка авторизации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-9-logout-i-proverka-avtorizacii/)
10. [FastAPI 10. Изменение данных пользователя](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-10-izmenenie-dannyh-polzovatelya/)
11. [FastAPI 11. Хранение и сокращение ссылок](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-11-hranenie-i-sokrashenie-ssylok/)
11. [FastAPI 12. Интеграция Starlette Admin](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-12-integraciya-starlette-admin/)
## Установка ## Установка
@@ -97,7 +106,14 @@ PostgreSQL, Poetry, Pydantic и других.
Затем откройте файл `.env` и заполните его значениями, соответствующими вашей системе (например, настройки Затем откройте файл `.env` и заполните его значениями, соответствующими вашей системе (например, настройки
подключения к базе данных PostgreSQL). подключения к базе данных PostgreSQL).
4. **Запустите приложение:** 4. **Запустите БД и Redis**
Для запуска контейнера с PostgreSQL и Redis используйте команду в терминале:
```bash
docker compose up -d
```
5. **Запустите приложение:**
Для запуска сервера в режиме разработки используйте команду с Poetry: Для запуска сервера в режиме разработки используйте команду с Poetry:
```bash ```bash

View File

@@ -3,7 +3,7 @@
[alembic] [alembic]
# path to migration scripts. # path to migration scripts.
# Use forward slashes (/) also on windows to provide an os agnostic path # Use forward slashes (/) also on windows to provide an os agnostic path
script_location = lkeep/database/alembic script_location = src/lkeep/database/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # 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 # Uncomment the line below if you want the files to be prepended with date and time

39
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,39 @@
services:
lkeep_db:
image: postgres:17.5-alpine
container_name: lkeep_db
restart: always
environment:
- TZ=Europe/Moscow
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
- PGDATA=/var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- lkeep_db:/var/lib/postgresql/data
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: [ "CMD-SHELL", "sh -c 'pg_isready -U ${DB_USER} -d ${DB_NAME}'" ]
interval: 10s
timeout: 3s
retries: 3
lkeep_redis:
image: redis:7.4-alpine
container_name: lkeep_redis
restart: always
ports:
- "6379:6379"
volumes:
- lkeep_redis:/data
healthcheck:
test: [ "CMD-SHELL", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 3
volumes:
lkeep_db:
lkeep_redis:

818
poetry.lock generated

File diff suppressed because it is too large Load Diff

2
poetry.toml Normal file
View File

@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true

View File

@@ -6,31 +6,40 @@ authors = [
{ name = "proDream", email = "sushkoos@gmail.com" } { name = "proDream", email = "sushkoos@gmail.com" }
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12,<4"
dependencies = [ dependencies = [
"fastapi[standard] (>=0.115.6,<0.116.0)", "fastapi[standard] (>=0.118.0,<0.120.0)",
"uvicorn[standard] (>=0.34.0,<0.35.0)", "uvicorn[standard] (>=0.37.0,<0.40.0)",
"pre-commit (>=4.0.1,<5.0.0)", "pre-commit (>=4.3.0,<5.0.0)",
"sqlalchemy (>=2.0.37,<3.0.0)", "sqlalchemy (>=2.0.44,<3.0.0)",
"asyncpg (>=0.30.0,<0.31.0)", "asyncpg (>=0.30.0,<0.31.0)",
"pydantic-settings (>=2.7.1,<3.0.0)", "pydantic-settings (>=2.11.0,<3.0.0)",
"alembic (>=1.14.0,<2.0.0)", "alembic (>=1.17.0,<2.0.0)",
"ruff (>=0.9.0,<0.10.0)", "ruff (>=0.14.0,<0.20.0)",
"pydantic[email] (>=2.10.5,<3.0.0)", "pydantic[email] (>=2.12.1,<3.0.0)",
"passlib (>=1.7.4,<2.0.0)", "passlib (>=1.7.4,<2.0.0)",
"bcrypt (==4.0.1)", "bcrypt (==4.0.1)",
"celery (>=5.4.0,<6.0.0)", "celery (>=5.5.3,<6.0.0)",
"redis (>=5.2.1,<6.0.0)", "redis (>=6.4.0,<7.0.0)",
"itsdangerous (>=2.2.0,<3.0.0)", "itsdangerous (>=2.2.0,<3.0.0)",
"pyjwt (>=2.10.1,<3.0.0)", "pyjwt (>=2.10.1,<3.0.0)",
"poetry-core (>=2.2.1,<3.0.0)",
"starlette-admin (>=0.15.1,<0.16.0)",
] ]
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry]
packages = [{include = "lkeep", from = "src"}]
[project.scripts] [project.scripts]
app = "lkeep.main:start" app = "lkeep.main:start"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^25.1.0" black = "^25.1.0"
[tool.ruff]
line-length = 120
indent-width = 4

View File

@@ -9,7 +9,11 @@ https://pressanybutton.ru/category/servis-na-fastapi/
from fastapi import APIRouter from fastapi import APIRouter
from lkeep.apps.auth.routes import auth_router from lkeep.apps.auth.routes import auth_router
from lkeep.apps.links.routes import links_router
from lkeep.apps.profile.routes import profile_router
apps_router = APIRouter(prefix="/api/v1") apps_router = APIRouter(prefix="/api/v1")
apps_router.include_router(router=auth_router) apps_router.include_router(router=auth_router)
apps_router.include_router(router=profile_router)
apps_router.include_router(router=links_router)

View File

@@ -0,0 +1,80 @@
from fastapi import HTTPException
from pydantic import EmailStr, ValidationError
from starlette.requests import Request
from starlette.responses import Response
from starlette_admin.auth import AuthProvider
from starlette_admin.exceptions import LoginFailed
from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.managers import UserManager
from lkeep.apps.auth.schemas import AuthUser, GetUserByID
from lkeep.apps.auth.utils import get_token_from_cookies
from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.core.core_dependency.redis_dependency import RedisDependency
from lkeep.core.settings import settings
class AdminAuthProvider(AuthProvider):
def __init__(self, handler: AuthHandler, manager: UserManager):
super().__init__()
self.handler = handler
self.manager = manager
async def is_authenticated(self, request: Request) -> bool:
try:
token = await get_token_from_cookies(request=request)
decoded_token = await self.handler.decode_access_token(token=token)
except HTTPException:
return False
user_id = str(decoded_token.get("user_id"))
session_id = str(decoded_token.get("session_id"))
if not await self.manager.get_access_token(user_id=user_id, session_id=session_id):
return False
request.state.user = {"id": user_id, "session_id": session_id}
return True
async def login(
self, email: EmailStr, password: str, remember_me: bool, request: Request, response: Response
) -> Response:
try:
auth_data = AuthUser(email=email, password=password)
except ValidationError:
raise LoginFailed(msg="Invalid email or password")
exist_user = await self.manager.get_user_by_email_for_admin(email=auth_data.email)
if (
exist_user is None
or not exist_user.is_superuser
or not await self.handler.verify_password(
hashed_password=exist_user.hashed_password, raw_password=auth_data.password
)
):
raise LoginFailed(msg="Invalid credentials")
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.set_cookie(key="Authorization", value=token, httponly=True, max_age=settings.access_token_expire)
return response
async def logout(self, request: Request, response: Response) -> Response:
response.delete_cookie("Authorization")
user = request.state.user
await self.manager.revoke_access_token(user_id=user["id"], session_id=user["session_id"])
return response
def get_admin_user(self, request: Request) -> GetUserByID:
return GetUserByID(**request.state.user)
def get_admin_auth_provider() -> AdminAuthProvider:
manager = UserManager(db=DBDependency(), redis=RedisDependency())
return AdminAuthProvider(handler=AuthHandler(), manager=manager)

View File

@@ -0,0 +1,17 @@
from fastapi import FastAPI
from starlette_admin.contrib.sqla import Admin
from lkeep.apps.admin.admin_auth import get_admin_auth_provider
from lkeep.apps.admin.views.link_view import LinkViewView
from lkeep.apps.admin.views.user_view import UserView
from lkeep.core.core_dependency.db_dependency import get_db_engine
from lkeep.database.models import Link, User
def setup_admin(app: FastAPI) -> None:
admin = Admin(engine=get_db_engine(), title="Lkeep Admin", auth_provider=get_admin_auth_provider())
admin.add_view(UserView(User))
admin.add_view(LinkViewView(Link))
admin.mount_to(app=app)

View File

View File

@@ -0,0 +1,20 @@
from typing import Any
from starlette.requests import Request
from starlette_admin import StringField
from starlette_admin.contrib.sqla import ModelView
from lkeep.database.models import Link
class LinkViewView(ModelView):
fields = [
"id",
"full_link",
"short_link",
StringField("owner_id", label="owner_id", read_only=True),
]
async def before_create(self, request: Request, data: dict[str, Any], link: Link):
admin_user = request.state.user
link.owner_id = admin_user["id"]

View File

@@ -0,0 +1,49 @@
from typing import Any
from email_validator import EmailSyntaxError, validate_email
from passlib.context import CryptContext
from starlette.requests import Request
from starlette_admin import PasswordField
from starlette_admin.contrib.sqla import ModelView
from starlette_admin.exceptions import FormValidationError
from lkeep.database.models import User
class UserView(ModelView):
fields = [
"id",
"email",
PasswordField(
name="password",
label="Password",
required=True,
exclude_from_list=True,
exclude_from_detail=True,
exclude_from_edit=True,
),
"is_active",
"is_verified",
"is_superuser",
"created_at",
"updated_at",
]
exclude_fields_from_edit = ["created_at", "updated_at"]
exclude_fields_from_create = ["created_at", "updated_at"]
sortable_fields = ["email", "created_at"]
fields_default_sort = [("created_at", True)]
searchable_fields = ["email"]
page_size = 20
page_size_options = [5, 10, 25, 50, -1]
async def before_create(self, request: Request, data: dict[str, Any], user: User) -> None:
try:
validate_email(data["email"])
except EmailSyntaxError:
raise FormValidationError(errors={"email": "Invalid email"})
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
user.hashed_password = pwd_context.hash(data.pop("password"))

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

@@ -10,7 +10,9 @@ import datetime
import uuid import uuid
import jwt import jwt
from fastapi import HTTPException
from passlib.context import CryptContext from passlib.context import CryptContext
from starlette import status
from lkeep.apps.auth.named_tuples import CreateTokenTuple from lkeep.apps.auth.named_tuples import CreateTokenTuple
from lkeep.core.settings import settings from lkeep.core.settings import settings
@@ -70,3 +72,22 @@ class AuthHandler:
encoded_jwt = jwt.encode(payload=data, key=self.secret, algorithm="HS256") encoded_jwt = jwt.encode(payload=data, key=self.secret, algorithm="HS256")
return CreateTokenTuple(encoded_jwt=encoded_jwt, session_id=session_id) 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")

View File

@@ -12,7 +12,13 @@ from fastapi import Depends, HTTPException
from sqlalchemy import insert, select, update from sqlalchemy import insert, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from lkeep.apps.auth.schemas import CreateUser, GetUserWithIDAndEmail, UserReturnData from lkeep.apps.auth.schemas import (
CreateUser,
GetUserForAdmin,
GetUserWithIDAndEmail,
UserReturnData,
UserVerifySchema,
)
from lkeep.core.core_dependency.db_dependency import DBDependency from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.core.core_dependency.redis_dependency import RedisDependency from lkeep.core.core_dependency.redis_dependency import RedisDependency
from lkeep.database.models import User from lkeep.database.models import User
@@ -93,6 +99,41 @@ class UserManager:
return None return None
async def get_user_by_email_for_admin(self, email: str) -> GetUserForAdmin | None:
async with self.db.db_session() as session:
query = select(self.model.id, self.model.email, self.model.hashed_password, self.model.is_superuser).where(
self.model.email == email
)
result = await session.execute(query)
user = result.mappings().first()
if user:
return GetUserForAdmin(**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: async def store_access_token(self, token: str, user_id: uuid.UUID | str, session_id: str) -> None:
""" """
Сохраняет токен доступа в хранилище (Redis). Сохраняет токен доступа в хранилище (Redis).
@@ -106,3 +147,30 @@ class UserManager:
""" """
async with self.redis.get_client() as client: async with self.redis.get_client() as client:
await client.set(f"{user_id}:{session_id}", token) 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

@@ -6,11 +6,14 @@
https://pressanybutton.ru/category/servis-na-fastapi/ https://pressanybutton.ru/category/servis-na-fastapi/
""" """
from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from starlette import status from starlette import status
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from lkeep.apps.auth.schemas import AuthUser, UserReturnData 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 from lkeep.apps.auth.services import UserService
auth_router = APIRouter(prefix="/auth", tags=["auth"]) auth_router = APIRouter(prefix="/auth", tags=["auth"])
@@ -63,3 +66,35 @@ async def login(user: AuthUser, service: UserService = Depends(UserService)) ->
:raises HTTPException: Если учетные данные не верны или произошла другая ошибка при входе. :raises HTTPException: Если учетные данные не верны или произошла другая ошибка при входе.
""" """
return await service.login_user(user=user) 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

View File

@@ -8,8 +8,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/
import datetime import datetime
import uuid import uuid
from typing import Annotated
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, StringConstraints
class GetUserByID(BaseModel): class GetUserByID(BaseModel):
@@ -17,10 +18,10 @@ class GetUserByID(BaseModel):
Класс для получения пользователя по его уникальному идентификатору (ID). Класс для получения пользователя по его уникальному идентификатору (ID).
:ivar id: Уникальный идентификатор пользователя, может быть представлен как объект типа uuid.UUID или строкой. :ivar id: Уникальный идентификатор пользователя, может быть представлен как объект типа uuid.UUID или строкой.
:type id: uuid.UUID | str :type id: uuid.UUID
""" """
id: uuid.UUID | str id: uuid.UUID
class GetUserByEmail(BaseModel): class GetUserByEmail(BaseModel):
@@ -34,6 +35,17 @@ class GetUserByEmail(BaseModel):
email: EmailStr email: EmailStr
class UserVerifySchema(GetUserByID, GetUserByEmail):
"""
Класс для валидации данных пользователя.
Данный класс наследует методы из классов GetUserByID и GetUserByEmail,
что позволяет использовать их функциональность для проверки данных пользователя.
"""
session_id: uuid.UUID | str | None = None
class AuthUser(GetUserByEmail): class AuthUser(GetUserByEmail):
""" """
Класс для регистрации пользователя, наследующий класс GetUserByEmail. Класс для регистрации пользователя, наследующий класс GetUserByEmail.
@@ -42,7 +54,7 @@ class AuthUser(GetUserByEmail):
:type password: str :type password: str
""" """
password: str password: Annotated[str, StringConstraints(min_length=8, max_length=128)]
class CreateUser(GetUserByEmail): class CreateUser(GetUserByEmail):
@@ -71,6 +83,10 @@ class GetUserWithIDAndEmail(GetUserByID, CreateUser):
pass pass
class GetUserForAdmin(GetUserWithIDAndEmail):
is_superuser: bool
class UserReturnData(GetUserByID, GetUserByEmail): class UserReturnData(GetUserByID, GetUserByEmail):
""" """
Класс для представления данных пользователя, возвращаемых из API. Класс для представления данных пользователя, возвращаемых из API.

View File

@@ -13,7 +13,12 @@ from starlette.responses import JSONResponse
from lkeep.apps.auth.handlers import AuthHandler from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.managers import UserManager from lkeep.apps.auth.managers import UserManager
from lkeep.apps.auth.schemas import AuthUser, CreateUser, UserReturnData from lkeep.apps.auth.schemas import (
AuthUser,
CreateUser,
UserReturnData,
UserVerifySchema,
)
from lkeep.apps.auth.tasks import send_confirmation_email from lkeep.apps.auth.tasks import send_confirmation_email
from lkeep.core.settings import settings from lkeep.core.settings import settings
@@ -85,7 +90,7 @@ class UserService:
""" """
exist_user = await self.manager.get_user_by_email(email=user.email) exist_user = await self.manager.get_user_by_email(email=user.email)
if exist_user is None or not self.handler.verify_password( if exist_user is None or not await self.handler.verify_password(
hashed_password=exist_user.hashed_password, raw_password=user.password hashed_password=exist_user.hashed_password, raw_password=user.password
): ):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong email or password") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong email or password")
@@ -103,3 +108,20 @@ class UserService:
) )
return response 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

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,124 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uuid
from fastapi import Depends
from sqlalchemy import delete, insert, select
from lkeep.apps.links.schemas import GetLinkSchema, LinkSchema
from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.database.models import Link
class LinksManager:
"""
Менеджер для выполнения операций над ссылками в базе данных.
"""
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None:
"""
Инициализирует менеджер с зависимостью доступа к базе данных.
:param db: Объект для получения асинхронных сессий с базой данных.
:type db: DBDependency
"""
self.db = db
self.link_model = Link
async def get_link(self, short_link: str) -> GetLinkSchema | None:
"""
Возвращает полную ссылку по короткому идентификатору.
:param short_link: Сокращенный идентификатор ссылки.
:type short_link: str
:returns: Найденная ссылка или None, если запись отсутствует.
:rtype: GetLinkSchema | None
"""
async with self.db.db_session() as session:
query = select(self.link_model.full_link).where(self.link_model.short_link == short_link)
result = await session.execute(query)
link = result.scalar_one_or_none()
if link:
return GetLinkSchema(full_link=link)
return None
async def get_links(self, user_id: uuid.UUID) -> list[LinkSchema]:
"""
Получает список ссылок, принадлежащих пользователю.
:param user_id: Идентификатор владельца ссылок.
:type user_id: uuid.UUID
:returns: Список ссылок пользователя.
:rtype: list[LinkSchema]
"""
async with self.db.db_session() as session:
query = select(self.link_model).where(self.link_model.owner_id == user_id)
result = await session.execute(query)
links = result.scalars().all()
return [LinkSchema.model_validate(link, from_attributes=True) for link in links]
async def create_link(self, full_link: str, user_id: uuid.UUID, short_link: str) -> LinkSchema:
"""
Создает новую ссылку и возвращает сохраненную запись.
:param full_link: Полный адрес, который требуется сократить.
:type full_link: str
:param user_id: Идентификатор владельца ссылки.
:type user_id: uuid.UUID
:param short_link: Сгенерированное короткое представление ссылки.
:type short_link: str
:returns: Созданная ссылка с заполненными полями.
:rtype: LinkSchema
"""
async with self.db.db_session() as session:
query = (
insert(self.link_model)
.values(full_link=full_link, short_link=short_link, owner_id=user_id)
.returning(self.link_model)
)
result = await session.execute(query)
await session.commit()
link = result.scalar()
return LinkSchema.model_validate(link, from_attributes=True)
async def get_link_owner(self, link_id: uuid.UUID) -> uuid.UUID | None:
"""
Возвращает идентификатор владельца ссылки.
:param link_id: Идентификатор ссылки.
:type link_id: uuid.UUID
:returns: Идентификатор владельца или None, если ссылка не найдена.
:rtype: uuid.UUID | None
"""
async with self.db.db_session() as session:
query = select(self.link_model.owner_id).where(self.link_model.id == link_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def delete_link(self, link_id: uuid.UUID) -> None:
"""
Удаляет ссылку по ее идентификатору.
:param link_id: Идентификатор ссылки, которую требуется удалить.
:type link_id: uuid.UUID
:returns: None
"""
async with self.db.db_session() as session:
query = delete(self.link_model).where(self.link_model.id == link_id)
await session.execute(query)
await session.commit()

View File

@@ -0,0 +1,97 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from starlette import status
from lkeep.apps.auth.depends import get_current_user
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.links.schemas import (
CreateLinkSchema,
DeleteLinkSchema,
GetLinkSchema,
LinkSchema,
)
from lkeep.apps.links.services import LinksService
links_router = APIRouter(prefix="/links", tags=["links"])
@links_router.get("/get_link", response_model=GetLinkSchema | None, status_code=status.HTTP_200_OK)
async def get_link(short_link: str, service: LinksService = Depends(LinksService)) -> GetLinkSchema | None:
"""
Возвращает полную ссылку по сокращенному идентификатору.
:param short_link: Короткий идентификатор ссылки.
:type short_link: str
:param service: Сервис ссылок, содержащий бизнес-логику.
:type service: LinksService
:returns: Полная ссылка либо None, если запись не найдена.
:rtype: GetLinkSchema | None
"""
return await service.get_link(short_link=short_link)
@links_router.get("/get_user_links", response_model=list[LinkSchema], status_code=status.HTTP_200_OK)
async def get_user_links(
user: Annotated[UserVerifySchema, Depends(get_current_user)], service: LinksService = Depends(LinksService)
) -> list[LinkSchema]:
"""
Возвращает список ссылок текущего пользователя.
:param user: Авторизованный пользователь, для которого запрашиваются ссылки.
:type user: UserVerifySchema
:param service: Сервис ссылок, выполняющий выборку данных.
:type service: LinksService
:returns: Коллекция ссылок пользователя.
:rtype: list[LinkSchema]
"""
return await service.get_links(user=user)
@links_router.post("/create_link", response_model=LinkSchema, status_code=status.HTTP_201_CREATED)
async def create_link(
link_data: CreateLinkSchema,
user: Annotated[UserVerifySchema, Depends(get_current_user)],
service: LinksService = Depends(LinksService),
) -> LinkSchema:
"""
Создает новую сокращенную ссылку для пользователя.
:param link_data: Данные с полным адресом ссылки.
:type link_data: CreateLinkSchema
:param user: Пользователь, для которого создается ссылка.
:type user: UserVerifySchema
:param service: Сервис ссылок, отвечающий за генерацию и сохранение записи.
:type service: LinksService
:returns: Созданная ссылка с коротким идентификатором.
:rtype: LinkSchema
"""
return await service.create_link(link_data=link_data, user=user)
@links_router.delete("/delete_link", status_code=status.HTTP_204_NO_CONTENT)
async def delete_link(
link_data: DeleteLinkSchema,
user: Annotated[UserVerifySchema, Depends(get_current_user)],
service: LinksService = Depends(LinksService),
) -> None:
"""
Удаляет ссылку, если она принадлежит текущему пользователю.
:param link_data: Данные с идентификатором ссылки для удаления.
:type link_data: DeleteLinkSchema
:param user: Авторизованный пользователь, запрашивающий удаление.
:type user: UserVerifySchema
:param service: Сервис ссылок, выполняющий проверку владельца и удаление.
:type service: LinksService
:returns: None
"""
await service.delete_link(link_data=link_data, user=user)

View File

@@ -0,0 +1,45 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uuid
from datetime import datetime
from pydantic import BaseModel
class BaseFullLink(BaseModel):
"""
Базовая схема, содержащая полный адрес ссылки.
"""
full_link: str
class DeleteLinkSchema(BaseModel):
"""
Схема для удаления ссылки по идентификатору.
"""
id: uuid.UUID
class LinkSchema(BaseFullLink, DeleteLinkSchema):
"""
Полная схема ссылки с коротким адресом и метаданными.
"""
short_link: str
created_at: datetime
class GetLinkSchema(BaseFullLink):
"""Схема ответа при получении полной ссылки по сокращенному адресу."""
class CreateLinkSchema(BaseFullLink):
"""Схема запроса на создание новой ссылки."""

View File

@@ -0,0 +1,100 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import secrets
from fastapi import Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from starlette import status
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.links.managers import LinksManager
from lkeep.apps.links.schemas import (
CreateLinkSchema,
DeleteLinkSchema,
GetLinkSchema,
LinkSchema,
)
from lkeep.core.settings import settings
class LinksService:
"""
Сервисный слой для работы с пользовательскими ссылками.
"""
def __init__(self, manager: LinksManager = Depends(LinksManager)) -> None:
"""
Создает сервис со связанным менеджером ссылок.
:param manager: Менеджер, выполняющий операции с базой данных.
:type manager: LinksManager
"""
self.manager = manager
async def get_link(self, short_link: str) -> GetLinkSchema | None:
"""
Получает полную ссылку по ее короткому представлению.
:param short_link: Сокращенный идентификатор ссылки.
:type short_link: str
:returns: Полная ссылка или None, если запись отсутствует.
:rtype: GetLinkSchema | None
"""
return await self.manager.get_link(short_link=short_link)
async def get_links(self, user: UserVerifySchema) -> list[LinkSchema]:
"""
Возвращает все ссылки, принадлежащие пользователю.
:param user: Данные авторизованного пользователя.
:type user: UserVerifySchema
:returns: Список ссылок пользователя.
:rtype: list[LinkSchema]
"""
return await self.manager.get_links(user_id=user.id)
async def create_link(self, link_data: CreateLinkSchema, user: UserVerifySchema) -> LinkSchema:
"""
Создает новую сокращенную ссылку для пользователя.
:param link_data: Данные запроса с полным адресом ссылки.
:type link_data: CreateLinkSchema
:param user: Пользователь, которому будет принадлежать ссылка.
:type user: UserVerifySchema
:returns: Созданная запись о ссылке.
:rtype: LinkSchema
"""
link_length = settings.link_length
while True:
short_link = secrets.token_urlsafe(link_length)[:link_length]
try:
return await self.manager.create_link(
full_link=link_data.full_link, user_id=user.id, short_link=short_link
)
except IntegrityError:
continue
async def delete_link(self, link_data: DeleteLinkSchema, user: UserVerifySchema) -> None:
"""
Удаляет ссылку пользователя после проверки владельца.
:param link_data: Данные с идентификатором ссылки для удаления.
:type link_data: DeleteLinkSchema
:param user: Пользователь, запрашивающий удаление.
:type user: UserVerifySchema
:raises HTTPException: Если пользователь не является владельцем ссылки.
:returns: None
"""
link_owner = await self.manager.get_link_owner(link_id=link_data.id)
if link_owner and link_owner == user.id:
await self.manager.delete_link(link_id=link_data.id)
else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Wrong link owner")

View File

@@ -0,0 +1,65 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uuid
from typing import Any
from fastapi import Depends
from sqlalchemy import select, update
from lkeep.core.core_dependency.db_dependency import DBDependency
from lkeep.database.models import User
class ProfileManager:
"""
Менеджер для работы с данными профиля в базе данных.
"""
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None:
"""
Инициализирует менеджер с зависимостью доступа к базе данных.
:param db: Провайдер асинхронных сессий базы данных.
:type db: DBDependency
"""
self.db = db
self.user_model = User
async def update_user_fields(self, user_id: uuid.UUID | str, **kwargs: Any) -> None:
"""
Обновляет выбранные поля пользователя по его идентификатору.
:param user_id: Идентификатор пользователя, данные которого нужно изменить.
:type user_id: uuid.UUID | str
:param kwargs: Поля и значения, подлежащие обновлению.
:type kwargs: Any
:returns: None
"""
async with self.db.db_session() as session:
query = update(self.user_model).where(self.user_model.id == user_id).values(**kwargs)
await session.execute(query)
await session.commit()
async def get_user_hashed_password(self, user_id: uuid.UUID | str) -> str:
"""
Возвращает хешированный пароль пользователя.
:param user_id: Идентификатор пользователя для поиска.
:type user_id: uuid.UUID | str
:returns: Хеш текущего пароля пользователя.
:rtype: str
"""
async with self.db.db_session() as session:
query = select(self.user_model.hashed_password).where(self.user_model.id == user_id)
result = await session.execute(query)
return result.scalar()

View File

@@ -0,0 +1,61 @@
"""
Проект: 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 Response
from lkeep.apps.auth.depends import get_current_user
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.profile.schemas import ChangeEmailRequest, ChangePasswordRequest
from lkeep.apps.profile.services import ProfileService
profile_router = APIRouter(prefix="/profile", tags=["profile"])
@profile_router.post("/change-email", status_code=status.HTTP_200_OK)
async def change_email(
data: ChangeEmailRequest,
user: Annotated[UserVerifySchema, Depends(get_current_user)],
service: ProfileService = Depends(ProfileService),
) -> None:
"""
Изменяет адрес электронной почты текущего пользователя.
:param data: Данные с новым адресом электронной почты пользователя.
:type data: ChangeEmailRequest
:param user: Авторизованный пользователь, инициирующий изменение почты.
:type user: UserVerifySchema
:param service: Сервисный слой, выполняющий бизнес-логику профиля.
:type service: ProfileService
:returns: None
"""
return await service.change_email(data=data, user=user)
@profile_router.post("/change-password", status_code=status.HTTP_200_OK)
async def change_password(
data: ChangePasswordRequest,
user: Annotated[UserVerifySchema, Depends(get_current_user)],
service: ProfileService = Depends(ProfileService),
) -> Response:
"""
Обновляет пароль авторизованного пользователя.
:param data: Данные с текущим и новым паролем.
:type data: ChangePasswordRequest
:param user: Пользователь, для которого выполняется смена пароля.
:type user: UserVerifySchema
:param service: Сервис профиля, реализующий проверку и обновление данных.
:type service: ProfileService
:returns: HTTP-ответ, подтверждающий успешную операцию либо ошибку.
:rtype: Response
"""
return await service.change_password(data=data, user=user)

View File

@@ -0,0 +1,28 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from typing import Annotated
from pydantic import BaseModel, EmailStr, StringConstraints
class ChangeEmailRequest(BaseModel):
"""
Схема запроса на изменение электронной почты пользователя.
"""
new_email: EmailStr
class ChangePasswordRequest(BaseModel):
"""
Схема запроса на обновление пароля пользователя.
"""
old_password: Annotated[str, StringConstraints(min_length=8, max_length=128)]
new_password: Annotated[str, StringConstraints(min_length=8, max_length=128)]

View File

@@ -0,0 +1,69 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from fastapi import Depends
from starlette.responses import JSONResponse
from lkeep.apps.auth.handlers import AuthHandler
from lkeep.apps.auth.schemas import UserVerifySchema
from lkeep.apps.profile.managers import ProfileManager
from lkeep.apps.profile.schemas import ChangeEmailRequest, ChangePasswordRequest
class ProfileService:
"""
Сервис для работы с данными профиля пользователя.
"""
def __init__(
self,
manager: ProfileManager = Depends(ProfileManager),
handler: AuthHandler = Depends(AuthHandler),
) -> None:
"""
Создает экземпляр сервиса профиля с необходимыми зависимостями.
:param manager: Менеджер для выполнения операций с моделью пользователя.
:type manager: ProfileManager
:param handler: Обработчик аутентификации, предоставляющий функции хеширования и проверки пароля.
:type handler: AuthHandler
"""
self.manager = manager
self.handler = handler
async def change_email(self, data: ChangeEmailRequest, user: UserVerifySchema) -> None:
"""
Обновляет адрес электронной почты пользователя.
:param data: Запрос с новым адресом электронной почты.
:type data: ChangeEmailRequest
:param user: Пользователь, для которого применяется изменение.
:type user: UserVerifySchema
:returns: None
"""
return await self.manager.update_user_fields(user_id=user.id, email=data.new_email)
async def change_password(self, data: ChangePasswordRequest, user: UserVerifySchema) -> None | JSONResponse:
"""
Изменяет пароль пользователя после проверки старого значения.
:param data: Запрос, содержащий старый и новый пароль.
:type data: ChangePasswordRequest
:param user: Пользователь, выполняющий смену пароля.
:type user: UserVerifySchema
:returns: None при успешном обновлении либо JSON-ответ с ошибкой.
:rtype: None | JSONResponse
"""
current_password_hash = await self.manager.get_user_hashed_password(user_id=user.id)
if await self.handler.verify_password(raw_password=data.old_password, hashed_password=current_password_hash):
hashed_password = await self.handler.get_password_hash(password=data.new_password)
await self.manager.update_user_fields(user_id=user.id, hashed_password=hashed_password)
return None
return JSONResponse({"error": "Invalid password"}, status_code=401)

View File

@@ -5,16 +5,3 @@
Специально для проекта "Код на салфетке" Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/ 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)

View File

@@ -1,4 +1,17 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine """
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from lkeep.core.settings import settings from lkeep.core.settings import settings
@@ -24,3 +37,11 @@ class DBDependency:
:rtype: async_sessionmaker[AsyncSession] :rtype: async_sessionmaker[AsyncSession]
""" """
return self._session_factory return self._session_factory
@property
def db_engine(self) -> AsyncEngine:
return self._engine
def get_db_engine() -> AsyncEngine:
return DBDependency().db_engine

View File

@@ -41,7 +41,7 @@ class RedisDependency:
return ConnectionPool.from_url(url=self._url, encoding="utf-8", decode_responses=True) return ConnectionPool.from_url(url=self._url, encoding="utf-8", decode_responses=True)
@asynccontextmanager @asynccontextmanager
async def get_client(self) -> AsyncGenerator[Redis, None]: async def get_client(self) -> AsyncGenerator[Redis]:
""" """
Получает клиентскую сессию Redis для взаимодействия с базой данных. Получает клиентскую сессию Redis для взаимодействия с базой данных.

View File

@@ -119,6 +119,8 @@ class Settings(BaseSettings):
:type frontend_url: str :type frontend_url: str
:ivar access_token_expire: Срок жизни JWT-токена :ivar access_token_expire: Срок жизни JWT-токена
:type access_token_expire: int :type access_token_expire: int
:ivar link_length: Максимальная длина короткой ссылки
:type link_length: int
""" """
db_settings: DBSettings = DBSettings() db_settings: DBSettings = DBSettings()
@@ -128,6 +130,8 @@ class Settings(BaseSettings):
templates_dir: str = "templates" templates_dir: str = "templates"
frontend_url: str frontend_url: str
access_token_expire: int access_token_expire: int
domain: str
link_length: int = 12
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")

View File

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

View File

View File

@@ -0,0 +1,41 @@
"""Links model
Revision ID: d93cd9da97e5
Revises: ccf7560dd457
Create Date: 2025-09-24 14:30:04.603787
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d93cd9da97e5"
down_revision: str | None = "ccf7560dd457"
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(
"link",
sa.Column("full_link", sa.String(), nullable=False),
sa.Column("short_link", sa.String(length=12), nullable=False),
sa.Column("owner_id", sa.UUID(), 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.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_link_short_link"), "link", ["short_link"], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_link_short_link"), table_name="link")
op.drop_table("link")
# ### end Alembic commands ###

View File

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

View File

@@ -7,7 +7,8 @@ https://pressanybutton.ru/category/servis-na-fastapi/
""" """
from lkeep.database.models.base import Base from lkeep.database.models.base import Base
from lkeep.database.models.links import Link
from lkeep.database.models.user import User from lkeep.database.models.user import User
__all__ = ("Base", "User") __all__ = ("Base", "User", "Link")

View File

@@ -0,0 +1,31 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
from sqlalchemy import UUID, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from lkeep.database.mixins.id_mixins import IDMixin
from lkeep.database.mixins.timestamp_mixins import CreatedAtMixin
from lkeep.database.models import Base
class Link(IDMixin, CreatedAtMixin, Base):
"""
Модель сокращенной ссылки с указанием владельца.
:ivar full_link: Полная ссылка
:type full_link: str
:ivar short_link: Сокращённая ссылка
:type short_link: str
:ivar owner_id: Создатель ссылки
:type owner_id: UUID
"""
full_link: Mapped[str] = mapped_column(String)
short_link: Mapped[str] = mapped_column(String(12), unique=True, index=True)
owner_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))

37
src/lkeep/main.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Проект: Lkeep
Автор: Иван Ашихмин
Год: 2025
Специально для проекта "Код на салфетке"
https://pressanybutton.ru/category/servis-na-fastapi/
"""
import uvicorn
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from lkeep.apps import apps_router
from lkeep.apps.admin.admin_base import setup_admin
app = FastAPI()
app.include_router(router=apps_router)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:3000"], # TODO: ЗАМЕНИТЬ ПОТОМ НА ДОМЕН
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
setup_admin(app=app)
def start():
"""
Запускает локальный сервер приложения с поддержкой автоматической перезагрузки.
:returns: None
"""
uvicorn.run(app="lkeep.main:app", reload=True)

0
tests/__init__.py Normal file
View File