feat: update le docs logic

This commit is contained in:
2026-06-09 20:14:27 +03:00
parent 14df805209
commit cc75f61e76
23 changed files with 420 additions and 262 deletions

View File

@@ -4,14 +4,14 @@ version = "0.1.0"
description = "Admin service for legal entities and B2B operations" description = "Admin service for legal entities and B2B operations"
requires-python = "==3.12.*" requires-python = "==3.12.*"
dependencies = [ dependencies = [
"acryl-datahub>=1.5.0.19", "acryl-datahub==1.5.0.19",
"acryl-sqlglot>=25.25.2.dev9", "acryl-sqlglot==25.25.2.dev9",
"aiobotocore>=2.21.0", "aiobotocore==2.21.0",
"apscheduler==3.11.2", "apscheduler==3.11.2",
"asyncpg==0.31.0", "asyncpg==0.31.0",
"bcrypt==5.0.0", "bcrypt==5.0.0",
"bip-utils>=2.9.3", "bip-utils==2.9.3",
"cryptography>=44.0.0", "cryptography==44.0.0",
"dotenv==0.9.9", "dotenv==0.9.9",
"fastapi==0.128.7", "fastapi==0.128.7",
"granian==2.6.1", "granian==2.6.1",
@@ -19,7 +19,7 @@ dependencies = [
"orjson==3.11.7", "orjson==3.11.7",
"pydantic-settings==2.12.0", "pydantic-settings==2.12.0",
"python-jose==3.5.0", "python-jose==3.5.0",
"python-multipart>=0.0.20", "python-multipart==0.0.20",
"python-ulid==3.1.0", "python-ulid==3.1.0",
"redis==7.2.0", "redis==7.2.0",
"sqlalchemy==2.0.46", "sqlalchemy==2.0.46",

View File

@@ -6,7 +6,6 @@ from src.application.abstractions.repositories import (
IAdminSessionRepository, IAdminSessionRepository,
IAdminUserRepository, IAdminUserRepository,
ILegalEntityRepository, ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository, IOrganizationWalletRepository,
IPurchaseRequestRepository, IPurchaseRequestRepository,
IUserRepository, IUserRepository,
@@ -36,8 +35,5 @@ class IUnitOfWork(Protocol):
@property @property
def organization_wallet_repository(self) -> IOrganizationWalletRepository: ... def organization_wallet_repository(self) -> IOrganizationWalletRepository: ...
@property
def organization_document_repository(self) -> IOrganizationDocumentRepository: ...
@property @property
def purchase_request_repository(self) -> IPurchaseRequestRepository: ... def purchase_request_repository(self) -> IPurchaseRequestRepository: ...

View File

@@ -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_admin_session_repository import IAdminSessionRepository
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository 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_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 from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository

View File

@@ -48,6 +48,14 @@ class ILegalEntityRepository(ABC):
async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None: async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None:
raise NotImplementedError 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 @abstractmethod
async def count_all(self) -> int: async def count_all(self) -> int:
raise NotImplementedError raise NotImplementedError

View File

@@ -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

View File

@@ -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.get_admin_me import GetAdminMeCommand
from src.application.commands.create_organization import CreateOrganizationCommand from src.application.commands.create_organization import CreateOrganizationCommand
from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand 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 ( from src.application.commands.organization_commands import (
ListOrganizationsCommand, ListOrganizationsCommand,
GetOrganizationCommand, GetOrganizationCommand,
@@ -33,7 +33,7 @@ __all__ = [
'GetAdminMeCommand', 'GetAdminMeCommand',
'CreateOrganizationCommand', 'CreateOrganizationCommand',
'CreateOrganizationWalletsCommand', 'CreateOrganizationWalletsCommand',
'UploadOrganizationDocumentCommand', 'PutOrganizationDocumentCommand',
'ListOrganizationsCommand', 'ListOrganizationsCommand',
'GetOrganizationCommand', 'GetOrganizationCommand',
'UpdateOrganizationCommand', 'UpdateOrganizationCommand',

View File

@@ -2,9 +2,11 @@ from __future__ import annotations
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger 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.exceptions import ApplicationException
from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
class ListOrganizationDocumentsCommand: class ListOrganizationDocumentsCommand:
@@ -13,9 +15,20 @@ class ListOrganizationDocumentsCommand:
self._logger = logger self._logger = logger
@transactional @transactional
async def __call__(self, organization_id: str) -> list[OrganizationDocumentEntity]: async def __call__(self, organization_id: str) -> list[OrganizationDocumentSlot]:
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) org = 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) 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: class GetOrganizationDocumentCommand:
@@ -24,8 +37,18 @@ class GetOrganizationDocumentCommand:
self._logger = logger self._logger = logger
@transactional @transactional
async def __call__(self, organization_id: str, document_id: str) -> OrganizationDocumentEntity: async def __call__(self, organization_id: str, document_type: str) -> OrganizationDocumentSlot:
doc = await self._unit_of_work.organization_document_repository.get_by_id(document_id) if document_type not in ORGANIZATION_DOCUMENT_TYPES:
if doc.organization_id != organization_id: raise ApplicationException(status_code=400, message='Invalid document type')
raise ApplicationException(status_code=404, message='Document not found')
return doc 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),
)

View File

@@ -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),
)

View File

@@ -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

View File

@@ -24,6 +24,7 @@ class LegalEntityEntity:
kyc_verified: bool kyc_verified: bool
kyc_verified_at: datetime | None kyc_verified_at: datetime | None
encrypted_mnemonic: str | None encrypted_mnemonic: str | None
document_s3_keys: dict[str, str | None]
created_by: str | None created_by: str | None
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
@@ -46,16 +47,13 @@ class CreateOrganizationWalletsResult:
@dataclass @dataclass
class OrganizationDocumentEntity: class OrganizationDocumentSlot:
id: str
organization_id: str organization_id: str
document_type: str document_type: str
file_name: str s3_key: str | None
s3_key: str file_name: str | None = None
content_type: str content_type: str | None = None
file_size_bytes: int file_size_bytes: int | None = None
uploaded_by: str | None
created_at: datetime | None = None
@dataclass @dataclass

View File

@@ -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()
}

