diff --git a/.gitignore b/.gitignore index c53c1463..670cef96 100644 --- a/.gitignore +++ b/.gitignore @@ -111,7 +111,7 @@ ipython_config.py # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# pdm recommends including projects-wide configuration in pdm.toml, but excluding .pdm-python. # https://pdm-project.org/en/latest/usage/project/#working-with-version-control # pdm.lock # pdm.toml @@ -126,7 +126,7 @@ ipython_config.py .pixi/* !.pixi/config.toml -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-projects/pdm __pypackages__/ # Celery stuff @@ -159,11 +159,11 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings +# Spyder projects settings .spyderproject .spyproject -# Rope project settings +# Rope projects settings .ropeproject # mkdocs documentation diff --git a/backend/api/api_v1/projects/list_views.py b/backend/api/api_v1/projects/list_views.py index f653293e..749423c3 100644 --- a/backend/api/api_v1/projects/list_views.py +++ b/backend/api/api_v1/projects/list_views.py @@ -2,6 +2,7 @@ AuthUserByAccessTokenDep, PaginationPageDep, PaginationSizeDep, + ProjectPaginationServiceDep, ProjectServiceDep, ) from dependencies.auth import get_auth_user_by_access_token @@ -11,6 +12,7 @@ ProjectWithRenderCreate, ProjectWithRenderResponse, ) +from services.projects.project_pagination import ProjectPaginationService router = APIRouter() @@ -24,11 +26,11 @@ ], ) async def get_public_projects( - project_service: ProjectServiceDep, + project_pagination_service: ProjectPaginationService, size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> ProjectResponseList: - return await project_service.get_public_projects( + return await project_pagination_service.get_public_projects( size=size, page=page, ) @@ -56,12 +58,12 @@ async def create_project( status_code=status.HTTP_200_OK, ) async def get_current_user_projects( - project_service: ProjectServiceDep, + project_pagination_service: ProjectPaginationServiceDep, user_id: AuthUserByAccessTokenDep, size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> ProjectResponseList: - return await project_service.get_user_projects( + return await project_pagination_service.get_user_projects( user_id=user_id, size=size, page=page, @@ -77,12 +79,12 @@ async def get_current_user_projects( ], ) async def get_user_public_projects( - project_service: ProjectServiceDep, + project_pagination_service: ProjectPaginationServiceDep, user_id: int, size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> ProjectResponseList: - return await project_service.get_user_public_projects( + return await project_pagination_service.get_user_public_projects( user_id=user_id, size=size, page=page, diff --git a/backend/dependencies/annotations.py b/backend/dependencies/annotations.py index 4a685f76..0718ecf1 100644 --- a/backend/dependencies/annotations.py +++ b/backend/dependencies/annotations.py @@ -7,7 +7,8 @@ from schemas.auth import LoginRequest from services.auth import AuthService from services.file_uploader import FileUploader -from services.project import ProjectService +from services.projects.project import ProjectService +from services.projects.project_pagination import ProjectPaginationService from services.tag import TagService from services.user import UserService @@ -21,6 +22,7 @@ from dependencies.services import ( get_auth_service, get_input_file_uploader, + get_project_pagination_service, get_project_service, get_tag_service, get_user_service, @@ -67,6 +69,12 @@ Depends(get_project_service), ] +ProjectPaginationServiceDep = Annotated[ + ProjectPaginationService, + Depends(get_project_pagination_service), +] + + RenderRepositoryDep = Annotated[ RenderRepository, Depends(get_render_repository), diff --git a/backend/dependencies/services.py b/backend/dependencies/services.py index 32e27fbc..0f269856 100644 --- a/backend/dependencies/services.py +++ b/backend/dependencies/services.py @@ -7,7 +7,9 @@ from infrastructure.minio.client import MinioClient from services.auth import AuthService from services.file_uploader import FileUploader -from services.project import ProjectService +from services.projects.access_validator import ProjectAccessValidatorService +from services.projects.project import ProjectService +from services.projects.project_pagination import ProjectPaginationService from services.tag import TagService from services.user import UserService @@ -61,12 +63,26 @@ async def get_project_service( Depends(get_unit_of_work), ], ) -> AsyncGenerator[ProjectService]: + project_access_validator = ProjectAccessValidatorService(unit_of_work) project_service = ProjectService( unit_of_work, + project_access_validator, ) yield project_service +async def get_project_pagination_service( + unit_of_work: Annotated[ + UnitOfWork, + Depends(get_unit_of_work), + ], +) -> AsyncGenerator[ProjectPaginationService]: + project_pagination_service = ProjectPaginationService( + unit_of_work, + ) + yield project_pagination_service + + async def get_tag_service( unit_of_work: Annotated[ UnitOfWork, diff --git a/backend/infrastructure/database/repositories/project.py b/backend/infrastructure/database/repositories/project.py index 4c3303cf..3bbd198c 100644 --- a/backend/infrastructure/database/repositories/project.py +++ b/backend/infrastructure/database/repositories/project.py @@ -85,7 +85,7 @@ async def update_project_status(self, project_id: int) -> Project | None: project.status = RenderStatus.completed.value # type: ignore[assignment] await self.session.flush() logger.debug( - "Updated project status in transaction, transaction_id=%s, project_id=%s, status=%s", # noqa: E501 + "Updated projects status in transaction, transaction_id=%s, project_id=%s, status=%s", # noqa: E501 id(self.session), project_id, project.status, @@ -106,7 +106,7 @@ async def create_project( self.session.add(project) await self.session.flush() logger.debug( - "Created project in transaction, transaction_id=%s, project_id=%s, name=%s, source_file_id=%s, visibility=%s", # noqa: E501 + "Created projects in transaction, transaction_id=%s, project_id=%s, name=%s, source_file_id=%s, visibility=%s", # noqa: E501 id(self.session), project.id, project.name, @@ -131,7 +131,7 @@ async def partial_update_project( await self.session.flush() logger.debug( - "Updated project in transaction, transaction_id=%s, project_id=%s, name=%s, source_file_id=%s, visibility=%s", # noqa: E501 + "Updated projects in transaction, transaction_id=%s, project_id=%s, name=%s, source_file_id=%s, visibility=%s", # noqa: E501 id(self.session), project.id, project.name, @@ -145,7 +145,7 @@ async def delete_by_id(self, project_id: int) -> None: await self.session.execute(stmt) await self.session.flush() logger.debug( - "Deleted project in transaction, transaction_id=%s, project_id=%s", + "Deleted projects in transaction, transaction_id=%s, project_id=%s", id(self.session), project_id, ) diff --git a/backend/infrastructure/kafka/consumer.py b/backend/infrastructure/kafka/consumer.py index 04732e0f..2587a415 100644 --- a/backend/infrastructure/kafka/consumer.py +++ b/backend/infrastructure/kafka/consumer.py @@ -13,7 +13,7 @@ from core.interfaces.kafka import AbstractKafkaConsumer, AbstractKafkaProducer from core.logging import get_logger from schemas.event import DLQMessage, RenderGeneratedEvent -from services.project import ProjectService +from services.projects.render_generate_handler import RenderGenerateHandlerService from infrastructure.database.core import session_factory from infrastructure.database.unit_of_work import UnitOfWork @@ -123,15 +123,15 @@ async def consume_render_generated(message: ConsumerRecord) -> None: async with ( session_factory() as session, UnitOfWork(session) as unit_of_work, - ProjectService(unit_of_work) as project_service, + RenderGenerateHandlerService(unit_of_work) as render_generate_handler_service, minio_session.create_client( "s3", **settings.minio.config, ) as client, MinioClient(client) as s3_client, ): - await project_service.update_project_status(project_id) - await project_service.handle_render_generated( + await render_generate_handler_service.update_project_status(project_id) + await render_generate_handler_service.handle_render_generated( render_generated_event, s3_client, ) diff --git a/backend/migrations/versions/ad7f1ef7f078_switch_render_project_to_projects_table.py b/backend/migrations/versions/ad7f1ef7f078_switch_render_project_to_projects_table.py index 52ec4a30..b878ccce 100644 --- a/backend/migrations/versions/ad7f1ef7f078_switch_render_project_to_projects_table.py +++ b/backend/migrations/versions/ad7f1ef7f078_switch_render_project_to_projects_table.py @@ -1,4 +1,4 @@ -"""switch render project to projects table +"""switch render projects to projects table Revision ID: ad7f1ef7f078 Revises: e926ed845570 diff --git a/backend/migrations/versions/da1780a42a65_remove_project_optional_description_.py b/backend/migrations/versions/da1780a42a65_remove_project_optional_description_.py index a93a1a8c..2827577b 100644 --- a/backend/migrations/versions/da1780a42a65_remove_project_optional_description_.py +++ b/backend/migrations/versions/da1780a42a65_remove_project_optional_description_.py @@ -1,4 +1,4 @@ -"""remove project optional description constraint +"""remove projects optional description constraint Revision ID: da1780a42a65 Revises: 11f4a9085334 diff --git a/backend/migrations/versions/e926ed845570_add_render_project_table.py b/backend/migrations/versions/e926ed845570_add_render_project_table.py index b2ad78d4..c24447b5 100644 --- a/backend/migrations/versions/e926ed845570_add_render_project_table.py +++ b/backend/migrations/versions/e926ed845570_add_render_project_table.py @@ -1,4 +1,4 @@ -"""add render project table +"""add render projects table Revision ID: e926ed845570 Revises: 3f72bb9a1698 diff --git a/backend/services/project.py b/backend/services/project.py deleted file mode 100644 index b06229ed..00000000 --- a/backend/services/project.py +++ /dev/null @@ -1,348 +0,0 @@ -from types import TracebackType -from typing import Self - -from core.config.application import settings -from core.constants import ProjectVisibility -from core.exceptions.auth import PermissionDeniedError -from core.exceptions.file import FileIdNotFoundError -from core.exceptions.project import ProjectIdNotFoundError -from core.exceptions.user import UserIdNotFoundError -from core.interfaces.clients import AbstractS3Client, AbstractUnitOfWorkClient -from core.logging import get_logger -from infrastructure.database.models import Project -from infrastructure.database.repositories import ( - FileRepository, - OutboxRepository, - ProjectRepository, - RenderRepository, - UserRepository, -) -from schemas.event import ( - CreateProjectEvent, - EventCreate, - RenderGeneratedEvent, -) -from schemas.file import FileCreate, FileLocationCreate -from schemas.project import ( - ProjectPartialUpdate, - ProjectResponse, - ProjectResponseList, - ProjectWithRenderCreate, - ProjectWithRenderFileFullResponse, - ProjectWithRenderFileResponse, - ProjectWithRenderResponse, -) -from schemas.render import RenderCreate - -logger = get_logger(__name__) - - -def generate_output_key(input_key: str) -> str: - output_key_list = input_key.split(".") - output_key_list[-1] = "png" - output_key = ".".join(output_key_list) - return output_key - - -class ProjectService: - def __init__( - self, - unit_of_work: AbstractUnitOfWorkClient, - ) -> None: - self.unit_of_work = unit_of_work - self.user_repository = self.unit_of_work.get_repository(UserRepository) - self.project_repository = self.unit_of_work.get_repository(ProjectRepository) - self.render_repository = self.unit_of_work.get_repository(RenderRepository) - self.file_repository = self.unit_of_work.get_repository(FileRepository) - self.outbox_repository = self.unit_of_work.get_repository(OutboxRepository) - - async def __aenter__(self) -> "Self": - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - """ - Метод для действий при выходе из контекстного менеджера. - """ - - async def get_by_id( - self, - project_id: int, - user_id: int, - s3_client: AbstractS3Client, - ) -> ProjectWithRenderFileFullResponse: - project = await self._validate_access_to_get_project( - project_id, - user_id, - ) - logger.info( - "Received project, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 - project.id, - project.user_id, - project.render_id, - project.source_file_id, - project.name, - project.status, - project.visibility, - ) - return await ProjectWithRenderFileFullResponse.get_from_database( - project, - s3_client, - ) - - async def get_user_projects( - self, - user_id: int, - size: int, - page: int, - ) -> ProjectResponseList: - user = await self.user_repository.get_by_id(user_id) - if user is None: - raise UserIdNotFoundError(user_id) - - get_projects = self.project_repository.get_user_projects( - user_id, - size, - page, - ) - projects = [ - ProjectResponse.model_validate(project) for project in await get_projects - ] - return ProjectResponseList( - project_list=projects, - size=size, - page=page, - ) - - async def get_public_projects(self, size: int, page: int) -> ProjectResponseList: - get_projects = self.project_repository.get_public_projects(size, page) - projects = [ - ProjectResponse.model_validate(project) for project in await get_projects - ] - return ProjectResponseList( - project_list=projects, - size=size, - page=page, - ) - - async def get_user_public_projects( - self, - user_id: int, - size: int, - page: int, - ) -> ProjectResponseList: - user = await self.user_repository.get_by_id(user_id) - if user is None: - raise UserIdNotFoundError(user_id) - - get_projects = self.project_repository.get_user_public_projects( - user_id, - size, - page, - ) - projects = [ - ProjectResponse.model_validate(project) for project in await get_projects - ] - return ProjectResponseList( - project_list=projects, - size=size, - page=page, - ) - - async def handle_render_generated( - self, - render_generated_event: RenderGeneratedEvent, - s3_client: AbstractS3Client, - ) -> None: - project_id = render_generated_event.project_id - file_location = render_generated_event.output - bucket = file_location.bucket - key = file_location.key - - project = await self.project_repository.get_by_id(project_id) - if project is None: - raise ProjectIdNotFoundError(project_id) - - name = f"{project.name}.png" - size = await s3_client.get_file_size(bucket, key) - file = FileCreate( - name=name, - size=size, - bucket=bucket, - key=key, - ) - render_file = await self.file_repository.create_file(file) - await self.render_repository.add_render_file( - render_id=project.render.id, - file_id=render_file.id, - ) - - logger.info( - "Added render to project, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 - project.id, - project.user_id, - project.render_id, - project.source_file_id, - project.name, - project.status, - project.visibility, - ) - - async def update_project_status( - self, - project_id: int, - ) -> ProjectWithRenderFileResponse: - project = await self.project_repository.update_project_status(project_id) - if project is None: - raise ProjectIdNotFoundError(project_id) - - logger.info( - "Updated project status, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 - project.id, - project.user_id, - project.render_id, - project.source_file_id, - project.name, - project.status, - project.visibility, - ) - return ProjectWithRenderFileResponse.model_validate(project) - - async def create_project( - self, - user_id: int, - create_project: ProjectWithRenderCreate, - ) -> ProjectWithRenderResponse: - create_project_data = create_project.project - create_full_render_data = create_project.render - file_id = create_project_data.source_file_id - file = await self.file_repository.get_by_id(file_id) - if file is None: - raise FileIdNotFoundError(file_id) - - create_render_data = RenderCreate.model_validate( - create_full_render_data.model_dump(), - ) - render = await self.render_repository.create_render(create_render_data) - project = await self.project_repository.create_project( - user_id=user_id, - render_id=render.id, - create_project_data=create_project_data, - ) - input_file_location = FileLocationCreate( - bucket=settings.minio.bucket.glb_sources, - key=file.key, - ) - output_key = generate_output_key(file.key) - output_file_location = FileLocationCreate( - bucket=settings.minio.bucket.renders, - key=output_key, - ) - event = CreateProjectEvent( - project_id=project.id, - input=input_file_location, - output=output_file_location, - render=create_full_render_data, - ) - event_create_data = EventCreate( - topic=settings.kafka.topic.project_created, - message=event.model_dump(), - ) - await self.outbox_repository.create_event(event_create_data) - - logger.info( - "Created project, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 - project.id, - user_id, - project.render_id, - project.source_file_id, - project.name, - project.status, - project.visibility, - ) - return ProjectWithRenderResponse.get_from_database(project, render) - - async def partial_update_project( - self, - project_id: int, - user_id: int, - partial_update_project_data: ProjectPartialUpdate, - ) -> ProjectWithRenderFileResponse: - await self._validate_access_to_change_project(project_id, user_id) - project = await self.project_repository.partial_update_project( - project_id, - partial_update_project_data, - ) - logger.info( - "Updated project, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 - project_id, - user_id, - project.render_id, - project.source_file_id, - project.name, - project.status, - project.visibility, - ) - return ProjectWithRenderFileResponse.model_validate(project) - - async def delete_by_id( - self, - project_id: int, - user_id: int, - ) -> None: - project = await self._validate_access_to_change_project(project_id, user_id) - await self.project_repository.delete_by_id(project_id) - logger.info( - "Deleted project, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 - project_id, - user_id, - project.render_id, - project.source_file_id, - project.name, - project.status, - project.visibility, - ) - - async def _validate_access_to_change_project( - self, - project_id: int, - user_id: int, - ) -> Project: - project = await self.project_repository.get_by_id( - project_id, - ) - if project is None: - raise ProjectIdNotFoundError(project_id) - - await self._validate_project_owner(project_id, user_id) - return project # type: ignore[no-any-return] - - async def _validate_access_to_get_project( - self, - project_id: int, - user_id: int, - ) -> Project: - project = await self.project_repository.get_by_id( - project_id, - ) - if project is None: - raise ProjectIdNotFoundError(project_id) - - if project.visibility == ProjectVisibility.private: - await self._validate_project_owner(project_id, user_id) - - return project # type: ignore[no-any-return] - - async def _validate_project_owner(self, project_id: int, user_id: int) -> None: - get_owner_coroutine = self.project_repository.get_project_owner( - project_id, - ) - project_owner = await get_owner_coroutine - if project_owner is None: - raise UserIdNotFoundError(user_id) - if project_owner.id != user_id: - raise PermissionDeniedError diff --git a/backend/tests/test_services/test_project/__init__.py b/backend/services/projects/__init__.py similarity index 100% rename from backend/tests/test_services/test_project/__init__.py rename to backend/services/projects/__init__.py diff --git a/backend/services/projects/access_validator.py b/backend/services/projects/access_validator.py new file mode 100644 index 00000000..16bef155 --- /dev/null +++ b/backend/services/projects/access_validator.py @@ -0,0 +1,58 @@ +from core.constants import ProjectVisibility +from core.exceptions.auth import PermissionDeniedError +from core.exceptions.project import ProjectIdNotFoundError +from core.exceptions.user import UserIdNotFoundError +from core.interfaces.clients import AbstractUnitOfWorkClient +from infrastructure.database.models import Project +from infrastructure.database.repositories import ( + ProjectRepository, +) + + +class ProjectAccessValidatorService: + def __init__( + self, + unit_of_work: AbstractUnitOfWorkClient, + ) -> None: + self.unit_of_work = unit_of_work + self.project_repository = self.unit_of_work.get_repository(ProjectRepository) + + async def validate_access_to_change_project( + self, + project_id: int, + user_id: int, + ) -> Project: + project = await self.project_repository.get_by_id( + project_id, + ) + if project is None: + raise ProjectIdNotFoundError(project_id) + + await self.validate_project_owner(project_id, user_id) + return project # type: ignore[no-any-return] + + async def validate_access_to_get_project( + self, + project_id: int, + user_id: int, + ) -> Project: + project = await self.project_repository.get_by_id( + project_id, + ) + if project is None: + raise ProjectIdNotFoundError(project_id) + + if project.visibility == ProjectVisibility.private: + await self.validate_project_owner(project_id, user_id) + + return project # type: ignore[no-any-return] + + async def validate_project_owner(self, project_id: int, user_id: int) -> None: + get_owner_coroutine = self.project_repository.get_project_owner( + project_id, + ) + project_owner = await get_owner_coroutine + if project_owner is None: + raise UserIdNotFoundError(user_id) + if project_owner.id != user_id: + raise PermissionDeniedError diff --git a/backend/services/projects/project.py b/backend/services/projects/project.py new file mode 100644 index 00000000..a9f7fe91 --- /dev/null +++ b/backend/services/projects/project.py @@ -0,0 +1,174 @@ +from core.config.application import settings +from core.exceptions.file import FileIdNotFoundError +from core.interfaces.clients import AbstractS3Client, AbstractUnitOfWorkClient +from core.logging import get_logger +from infrastructure.database.repositories import ( + FileRepository, + OutboxRepository, + ProjectRepository, + RenderRepository, +) +from schemas.event import ( + CreateProjectEvent, + EventCreate, +) +from schemas.file import FileLocationCreate +from schemas.project import ( + ProjectPartialUpdate, + ProjectWithRenderCreate, + ProjectWithRenderFileFullResponse, + ProjectWithRenderFileResponse, + ProjectWithRenderResponse, +) +from schemas.render import RenderCreate + +from services.projects.access_validator import ProjectAccessValidatorService + +logger = get_logger(__name__) + + +def generate_output_key(input_key: str) -> str: + output_key_list = input_key.split(".") + output_key_list[-1] = "png" + output_key = ".".join(output_key_list) + return output_key + + +class ProjectService: + def __init__( + self, + unit_of_work: AbstractUnitOfWorkClient, + project_access_validator: ProjectAccessValidatorService, + ) -> None: + self.project_access_validator = project_access_validator + self.unit_of_work = unit_of_work + self.project_repository = self.unit_of_work.get_repository(ProjectRepository) + self.render_repository = self.unit_of_work.get_repository(RenderRepository) + self.file_repository = self.unit_of_work.get_repository(FileRepository) + self.outbox_repository = self.unit_of_work.get_repository(OutboxRepository) + + async def get_by_id( + self, + project_id: int, + user_id: int, + s3_client: AbstractS3Client, + ) -> ProjectWithRenderFileFullResponse: + project = await self.project_access_validator.validate_access_to_get_project( + project_id, + user_id, + ) + logger.info( + "Received projects, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 + project.id, + project.user_id, + project.render_id, + project.source_file_id, + project.name, + project.status, + project.visibility, + ) + return await ProjectWithRenderFileFullResponse.get_from_database( + project, + s3_client, + ) + + async def create_project( + self, + user_id: int, + create_project: ProjectWithRenderCreate, + ) -> ProjectWithRenderResponse: + create_project_data = create_project.project + create_full_render_data = create_project.render + file_id = create_project_data.source_file_id + file = await self.file_repository.get_by_id(file_id) + if file is None: + raise FileIdNotFoundError(file_id) + + create_render_data = RenderCreate.model_validate( + create_full_render_data.model_dump(), + ) + render = await self.render_repository.create_render(create_render_data) + project = await self.project_repository.create_project( + user_id=user_id, + render_id=render.id, + create_project_data=create_project_data, + ) + input_file_location = FileLocationCreate( + bucket=settings.minio.bucket.glb_sources, + key=file.key, + ) + output_key = generate_output_key(file.key) + output_file_location = FileLocationCreate( + bucket=settings.minio.bucket.renders, + key=output_key, + ) + event = CreateProjectEvent( + project_id=project.id, + input=input_file_location, + output=output_file_location, + render=create_full_render_data, + ) + event_create_data = EventCreate( + topic=settings.kafka.topic.project_created, + message=event.model_dump(), + ) + await self.outbox_repository.create_event(event_create_data) + + logger.info( + "Created projects, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 + project.id, + user_id, + project.render_id, + project.source_file_id, + project.name, + project.status, + project.visibility, + ) + return ProjectWithRenderResponse.get_from_database(project, render) + + async def partial_update_project( + self, + project_id: int, + user_id: int, + partial_update_project_data: ProjectPartialUpdate, + ) -> ProjectWithRenderFileResponse: + await self.project_access_validator.validate_access_to_change_project( + project_id, + user_id, + ) + project = await self.project_repository.partial_update_project( + project_id, + partial_update_project_data, + ) + logger.info( + "Updated projects, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 + project_id, + user_id, + project.render_id, + project.source_file_id, + project.name, + project.status, + project.visibility, + ) + return ProjectWithRenderFileResponse.model_validate(project) + + async def delete_by_id( + self, + project_id: int, + user_id: int, + ) -> None: + project = await self.project_access_validator.validate_access_to_change_project( + project_id, + user_id, + ) + await self.project_repository.delete_by_id(project_id) + logger.info( + "Deleted projects, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 + project_id, + user_id, + project.render_id, + project.source_file_id, + project.name, + project.status, + project.visibility, + ) diff --git a/backend/services/projects/project_pagination.py b/backend/services/projects/project_pagination.py new file mode 100644 index 00000000..5cdb4c14 --- /dev/null +++ b/backend/services/projects/project_pagination.py @@ -0,0 +1,73 @@ +from core.exceptions.user import UserIdNotFoundError +from core.interfaces.clients import AbstractUnitOfWorkClient +from infrastructure.database.repositories import ProjectRepository, UserRepository +from schemas.project import ProjectResponse, ProjectResponseList + + +class ProjectPaginationService: + def __init__( + self, + unit_of_work: AbstractUnitOfWorkClient, + ) -> None: + self.unit_of_work = unit_of_work + self.project_repository = self.unit_of_work.get_repository(ProjectRepository) + self.user_repository = self.unit_of_work.get_repository(UserRepository) + + async def get_user_projects( + self, + user_id: int, + size: int, + page: int, + ) -> ProjectResponseList: + user = await self.user_repository.get_by_id(user_id) + if user is None: + raise UserIdNotFoundError(user_id) + + get_projects = self.project_repository.get_user_projects( + user_id, + size, + page, + ) + projects = [ + ProjectResponse.model_validate(project) for project in await get_projects + ] + return ProjectResponseList( + project_list=projects, + size=size, + page=page, + ) + + async def get_public_projects(self, size: int, page: int) -> ProjectResponseList: + get_projects = self.project_repository.get_public_projects(size, page) + projects = [ + ProjectResponse.model_validate(project) for project in await get_projects + ] + return ProjectResponseList( + project_list=projects, + size=size, + page=page, + ) + + async def get_user_public_projects( + self, + user_id: int, + size: int, + page: int, + ) -> ProjectResponseList: + user = await self.user_repository.get_by_id(user_id) + if user is None: + raise UserIdNotFoundError(user_id) + + get_projects = self.project_repository.get_user_public_projects( + user_id, + size, + page, + ) + projects = [ + ProjectResponse.model_validate(project) for project in await get_projects + ] + return ProjectResponseList( + project_list=projects, + size=size, + page=page, + ) diff --git a/backend/services/projects/render_generate_handler.py b/backend/services/projects/render_generate_handler.py new file mode 100644 index 00000000..27eba746 --- /dev/null +++ b/backend/services/projects/render_generate_handler.py @@ -0,0 +1,99 @@ +from types import TracebackType +from typing import Self + +from core.exceptions.project import ProjectIdNotFoundError +from core.interfaces.clients import AbstractS3Client, AbstractUnitOfWorkClient +from core.logging import get_logger +from infrastructure.database.repositories import ( + FileRepository, + ProjectRepository, + RenderRepository, +) +from schemas.event import RenderGeneratedEvent +from schemas.file import FileCreate +from schemas.project import ProjectWithRenderFileResponse + +logger = get_logger(__name__) + + +class RenderGenerateHandlerService: + def __init__( + self, + unit_of_work: AbstractUnitOfWorkClient, + ) -> None: + self.unit_of_work = unit_of_work + self.project_repository = self.unit_of_work.get_repository(ProjectRepository) + self.render_repository = self.unit_of_work.get_repository(RenderRepository) + self.file_repository = self.unit_of_work.get_repository(FileRepository) + + async def __aenter__(self) -> "Self": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """ + Метод для действий при выходе из контекстного менеджера. + """ + + async def handle_render_generated( + self, + render_generated_event: RenderGeneratedEvent, + s3_client: AbstractS3Client, + ) -> None: + project_id = render_generated_event.project_id + file_location = render_generated_event.output + bucket = file_location.bucket + key = file_location.key + + project = await self.project_repository.get_by_id(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + + name = f"{project.name}.png" + size = await s3_client.get_file_size(bucket, key) + file = FileCreate( + name=name, + size=size, + bucket=bucket, + key=key, + ) + render_file = await self.file_repository.create_file(file) + await self.render_repository.add_render_file( + render_id=project.render.id, + file_id=render_file.id, + ) + + logger.info( + "Added render to projects, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 + project.id, + project.user_id, + project.render_id, + project.source_file_id, + project.name, + project.status, + project.visibility, + ) + + async def update_project_status( + self, + project_id: int, + ) -> ProjectWithRenderFileResponse: + project = await self.project_repository.update_project_status(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + + logger.info( + "Updated projects status, project_id=%s, user_id=%s, render_id=%s, source_file_id=%s, name=%s, status=%s, visibility=%s", # noqa: E501 + project.id, + project.user_id, + project.render_id, + project.source_file_id, + project.name, + project.status, + project.visibility, + ) + return ProjectWithRenderFileResponse.model_validate(project) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 762912b2..9b3c6688 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,7 +6,10 @@ from infrastructure.minio.client import MinioClient from services.auth import AuthService from services.file_uploader import FileUploader -from services.project import ProjectService +from services.projects.access_validator import ProjectAccessValidatorService +from services.projects.project import ProjectService +from services.projects.project_pagination import ProjectPaginationService +from services.projects.render_generate_handler import RenderGenerateHandlerService from services.tag import TagService from services.user import UserService from sqlalchemy.ext.asyncio import ( @@ -109,14 +112,39 @@ async def file_uploader( return file_uploader +@pytest.fixture(scope="function") +def project_access_validator( + unit_of_work: UnitOfWork, +) -> ProjectAccessValidatorService: + project_access_validator = ProjectAccessValidatorService(unit_of_work) + return project_access_validator + + @pytest.fixture(scope="function") def project_service( unit_of_work: UnitOfWork, + project_access_validator: ProjectAccessValidatorService, ) -> ProjectService: - project_service = ProjectService(unit_of_work) + project_service = ProjectService(unit_of_work, project_access_validator) return project_service +@pytest.fixture(scope="function") +def project_pagination_service( + unit_of_work: UnitOfWork, +) -> ProjectPaginationService: + project_pagination_service = ProjectPaginationService(unit_of_work) + return project_pagination_service + + +@pytest.fixture(scope="function") +def render_generate_handler( + unit_of_work: UnitOfWork, +) -> RenderGenerateHandlerService: + render_generate_handler_service = RenderGenerateHandlerService(unit_of_work) + return render_generate_handler_service + + @pytest.fixture(scope="function") def tag_service( unit_of_work: UnitOfWork, diff --git a/backend/tests/test_services/test_projects/__init__.py b/backend/tests/test_services/test_projects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_services/test_project/conftest.py b/backend/tests/test_services/test_projects/conftest.py similarity index 100% rename from backend/tests/test_services/test_project/conftest.py rename to backend/tests/test_services/test_projects/conftest.py diff --git a/backend/tests/test_services/test_project/test_project_service.py b/backend/tests/test_services/test_projects/test_project.py similarity index 88% rename from backend/tests/test_services/test_project/test_project_service.py rename to backend/tests/test_services/test_projects/test_project.py index ab6b70ec..a9190b14 100644 --- a/backend/tests/test_services/test_project/test_project_service.py +++ b/backend/tests/test_services/test_projects/test_project.py @@ -10,7 +10,7 @@ ProjectPartialUpdate, ProjectWithRenderCreate, ) -from services.project import ProjectService +from services.projects.project import ProjectService class TestProjectService: @@ -23,13 +23,13 @@ async def test_get_by_id_valid( project_service: ProjectService, ) -> None: mocker.patch.object( - project_service, - "_validate_access_to_get_project", + project_service.project_access_validator, + "validate_access_to_get_project", return_value=full_project, autospec=True, ) mocker.patch( - "services.project.ProjectWithRenderFileFullResponse.get_from_database", + "services.projects.project.ProjectWithRenderFileFullResponse.get_from_database", return_value=full_project, autospec=True, ) @@ -71,13 +71,13 @@ async def test_get_private_project_by_id_not_by_owner( ) -> None: full_project.visibility = ProjectVisibility.private mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_by_id", return_value=full_project, autospec=True, ) mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_project_owner", return_value=project_owner, autospec=True, @@ -156,17 +156,29 @@ async def test_partial_update_project_valid( project_service: ProjectService, ) -> None: mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_by_id", return_value=full_project, autospec=True, ) + mocker.patch.object( + project_service.project_access_validator.project_repository, + "get_project_owner", + return_value=project_owner, + autospec=True, + ) mocker.patch.object( project_service.project_repository, "get_project_owner", return_value=project_owner, autospec=True, ) + mocker.patch.object( + project_service.project_repository, + "partial_update_project", + return_value=full_project, + autospec=True, + ) partial_update_project_data = ProjectPartialUpdate( visibility=ProjectVisibility.private, ) @@ -207,13 +219,13 @@ async def test_partial_update_project_not_by_owner( project_service: ProjectService, ) -> None: mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_by_id", return_value=full_project, autospec=True, ) mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_project_owner", return_value=project_owner, autospec=True, @@ -236,12 +248,12 @@ async def test_delete_by_id_valid( project_service: ProjectService, ) -> None: mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_by_id", return_value=full_project, ) mocker.patch.object( - project_service.project_repository, + project_service.project_access_validator.project_repository, "get_project_owner", return_value=project_owner, ) diff --git a/backend/tests/test_services/test_project/test_project_pagination.py b/backend/tests/test_services/test_projects/test_project_pagination.py similarity index 67% rename from backend/tests/test_services/test_project/test_project_pagination.py rename to backend/tests/test_services/test_projects/test_project_pagination.py index bdfc9716..a11626ed 100644 --- a/backend/tests/test_services/test_project/test_project_pagination.py +++ b/backend/tests/test_services/test_projects/test_project_pagination.py @@ -2,7 +2,7 @@ from core.exceptions.user import UserIdNotFoundError from infrastructure.database.models import Project, User from pytest_mock import MockerFixture -from services.project import ProjectService +from services.projects.project_pagination import ProjectPaginationService class TestProjectPaginationService: @@ -11,20 +11,20 @@ async def test_get_user_projects_valid( project_owner: User, full_project: Project, mocker: MockerFixture, - project_service: ProjectService, + project_pagination_service: ProjectPaginationService, ) -> None: mocker.patch.object( - project_service.user_repository, + project_pagination_service.user_repository, "get_by_id", return_value=project_owner, ) mocker.patch.object( - project_service.project_repository, + project_pagination_service.project_repository, "get_user_projects", return_value=[full_project], autospec=True, ) - await project_service.get_user_projects( + await project_pagination_service.get_user_projects( project_owner.id, size=10, page=1, @@ -34,16 +34,16 @@ async def test_get_user_projects_user_not_exists( self, project_owner: User, mocker: MockerFixture, - project_service: ProjectService, + project_pagination_service: ProjectPaginationService, ) -> None: mocker.patch.object( - project_service.user_repository, + project_pagination_service.user_repository, "get_by_id", return_value=None, autospec=True, ) with pytest.raises(UserIdNotFoundError): - await project_service.get_user_projects( + await project_pagination_service.get_user_projects( project_owner.id, size=10, page=1, @@ -53,15 +53,15 @@ async def test_get_public_projects_valid( self, full_project: Project, mocker: MockerFixture, - project_service: ProjectService, + project_pagination_service: ProjectPaginationService, ) -> None: mocker.patch.object( - project_service.project_repository, + project_pagination_service.project_repository, "get_public_projects", return_value=[full_project], autospec=True, ) - await project_service.get_public_projects( + await project_pagination_service.get_public_projects( size=10, page=1, ) @@ -70,14 +70,14 @@ async def test_get_user_public_projects_valid( self, project_owner: User, mocker: MockerFixture, - project_service: ProjectService, + project_pagination_service: ProjectPaginationService, ) -> None: mocker.patch.object( - project_service.user_repository, + project_pagination_service.user_repository, "get_by_id", return_value=project_owner, ) - await project_service.get_user_public_projects( + await project_pagination_service.get_user_public_projects( user_id=project_owner.id, size=10, page=1, @@ -87,15 +87,15 @@ async def test_get_user_public_projects_user_not_exists( self, project_owner: User, mocker: MockerFixture, - project_service: ProjectService, + project_pagination_service: ProjectPaginationService, ) -> None: mocker.patch.object( - project_service.user_repository, + project_pagination_service.user_repository, "get_by_id", return_value=None, ) with pytest.raises(UserIdNotFoundError): - await project_service.get_user_public_projects( + await project_pagination_service.get_user_public_projects( user_id=project_owner.id, size=10, page=1, diff --git a/backend/tests/test_services/test_project/test_render_generate_handler.py b/backend/tests/test_services/test_projects/test_render_generate_handler.py similarity index 68% rename from backend/tests/test_services/test_project/test_render_generate_handler.py rename to backend/tests/test_services/test_projects/test_render_generate_handler.py index 01445f5a..f2b521f7 100644 --- a/backend/tests/test_services/test_project/test_render_generate_handler.py +++ b/backend/tests/test_services/test_projects/test_render_generate_handler.py @@ -4,7 +4,7 @@ from infrastructure.minio.client import MinioClient from pytest_mock import MockerFixture from schemas.event import RenderGeneratedEvent -from services.project import ProjectService +from services.projects.render_generate_handler import RenderGenerateHandlerService class TestRenderGenerateHandler: @@ -15,10 +15,10 @@ async def test_handle_render_generated_valid( render_generated_event: RenderGeneratedEvent, mocker: MockerFixture, minio_client: MinioClient, - project_service: ProjectService, + render_generate_handler: RenderGenerateHandlerService, ) -> None: mocker.patch.object( - project_service.project_repository, + render_generate_handler.project_repository, "get_by_id", return_value=full_project, ) @@ -28,16 +28,16 @@ async def test_handle_render_generated_valid( return_value=1000, ) mocker.patch.object( - project_service.file_repository, + render_generate_handler.file_repository, "create_file", return_value=render_file, ) mocker.patch.object( - project_service.render_repository, + render_generate_handler.render_repository, "add_render_file", return_value=None, ) - await project_service.handle_render_generated( + await render_generate_handler.handle_render_generated( render_generated_event, minio_client, ) @@ -47,15 +47,15 @@ async def test_handle_render_generated_project_not_exists( render_generated_event: RenderGeneratedEvent, mocker: MockerFixture, minio_client: MinioClient, - project_service: ProjectService, + render_generate_handler: RenderGenerateHandlerService, ) -> None: mocker.patch.object( - project_service.project_repository, + render_generate_handler.project_repository, "get_by_id", return_value=None, ) with pytest.raises(ProjectIdNotFoundError): - await project_service.handle_render_generated( + await render_generate_handler.handle_render_generated( render_generated_event, minio_client, ) @@ -64,25 +64,25 @@ async def test_update_project_status_valid( self, full_project: Project, mocker: MockerFixture, - project_service: ProjectService, + render_generate_handler: RenderGenerateHandlerService, ) -> None: mocker.patch.object( - project_service.project_repository, + render_generate_handler.project_repository, "update_project_status", return_value=full_project, ) - await project_service.update_project_status(full_project.id) + await render_generate_handler.update_project_status(full_project.id) async def test_update_not_exists_project( self, full_project: Project, mocker: MockerFixture, - project_service: ProjectService, + render_generate_handler: RenderGenerateHandlerService, ) -> None: mocker.patch.object( - project_service.project_repository, + render_generate_handler.project_repository, "update_project_status", return_value=None, ) with pytest.raises(ProjectIdNotFoundError): - await project_service.update_project_status(full_project.id) + await render_generate_handler.update_project_status(full_project.id)