Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/python-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,11 @@ jobs:
- name: install dependencies
run: poetry install --with dev

- name: generate JWT keys
run: |
mkdir -p certs
openssl genrsa -out certs/jwt-private.pem 2048
openssl rsa -in certs/jwt-private.pem -pubout -out certs/jwt-public.pem

- name: run tests
run: pytest -sv
10 changes: 10 additions & 0 deletions backend/core/interfaces/clients.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from types import TracebackType
from typing import BinaryIO, Self
from uuid import uuid4

from core.interfaces.repositories import AbstractRepository

Expand Down Expand Up @@ -46,6 +47,15 @@ async def delete_object(self, bucket: str, key: str) -> None:
Метод для удаления файла из s3 хранилища.
"""

@classmethod
def generate_key(cls, file_name: str) -> str:
"""
Генерация ключа файла по его имени.
"""
uuid = uuid4()
key = f"{uuid}_{file_name}"
return key


class AbstractUnitOfWorkClient(ABC):
"""
Expand Down
15 changes: 0 additions & 15 deletions backend/core/security/password_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
from bcrypt import checkpw, gensalt, hashpw
from schemas.auth import RegisterRequest
from schemas.user import UserCreate


def hash_password(password: str) -> str:
Expand All @@ -24,16 +22,3 @@ def validate_password(
password=password_bytes,
hashed_password=hashed_password_bytes,
)


def convert_register_to_create_user(register_user_data: RegisterRequest) -> UserCreate:
password = register_user_data.password
encrypted_password = hash_password(password)
register_user_data_dict = register_user_data.model_dump(
exclude={"password"},
)
create_user_data = UserCreate(
encrypted_password=encrypted_password,
**register_user_data_dict,
)
return create_user_data
733 changes: 549 additions & 184 deletions backend/poetry.lock

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dependencies = [
"aiokafka (>=0.14.0,<0.15.0)",
"tenacity (>=9.1.4,<10.0.0)",
"prometheus-fastapi-instrumentator (>=8.1.0,<9.0.0)",
"testcontainers (>=4.15.0,<5.0.0)",
"cryptography (>=50.0.2,<51.0.0)",
]


Expand All @@ -33,7 +33,9 @@ dev = [
"ruff (>=0.15.22,<0.16.0)",
"mypy (>=2.3.0,<3.0.0)",
"black (==26.5.1)",
"pytest-asyncio (>=1.4.0,<2.0.0)"
"pytest-asyncio (>=1.4.0,<2.0.0)",
"pytest-mock (>=3.16.0,<4.0.0)",
"testcontainers[minio] (>=4.15.0,<5.0.0)"
]


Expand Down Expand Up @@ -177,7 +179,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"core/config/*" = ["S105"]
"core/constants.py" = ["S105"]
"tests/**/*.py" = ["DTZ001", "DTZ005", "DTZ007"]
"tests/**/*.py" = ["DTZ001", "DTZ005", "DTZ007", "S106"]


# Allow fix for all enabled rules (when `--fix`) is provided.
Expand Down
17 changes: 16 additions & 1 deletion backend/schemas/user.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from datetime import datetime
from typing import ClassVar
from typing import ClassVar, Self

from core.security.password_utils import hash_password
from pydantic import BaseModel, ConfigDict