View File

@@ -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.admin_session import AdminSessionModel
from src.infrastructure.database.models.legal_entity import LegalEntityModel from src.infrastructure.database.models.legal_entity import LegalEntityModel
from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel 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 from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
__all__ = [ __all__ = [
@@ -14,6 +13,5 @@ __all__ = [
'AdminSessionModel', 'AdminSessionModel',
'LegalEntityModel', 'LegalEntityModel',
'OrganizationWalletModel', 'OrganizationWalletModel',
'OrganizationDocumentModel',
'PurchaseRequestModel', 'PurchaseRequestModel',
] ]

View File

@@ -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: 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) kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, 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( created_by: Mapped[str | None] = mapped_column(
String(26), String(26),
ForeignKey('admin_users.id'), ForeignKey('admin_users.id'),

View File

@@ -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(),
)

View File

@@ -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.user_repository import UserRepository
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository 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_wallet_repository import OrganizationWalletRepository
from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
__all__ = [ __all__ = [
@@ -12,6 +11,5 @@ __all__ = [
'UserRepository', 'UserRepository',
'LegalEntityRepository', 'LegalEntityRepository',
'OrganizationWalletRepository', 'OrganizationWalletRepository',
'OrganizationDocumentRepository',
'PurchaseRequestRepository', 'PurchaseRequestRepository',
] ]

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import status
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError 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.contracts import ILogger
from src.application.domain.entities.organization import LegalEntityEntity from src.application.domain.entities.organization import LegalEntityEntity
from src.application.domain.exceptions import ApplicationException 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 from src.infrastructure.database.models import LegalEntityModel
@@ -19,6 +19,12 @@ class LegalEntityRepository(ILegalEntityRepository):
self._session = session self._session = session
self._logger = logger 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: def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity:
return LegalEntityEntity( return LegalEntityEntity(
id=m.id, id=m.id,
@@ -37,6 +43,7 @@ class LegalEntityRepository(ILegalEntityRepository):
kyc_verified=m.kyc_verified, kyc_verified=m.kyc_verified,
kyc_verified_at=m.kyc_verified_at, kyc_verified_at=m.kyc_verified_at,
encrypted_mnemonic=m.encrypted_mnemonic, encrypted_mnemonic=m.encrypted_mnemonic,
document_s3_keys=self._document_s3_keys_from_model(m),
created_by=m.created_by, created_by=m.created_by,
created_at=m.created_at, created_at=m.created_at,
updated_at=m.updated_at, updated_at=m.updated_at,
@@ -121,3 +128,19 @@ class LegalEntityRepository(ILegalEntityRepository):
.values(encrypted_mnemonic=encrypted_mnemonic) .values(encrypted_mnemonic=encrypted_mnemonic)
) )
await self._session.flush() 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)

View File

@@ -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()]

