diff --git a/api/app_entrypoint/dependencies.py b/api/app_entrypoint/dependencies.py index cf90398..d292a32 100644 --- a/api/app_entrypoint/dependencies.py +++ b/api/app_entrypoint/dependencies.py @@ -1,5 +1,6 @@ from fastapi import FastAPI -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import (AsyncEngine, AsyncSession, + async_sessionmaker) from api.application.abstractions.uow import UnitOfWork from api.application.protocols.date_time import DateTimeProvider @@ -7,35 +8,28 @@ from api.application.protocols.jwt import JwtTokenProcessor from api.application.protocols.password_hasher import PasswordHasher from api.application.usecase.auth.auth_user import LoginUser from api.application.usecase.auth.create_user import CreateUser -from api.application.usecase.company.get_users_company import GetCompaniesByOwnerEmail +from api.application.usecase.company.create_company import CreateCompany +from api.application.usecase.company.get_users_company import \ + GetCompaniesByOwnerEmail from api.domain.company.repository import CompanyRepository from api.domain.user.repository import UserRepository from api.infrastructure.auth.jwt_settings import JwtSettings -from api.infrastructure.dependencies.adapters import ( - create_engine, - create_session_maker, - new_session, - new_unit_of_work, -) -from api.infrastructure.dependencies.configs import ( - app_settings, - get_db_settings, - get_jwt_settings, -) -from api.infrastructure.dependencies.protocols import ( - get_date_time_provider, - get_jwt_token_processor, - get_password_hasher, - get_user_login, -) +from api.infrastructure.dependencies.adapters import (create_engine, + create_session_maker, + new_session, + new_unit_of_work) +from api.infrastructure.dependencies.configs import (app_settings, + get_db_settings, + get_jwt_settings) +from api.infrastructure.dependencies.protocols import (get_date_time_provider, + get_jwt_token_processor, + get_password_hasher, + get_user_login) from api.infrastructure.dependencies.repositories import ( - get_company_repository, - get_user_repository, -) + get_company_repository, get_user_repository) from api.infrastructure.dependencies.usecases import ( - provide_create_user, - provide_get_companies_by_email, -) + provide_create_company, provide_create_user, + provide_get_companies_by_email) from api.infrastructure.persistence.db_setings import DBSettings from api.infrastructure.settings import Settings @@ -62,3 +56,4 @@ def init_dependencies(app: FastAPI) -> None: app.dependency_overrides[CreateUser] = provide_create_user app.dependency_overrides[GetCompaniesByOwnerEmail] = provide_get_companies_by_email + app.dependency_overrides[CreateCompany] = provide_create_company diff --git a/api/app_entrypoint/main.py b/api/app_entrypoint/main.py index bad1e6b..96745d2 100644 --- a/api/app_entrypoint/main.py +++ b/api/app_entrypoint/main.py @@ -8,11 +8,9 @@ from api.app_entrypoint.dependencies import init_dependencies from api.app_entrypoint.error_handlers import init_exc_handlers from api.infrastructure.auth.jwt_settings import JwtSettings from api.infrastructure.dependencies.adapters import create_engine -from api.infrastructure.dependencies.configs import ( - app_settings, - get_db_settings, - get_jwt_settings, -) +from api.infrastructure.dependencies.configs import (app_settings, + get_db_settings, + get_jwt_settings) from api.infrastructure.persistence.db_setings import DBSettings from api.infrastructure.persistence.models import Base from api.infrastructure.settings import Settings diff --git a/api/application/contracts/auth/auth_request.py b/api/application/contracts/auth/auth_request.py index e1b9592..a85b345 100644 --- a/api/application/contracts/auth/auth_request.py +++ b/api/application/contracts/auth/auth_request.py @@ -4,6 +4,7 @@ from dataclasses import dataclass @dataclass(frozen=True) class UserCreateRequest: name: str + last_name: str email: str password: str diff --git a/api/application/contracts/company/company_request.py b/api/application/contracts/company/company_request.py index 7f37c0e..9a39f9d 100644 --- a/api/application/contracts/company/company_request.py +++ b/api/application/contracts/company/company_request.py @@ -1,4 +1,12 @@ from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class CreateNewCompany: + name: str + email: str + address: str @dataclass(frozen=True) diff --git a/api/application/usecase/auth/create_user.py b/api/application/usecase/auth/create_user.py index 0f4f82e..c150eff 100644 --- a/api/application/usecase/auth/create_user.py +++ b/api/application/usecase/auth/create_user.py @@ -1,6 +1,9 @@ +from sqlalchemy.exc import IntegrityError + from api.application.abstractions import UnitOfWork from api.application.contracts.auth.auth_request import UserCreateRequest from api.application.protocols.password_hasher import PasswordHasher +from api.domain.user.error import UserAlreadyExistsError from api.domain.user.model import User from api.domain.user.repository import UserRepository @@ -19,8 +22,18 @@ class CreateUser: async def execute(self, request: UserCreateRequest) -> None: user = User.create( name=request.name, + last_name=request.last_name, email=request.email, hashed_password=self.hasher.hash_password(request.password), ) - await self.user_repository.create_user(user=user) - await self.uow.commit() + + try: + await self.user_repository.create_user(user=user) + await self.uow.commit() + + except IntegrityError as e: + msg = e.args[0].split("\n")[-1] + msg = msg.split(":")[1].strip() + + if "email" in msg: + raise UserAlreadyExistsError(message=msg) diff --git a/api/application/usecase/company/create_company.py b/api/application/usecase/company/create_company.py new file mode 100644 index 0000000..189fc06 --- /dev/null +++ b/api/application/usecase/company/create_company.py @@ -0,0 +1,12 @@ +from api.application.contracts.company.company_request import CreateNewCompany +from api.application.contracts.company.company_response import CompanyBaseResponse +from api.domain.company.repository import CompanyRepository + + +class CreateCompany: + def __init__(self, company_repository: CompanyRepository) -> None: + self.company_repository = company_repository + + async def execute(self, request: CreateNewCompany) -> CompanyBaseResponse: + # companies = await self.company_repository. + return CompanyBaseResponse(name=request.name, email=request.email) diff --git a/api/domain/company/model.py b/api/domain/company/model.py index 32577c0..1f4332b 100644 --- a/api/domain/company/model.py +++ b/api/domain/company/model.py @@ -4,6 +4,7 @@ from uuid import UUID, uuid4 from api.domain import DomainValidationError from api.domain.entity import DomainEntity +from api.domain.user.model import UserId from api.domain.value_obj import DomainValueObject @@ -15,7 +16,9 @@ class CompanyEmail(DomainValueObject): pattern = r"^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$" if not re.match(pattern, self.value): - raise DomainValidationError("Invalid email format. Email must be in the format 'example@example.com'.") + raise DomainValidationError( + "Invalid email format. Email must be in the format 'example@example.com'." + ) @dataclass(frozen=True) @@ -26,7 +29,9 @@ class CompanyName(DomainValueObject): if len(self.value) < 1: raise DomainValidationError("First name must be at least 1 character long.") if len(self.value) > 100: - raise DomainValidationError("First name must be at most 100 characters long.") + raise DomainValidationError( + "First name must be at most 100 characters long." + ) if not self.value.isalpha(): raise DomainValidationError("First name must only contain letters.") @@ -36,15 +41,32 @@ class CompanyId(DomainValueObject): value: UUID +@dataclass(frozen=True) +class CompanyAddress(DomainValueObject): + value: str + + def __post_init__(self) -> None: + if len(self.value) < 1: + raise DomainValidationError("Address must be at least 1 character long.") + if len(self.value) > 100: + raise DomainValidationError("Address must be at most 100 characters long.") + if not self.value.isalpha(): + raise DomainValidationError("Address must only contain letters.") + + @dataclass class Company(DomainEntity[CompanyId]): name: CompanyName email: CompanyEmail + address: CompanyAddress + owner_id: UserId @staticmethod - def create(name: str, email: str) -> "Company": + def create(name: str, email: str, address: str, owner_id: str) -> "Company": return Company( id=CompanyId(uuid4()), name=CompanyName(name), email=CompanyEmail(email), + address=CompanyAddress(address), + owner_id=UserId(UUID(owner_id)), ) diff --git a/api/domain/user/model.py b/api/domain/user/model.py index 7d78b48..bb4e899 100644 --- a/api/domain/user/model.py +++ b/api/domain/user/model.py @@ -15,7 +15,9 @@ class UserEmail(DomainValueObject): pattern = r"^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$" if not re.match(pattern, self.value): - raise DomainValidationError("Invalid email format. Email must be in the format 'example@example.com'.") + raise DomainValidationError( + "Invalid email format. Email must be in the format 'example@example.com'." + ) @dataclass(frozen=True) @@ -26,7 +28,9 @@ class UserFirstName(DomainValueObject): if len(self.value) < 1: raise DomainValidationError("First name must be at least 1 character long.") if len(self.value) > 100: - raise DomainValidationError("First name must be at most 100 characters long.") + raise DomainValidationError( + "First name must be at most 100 characters long." + ) if not self.value.isalpha(): raise DomainValidationError("First name must only contain letters.") @@ -39,7 +43,9 @@ class UserLastName(DomainValueObject): if len(self.value) < 1: raise DomainValidationError("Last name must be at least 1 character long.") if len(self.value) > 100: - raise DomainValidationError("Last name must be at most 100 characters long.") + raise DomainValidationError( + "Last name must be at most 100 characters long." + ) if not self.value.isalpha(): raise DomainValidationError("Last name must only contain letters.") @@ -52,14 +58,16 @@ class UserId(DomainValueObject): @dataclass class User(DomainEntity[UserId]): name: UserFirstName + last_name: UserLastName email: UserEmail hashed_password: str @staticmethod - def create(name: str, email: str, hashed_password: str) -> "User": + def create(name: str, last_name: str, email: str, hashed_password: str) -> "User": return User( id=UserId(uuid4()), name=UserFirstName(name), + last_name=UserLastName(last_name), email=UserEmail(email), hashed_password=hashed_password, ) diff --git a/api/infrastructure/dependencies/usecases.py b/api/infrastructure/dependencies/usecases.py index 80b0170..4ec45b1 100644 --- a/api/infrastructure/dependencies/usecases.py +++ b/api/infrastructure/dependencies/usecases.py @@ -5,7 +5,9 @@ from fastapi import Depends from api.application.abstractions.uow import UnitOfWork from api.application.protocols.password_hasher import PasswordHasher from api.application.usecase.auth.create_user import CreateUser -from api.application.usecase.company.get_users_company import GetCompaniesByOwnerEmail +from api.application.usecase.company.create_company import CreateCompany +from api.application.usecase.company.get_users_company import \ + GetCompaniesByOwnerEmail from api.domain.company.repository import CompanyRepository from api.domain.user.repository import UserRepository from api.infrastructure.dependencies.stub import Stub @@ -16,10 +18,18 @@ def provide_create_user( uow: Annotated[UnitOfWork, Depends(Stub(UnitOfWork))], password_hasher: Annotated[PasswordHasher, Depends(Stub(PasswordHasher))], ) -> CreateUser: - return CreateUser(uow=uow, user_repository=user_repository, password_hasher=password_hasher) + return CreateUser( + uow=uow, user_repository=user_repository, password_hasher=password_hasher + ) def provide_get_companies_by_email( company_repository: Annotated[CompanyRepository, Depends(Stub(CompanyRepository))], ) -> GetCompaniesByOwnerEmail: return GetCompaniesByOwnerEmail(company_repository=company_repository) + + +def provide_create_company( + company_repository: Annotated[CompanyRepository, Depends(Stub(CompanyRepository))] +) -> CreateCompany: + return CreateCompany(company_repository=company_repository) diff --git a/api/infrastructure/persistence/error.py b/api/infrastructure/persistence/error.py new file mode 100644 index 0000000..f4606ab --- /dev/null +++ b/api/infrastructure/persistence/error.py @@ -0,0 +1,4 @@ +class TransactionContextManagerError(Exception): + def __init__(self, message: str, *args: object) -> None: + self.message = message + super().__init__(*args) diff --git a/api/infrastructure/persistence/models/company.py b/api/infrastructure/persistence/models/company.py index 0890847..05fe150 100644 --- a/api/infrastructure/persistence/models/company.py +++ b/api/infrastructure/persistence/models/company.py @@ -1,13 +1,13 @@ import uuid -from sqlalchemy import UUID +from sqlalchemy import UUID, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from api.infrastructure.persistence.models.base import Base class CompanyModel(Base): - __tablename__ = "companies" + __tablename__ = "company" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), @@ -15,3 +15,45 @@ class CompanyModel(Base): ) name: Mapped[str] email: Mapped[str] = mapped_column(unique=True) + address: Mapped[str] + owner_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + ) + + +class DepartmentModel(Base): + __tablename__ = "department" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + ) + name: Mapped[str] + address: Mapped[str] + company_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("company.id", ondelete="CASCADE") + ) + + +class CompanyDepartmentModel(Base): + __tablename__ = "company_department_m2m" + company_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("company.id", ondelete="CASCADE"), + primary_key=True, + ) + department_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("department.id", ondelete="CASCADE"), + primary_key=True, + ) + + +class DepartmentUserModel(Base): + __tablename__ = "department_user_m2m" + department_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("department.id", ondelete="CASCADE"), + primary_key=True, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id"), + primary_key=True, + ) diff --git a/api/infrastructure/persistence/models/user.py b/api/infrastructure/persistence/models/user.py index aad0522..f9f1055 100644 --- a/api/infrastructure/persistence/models/user.py +++ b/api/infrastructure/persistence/models/user.py @@ -14,5 +14,6 @@ class UserModel(Base): primary_key=True, ) name: Mapped[str] + last_name: Mapped[str] email: Mapped[str] = mapped_column(unique=True) hashed_password: Mapped[str] diff --git a/api/infrastructure/persistence/repositories/company_repository.py b/api/infrastructure/persistence/repositories/company_repository.py index 71e99ef..45213eb 100644 --- a/api/infrastructure/persistence/repositories/company_repository.py +++ b/api/infrastructure/persistence/repositories/company_repository.py @@ -1,30 +1,33 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from api.domain.company.model import Company, CompanyEmail, CompanyId, CompanyName +from api.domain.company.model import (Company, CompanyAddress, CompanyEmail, + CompanyId, CompanyName) from api.domain.company.repository import CompanyRepository +from api.domain.user.model import UserId class SqlAlchemyCompanyRepository(CompanyRepository): def __init__(self, session: AsyncSession) -> None: self.session = session - # async def create_user(self, user: User) -> None: - # stmt = text( - # """INSERT INTO users (id, name, email, hashed_password) - # VALUES(:id, :name, :email, :hashed_password) - # """ - # ) - # await self.session.execute( - # stmt, - # { - # "id": str(user.id.value), - # "name": user.name.value, - # "email": user.email.value, - # "hashed_password": user.hashed_password, - # }, - # ) - # + async def create_company(self, company: Company) -> None: + stmt = text( + """INSERT INTO company (id, name, email, address, owner_id) + VALUES(:id, :name, :email, :address, :owner_id) + """ + ) + await self.session.execute( + stmt, + { + "id": str(company.id.value), + "name": company.name.value, + "email": company.email.value, + "address": company.address.value, + "owner_id": str(company.owner_id.value), + }, + ) + async def get_companies_by_owner_email(self, filter: dict) -> list[Company]: stmt = text("""SELECT * FROM companies WHERE email = :val""") result = await self.session.execute(stmt, {"val": filter["email"]}) @@ -36,6 +39,8 @@ class SqlAlchemyCompanyRepository(CompanyRepository): id=CompanyId(c.id), name=CompanyName(c.name), email=CompanyEmail(c.email), + address=CompanyAddress(c.address), + owner_id=UserId(c.owner_id), ) for c in result ] diff --git a/api/infrastructure/persistence/repositories/user_repository.py b/api/infrastructure/persistence/repositories/user_repository.py index b9db15a..2d13d66 100644 --- a/api/infrastructure/persistence/repositories/user_repository.py +++ b/api/infrastructure/persistence/repositories/user_repository.py @@ -2,7 +2,8 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from api.domain.user import User, UserRepository -from api.domain.user.model import UserEmail, UserFirstName, UserId +from api.domain.user.model import (UserEmail, UserFirstName, UserId, + UserLastName) class SqlAlchemyUserRepository(UserRepository): @@ -11,8 +12,8 @@ class SqlAlchemyUserRepository(UserRepository): async def create_user(self, user: User) -> None: stmt = text( - """INSERT INTO users (id, name, email, hashed_password) - VALUES(:id, :name, :email, :hashed_password) + """INSERT INTO users (id, name, last_name, email, hashed_password) + VALUES(:id, :name, :last_name, :email, :hashed_password) """ ) await self.session.execute( @@ -20,6 +21,7 @@ class SqlAlchemyUserRepository(UserRepository): { "id": str(user.id.value), "name": user.name.value, + "last_name": user.last_name.value, "email": user.email.value, "hashed_password": user.hashed_password, }, @@ -37,6 +39,7 @@ class SqlAlchemyUserRepository(UserRepository): return User( id=UserId(result.id), name=UserFirstName(result.name), + last_name=UserLastName(result.last_name), email=UserEmail(result.email), hashed_password=result.hashed_password, ) diff --git a/api/infrastructure/persistence/transaction.py b/api/infrastructure/persistence/transaction.py new file mode 100644 index 0000000..4064266 --- /dev/null +++ b/api/infrastructure/persistence/transaction.py @@ -0,0 +1,29 @@ +from types import TracebackType + +from sqlalchemy.ext.asyncio import AsyncSession + +from api.infrastructure.persistence.error import TransactionContextManagerError + + +class SqlalchemyTransactionContextManager: + def __init__(self, session: AsyncSession): + self._session = session + + async def __aenter__(self): + return self + + async def __aexit__( + self, + exc_type: type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> None: + if exc_type: + await self.rollback() + raise TransactionContextManagerError(message="Transaction Error") + + async def commit(self): + await self._session.commit() + + async def rollback(self): + await self._session.rollback() diff --git a/api/presentation/routers/company.py b/api/presentation/routers/company.py index ed5498a..8cf2b03 100644 --- a/api/presentation/routers/company.py +++ b/api/presentation/routers/company.py @@ -2,31 +2,42 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request -from api.application.contracts.company.company_request import CompanyByOwnerEmail +from api.application.contracts.company.company_request import ( + CompanyByOwnerEmail, + CreateNewCompany, +) from api.application.contracts.company.company_response import CompanyBaseResponse from api.application.protocols.jwt import JwtTokenProcessor +from api.application.usecase.company.create_company import CreateCompany from api.application.usecase.company.get_users_company import GetCompaniesByOwnerEmail from api.domain.user.error import UserValidationError from api.infrastructure.dependencies.stub import Stub from api.presentation.auth.fasapi_auth import auth_required -company_router = APIRouter(prefix="/company", tags=["Company"]) +company_router = APIRouter( + prefix="/me", + tags=["Company"], + dependencies=[Depends(auth_required)], +) @company_router.get( - "/", - response_model=None, - dependencies=[Depends(auth_required)], + "/companies", + response_model=list[CompanyBaseResponse], ) -async def get_companies( +async def get_my_companies( request: Request, token_processor: Annotated[JwtTokenProcessor, Depends(Stub(JwtTokenProcessor))], - usecase: Annotated[GetCompaniesByOwnerEmail, Depends(Stub(GetCompaniesByOwnerEmail))], + usecase: Annotated[ + GetCompaniesByOwnerEmail, Depends(Stub(GetCompaniesByOwnerEmail)) + ], ) -> list[CompanyBaseResponse]: token_data = token_processor.validate_token(request.scope["auth"]) if not token_data: raise UserValidationError("Login required") - companies = await usecase.execute(request=CompanyByOwnerEmail(email=token_data[1].value)) + companies = await usecase.execute( + request=CompanyByOwnerEmail(email=token_data[1].value) + ) return [ CompanyBaseResponse( name=c.name, @@ -34,3 +45,18 @@ async def get_companies( ) for c in companies ] + + +@company_router.post("/companies/create", response_model=CompanyBaseResponse) +async def create_new_company( + request: Request, + request_data: CreateNewCompany, + token_processor: Annotated[JwtTokenProcessor, Depends(Stub(JwtTokenProcessor))], + usecase: Annotated[CreateCompany, Depends(Stub(CreateCompany))], +) -> CompanyBaseResponse: + token_data = token_processor.validate_token(request.scope["auth"]) + if not token_data: + raise UserValidationError("Login required") + + company = await usecase.execute(request=request_data) + return company diff --git a/config/api_config.yml b/config/api_config.yml index a5116a7..37c39ea 100644 --- a/config/api_config.yml +++ b/config/api_config.yml @@ -1,5 +1,5 @@ db: - host: "db" + host: "localhost" port: 5432 database: "serviceman_db" user: "demo_user"