from schemas.auth import RegisterRequest
from schemas.constraints.user import (
EmailConstraint,
EncryptedPasswordConstraint,
Expand Down Expand Up @@ -32,6 +34,19 @@ class UserCreate(UserBase):
email: EmailConstraint
encrypted_password: EncryptedPasswordConstraint

@classmethod
def get_from_register_request(cls, register_user_data: RegisterRequest) -> Self:
password = register_user_data.password
encrypted_password = hash_password(password)
register_user_data_dict = register_user_data.model_dump(
exclude={"password"},
)
create_user_data = cls(
encrypted_password=encrypted_password,
**register_user_data_dict,
)
return create_user_data


class UserUpdate(UserBase):
"""
Expand Down
9 changes: 3 additions & 6 deletions backend/services/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,11 @@
from core.interfaces.clients import AbstractUnitOfWorkClient
from core.logging import get_logger
from core.security.jwt_tokens.factory import create_access_token, create_token
from core.security.password_utils import (
convert_register_to_create_user,
validate_password,
)
from core.security.password_utils import validate_password
from infrastructure.database.repositories.user import UserRepository
from schemas.auth import LoginRequest, RegisterRequest
from schemas.token import TokenInfo
from schemas.user import UserResponse
from schemas.user import UserCreate, UserResponse

logger = get_logger(__name__)

Expand All @@ -37,7 +34,7 @@ async def register(self, register_user_data: RegisterRequest) -> TokenInfo:
if user is not None:
raise UserEmailAlreadyExistsError(register_user_data.email)

create_user_data = convert_register_to_create_user(register_user_data)
create_user_data = UserCreate.get_from_register_request(register_user_data)
user = await self.user_repository.create_user(create_user_data)
user_response = UserResponse.model_validate(user)
token = create_token(user_response)
Expand Down
22 changes: 2 additions & 20 deletions backend/services/file_uploader.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import asyncio
from os import fstat
from typing import BinaryIO
from uuid import uuid4

from core.interfaces.clients import AbstractS3Client, AbstractUnitOfWorkClient
from core.interfaces.services import AbstractFileUploader
Expand All @@ -24,29 +22,13 @@ def __init__(
self.file_repository = self.unit_of_work.get_repository(FileRepository)
self.bucket = bucket

@staticmethod
def _generate_key(file_name: str) -> str:
uuid = uuid4()
key = f"{uuid}_{file_name}"
return key

@staticmethod
def _get_file_size(file: BinaryIO) -> int:
"""
Получить размер файла в байтах.
"""
file_descriptor = file.fileno()
file_status = fstat(file_descriptor)
size = file_status.st_size
return size

async def upload(
self,
file_name: str,
file: BinaryIO,
) -> FileResponse:
key = self._generate_key(file_name)
size = self._get_file_size(file)
key = self.s3_client.generate_key(file_name)
size = await self.s3_client.get_file_size(self.bucket, key)
create_file_data = FileCreate(
name=file_name,
size=size,
Expand Down
27 changes: 18 additions & 9 deletions backend/services/tag.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
from typing import cast

from core.constants import ProjectVisibility
from core.exceptions.auth import PermissionDeniedError
from core.exceptions.project import ProjectIdNotFoundError
from core.exceptions.tag import TagIdNotFoundError
from core.exceptions.user import UserIdNotFoundError
from core.interfaces.clients import AbstractUnitOfWorkClient
from infrastructure.database.models import User
from infrastructure.database.repositories import ProjectRepository, TagRepository
from schemas.tag import TagCreate, TagResponse, TagResponseList

Expand All @@ -24,8 +22,14 @@ async def get_project_tags(self, project_id: int, user_id: int) -> TagResponseLi
get_owner_coroutine = self.project_repository.get_project_owner(
project_id,
)
owner = cast(User, await get_owner_coroutine)
if project.visibility == ProjectVisibility.private and user_id != owner.id:
project_owner = await get_owner_coroutine
if project_owner is None:
raise UserIdNotFoundError(user_id)

if (
project.visibility == ProjectVisibility.private
and user_id != project_owner.id
):
raise PermissionDeniedError

tag_list = [
Expand All @@ -47,8 +51,11 @@ async def create_tag(
get_owner_coroutine = self.project_repository.get_project_owner(
project_id,
)
owner = cast(User, await get_owner_coroutine)
if owner.id != user_id:
project_owner = await get_owner_coroutine
if project_owner is None:
raise UserIdNotFoundError(user_id)

if project_owner.id != user_id:
raise PermissionDeniedError

tag = await self.tag_repository.create_tag(create_tag_data)
Expand All @@ -62,8 +69,10 @@ async def delete_by_id(self, tag_id: int, user_id: int) -> None:
get_owner_coroutine = self.project_repository.get_project_owner(
tag.project.id,
)
owner = cast(User, await get_owner_coroutine)
if owner.id != user_id:
project_owner = await get_owner_coroutine
if project_owner is None:
raise UserIdNotFoundError(user_id)
if project_owner.id != user_id:
raise PermissionDeniedError

await self.tag_repository.delete_by_id(tag_id)
2 changes: 1 addition & 1 deletion backend/services/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __init__(

async def get_by_id(self, user_id: int) -> UserResponse:
user_full_response = await self.get_profile_by_id(user_id)
return UserResponse.model_validate(user_full_response)
return UserResponse.model_validate(user_full_response.model_dump())

async def get_profile_by_id(self, user_id: int) -> UserFullResponse:
user = await self.user_repository.get_by_id(user_id)
Expand Down
96 changes: 96 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
from collections.abc import AsyncGenerator, Generator

import pytest
from aiobotocore import session as minio_session
from aiobotocore.client import AioBaseClient
from aiobotocore.session import AioSession
from infrastructure.database.unit_of_work import UnitOfWork
from infrastructure.minio.client import MinioClient
from services.auth import AuthService
from services.file_uploader import FileUploader
from services.tag import TagService
from services.user import UserService
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from testcontainers.community.minio import MinioContainer
from testcontainers.community.postgres import PostgresContainer

from tests.helpers import run_migrations
Expand Down Expand Up @@ -49,3 +59,89 @@ async def session(
) -> AsyncGenerator[AsyncSession]:
async with session_factory() as session:
yield session


@pytest.fixture(scope="function")
async def unit_of_work(
session: AsyncSession,
) -> AsyncGenerator[UnitOfWork]:
unit_of_work = UnitOfWork(session)
yield unit_of_work
await unit_of_work.rollback()


@pytest.fixture(scope="function")
async def auth_service(
unit_of_work: UnitOfWork,
) -> AuthService:
auth_service = AuthService(unit_of_work)
return auth_service


@pytest.fixture(scope="function")
async def user_service(
unit_of_work: UnitOfWork,
) -> UserService:
user_service = UserService(unit_of_work)
return user_service


@pytest.fixture(scope="function")
def s3_container() -> Generator[MinioContainer]:
with MinioContainer("quay.io/minio/minio") as minio_container:
yield minio_container


@pytest.fixture(scope="session")
async def s3_session() -> AioSession:
return minio_session.get_session()


@pytest.fixture(scope="function")
def s3_config(s3_container: MinioContainer) -> dict:
connection_params = s3_container.get_config()
host = s3_container.get_container_host_ip()
endpoint_url = f"http://{host}:{s3_container.port}"
return {
"endpoint_url": endpoint_url,
"aws_access_key_id": connection_params["access_key"],
"aws_secret_access_key": connection_params["secret_key"],
}


@pytest.fixture(scope="function")
async def s3_client(
s3_session: AioSession,
s3_config: dict,
) -> AsyncGenerator[AioBaseClient]:
async with s3_session.create_client(
"s3",
**s3_config,
) as client:
yield client


@pytest.fixture(scope="function")
def minio_client(s3_client: AioBaseClient) -> MinioClient:
return MinioClient(s3_client)


@pytest.fixture(scope="function")
async def file_uploader(
minio_client: MinioClient,
unit_of_work: UnitOfWork,
) -> FileUploader:
file_uploader = FileUploader(
bucket="bucket",
s3_client=minio_client,
unit_of_work=unit_of_work,
)
return file_uploader


@pytest.fixture(scope="function")
def tag_service(
unit_of_work: UnitOfWork,
) -> TagService:
tag_service = TagService(unit_of_work)
return tag_service
Empty file.
Empty file.
Empty file.
Loading
Loading