View File

@@ -5,7 +5,6 @@ from src.application.abstractions.repositories import (
IAdminSessionRepository, IAdminSessionRepository,
IAdminUserRepository, IAdminUserRepository,
ILegalEntityRepository, ILegalEntityRepository,
IOrganizationDocumentRepository,
IOrganizationWalletRepository, IOrganizationWalletRepository,
IPurchaseRequestRepository, IPurchaseRequestRepository,
IUserRepository, IUserRepository,
@@ -16,7 +15,6 @@ from src.infrastructure.database.repositories import (
AdminSessionRepository, AdminSessionRepository,
AdminUserRepository, AdminUserRepository,
LegalEntityRepository, LegalEntityRepository,
OrganizationDocumentRepository,
OrganizationWalletRepository, OrganizationWalletRepository,
PurchaseRequestRepository, PurchaseRequestRepository,
UserRepository, UserRepository,
@@ -32,7 +30,6 @@ class UnitOfWork(IUnitOfWork):
self._admin_session_repository: IAdminSessionRepository | None = None self._admin_session_repository: IAdminSessionRepository | None = None
self._legal_entity_repository: ILegalEntityRepository | None = None self._legal_entity_repository: ILegalEntityRepository | None = None
self._organization_wallet_repository: IOrganizationWalletRepository | None = None self._organization_wallet_repository: IOrganizationWalletRepository | None = None
self._organization_document_repository: IOrganizationDocumentRepository | None = None
self._purchase_request_repository: IPurchaseRequestRepository | None = None self._purchase_request_repository: IPurchaseRequestRepository | None = None
self._logger: ILogger = logger self._logger: ILogger = logger
@@ -88,14 +85,6 @@ class UnitOfWork(IUnitOfWork):
) )
return self._organization_wallet_repository 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 @property
def purchase_request_repository(self) -> IPurchaseRequestRepository: def purchase_request_repository(self) -> IPurchaseRequestRepository:
if self._purchase_request_repository is None: if self._purchase_request_repository is None:

View File

@@ -23,9 +23,13 @@ class S3DocumentsService:
self._key_prefix = key_prefix.strip('/') self._key_prefix = key_prefix.strip('/')
self._presigned_ttl_seconds = presigned_ttl_seconds 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('\\', '_') 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]: def _client_kwargs(self) -> dict[str, object]:
kw: dict[str, object] = {'region_name': self._region} kw: dict[str, object] = {'region_name': self._region}

View File

