feat: creatives and external channels
This commit is contained in:
123
src/usecase/__init__.py
Normal file
123
src/usecase/__init__.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import datetime
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from src import domain
|
||||
|
||||
from .creative.archive_creative import archive_creative
|
||||
from .creative.create_creative import create_creative
|
||||
from .creative.delete_creative import delete_creative
|
||||
from .creative.get_creative import get_creative
|
||||
from .creative.get_creatives import get_creatives
|
||||
from .creative.update_creative import update_creative
|
||||
from .external_channel.create_external_channel import create_external_channel
|
||||
from .external_channel.delete_external_channel import delete_external_channel
|
||||
from .external_channel.get_external_channels import get_external_channels
|
||||
from .external_channel.import_external_channels_from_excel import import_external_channels_from_excel
|
||||
from .external_channel.update_external_channel_links import update_external_channel_links
|
||||
from .target_channel.connect_target_chan import connect_target_chan
|
||||
from .target_channel.disconnect_target_chan import disconnect_target_chan
|
||||
from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_chan_by_tg_id
|
||||
from .target_channel.get_user_target_chans import get_user_target_chans
|
||||
from .target_channel.update_target_chan_permissions import update_target_chan_permissions
|
||||
from .telegram_login import telegram_login
|
||||
from .validate_login_token import validate_login_token
|
||||
|
||||
|
||||
class Database(typing.Protocol):
|
||||
def transaction(self) -> typing.AsyncContextManager[None]: ...
|
||||
|
||||
async def get_user(self, *, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ...
|
||||
|
||||
async def create_user(self, user: domain.User) -> domain.User: ...
|
||||
|
||||
async def create_login_token(
|
||||
self, user_id: UUID, token: str, expires_at: datetime.datetime
|
||||
) -> domain.LoginToken: ...
|
||||
|
||||
async def get_login_token(self, token: str) -> domain.LoginToken | None: ...
|
||||
|
||||
async def mark_token_as_used(self, token: str) -> None: ...
|
||||
|
||||
async def get_target_channel(
|
||||
self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
|
||||
) -> domain.TargetChannel | None: ...
|
||||
|
||||
async def create_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ...
|
||||
|
||||
async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ...
|
||||
|
||||
async def get_user_target_channels(self, user_id: UUID) -> list[domain.TargetChannel]: ...
|
||||
|
||||
async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ...
|
||||
|
||||
async def get_external_channel(
|
||||
self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
|
||||
) -> domain.ExternalChannel | None: ...
|
||||
|
||||
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: ...
|
||||
|
||||
async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ...
|
||||
|
||||
async def add_external_channel_to_targets(
|
||||
self, external_channel_id: UUID, target_channel_ids: list[UUID]
|
||||
) -> None: ...
|
||||
|
||||
async def remove_external_channel_from_target(self, external_channel_id: UUID, target_channel_id: UUID) -> None: ...
|
||||
|
||||
async def delete_external_channel(self, channel_id: UUID) -> None: ...
|
||||
|
||||
async def get_creative(self, user_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
|
||||
|
||||
async def create_creative(self, creative: domain.Creative) -> domain.Creative: ...
|
||||
|
||||
async def update_creative(self, creative: domain.Creative) -> domain.Creative: ...
|
||||
|
||||
async def get_user_creatives(
|
||||
self,
|
||||
user_id: UUID,
|
||||
*,
|
||||
target_channel_id: UUID | None = None,
|
||||
include_archived: bool = False,
|
||||
) -> list[domain.Creative]: ...
|
||||
|
||||
async def delete_creative(self, creative_id: UUID) -> None: ...
|
||||
|
||||
|
||||
class TelegramWriter(typing.Protocol):
|
||||
async def send_message(self, text: str, chat_id: int) -> None: ...
|
||||
|
||||
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: ...
|
||||
|
||||
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, typing.Any]: ...
|
||||
|
||||
|
||||
class JWTEncoder(typing.Protocol):
|
||||
def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Usecase:
|
||||
database: Database
|
||||
telegram_writer: TelegramWriter
|
||||
jwt_encoder: JWTEncoder
|
||||
|
||||
validate_login_token = validate_login_token
|
||||
telegram_login = telegram_login
|
||||
connect_target_chan = connect_target_chan
|
||||
get_user_target_chans = get_user_target_chans
|
||||
disconnect_target_chan = disconnect_target_chan
|
||||
disconnect_target_chan_by_tg_id = disconnect_target_chan_by_tg_id
|
||||
update_target_chan_permissions = update_target_chan_permissions
|
||||
get_external_channels = get_external_channels
|
||||
create_external_channel = create_external_channel
|
||||
delete_external_channel = delete_external_channel
|
||||
update_external_channel_links = update_external_channel_links
|
||||
import_external_channels_from_excel = import_external_channels_from_excel
|
||||
get_creatives = get_creatives
|
||||
get_creative = get_creative
|
||||
create_creative = create_creative
|
||||
update_creative = update_creative
|
||||
archive_creative = archive_creative
|
||||
delete_creative = delete_creative
|
||||
32
src/usecase/creative/archive_creative.py
Normal file
32
src/usecase/creative/archive_creative.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def archive_creative(self: 'Usecase', creative_id: uuid.UUID, user_id: uuid.UUID) -> dto.CreativeOutput:
|
||||
async with self.database.transaction():
|
||||
creative = await self.database.get_creative(user_id, creative_id)
|
||||
if not creative:
|
||||
log.warning('User %s attempted to change archive status for unavailable creative %s', user_id, creative_id)
|
||||
raise domain.CreativeNotFound(creative_id)
|
||||
|
||||
creative.status = domain.CreativeStatus.ARCHIVED
|
||||
updated = await self.database.update_creative(creative)
|
||||
|
||||
return dto.CreativeOutput(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
text=updated.text,
|
||||
target_channel_id=updated.target_channel_id,
|
||||
target_channel_title=updated.target_channel.title,
|
||||
created_at=updated.created_at,
|
||||
status=updated.status,
|
||||
purchases_count=updated.purchases_count,
|
||||
)
|
||||
40
src/usecase/creative/create_creative.py
Normal file
40
src/usecase/creative/create_creative.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID) -> dto.CreativeOutput:
|
||||
async with self.database.transaction():
|
||||
target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id)
|
||||
if not target_channel:
|
||||
log.warning(
|
||||
'User %s attempted to create creative for unavailable target %s', user_id, input.target_channel_id
|
||||
)
|
||||
raise domain.TargetChannelNotFound(input.target_channel_id)
|
||||
|
||||
creative = domain.Creative(
|
||||
name=input.name,
|
||||
text=input.text,
|
||||
target_channel_id=target_channel.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
created = await self.database.create_creative(creative)
|
||||
|
||||
return dto.CreativeOutput(
|
||||
id=created.id,
|
||||
name=created.name,
|
||||
text=created.text,
|
||||
target_channel_id=created.target_channel_id,
|
||||
target_channel_title=created.target_channel.title,
|
||||
created_at=created.created_at,
|
||||
status=created.status,
|
||||
purchases_count=created.purchases_count,
|
||||
)
|
||||
23
src/usecase/creative/delete_creative.py
Normal file
23
src/usecase/creative/delete_creative.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
|
||||
async with self.database.transaction():
|
||||
creative = await self.database.get_creative(input.user_id, input.creative_id)
|
||||
if not creative:
|
||||
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
|
||||
raise domain.CreativeNotFound(input.creative_id)
|
||||
|
||||
if creative.purchases_count > 0:
|
||||
log.warning('Creative %s is used in purchases and cannot be deleted', input.creative_id)
|
||||
raise domain.CreativeInUse(input.creative_id)
|
||||
|
||||
await self.database.delete_creative(input.creative_id)
|
||||
28
src/usecase/creative/get_creative.py
Normal file
28
src/usecase/creative/get_creative.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
|
||||
async with self.database.transaction():
|
||||
creative = await self.database.get_creative(input.user_id, input.creative_id)
|
||||
if not creative:
|
||||
log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id)
|
||||
raise domain.CreativeNotFound(input.creative_id)
|
||||
|
||||
return dto.CreativeOutput(
|
||||
id=creative.id,
|
||||
name=creative.name,
|
||||
text=creative.text,
|
||||
target_channel_id=creative.target_channel_id,
|
||||
target_channel_title=creative.target_channel.title,
|
||||
created_at=creative.created_at,
|
||||
status=creative.status,
|
||||
purchases_count=creative.purchases_count,
|
||||
)
|
||||
29
src/usecase/creative/get_creatives.py
Normal file
29
src/usecase/creative/get_creatives.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput:
|
||||
async with self.database.transaction():
|
||||
creatives = await self.database.get_user_creatives(
|
||||
input.user_id, target_channel_id=input.target_channel_id, include_archived=input.include_archived
|
||||
)
|
||||
|
||||
return dto.GetCreativesOutput(
|
||||
creatives=[
|
||||
dto.CreativeOutput(
|
||||
id=creative.id,
|
||||
name=creative.name,
|
||||
text=creative.text,
|
||||
target_channel_id=creative.target_channel_id,
|
||||
target_channel_title=creative.target_channel.title,
|
||||
created_at=creative.created_at,
|
||||
status=creative.status,
|
||||
purchases_count=creative.purchases_count,
|
||||
)
|
||||
for creative in creatives
|
||||
]
|
||||
)
|
||||
38
src/usecase/creative/update_creative.py
Normal file
38
src/usecase/creative/update_creative.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def update_creative(
|
||||
self: 'Usecase', creative_id: uuid.UUID, input: dto.UpdateCreativeInput, user_id: uuid.UUID
|
||||
) -> dto.CreativeOutput:
|
||||
async with self.database.transaction():
|
||||
creative = await self.database.get_creative(user_id, creative_id)
|
||||
if not creative:
|
||||
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
|
||||
raise domain.CreativeNotFound(creative_id)
|
||||
|
||||
if input.name is not None:
|
||||
creative.name = input.name
|
||||
if input.text is not None:
|
||||
creative.text = input.text
|
||||
|
||||
updated = await self.database.update_creative(creative)
|
||||
|
||||
return dto.CreativeOutput(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
text=updated.text,
|
||||
target_channel_id=updated.target_channel_id,
|
||||
target_channel_title=updated.target_channel.title,
|
||||
created_at=updated.created_at,
|
||||
status=updated.status,
|
||||
purchases_count=updated.purchases_count,
|
||||
)
|
||||
47
src/usecase/external_channel/create_external_channel.py
Normal file
47
src/usecase/external_channel/create_external_channel.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_external_channel(
|
||||
self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID
|
||||
) -> dto.ExternalChannelOutput:
|
||||
async with self.database.transaction():
|
||||
for target_id in input.target_channel_ids:
|
||||
target_channel = await self.database.get_target_channel(user_id, channel_id=target_id)
|
||||
if not target_channel:
|
||||
log.warning('User %s attempted to add external channel to unavailable target %s', user_id, target_id)
|
||||
raise domain.TargetChannelNotFound(target_id)
|
||||
|
||||
channel = domain.ExternalChannel(
|
||||
telegram_id=input.telegram_id,
|
||||
title=input.title,
|
||||
username=input.username,
|
||||
description=input.description,
|
||||
subscribers_count=input.subscribers_count,
|
||||
user_id=user_id,
|
||||
)
|
||||
created_channel = await self.database.create_external_channel(channel)
|
||||
|
||||
if input.target_channel_ids:
|
||||
await self.database.add_external_channel_to_targets(created_channel.id, input.target_channel_ids)
|
||||
|
||||
log.info(
|
||||
'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids)
|
||||
)
|
||||
|
||||
return dto.ExternalChannelOutput(
|
||||
id=created_channel.id,
|
||||
telegram_id=created_channel.telegram_id,
|
||||
title=created_channel.title,
|
||||
username=created_channel.username,
|
||||
description=created_channel.description,
|
||||
subscribers_count=created_channel.subscribers_count,
|
||||
)
|
||||
20
src/usecase/external_channel/delete_external_channel.py
Normal file
20
src/usecase/external_channel/delete_external_channel.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None:
|
||||
async with self.database.transaction():
|
||||
channel = await self.database.get_external_channel(input.user_id, channel_id=input.channel_id)
|
||||
if not channel:
|
||||
log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id)
|
||||
raise domain.ExternalChannelNotFound(input.channel_id)
|
||||
|
||||
await self.database.delete_external_channel(input.channel_id)
|
||||
log.info('User %s deleted external channel %s', input.user_id, input.channel_id)
|
||||
35
src/usecase/external_channel/get_external_channels.py
Normal file
35
src/usecase/external_channel/get_external_channels.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput:
|
||||
async with self.database.transaction():
|
||||
target_channel = await self.database.get_target_channel(input.user_id, channel_id=input.target_channel_id)
|
||||
if not target_channel:
|
||||
log.warning(
|
||||
'Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id
|
||||
)
|
||||
raise domain.TargetChannelNotFound(input.target_channel_id)
|
||||
|
||||
channels = await self.database.get_external_channels_for_target(input.target_channel_id)
|
||||
|
||||
return dto.GetExternalChannelsOutput(
|
||||
external_channels=[
|
||||
dto.ExternalChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
description=channel.description,
|
||||
subscribers_count=channel.subscribers_count,
|
||||
)
|
||||
for channel in channels
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def import_external_channels_from_excel(
|
||||
self: 'Usecase', input: dto.ImportExternalChannelsInput
|
||||
) -> dto.ImportExternalChannelsOutput:
|
||||
async with self.database.transaction():
|
||||
target_channel = await self.database.get_target_channel(
|
||||
user_id=input.user_id, channel_id=input.target_channel_id
|
||||
)
|
||||
if not target_channel:
|
||||
log.warning(
|
||||
'User %s attempted to import to unavailable target channel %s', input.user_id, input.target_channel_id
|
||||
)
|
||||
raise domain.TargetChannelNotFound(input.target_channel_id)
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
errors: list[str] = []
|
||||
|
||||
log.info(
|
||||
'Excel import completed for target %s: %s created, %s skipped, %s errors',
|
||||
input.target_channel_id,
|
||||
created_count,
|
||||
skipped_count,
|
||||
len(errors),
|
||||
)
|
||||
|
||||
return dto.ImportExternalChannelsOutput(
|
||||
created_count=created_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def update_external_channel_links(
|
||||
self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelLinksInput, user_id: uuid.UUID
|
||||
) -> dto.ExternalChannelOutput:
|
||||
async with self.database.transaction():
|
||||
external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id)
|
||||
if not external_channel:
|
||||
log.warning('External channel %s not found', channel_id)
|
||||
raise domain.ExternalChannelNotFound(channel_id)
|
||||
|
||||
for target_id in input.add_target_channel_ids:
|
||||
target_channel = await self.database.get_target_channel(user_id, channel_id=target_id)
|
||||
if not target_channel:
|
||||
log.warning(
|
||||
'User %s attempted to link external channel to target %s they do not own', user_id, target_id
|
||||
)
|
||||
raise domain.TargetChannelNotFound(target_id)
|
||||
|
||||
if input.add_target_channel_ids:
|
||||
await self.database.add_external_channel_to_targets(channel_id, input.add_target_channel_ids)
|
||||
|
||||
for target_id in input.remove_target_channel_ids:
|
||||
await self.database.remove_external_channel_from_target(channel_id, target_id)
|
||||
|
||||
log.info(
|
||||
'External channel %s links updated: %s added, %s removed',
|
||||
channel_id,
|
||||
len(input.add_target_channel_ids),
|
||||
len(input.remove_target_channel_ids),
|
||||
)
|
||||
|
||||
return dto.ExternalChannelOutput(
|
||||
id=external_channel.id,
|
||||
telegram_id=external_channel.telegram_id,
|
||||
title=external_channel.title,
|
||||
username=external_channel.username,
|
||||
description=external_channel.description,
|
||||
subscribers_count=external_channel.subscribers_count,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.usecase.usecase import Usecase
|
||||
|
||||
|
||||
async def get_user(self: 'Usecase', user_id: uuid.UUID) -> dto.GetUserOutput:
|
||||
user: domain.User | None = await self.database.get_user(user_id=user_id)
|
||||
if user is None:
|
||||
raise domain.UserNotFound(user_id)
|
||||
|
||||
return dto.GetUserOutput(
|
||||
user_id=user.id,
|
||||
name=user.name,
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.usecase.usecase import Usecase
|
||||
|
||||
|
||||
async def notify_user(self: 'Usecase', user_id: uuid.UUID, chat_id: int, message: str) -> None:
|
||||
"""Send notification to user via Telegram"""
|
||||
if self.telegram_writer is None:
|
||||
raise RuntimeError('TelegramWriter not configured')
|
||||
|
||||
# Get user from database
|
||||
user = await self.database.get_user(user_id)
|
||||
if user is None:
|
||||
raise domain.UserNotFound(user_id)
|
||||
|
||||
# Send personalized message
|
||||
text = f'Привет, {user.name}!\n\n{message}'
|
||||
await self.telegram_writer.send_message(chat_id=chat_id, text=text)
|
||||
77
src/usecase/target_channel/connect_target_chan.py
Normal file
77
src/usecase/target_channel/connect_target_chan.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput) -> dto.ConnectTargetChanOutput:
|
||||
permissions = input.bot_permissions
|
||||
|
||||
if not permissions.is_admin:
|
||||
log.warning(f'Bot is not admin in channel {input.telegram_id}. Attempted by user {input.user_telegram_id}')
|
||||
await self.telegram_writer.send_message(
|
||||
f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n'
|
||||
'Пожалуйста, сделайте бота администратором канала.',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
raise domain.ChannelNoAdminRights()
|
||||
|
||||
missing_permissions = []
|
||||
if not permissions.can_invite_users:
|
||||
missing_permissions.append('Создание инвайт-ссылок')
|
||||
if not permissions.can_restrict_members:
|
||||
missing_permissions.append('Управление пользователями (видеть вступления)')
|
||||
|
||||
if missing_permissions:
|
||||
log.warning(
|
||||
f'Bot lacks required permissions in channel {input.telegram_id}: {missing_permissions}. '
|
||||
f'Attempted by user {input.user_telegram_id}'
|
||||
)
|
||||
permissions_text = '\n'.join(f'• {p}' for p in missing_permissions)
|
||||
await self.telegram_writer.send_message(
|
||||
f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n'
|
||||
f'Отсутствующие права:\n{permissions_text}\n\n'
|
||||
'Пожалуйста, предоставьте эти права боту в настройках канала.',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
raise domain.ChannelNoAdminRights()
|
||||
|
||||
async with self.database.transaction():
|
||||
user = await self.database.get_user(telegram_id=input.user_telegram_id)
|
||||
if not user:
|
||||
log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}')
|
||||
await self.telegram_writer.send_message(
|
||||
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
|
||||
'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
raise domain.UserNotFound()
|
||||
|
||||
channel = domain.TargetChannel(
|
||||
telegram_id=input.telegram_id,
|
||||
title=input.title,
|
||||
username=input.username,
|
||||
user_id=user.id,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
upserted_channel = await self.database.upsert_target_channel(channel)
|
||||
log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}')
|
||||
|
||||
await self.telegram_writer.send_message(
|
||||
f'✅ Канал "{input.title}" успешно подключен!',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
|
||||
return dto.ConnectTargetChanOutput(
|
||||
id=upserted_channel.id,
|
||||
telegram_id=upserted_channel.telegram_id,
|
||||
title=upserted_channel.title,
|
||||
username=upserted_channel.username,
|
||||
is_active=upserted_channel.is_active,
|
||||
)
|
||||
21
src/usecase/target_channel/disconnect_target_chan.py
Normal file
21
src/usecase/target_channel/disconnect_target_chan.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def disconnect_target_chan(self: 'Usecase', input: dto.DisconnectTargetChanInput) -> None:
|
||||
async with self.database.transaction():
|
||||
channel = await self.database.get_target_channel(channel_id=input.channel_id, user_id=input.user_id)
|
||||
if not channel:
|
||||
raise domain.TargetChannelNotFound(input.channel_id)
|
||||
|
||||
channel.is_active = False
|
||||
await self.database.update_target_channel(channel)
|
||||
|
||||
log.info(f'Target channel {input.channel_id} deactivated')
|
||||
@@ -0,0 +1,22 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.DisconnectTargetChanByTgIdInput) -> None:
|
||||
async with self.database.transaction():
|
||||
channel = await self.database.get_target_channel(input.user_id, telegram_id=input.telegram_id)
|
||||
if not channel:
|
||||
log.warning(f'Target channel {input.telegram_id} not found')
|
||||
raise domain.TargetChannelNotFound()
|
||||
|
||||
channel.is_active = False
|
||||
await self.database.update_target_channel(channel)
|
||||
|
||||
log.info(f'Target channel {input.telegram_id} deactivated')
|
||||
24
src/usecase/target_channel/get_user_target_chans.py
Normal file
24
src/usecase/target_channel/get_user_target_chans.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
|
||||
async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput:
|
||||
async with self.database.transaction():
|
||||
channels = await self.database.get_user_target_channels(input.user_id)
|
||||
|
||||
return dto.GetUserTargetChansOutput(
|
||||
target_channels=[
|
||||
dto.ConnectTargetChanOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
is_active=channel.is_active,
|
||||
)
|
||||
for channel in channels
|
||||
]
|
||||
)
|
||||
49
src/usecase/target_channel/update_target_chan_permissions.py
Normal file
49
src/usecase/target_channel/update_target_chan_permissions.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTargetChanPermissionsInput) -> None:
|
||||
missing_permissions = []
|
||||
if not input.permissions.can_invite_users:
|
||||
missing_permissions.append('Создание инвайт-ссылок')
|
||||
if not input.permissions.can_restrict_members:
|
||||
missing_permissions.append('Управление пользователями')
|
||||
|
||||
async with self.database.transaction():
|
||||
channel = await self.database.get_target_channel(user_id, telegram_id=input.telegram_id)
|
||||
if not channel:
|
||||
log.warning(f'Target channel {input.telegram_id} not found when permissions changed')
|
||||
raise domain.TargetChannelNotFound()
|
||||
|
||||
if not missing_permissions:
|
||||
if not channel.is_active:
|
||||
channel.is_active = True
|
||||
await self.database.update_target_channel(channel)
|
||||
|
||||
await self.telegram_writer.send_message(
|
||||
f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
log.info(f'Target channel {input.telegram_id} reactivated - all permissions granted')
|
||||
return
|
||||
|
||||
if channel.is_active:
|
||||
channel.is_active = False
|
||||
await self.database.update_target_channel(channel)
|
||||
|
||||
missed_permissions = '\n'.join(f'• {p}' for p in missing_permissions)
|
||||
await self.telegram_writer.send_message(
|
||||
f'⚠️ Канал "{input.chat_title}" был деактивирован.\
|
||||
\n\nБоту убрали необходимые права:\n{missed_permissions}',
|
||||
chat_id=input.user_telegram_id,
|
||||
)
|
||||
log.warning(
|
||||
f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}'
|
||||
)
|
||||
36
src/usecase/telegram_login.py
Normal file
36
src/usecase/telegram_login.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import datetime
|
||||
import secrets
|
||||
import typing
|
||||
|
||||
from src import domain, dto
|
||||
from src.config import settings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Usecase
|
||||
|
||||
|
||||
async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None:
|
||||
async with self.database.transaction():
|
||||
user: domain.User | None = await self.database.get_user(telegram_id=input.telegram_id)
|
||||
|
||||
if not user:
|
||||
user = domain.User(
|
||||
telegram_id=input.telegram_id,
|
||||
username=input.username,
|
||||
)
|
||||
user = await self.database.create_user(user)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10)
|
||||
|
||||
await self.database.create_login_token(user.id, token, expires_at)
|
||||
|
||||
login_url = f'{settings.app.URL}/auth/complete?token={token}'
|
||||
|
||||
username_display = f'@{user.username}' if user.username else 'пользователь'
|
||||
await self.telegram_writer.send_message_with_button(
|
||||
f'Привет, {username_display}!\n\nНажми кнопку ниже для входа на сайт.',
|
||||
chat_id=input.chat_id,
|
||||
button_text='Войти на сайт',
|
||||
button_url=login_url,
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from src import domain
|
||||
|
||||
from .get_user import get_user
|
||||
from .notify_user import notify_user
|
||||
|
||||
|
||||
class Database(Protocol):
|
||||
async def get_user(self, user_id: uuid.UUID) -> domain.User | None: ...
|
||||
|
||||
|
||||
class TelegramWriter(Protocol):
|
||||
async def send_message(self, chat_id: int, text: str) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Usecase:
|
||||
database: Database
|
||||
telegram_writer: TelegramWriter
|
||||
|
||||
get_user = get_user
|
||||
notify_user = notify_user
|
||||
38
src/usecase/validate_login_token.py
Normal file
38
src/usecase/validate_login_token.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Usecase
|
||||
|
||||
|
||||
async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInput) -> dto.ValidateLoginTokenOutput:
|
||||
async with self.database.transaction():
|
||||
login_token = await self.database.get_login_token(input.token)
|
||||
|
||||
if not login_token:
|
||||
raise domain.LoginTokenNotFound()
|
||||
|
||||
if login_token.used_at:
|
||||
raise domain.LoginTokenAlreadyUsed()
|
||||
|
||||
if login_token.expires_at < datetime.datetime.now(datetime.UTC):
|
||||
raise domain.LoginTokenExpired()
|
||||
|
||||
user = await self.database.get_user(user_id=login_token.user_id)
|
||||
if not user:
|
||||
raise domain.UserNotFound(login_token.user_id)
|
||||
|
||||
await self.database.mark_token_as_used(input.token)
|
||||
|
||||
# Генерируем JWT токен с информацией о пользователе
|
||||
access_token = self.jwt_encoder.encode_access_token(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
return dto.ValidateLoginTokenOutput(
|
||||
access_token=access_token,
|
||||
)
|
||||
Reference in New Issue
Block a user