diff --git a/pyproject.toml b/pyproject.toml index 280fe73..74ece12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ version = "0.1.0" description = "Admin service for legal entities and B2B operations" requires-python = "==3.12.*" dependencies = [ - "acryl-datahub>=1.5.0.19", - "acryl-sqlglot>=25.25.2.dev9", - "aiobotocore>=2.21.0", + "acryl-datahub==1.5.0.19", + "acryl-sqlglot==25.25.2.dev9", + "aiobotocore==2.21.0", "apscheduler==3.11.2", "asyncpg==0.31.0", "bcrypt==5.0.0", - "bip-utils>=2.9.3", - "cryptography>=44.0.0", + "bip-utils==2.9.3", + "cryptography==44.0.0", "dotenv==0.9.9", "fastapi==0.128.7", "granian==2.6.1", @@ -19,7 +19,7 @@ dependencies = [ "orjson==3.11.7", "pydantic-settings==2.12.0", "python-jose==3.5.0", - "python-multipart>=0.0.20", + "python-multipart==0.0.20", "python-ulid==3.1.0", "redis==7.2.0", "sqlalchemy==2.0.46", diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py index 0860fcf..334b4a4 100644 --- a/src/application/abstractions/i_unit_of_work.py +++ b/src/application/abstractions/i_unit_of_work.py @@ -6,7 +6,6 @@ from src.application.abstractions.repositories import ( IAdminSessionRepository, IAdminUserRepository, ILegalEntityRepository, - IOrganizationDocumentRepository, IOrganizationWalletRepository, IPurchaseRequestRepository, IUserRepository, @@ -36,8 +35,5 @@ class IUnitOfWork(Protocol): @property def organization_wallet_repository(self) -> IOrganizationWalletRepository: ... - @property - def organization_document_repository(self) -> IOrganizationDocumentRepository: ... - @property def purchase_request_repository(self) -> IPurchaseRequestRepository: ... diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py index 743d4ea..7f4d3da 100644 --- a/src/application/abstractions/repositories/__init__.py +++ b/src/application/abstractions/repositories/__init__.py @@ -3,5 +3,4 @@ from src.application.abstractions.repositories.i_admin_user_repository import IA from src.application.abstractions.repositories.i_admin_session_repository import IAdminSessionRepository from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository -from src.application.abstractions.repositories.i_organization_document_repository import IOrganizationDocumentRepository from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository diff --git a/src/application/abstractions/repositories/i_legal_entity_repository.py b/src/application/abstractions/repositories/i_legal_entity_repository.py index 0d193e8..f989e35 100644 --- a/src/application/abstractions/repositories/i_legal_entity_repository.py +++ b/src/application/abstractions/repositories/i_legal_entity_repository.py @@ -48,6 +48,14 @@ class ILegalEntityRepository(ABC): async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None: raise NotImplementedError + @abstractmethod + async def get_document_s3_key(self, organization_id: str, document_type: str) -> str | None: + raise NotImplementedError + + @abstractmethod + async def set_document_s3_key(self, organization_id: str, document_type: str, s3_key: str) -> LegalEntityEntity: + raise NotImplementedError + @abstractmethod async def count_all(self) -> int: raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_organization_document_repository.py b/src/application/abstractions/repositories/i_organization_document_repository.py deleted file mode 100644 index 5003e24..0000000 --- a/src/application/abstractions/repositories/i_organization_document_repository.py +++ /dev/null @@ -1,17 +0,0 @@ -from abc import ABC, abstractmethod - -from src.application.domain.entities.organization import OrganizationDocumentEntity - - -class IOrganizationDocumentRepository(ABC): - @abstractmethod - async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity: - raise NotImplementedError - - @abstractmethod - async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity: - raise NotImplementedError - - @abstractmethod - async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]: - raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 306fb05..3d6ee42 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -4,7 +4,7 @@ from src.application.commands.admin_jwt_refresh import AdminJwtRefreshCommand from src.application.commands.get_admin_me import GetAdminMeCommand from src.application.commands.create_organization import CreateOrganizationCommand from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand -from src.application.commands.upload_organization_document import UploadOrganizationDocumentCommand +from src.application.commands.put_organization_document import PutOrganizationDocumentCommand from src.application.commands.organization_commands import ( ListOrganizationsCommand, GetOrganizationCommand, @@ -33,7 +33,7 @@ __all__ = [ 'GetAdminMeCommand', 'CreateOrganizationCommand', 'CreateOrganizationWalletsCommand', - 'UploadOrganizationDocumentCommand', + 'PutOrganizationDocumentCommand', 'ListOrganizationsCommand', 'GetOrganizationCommand', 'UpdateOrganizationCommand', diff --git a/src/application/commands/organization_document_commands.py b/src/application/commands/organization_document_commands.py index c251616..32f56a4 100644 --- a/src/application/commands/organization_document_commands.py +++ b/src/application/commands/organization_document_commands.py @@ -2,9 +2,11 @@ from __future__ import annotations from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger -from src.application.domain.entities.organization import OrganizationDocumentEntity +from src.application.domain.entities.organization import OrganizationDocumentSlot from src.application.domain.exceptions import ApplicationException +from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES from src.infrastructure.database.decorators import transactional +from src.infrastructure.storage.s3_documents_service import S3DocumentsService class ListOrganizationDocumentsCommand: @@ -13,9 +15,20 @@ class ListOrganizationDocumentsCommand: self._logger = logger @transactional - async def __call__(self, organization_id: str) -> list[OrganizationDocumentEntity]: - await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) - return await self._unit_of_work.organization_document_repository.list_by_organization(organization_id) + async def __call__(self, organization_id: str) -> list[OrganizationDocumentSlot]: + org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + slots: list[OrganizationDocumentSlot] = [] + for document_type in ORGANIZATION_DOCUMENT_TYPES: + s3_key = org.document_s3_keys.get(document_type) + slots.append( + OrganizationDocumentSlot( + organization_id=organization_id, + document_type=document_type, + s3_key=s3_key, + file_name=S3DocumentsService.file_name_from_key(s3_key) if s3_key else None, + ) + ) + return slots class GetOrganizationDocumentCommand: @@ -24,8 +37,18 @@ class GetOrganizationDocumentCommand: self._logger = logger @transactional - async def __call__(self, organization_id: str, document_id: str) -> OrganizationDocumentEntity: - doc = await self._unit_of_work.organization_document_repository.get_by_id(document_id) - if doc.organization_id != organization_id: - raise ApplicationException(status_code=404, message='Document not found') - return doc + async def __call__(self, organization_id: str, document_type: str) -> OrganizationDocumentSlot: + if document_type not in ORGANIZATION_DOCUMENT_TYPES: + raise ApplicationException(status_code=400, message='Invalid document type') + + org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + s3_key = org.document_s3_keys.get(document_type) + if not s3_key: + raise ApplicationException(status_code=404, message='Document not uploaded') + + return OrganizationDocumentSlot( + organization_id=organization_id, + document_type=document_type, + s3_key=s3_key, + file_name=S3DocumentsService.file_name_from_key(s3_key), + ) diff --git a/src/application/commands/put_organization_document.py b/src/application/commands/put_organization_document.py new file mode 100644 index 0000000..9ffd9b1 --- /dev/null +++ b/src/application/commands/put_organization_document.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.organization import OrganizationDocumentSlot +from src.application.domain.exceptions import ApplicationException +from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES +from src.infrastructure.database.decorators import transactional +from src.infrastructure.storage.s3_documents_service import S3DocumentsService + + +class PutOrganizationDocumentCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + s3_service: S3DocumentsService, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._s3 = s3_service + self._logger = logger + + @transactional + async def __call__( + self, + *, + organization_id: str, + document_type: str, + file_name: str, + content_type: str, + body: bytes, + ) -> OrganizationDocumentSlot: + if document_type not in ORGANIZATION_DOCUMENT_TYPES: + raise ApplicationException(status_code=400, message='Invalid document type') + + if not body: + raise ApplicationException(status_code=400, message='File is empty') + + await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + old_s3_key = await self._unit_of_work.legal_entity_repository.get_document_s3_key( + organization_id, + document_type, + ) + + new_s3_key = self._s3.build_object_key(organization_id, document_type, file_name) + await self._s3.upload_bytes(key=new_s3_key, body=body, content_type=content_type) + + try: + await self._unit_of_work.legal_entity_repository.set_document_s3_key( + organization_id, + document_type, + new_s3_key, + ) + await self._unit_of_work.commit() + except Exception: + await self._unit_of_work.rollback() + await self._s3.delete_object(key=new_s3_key) + raise + + if old_s3_key and old_s3_key != new_s3_key: + await self._s3.delete_object(key=old_s3_key) + + self._logger.info( + f'Document uploaded org={organization_id} type={document_type} key={new_s3_key}' + ) + return OrganizationDocumentSlot( + organization_id=organization_id, + document_type=document_type, + s3_key=new_s3_key, + file_name=self._s3.file_name_from_key(new_s3_key), + content_type=content_type, + file_size_bytes=len(body), + ) diff --git a/src/application/commands/upload_organization_document.py b/src/application/commands/upload_organization_document.py deleted file mode 100644 index 694b31a..0000000 --- a/src/application/commands/upload_organization_document.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from ulid import ULID - -from src.application.abstractions import IUnitOfWork -from src.application.contracts import ILogger -from src.application.domain.entities.organization import OrganizationDocumentEntity -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.decorators import transactional -from src.infrastructure.storage.s3_documents_service import S3DocumentsService - - -ALLOWED_DOCUMENT_TYPES = frozenset({ - 'charter', 'inn_certificate', 'ogrn_certificate', - 'bank_details', 'kyc_representative', 'power_of_attorney', 'other', -}) - - -class UploadOrganizationDocumentCommand: - def __init__( - self, - unit_of_work: IUnitOfWork, - s3_service: S3DocumentsService, - logger: ILogger, - ): - self._unit_of_work = unit_of_work - self._s3 = s3_service - self._logger = logger - - @transactional - async def __call__( - self, - *, - organization_id: str, - admin_user_id: str, - document_type: str, - file_name: str, - content_type: str, - body: bytes, - ) -> OrganizationDocumentEntity: - if document_type not in ALLOWED_DOCUMENT_TYPES: - raise ApplicationException(status_code=400, message='Invalid document type') - - await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) - - document_id = str(ULID()) - s3_key = self._s3.build_object_key(organization_id, document_id, file_name) - await self._s3.upload_bytes(key=s3_key, body=body, content_type=content_type) - - entity = OrganizationDocumentEntity( - id=document_id, - organization_id=organization_id, - document_type=document_type, - file_name=file_name, - s3_key=s3_key, - content_type=content_type, - file_size_bytes=len(body), - uploaded_by=admin_user_id, - ) - saved = await self._unit_of_work.organization_document_repository.create(entity) - self._logger.info(f'Document uploaded id={saved.id} org={organization_id}') - return saved diff --git a/src/application/domain/entities/organization.py b/src/application/domain/entities/organization.py index 7d12e72..a3962bd 100644 --- a/src/application/domain/entities/organization.py +++ b/src/application/domain/entities/organization.py @@ -24,6 +24,7 @@ class LegalEntityEntity: kyc_verified: bool kyc_verified_at: datetime | None encrypted_mnemonic: str | None + document_s3_keys: dict[str, str | None] created_by: str | None created_at: datetime | None = None updated_at: datetime | None = None @@ -46,16 +47,13 @@ class CreateOrganizationWalletsResult: @dataclass -class OrganizationDocumentEntity: - id: str +class OrganizationDocumentSlot: organization_id: str document_type: str - file_name: str - s3_key: str - content_type: str - file_size_bytes: int - uploaded_by: str | None - created_at: datetime | None = None + s3_key: str | None + file_name: str | None = None + content_type: str | None = None + file_size_bytes: int | None = None @dataclass diff --git a/src/application/domain/organization_documents.py b/src/application/domain/organization_documents.py new file mode 100644 index 0000000..5d300e4 --- /dev/null +++ b/src/application/domain/organization_documents.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +ORGANIZATION_DOCUMENT_TYPES: tuple[str, ...] = ( + 'charter', + 'inn_certificate', + 'ogrn_certificate', + 'bank_details', + 'kyc_representative', + 'power_of_attorney', + 'other', +) + +DOCUMENT_TYPE_TO_COLUMN: dict[str, str] = { + 'charter': 'charter_s3_key', + 'inn_certificate': 'inn_certificate_s3_key', + 'ogrn_certificate': 'ogrn_certificate_s3_key', + 'bank_details': 'bank_details_document_s3_key', + 'kyc_representative': 'kyc_representative_s3_key', + 'power_of_attorney': 'power_of_attorney_s3_key', + 'other': 'other_document_s3_key', +} + +DOCUMENT_TYPE_URL_SLUGS: dict[str, str] = { + 'charter': 'charter', + 'inn_certificate': 'inn-certificate', + 'ogrn_certificate': 'ogrn-certificate', + 'bank_details': 'bank-details', + 'kyc_representative': 'kyc-representative', + 'power_of_attorney': 'power-of-attorney', + 'other': 'other', +} + +URL_SLUG_TO_DOCUMENT_TYPE: dict[str, str] = { + slug: document_type for document_type, slug in DOCUMENT_TYPE_URL_SLUGS.items() +} diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py index 7f95d1c..727a605 100644 --- a/src/infrastructure/database/models/__init__.py +++ b/src/infrastructure/database/models/__init__.py @@ -4,7 +4,6 @@ from src.infrastructure.database.models.admin_user import AdminUserModel from src.infrastructure.database.models.admin_session import AdminSessionModel from src.infrastructure.database.models.legal_entity import LegalEntityModel from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel -from src.infrastructure.database.models.organization_document import OrganizationDocumentModel from src.infrastructure.database.models.purchase_request import PurchaseRequestModel __all__ = [ @@ -14,6 +13,5 @@ __all__ = [ 'AdminSessionModel', 'LegalEntityModel', 'OrganizationWalletModel', - 'OrganizationDocumentModel', 'PurchaseRequestModel', ] diff --git a/src/infrastructure/database/models/legal_entity.py b/src/infrastructure/database/models/legal_entity.py index 2486991..c7d78a6 100644 --- a/src/infrastructure/database/models/legal_entity.py +++ b/src/infrastructure/database/models/legal_entity.py @@ -35,6 +35,13 @@ class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True) kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True) + charter_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + inn_certificate_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + ogrn_certificate_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + bank_details_document_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + kyc_representative_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + power_of_attorney_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) + other_document_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True) created_by: Mapped[str | None] = mapped_column( String(26), ForeignKey('admin_users.id'), diff --git a/src/infrastructure/database/models/organization_document.py b/src/infrastructure/database/models/organization_document.py deleted file mode 100644 index e7dbbb0..0000000 --- a/src/infrastructure/database/models/organization_document.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func -from sqlalchemy.orm import Mapped, mapped_column - -from src.infrastructure.database.models.base import Base -from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin - - -class OrganizationDocumentModel(Base, UlidPrimaryKeyMixin): - __tablename__ = 'organization_documents' - - organization_id: Mapped[str] = mapped_column( - String(26), - ForeignKey('legal_entities.id', ondelete='RESTRICT'), - nullable=False, - index=True, - ) - document_type: Mapped[str] = mapped_column(String(64), nullable=False) - file_name: Mapped[str] = mapped_column(String(512), nullable=False) - s3_key: Mapped[str] = mapped_column(String(1024), nullable=False) - content_type: Mapped[str] = mapped_column(String(128), nullable=False) - file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) - uploaded_by: Mapped[str | None] = mapped_column( - String(26), - ForeignKey('admin_users.id'), - nullable=True, - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - server_default=func.now(), - ) diff --git a/src/infrastructure/database/repositories/__init__.py b/src/infrastructure/database/repositories/__init__.py index a843878..edef283 100644 --- a/src/infrastructure/database/repositories/__init__.py +++ b/src/infrastructure/database/repositories/__init__.py @@ -3,7 +3,6 @@ from src.infrastructure.database.repositories.admin_session_repository import Ad from src.infrastructure.database.repositories.user_repository import UserRepository from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository -from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository __all__ = [ @@ -12,6 +11,5 @@ __all__ = [ 'UserRepository', 'LegalEntityRepository', 'OrganizationWalletRepository', - 'OrganizationDocumentRepository', 'PurchaseRequestRepository', ] diff --git a/src/infrastructure/database/repositories/legal_entity_repository.py b/src/infrastructure/database/repositories/legal_entity_repository.py index 567d019..d715d7c 100644 --- a/src/infrastructure/database/repositories/legal_entity_repository.py +++ b/src/infrastructure/database/repositories/legal_entity_repository.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Any -from fastapi import status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -11,6 +10,7 @@ from src.application.abstractions.repositories import ILegalEntityRepository from src.application.contracts import ILogger from src.application.domain.entities.organization import LegalEntityEntity from src.application.domain.exceptions import ApplicationException +from src.application.domain.organization_documents import DOCUMENT_TYPE_TO_COLUMN, ORGANIZATION_DOCUMENT_TYPES from src.infrastructure.database.models import LegalEntityModel @@ -19,6 +19,12 @@ class LegalEntityRepository(ILegalEntityRepository): self._session = session self._logger = logger + def _document_s3_keys_from_model(self, m: LegalEntityModel) -> dict[str, str | None]: + return { + document_type: getattr(m, DOCUMENT_TYPE_TO_COLUMN[document_type]) + for document_type in ORGANIZATION_DOCUMENT_TYPES + } + def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity: return LegalEntityEntity( id=m.id, @@ -37,6 +43,7 @@ class LegalEntityRepository(ILegalEntityRepository): kyc_verified=m.kyc_verified, kyc_verified_at=m.kyc_verified_at, encrypted_mnemonic=m.encrypted_mnemonic, + document_s3_keys=self._document_s3_keys_from_model(m), created_by=m.created_by, created_at=m.created_at, updated_at=m.updated_at, @@ -121,3 +128,19 @@ class LegalEntityRepository(ILegalEntityRepository): .values(encrypted_mnemonic=encrypted_mnemonic) ) await self._session.flush() + + async def get_document_s3_key(self, organization_id: str, document_type: str) -> str | None: + org = await self.get_by_id(organization_id) + return org.document_s3_keys.get(document_type) + + async def set_document_s3_key(self, organization_id: str, document_type: str, s3_key: str) -> LegalEntityEntity: + column = DOCUMENT_TYPE_TO_COLUMN.get(document_type) + if column is None: + raise ApplicationException(status_code=400, message='Invalid document type') + await self._session.execute( + update(LegalEntityModel) + .where(LegalEntityModel.id == organization_id) + .values(**{column: s3_key}) + ) + await self._session.flush() + return await self.get_by_id(organization_id) diff --git a/src/infrastructure/database/repositories/organization_document_repository.py b/src/infrastructure/database/repositories/organization_document_repository.py deleted file mode 100644 index b4e39fa..0000000 --- a/src/infrastructure/database/repositories/organization_document_repository.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.exc import SQLAlchemyError - -from src.application.abstractions.repositories import IOrganizationDocumentRepository -from src.application.contracts import ILogger -from src.application.domain.entities.organization import OrganizationDocumentEntity -from src.application.domain.exceptions import ApplicationException -from src.infrastructure.database.models import OrganizationDocumentModel - - -class OrganizationDocumentRepository(IOrganizationDocumentRepository): - def __init__(self, session: AsyncSession, logger: ILogger): - self._session = session - self._logger = logger - - def _to_entity(self, m: OrganizationDocumentModel) -> OrganizationDocumentEntity: - return OrganizationDocumentEntity( - id=m.id, - organization_id=m.organization_id, - document_type=m.document_type, - file_name=m.file_name, - s3_key=m.s3_key, - content_type=m.content_type, - file_size_bytes=m.file_size_bytes, - uploaded_by=m.uploaded_by, - created_at=m.created_at, - ) - - async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity: - model = OrganizationDocumentModel( - id=document.id, - organization_id=document.organization_id, - document_type=document.document_type, - file_name=document.file_name, - s3_key=document.s3_key, - content_type=document.content_type, - file_size_bytes=document.file_size_bytes, - uploaded_by=document.uploaded_by, - ) - self._session.add(model) - try: - await self._session.flush() - return self._to_entity(model) - except SQLAlchemyError as exc: - self._logger.exception(str(exc)) - raise ApplicationException(status_code=500, message='Database error') - - async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity: - res = await self._session.execute( - select(OrganizationDocumentModel).where(OrganizationDocumentModel.id == document_id) - ) - m = res.scalar_one_or_none() - if m is None: - raise ApplicationException(status_code=404, message='Document not found') - return self._to_entity(m) - - async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]: - res = await self._session.execute( - select(OrganizationDocumentModel) - .where(OrganizationDocumentModel.organization_id == organization_id) - .order_by(OrganizationDocumentModel.created_at.desc()) - ) - return [self._to_entity(m) for m in res.scalars().all()] diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py index a4476f0..31cd1cc 100644 --- a/src/infrastructure/database/unit_of_work.py +++ b/src/infrastructure/database/unit_of_work.py @@ -5,7 +5,6 @@ from src.application.abstractions.repositories import ( IAdminSessionRepository, IAdminUserRepository, ILegalEntityRepository, - IOrganizationDocumentRepository, IOrganizationWalletRepository, IPurchaseRequestRepository, IUserRepository, @@ -16,7 +15,6 @@ from src.infrastructure.database.repositories import ( AdminSessionRepository, AdminUserRepository, LegalEntityRepository, - OrganizationDocumentRepository, OrganizationWalletRepository, PurchaseRequestRepository, UserRepository, @@ -32,7 +30,6 @@ class UnitOfWork(IUnitOfWork): self._admin_session_repository: IAdminSessionRepository | None = None self._legal_entity_repository: ILegalEntityRepository | None = None self._organization_wallet_repository: IOrganizationWalletRepository | None = None - self._organization_document_repository: IOrganizationDocumentRepository | None = None self._purchase_request_repository: IPurchaseRequestRepository | None = None self._logger: ILogger = logger @@ -88,14 +85,6 @@ class UnitOfWork(IUnitOfWork): ) return self._organization_wallet_repository - @property - def organization_document_repository(self) -> IOrganizationDocumentRepository: - if self._organization_document_repository is None: - self._organization_document_repository = OrganizationDocumentRepository( - session=self._session, logger=self._logger - ) - return self._organization_document_repository - @property def purchase_request_repository(self) -> IPurchaseRequestRepository: if self._purchase_request_repository is None: diff --git a/src/infrastructure/storage/s3_documents_service.py b/src/infrastructure/storage/s3_documents_service.py index bad3929..76f4683 100644 --- a/src/infrastructure/storage/s3_documents_service.py +++ b/src/infrastructure/storage/s3_documents_service.py @@ -23,9 +23,13 @@ class S3DocumentsService: self._key_prefix = key_prefix.strip('/') self._presigned_ttl_seconds = presigned_ttl_seconds - def build_object_key(self, organization_id: str, document_id: str, file_name: str) -> str: + @staticmethod + def file_name_from_key(key: str) -> str: + return key.rsplit('/', 1)[-1] + + def build_object_key(self, organization_id: str, document_type: str, file_name: str) -> str: safe_name = file_name.replace('/', '_').replace('\\', '_') - return f'{self._key_prefix}/{organization_id}/{document_id}/{safe_name}' + return f'{self._key_prefix}/{organization_id}/{document_type}/{safe_name}' def _client_kwargs(self) -> dict[str, object]: kw: dict[str, object] = {'region_name': self._region} diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index b0b0ff5..dbfab80 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -22,7 +22,7 @@ from src.application.commands import ( SetPurchaseRequestQuoteCommand, UpdateOrganizationCommand, UpdatePurchaseRequestStatusCommand, - UploadOrganizationDocumentCommand, + PutOrganizationDocumentCommand, ) from src.application.contracts import IHashService, IJwtService, ILogger from src.infrastructure.config import settings @@ -115,11 +115,11 @@ def get_get_organization_secret_keys_command( return GetOrganizationSecretKeysCommand(uow, logger) -def get_upload_organization_document_command( +def get_put_organization_document_command( uow: IUnitOfWork = Depends(get_unit_of_work), logger: ILogger = Depends(get_logger), -) -> UploadOrganizationDocumentCommand: - return UploadOrganizationDocumentCommand(uow, get_s3_documents_service(), logger) +) -> PutOrganizationDocumentCommand: + return PutOrganizationDocumentCommand(uow, get_s3_documents_service(), logger) def get_list_organizations_command( diff --git a/src/presentation/routing/documents.py b/src/presentation/routing/documents.py index 4a93c95..5b6481f 100644 --- a/src/presentation/routing/documents.py +++ b/src/presentation/routing/documents.py @@ -1,17 +1,17 @@ -from fastapi import APIRouter, Depends, File, Form, UploadFile, status +from fastapi import APIRouter, Depends, File, UploadFile from src.application.commands import ( GetOrganizationDocumentCommand, ListOrganizationDocumentsCommand, - UploadOrganizationDocumentCommand, + PutOrganizationDocumentCommand, ) from src.application.domain.dto import AdminAuthContext from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role from src.presentation.dependencies.commands import ( get_get_organization_document_command, get_list_organization_documents_command, + get_put_organization_document_command, get_s3_documents_service, - get_upload_organization_document_command, ) from src.infrastructure.storage.s3_documents_service import S3DocumentsService from src.presentation.schemas.mappers import document_to_response @@ -20,6 +20,12 @@ from src.presentation.schemas.organization import DocumentResponse documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents']) +async def _document_download_url(s3: S3DocumentsService, s3_key: str | None) -> str | None: + if not s3_key: + return None + return await s3.generate_presigned_download_url(key=s3_key) + + @documents_router.get('', response_model=list[DocumentResponse]) async def list_documents( organization_id: str, @@ -30,41 +36,226 @@ async def list_documents( docs = await command(organization_id) result: list[DocumentResponse] = [] for doc in docs: - url = await s3.generate_presigned_download_url(key=doc.s3_key) + url = await _document_download_url(s3, doc.s3_key) result.append(document_to_response(doc, download_url=url)) return result -@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) -async def upload_document( +async def _put_document( + *, organization_id: str, - document_type: str = Form(...), - file: UploadFile = File(...), - auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), - command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command), - s3: S3DocumentsService = Depends(get_s3_documents_service), -): + document_type: str, + file: UploadFile, + command: PutOrganizationDocumentCommand, + s3: S3DocumentsService, +) -> DocumentResponse: body = await file.read() saved = await command( organization_id=organization_id, - admin_user_id=auth.admin_user_id, document_type=document_type, file_name=file.filename or 'document', content_type=file.content_type or 'application/octet-stream', body=body, ) - url = await s3.generate_presigned_download_url(key=saved.s3_key) + url = await _document_download_url(s3, saved.s3_key) return document_to_response(saved, download_url=url) -@documents_router.get('/{document_id}', response_model=DocumentResponse) -async def get_document( +@documents_router.put('/charter', response_model=DocumentResponse) +async def put_charter_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='charter', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.put('/inn-certificate', response_model=DocumentResponse) +async def put_inn_certificate_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='inn_certificate', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.put('/ogrn-certificate', response_model=DocumentResponse) +async def put_ogrn_certificate_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='ogrn_certificate', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.put('/bank-details', response_model=DocumentResponse) +async def put_bank_details_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='bank_details', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.put('/kyc-representative', response_model=DocumentResponse) +async def put_kyc_representative_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='kyc_representative', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.put('/power-of-attorney', response_model=DocumentResponse) +async def put_power_of_attorney_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='power_of_attorney', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.put('/other', response_model=DocumentResponse) +async def put_other_document( + organization_id: str, + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _put_document( + organization_id=organization_id, + document_type='other', + file=file, + command=command, + s3=s3, + ) + + +@documents_router.get('/charter', response_model=DocumentResponse) +async def get_charter_document( organization_id: str, - document_id: str, auth: AdminAuthContext = Depends(require_admin_access), command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), s3: S3DocumentsService = Depends(get_s3_documents_service), ): - doc = await command(organization_id, document_id) - url = await s3.generate_presigned_download_url(key=doc.s3_key) + return await _get_document(organization_id, 'charter', command, s3) + + +@documents_router.get('/inn-certificate', response_model=DocumentResponse) +async def get_inn_certificate_document( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _get_document(organization_id, 'inn_certificate', command, s3) + + +@documents_router.get('/ogrn-certificate', response_model=DocumentResponse) +async def get_ogrn_certificate_document( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _get_document(organization_id, 'ogrn_certificate', command, s3) + + +@documents_router.get('/bank-details', response_model=DocumentResponse) +async def get_bank_details_document( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _get_document(organization_id, 'bank_details', command, s3) + + +@documents_router.get('/kyc-representative', response_model=DocumentResponse) +async def get_kyc_representative_document( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _get_document(organization_id, 'kyc_representative', command, s3) + + +@documents_router.get('/power-of-attorney', response_model=DocumentResponse) +async def get_power_of_attorney_document( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _get_document(organization_id, 'power_of_attorney', command, s3) + + +@documents_router.get('/other', response_model=DocumentResponse) +async def get_other_document( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + return await _get_document(organization_id, 'other', command, s3) + + +async def _get_document( + organization_id: str, + document_type: str, + command: GetOrganizationDocumentCommand, + s3: S3DocumentsService, +) -> DocumentResponse: + doc = await command(organization_id, document_type) + url = await _document_download_url(s3, doc.s3_key) return document_to_response(doc, download_url=url) diff --git a/src/presentation/schemas/mappers.py b/src/presentation/schemas/mappers.py index 0f7a83e..c240bfe 100644 --- a/src/presentation/schemas/mappers.py +++ b/src/presentation/schemas/mappers.py @@ -2,7 +2,7 @@ from __future__ import annotations from src.application.domain.entities.organization import ( LegalEntityEntity, - OrganizationDocumentEntity, + OrganizationDocumentSlot, OrganizationWalletEntity, PurchaseRequestEntity, ) @@ -78,19 +78,17 @@ def secret_key_to_response( def document_to_response( - entity: OrganizationDocumentEntity, + entity: OrganizationDocumentSlot, *, download_url: str | None = None, ) -> DocumentResponse: return DocumentResponse( - id=entity.id, organization_id=entity.organization_id, document_type=entity.document_type, + s3_key=entity.s3_key, file_name=entity.file_name, content_type=entity.content_type, file_size_bytes=entity.file_size_bytes, - uploaded_by=entity.uploaded_by, - created_at=entity.created_at.isoformat() if entity.created_at else None, download_url=download_url, ) diff --git a/src/presentation/schemas/organization.py b/src/presentation/schemas/organization.py index 26ab3e2..cdec1b0 100644 --- a/src/presentation/schemas/organization.py +++ b/src/presentation/schemas/organization.py @@ -87,14 +87,12 @@ class SecretKeyResponse(BaseModel): class DocumentResponse(BaseModel): - id: str organization_id: str document_type: str - file_name: str - content_type: str - file_size_bytes: int - uploaded_by: str | None - created_at: str | None + s3_key: str | None + file_name: str | None + content_type: str | None = None + file_size_bytes: int | None = None download_url: str | None = None