@@ -22,7 +22,7 @@ from src.application.commands import (
SetPurchaseRequestQuoteCommand, SetPurchaseRequestQuoteCommand,
UpdateOrganizationCommand, UpdateOrganizationCommand,
UpdatePurchaseRequestStatusCommand, UpdatePurchaseRequestStatusCommand,
UploadOrganizationDocumentCommand, PutOrganizationDocumentCommand,
) )
from src.application.contracts import IHashService, IJwtService, ILogger from src.application.contracts import IHashService, IJwtService, ILogger
from src.infrastructure.config import settings from src.infrastructure.config import settings
@@ -115,11 +115,11 @@ def get_get_organization_secret_keys_command(
return GetOrganizationSecretKeysCommand(uow, logger) return GetOrganizationSecretKeysCommand(uow, logger)
def get_upload_organization_document_command( def get_put_organization_document_command(
uow: IUnitOfWork = Depends(get_unit_of_work), uow: IUnitOfWork = Depends(get_unit_of_work),
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
) -> UploadOrganizationDocumentCommand: ) -> PutOrganizationDocumentCommand:
return UploadOrganizationDocumentCommand(uow, get_s3_documents_service(), logger) return PutOrganizationDocumentCommand(uow, get_s3_documents_service(), logger)
def get_list_organizations_command( def get_list_organizations_command(

View File

@@ -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 ( from src.application.commands import (
GetOrganizationDocumentCommand, GetOrganizationDocumentCommand,
ListOrganizationDocumentsCommand, ListOrganizationDocumentsCommand,
UploadOrganizationDocumentCommand, PutOrganizationDocumentCommand,
) )
from src.application.domain.dto import AdminAuthContext from src.application.domain.dto import AdminAuthContext
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
from src.presentation.dependencies.commands import ( from src.presentation.dependencies.commands import (
get_get_organization_document_command, get_get_organization_document_command,
get_list_organization_documents_command, get_list_organization_documents_command,
get_put_organization_document_command,
get_s3_documents_service, get_s3_documents_service,
get_upload_organization_document_command,
) )
from src.infrastructure.storage.s3_documents_service import S3DocumentsService from src.infrastructure.storage.s3_documents_service import S3DocumentsService
from src.presentation.schemas.mappers import document_to_response 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']) 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]) @documents_router.get('', response_model=list[DocumentResponse])
async def list_documents( async def list_documents(
organization_id: str, organization_id: str,
@@ -30,41 +36,226 @@ async def list_documents(
docs = await command(organization_id) docs = await command(organization_id)
result: list[DocumentResponse] = [] result: list[DocumentResponse] = []
for doc in docs: 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)) result.append(document_to_response(doc, download_url=url))
return result return result
@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) async def _put_document(
async def upload_document( *,
organization_id: str, organization_id: str,
document_type: str = Form(...), document_type: str,
file: UploadFile = File(...), file: UploadFile,
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), command: PutOrganizationDocumentCommand,
command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command), s3: S3DocumentsService,
s3: S3DocumentsService = Depends(get_s3_documents_service), ) -> DocumentResponse:
):
body = await file.read() body = await file.read()
saved = await command( saved = await command(
organization_id=organization_id, organization_id=organization_id,
admin_user_id=auth.admin_user_id,
document_type=document_type, document_type=document_type,
file_name=file.filename or 'document', file_name=file.filename or 'document',
content_type=file.content_type or 'application/octet-stream', content_type=file.content_type or 'application/octet-stream',
body=body, 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) return document_to_response(saved, download_url=url)
@documents_router.get('/{document_id}', response_model=DocumentResponse) @documents_router.put('/charter', response_model=DocumentResponse)
async def get_document( 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, organization_id: str,
document_id: str,
auth: AdminAuthContext = Depends(require_admin_access), auth: AdminAuthContext = Depends(require_admin_access),
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
s3: S3DocumentsService = Depends(get_s3_documents_service), s3: S3DocumentsService = Depends(get_s3_documents_service),
): ):
doc = await command(organization_id, document_id) return await _get_document(organization_id, 'charter', command, s3)
url = await s3.generate_presigned_download_url(key=doc.s3_key)
@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) return document_to_response(doc, download_url=url)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from src.application.domain.entities.organization import ( from src.application.domain.entities.organization import (
LegalEntityEntity, LegalEntityEntity,
OrganizationDocumentEntity, OrganizationDocumentSlot,
OrganizationWalletEntity, OrganizationWalletEntity,
PurchaseRequestEntity, PurchaseRequestEntity,
) )
@@ -78,19 +78,17 @@ def secret_key_to_response(
def document_to_response( def document_to_response(
entity: OrganizationDocumentEntity, entity: OrganizationDocumentSlot,
*, *,
download_url: str | None = None, download_url: str | None = None,
) -> DocumentResponse: ) -> DocumentResponse:
return DocumentResponse( return DocumentResponse(
id=entity.id,
organization_id=entity.organization_id, organization_id=entity.organization_id,
document_type=entity.document_type, document_type=entity.document_type,
s3_key=entity.s3_key,
file_name=entity.file_name, file_name=entity.file_name,
content_type=entity.content_type, content_type=entity.content_type,
file_size_bytes=entity.file_size_bytes, 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, download_url=download_url,
) )

View File

@@ -87,14 +87,12 @@ class SecretKeyResponse(BaseModel):
class DocumentResponse(BaseModel): class DocumentResponse(BaseModel):
id: str
organization_id: str organization_id: str
document_type: str document_type: str
file_name: str s3_key: str | None
content_type: str file_name: str | None
file_size_bytes: int content_type: str | None = None
uploaded_by: str | None file_size_bytes: int | None = None
created_at: str | None
download_url: str | None = None download_url: str | None = None