s3
This commit is contained in:
88
src/adapter/s3.py
Normal file
88
src/adapter/s3.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import aioboto3
|
||||
from pydantic import BaseModel
|
||||
from types_aiobotocore_s3.client import S3Client
|
||||
|
||||
from src.usecase import S3Storage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class S3Config(BaseModel):
|
||||
ENDPOINT_URL: str
|
||||
ACCESS_KEY_ID: str
|
||||
SECRET_ACCESS_KEY: str
|
||||
BUCKET_NAME: str
|
||||
REGION: str = 'us-east-1'
|
||||
|
||||
|
||||
class S3(S3Storage):
|
||||
def __init__(self, config: S3Config) -> None:
|
||||
self.config = config
|
||||
self.session = aioboto3.Session()
|
||||
self._client: S3Client | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
# Создаем клиента для проверки/создания bucket
|
||||
async with self.session.client(
|
||||
's3',
|
||||
endpoint_url=self.config.ENDPOINT_URL,
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
) as client:
|
||||
# Проверяем существование bucket
|
||||
try:
|
||||
await client.head_bucket(Bucket=self.config.BUCKET_NAME)
|
||||
log.info(f'S3 bucket {self.config.BUCKET_NAME} exists')
|
||||
except Exception:
|
||||
# Bucket не существует, создаем
|
||||
log.info(f'Creating S3 bucket {self.config.BUCKET_NAME}')
|
||||
await client.create_bucket(Bucket=self.config.BUCKET_NAME)
|
||||
log.info(f'S3 bucket {self.config.BUCKET_NAME} created')
|
||||
|
||||
async def close(self) -> None:
|
||||
# aioboto3 использует context manager, не нужно закрывать
|
||||
pass
|
||||
|
||||
async def upload(self, key: str, data: bytes, content_type: str) -> None:
|
||||
async with self.session.client(
|
||||
's3',
|
||||
endpoint_url=self.config.ENDPOINT_URL,
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
) as client:
|
||||
await client.put_object(
|
||||
Bucket=self.config.BUCKET_NAME,
|
||||
Key=key,
|
||||
Body=data,
|
||||
ContentType=content_type,
|
||||
)
|
||||
log.info(f'Uploaded file to S3: {key}')
|
||||
|
||||
async def get(self, key: str) -> bytes:
|
||||
async with self.session.client(
|
||||
's3',
|
||||
endpoint_url=self.config.ENDPOINT_URL,
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
) as client:
|
||||
response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||
data = await response['Body'].read()
|
||||
log.info(f'Downloaded file from S3: {key}')
|
||||
return data
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
async with self.session.client(
|
||||
's3',
|
||||
endpoint_url=self.config.ENDPOINT_URL,
|
||||
aws_access_key_id=self.config.ACCESS_KEY_ID,
|
||||
aws_secret_access_key=self.config.SECRET_ACCESS_KEY,
|
||||
region_name=self.config.REGION,
|
||||
) as client:
|
||||
await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key)
|
||||
log.info(f'Deleted file from S3: {key}')
|
||||
@@ -6,6 +6,7 @@ from shared.datebase_base import DatabaseConfig
|
||||
from shared.jwt_base import JWTConfig
|
||||
from shared.logger import LoggerConfig
|
||||
from shared.telegram_base import TelegramConfig
|
||||
from src.adapter.s3 import S3Config
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
@@ -25,6 +26,7 @@ class Settings(BaseSettings):
|
||||
telegram: TelegramConfig
|
||||
jwt: JWTConfig
|
||||
parser: ParserConfig
|
||||
s3: S3Config
|
||||
|
||||
|
||||
settings: Settings = load_settings(Settings)
|
||||
|
||||
@@ -23,7 +23,7 @@ class Creative(TimestampedModel):
|
||||
text = fields.TextField()
|
||||
media_type = fields.CharField(max_length=32, null=True)
|
||||
media_file_id = fields.CharField(max_length=512, null=True)
|
||||
media_data = fields.BinaryField(null=True)
|
||||
media_s3_key = fields.CharField(max_length=512, null=True)
|
||||
buttons = fields.JSONField(default=list)
|
||||
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from src import deps
|
||||
from src.adapter.jwt import JWT
|
||||
from src.adapter.parser import ParserClient
|
||||
from src.adapter.postgres import Postgres
|
||||
from src.adapter.s3 import S3
|
||||
from src.adapter.telegram_bot import TelegramBot
|
||||
from src.config import settings
|
||||
from src.controller.http_v1 import api_router
|
||||
@@ -22,11 +23,13 @@ from src.usecase import Usecase
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
|
||||
await postgres.connect()
|
||||
await s3.connect()
|
||||
await fetch_placement_post_worker.start()
|
||||
|
||||
yield
|
||||
|
||||
await fetch_placement_post_worker.stop()
|
||||
await s3.close()
|
||||
await postgres.close()
|
||||
|
||||
|
||||
@@ -37,12 +40,14 @@ postgres = Postgres(settings.db)
|
||||
telegram = TelegramBot(settings.telegram)
|
||||
jwt_encoder = JWT(settings.jwt)
|
||||
parser_client = ParserClient(base_url=settings.parser.URL)
|
||||
s3 = S3(settings.s3)
|
||||
|
||||
usecase = Usecase(
|
||||
database=postgres,
|
||||
telegram_bot=telegram,
|
||||
jwt_encoder=jwt_encoder,
|
||||
parser=parser_client,
|
||||
s3=s3,
|
||||
)
|
||||
deps.set_usecase(usecase)
|
||||
|
||||
|
||||
@@ -290,12 +290,25 @@ class Parser(typing.Protocol):
|
||||
async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ...
|
||||
|
||||
|
||||
class S3Storage(typing.Protocol):
|
||||
async def connect(self) -> None: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
async def upload(self, key: str, data: bytes, content_type: str) -> None: ...
|
||||
|
||||
async def get(self, key: str) -> bytes: ...
|
||||
|
||||
async def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Usecase:
|
||||
database: Database
|
||||
telegram_bot: TelegramBotWriter
|
||||
jwt_encoder: JWTEncoder
|
||||
parser: Parser
|
||||
s3: S3Storage
|
||||
|
||||
async def ensure_workspace_access(self, workspace_id: UUID, user_id: UUID) -> domain.Workspace:
|
||||
workspace = await self.database.get_workspace_for_user(workspace_id, user_id)
|
||||
|
||||
@@ -26,12 +26,20 @@ async def create_creative(
|
||||
|
||||
creative_text = domain.replace_invite_link_with_tag(input.text)
|
||||
|
||||
media_s3_key: str | None = None
|
||||
if input.media_data:
|
||||
file_id = uuid.uuid4()
|
||||
media_s3_key = f'creatives/{workspace_id}/{file_id}'
|
||||
content_type = _get_content_type(input.media_type)
|
||||
await self.s3.upload(media_s3_key, input.media_data, content_type)
|
||||
log.info('Uploaded creative media to S3: %s', media_s3_key)
|
||||
|
||||
creative = domain.Creative(
|
||||
name=input.name,
|
||||
text=creative_text,
|
||||
media_type=input.media_type,
|
||||
media_file_id=input.media_file_id,
|
||||
media_data=input.media_data,
|
||||
media_s3_key=media_s3_key,
|
||||
buttons=[button.model_dump() for button in input.buttons],
|
||||
status=domain.CreativeStatus.ACTIVE,
|
||||
project_id=project.id,
|
||||
@@ -52,3 +60,16 @@ async def create_creative(
|
||||
status=creative.status,
|
||||
placements_count=0,
|
||||
)
|
||||
|
||||
|
||||
def _get_content_type(media_type: str | None) -> str:
|
||||
"""Map Telegram media type to MIME content type."""
|
||||
if not media_type:
|
||||
return 'application/octet-stream'
|
||||
|
||||
mapping = {
|
||||
'photo': 'image/jpeg',
|
||||
'video': 'video/mp4',
|
||||
'animation': 'image/gif',
|
||||
}
|
||||
return mapping.get(media_type, 'application/octet-stream')
|
||||
|
||||
@@ -26,4 +26,12 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No
|
||||
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
|
||||
raise domain.CreativeInUse(input.creative_id)
|
||||
|
||||
media_key = creative.media_s3_key
|
||||
if media_key:
|
||||
try:
|
||||
await self.s3.delete(media_key)
|
||||
log.info('Deleted creative media from S3: %s', media_key)
|
||||
except Exception as e:
|
||||
log.warning('Failed to delete creative media from S3: %s', e)
|
||||
|
||||
await self.database.delete_creative(input.creative_id)
|
||||
|
||||
@@ -32,7 +32,27 @@ async def update_creative(
|
||||
creative.text = domain.replace_invite_link_with_tag(input.text)
|
||||
if input.media_data is not None:
|
||||
domain.validate_media_size(input.media_data)
|
||||
creative.media_data = input.media_data
|
||||
|
||||
# Удаляем старое медиа из S3
|
||||
old_media_key = creative.media_s3_key
|
||||
if old_media_key:
|
||||
try:
|
||||
await self.s3.delete(old_media_key)
|
||||
log.info('Deleted old creative media from S3: %s', old_media_key)
|
||||
except Exception as e:
|
||||
log.warning('Failed to delete old creative media from S3: %s', e)
|
||||
|
||||
# Загружаем новое медиа в S3
|
||||
new_media_data = input.media_data
|
||||
if new_media_data:
|
||||
file_id = uuid.uuid4()
|
||||
new_media_key = f'creatives/{workspace_id}/{file_id}'
|
||||
content_type = _get_content_type(input.media_type)
|
||||
await self.s3.upload(new_media_key, new_media_data, content_type)
|
||||
log.info('Uploaded new creative media to S3: %s', new_media_key)
|
||||
creative.media_s3_key = new_media_key
|
||||
else:
|
||||
creative.media_s3_key = None
|
||||
if input.media_type is not None:
|
||||
creative.media_type = input.media_type
|
||||
if input.media_file_id is not None:
|
||||
@@ -59,3 +79,16 @@ async def update_creative(
|
||||
status=creative.status,
|
||||
placements_count=placements_count,
|
||||
)
|
||||
|
||||
|
||||
def _get_content_type(media_type: str | None) -> str:
|
||||
"""Map Telegram media type to MIME content type."""
|
||||
if not media_type:
|
||||
return 'application/octet-stream'
|
||||
|
||||
mapping = {
|
||||
'photo': 'image/jpeg',
|
||||
'video': 'video/mp4',
|
||||
'animation': 'image/gif',
|
||||
}
|
||||
return mapping.get(media_type, 'application/octet-stream')
|
||||
|
||||
Reference in New Issue
Block a user