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