feat: creatives and external channels

This commit is contained in:
Artem Tsyrulnikov
2025-11-10 15:13:38 +03:00
parent 3800c72662
commit cd167fdb43
73 changed files with 3024 additions and 947 deletions

View File

@@ -1,4 +1,6 @@
APP__URL=https://app.example.com
DB__URL=postgresql+asyncpg://user:password@localhost:5432/tgex
LOGGER__APP_NAME="tgex-backend"
LOGGER__PRETTY_CONSOLE=true
TELEGRAM__TOKEN=your_bot_token_here
JWT__SECRET_KEY=your_secret_key_here_please_generate_a_strong_random_string

169
CLAUDE.md Normal file
View File

@@ -0,0 +1,169 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a FastAPI + Telegram Bot backend application for managing Telegram channel integrations. The project uses:
- **FastAPI** for HTTP API endpoints
- **aiogram** for Telegram bot functionality
- **SQLAlchemy 2.0** (async) for database operations
- **PostgreSQL** with asyncpg driver
- **Alembic** for database migrations
- **uv** for Python package management
## Development Commands
### Environment Setup
```bash
# Install dependencies (using uv)
uv sync
# Start PostgreSQL (via Docker Compose)
docker-compose up -d
# Run database migrations
uv run alembic upgrade head
```
### Running the Application
```bash
# Start the FastAPI server
uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
```
### Code Quality
```bash
# Run linter (Ruff)
uv run ruff check .
# Format code
uv run ruff format .
# Type checking (mypy with strict mode)
uv run mypy .
```
### Testing
```bash
# Run all tests
uv run pytest
# Run a single test file
uv run pytest tests/test_file.py
# Run a specific test
uv run pytest tests/test_file.py::test_function_name
```
### Database Migrations
```bash
# Create a new migration
uv run alembic revision --autogenerate -m "description"
# Apply migrations
uv run alembic upgrade head
# Rollback one migration
uv run alembic downgrade -1
```
## Architecture
### Layered Architecture
The codebase follows a clean architecture pattern with clear separation of concerns:
1. **Domain Layer** (`src/domain/`)
- SQLAlchemy ORM models that represent database entities
- All models inherit from `domain.Base` which provides:
- Auto-generated `id` (UUID primary key)
- Timestamps: `created_at`, `updated_at`, `deleted_at`
- Automatic table naming (pluralized lowercase class name)
- Timezone-aware datetime fields
2. **Use Case Layer** (`src/usecase/`)
- Business logic functions organized by feature
- Each use case is a standalone function
- Depends on Protocol interfaces (Database, TelegramWriter, JWTEncoder)
- Use cases are assembled in the `Usecase` dataclass for dependency injection
3. **Adapter Layer** (`src/adapter/`)
- Concrete implementations of protocol interfaces:
- `Postgres`: Database operations (implements Database protocol)
- `Telegram`: Telegram bot integration (implements TelegramWriter protocol)
- `JWT`: Token encoding/decoding (implements JWTEncoder protocol)
4. **Controller Layer** (`src/controller/`)
- **HTTP controllers** (`src/controller/http/`): FastAPI route handlers
- **Telegram callbacks** (`src/controller/telegram_callback/`): Telegram event handlers
- Controllers call use cases to perform business logic
5. **DTO Layer** (`src/dto/`)
- Pydantic models for request/response validation
- Separate from domain models to decouple API contracts from database schema
### Key Architectural Patterns
**Dependency Injection via Protocols:**
- Use cases depend on Protocol interfaces, not concrete implementations
- Allows for easy testing and swapping implementations
- See `src/usecase/__init__.py` for protocol definitions
**Transaction Management:**
- Database uses context-aware transactions via `DatabaseBase.transaction()`
- Session stored in ContextVar (`_session_ctx`) for implicit session access
- Always use `async with database.transaction()` for database operations
- The session is accessible via `database.session` within transaction context
**Shared Base Classes:**
- `shared/datebase_base.py`: Base database class with connection pooling and migration checking
- `shared/telegram_base.py`: Base Telegram bot class with polling lifecycle
- `shared/logger/`: Structured logging with JSON and console formatters
**Configuration:**
- All config in `src/config.py` using Pydantic Settings
- Environment variables loaded from `.env` file
- Nested config via double underscore: `DB__URL`, `TELEGRAM__TOKEN`
### Application Lifecycle
1. **Startup** (in `src/main.py`):
- Logger initialized
- Adapters instantiated (Postgres, Telegram, JWT)
- Usecase dataclass created with adapter dependencies
- FastAPI lifespan context:
- Connect to database (includes migration check)
- Start Telegram bot polling
2. **Request Flow**:
- HTTP: FastAPI route → Controller → Usecase → Adapter → Database
- Telegram: Event → Telegram callback → Usecase → Adapter → Database
3. **Shutdown**:
- Stop Telegram bot polling
- Close database connections
### Important Implementation Details
**Database Session Management:**
- DO NOT create sessions manually
- Use `async with database.transaction()` which handles begin/commit/rollback
- Access session via `database.session` property within transaction context
- Session lifecycle is managed by ContextVars for thread-safety
**Telegram Bot Integration:**
- Bot runs in polling mode (not webhook)
- Routers registered in `src/controller/telegram_callback/`
- Base class handles graceful shutdown of polling task
**Authentication:**
- JWT-based authentication for HTTP endpoints
- Login flow: Telegram bot → Login token → JWT access token
- Use `get_current_user` dependency for protected routes
**Code Style:**
- Single quotes for strings (configured in Ruff)
- Line length: 120 characters
- Python 3.13+ syntax
- Strict mypy typing enabled

View File

