diff --git a/app/api/api_v1/project.py b/app/api/api_v1/project.py index 30aa1b5..8324921 100644 --- a/app/api/api_v1/project.py +++ b/app/api/api_v1/project.py @@ -1,9 +1,12 @@ from dependencies.annotations import ( AuthUserByAccessTokenDep, MinioClientDep, + PaginationPageDep, + PaginationSizeDep, ProjectServiceDep, ) -from fastapi import APIRouter, status +from dependencies.auth import get_auth_user_by_access_token +from fastapi import APIRouter, Depends, status from schemas.project import ( ProjectPartialUpdate, ProjectResponseList, @@ -19,24 +22,6 @@ ) -@router.get( - "/{project_id}", - response_model=ProjectWithRenderFileFullResponse, - status_code=status.HTTP_200_OK, -) -async def get_project( - project_id: int, - user_id: AuthUserByAccessTokenDep, - project_service: ProjectServiceDep, - s3_client: MinioClientDep, -) -> ProjectWithRenderFileFullResponse: - return await project_service.get_by_id( - project_id=project_id, - user_id=user_id, - s3_client=s3_client, - ) - - @router.get( "/", response_model=ProjectResponseList, @@ -45,8 +30,8 @@ async def get_project( async def get_current_user_projects( project_service: ProjectServiceDep, user_id: AuthUserByAccessTokenDep, - size: int = 10, - page: int = 1, + size: PaginationSizeDep = 10, + page: PaginationPageDep = 1, ) -> ProjectResponseList: return await project_service.get_user_projects( user_id=user_id, @@ -55,16 +40,35 @@ async def get_current_user_projects( ) +@router.get( + "/public", + response_model=ProjectResponseList, + status_code=status.HTTP_200_OK, + dependencies=[ + Depends(get_auth_user_by_access_token), + ], +) +async def get_public_projects( + project_service: ProjectServiceDep, + size: PaginationSizeDep = 10, + page: PaginationPageDep = 1, +) -> ProjectResponseList: + return await project_service.get_public_projects(size=size, page=page) + + @router.get( "/public/user/{user_id}", response_model=ProjectResponseList, status_code=status.HTTP_200_OK, + dependencies=[ + Depends(get_auth_user_by_access_token), + ], ) async def get_user_public_projects( project_service: ProjectServiceDep, user_id: int, - size: int = 10, - page: int = 1, + size: PaginationSizeDep = 10, + page: PaginationPageDep = 1, ) -> ProjectResponseList: return await project_service.get_user_public_projects( user_id=user_id, @@ -120,3 +124,21 @@ async def delete_project( project_id=project_id, user_id=user_id, ) + + +@router.get( + "/{project_id}", + response_model=ProjectWithRenderFileFullResponse, + status_code=status.HTTP_200_OK, +) +async def get_project( + project_id: int, + user_id: AuthUserByAccessTokenDep, + project_service: ProjectServiceDep, + s3_client: MinioClientDep, +) -> ProjectWithRenderFileFullResponse: + return await project_service.get_by_id( + project_id=project_id, + user_id=user_id, + s3_client=s3_client, + ) diff --git a/app/core/config/application.py b/app/core/config/application.py index ecaa9ca..6932c8d 100644 --- a/app/core/config/application.py +++ b/app/core/config/application.py @@ -6,6 +6,7 @@ from core.config.database import DataBaseConfig from core.config.jwt import JWTConfig +from core.config.kafka import KafkaConfig from core.config.logging import LoggingConfig from core.config.minio import MinioConfig @@ -13,8 +14,9 @@ class Settings(BaseSettings): base_dir: Path = Path(__file__).parent.parent.parent database: DataBaseConfig = DataBaseConfig() - jwt: JWTConfig = JWTConfig() minio: MinioConfig = MinioConfig() + kafka: KafkaConfig = KafkaConfig() + jwt: JWTConfig = JWTConfig() logging: LoggingConfig = LoggingConfig() oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( tokenUrl="/api/v1/auth/login", diff --git a/app/core/config/kafka.py b/app/core/config/kafka.py new file mode 100644 index 0000000..df7a86e --- /dev/null +++ b/app/core/config/kafka.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class KafkaTopicConfig(BaseModel): + create_project: str = "create_project" + generate_render: str = "generate_model" + + +class KafkaConfig(BaseModel): + host: str = "kafka" + port: int = 9092 + + topic: KafkaTopicConfig = KafkaTopicConfig() diff --git a/app/core/config/minio.py b/app/core/config/minio.py index b2fd5ad..24928ac 100644 --- a/app/core/config/minio.py +++ b/app/core/config/minio.py @@ -1,12 +1,19 @@ from pydantic import BaseModel +class MinioBucketConfig(BaseModel): + input: str = "input" + output: str = "output" + + class MinioConfig(BaseModel): host: str = "minio" port: int = 9000 username: str = "adminadmin" password: str = "adminadmin" + bucket: MinioBucketConfig = MinioBucketConfig() + @property def url(self) -> str: return f"http://{self.host}:{self.port}" diff --git a/app/core/constants.py b/app/core/constants.py index dd83652..5c35109 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from enum import StrEnum USER_SURNAME_MIN_LENGTH = 3 @@ -24,6 +23,9 @@ PROJECT_DESCRIPTION_MIN_LENGTH = 0 PROJECT_DESCRIPTION_MAX_LENGTH = 512 +PROJECT_PAGINATION_SIZE_MIN_VALUE = 1 +PROJECT_PAGINATION_PAGE_MIN_VALUE = 1 + RENDER_WIDTH_MIN_VALUE = 20 RENDER_WIDTH_MAX_VALUE = 2160 @@ -44,6 +46,7 @@ OUTBOX_TOPIC_MAX_LENGTH = 512 + FIELD_SUB = "sub" TOKEN_TYPE = "type" @@ -65,19 +68,3 @@ class RenderStatus(StrEnum): class ProjectVisibility(StrEnum): public = "public" private = "private" - - -@dataclass(frozen=True) -class S3Bucket: - input_bucket = "input" - output_bucket = "output" - - -@dataclass(frozen=True) -class KafkaTopic: - create_project: str = "create_project" - generate_model: str = "generate_model" - - -s3_bucket = S3Bucket() -kafka_topic = KafkaTopic() diff --git a/app/core/exceptions/auth.py b/app/core/exceptions/auth.py index fec88cf..9933a20 100644 --- a/app/core/exceptions/auth.py +++ b/app/core/exceptions/auth.py @@ -17,6 +17,6 @@ class PermissionDeniedError(ForbiddenError): Ошибка, связанная с отсутствием прав доступ. """ - def __init__(self, access_level: str) -> None: - detail = f"Permission denied: access level = {access_level} is not sufficient." + def __init__(self) -> None: + detail = "Permission denied: access level is not sufficient." super().__init__(detail) diff --git a/app/core/interfaces/repositories.py b/app/core/interfaces/repositories.py index 610c414..880acfa 100644 --- a/app/core/interfaces/repositories.py +++ b/app/core/interfaces/repositories.py @@ -121,11 +121,22 @@ async def get_project_owner(self, project_id: int) -> User | None: """ @abstractmethod - async def get_user_projects(self, user_id: int) -> list[Project]: + async def get_user_projects( + self, + user_id: int, + size: int, + page: int, + ) -> list[Project]: """ Посмотреть список проектов пользователя. """ + @abstractmethod + async def get_public_projects(self, size: int, page: int) -> list[Project]: + """ + Посмотреть список публичных проектов. + """ + @abstractmethod async def get_user_public_projects( self, diff --git a/app/dependencies/annotations.py b/app/dependencies/annotations.py index 9a2de74..da15738 100644 --- a/app/dependencies/annotations.py +++ b/app/dependencies/annotations.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Query from infrastructure.database.repositories.render import RenderRepository from infrastructure.database.unit_of_work import UnitOfWork from infrastructure.minio.client import MinioClient @@ -74,3 +74,17 @@ MinioClient, Depends(get_minio_client), ] + +PaginationSizeDep = Annotated[ + int, + Query( + ge=1, + ), +] + +PaginationPageDep = Annotated[ + int, + Query( + ge=1, + ), +] diff --git a/app/dependencies/services.py b/app/dependencies/services.py index ac067bb..a5e320f 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -2,7 +2,7 @@ from typing import Annotated from aiokafka import AIOKafkaProducer -from core.constants import s3_bucket +from core.config.application import settings from fastapi import Depends from infrastructure.database.unit_of_work import UnitOfWork from infrastructure.kafka import producer @@ -33,7 +33,7 @@ async def get_input_file_uploader( ], ) -> AsyncGenerator[FileUploader]: file_uploader = FileUploader( - bucket=s3_bucket.input_bucket, + bucket=settings.minio.bucket.input, s3_client=s3_client, unit_of_work=unit_of_work, ) diff --git a/app/infrastructure/database/repositories/__init__.py b/app/infrastructure/database/repositories/__init__.py index e69de29..6d2a7cb 100644 --- a/app/infrastructure/database/repositories/__init__.py +++ b/app/infrastructure/database/repositories/__init__.py @@ -0,0 +1,12 @@ +__all__ = ( + "FileRepository", + "OutboxRepository", + "ProjectRepository", + "RenderRepository", + "UserRepository", +) +from .file import FileRepository +from .outbox import OutboxRepository +from .project import ProjectRepository +from .render import RenderRepository +from .user import UserRepository diff --git a/app/infrastructure/database/repositories/project.py b/app/infrastructure/database/repositories/project.py index e1d822c..9b4ec88 100644 --- a/app/infrastructure/database/repositories/project.py +++ b/app/infrastructure/database/repositories/project.py @@ -32,8 +32,28 @@ async def get_project_owner(self, project_id: int) -> User | None: result = await self.session.execute(stmt) return result.scalar() - async def get_user_projects(self, user_id: int) -> list[Project]: - stmt = select(Project).where(Project.user_id == user_id) + async def get_public_projects(self, size: int, page: int) -> list[Project]: + stmt = ( + select(Project) + .where(Project.visibility == ProjectVisibility.public) + .limit(size) + .offset(size * (page - 1)) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def get_user_projects( + self, + user_id: int, + size: int, + page: int, + ) -> list[Project]: + stmt = ( + select(Project) + .where(Project.user_id == user_id) + .limit(size) + .offset(size * (page - 1)) + ) result = await self.session.execute(stmt) return list(result.scalars().all()) diff --git a/app/infrastructure/kafka.py b/app/infrastructure/kafka.py index f39acc6..ddf2b8a 100644 --- a/app/infrastructure/kafka.py +++ b/app/infrastructure/kafka.py @@ -2,7 +2,6 @@ from aiokafka import AIOKafkaConsumer, AIOKafkaProducer, ConsumerRecord from core.config.application import settings -from core.constants import kafka_topic from core.logging import get_logger from pydantic import BaseModel from schemas.event import AddRenderProjectEvent @@ -62,7 +61,7 @@ async def add_render_to_project_consume(consumer: AIOKafkaConsumer) -> None: consumer = AIOKafkaConsumer( - kafka_topic.generate_model, + settings.kafka.topic.generate_render, bootstrap_servers="kafka:9092", group_id="app", value_deserializer=deserialize_message, diff --git a/app/outbox_worker/worker.py b/app/outbox_worker/worker.py index 70ab3bf..cefb6f6 100644 --- a/app/outbox_worker/worker.py +++ b/app/outbox_worker/worker.py @@ -3,7 +3,7 @@ from typing import Self from aiokafka import AIOKafkaProducer -from core.constants import kafka_topic +from core.config.application import settings from core.interfaces.clients import AbstractUnitOfWorkClient from core.logging import get_logger from infrastructure.database.repositories.outbox import OutboxRepository @@ -43,13 +43,14 @@ async def process_messages(self) -> None: logger.info("Received message, message=%s", event.message) message = self._serialize_message(event.message) await self.outbox_repository.mark_event_as_sent(event.id) + topic = settings.kafka.topic.create_project await self.producer.send( - topic=kafka_topic.create_project, + topic=topic, value=message, ) logger.info( "Sent message, topic=%s, message=%s", - kafka_topic.create_project, + topic, message, ) diff --git a/app/schemas/constraints/pagination.py b/app/schemas/constraints/pagination.py new file mode 100644 index 0000000..bdde125 --- /dev/null +++ b/app/schemas/constraints/pagination.py @@ -0,0 +1,17 @@ +from typing import Annotated + +from core.constants import ( + PROJECT_PAGINATION_PAGE_MIN_VALUE, + PROJECT_PAGINATION_SIZE_MIN_VALUE, +) +from pydantic import Field + +SizeConstraint = Annotated[ + int, + Field(ge=PROJECT_PAGINATION_SIZE_MIN_VALUE), +] + +PageConstraint = Annotated[ + int, + Field(ge=PROJECT_PAGINATION_PAGE_MIN_VALUE), +] diff --git a/app/schemas/event.py b/app/schemas/event.py index aa1dbb6..2c1c4c0 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -1,3 +1,6 @@ +from typing import Self + +from infrastructure.database.models import File, Project, Render from pydantic import BaseModel from schemas.constraints.event import TopicConstraint @@ -11,15 +14,6 @@ class EventBase(BaseModel): """ -class EventCreate(BaseModel): - """ - Схема для создания записи о событии в outbox. - """ - - topic: TopicConstraint - message: dict # type: ignore[type-arg] - - class GenerateRenderEvent(BaseModel): """ Схема для события генерация проекта. @@ -29,6 +23,48 @@ class GenerateRenderEvent(BaseModel): render: RenderCreate file: FileLocationCreate + @classmethod + def get_from_database( + cls, + project: Project, + render: Render, + file: File, + ) -> Self: + file_location_create = FileLocationCreate.model_validate(file) + create_render_data = RenderCreate.model_validate(render) + return cls( + project_id=project.id, + render=create_render_data, + file=file_location_create, + ) + + +class EventCreate(BaseModel): + """ + Схема для создания записи о событии в outbox. + """ + + topic: TopicConstraint + message: dict # type: ignore[type-arg] + + @classmethod + def get_from_database( + cls, + project: Project, + render: Render, + file: File, + topic: str, + ) -> Self: + message = GenerateRenderEvent.get_from_database( + project, + render, + file, + ) + return cls( + topic=topic, + message=message.model_dump(), + ) + class AddRenderProjectEvent(BaseModel): """ diff --git a/app/schemas/file.py b/app/schemas/file.py index fba37ea..e0293c3 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -27,6 +27,7 @@ class FileLocationCreate(BaseModel): bucket: BucketConstraint key: KeyConstraint + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) class FileCreate(FileBase, FileLocationCreate): diff --git a/app/schemas/project.py b/app/schemas/project.py index 9d4fc2e..6808c03 100644 --- a/app/schemas/project.py +++ b/app/schemas/project.py @@ -1,8 +1,10 @@ -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar, Self from core.constants import ProjectVisibility, RenderStatus +from infrastructure.database.models import Project, Render from pydantic import BaseModel, ConfigDict +from schemas.constraints.pagination import PageConstraint, SizeConstraint from schemas.constraints.project import DescriptionConstraint, NameConstraint from schemas.render import ( RenderCreate, @@ -11,6 +13,9 @@ RenderWithFileResponse, ) +if TYPE_CHECKING: + from core.interfaces.clients import AbstractS3Client + class ProjectBase(BaseModel): """ @@ -76,6 +81,36 @@ class ProjectWithRenderFileFullResponse(ProjectFullResponse): render: RenderWithFileFullResponse + @classmethod + async def get_from_database( + cls, + project: Project, + s3_client: "AbstractS3Client", + ) -> Self: + project_response = ProjectResponse.model_validate(project) + source_file_url = await s3_client.generate_presigned_url( + bucket=project.source_file.bucket, + key=project.source_file.key, + expires_in=10 * 60, + client_method="get_object", + ) + + render = RenderWithFileFullResponse.model_validate(project.render) + if project.render.file is not None: + render_file_url = await s3_client.generate_presigned_url( + bucket=project.render.file.bucket, + key=project.render.file.key, + expires_in=10 * 60, + client_method="get_object", + ) + render.url = render_file_url.replace("minio", "localhost") + + return cls( + **project_response.model_dump(), + render=render, + url=source_file_url.replace("minio", "localhost"), + ) + class ProjectWithRenderResponse(ProjectResponse): """ @@ -84,6 +119,15 @@ class ProjectWithRenderResponse(ProjectResponse): render: RenderResponse + @classmethod + def get_from_database(cls, project: Project, render: Render) -> Self: + render_response = RenderResponse.model_validate(render) + project_response = ProjectResponse.model_validate(project) + return cls( + render=render_response, + **project_response.model_dump(), + ) + class ProjectWithRenderFileResponse(ProjectResponse): """ @@ -99,8 +143,8 @@ class ProjectResponseList(BaseModel): """ project_list: list[ProjectResponse] - size: int - page: int + size: SizeConstraint + page: PageConstraint class ProjectWithRenderFileResponseList(BaseModel): @@ -109,5 +153,5 @@ class ProjectWithRenderFileResponseList(BaseModel): """ project_list: list[ProjectWithRenderFileResponse] - size: int - page: int + size: SizeConstraint + page: PageConstraint diff --git a/app/schemas/render.py b/app/schemas/render.py index dff3328..67bc31c 100644 --- a/app/schemas/render.py +++ b/app/schemas/render.py @@ -29,6 +29,8 @@ class RenderCreate(RenderBase): Схема для создания рендера. """ + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + class RenderResponse(RenderBase): """ diff --git a/app/services/project.py b/app/services/project.py index 2640305..17bec21 100644 --- a/app/services/project.py +++ b/app/services/project.py @@ -1,21 +1,24 @@ from types import TracebackType from typing import Self, cast -from core.constants import KafkaTopic, ProjectVisibility +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 User -from infrastructure.database.repositories.file import FileRepository -from infrastructure.database.repositories.outbox import OutboxRepository -from infrastructure.database.repositories.project import ProjectRepository -from infrastructure.database.repositories.render import RenderRepository -from infrastructure.database.repositories.user import UserRepository -from schemas.event import AddRenderProjectEvent, EventCreate, GenerateRenderEvent -from schemas.file import FileCreate, FileLocationCreate +from infrastructure.database.models import Project, User +from infrastructure.database.repositories import ( + FileRepository, + OutboxRepository, + ProjectRepository, + RenderRepository, + UserRepository, +) +from schemas.event import AddRenderProjectEvent, EventCreate +from schemas.file import FileCreate from schemas.project import ( ProjectPartialUpdate, ProjectResponse, @@ -25,7 +28,6 @@ ProjectWithRenderFileResponse, ProjectWithRenderResponse, ) -from schemas.render import RenderResponse, RenderWithFileFullResponse logger = get_logger(__name__) @@ -61,21 +63,10 @@ async def get_by_id( user_id: int, s3_client: AbstractS3Client, ) -> ProjectWithRenderFileFullResponse: - project = await self.project_repository.get_by_id(project_id) - if project is None: - raise ProjectIdNotFoundError(project_id) - - get_owner_coroutine = self.project_repository.get_project_owner( + project = await self._validate_access_to_get_project( project_id, + user_id, ) - owner = cast(User, await get_owner_coroutine) - if ( - project.visibility == ProjectVisibility.private.value - and owner.id != user_id - ): - detail = "You are not allowed to watch this project." - raise PermissionDeniedError(detail) - 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, @@ -86,28 +77,9 @@ async def get_by_id( project.status, project.visibility, ) - project_response = ProjectResponse.model_validate(project) - source_file_url = await s3_client.generate_presigned_url( - bucket=project.source_file.bucket, - key=project.source_file.key, - expires_in=10 * 60, - client_method="get_object", - ) - - render = RenderWithFileFullResponse.model_validate(project.render) - if project.render.file is not None: - render_file_url = await s3_client.generate_presigned_url( - bucket=project.render.file.bucket, - key=project.render.file.key, - expires_in=10 * 60, - client_method="get_object", - ) - render.url = render_file_url.replace("minio", "localhost") - - return ProjectWithRenderFileFullResponse( - **project_response.model_dump(), - render=render, - url=source_file_url.replace("minio", "localhost"), + return await ProjectWithRenderFileFullResponse.get_from_database( + project, + s3_client, ) async def get_user_projects( @@ -122,6 +94,8 @@ async def get_user_projects( get_projects = self.project_repository.get_user_projects( user_id, + size, + page, ) projects = [ ProjectResponse.model_validate(project) for project in await get_projects @@ -132,6 +106,17 @@ async def get_user_projects( 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, @@ -227,24 +212,12 @@ async def create_project( render_id=render.id, create_project_data=create_project_data, ) - render_response = RenderResponse.model_validate(render) - project_response = ProjectResponse.model_validate(project) - project_with_render_response = ProjectWithRenderResponse( - render=render_response, - **project_response.model_dump(), - ) - file_location_create = FileLocationCreate( - bucket=file.bucket, - key=file.key, - ) - message = GenerateRenderEvent( - project_id=project.id, - render=create_render_data, - file=file_location_create, - ) - event_create_data = EventCreate( - topic=KafkaTopic.create_project, - message=message.model_dump(), + + event_create_data = EventCreate.get_from_database( + project=project, + render=render, + file=file, + topic=settings.kafka.topic.create_project, ) await self.outbox_repository.create_event(event_create_data) @@ -258,7 +231,7 @@ async def create_project( project.status, project.visibility, ) - return project_with_render_response + return ProjectWithRenderResponse.get_from_database(project, render) async def partial_update_project( self, @@ -266,20 +239,7 @@ async def partial_update_project( user_id: int, partial_update_project_data: ProjectPartialUpdate, ) -> ProjectWithRenderFileResponse: - project = await self.project_repository.get_by_id( - project_id, - ) - if project is None: - raise ProjectIdNotFoundError(project_id) - - get_owner_coroutine = self.project_repository.get_project_owner( - project_id, - ) - owner = cast(User, await get_owner_coroutine) - if owner.id != user_id: - detail = "You are not allowed to watch this project." - raise PermissionDeniedError(detail) - + 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, @@ -301,20 +261,7 @@ async def delete_by_id( project_id: int, user_id: int, ) -> None: - project = await self.project_repository.get_by_id( - project_id, - ) - if project is None: - raise ProjectIdNotFoundError(project_id) - - get_owner_coroutine = self.project_repository.get_project_owner( - project_id, - ) - owner = cast(User, await get_owner_coroutine) - if owner.id != user_id: - detail = "You are not allowed to watch this project." - raise PermissionDeniedError(detail) - + 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 @@ -326,3 +273,41 @@ async def delete_by_id( 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, + ) + owner = cast(User, await get_owner_coroutine) + if owner.id != user_id: + raise PermissionDeniedError