From d73748deaba949c2167cc66a205c1e99e95c8e84 Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Mon, 8 Sep 2025 10:59:06 +0400 Subject: [PATCH] refactor: mixins for services --- repositories/board.py | 13 ++----- repositories/deal.py | 12 +----- repositories/mixins.py | 21 +++++------ repositories/project.py | 13 ++----- repositories/status.py | 14 ++----- routers/board.py | 8 ++-- routers/deal.py | 8 ++-- routers/project.py | 8 ++-- routers/status.py | 8 ++-- schemas/base.py | 1 + schemas/base_crud.py | 21 +++++++++++ services/board.py | 52 ++++++++----------------- services/deal.py | 43 +++++++-------------- services/mixins.py | 84 +++++++++++++++++++++++++++++++++++++++++ services/project.py | 54 ++++++++------------------ services/status.py | 52 +++++++++---------------- 16 files changed, 207 insertions(+), 205 deletions(-) create mode 100644 schemas/base_crud.py create mode 100644 services/mixins.py diff --git a/repositories/board.py b/repositories/board.py index 44f27e2..47c1f58 100644 --- a/repositories/board.py +++ b/repositories/board.py @@ -1,25 +1,18 @@ -from sqlalchemy import Select from sqlalchemy.orm import selectinload from models import Board from repositories.base import BaseRepository -from repositories.mixins import ( - RepDeleteMixin, - RepCreateMixin, - GetByIdMixin, - GetAllMixin, - RepUpdateMixin, -) +from repositories.mixins import * from schemas.board import UpdateBoardSchema, CreateBoardSchema class BoardRepository( BaseRepository, - GetAllMixin[Board], + RepGetAllMixin[Board], RepDeleteMixin[Board], RepCreateMixin[Board, CreateBoardSchema], RepUpdateMixin[Board, UpdateBoardSchema], - GetByIdMixin[Board], + RepGetByIdMixin[Board], ): entity_class = Board diff --git a/repositories/deal.py b/repositories/deal.py index 8bab9bb..f6a5590 100644 --- a/repositories/deal.py +++ b/repositories/deal.py @@ -1,16 +1,8 @@ -from typing import Optional - -from sqlalchemy import select, Select from sqlalchemy.orm import joinedload from models import Deal, CardStatusHistory, Board from repositories.base import BaseRepository -from repositories.mixins import ( - RepDeleteMixin, - RepCreateMixin, - GetByIdMixin, - RepUpdateMixin, -) +from repositories.mixins import * from schemas.base import SortDir from schemas.deal import UpdateDealSchema, CreateDealSchema from utils.sorting import apply_sorting @@ -21,7 +13,7 @@ class DealRepository( RepDeleteMixin[Deal], RepCreateMixin[Deal, CreateDealSchema], RepUpdateMixin[Deal, UpdateDealSchema], - GetByIdMixin[Deal], + RepGetByIdMixin[Deal], ): entity_class = Deal diff --git a/repositories/mixins.py b/repositories/mixins.py index b06b708..a608baf 100644 --- a/repositories/mixins.py +++ b/repositories/mixins.py @@ -1,18 +1,14 @@ -from typing import Generic, TypeVar, Type, Optional +from typing import Type, Optional from sqlalchemy import select, Select from sqlalchemy.ext.asyncio import AsyncSession -EntityType = TypeVar("EntityType") -CreateType = TypeVar("CreateType") -UpdateType = TypeVar("UpdateType") - -class RepBaseMixin(Generic[EntityType]): +class RepBaseMixin[EntityType]: session: AsyncSession -class RepDeleteMixin(RepBaseMixin[EntityType]): +class RepDeleteMixin[EntityType](RepBaseMixin[EntityType]): async def delete(self, obj: EntityType, is_soft: bool) -> None: if not is_soft: await self.session.delete(obj) @@ -28,7 +24,7 @@ class RepDeleteMixin(RepBaseMixin[EntityType]): await self.session.commit() -class RepCreateMixin(RepBaseMixin[EntityType], Generic[EntityType, CreateType]): +class RepCreateMixin[EntityType, CreateType](RepBaseMixin[EntityType]): entity_class: Type[EntityType] async def create(self, data: CreateType) -> int: @@ -39,7 +35,7 @@ class RepCreateMixin(RepBaseMixin[EntityType], Generic[EntityType, CreateType]): return obj.id -class RepUpdateMixin(RepBaseMixin[EntityType], Generic[EntityType, UpdateType]): +class RepUpdateMixin[EntityType, UpdateType](RepBaseMixin[EntityType]): async def _apply_update_data_to_model( self, model: EntityType, @@ -61,8 +57,11 @@ class RepUpdateMixin(RepBaseMixin[EntityType], Generic[EntityType, UpdateType]): await self.session.refresh(model) return model + async def update(self, entity: EntityType, data: UpdateType) -> EntityType: + pass -class GetByIdMixin(RepBaseMixin[EntityType]): + +class RepGetByIdMixin[EntityType](RepBaseMixin[EntityType]): entity_class: Type[EntityType] def _process_get_by_id_stmt(self, stmt: Select) -> Select: @@ -78,7 +77,7 @@ class GetByIdMixin(RepBaseMixin[EntityType]): return result.scalar_one_or_none() -class GetAllMixin(RepBaseMixin[EntityType]): +class RepGetAllMixin[EntityType](RepBaseMixin[EntityType]): entity_class: Type[EntityType] def _process_get_all_stmt_with_args(self, stmt: Select, *args) -> Select: diff --git a/repositories/project.py b/repositories/project.py index b5ab5e1..2ad3a41 100644 --- a/repositories/project.py +++ b/repositories/project.py @@ -1,25 +1,18 @@ -from sqlalchemy import Select from sqlalchemy.orm import selectinload from models.project import Project from repositories.base import BaseRepository -from repositories.mixins import ( - RepDeleteMixin, - RepCreateMixin, - GetByIdMixin, - GetAllMixin, - RepUpdateMixin, -) +from repositories.mixins import * from schemas.project import CreateProjectSchema, UpdateProjectSchema class ProjectRepository( BaseRepository, - GetAllMixin[Project], + RepGetAllMixin[Project], RepDeleteMixin[Project], RepCreateMixin[Project, CreateProjectSchema], RepUpdateMixin[Project, UpdateProjectSchema], - GetByIdMixin[Project], + RepGetByIdMixin[Project], ): entity_class = Project diff --git a/repositories/status.py b/repositories/status.py index ab8d4d3..d58c565 100644 --- a/repositories/status.py +++ b/repositories/status.py @@ -1,24 +1,18 @@ -from sqlalchemy import select, func, Select +from sqlalchemy import func from models import Status, Deal from repositories.base import BaseRepository -from repositories.mixins import ( - RepDeleteMixin, - RepCreateMixin, - GetByIdMixin, - GetAllMixin, - RepUpdateMixin, -) +from repositories.mixins import * from schemas.status import UpdateStatusSchema, CreateStatusSchema class StatusRepository( BaseRepository, - GetAllMixin[Status], + RepGetAllMixin[Status], RepDeleteMixin[Status], RepCreateMixin[Status, CreateStatusSchema], RepUpdateMixin[Status, UpdateStatusSchema], - GetByIdMixin[Status], + RepGetByIdMixin[Status], ): entity_class = Status diff --git a/routers/board.py b/routers/board.py index 9b2d569..e237b87 100644 --- a/routers/board.py +++ b/routers/board.py @@ -18,7 +18,7 @@ async def get_boards( session: SessionDependency, project_id: int = Path(alias="projectId"), ): - return await BoardService(session).get_boards(project_id) + return await BoardService(session).get_all(project_id) @board_router.post( @@ -30,7 +30,7 @@ async def create_board( session: SessionDependency, request: CreateBoardRequest, ): - return await BoardService(session).create_board(request) + return await BoardService(session).create(request) @board_router.patch( @@ -43,7 +43,7 @@ async def update_board( request: UpdateBoardRequest, pk: int = Path(), ): - return await BoardService(session).update_board(pk, request) + return await BoardService(session).update(pk, request) @board_router.delete( @@ -55,4 +55,4 @@ async def delete_board( session: SessionDependency, pk: int = Path(), ): - return await BoardService(session).delete_board(pk) + return await BoardService(session).delete(pk) diff --git a/routers/deal.py b/routers/deal.py index 2ac2d60..cb2ddad 100644 --- a/routers/deal.py +++ b/routers/deal.py @@ -28,7 +28,7 @@ async def get_deals( id: Optional[int] = Query(default=None), name: Optional[str] = Query(default=None), ): - return await DealService(session).get_deals( + return await DealService(session).get_all( pagination, sorting, project_id, @@ -48,7 +48,7 @@ async def create_deal( session: SessionDependency, request: CreateDealRequest, ): - return await DealService(session).create_deal(request) + return await DealService(session).create(request) @deal_router.patch( @@ -61,7 +61,7 @@ async def update_deal( request: UpdateDealRequest, pk: int = Path(), ): - return await DealService(session).update_deal(pk, request) + return await DealService(session).update(pk, request) @deal_router.delete( @@ -73,4 +73,4 @@ async def delete_deal( session: SessionDependency, pk: int = Path(), ): - return await DealService(session).delete_deal(pk) + return await DealService(session).delete(pk) diff --git a/routers/project.py b/routers/project.py index 7bf3589..4243b02 100644 --- a/routers/project.py +++ b/routers/project.py @@ -17,7 +17,7 @@ project_router = APIRouter( async def get_projects( session: SessionDependency, ): - return await ProjectService(session).get_projects() + return await ProjectService(session).get_all() @project_router.post( @@ -29,7 +29,7 @@ async def create_project( session: SessionDependency, request: CreateProjectRequest, ): - return await ProjectService(session).create_project(request) + return await ProjectService(session).create(request) @project_router.patch( @@ -42,7 +42,7 @@ async def update_project( request: UpdateProjectRequest, pk: int = Path(), ): - return await ProjectService(session).update_project(pk, request) + return await ProjectService(session).update(pk, request) @project_router.delete( @@ -54,4 +54,4 @@ async def delete_project( session: SessionDependency, pk: int = Path(), ): - return await ProjectService(session).delete_project(pk) + return await ProjectService(session).delete(pk) diff --git a/routers/status.py b/routers/status.py index b4e51f7..f033800 100644 --- a/routers/status.py +++ b/routers/status.py @@ -18,7 +18,7 @@ async def get_statuses( session: SessionDependency, board_id: int = Path(alias="boardId"), ): - return await StatusService(session).get_statuses(board_id) + return await StatusService(session).get_all(board_id) @status_router.post( @@ -30,7 +30,7 @@ async def create_status( session: SessionDependency, request: CreateStatusRequest, ): - return await StatusService(session).create_status(request) + return await StatusService(session).create(request) @status_router.patch( @@ -43,7 +43,7 @@ async def update_status( request: UpdateStatusRequest, pk: int = Path(), ): - return await StatusService(session).update_status(pk, request) + return await StatusService(session).update(pk, request) @status_router.delete( @@ -55,4 +55,4 @@ async def delete_status( session: SessionDependency, pk: int = Path(), ): - return await StatusService(session).delete_status(pk) + return await StatusService(session).delete(pk) diff --git a/schemas/base.py b/schemas/base.py index 49b797a..84af798 100644 --- a/schemas/base.py +++ b/schemas/base.py @@ -14,6 +14,7 @@ class BaseSchema(BaseModel): from_attributes = True alias_generator = to_camel populate_by_name = True + arbitrary_types_allowed = True @classmethod def from_sql_model(cls, model, fields: dict): diff --git a/schemas/base_crud.py b/schemas/base_crud.py new file mode 100644 index 0000000..e79c147 --- /dev/null +++ b/schemas/base_crud.py @@ -0,0 +1,21 @@ +from typing import TypeVar + +from schemas.base import BaseResponse, BaseSchema + +SchemaType = TypeVar("SchemaType", bound=BaseSchema) + + +class BaseGetAllResponse[SchemaType](BaseSchema): + items: list[SchemaType] + + +class BaseCreateResponse[SchemaType](BaseResponse): + entity: SchemaType + + +class BaseUpdateResponse(BaseResponse): + pass + + +class BaseDeleteResponse(BaseResponse): + pass diff --git a/services/board.py b/services/board.py index 9521651..1e83c44 100644 --- a/services/board.py +++ b/services/board.py @@ -1,43 +1,23 @@ -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession - +from models import Board from repositories import BoardRepository from schemas.board import * +from services.mixins import * -class BoardService: +class BoardService( + ServiceGetAllMixin[Board, BoardSchema], + ServiceCreateMixin[Board, CreateBoardRequest, BoardSchema], + ServiceUpdateMixin[Board, UpdateBoardRequest], + ServiceDeleteMixin[Board], +): + schema_class = BoardSchema + entity_not_found_msg = "Доска не найдена" + entity_deleted_msg = "Доска успешно удалена" + entity_updated_msg = "Доска успешно обновлена" + entity_created_msg = "Доска успешно создана" + def __init__(self, session: AsyncSession): self.repository = BoardRepository(session) - async def get_boards(self, project_id: int) -> GetBoardsResponse: - boards = await self.repository.get_all(project_id) - return GetBoardsResponse( - items=[BoardSchema.model_validate(board) for board in boards] - ) - - async def create_board(self, request: CreateBoardRequest) -> CreateBoardResponse: - board_id = await self.repository.create(request.entity) - board = await self.repository.get_by_id(board_id) - return CreateBoardResponse( - entity=BoardSchema.model_validate(board), - message="Доска успешно создана", - ) - - async def update_board( - self, board_id: int, request: UpdateBoardRequest - ) -> UpdateBoardResponse: - board = await self.repository.get_by_id(board_id) - if not board: - raise HTTPException(status_code=404, detail="Доска не найдена") - - await self.repository.update(board, request.entity) - return UpdateBoardResponse(message="Доска успешно обновлена") - - async def delete_board(self, board_id: int) -> DeleteBoardResponse: - board = await self.repository.get_by_id(board_id) - if not board: - raise HTTPException(status_code=404, detail="Доска не найдена") - - is_soft_needed: bool = len(board.deals) > 0 - await self.repository.delete(board, is_soft_needed) - return DeleteBoardResponse(message="Доска успешно удалена") + async def is_soft_delete(self, board: BoardSchema) -> bool: + return len(board.deals) > 0 diff --git a/services/deal.py b/services/deal.py index fa8d251..79e6287 100644 --- a/services/deal.py +++ b/services/deal.py @@ -1,18 +1,27 @@ import math -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession - +from models import Deal from repositories import DealRepository from schemas.base import PaginationSchema, SortingSchema from schemas.deal import * +from services.mixins import * -class DealService: +class DealService( + ServiceCreateMixin[Deal, CreateDealRequest, DealSchema], + ServiceUpdateMixin[Deal, UpdateDealRequest], + ServiceDeleteMixin[Deal], +): + schema_class = DealSchema + entity_not_found_msg = "Сделка не найдена" + entity_deleted_msg = "Сделка успешно удалена" + entity_updated_msg = "Сделка успешно обновлена" + entity_created_msg = "Сделка успешно создана" + def __init__(self, session: AsyncSession): self.repository = DealRepository(session) - async def get_deals( + async def get_all( self, pagination: PaginationSchema, sorting: SortingSchema, @@ -36,27 +45,3 @@ class DealService: total_pages=total_pages, total_items=total_items ), ) - - async def create_deal(self, request: CreateDealRequest) -> CreateDealResponse: - deal_id = await self.repository.create(request.entity) - deal = await self.repository.get_by_id(deal_id) - return CreateDealResponse( - entity=DealSchema.model_validate(deal), - message="Сделка успешно создана", - ) - - async def update_deal(self, deal_id: int, request: UpdateDealRequest): - deal = await self.repository.get_by_id(deal_id) - if not deal: - raise HTTPException(status_code=404, detail="Сделка не найдена") - - await self.repository.update(deal, request.entity) - return UpdateDealResponse(message="Сделка успешно обновлена") - - async def delete_deal(self, deal_id: int) -> DeleteDealResponse: - deal = await self.repository.get_by_id(deal_id) - if not deal: - raise HTTPException(status_code=404, detail="Сделка не найдена") - - await self.repository.delete(deal, True) - return DeleteDealResponse(message="Сделка успешно удалена") \ No newline at end of file diff --git a/services/mixins.py b/services/mixins.py new file mode 100644 index 0000000..ced82f2 --- /dev/null +++ b/services/mixins.py @@ -0,0 +1,84 @@ +from typing import Generic + +from fastapi import HTTPException + +from repositories.mixins import * +from schemas.base_crud import * + +RepositoryMixin = TypeVar("RepositoryMixin") + +EntityType = TypeVar("EntityType") +SchemaType = TypeVar("SchemaType", bound=BaseSchema) +UpdateRequestType = TypeVar("UpdateRequestType", bound=BaseSchema) +CreateRequestType = TypeVar("CreateRequestType", bound=BaseSchema) + + +class ServiceBaseMixin(Generic[RepositoryMixin]): + repository: RepositoryMixin + + +class ServiceCreateMixin( + Generic[EntityType, CreateRequestType, SchemaType], + ServiceBaseMixin[RepCreateMixin | RepGetByIdMixin], +): + entity_created_msg = "Entity created" + schema_class: type[SchemaType] + + async def create(self, request: CreateRequestType) -> BaseCreateResponse: + entity_id = await self.repository.create(request.entity) + entity = await self.repository.get_by_id(entity_id) + return BaseCreateResponse( + entity=self.schema_class.model_validate(entity), + message=self.entity_created_msg, + ) + + +class ServiceGetAllMixin( + ServiceBaseMixin[RepGetAllMixin], + Generic[EntityType, SchemaType], +): + schema_class: type[SchemaType] + + async def get_all(self, *args) -> BaseGetAllResponse[SchemaType]: + entities = await self.repository.get_all(*args) + return BaseGetAllResponse[SchemaType]( + items=[self.schema_class.model_validate(entity) for entity in entities] + ) + + +class ServiceUpdateMixin( + ServiceBaseMixin[RepUpdateMixin | RepGetByIdMixin], + Generic[EntityType, UpdateRequestType], +): + entity_not_found_msg = "Entity not found" + entity_updated_msg = "Entity updated" + + async def update( + self, entity_id: int, request: UpdateRequestType + ) -> BaseUpdateResponse: + entity = await self.repository.get_by_id(entity_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + await self.repository.update(entity, request.entity) + return BaseUpdateResponse(message=self.entity_updated_msg) + + +class ServiceDeleteMixin( + ServiceBaseMixin[RepDeleteMixin | RepGetByIdMixin], + Generic[EntityType], +): + entity_not_found_msg = "Entity not found" + entity_deleted_msg = "Entity deleted" + + async def is_soft_delete(self, entity: EntityType) -> bool: + return hasattr(entity, "is_deleted") + + async def delete(self, entity_id: int) -> BaseDeleteResponse: + entity = await self.repository.get_by_id(entity_id) + if not entity: + raise HTTPException(status_code=404, detail=self.entity_not_found_msg) + + is_soft = await self.is_soft_delete(entity) + + await self.repository.delete(entity, is_soft) + return BaseDeleteResponse(message=self.entity_deleted_msg) diff --git a/services/project.py b/services/project.py index 51685f0..c905072 100644 --- a/services/project.py +++ b/services/project.py @@ -1,45 +1,23 @@ -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession - +from models import Project from repositories import ProjectRepository from schemas.project import * +from services.mixins import * -class ProjectService: +class ProjectService( + ServiceGetAllMixin[Project, ProjectSchema], + ServiceCreateMixin[Project, CreateProjectRequest, ProjectSchema], + ServiceUpdateMixin[Project, UpdateProjectRequest], + ServiceDeleteMixin[Project], +): + schema_class = ProjectSchema + entity_not_found_msg = "Проект не найден" + entity_deleted_msg = "Проект успешно удален" + entity_updated_msg = "Проект успешно обновлен" + entity_created_msg = "Проект успешно создан" + def __init__(self, session: AsyncSession): self.repository = ProjectRepository(session) - async def get_projects(self) -> GetProjectsResponse: - projects = await self.repository.get_all() - return GetProjectsResponse( - items=[ProjectSchema.model_validate(project) for project in projects] - ) - - async def create_project( - self, request: CreateProjectRequest - ) -> CreateProjectResponse: - project_id = await self.repository.create(request.entity) - project = await self.repository.get_by_id(project_id) - return CreateProjectResponse( - entity=ProjectSchema.model_validate(project), - message="Проект успешно создан", - ) - - async def update_project( - self, project_id: int, request: UpdateProjectRequest - ) -> UpdateProjectResponse: - project = await self.repository.get_by_id(project_id) - if not project: - raise HTTPException(status_code=404, detail="Проект не найден") - - await self.repository.update(project, request.entity) - return UpdateProjectResponse(message="Проект успешно обновлен") - - async def delete_project(self, project_id: int) -> DeleteProjectResponse: - project = await self.repository.get_by_id(project_id) - if not project: - raise HTTPException(status_code=404, detail="Проект не найден") - - is_soft_needed: bool = len(project.boards) > 0 - await self.repository.delete(project, is_soft_needed) - return DeleteProjectResponse(message="Проект успешно удален") + async def is_soft_delete(self, project: ProjectSchema) -> bool: + return len(project.boards) > 0 diff --git a/services/status.py b/services/status.py index 4029fe8..8128b8c 100644 --- a/services/status.py +++ b/services/status.py @@ -1,43 +1,25 @@ -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession - +from models import Status from repositories import StatusRepository -from schemas.board import UpdateBoardResponse from schemas.status import * +from services.mixins import * -class StatusService: +class StatusService( + ServiceGetAllMixin[Status, StatusSchema], + ServiceCreateMixin[Status, CreateStatusRequest, StatusSchema], + ServiceUpdateMixin[Status, UpdateStatusRequest], + ServiceDeleteMixin[Status], +): + schema_class = StatusSchema + entity_not_found_msg = "Статус не найден" + entity_deleted_msg = "Статус успешно удален" + entity_updated_msg = "Статус успешно обновлен" + entity_created_msg = "Статус успешно создан" + def __init__(self, session: AsyncSession): self.repository = StatusRepository(session) - async def get_statuses(self, board_id: int) -> GetStatusesResponse: - statuses = await self.repository.get_all(board_id) - return GetStatusesResponse( - items=[StatusSchema.model_validate(status) for status in statuses] - ) - - async def create_status(self, request: CreateStatusRequest) -> CreateStatusResponse: - status_id = await self.repository.create(request.entity) - status = await self.repository.get_by_id(status_id) - return CreateStatusResponse( - entity=StatusSchema.model_validate(status), - message="Статус успешно создан", - ) - - async def update_status(self, status_id: int, request: UpdateStatusRequest): - status = await self.repository.get_by_id(status_id) - if not status: - raise HTTPException(status_code=404, detail="Статус не найден") - - await self.repository.update(status, request.entity) - return UpdateBoardResponse(message="Статус успешно обновлен") - - async def delete_status(self, status_id: int) -> DeleteStatusResponse: - board = await self.repository.get_by_id(status_id) - if not board: - raise HTTPException(status_code=404, detail="Статус не найден") - - deals_count = await self.repository.get_deals_count(status_id) + async def is_soft_delete(self, status: StatusSchema) -> bool: + deals_count = await self.repository.get_deals_count(status.id) is_soft_needed: bool = deals_count > 0 - await self.repository.delete(board, is_soft_needed) - return DeleteStatusResponse(message="Статус успешно удален") + return is_soft_needed