From be4e939f392b6749c07495d38a886ec21e2e1721 Mon Sep 17 00:00:00 2001 From: prodream Date: Thu, 25 Sep 2025 12:00:27 +0400 Subject: [PATCH] =?UTF-8?q?FastAPI=2011.=20=D0=A5=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=81=D0=BE=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 4 +- README.md | 7 +- poetry.toml | 2 + src/lkeep/apps/__init__.py | 2 + src/lkeep/apps/auth/schemas.py | 4 +- src/lkeep/apps/links/__init__.py | 7 + src/lkeep/apps/links/managers.py | 124 ++++++++++++++++++ src/lkeep/apps/links/routes.py | 97 ++++++++++++++ src/lkeep/apps/links/schemas.py | 45 +++++++ src/lkeep/apps/links/services.py | 100 ++++++++++++++ src/lkeep/apps/profile/__init__.py | 7 + src/lkeep/apps/profile/managers.py | 35 +++++ src/lkeep/apps/profile/routes.py | 31 +++++ src/lkeep/apps/profile/schemas.py | 16 +++ src/lkeep/apps/profile/services.py | 39 ++++++ src/lkeep/core/core_dependency/__init__.py | 7 + .../core/core_dependency/db_dependency.py | 8 ++ .../core/core_dependency/redis_dependency.py | 2 +- src/lkeep/core/settings.py | 3 + ...025_09_24_1430-d93cd9da97e5_links_model.py | 41 ++++++ src/lkeep/database/models/__init__.py | 3 +- src/lkeep/database/models/links.py | 31 +++++ src/lkeep/main.py | 5 + 23 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 poetry.toml create mode 100644 src/lkeep/apps/links/__init__.py create mode 100644 src/lkeep/apps/links/managers.py create mode 100644 src/lkeep/apps/links/routes.py create mode 100644 src/lkeep/apps/links/schemas.py create mode 100644 src/lkeep/apps/links/services.py create mode 100644 src/lkeep/database/alembic/versions/2025_09_24_1430-d93cd9da97e5_links_model.py create mode 100644 src/lkeep/database/models/links.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96cdab9..2b8f762 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: rev: v3.19.0 hooks: - id: pyupgrade - args: [ --py312-plus ] + args: [ --py313-plus ] - repo: https://github.com/hhatto/autopep8 rev: v2.3.1 @@ -39,7 +39,7 @@ repos: rev: 24.10.0 hooks: - id: black - language_version: python3.12 + language_version: python3.13 args: [ --line-length=120 ] - repo: https://github.com/astral-sh/ruff-pre-commit diff --git a/README.md b/README.md index f4f115f..fd3d207 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![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) +[![Кот на салфетке](https://img.shields.io/badge/Telegram-Кот_на_салфетке-blue)](https://t.me/+Li2vbxfWo0Q4ZDk6) Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI, PostgreSQL, Poetry, Pydantic и других. @@ -59,10 +59,11 @@ PostgreSQL, Poetry, Pydantic и других. 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/) 9. [FastAPI 9. Logout и проверка авторизации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-9-logout-i-proverka-avtorizacii/) -10. [FastAPI 10. Изменение данных пользователя]() +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/) ## Установка - +h Для установки и запуска проекта на вашем локальном компьютере выполните следующие шаги. 1. **Клонируйте репозиторий:** diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/src/lkeep/apps/__init__.py b/src/lkeep/apps/__init__.py index 41c8a47..5d5c315 100644 --- a/src/lkeep/apps/__init__.py +++ b/src/lkeep/apps/__init__.py @@ -9,9 +9,11 @@ https://pressanybutton.ru/category/servis-na-fastapi/ from fastapi import APIRouter 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.include_router(router=auth_router) apps_router.include_router(router=profile_router) +apps_router.include_router(router=links_router) diff --git a/src/lkeep/apps/auth/schemas.py b/src/lkeep/apps/auth/schemas.py index d1e09ac..69743ac 100644 --- a/src/lkeep/apps/auth/schemas.py +++ b/src/lkeep/apps/auth/schemas.py @@ -18,10 +18,10 @@ class GetUserByID(BaseModel): Класс для получения пользователя по его уникальному идентификатору (ID). :ivar id: Уникальный идентификатор пользователя, может быть представлен как объект типа uuid.UUID или строкой. - :type id: uuid.UUID | str + :type id: uuid.UUID """ - id: uuid.UUID | str + id: uuid.UUID class GetUserByEmail(BaseModel): diff --git a/src/lkeep/apps/links/__init__.py b/src/lkeep/apps/links/__init__.py new file mode 100644 index 0000000..c30336a --- /dev/null +++ b/src/lkeep/apps/links/__init__.py @@ -0,0 +1,7 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" diff --git a/src/lkeep/apps/links/managers.py b/src/lkeep/apps/links/managers.py new file mode 100644 index 0000000..1e5ee30 --- /dev/null +++ b/src/lkeep/apps/links/managers.py @@ -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() diff --git a/src/lkeep/apps/links/routes.py b/src/lkeep/apps/links/routes.py new file mode 100644 index 0000000..b5637c8 --- /dev/null +++ b/src/lkeep/apps/links/routes.py @@ -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) diff --git a/src/lkeep/apps/links/schemas.py b/src/lkeep/apps/links/schemas.py new file mode 100644 index 0000000..663c3a6 --- /dev/null +++ b/src/lkeep/apps/links/schemas.py @@ -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): + """Схема запроса на создание новой ссылки.""" diff --git a/src/lkeep/apps/links/services.py b/src/lkeep/apps/links/services.py new file mode 100644 index 0000000..49899c9 --- /dev/null +++ b/src/lkeep/apps/links/services.py @@ -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") diff --git a/src/lkeep/apps/profile/__init__.py b/src/lkeep/apps/profile/__init__.py index e69de29..c30336a 100644 --- a/src/lkeep/apps/profile/__init__.py +++ b/src/lkeep/apps/profile/__init__.py @@ -0,0 +1,7 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" diff --git a/src/lkeep/apps/profile/managers.py b/src/lkeep/apps/profile/managers.py index 62328cb..c9e289b 100644 --- a/src/lkeep/apps/profile/managers.py +++ b/src/lkeep/apps/profile/managers.py @@ -1,3 +1,11 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" + import uuid from typing import Any @@ -9,11 +17,30 @@ 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) @@ -22,6 +49,14 @@ class ProfileManager: 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) diff --git a/src/lkeep/apps/profile/routes.py b/src/lkeep/apps/profile/routes.py index 01212a5..1953fea 100644 --- a/src/lkeep/apps/profile/routes.py +++ b/src/lkeep/apps/profile/routes.py @@ -1,3 +1,11 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" + from typing import Annotated from fastapi import APIRouter, Depends @@ -18,6 +26,17 @@ async def change_email( 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) @@ -27,4 +46,16 @@ async def change_password( 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) diff --git a/src/lkeep/apps/profile/schemas.py b/src/lkeep/apps/profile/schemas.py index d395dbf..3086f75 100644 --- a/src/lkeep/apps/profile/schemas.py +++ b/src/lkeep/apps/profile/schemas.py @@ -1,12 +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)] diff --git a/src/lkeep/apps/profile/services.py b/src/lkeep/apps/profile/services.py index c17eb4f..af7b859 100644 --- a/src/lkeep/apps/profile/services.py +++ b/src/lkeep/apps/profile/services.py @@ -1,3 +1,11 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" + from fastapi import Depends from starlette.responses import JSONResponse @@ -8,18 +16,49 @@ 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): diff --git a/src/lkeep/core/core_dependency/__init__.py b/src/lkeep/core/core_dependency/__init__.py index e69de29..c30336a 100644 --- a/src/lkeep/core/core_dependency/__init__.py +++ b/src/lkeep/core/core_dependency/__init__.py @@ -0,0 +1,7 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" diff --git a/src/lkeep/core/core_dependency/db_dependency.py b/src/lkeep/core/core_dependency/db_dependency.py index 43da0ec..07fcda3 100644 --- a/src/lkeep/core/core_dependency/db_dependency.py +++ b/src/lkeep/core/core_dependency/db_dependency.py @@ -1,3 +1,11 @@ +""" +Проект: Lkeep +Автор: Иван Ашихмин +Год: 2025 +Специально для проекта "Код на салфетке" +https://pressanybutton.ru/category/servis-na-fastapi/ +""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from lkeep.core.settings import settings diff --git a/src/lkeep/core/core_dependency/redis_dependency.py b/src/lkeep/core/core_dependency/redis_dependency.py index cd71579..ee8051a 100644 --- a/src/lkeep/core/core_dependency/redis_dependency.py +++ b/src/lkeep/core/core_dependency/redis_dependency.py @@ -41,7 +41,7 @@ class RedisDependency: return ConnectionPool.from_url(url=self._url, encoding="utf-8", decode_responses=True) @asynccontextmanager - async def get_client(self) -> AsyncGenerator[Redis, None]: + async def get_client(self) -> AsyncGenerator[Redis]: """ Получает клиентскую сессию Redis для взаимодействия с базой данных. diff --git a/src/lkeep/core/settings.py b/src/lkeep/core/settings.py index 93be1df..264c84d 100644 --- a/src/lkeep/core/settings.py +++ b/src/lkeep/core/settings.py @@ -119,6 +119,8 @@ class Settings(BaseSettings): :type frontend_url: str :ivar access_token_expire: Срок жизни JWT-токена :type access_token_expire: int + :ivar link_length: Максимальная длина короткой ссылки + :type link_length: int """ db_settings: DBSettings = DBSettings() @@ -129,6 +131,7 @@ class Settings(BaseSettings): frontend_url: str access_token_expire: int domain: str + link_length: int = 12 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore") diff --git a/src/lkeep/database/alembic/versions/2025_09_24_1430-d93cd9da97e5_links_model.py b/src/lkeep/database/alembic/versions/2025_09_24_1430-d93cd9da97e5_links_model.py new file mode 100644 index 0000000..27eb458 --- /dev/null +++ b/src/lkeep/database/alembic/versions/2025_09_24_1430-d93cd9da97e5_links_model.py @@ -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 ### diff --git a/src/lkeep/database/models/__init__.py b/src/lkeep/database/models/__init__.py index 65695bc..5cf3dac 100644 --- a/src/lkeep/database/models/__init__.py +++ b/src/lkeep/database/models/__init__.py @@ -7,7 +7,8 @@ https://pressanybutton.ru/category/servis-na-fastapi/ """ from lkeep.database.models.base import Base +from lkeep.database.models.links import Link from lkeep.database.models.user import User -__all__ = ("Base", "User") +__all__ = ("Base", "User", "Link") diff --git a/src/lkeep/database/models/links.py b/src/lkeep/database/models/links.py new file mode 100644 index 0000000..55bf9d4 --- /dev/null +++ b/src/lkeep/database/models/links.py @@ -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")) diff --git a/src/lkeep/main.py b/src/lkeep/main.py index f2a1fd4..9986d13 100644 --- a/src/lkeep/main.py +++ b/src/lkeep/main.py @@ -26,4 +26,9 @@ app.add_middleware( def start(): + """ + Запускает локальный сервер приложения с поддержкой автоматической перезагрузки. + + :returns: None + """ uvicorn.run(app="lkeep.main:app", reload=True)