креативы

This commit is contained in:
Artem Tsyrulnikov
2026-01-03 16:11:05 +03:00
parent 841a8b5aaa
commit c04faf8132
15 changed files with 597 additions and 59 deletions

View File

@@ -29,8 +29,18 @@ class ValidateTokenRequest(BaseModel):
@internal_router.get('/auth/jwt')
async def get_jwt_by_telegram_id(telegram_id: int) -> dto.ValidateLoginTokenOutput:
return await deps.get_usecase().get_jwt_by_telegram_id(telegram_id)
async def get_jwt_by_telegram_id(
telegram_id: int,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> dto.ValidateLoginTokenOutput:
return await deps.get_usecase().get_jwt_by_telegram_id(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
)
@internal_router.get('/projects/{workspace_id}/{project_id}')

View File

@@ -21,6 +21,8 @@ __all__ = (
'PurchaseChannelStatus',
'Creative',
'replace_invite_link_with_tag',
'validate_media_size',
'MAX_CREATIVE_MEDIA_BYTES',
'Placement',
'Post',
'Subscription',
@@ -53,18 +55,20 @@ __all__ = (
'CreativeInUse',
'CreativeInviteLinkNotFound',
'CreativeMultipleInviteLinks',
'CreativeMediaTooLarge',
'PlacementNotFound',
'UserByUsernameNotFound',
)
from .channel import Channel
from .creative import Creative, CreativeStatus, replace_invite_link_with_tag
from .creative import Creative, CreativeStatus, MAX_CREATIVE_MEDIA_BYTES, replace_invite_link_with_tag, validate_media_size
from .error import (
ChannelAlreadyExists,
ChannelNoAdminRights,
ChannelNotFound,
CreativeInUse,
CreativeInviteLinkNotFound,
CreativeMediaTooLarge,
CreativeMultipleInviteLinks,
CreativeNotFound,
LoginTokenAlreadyUsed,

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from tortoise import fields
from .base import TimestampedModel
from .error import CreativeInviteLinkNotFound, CreativeMultipleInviteLinks
from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMultipleInviteLinks
if TYPE_CHECKING:
from .project import Project
@@ -21,6 +21,10 @@ class CreativeStatus(str, enum.Enum):
class Creative(TimestampedModel):
name = fields.CharField(max_length=255)
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)
buttons = fields.JSONField(default=list)
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
@@ -38,6 +42,9 @@ class Creative(TimestampedModel):
table = 'creative'
MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024
_INVITE_LINK_HTML = re.compile(r'<a\s+href=["\']https?://t\.me/\+[a-zA-Z0-9_-]+["\']>([^<]*)</a>', re.IGNORECASE)
_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', re.IGNORECASE)
@@ -57,3 +64,10 @@ def replace_invite_link_with_tag(text: str) -> str:
if html_count == 1:
return _INVITE_LINK_HTML.sub(r'<a class="tg-link">\1</a>', text)
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
def validate_media_size(media_data: bytes | None) -> None:
if media_data is None:
return
if len(media_data) > MAX_CREATIVE_MEDIA_BYTES:
raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES)

View File

@@ -79,6 +79,10 @@ def CreativeMultipleInviteLinks() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain only one invite link')
def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)')
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException(
status.HTTP_400_BAD_REQUEST,

View File

@@ -22,6 +22,7 @@ __all__ = (
'GetPurchasesInput',
'GetPurchasesOutput',
'GetPurchaseInput',
'CreativeButton',
'CreativeOutput',
'GetCreativesInput',
'GetCreativesOutput',
@@ -95,6 +96,7 @@ from .channel import (
)
from .creative import (
CreateCreativeInput,
CreativeButton,
CreativeOutput,
DeleteCreativeInput,
GetCreativeInput,

View File

@@ -6,10 +6,18 @@ import pydantic
from src import domain
class CreativeButton(pydantic.BaseModel):
text: str
url: str
class CreativeOutput(pydantic.BaseModel):
id: uuid.UUID
name: str
text: str
media_type: str | None
media_file_id: str | None
buttons: list[CreativeButton]
project_id: uuid.UUID
project_channel_title: str
created_at: datetime.datetime
@@ -37,11 +45,19 @@ class GetCreativeInput(pydantic.BaseModel):
class CreateCreativeInput(pydantic.BaseModel):
name: str
text: str
media_type: str | None = None
media_file_id: str | None = None
media_data: bytes | None = None
buttons: list[CreativeButton] = pydantic.Field(default_factory=list)
class UpdateCreativeInput(pydantic.BaseModel):
name: str | None = None
text: str | None = None
media_type: str | None = None
media_file_id: str | None = None
media_data: bytes | None = None
buttons: list[CreativeButton] | None = None
status: domain.CreativeStatus | None = None

View File

@@ -6,11 +6,35 @@ if typing.TYPE_CHECKING:
from .. import Usecase
async def get_jwt_by_telegram_id(self: 'Usecase', telegram_id: int) -> dto.ValidateLoginTokenOutput:
async def get_jwt_by_telegram_id(
self: 'Usecase',
telegram_id: int,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> dto.ValidateLoginTokenOutput:
user = await self.database.get_user(telegram_id=telegram_id)
if not user:
raise domain.UserNotFound()
user = domain.User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
)
await self.database.create_user(user)
else:
updated = False
if username is not None and user.username != username:
user.username = username
updated = True
if first_name is not None and user.first_name != first_name:
user.first_name = first_name
updated = True
if last_name is not None and user.last_name != last_name:
user.last_name = last_name
updated = True
if updated:
await self.database.update_user(user)
access_token = self.jwt_encoder.encode_access_token(
user_id=user.id,

View File

@@ -22,11 +22,17 @@ async def create_creative(
log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
raise domain.ProjectNotFound(project_id)
domain.validate_media_size(input.media_data)
creative_text = domain.replace_invite_link_with_tag(input.text)
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,
buttons=[button.model_dump() for button in input.buttons],
status=domain.CreativeStatus.ACTIVE,
project_id=project.id,
workspace_id=workspace_id,
@@ -37,6 +43,9 @@ async def create_creative(
id=creative.id,
name=creative.name,
text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=project.id,
project_channel_title=project.channel.title,
created_at=creative.created_at,

View File

@@ -26,6 +26,9 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
id=creative.id,
name=creative.name,
text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=creative.project_id,
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,

View File

@@ -32,6 +32,9 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
id=creative.id,
name=creative.name,
text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=creative.project_id,
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,

View File

@@ -30,6 +30,15 @@ async def update_creative(
creative.name = input.name
if input.text:
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
if input.media_type is not None:
creative.media_type = input.media_type
if input.media_file_id is not None:
creative.media_file_id = input.media_file_id
if input.buttons is not None:
creative.buttons = [button.model_dump() for button in input.buttons]
if input.status:
creative.status = input.status
@@ -41,6 +50,9 @@ async def update_creative(
id=creative.id,
name=creative.name,
text=creative.text,
media_type=creative.media_type,
media_file_id=creative.media_file_id,
buttons=creative.buttons,
project_id=creative.project_id,
project_channel_title=creative.project.channel.title,
created_at=creative.created_at,