@@ -1,38 +0,0 @@
"""init
Revision ID: 9439868200c8
Revises:
Create Date: 2025-11-08 18:09:40.188129
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '9439868200c8'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'users',
sa.Column('name', sa.String(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###

View File

View File

@@ -0,0 +1,145 @@
"""init
Revision ID: e005b383b5c6
Revises:
Create Date: 2025-11-10 15:11:29.376116
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e005b383b5c6'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'users',
sa.Column('telegram_id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_users_telegram_id'), 'users', ['telegram_id'], unique=True)
op.create_table(
'externalchannels',
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('subscribers_count', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
['user_id'],
['users.id'],
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_externalchannels_telegram_id'), 'externalchannels', ['telegram_id'], unique=True)
op.create_index(op.f('ix_externalchannels_user_id'), 'externalchannels', ['user_id'], unique=False)
op.create_index(op.f('ix_externalchannels_username'), 'externalchannels', ['username'], unique=False)
op.create_table(
'logintokens',
sa.Column('token', sa.String(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
['user_id'],
['users.id'],
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_logintokens_token'), 'logintokens', ['token'], unique=True)
op.create_table(
'targetchannels',
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'ARCHIVED', name='targetchannelstatus'), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
['user_id'],
['users.id'],
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_targetchannels_telegram_id'), 'targetchannels', ['telegram_id'], unique=True)
op.create_index(op.f('ix_targetchannels_user_id'), 'targetchannels', ['user_id'], unique=False)
op.create_index(op.f('ix_targetchannels_username'), 'targetchannels', ['username'], unique=False)
op.create_table(
'creatives',
sa.Column('name', sa.String(), nullable=False),
sa.Column('text', sa.String(), nullable=False),
sa.Column('status', sa.Enum('ACTIVE', 'ARCHIVED', name='creativestatus'), nullable=False),
sa.Column('purchases_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('target_channel_id', sa.Uuid(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
['target_channel_id'],
['targetchannels.id'],
),
sa.ForeignKeyConstraint(
['user_id'],
['users.id'],
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_creatives_target_channel_id'), 'creatives', ['target_channel_id'], unique=False)
op.create_index(op.f('ix_creatives_user_id'), 'creatives', ['user_id'], unique=False)
op.create_table(
'target_channel_external_channels',
sa.Column('target_channel_id', sa.Uuid(), nullable=False),
sa.Column('external_channel_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['external_channel_id'], ['externalchannels.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['target_channel_id'], ['targetchannels.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('target_channel_id', 'external_channel_id'),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('target_channel_external_channels')
op.drop_index(op.f('ix_creatives_user_id'), table_name='creatives')
op.drop_index(op.f('ix_creatives_target_channel_id'), table_name='creatives')
op.drop_table('creatives')
op.drop_index(op.f('ix_targetchannels_username'), table_name='targetchannels')
op.drop_index(op.f('ix_targetchannels_user_id'), table_name='targetchannels')
op.drop_index(op.f('ix_targetchannels_telegram_id'), table_name='targetchannels')
op.drop_table('targetchannels')
op.drop_index(op.f('ix_logintokens_token'), table_name='logintokens')
op.drop_table('logintokens')
op.drop_index(op.f('ix_externalchannels_username'), table_name='externalchannels')
op.drop_index(op.f('ix_externalchannels_user_id'), table_name='externalchannels')
op.drop_index(op.f('ix_externalchannels_telegram_id'), table_name='externalchannels')
op.drop_table('externalchannels')
op.drop_index(op.f('ix_users_telegram_id'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -9,6 +9,8 @@ dependencies = [
"asyncpg>=0.30.0",
"fastapi>=0.121.0",
"pydantic-settings>=2.11.0",
"pyjwt>=2.10.1",
"python-multipart>=0.0.20",
"sqlalchemy[asyncio]>=2.0.38",
"uvicorn>=0.38.0",
]
@@ -26,7 +28,9 @@ dev = [
[tool.ruff]
exclude = [".venv"]
target-version = "py313"
line-length = 100
line-length = 120
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
@@ -35,8 +39,6 @@ select = [
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
[tool.ruff.lint]
ignore = ["B008"]
[tool.ruff.format]

1
shared/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared utilities package."""

View File

@@ -9,18 +9,19 @@ RESET = '\033[0m'
BOLD = '\033[1m'
def load_settings(settings_class: type[BaseSettings]) -> BaseSettings:
def load_settings[T: BaseSettings](settings_class: type[T]) -> T:
try:
return settings_class()
except ValidationError as e:
print(f'{RED}{BOLD}Missing environment variables:{RESET}')
missing = set()
missing: set[str] = set()
for err in e.errors():
if err['type'] == 'missing' and len(err['loc']) == 1:
field = settings_class.model_fields.get(str(err['loc'][0]))
if field and hasattr(field.annotation, 'model_fields'):
for name, info in field.annotation.model_fields.items():
field_info = settings_class.model_fields.get(str(err['loc'][0]))
if field_info and field_info.annotation is not None and hasattr(field_info.annotation, 'model_fields'):
annotation_fields = getattr(field_info.annotation, 'model_fields', {})
for name, info in annotation_fields.items():
if info.is_required():
missing.add(f'{err["loc"][0]}__{name}'.upper())
else:
@@ -28,8 +29,8 @@ def load_settings(settings_class: type[BaseSettings]) -> BaseSettings:
else:
missing.add('__'.join(str(loc).upper() for loc in err['loc']))
for field in sorted(missing):
print(f' {YELLOW}{field}{RESET}')
for field_name in sorted(missing):
print(f' {YELLOW}{field_name}{RESET}')
print(f'\n{RED}Check .env file or set required variables{RESET}')
sys.exit(1)

165
shared/datebase_base.py Normal file
View File

@@ -0,0 +1,165 @@
import asyncio
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from contextvars import ContextVar
import pydantic
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
_session_ctx: ContextVar[AsyncSession | None] = ContextVar('session', default=None)
class DatabaseConfig(pydantic.BaseModel):
URL: pydantic.PostgresDsn
ECHO: bool = False
ECHO_POOL: bool = False
POOL_SIZE: int = 50
MAX_OVERFLOW: int = 10
class DatabaseBase:
def __init__(self, config: DatabaseConfig, migrations_path: str = 'migration/versions') -> None:
self.config = config
self.migrations_path = migrations_path
self.engine: AsyncEngine | None = None
self.session_factory: async_sessionmaker[AsyncSession] | None = None
self._connect_lock = asyncio.Lock()
async def connect(self) -> None:
async with self._connect_lock:
if self.engine is not None:
logging.warning('Database already connected')
return
self.engine = create_async_engine(
url=str(self.config.URL),
echo=self.config.ECHO,
echo_pool=self.config.ECHO_POOL,
pool_size=self.config.POOL_SIZE,
max_overflow=self.config.MAX_OVERFLOW,
)
self.session_factory = async_sessionmaker(
bind=self.engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
await self.ping()
await self._check_migrations()
logging.info('Database initialized')
async def ping(self) -> None:
if self.session_factory is None:
raise RuntimeError('Database not connected. Call connect() first.')
try:
async with self.session_factory() as session:
await session.execute(text('SELECT 1'))
except Exception as e:
logging.error(f'Database ping failed: {e}')
async def close(self) -> None:
async with self._connect_lock:
if self.engine is None:
logging.warning('Database already closed')
return
await self.engine.dispose()
self.engine = None
self.session_factory = None
logging.info('Database closed')
@property
def session(self) -> AsyncSession:
session = _session_ctx.get()
if session is None:
raise RuntimeError('No active transaction. Use transaction() or begin() first.')
return session
async def begin(self) -> None:
if _session_ctx.get() is not None:
raise RuntimeError('Transaction already started')
if self.session_factory is None:
raise RuntimeError('Database not connected. Call connect() first.')
session = self.session_factory()
_session_ctx.set(session)
async def commit(self) -> None:
session = _session_ctx.get()
if session is None:
raise RuntimeError('No active transaction')
try:
await session.commit()
finally:
await session.close()
_session_ctx.set(None)
async def rollback(self) -> None:
session = _session_ctx.get()
if session is None:
raise RuntimeError('No active transaction')
try:
await session.rollback()
finally:
await session.close()
_session_ctx.set(None)
@asynccontextmanager
async def transaction(self) -> AsyncGenerator[None]:
await self.begin()
try:
yield
await self.commit()
except Exception:
await self.rollback()
raise
async def _check_migrations(self) -> None:
if self.session_factory is None:
raise RuntimeError('Database not connected. Call connect() first.')
async with self.session_factory() as session:
result = await session.execute(
text("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'alembic_version')")
)
if not result.scalar():
logging.error('Migrations not applied. Run: alembic upgrade head')
return
result = await session.execute(text('SELECT version_num FROM alembic_version'))
current_version = result.scalar()
if not current_version:
logging.error('No migration version found in database. Run: alembic upgrade head')
return
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
alembic_cfg = Config('alembic.ini')
script = ScriptDirectory.from_config(alembic_cfg)
head_revision = script.get_current_head()
if head_revision and current_version != head_revision:
logging.warning(
f'Database version {current_version} is outdated (latest: {head_revision}). '
f'Run: alembic upgrade head'
)
except Exception as e:
logging.debug(f'Could not check migration head: {e}')

View File

@@ -39,11 +39,33 @@ class ColoredConsoleFormatter(logging.Formatter):
# Add extra fields
extra_parts = []
for key, value in record.__dict__.items():
if key not in ['name', 'msg', 'args', 'created', 'filename', 'funcName', 'levelname',
'levelno', 'lineno', 'module', 'msecs', 'message', 'pathname', 'process',
'processName', 'relativeCreated', 'thread', 'threadName', 'exc_info',
'exc_text', 'stack_info', 'app_name', 'app_version', 'taskName',
'color_message']:
if key not in [
'name',
'msg',
'args',
'created',
'filename',
'funcName',
'levelname',
'levelno',
'lineno',
'module',
'msecs',
'message',
'pathname',
'process',
'processName',
'relativeCreated',
'thread',
'threadName',
'exc_info',
'exc_text',
'stack_info',
'app_name',
'app_version',
'taskName',
'color_message',
]:
value_color = self.RED if key == 'error' else self.DARK_YELLOW
extra_parts.append(f'{self.DARK_CYAN}{key}{self.RESET}={value_color}{value}{self.RESET}')

View File

@@ -11,15 +11,16 @@ class JSONFormatter(logging.Formatter):
'message': record.getMessage(),
'module': record.module,
'package': record.name,
'app_name': record.app_name,
'app_version': record.app_version,
'app_name': getattr(record, 'app_name', 'unknown'),
'app_version': getattr(record, 'app_version', 'unknown'),
}
if record.levelno >= logging.ERROR:
log_data['location'] = f'{record.pathname}:{record.lineno}'
if record.exc_info:
log_data['error_type'] = record.exc_info[0].__name__
exc_type = record.exc_info[0]
log_data['error_type'] = exc_type.__name__ if exc_type else 'Unknown'
log_data['error_message'] = str(record.exc_info[1])
log_data['traceback'] = self.formatException(record.exc_info)

View File

@@ -42,7 +42,7 @@ def init(config: LoggerConfig) -> None:
old_factory = logging.getLogRecordFactory()
def record_factory(*args, **kwargs) -> logging.LogRecord:
def record_factory(*args: object, **kwargs: object) -> logging.LogRecord:
record = old_factory(*args, **kwargs)
record.app_name = config.APP_NAME
record.app_version = config.APP_VERSION
@@ -50,6 +50,4 @@ def init(config: LoggerConfig) -> None:
logging.setLogRecordFactory(record_factory)
logging.info(
'Logger initialized', extra={'app': config.APP_NAME, 'version': config.APP_VERSION}
)
logging.info('Logger initialized', extra={'app': config.APP_NAME, 'version': config.APP_VERSION})

View File

@@ -1,3 +0,0 @@
__all__ = ('DatabaseConfig', 'DatabaseHelper')
from .postgres_helper import DatabaseConfig, DatabaseHelper

View File

@@ -1,117 +0,0 @@
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
import pydantic
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
class DatabaseConfig(pydantic.BaseModel):
URL: pydantic.PostgresDsn
ECHO: bool = False
ECHO_POOL: bool = False
POOL_SIZE: int = 50
MAX_OVERFLOW: int = 10
class DatabaseHelper:
def __init__(self, config: DatabaseConfig, migrations_path: str = 'migration/versions') -> None:
self.config = config
self.migrations_path = migrations_path
self.engine: AsyncEngine | None = None
self.session_factory: async_sessionmaker[AsyncSession] | None = None
async def connect(self) -> None:
if self.engine is not None:
logging.warning('Database already connected')
return
self.engine = create_async_engine(
url=str(self.config.URL),
echo=self.config.ECHO,
echo_pool=self.config.ECHO_POOL,
pool_size=self.config.POOL_SIZE,
max_overflow=self.config.MAX_OVERFLOW,
)
self.session_factory = async_sessionmaker(
bind=self.engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
await self.ping()
await self._check_migrations()
logging.info('Database initialized')
async def ping(self) -> None:
try:
async with self.session_getter() as session:
await session.execute(text('SELECT 1'))
except Exception as e:
logging.error(f'Database ping failed: {e}')
async def close(self) -> None:
if self.engine is None:
logging.warning('Database already closed')
return
await self.engine.dispose()
self.engine = None
self.session_factory = None
logging.info('Database closed')
@asynccontextmanager
async def session_getter(self) -> AsyncGenerator[AsyncSession]:
"""Контекстный менеджер для получения сессии"""
if self.session_factory is None:
raise RuntimeError('Database not connected. Call connect() first.')
async with self.session_factory() as session:
yield session
async def _check_migrations(self) -> None:
migrations_dir = Path(self.migrations_path)
if not migrations_dir.exists():
return
latest_file = next(
(
f
for f in sorted(migrations_dir.glob('*.py'), reverse=True)
if f.name != '__init__.py'
),
None,
)
if not latest_file:
return
latest_version = latest_file.stem.split('_')[0]
async with self.session_getter() as session:
result = await session.execute(
text(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'alembic_version')"
)
)
if not result.scalar():
logging.error('Migrations not applied. Run: alembic upgrade head')
return
result = await session.execute(text('SELECT version_num FROM alembic_version'))
current_version = result.scalar()
if current_version != latest_version:
logging.warning(
f'Database version {current_version} is outdated (latest: {latest_version}). Run: alembic upgrade head'
)

View File

@@ -1,3 +0,0 @@
from shared.telegram.telegram_bot import TelegramConfig, TelegramHelper
__all__ = ['TelegramHelper', 'TelegramConfig']

View File

@@ -10,11 +10,11 @@ class TelegramConfig(pydantic.BaseModel):
DROP_PENDING_UPDATES: bool = True
class TelegramHelper:
class TelegramBase:
def __init__(self, config: TelegramConfig, *routers: Router) -> None:
self.bot: Bot = Bot(token=config.TOKEN)
self.dispatcher: Dispatcher = Dispatcher()
self._polling_task: asyncio.Task | None = None
self._polling_task: asyncio.Task[None] | None = None
self._drop_pending_updates: bool = config.DROP_PENDING_UPDATES
for router in routers:
@@ -23,27 +23,43 @@ class TelegramHelper:
logging.info('Telegram bot initialized')
async def start_polling(self) -> None:
"""Start bot polling in background"""
if self._polling_task is not None:
logging.warning('Bot polling already started')
return
self._polling_task = asyncio.create_task(
self.dispatcher.start_polling(self.bot, drop_pending_updates=self._drop_pending_updates)
self.dispatcher.start_polling(
self.bot,
drop_pending_updates=self._drop_pending_updates,
handle_signals=False, # Don't handle SIGINT/SIGTERM in dispatcher
)
)
logging.info('Telegram bot polling started')
async def stop_polling(self) -> None:
"""Stop bot polling and cleanup"""
if self._polling_task is None:
logging.warning('Bot polling not started')
return
self._polling_task.cancel()
# Stop dispatcher polling gracefully
await self.dispatcher.stop_polling()
if self.bot.session is not None:
await self.bot.session.close()
# Wait for polling task to finish
try:
await asyncio.wait_for(self._polling_task, timeout=5.0)
except TimeoutError:
logging.warning('Polling task did not stop in time, cancelling')
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
except asyncio.CancelledError:
logging.info('Polling task cancelled')
# Close bot session
await self.bot.session.close()
self._polling_task = None

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""tgex backend package."""

65
src/adapter/jwt.py Normal file
View File

@@ -0,0 +1,65 @@
import datetime
import uuid
import jwt
import pydantic
class JWTConfig(pydantic.BaseModel):
SECRET_KEY: str
ALGORITHM: str = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
class JWTPayload(pydantic.BaseModel):
user_id: uuid.UUID
telegram_id: int
username: str | None
class JWT:
def __init__(self, config: JWTConfig) -> None:
self.config = config
def encode_access_token(
self,
user_id: uuid.UUID,
telegram_id: int,
username: str | None = None,
) -> str:
expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES
)
payload = {
'sub': str(user_id),
'telegram_id': telegram_id,
'username': username,
'exp': expire,
'type': 'access',
}
encoded: str = jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
return encoded
def decode_access_token(self, token: str) -> JWTPayload:
try:
payload = jwt.decode(token, self.config.SECRET_KEY, algorithms=[self.config.ALGORITHM])
if payload.get('type') != 'access':
raise ValueError('Invalid token type')
user_id = payload.get('sub')
if not user_id:
raise ValueError('Token missing subject')
return JWTPayload(
user_id=uuid.UUID(user_id),
telegram_id=payload['telegram_id'],
username=payload.get('username'),
)
except jwt.ExpiredSignatureError as e:
raise ValueError('Token has expired') from e
except jwt.InvalidTokenError as e:
raise ValueError('Invalid token') from e

210
src/adapter/postgres.py Normal file
View File

@@ -0,0 +1,210 @@
import datetime
import uuid
from sqlalchemy import delete, select
from sqlalchemy.orm import selectinload
from shared.datebase_base import DatabaseBase
from src import domain
class Postgres(DatabaseBase):
async def get_user(self, *, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
if user_id:
return await self.session.get(domain.User, user_id)
elif telegram_id:
stmt = select(domain.User).where(domain.User.telegram_id == telegram_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
raise ValueError('Either user_id or telegram_id must be provided')
async def create_user(self, user: domain.User) -> domain.User:
self.session.add(user)
await self.session.flush()
await self.session.refresh(user)
return user
async def create_login_token(
self, user_id: uuid.UUID, token: str, expires_at: datetime.datetime
) -> domain.LoginToken:
login_token = domain.LoginToken(user_id=user_id, token=token, expires_at=expires_at)
self.session.add(login_token)
await self.session.flush()
await self.session.refresh(login_token)
return login_token
async def get_login_token(self, token: str) -> domain.LoginToken | None:
stmt = select(domain.LoginToken).where(domain.LoginToken.token == token)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def mark_token_as_used(self, token: str) -> None:
stmt = select(domain.LoginToken).where(domain.LoginToken.token == token)
result = await self.session.execute(stmt)
login_token = result.scalar_one_or_none()
if login_token:
login_token.used_at = datetime.datetime.now(datetime.UTC)
await self.session.flush()
async def get_target_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
) -> domain.TargetChannel | None:
if not channel_id and not telegram_id:
raise ValueError('Either channel_id or telegram_id must be provided')
stmt = select(domain.TargetChannel).where(domain.TargetChannel.user_id == user_id)
if channel_id:
stmt = stmt.where(domain.TargetChannel.id == channel_id)
if telegram_id:
stmt = stmt.where(domain.TargetChannel.telegram_id == telegram_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
self.session.add(channel)
await self.session.flush()
await self.session.refresh(channel)
return channel
async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
existing = await self.get_target_channel(user_id=channel.user_id, telegram_id=channel.telegram_id)
if not existing:
self.session.add(channel)
await self.session.flush()
await self.session.refresh(channel)
return channel
existing.title = channel.title
existing.username = channel.username
existing.user_id = channel.user_id
existing.is_active = channel.is_active
existing.deleted_at = None
existing.updated_at = datetime.datetime.now(datetime.UTC)
await self.session.flush()
await self.session.refresh(existing)
return existing
async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]:
stmt = select(domain.TargetChannel).where(
domain.TargetChannel.status == domain.TargetChannelStatus.ACTIVE,
domain.TargetChannel.user_id == user_id,
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
await self.session.flush()
await self.session.refresh(channel)
return channel
async def get_external_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | None:
stmt = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id)
if channel_id:
stmt = stmt.where(domain.ExternalChannel.id == channel_id)
if telegram_id:
stmt = stmt.where(domain.ExternalChannel.telegram_id == telegram_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel:
self.session.add(channel)
await self.session.flush()
await self.session.refresh(channel)
return channel
async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]:
stmt = (
select(domain.ExternalChannel)
.join(domain.ExternalChannel.target_channels)
.where(domain.TargetChannel.id == target_channel_id)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def add_external_channel_to_targets(
self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID]
) -> None:
external_channel = await self.session.get(domain.ExternalChannel, external_channel_id)
if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found')
for target_id in target_channel_ids:
target_channel = await self.session.get(domain.TargetChannel, target_id)
if target_channel and target_channel not in external_channel.target_channels:
external_channel.target_channels.append(target_channel)
await self.session.flush()
async def remove_external_channel_from_target(
self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID
) -> None:
external_channel = await self.session.get(domain.ExternalChannel, external_channel_id)
if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found')
target_channel = await self.session.get(domain.TargetChannel, target_channel_id)
if target_channel and target_channel in external_channel.target_channels:
external_channel.target_channels.remove(target_channel)
await self.session.flush()
async def delete_external_channel(self, channel_id: uuid.UUID) -> None:
stmt = delete(domain.ExternalChannel).where(domain.ExternalChannel.id == channel_id)
await self.session.execute(stmt)
await self.session.flush()
async def get_creative(self, creative_id: uuid.UUID, user_id: uuid.UUID) -> domain.Creative | None:
stmt = (
select(domain.Creative)
.options(selectinload(domain.Creative.target_channel))
.where(domain.Creative.id == creative_id, domain.Creative.user_id == user_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_creative(self, creative: domain.Creative) -> domain.Creative:
self.session.add(creative)
await self.session.flush()
await self.session.refresh(creative)
return creative
async def update_creative(self, creative: domain.Creative) -> domain.Creative:
await self.session.flush()
await self.session.refresh(creative)
return creative
async def get_user_creatives(
self, user_id: uuid.UUID, *, target_channel_id: uuid.UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]:
stmt = (
select(domain.Creative)
.options(selectinload(domain.Creative.target_channel))
.where(domain.Creative.user_id == user_id)
)
if target_channel_id:
stmt = stmt.where(domain.Creative.target_channel_id == target_channel_id)
if not include_archived:
stmt = stmt.where(domain.Creative.status == domain.CreativeStatus.ACTIVE)
stmt = stmt.order_by(domain.Creative.created_at.desc())
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def delete_creative(self, creative_id: uuid.UUID) -> None:
stmt = delete(domain.Creative).where(domain.Creative.id == creative_id)
await self.session.execute(stmt)
await self.session.flush()

View File

@@ -1,13 +0,0 @@
import uuid
from shared.postgres.postgres_helper import DatabaseConfig, DatabaseHelper
from src import domain
class Postgres(DatabaseHelper):
def __init__(self, config: DatabaseConfig, migrations_path: str = 'migration/versions') -> None:
super().__init__(config, migrations_path)
async def get_user(self, user_id: uuid.UUID) -> domain.User | None:
async with self.session_getter() as session:
return await session.get(domain.User, user_id)

21
src/adapter/telegram.py Normal file
View File

@@ -0,0 +1,21 @@
import logging
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from shared.telegram_base import TelegramBase
log = logging.getLogger(__name__)
class Telegram(TelegramBase):
async def send_message(self, text: str, chat_id: int) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None:
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=button_text, url=button_url)]])
await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard)
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, Any]:
member = await self.bot.get_chat_member(chat_id=chat_id, user_id=user_id)
return member.model_dump()

View File

@@ -1,6 +0,0 @@
from shared.telegram import TelegramHelper
class Telegram(TelegramHelper):
async def send_message(self, chat_id: int, text: str) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)

View File

@@ -1,19 +1,25 @@
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
from shared.config_helper import load_settings
from shared.datebase_base import DatabaseConfig
from shared.logger import LoggerConfig
from shared.postgres import DatabaseConfig
from shared.telegram.telegram_bot import TelegramConfig
from shared.telegram_base import TelegramConfig
from src.adapter.jwt import JWTConfig
class AppConfig(BaseModel):
URL: str
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file='.env', case_sensitive=False, env_nested_delimiter='__'
)
model_config = SettingsConfigDict(env_file='.env', case_sensitive=False, env_nested_delimiter='__')
app: AppConfig
db: DatabaseConfig
logger: LoggerConfig
telegram: TelegramConfig
jwt: JWTConfig
settings = load_settings(Settings)
settings: Settings = load_settings(Settings)

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
from src.controller.http.auth import auth_router
from src.controller.http.creatives import creatives_router
from src.controller.http.external_channels import external_channels_router
from src.controller.http.target_channels import target_channels_router
api_router = APIRouter()
# Public auth endpoints (no prefix)
api_router.include_router(auth_router)
# API v1 endpoints
api_v1_router = APIRouter(prefix='/api/v1')
api_v1_router.include_router(target_channels_router)
api_v1_router.include_router(external_channels_router)
api_v1_router.include_router(creatives_router)
api_router.include_router(api_v1_router)

View File

@@ -0,0 +1,17 @@
from fastapi import Depends
from fastapi.routing import APIRouter
from src import dto
from src.dependencies import get_usecase
from src.usecase import Usecase
auth_router = APIRouter(prefix='/auth', tags=['auth'])
@auth_router.get('/complete')
async def complete_auth(token: str, usecase: Usecase = Depends(get_usecase)) -> dto.ValidateLoginTokenOutput:
return await usecase.validate_login_token(
input=dto.ValidateLoginTokenInput(
token=token,
)
)

View File

@@ -0,0 +1,85 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import dependencies, dto
from src.adapter.jwt import JWTPayload
creatives_router = APIRouter(prefix='/creatives', tags=['creatives'])
@creatives_router.get('')
async def list_creatives(
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
target_channel_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> dto.GetCreativesOutput:
input = dto.GetCreativesInput(
user_id=current_user.user_id,
target_channel_id=target_channel_id,
include_archived=include_archived,
)
return await dependencies.get_usecase().get_creatives(input=input)
@creatives_router.get('/{creative_id}')
async def get_creative(
creative_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
input = dto.GetCreativeInput(
creative_id=creative_id,
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_creative(input=input)
@creatives_router.post('')
async def create_creative(
request: dto.CreateCreativeInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
return await dependencies.get_usecase().create_creative(request, current_user.user_id)
@creatives_router.patch('/{creative_id}')
async def update_creative(
creative_id: uuid.UUID,
request: dto.UpdateCreativeInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
return await dependencies.get_usecase().update_creative(
creative_id=creative_id,
input=request,
user_id=current_user.user_id,
)
@creatives_router.post('/{creative_id}/archive')
async def archive_creative(
creative_id: uuid.UUID,
request: dto.ArchiveCreativeInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
return await dependencies.get_usecase().archive_creative(
creative_id=creative_id,
input=request,
user_id=current_user.user_id,
)
@creatives_router.delete('/{creative_id}')
async def delete_creative(
creative_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
input = dto.DeleteCreativeInput(
creative_id=creative_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().delete_creative(input)

View File

@@ -0,0 +1,90 @@
import uuid
from typing import Annotated
from fastapi import Depends, File, UploadFile
from fastapi.routing import APIRouter
from src import dependencies, dto
from src.adapter.jwt import JWTPayload
external_channels_router = APIRouter(prefix='/external_channels', tags=['external_channels'])
@external_channels_router.get('/target/{target_channel_id}')
async def get_external_channels(
target_channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.GetExternalChannelsOutput:
"""Get all external channels for a specific target channel."""
input = dto.GetExternalChannelsInput(
target_channel_id=target_channel_id,
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_external_channels(input=input)
@external_channels_router.post('')
async def create_external_channel(
request: dto.CreateExternalChannelInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.ExternalChannelOutput:
"""Create a new external channel and link it to target channels."""
return await dependencies.get_usecase().create_external_channel(
input=request,
user_id=current_user.user_id,
)
@external_channels_router.patch('/{channel_id}/links')
async def update_external_channel_links(
channel_id: uuid.UUID,
request: dto.UpdateExternalChannelLinksInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.ExternalChannelOutput:
"""
Update target channel links for an existing external channel.
You can add new target channels, remove existing ones, or both in a single request.
"""
return await dependencies.get_usecase().update_external_channel_links(
channel_id=channel_id,
input=request,
user_id=current_user.user_id,
)
@external_channels_router.delete('/{channel_id}')
async def delete_external_channel(
channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
"""Delete an external channel."""
input = dto.DeleteExternalChannelInput(
channel_id=channel_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().delete_external_channel(input=input)
@external_channels_router.post('/import/{target_channel_id}')
async def import_external_channels(
target_channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
file: UploadFile = File(...),
) -> dto.ImportExternalChannelsOutput:
"""
Import external channels from Excel file (Tgstat export).
Note: This is a mock endpoint until Excel structure is clarified.
"""
file_content = await file.read()
input = dto.ImportExternalChannelsInput(
target_channel_id=target_channel_id,
user_id=current_user.user_id,
file_content=file_content,
)
return await dependencies.get_usecase().import_external_channels_from_excel(input=input)

View File

@@ -0,0 +1,34 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import dependencies, dto
from src.adapter.jwt import JWTPayload
target_channels_router = APIRouter(prefix='/target_channels', tags=['target_channels'])
@target_channels_router.get('')
async def get_user_target_chans(
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.GetUserTargetChansOutput:
input = dto.GetUserTargetChansInput(
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_user_target_chans(input=input)
@target_channels_router.delete('/{channel_id}')
async def disconnect_target_chan(
channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
input = dto.DisconnectTargetChanInput(
channel_id=channel_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().disconnect_target_chan(input=input)

View File

@@ -1,17 +0,0 @@
import uuid
from fastapi import APIRouter, Depends
from src import dto
from src.dependencies import get_usecase
from src.usecase.usecase import Usecase
user_router = APIRouter(prefix='/users', dependencies=[Depends(get_usecase)])
@user_router.get('/{user_id}')
async def get_user(
user_id: uuid.UUID,
usecase: Usecase = Depends(get_usecase),
) -> dto.GetUserOutput:
return await usecase.get_user(user_id)

View File

@@ -1,83 +0,0 @@
import uuid
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from src.dependencies import get_usecase
telegram_router = Router()
@telegram_router.message(Command('start'))
async def cmd_start(message: Message) -> None:
await message.answer(
'Привет! Я бот для работы с пользователями.\n\n'
'Доступные команды:\n'
'/getuser <user_id> - получить информацию о пользователе\n'
'/notify <user_id> <message> - отправить уведомление пользователю'
)
@telegram_router.message(Command('getuser'))
async def cmd_get_user(message: Message) -> None:
usecase = get_usecase()
if not message.text:
await message.answer('Ошибка: не удалось прочитать текст команды')
return
args = message.text.split()
if len(args) < 2:
await message.answer(
'Использование: /getuser <user_id>\n' 'Пример: /getuser 123e4567-e89b-12d3-a456-426614174000'
)
return
try:
user_id = uuid.UUID(args[1])
except ValueError:
await message.answer('Ошибка: неверный формат UUID')
return
try:
user = await usecase.get_user(user_id)
await message.answer(
f'Пользователь найден:\n'
f'ID: {user.id}\n'
f'Имя: {user.name}\n'
f'Создан: {user.created_at}\n'
f'Обновлён: {user.updated_at}'
)
except Exception as e:
await message.answer(f'Ошибка при получении пользователя: {str(e)}')
@telegram_router.message(Command('notify'))
async def cmd_notify_user(message: Message) -> None:
usecase = get_usecase()
if not message.text or not message.from_user:
await message.answer('Ошибка: не удалось прочитать команду')
return
args = message.text.split(maxsplit=2)
if len(args) < 3:
await message.answer(
'Использование: /notify <user_id> <message>\n'
'Пример: /notify 123e4567-e89b-12d3-a456-426614174000 Привет!'
)
return
try:
user_id = uuid.UUID(args[1])
notification_text = args[2]
except ValueError:
await message.answer('Ошибка: неверный формат UUID')
return
try:
await usecase.notify_user(user_id, message.from_user.id, notification_text)
await message.answer('Уведомление отправлено!')
except Exception as e:
await message.answer(f'Ошибка при отправке уведомления: {str(e)}')

View File

@@ -0,0 +1,6 @@
from aiogram import Router
telegram_callback_router = Router()
# Import handlers to register them
from . import my_chat_member, start_with_login # noqa: E402, F401

View File

@@ -0,0 +1,83 @@
import logging
from aiogram.types import ChatMemberAdministrator, ChatMemberUpdated
from fastapi import HTTPException
from src import dependencies, dto
from src.controller.telegram_callback import telegram_callback_router
log = logging.getLogger(__name__)
@telegram_callback_router.my_chat_member()
async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
if event.chat.type not in {'channel', 'supergroup'}:
return
if not event.from_user:
log.error('Failed to get user data from chat member update')
return
usecase = dependencies.get_usecase()
old_status = event.old_chat_member.status
new_status = event.new_chat_member.status
# Проверяем что бот был удален
was_member = old_status in ('administrator', 'member')
is_now_not_member = new_status in ('left', 'kicked')
bot_removed = was_member and is_now_not_member
if bot_removed:
disconnect_input = dto.DisconnectTargetChanByTgIdInput(
telegram_id=event.chat.id,
)
try:
await usecase.disconnect_target_chan_by_tg_id(input=disconnect_input)
except HTTPException as e:
log.error(e)
return
# Проверяем изменение прав администратора
permissions_changed = old_status == 'administrator' and new_status == 'administrator'
# Извлекаем права бота из события
new_member = event.new_chat_member
bot_permissions = dto.ChannelBotPermissions(
is_admin=isinstance(new_member, ChatMemberAdministrator),
can_invite_users=getattr(new_member, 'can_invite_users', False),
can_restrict_members=getattr(new_member, 'can_restrict_members', False),
)
if permissions_changed:
permissions_input = dto.UpdateTargetChanPermissionsInput(
telegram_id=event.chat.id,
permissions=bot_permissions,
chat_title=event.chat.title or f'Channel {event.chat.id}',
user_telegram_id=event.from_user.id,
)
try:
await usecase.update_target_chan_permissions(input=permissions_input)
except HTTPException as e:
log.error(e)
return
# Проверяем что бот был добавлен
was_not_member = old_status in ('left', 'kicked')
is_now_member = new_status in ('administrator', 'member')
bot_added = was_not_member and is_now_member
if bot_added:
connect_input = dto.ConnectTargetChanInput(
telegram_id=event.chat.id,
title=event.chat.title or f'Channel {event.chat.id}',
username=event.chat.username,
user_telegram_id=event.from_user.id,
bot_permissions=bot_permissions,
)
try:
await usecase.connect_target_chan(input=connect_input)
except HTTPException as e:
log.error(e)

View File

@@ -0,0 +1,30 @@
import logging
from aiogram.filters import CommandStart
from aiogram.types import Message
from src import dependencies, dto
from src.controller.telegram_callback import telegram_callback_router
log = logging.getLogger(__name__)
@telegram_callback_router.message(CommandStart(deep_link=True))
async def cmd_start_with_deeplink(message: Message) -> None:
if not message.from_user:
log.error('Failed to get user data from tg_message')
return
args = message.text.split() if message.text else []
deeplink_param = args[1].split('_')[0] if len(args) > 1 else None
if deeplink_param != 'login':
return
input = dto.TelegramLoginInput(
telegram_id=message.from_user.id,
username=message.from_user.username,
chat_id=message.chat.id,
)
await dependencies.get_usecase().telegram_login(input=input)

View File

@@ -1,36 +1,41 @@
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager
from typing import Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src.usecase.usecase import Usecase
from src.adapter.jwt import JWT, JWTPayload
from src.config import settings
from src.usecase import Usecase
_usecase_instance: Usecase | None = None
_session_context_manager: Callable[[], AbstractAsyncContextManager[AsyncSession]] | None = None
_usecase_template: Usecase | None = None
security = HTTPBearer()
def set_usecase(usecase: Usecase) -> None:
global _usecase_instance
_usecase_instance = usecase
global _usecase_template
_usecase_template = usecase
def get_usecase() -> Usecase:
if _usecase_instance is None:
if _usecase_template is None:
raise RuntimeError('Usecase not initialized. Call set_usecase() first')
return _usecase_instance
return _usecase_template
def set_get_session(
session_context_manager: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
global _session_context_manager
_session_context_manager = session_context_manager
def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> JWTPayload:
token = credentials.credentials
jwt_decoder = JWT(settings.jwt)
async def get_session() -> AsyncGenerator[AsyncSession]:
if _session_context_manager is None:
raise RuntimeError('get_session not initialized. Call set_get_session() first')
try:
payload: JWTPayload = jwt_decoder.decode_access_token(token)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={'WWW-Authenticate': 'Bearer'},
) from e
async with _session_context_manager() as session:
yield session
return payload

View File

@@ -1,5 +1,41 @@
__all__ = ('Base', 'User', 'UserNotFound')
__all__ = (
'Base',
'User',
'TargetChannel',
'ExternalChannel',
'Creative',
'TargetChannelStatus',
'CreativeStatus',
'LoginToken',
'UserNotFound',
'LoginTokenNotFound',
'LoginTokenExpired',
'LoginTokenAlreadyUsed',
'TargetChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
'ExternalChannelNotFound',
'ExternalChannelAlreadyExists',
'CreativeNotFound',
'CreativeInUse',
)
from .base import Base
from .error import UserNotFound
from .creative import Creative, CreativeStatus
from .error import (
ChannelAlreadyExists,
ChannelNoAdminRights,
CreativeInUse,
CreativeNotFound,
ExternalChannelAlreadyExists,
ExternalChannelNotFound,
LoginTokenAlreadyUsed,
LoginTokenExpired,
LoginTokenNotFound,
TargetChannelNotFound,
UserNotFound,
)
from .external_channel import ExternalChannel
from .login_token import LoginToken
from .target_channel import TargetChannel, TargetChannelStatus
from .user import User

View File

@@ -3,11 +3,17 @@ import uuid
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy.types import DateTime
class Base(DeclarativeBase):
__abstract__ = True
# Use timezone-aware datetime (Postgres TIMESTAMP WITH TIME ZONE)
type_annotation_map = {
datetime.datetime: DateTime(timezone=True),
}
# Авто имя таблиц по названию класса
@declared_attr.directive
def __tablename__(cls) -> str:
@@ -15,7 +21,5 @@ class Base(DeclarativeBase):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime.datetime] = mapped_column(
server_default=func.now(), onupdate=func.now()
)
updated_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
deleted_at: Mapped[datetime.datetime | None]

27
src/domain/creative.py Normal file
View File

@@ -0,0 +1,27 @@
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from .target_channel import TargetChannel
class CreativeStatus(StrEnum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class Creative(domain.Base):
name: Mapped[str]
text: Mapped[str]
status: Mapped[CreativeStatus]
purchases_count: Mapped[int] = mapped_column(default=0, server_default='0')
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True)
target_channel: Mapped['TargetChannel'] = relationship(back_populates='creatives')

View File

@@ -3,5 +3,58 @@ import uuid
from fastapi import HTTPException, status
def UserNotFound(user_id: uuid.UUID) -> HTTPException:
def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException:
if user_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
def LoginTokenNotFound() -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
def LoginTokenExpired() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has expired')
def LoginTokenAlreadyUsed() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used')
def TargetChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Target channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Target channel {channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
def ChannelNoAdminRights() -> HTTPException:
return HTTPException(
status.HTTP_403_FORBIDDEN, 'Bot must be added as administrator with invite link creation rights'
)
def ExternalChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'External channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'External channel {channel_id} not found')
def ExternalChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'External channel {telegram_id} already exists')
def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
if creative_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found')
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException(
status.HTTP_400_BAD_REQUEST,
f'Creative {creative_id} is used in active purchases and cannot be deleted',
)

View File

@@ -0,0 +1,33 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, Column, ForeignKey, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
# M2M association table between TargetChannel and ExternalChannel
target_channel_external_channel = Table(
'target_channel_external_channels',
domain.Base.metadata,
Column('target_channel_id', ForeignKey('targetchannels.id', ondelete='CASCADE'), primary_key=True),
Column('external_channel_id', ForeignKey('externalchannels.id', ondelete='CASCADE'), primary_key=True),
)
class ExternalChannel(domain.Base):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
title: Mapped[str]
username: Mapped[str | None] = mapped_column(index=True)
description: Mapped[str | None]
subscribers_count: Mapped[int | None]
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
# M2M relationship with target channels
target_channels: Mapped[list['TargetChannel']] = relationship(
secondary=target_channel_external_channel,
back_populates='external_channels',
)

14
src/domain/login_token.py Normal file
View File

@@ -0,0 +1,14 @@
import datetime
import uuid
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from src import domain
class LoginToken(domain.Base):
token: Mapped[str] = mapped_column(unique=True, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'))
expires_at: Mapped[datetime.datetime]
used_at: Mapped[datetime.datetime | None]

View File

@@ -0,0 +1,44 @@
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import ExternalChannel, creative, user
class TargetChannelStatus(StrEnum):
ACTIVE = 'active'
INACTIVE = 'inactive'
ARCHIVED = 'archived'
class TargetChannel(domain.Base):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
title: Mapped[str]
username: Mapped[str | None] = mapped_column(index=True)
status: Mapped[TargetChannelStatus]
# Relationship with user
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
user: Mapped['user.User'] = relationship(back_populates='target_channels')
# M2M relationship with external channels
external_channels: Mapped[list['ExternalChannel']] = relationship(
secondary='target_channel_external_channels',
back_populates='target_channels',
)
creatives: Mapped[list['creative.Creative']] = relationship(back_populates='target_channel')
@property
def is_active(self) -> bool:
return self.status == domain.TargetChannelStatus.ACTIVE
@is_active.setter
def is_active(self, value: bool) -> None:
self.status = domain.TargetChannelStatus.ACTIVE if value else domain.TargetChannelStatus.INACTIVE

View File

@@ -1,7 +1,16 @@
from sqlalchemy.orm import Mapped
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
class User(domain.Base):
name: Mapped[str]
telegram_id: Mapped[int] = mapped_column(unique=True, index=True)
username: Mapped[str | None]
# Relationship with channels
target_channels: Mapped[list['TargetChannel']] = relationship(back_populates='user')

View File

@@ -1,3 +1,62 @@
__all__ = ('GetUserInput', 'GetUserOutput')
__all__ = (
'UpdateTargetChanPermissionsInput',
'GetUserTargetChansInput',
'GetUserTargetChansOutput',
'DisconnectTargetChanInput',
'DisconnectTargetChanByTgIdInput',
'ConnectTargetChanInput',
'ConnectTargetChanOutput',
'ValidateLoginTokenInput',
'ValidateLoginTokenOutput',
'TelegramLoginInput',
'ChannelBotPermissions',
'ExternalChannelOutput',
'GetExternalChannelsInput',
'GetExternalChannelsOutput',
'CreateExternalChannelInput',
'DeleteExternalChannelInput',
'UpdateExternalChannelLinksInput',
'ImportExternalChannelsInput',
'ImportExternalChannelsOutput',
'CreativeOutput',
'GetCreativesInput',
'GetCreativesOutput',
'GetCreativeInput',
'CreateCreativeInput',
'UpdateCreativeInput',
'ArchiveCreativeInput',
'DeleteCreativeInput',
)
from .get_user import GetUserInput, GetUserOutput
from .creative import (
ArchiveCreativeInput,
CreateCreativeInput,
CreativeOutput,
DeleteCreativeInput,
GetCreativeInput,
GetCreativesInput,
GetCreativesOutput,
UpdateCreativeInput,
)
from .external_channel import (
CreateExternalChannelInput,
DeleteExternalChannelInput,
ExternalChannelOutput,
GetExternalChannelsInput,
GetExternalChannelsOutput,
ImportExternalChannelsInput,
ImportExternalChannelsOutput,
UpdateExternalChannelLinksInput,
)
from .target_channel import (
ChannelBotPermissions,
ConnectTargetChanInput,
ConnectTargetChanOutput,
DisconnectTargetChanByTgIdInput,
DisconnectTargetChanInput,
GetUserTargetChansInput,
GetUserTargetChansOutput,
UpdateTargetChanPermissionsInput,
)
from .telegram_login import TelegramLoginInput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput

48
src/dto/creative.py Normal file
View File

@@ -0,0 +1,48 @@
import datetime
import uuid
import pydantic
from src import domain
class CreativeOutput(pydantic.BaseModel):
id: uuid.UUID
name: str
text: str
target_channel_id: uuid.UUID
target_channel_title: str
created_at: datetime.datetime
status: domain.CreativeStatus
purchases_count: int
class GetCreativesInput(pydantic.BaseModel):
user_id: uuid.UUID
target_channel_id: uuid.UUID | None = None
include_archived: bool = False
class GetCreativesOutput(pydantic.BaseModel):
creatives: list[CreativeOutput]
class GetCreativeInput(pydantic.BaseModel):
creative_id: uuid.UUID
user_id: uuid.UUID
class CreateCreativeInput(pydantic.BaseModel):
name: str
text: str
target_channel_id: uuid.UUID
class UpdateCreativeInput(pydantic.BaseModel):
name: str | None = None
text: str | None = None
class DeleteCreativeInput(pydantic.BaseModel):
creative_id: uuid.UUID
user_id: uuid.UUID

View File

@@ -0,0 +1,54 @@
import uuid
import pydantic
class ExternalChannelOutput(pydantic.BaseModel):
id: uuid.UUID
telegram_id: int
title: str
username: str | None
description: str | None
subscribers_count: int | None
class GetExternalChannelsInput(pydantic.BaseModel):
target_channel_id: uuid.UUID
user_id: uuid.UUID
class GetExternalChannelsOutput(pydantic.BaseModel):
external_channels: list[ExternalChannelOutput]
class CreateExternalChannelInput(pydantic.BaseModel):
telegram_id: int
title: str
username: str | None = None
description: str | None = None
subscribers_count: int | None = None
target_channel_ids: list[uuid.UUID]
class DeleteExternalChannelInput(pydantic.BaseModel):
channel_id: uuid.UUID
user_id: uuid.UUID
class UpdateExternalChannelLinksInput(pydantic.BaseModel):
"""Request body for PATCH endpoint - links to add or remove."""
add_target_channel_ids: list[uuid.UUID] = []
remove_target_channel_ids: list[uuid.UUID] = []
class ImportExternalChannelsInput(pydantic.BaseModel):
target_channel_id: uuid.UUID
user_id: uuid.UUID
file_content: bytes # Excel file content
class ImportExternalChannelsOutput(pydantic.BaseModel):
created_count: int
skipped_count: int
errors: list[str]

View File

@@ -1,12 +0,0 @@
import uuid
import pydantic
class GetUserInput(pydantic.BaseModel):
user_id: uuid.UUID
class GetUserOutput(pydantic.BaseModel):
user_id: uuid.UUID
name: str

56
src/dto/target_channel.py Normal file
View File

@@ -0,0 +1,56 @@
import uuid
import pydantic
class ChannelBotPermissions(pydantic.BaseModel):
is_admin: bool
can_invite_users: bool
can_restrict_members: bool
can_manage_chat: bool | None = None
can_delete_messages: bool | None = None
can_manage_video_chats: bool | None = None
can_post_messages: bool | None = None
can_edit_messages: bool | None = None
can_pin_messages: bool | None = None
class ConnectTargetChanInput(pydantic.BaseModel):
telegram_id: int
title: str
username: str | None
user_telegram_id: int
bot_permissions: ChannelBotPermissions
class ConnectTargetChanOutput(pydantic.BaseModel):
id: uuid.UUID
telegram_id: int
title: str
username: str | None
is_active: bool
class GetUserTargetChansInput(pydantic.BaseModel):
user_id: uuid.UUID
class GetUserTargetChansOutput(pydantic.BaseModel):
target_channels: list[ConnectTargetChanOutput]
class DisconnectTargetChanInput(pydantic.BaseModel):
channel_id: uuid.UUID
user_id: uuid.UUID
class DisconnectTargetChanByTgIdInput(pydantic.BaseModel):
telegram_id: int
user_id: uuid.UUID
class UpdateTargetChanPermissionsInput(pydantic.BaseModel):
telegram_id: int
permissions: ChannelBotPermissions
chat_title: str
user_telegram_id: int

View File

@@ -0,0 +1,7 @@
import pydantic
class TelegramLoginInput(pydantic.BaseModel):
telegram_id: int
username: str | None
chat_id: int

View File

@@ -0,0 +1,9 @@
import pydantic
class ValidateLoginTokenInput(pydantic.BaseModel):
token: str
class ValidateLoginTokenOutput(pydantic.BaseModel):
access_token: str

View File

@@ -2,16 +2,16 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.routing import APIRouter
from shared import logger
from src import dependencies
from src.adapter.postgres.postgres import Postgres
from src.adapter.telegram.telegram import Telegram
from src.adapter.jwt import JWT
from src.adapter.postgres import Postgres
from src.adapter.telegram import Telegram
from src.config import settings
from src.controller.http.user import user_router
from src.controller.telegram.bot import telegram_router
from src.usecase.usecase import Usecase
from src.controller.http import api_router
from src.controller.telegram_callback import telegram_callback_router
from src.usecase import Usecase
@asynccontextmanager
@@ -29,23 +29,15 @@ logger.init(settings.logger)
# Dependencies
postgres = Postgres(settings.db)
dependencies.set_get_session(postgres.session_getter)
telegram = Telegram(settings.telegram, telegram_router)
telegram = Telegram(settings.telegram, telegram_callback_router)
jwt_encoder = JWT(settings.jwt)
usecase = Usecase(
database=postgres,
telegram_writer=telegram,
jwt_encoder=jwt_encoder,
)
dependencies.set_usecase(usecase)
app: FastAPI = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
# Routing
api_router = APIRouter(prefix='/api')
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(user_router)
api_router.include_router(v1_router)
app = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
app.include_router(api_router)

123
src/usecase/__init__.py Normal file
View 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

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

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

View 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)

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

View 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
]
)

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

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

View 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)

View 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
]
)

View File

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

View File

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

View File

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

View File

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

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

View 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')

View File

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

View 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
]
)

View 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}'
)

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

View File

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

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

1034
uv.lock generated

File diff suppressed because it is too large Load Diff